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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.58",
3
+ "version": "0.2.60",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 { createWechatAdapter } from "../wechat-platform.ts";
5
-
6
- describe("createWechatAdapter", () => {
7
- it("degrades raw cards to plain text messages", async () => {
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
- printServiceDidNotStart((err as Error).message);
822
- process.exit(1);
821
+ console.error(`\n[飞书] 启动失败: ${(err as Error).message}`);
822
+ console.error("[飞书] 微信等其他平台不受影响,将继续启动。\n");
823
823
  }
824
824
  }
825
825
 
@@ -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 || ilinkEnabled;
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 feishuEnabled = c.platforms && c.platforms.feishu ? c.platforms.feishu.enabled !== false : true;
1203
- var ilinkEnabled = c.platforms && c.platforms.ilink ? c.platforms.ilink.enabled !== false : true;
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;
@@ -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条后非最终消息不再发送(claw 限制)
164
- if (count > 10 && !isFinal) {
165
- log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
166
- return false;
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
- const contextToken = contextTokenMap.get(chatId);
171
- if (contextToken) {
172
- await wire.sendText(chatId, text, contextToken);
173
- } else {
174
- await wire.push(chatId, text);
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, text);
619
+ appendChatLog(chatId, chatId, fullText);
499
620
 
500
621
  // 用户回复,重置 claw 连发计数
501
622
  consecutiveSendCount.set(chatId, 0);
502
623
 
503
- // WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
504
- // await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
505
- handler(text, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
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
  }