chatccc 0.2.58 → 0.2.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/im-skills/wechat-skill/receive-send-image.md +39 -0
- package/im-skills/wechat-skill/send-image.mjs +80 -0
- package/im-skills/wechat-skill/skill.md +11 -0
- package/package.json +1 -1
- package/src/__tests__/orchestrator.test.ts +168 -168
- package/src/__tests__/wechat-platform.test.ts +54 -12
- package/src/index.ts +17 -17
- package/src/orchestrator.ts +31 -31
- package/src/session.ts +2 -0
- package/src/web-ui.ts +9 -5
- package/src/wechat-platform.ts +145 -18
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Receiving & Sending Images (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Images
|
|
4
|
+
|
|
5
|
+
Images sent to the bot are **automatically downloaded** to `~/.chatccc/images/downloads/` with the `wx_` filename prefix.
|
|
6
|
+
|
|
7
|
+
The message text you receive will include the downloaded path in the format:
|
|
8
|
+
```
|
|
9
|
+
[图片] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>.png
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the image file at that path to understand what the user sent.
|
|
13
|
+
|
|
14
|
+
## Send Images
|
|
15
|
+
|
|
16
|
+
### Script (recommended)
|
|
17
|
+
```bash
|
|
18
|
+
node "{{wechat_send_image_script}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the image to the most recent chat.
|
|
22
|
+
|
|
23
|
+
### Direct usage in test scripts
|
|
24
|
+
|
|
25
|
+
The underlying SDK call is:
|
|
26
|
+
```ts
|
|
27
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
+
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
+
await wire.sendMediaFile(chatId, contextToken, imageBuffer, "filename.png", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Rules
|
|
33
|
+
|
|
34
|
+
- Save or choose a local image file first.
|
|
35
|
+
- Use an absolute local path.
|
|
36
|
+
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
37
|
+
- Max image size: 10MB (SDK guideline; larger files upload slower).
|
|
38
|
+
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
39
|
+
- **Claw 限制**: 图片发送也计入微信 claw 连发计数,连续 10 条未回复会截断。
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
+
|
|
8
|
+
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const key = argv[i];
|
|
16
|
+
if (!key.startsWith("--")) continue;
|
|
17
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
18
|
+
result[key.slice(2)] = value;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
console.error(`Usage:
|
|
25
|
+
node ${basename(process.argv[1])} --path <absolute image path> [--caption <text>]`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const args = parseArgs(process.argv.slice(2));
|
|
30
|
+
const imagePath = args.path;
|
|
31
|
+
const caption = args.caption || "";
|
|
32
|
+
|
|
33
|
+
if (!imagePath) {
|
|
34
|
+
usage();
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ext = extname(imagePath).toLowerCase();
|
|
39
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
40
|
+
console.error(`Unsupported image extension: ${ext || "(none)"}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const st = statSync(imagePath);
|
|
45
|
+
if (!st.isFile()) {
|
|
46
|
+
console.error("Image path is not a file");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (st.size > MAX_IMAGE_BYTES) {
|
|
50
|
+
console.error("Image file exceeds 10MB limit");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
55
|
+
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
56
|
+
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
61
|
+
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
62
|
+
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const imgData = readFileSync(imagePath);
|
|
67
|
+
const fileName = basename(imagePath);
|
|
68
|
+
|
|
69
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
70
|
+
base_url: snap.baseUrl,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, imgData, fileName, caption);
|
|
74
|
+
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wechat-skill
|
|
3
|
+
description: WeChat iLink local skills for sending and receiving images.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Current working directory: {{cwd}}
|
|
7
|
+
|
|
8
|
+
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper scripts below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive images**: Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[图片] <absolute path>`.
|
|
11
|
+
- **Send images**: Use the send-image helper script — read `{{im_skills_cache_dir}}/wechat-skill/receive-send-image.md`
|
package/package.json
CHANGED
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
7
|
-
import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
|
|
8
|
-
|
|
9
|
-
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
-
|
|
11
|
-
vi.mock("../im-skills.ts", () => ({
|
|
12
|
-
buildImSkillsPrompt: async () => "",
|
|
13
|
-
exportSkillSubDocs: async () => {},
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
vi.mock("../stream-state.ts", () => ({
|
|
17
|
-
readStreamState: async (sessionId: string) => {
|
|
18
|
-
const state = mockStreamStates.get(sessionId);
|
|
19
|
-
if (!state) return null;
|
|
20
|
-
return {
|
|
21
|
-
sessionId,
|
|
22
|
-
status: state.status,
|
|
23
|
-
accumulatedContent: "",
|
|
24
|
-
finalReply: state.finalReply,
|
|
25
|
-
chunkCount: 0,
|
|
26
|
-
turnCount: 1,
|
|
27
|
-
contextTokens: 0,
|
|
28
|
-
updatedAt: Date.now(),
|
|
29
|
-
cwd: "F:\\repo",
|
|
30
|
-
tool: "claude",
|
|
31
|
-
};
|
|
32
|
-
},
|
|
33
|
-
writeStreamState: async (state: { sessionId: string; status: "running" | "done" | "stopped"; finalReply: string }) => {
|
|
34
|
-
mockStreamStates.set(state.sessionId, {
|
|
35
|
-
status: state.status,
|
|
36
|
-
finalReply: state.finalReply,
|
|
37
|
-
});
|
|
38
|
-
},
|
|
39
|
-
createEmptyStreamState: (sessionId: string, cwd: string, tool: string, turnCount: number) => ({
|
|
40
|
-
sessionId,
|
|
41
|
-
status: "running" as const,
|
|
42
|
-
accumulatedContent: "",
|
|
43
|
-
finalReply: "",
|
|
44
|
-
chunkCount: 0,
|
|
45
|
-
turnCount,
|
|
46
|
-
contextTokens: 0,
|
|
47
|
-
updatedAt: Date.now(),
|
|
48
|
-
cwd,
|
|
49
|
-
tool,
|
|
50
|
-
}),
|
|
51
|
-
fixStaleStreamStates: async () => {},
|
|
52
|
-
}));
|
|
53
|
-
|
|
54
|
-
import { handleCommand } from "../orchestrator.ts";
|
|
55
|
-
import {
|
|
56
|
-
_clearAdapterCacheForTest,
|
|
57
|
-
_resetSessionRegistryFileForTest,
|
|
58
|
-
_resetSessionToolsFileForTest,
|
|
59
|
-
_setAdapterForToolForTest,
|
|
60
|
-
_setSessionRegistryFileForTest,
|
|
61
|
-
_setSessionToolsFileForTest,
|
|
62
|
-
recordSessionRegistry,
|
|
63
|
-
resetState,
|
|
64
|
-
} from "../session.ts";
|
|
65
|
-
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
66
|
-
|
|
67
|
-
function mockPlatform(): PlatformAdapter {
|
|
68
|
-
return {
|
|
69
|
-
kind: "wechat",
|
|
70
|
-
sendText: vi.fn(async () => true),
|
|
71
|
-
sendCard: vi.fn(async () => true),
|
|
72
|
-
sendRawCard: vi.fn(async () => true),
|
|
73
|
-
createGroup: vi.fn(async () => "unused-group"),
|
|
74
|
-
updateChatInfo: vi.fn(async () => {}),
|
|
75
|
-
getChatInfo: vi.fn(async () => ({ name: "微信会话", description: "" })),
|
|
76
|
-
disbandChat: vi.fn(async () => {}),
|
|
77
|
-
setChatAvatar: vi.fn(async () => {}),
|
|
78
|
-
extractSessionInfo: vi.fn(() => null),
|
|
79
|
-
cardCreate: vi.fn(async () => "card-id"),
|
|
80
|
-
cardSend: vi.fn(async () => "message-id"),
|
|
81
|
-
cardUpdate: vi.fn(async () => {}),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function mockAdapter(): ToolAdapter {
|
|
86
|
-
return {
|
|
87
|
-
displayName: "Claude",
|
|
88
|
-
sessionDescPrefix: "Claude Session:",
|
|
89
|
-
createSession: async () => ({ sessionId: "sid-wechat" }),
|
|
90
|
-
prompt: async function* () {
|
|
91
|
-
yield {
|
|
92
|
-
type: "assistant",
|
|
93
|
-
blocks: [{ type: "text", text: "done" }],
|
|
94
|
-
};
|
|
95
|
-
},
|
|
96
|
-
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
97
|
-
sessionId,
|
|
98
|
-
cwd: "F:\\repo",
|
|
99
|
-
}),
|
|
100
|
-
closeSession: async () => {},
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
describe("handleCommand WeChat processing ack", () => {
|
|
105
|
-
let tempDir: string;
|
|
106
|
-
|
|
107
|
-
beforeEach(async () => {
|
|
108
|
-
vi.useFakeTimers();
|
|
109
|
-
tempDir = await mkdtemp(join(tmpdir(), "chatccc-orchestrator-"));
|
|
110
|
-
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
111
|
-
_setSessionToolsFileForTest(join(tempDir, "sessions.json"));
|
|
112
|
-
resetState();
|
|
113
|
-
resetBindingState();
|
|
114
|
-
mockStreamStates.clear();
|
|
115
|
-
_setAdapterForToolForTest("claude", mockAdapter());
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
afterEach(async () => {
|
|
119
|
-
resetState();
|
|
120
|
-
resetBindingState();
|
|
121
|
-
_clearAdapterCacheForTest();
|
|
122
|
-
_resetSessionRegistryFileForTest();
|
|
123
|
-
_resetSessionToolsFileForTest();
|
|
124
|
-
vi.useRealTimers();
|
|
125
|
-
await rm(tempDir, { recursive: true, force: true });
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("does not send the WeChat processing ack when the session is already running", async () => {
|
|
129
|
-
const platform = mockPlatform();
|
|
130
|
-
await recordSessionRegistry({
|
|
131
|
-
chatId: "wx-chat",
|
|
132
|
-
sessionId: "sid-wechat",
|
|
133
|
-
tool: "claude",
|
|
134
|
-
chatName: "busy-session",
|
|
135
|
-
running: true,
|
|
136
|
-
});
|
|
137
|
-
activePrompts.set("sid-wechat", {
|
|
138
|
-
controller: new AbortController(),
|
|
139
|
-
stopped: false,
|
|
140
|
-
startTime: Date.now(),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
144
|
-
|
|
145
|
-
expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
146
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
147
|
-
"wx-chat",
|
|
148
|
-
"生成中",
|
|
149
|
-
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
150
|
-
"yellow",
|
|
151
|
-
);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
155
|
-
const platform = mockPlatform();
|
|
156
|
-
await recordSessionRegistry({
|
|
157
|
-
chatId: "wx-chat",
|
|
158
|
-
sessionId: "sid-wechat",
|
|
159
|
-
tool: "claude",
|
|
160
|
-
chatName: "ready-session",
|
|
161
|
-
running: false,
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
165
|
-
|
|
166
|
-
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
167
|
-
});
|
|
168
|
-
});
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
7
|
+
import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
|
|
8
|
+
|
|
9
|
+
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
+
|
|
11
|
+
vi.mock("../im-skills.ts", () => ({
|
|
12
|
+
buildImSkillsPrompt: async () => "",
|
|
13
|
+
exportSkillSubDocs: async () => {},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("../stream-state.ts", () => ({
|
|
17
|
+
readStreamState: async (sessionId: string) => {
|
|
18
|
+
const state = mockStreamStates.get(sessionId);
|
|
19
|
+
if (!state) return null;
|
|
20
|
+
return {
|
|
21
|
+
sessionId,
|
|
22
|
+
status: state.status,
|
|
23
|
+
accumulatedContent: "",
|
|
24
|
+
finalReply: state.finalReply,
|
|
25
|
+
chunkCount: 0,
|
|
26
|
+
turnCount: 1,
|
|
27
|
+
contextTokens: 0,
|
|
28
|
+
updatedAt: Date.now(),
|
|
29
|
+
cwd: "F:\\repo",
|
|
30
|
+
tool: "claude",
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
writeStreamState: async (state: { sessionId: string; status: "running" | "done" | "stopped"; finalReply: string }) => {
|
|
34
|
+
mockStreamStates.set(state.sessionId, {
|
|
35
|
+
status: state.status,
|
|
36
|
+
finalReply: state.finalReply,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
createEmptyStreamState: (sessionId: string, cwd: string, tool: string, turnCount: number) => ({
|
|
40
|
+
sessionId,
|
|
41
|
+
status: "running" as const,
|
|
42
|
+
accumulatedContent: "",
|
|
43
|
+
finalReply: "",
|
|
44
|
+
chunkCount: 0,
|
|
45
|
+
turnCount,
|
|
46
|
+
contextTokens: 0,
|
|
47
|
+
updatedAt: Date.now(),
|
|
48
|
+
cwd,
|
|
49
|
+
tool,
|
|
50
|
+
}),
|
|
51
|
+
fixStaleStreamStates: async () => {},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import { handleCommand } from "../orchestrator.ts";
|
|
55
|
+
import {
|
|
56
|
+
_clearAdapterCacheForTest,
|
|
57
|
+
_resetSessionRegistryFileForTest,
|
|
58
|
+
_resetSessionToolsFileForTest,
|
|
59
|
+
_setAdapterForToolForTest,
|
|
60
|
+
_setSessionRegistryFileForTest,
|
|
61
|
+
_setSessionToolsFileForTest,
|
|
62
|
+
recordSessionRegistry,
|
|
63
|
+
resetState,
|
|
64
|
+
} from "../session.ts";
|
|
65
|
+
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
66
|
+
|
|
67
|
+
function mockPlatform(): PlatformAdapter {
|
|
68
|
+
return {
|
|
69
|
+
kind: "wechat",
|
|
70
|
+
sendText: vi.fn(async () => true),
|
|
71
|
+
sendCard: vi.fn(async () => true),
|
|
72
|
+
sendRawCard: vi.fn(async () => true),
|
|
73
|
+
createGroup: vi.fn(async () => "unused-group"),
|
|
74
|
+
updateChatInfo: vi.fn(async () => {}),
|
|
75
|
+
getChatInfo: vi.fn(async () => ({ name: "微信会话", description: "" })),
|
|
76
|
+
disbandChat: vi.fn(async () => {}),
|
|
77
|
+
setChatAvatar: vi.fn(async () => {}),
|
|
78
|
+
extractSessionInfo: vi.fn(() => null),
|
|
79
|
+
cardCreate: vi.fn(async () => "card-id"),
|
|
80
|
+
cardSend: vi.fn(async () => "message-id"),
|
|
81
|
+
cardUpdate: vi.fn(async () => {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mockAdapter(): ToolAdapter {
|
|
86
|
+
return {
|
|
87
|
+
displayName: "Claude",
|
|
88
|
+
sessionDescPrefix: "Claude Session:",
|
|
89
|
+
createSession: async () => ({ sessionId: "sid-wechat" }),
|
|
90
|
+
prompt: async function* () {
|
|
91
|
+
yield {
|
|
92
|
+
type: "assistant",
|
|
93
|
+
blocks: [{ type: "text", text: "done" }],
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
97
|
+
sessionId,
|
|
98
|
+
cwd: "F:\\repo",
|
|
99
|
+
}),
|
|
100
|
+
closeSession: async () => {},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("handleCommand WeChat processing ack", () => {
|
|
105
|
+
let tempDir: string;
|
|
106
|
+
|
|
107
|
+
beforeEach(async () => {
|
|
108
|
+
vi.useFakeTimers();
|
|
109
|
+
tempDir = await mkdtemp(join(tmpdir(), "chatccc-orchestrator-"));
|
|
110
|
+
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
111
|
+
_setSessionToolsFileForTest(join(tempDir, "sessions.json"));
|
|
112
|
+
resetState();
|
|
113
|
+
resetBindingState();
|
|
114
|
+
mockStreamStates.clear();
|
|
115
|
+
_setAdapterForToolForTest("claude", mockAdapter());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
resetState();
|
|
120
|
+
resetBindingState();
|
|
121
|
+
_clearAdapterCacheForTest();
|
|
122
|
+
_resetSessionRegistryFileForTest();
|
|
123
|
+
_resetSessionToolsFileForTest();
|
|
124
|
+
vi.useRealTimers();
|
|
125
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not send the WeChat processing ack when the session is already running", async () => {
|
|
129
|
+
const platform = mockPlatform();
|
|
130
|
+
await recordSessionRegistry({
|
|
131
|
+
chatId: "wx-chat",
|
|
132
|
+
sessionId: "sid-wechat",
|
|
133
|
+
tool: "claude",
|
|
134
|
+
chatName: "busy-session",
|
|
135
|
+
running: true,
|
|
136
|
+
});
|
|
137
|
+
activePrompts.set("sid-wechat", {
|
|
138
|
+
controller: new AbortController(),
|
|
139
|
+
stopped: false,
|
|
140
|
+
startTime: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
144
|
+
|
|
145
|
+
expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
146
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
147
|
+
"wx-chat",
|
|
148
|
+
"生成中",
|
|
149
|
+
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
150
|
+
"yellow",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
155
|
+
const platform = mockPlatform();
|
|
156
|
+
await recordSessionRegistry({
|
|
157
|
+
chatId: "wx-chat",
|
|
158
|
+
sessionId: "sid-wechat",
|
|
159
|
+
tool: "claude",
|
|
160
|
+
chatName: "ready-session",
|
|
161
|
+
running: false,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
165
|
+
|
|
166
|
+
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { buildHelpCard } from "../cards.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildHelpCard } from "../cards.ts";
|
|
4
|
+
import {
|
|
5
|
+
_flushPendingClawFinalTextForTest,
|
|
6
|
+
_resetWechatClawStateForTest,
|
|
7
|
+
createWechatAdapter,
|
|
8
|
+
} from "../wechat-platform.ts";
|
|
9
|
+
|
|
10
|
+
describe("createWechatAdapter", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_resetWechatClawStateForTest();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("degrades raw cards to plain text messages", async () => {
|
|
8
16
|
const wire = {
|
|
9
17
|
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
10
18
|
sendText: vi.fn(
|
|
@@ -30,7 +38,7 @@ describe("createWechatAdapter", () => {
|
|
|
30
38
|
expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
|
|
31
39
|
});
|
|
32
40
|
|
|
33
|
-
it("reports unsent non-final messages after the claw limit", async () => {
|
|
41
|
+
it("reports unsent non-final messages after the claw limit", async () => {
|
|
34
42
|
const wire = {
|
|
35
43
|
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
36
44
|
sendText: vi.fn(
|
|
@@ -51,7 +59,41 @@ describe("createWechatAdapter", () => {
|
|
|
51
59
|
await expect(platform.sendText("wx-chat-limit", "chunk 10")).resolves.toBe(false);
|
|
52
60
|
expect(wire.push).toHaveBeenCalledTimes(10);
|
|
53
61
|
expect(log).toHaveBeenCalledWith(
|
|
54
|
-
"[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=11",
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
|
|
62
|
+
"[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=11",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("queues final messages after the claw limit until the user wakes the chat", async () => {
|
|
67
|
+
const wire = {
|
|
68
|
+
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
69
|
+
sendText: vi.fn(
|
|
70
|
+
async (_chatId: string, _text: string, _contextToken?: string) =>
|
|
71
|
+
"msg-id",
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
const log = vi.fn();
|
|
75
|
+
const platform = createWechatAdapter({
|
|
76
|
+
getWire: () => wire,
|
|
77
|
+
log,
|
|
78
|
+
});
|
|
79
|
+
const chatId = "wx-chat-final-limit";
|
|
80
|
+
const finalText = "done\n━━━ 回答结束 ━━━";
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < 10; i++) {
|
|
83
|
+
await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await expect(platform.sendText(chatId, finalText)).resolves.toBe(true);
|
|
87
|
+
expect(wire.push).toHaveBeenCalledTimes(10);
|
|
88
|
+
expect(log).toHaveBeenCalledWith(
|
|
89
|
+
`[WECHAT] final queued (claw limit): chatId=${chatId} count=11 len=${finalText.length}`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await expect(_flushPendingClawFinalTextForTest(chatId, wire, log)).resolves.toBe(true);
|
|
93
|
+
expect(wire.push).toHaveBeenCalledTimes(11);
|
|
94
|
+
expect(wire.push).toHaveBeenLastCalledWith(chatId, finalText);
|
|
95
|
+
expect(log).toHaveBeenCalledWith(
|
|
96
|
+
`[WECHAT] pending final sent after claw wake: chatId=${chatId} len=${finalText.length}`,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -84,9 +84,9 @@ import {
|
|
|
84
84
|
updateCardKitCard,
|
|
85
85
|
} from "./cardkit.ts";
|
|
86
86
|
import {
|
|
87
|
-
MAX_PROCESSED,
|
|
88
|
-
clearAdapterCache,
|
|
89
|
-
loadSessionRegistryForBinding,
|
|
87
|
+
MAX_PROCESSED,
|
|
88
|
+
clearAdapterCache,
|
|
89
|
+
loadSessionRegistryForBinding,
|
|
90
90
|
processedMessages,
|
|
91
91
|
rebuildBindingsFromRegistry,
|
|
92
92
|
resetState,
|
|
@@ -744,12 +744,12 @@ async function main(): Promise<void> {
|
|
|
744
744
|
// "保存并启动" 时,web-ui 会调用本回调,把磁盘上刚保存的 config.json
|
|
745
745
|
// 刷进进程内的 export let 常量(live binding 让 CLAUDE_MODEL 等下次创建
|
|
746
746
|
// 会话时自动看到新值)。setup 首次激活走 onActivate 路径,不依赖此 hook。
|
|
747
|
-
setReloadConfigHook(() => {
|
|
748
|
-
reloadConfigFromDisk();
|
|
749
|
-
clearAdapterCache();
|
|
750
|
-
appendStartupTrace("reload-from-ui: config reloaded", {
|
|
751
|
-
appIdMask: maskAppId(APP_ID),
|
|
752
|
-
});
|
|
747
|
+
setReloadConfigHook(() => {
|
|
748
|
+
reloadConfigFromDisk();
|
|
749
|
+
clearAdapterCache();
|
|
750
|
+
appendStartupTrace("reload-from-ui: config reloaded", {
|
|
751
|
+
appIdMask: maskAppId(APP_ID),
|
|
752
|
+
});
|
|
753
753
|
});
|
|
754
754
|
setExtraApiHandler(async (req, res) => {
|
|
755
755
|
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
@@ -769,12 +769,12 @@ async function main(): Promise<void> {
|
|
|
769
769
|
// 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
|
|
770
770
|
// 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
|
|
771
771
|
startSetupMode(CHATCCC_PORT, {
|
|
772
|
-
onActivate: async (httpServer: Server) => {
|
|
773
|
-
reloadConfigFromDisk();
|
|
774
|
-
clearAdapterCache();
|
|
775
|
-
appendStartupTrace("setup-activate: reloaded config from disk", {
|
|
776
|
-
appIdMaskAfterReload: maskAppId(APP_ID),
|
|
777
|
-
});
|
|
772
|
+
onActivate: async (httpServer: Server) => {
|
|
773
|
+
reloadConfigFromDisk();
|
|
774
|
+
clearAdapterCache();
|
|
775
|
+
appendStartupTrace("setup-activate: reloaded config from disk", {
|
|
776
|
+
appIdMaskAfterReload: maskAppId(APP_ID),
|
|
777
|
+
});
|
|
778
778
|
try {
|
|
779
779
|
await startBotService({ httpServer, port: CHATCCC_PORT });
|
|
780
780
|
installShutdownHandlers(httpServer);
|
|
@@ -818,8 +818,8 @@ async function main(): Promise<void> {
|
|
|
818
818
|
try {
|
|
819
819
|
await startBotService({ httpServer, port: CHATCCC_PORT });
|
|
820
820
|
} catch (err) {
|
|
821
|
-
|
|
822
|
-
|
|
821
|
+
console.error(`\n[飞书] 启动失败: ${(err as Error).message}`);
|
|
822
|
+
console.error("[飞书] 微信等其他平台不受影响,将继续启动。\n");
|
|
823
823
|
}
|
|
824
824
|
}
|
|
825
825
|
|
package/src/orchestrator.ts
CHANGED
|
@@ -72,21 +72,21 @@ export function cwdDisplayName(cwd: string): string {
|
|
|
72
72
|
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function sessionChatName(left: string, cwd: string): string {
|
|
76
|
-
return `${left}-${cwdDisplayName(cwd)}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isUntitledSessionChatName(name: string): boolean {
|
|
80
|
-
return name === "新会话" || name.startsWith("新会话-");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function shouldSendWechatProcessingAck(
|
|
84
|
-
platform: PlatformAdapter,
|
|
85
|
-
textLower: string,
|
|
86
|
-
chatType: string,
|
|
87
|
-
): boolean {
|
|
88
|
-
return platform.kind === "wechat" && chatType === "p2p" && !textLower.startsWith("/");
|
|
89
|
-
}
|
|
75
|
+
export function sessionChatName(left: string, cwd: string): string {
|
|
76
|
+
return `${left}-${cwdDisplayName(cwd)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isUntitledSessionChatName(name: string): boolean {
|
|
80
|
+
return name === "新会话" || name.startsWith("新会话-");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function shouldSendWechatProcessingAck(
|
|
84
|
+
platform: PlatformAdapter,
|
|
85
|
+
textLower: string,
|
|
86
|
+
chatType: string,
|
|
87
|
+
): boolean {
|
|
88
|
+
return platform.kind === "wechat" && chatType === "p2p" && !textLower.startsWith("/");
|
|
89
|
+
}
|
|
90
90
|
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
// handleCommand — 平台无关的命令分发
|
|
@@ -979,11 +979,11 @@ export async function handleCommand(
|
|
|
979
979
|
}
|
|
980
980
|
|
|
981
981
|
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
982
|
-
if (isSessionRunning(sessionId)) {
|
|
983
|
-
logTrace(tid, "BLOCKED", {
|
|
984
|
-
outcome: "session_busy",
|
|
985
|
-
sessionId,
|
|
986
|
-
});
|
|
982
|
+
if (isSessionRunning(sessionId)) {
|
|
983
|
+
logTrace(tid, "BLOCKED", {
|
|
984
|
+
outcome: "session_busy",
|
|
985
|
+
sessionId,
|
|
986
|
+
});
|
|
987
987
|
console.log(
|
|
988
988
|
`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`,
|
|
989
989
|
);
|
|
@@ -992,17 +992,17 @@ export async function handleCommand(
|
|
|
992
992
|
"生成中",
|
|
993
993
|
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
994
994
|
"yellow",
|
|
995
|
-
);
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
if (shouldSendWechatProcessingAck(platform, textLower, chatType)) {
|
|
1000
|
-
await platform.sendText(chatId, "生成中...").catch(() => {});
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
try {
|
|
1004
|
-
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
1005
|
-
await resumeAndPrompt(
|
|
995
|
+
);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (shouldSendWechatProcessingAck(platform, textLower, chatType)) {
|
|
1000
|
+
await platform.sendText(chatId, "生成中...").catch(() => {});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
1005
|
+
await resumeAndPrompt(
|
|
1006
1006
|
sessionId,
|
|
1007
1007
|
text,
|
|
1008
1008
|
platform,
|
package/src/session.ts
CHANGED
|
@@ -698,6 +698,7 @@ export async function runAgentSession(
|
|
|
698
698
|
|
|
699
699
|
// 构建 IM skills prompt(sessionId 方式,无 token)
|
|
700
700
|
const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
|
|
701
|
+
const wechatSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-skill");
|
|
701
702
|
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
702
703
|
const skillVariables = {
|
|
703
704
|
cwd,
|
|
@@ -708,6 +709,7 @@ export async function runAgentSession(
|
|
|
708
709
|
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
709
710
|
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
710
711
|
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
712
|
+
wechat_send_image_script: join(wechatSkillDir, "send-image.mjs"),
|
|
711
713
|
};
|
|
712
714
|
var imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
|
|
713
715
|
await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
|
package/src/web-ui.ts
CHANGED
|
@@ -270,9 +270,8 @@ async function handleApiCheck(_req: IncomingMessage, res: ServerResponse): Promi
|
|
|
270
270
|
if (hasConfig) {
|
|
271
271
|
const c = loadConfig();
|
|
272
272
|
const feishuEnabled = c.platforms?.feishu?.enabled !== false; // 默认 true(向后兼容)
|
|
273
|
-
const ilinkEnabled = c.platforms?.ilink?.enabled !== false;
|
|
274
273
|
const feishuOk = feishuEnabled && Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
|
|
275
|
-
hasCreds = feishuOk ||
|
|
274
|
+
hasCreds = feishuOk || !feishuEnabled;
|
|
276
275
|
}
|
|
277
276
|
jsonReply(res, 200, { hasConfig, hasCreds, configPath: CONFIG_FILE });
|
|
278
277
|
}
|
|
@@ -1198,9 +1197,14 @@ function renderStep1() {
|
|
|
1198
1197
|
var f = c.feishu || {};
|
|
1199
1198
|
prefillNested('field-CHATCCC_APP_ID', f.appId);
|
|
1200
1199
|
prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
|
|
1201
|
-
// 平台开关:按已有 config
|
|
1202
|
-
var
|
|
1203
|
-
var
|
|
1200
|
+
// 平台开关:按已有 config 回填;首次配置(无飞书凭证)时默认关闭飞书、开启微信
|
|
1201
|
+
var hasExistingCreds = Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
|
|
1202
|
+
var feishuEnabled = hasExistingCreds
|
|
1203
|
+
? (c.platforms?.feishu?.enabled !== false)
|
|
1204
|
+
: false;
|
|
1205
|
+
var ilinkEnabled = hasExistingCreds
|
|
1206
|
+
? (c.platforms?.ilink?.enabled !== false)
|
|
1207
|
+
: true;
|
|
1204
1208
|
state.platformsEnabled = { feishu: feishuEnabled, ilink: ilinkEnabled };
|
|
1205
1209
|
var feToggle = document.getElementById('platform-enable-feishu');
|
|
1206
1210
|
if (feToggle) feToggle.checked = feishuEnabled;
|
package/src/wechat-platform.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { dirname, extname, join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
|
|
16
16
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type GetUpdatesResponse,
|
|
20
20
|
type WeixinMessage,
|
|
21
21
|
} from "@openilink/openilink-sdk-node";
|
|
22
|
+
import type { CDNMedia, ImageItem } from "@openilink/openilink-sdk-node";
|
|
22
23
|
|
|
23
24
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
24
25
|
import { setupFileLogging } from "./shared.ts";
|
|
@@ -56,10 +57,17 @@ const { logPath: WECHAT_LOG_PATH } =
|
|
|
56
57
|
setupFileLogging(ILINK_LOG_DIR, "wechat");
|
|
57
58
|
|
|
58
59
|
let ilinkWire: OpenIlinkWire | null = null;
|
|
60
|
+
|
|
61
|
+
/** 获取当前 iLink wire 实例(供外部脚本/测试使用) */
|
|
62
|
+
export function getIlinkWire(): OpenIlinkWire | null {
|
|
63
|
+
return ilinkWire;
|
|
64
|
+
}
|
|
59
65
|
/** chatId → 最新 context_token */
|
|
60
66
|
const contextTokenMap = new Map<string, string>();
|
|
61
67
|
/** chatId → 用户未回复时已连发消息数 */
|
|
62
68
|
const consecutiveSendCount = new Map<string, number>();
|
|
69
|
+
/** chatId → claw 限制后等待用户唤醒再补发的最终消息 */
|
|
70
|
+
const pendingClawFinalText = new Map<string, string>();
|
|
63
71
|
const textCardMap = new Map<string, { chatId?: string; text: string; lastSentText: string; lastSentAt: number }>();
|
|
64
72
|
let textCardSeq = 0;
|
|
65
73
|
let platformLog: (msg: string) => void = () => {};
|
|
@@ -87,6 +95,49 @@ function compressGeneratingText(text: string): string {
|
|
|
87
95
|
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
async function sendWechatTextRaw(wire: WechatWireSender, chatId: string, text: string): Promise<void> {
|
|
99
|
+
const contextToken = contextTokenMap.get(chatId);
|
|
100
|
+
if (contextToken) {
|
|
101
|
+
await wire.sendText(chatId, text, contextToken);
|
|
102
|
+
} else {
|
|
103
|
+
await wire.push(chatId, text);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function flushPendingClawFinalText(
|
|
108
|
+
chatId: string,
|
|
109
|
+
wire: WechatWireSender | null,
|
|
110
|
+
log: (msg: string) => void,
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const text = pendingClawFinalText.get(chatId);
|
|
113
|
+
if (!text || !wire) return false;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await sendWechatTextRaw(wire, chatId, text);
|
|
117
|
+
pendingClawFinalText.delete(chatId);
|
|
118
|
+
consecutiveSendCount.set(chatId, 1);
|
|
119
|
+
log(`[WECHAT] pending final sent after claw wake: chatId=${chatId} len=${text.length}`);
|
|
120
|
+
return true;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log(`[WECHAT] pending final send failed: ${(err as Error).message}`);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function _resetWechatClawStateForTest(): void {
|
|
128
|
+
consecutiveSendCount.clear();
|
|
129
|
+
pendingClawFinalText.clear();
|
|
130
|
+
contextTokenMap.clear();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function _flushPendingClawFinalTextForTest(
|
|
134
|
+
chatId: string,
|
|
135
|
+
wire: WechatWireSender | null,
|
|
136
|
+
log: (msg: string) => void = () => {},
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
return flushPendingClawFinalText(chatId, wire, log);
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
// ---------------------------------------------------------------------------
|
|
91
142
|
// Snapshot 持久化
|
|
92
143
|
// ---------------------------------------------------------------------------
|
|
@@ -160,21 +211,24 @@ export function createWechatAdapter(
|
|
|
160
211
|
text = text + "\n━━ 后台工作中,由于微信claw机制限制,请唤醒我才能继续发送消息";
|
|
161
212
|
}
|
|
162
213
|
|
|
163
|
-
// 超过10
|
|
164
|
-
if (count > 10
|
|
165
|
-
|
|
166
|
-
|
|
214
|
+
// 超过10条后不再直接发送。微信端 claw 可能会静默丢弃,即使 iLink 返回 OK。
|
|
215
|
+
if (count > 10) {
|
|
216
|
+
if (isFinal) {
|
|
217
|
+
pendingClawFinalText.set(chatId, text);
|
|
218
|
+
log(`[WECHAT] final queued (claw limit): chatId=${chatId} count=${count} len=${text.length}`);
|
|
219
|
+
return true;
|
|
220
|
+
} else {
|
|
221
|
+
log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
167
224
|
}
|
|
168
225
|
|
|
169
226
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
177
|
-
log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
|
|
227
|
+
// 最后一步:非最终回复压缩行数(最多11行:头5 + ... + 尾5)
|
|
228
|
+
const sendText = isFinal ? text : compressGeneratingText(text);
|
|
229
|
+
await sendWechatTextRaw(wire, chatId, sendText);
|
|
230
|
+
const preview = sendText.length > 60 ? sendText.slice(0, 60) + "..." : sendText;
|
|
231
|
+
log(`[WECHAT] sendText OK: chatId=${chatId} len=${sendText.length} count=${count} text="${preview}"`);
|
|
178
232
|
return true;
|
|
179
233
|
} catch (err) {
|
|
180
234
|
log(`[WECHAT] sendText failed: ${(err as Error).message}`);
|
|
@@ -467,6 +521,44 @@ export async function startWechatPlatform(
|
|
|
467
521
|
);
|
|
468
522
|
}
|
|
469
523
|
|
|
524
|
+
const WECHAT_IMAGE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "images", "downloads");
|
|
525
|
+
|
|
526
|
+
function extFromMimeOrName(mime?: string | null, fileName?: string | null): string {
|
|
527
|
+
if (mime) {
|
|
528
|
+
const map: Record<string, string> = {
|
|
529
|
+
"image/png": ".png",
|
|
530
|
+
"image/jpeg": ".jpg",
|
|
531
|
+
"image/webp": ".webp",
|
|
532
|
+
"image/gif": ".gif",
|
|
533
|
+
"image/bmp": ".bmp",
|
|
534
|
+
"image/svg+xml": ".svg",
|
|
535
|
+
};
|
|
536
|
+
const key = mime.split(";")[0].trim().toLowerCase();
|
|
537
|
+
if (map[key]) return map[key];
|
|
538
|
+
}
|
|
539
|
+
if (fileName) {
|
|
540
|
+
const ext = extname(fileName).toLowerCase();
|
|
541
|
+
if (ext) return ext;
|
|
542
|
+
}
|
|
543
|
+
return ".png";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function downloadWechatImage(imageItem: ImageItem, msgId?: number): Promise<string> {
|
|
547
|
+
const wire = ilinkWire;
|
|
548
|
+
if (!wire) throw new Error("iLink wire not available");
|
|
549
|
+
if (!imageItem.media) throw new Error("image item has no media");
|
|
550
|
+
|
|
551
|
+
const data = await wire.downloadMedia(imageItem.media);
|
|
552
|
+
const mime = (imageItem as Record<string, unknown>).mime_type as string | undefined;
|
|
553
|
+
const ext = extFromMimeOrName(mime);
|
|
554
|
+
const key = imageItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
|
|
555
|
+
await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
556
|
+
const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}${ext}`);
|
|
557
|
+
writeFileSync(localPath, data);
|
|
558
|
+
platformLog(`图片已下载: ${localPath}`);
|
|
559
|
+
return localPath;
|
|
560
|
+
}
|
|
561
|
+
|
|
470
562
|
async function handleWechatMessage(
|
|
471
563
|
message: WeixinMessage,
|
|
472
564
|
handler: MessageHandler,
|
|
@@ -492,17 +584,52 @@ async function handleWechatMessage(
|
|
|
492
584
|
const text = extractText(message).trim();
|
|
493
585
|
const msgTimestamp = message.create_time_ms ?? Date.now();
|
|
494
586
|
|
|
587
|
+
// 检测并下载图片
|
|
588
|
+
const imagePaths: string[] = [];
|
|
589
|
+
const items = message.item_list;
|
|
590
|
+
if (items) {
|
|
591
|
+
for (const item of items) {
|
|
592
|
+
if (item.image_item?.media) {
|
|
593
|
+
try {
|
|
594
|
+
const localPath = await downloadWechatImage(item.image_item, message.message_id);
|
|
595
|
+
imagePaths.push(localPath);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
platformLog(`图片下载失败: ${(err as Error).message}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 构建消息文本:文本内容 + 图片路径
|
|
604
|
+
let fullText = text;
|
|
605
|
+
if (imagePaths.length > 0) {
|
|
606
|
+
const imageLines = imagePaths.map((p) => `[图片] ${p}`).join("\n");
|
|
607
|
+
fullText = fullText ? `${fullText}\n${imageLines}` : imageLines;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 纯图片且无文字时跳过(避免空消息触发会话)
|
|
611
|
+
if (!fullText.trim()) {
|
|
612
|
+
platformLog(`跳过纯媒体消息(无文本): chatId=${chatId}`);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
495
616
|
platformLog(
|
|
496
|
-
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}"`,
|
|
617
|
+
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${imagePaths.length}`,
|
|
497
618
|
);
|
|
498
|
-
appendChatLog(chatId, chatId,
|
|
619
|
+
appendChatLog(chatId, chatId, fullText);
|
|
499
620
|
|
|
500
621
|
// 用户回复,重置 claw 连发计数
|
|
501
622
|
consecutiveSendCount.set(chatId, 0);
|
|
502
623
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
624
|
+
// 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
|
|
625
|
+
if (pendingClawFinalText.has(chatId)) {
|
|
626
|
+
await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
|
|
631
|
+
// 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
|
|
632
|
+
handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
|
|
506
633
|
platformLog(`消息处理失败: ${(err as Error).stack ?? (err as Error).message}`);
|
|
507
634
|
});
|
|
508
635
|
}
|