chatccc 0.2.57 → 0.2.59

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/README.md CHANGED
@@ -32,7 +32,7 @@ ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机
32
32
  | 会话形态 | 群聊,一群一会话 | 私聊,一对一 |
33
33
  | 消息展示 | CardKit 卡片,流式更新 | 纯文本,增量推送 |
34
34
  | `/new` | 自动创建新群并绑定新会话 | 在当前私聊里创建新会话 |
35
- | 多会话并行 | 直接切换不同群 | 需要在同一私聊内切换上下文 |
35
+ | 多会话并行 | 直接切换不同群 | 支持并行,使用切换指令后未完成的任务会继续在后台进行,但不如飞书直观方便 |
36
36
  | 群管理 | 支持创建、重命名、解散、头像 | 不支持 |
37
37
  | 接入成本 | 需要配置飞书应用 | 启动后扫码登录 |
38
38
 
@@ -158,9 +158,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
158
158
  "claude": {
159
159
  "enabled": false,
160
160
  "defaultAgent": true,
161
- "model": "claude-sonnet-4-6",
162
- "subagentModel": "",
163
- "effort": "",
161
+ "model": "claude-sonnet-4-6",
162
+ "subagentModel": "",
163
+ "effort": "",
164
164
  "apiKey": "",
165
165
  "baseUrl": ""
166
166
  },
@@ -189,9 +189,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
189
189
  | `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
190
190
  | `*.enabled` | 是否启用对应 AI Agent |
191
191
  | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
192
- | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
193
- | `claude.model` / `claude.subagentModel` | Claude Code 主模型 / subagent 模型;`subagentModel` 仅在第三方 API 模式下注入 `CLAUDE_CODE_SUBAGENT_MODEL` |
194
- | `claude.apiKey` / `claude.baseUrl` | 第三方 Anthropic 兼容网关配置;官方 Claude 用户留空 |
192
+ | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
193
+ | `claude.model` / `claude.subagentModel` | Claude Code 主模型 / subagent 模型;`subagentModel` 仅在第三方 API 模式下注入 `CLAUDE_CODE_SUBAGENT_MODEL` |
194
+ | `claude.apiKey` / `claude.baseUrl` | 第三方 Anthropic 兼容网关配置;官方 Claude 用户留空 |
195
195
 
196
196
  > 当前 ChatCCC 以 `bypassPermissions` 模式运行,会跳过 Agent 操作确认。请只在可信环境中使用。
197
197
 
@@ -10,14 +10,14 @@
10
10
  "port": 18080,
11
11
  "gitTimeoutSeconds": 180,
12
12
  "allowInterrupt": false,
13
- "claude": {
14
- "enabled": false,
15
- "defaultAgent": true,
16
- "model": "claude-sonnet-4-6",
17
- "subagentModel": "",
18
- "effort": "",
19
- "apiKey": "",
20
- "baseUrl": ""
13
+ "claude": {
14
+ "enabled": false,
15
+ "defaultAgent": true,
16
+ "model": "claude-sonnet-4-6",
17
+ "subagentModel": "",
18
+ "effort": "",
19
+ "apiKey": "",
20
+ "baseUrl": ""
21
21
  },
22
22
  "cursor": {
23
23
  "enabled": false,
@@ -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,59 +1,59 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.2.57",
4
- "description": "Feishu bot bridge for Claude Code",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "main": "./src/index.ts",
8
- "bin": {
9
- "chatccc": "bin/chatccc.mjs"
10
- },
11
- "files": [
12
- "src/",
13
- "bin/",
14
- "demo/ilink_echo_probe.ts",
15
- "im-skills/",
16
- "images/img_readme_*.jpg",
17
- "images/img_readme_*.png",
18
- "images/avatars/status_*.png",
19
- "images/avatars/badges/",
20
- "package.json",
21
- "README.md",
22
- "config.sample.json"
23
- ],
24
- "scripts": {
25
- "dev": "tsx src/index.ts",
26
- "chatccc": "tsx src/index.ts",
27
- "start": "tsx src/index.ts",
28
- "demo:bot-test": "tsx demo/bot_test.ts",
29
- "demo:bot-test:local": "tsx demo/bot_test.ts --local",
30
- "demo:create-group": "tsx src/index.ts",
31
- "demo:create-group:local": "tsx src/index.ts --local",
32
- "demo:permission-check": "tsx demo/permission_check.ts",
33
- "demo:claude-hi": "tsx demo/claude_say_hi.ts",
34
- "demo:codex-hi": "tsx demo/codex_say_hi.ts",
35
- "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
36
- "test": "vitest run",
37
- "test:watch": "vitest"
38
- },
39
- "dependencies": {
40
- "@anthropic-ai/claude-agent-sdk": "0.2.133",
41
- "@larksuiteoapi/node-sdk": "^1.59.0",
42
- "@openilink/openilink-sdk-node": "^0.6.0",
43
- "nodemailer": "^8.0.7",
44
- "qrcode-terminal": "^0.12.0",
45
- "sharp": "^0.34.5",
46
- "tsx": "^4.0.0",
47
- "ws": "^8.18.0"
48
- },
49
- "devDependencies": {
50
- "@types/node": "^20.0.0",
51
- "@types/qrcode-terminal": "^0.12.2",
52
- "@types/ws": "^8.18.1",
53
- "typescript": "^5.0.0",
54
- "vitest": "^4.1.5"
55
- },
56
- "engines": {
57
- "node": ">=20"
58
- }
59
- }
1
+ {
2
+ "name": "chatccc",
3
+ "version": "0.2.59",
4
+ "description": "Feishu bot bridge for Claude Code",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "bin": {
9
+ "chatccc": "bin/chatccc.mjs"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "bin/",
14
+ "demo/ilink_echo_probe.ts",
15
+ "im-skills/",
16
+ "images/img_readme_*.jpg",
17
+ "images/img_readme_*.png",
18
+ "images/avatars/status_*.png",
19
+ "images/avatars/badges/",
20
+ "package.json",
21
+ "README.md",
22
+ "config.sample.json"
23
+ ],
24
+ "scripts": {
25
+ "dev": "tsx src/index.ts",
26
+ "chatccc": "tsx src/index.ts",
27
+ "start": "tsx src/index.ts",
28
+ "demo:bot-test": "tsx demo/bot_test.ts",
29
+ "demo:bot-test:local": "tsx demo/bot_test.ts --local",
30
+ "demo:create-group": "tsx src/index.ts",
31
+ "demo:create-group:local": "tsx src/index.ts --local",
32
+ "demo:permission-check": "tsx demo/permission_check.ts",
33
+ "demo:claude-hi": "tsx demo/claude_say_hi.ts",
34
+ "demo:codex-hi": "tsx demo/codex_say_hi.ts",
35
+ "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ },
39
+ "dependencies": {
40
+ "@anthropic-ai/claude-agent-sdk": "0.2.133",
41
+ "@larksuiteoapi/node-sdk": "^1.59.0",
42
+ "@openilink/openilink-sdk-node": "^0.6.0",
43
+ "nodemailer": "^8.0.7",
44
+ "qrcode-terminal": "^0.12.0",
45
+ "sharp": "^0.34.5",
46
+ "tsx": "^4.0.0",
47
+ "ws": "^8.18.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.0.0",
51
+ "@types/qrcode-terminal": "^0.12.2",
52
+ "@types/ws": "^8.18.1",
53
+ "typescript": "^5.0.0",
54
+ "vitest": "^4.1.5"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }
@@ -771,10 +771,10 @@ describe("createClaudeAdapter — env 注入", () => {
771
771
  expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://api.deepseek.com/anthropic");
772
772
  });
773
773
 
774
- it("apiKey + baseUrl 都设置 → 同时覆盖", async () => {
775
- setupMockCreateSession();
776
- const adapter = createClaudeAdapter({
777
- model: "",
774
+ it("apiKey + baseUrl 都设置 → 同时覆盖", async () => {
775
+ setupMockCreateSession();
776
+ const adapter = createClaudeAdapter({
777
+ model: "",
778
778
  effort: "",
779
779
  isEmpty: (v) => v.trim() === "",
780
780
  apiKey: "sk-x",
@@ -784,50 +784,50 @@ describe("createClaudeAdapter — env 注入", () => {
784
784
  await adapter.createSession("/cwd");
785
785
 
786
786
  const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
787
- expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-x");
788
- expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://gateway.example/anthropic");
789
- });
790
-
791
- it("官方 API 模式下 subagentModel 不注入 env,也不覆盖 Claude 登录环境", async () => {
792
- setupMockCreateSession();
793
- process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
794
- const adapter = createClaudeAdapter({
795
- model: "claude-sonnet-4-6",
796
- subagentModel: "claude-haiku-4-5-20251001",
797
- effort: "",
798
- isEmpty: (v) => v.trim() === "",
799
- apiKey: "",
800
- baseUrl: "",
801
- });
802
-
803
- await adapter.createSession("/cwd");
804
-
805
- const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
806
- expect(opts).not.toHaveProperty("env");
807
- });
808
-
809
- it("第三方 API 模式下 subagentModel 通过 CLAUDE_CODE_SUBAGENT_MODEL 覆盖 Haiku", async () => {
810
- setupMockCreateSession();
811
- process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
812
- const adapter = createClaudeAdapter({
813
- model: "claude-sonnet-4-6",
814
- subagentModel: "claude-haiku-4-5-20251001",
815
- effort: "",
816
- isEmpty: (v) => v.trim() === "",
817
- apiKey: "sk-thirdparty",
818
- baseUrl: "https://gateway.example/anthropic",
819
- });
820
-
821
- await adapter.createSession("/cwd");
822
-
823
- const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
824
- expect(opts.model).toBe("claude-sonnet-4-6");
825
- expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-thirdparty");
826
- expect(opts.env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
827
- expect(opts.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
828
- });
829
-
830
- it("ChatCCC API config is isolated from Claude settings auth/model env", async () => {
787
+ expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-x");
788
+ expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://gateway.example/anthropic");
789
+ });
790
+
791
+ it("官方 API 模式下 subagentModel 不注入 env,也不覆盖 Claude 登录环境", async () => {
792
+ setupMockCreateSession();
793
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
794
+ const adapter = createClaudeAdapter({
795
+ model: "claude-sonnet-4-6",
796
+ subagentModel: "claude-haiku-4-5-20251001",
797
+ effort: "",
798
+ isEmpty: (v) => v.trim() === "",
799
+ apiKey: "",
800
+ baseUrl: "",
801
+ });
802
+
803
+ await adapter.createSession("/cwd");
804
+
805
+ const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
806
+ expect(opts).not.toHaveProperty("env");
807
+ });
808
+
809
+ it("第三方 API 模式下 subagentModel 通过 CLAUDE_CODE_SUBAGENT_MODEL 覆盖 Haiku", async () => {
810
+ setupMockCreateSession();
811
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
812
+ const adapter = createClaudeAdapter({
813
+ model: "claude-sonnet-4-6",
814
+ subagentModel: "claude-haiku-4-5-20251001",
815
+ effort: "",
816
+ isEmpty: (v) => v.trim() === "",
817
+ apiKey: "sk-thirdparty",
818
+ baseUrl: "https://gateway.example/anthropic",
819
+ });
820
+
821
+ await adapter.createSession("/cwd");
822
+
823
+ const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
824
+ expect(opts.model).toBe("claude-sonnet-4-6");
825
+ expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-thirdparty");
826
+ expect(opts.env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
827
+ expect(opts.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
828
+ });
829
+
830
+ it("ChatCCC API config is isolated from Claude settings auth/model env", async () => {
831
831
  setupMockCreateSession();
832
832
  process.env.ANTHROPIC_AUTH_TOKEN = "token-from-claude-settings";
833
833
  process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-claude-settings";
@@ -10,9 +10,9 @@ import {
10
10
  CHATCCC_PORT,
11
11
  CLAUDE_API_KEY,
12
12
  CLAUDE_BASE_URL,
13
- CLAUDE_EFFORT,
14
- CLAUDE_MODEL,
15
- CLAUDE_SUBAGENT_MODEL,
13
+ CLAUDE_EFFORT,
14
+ CLAUDE_MODEL,
15
+ CLAUDE_SUBAGENT_MODEL,
16
16
  CURSOR_AGENT_ARGS,
17
17
  CURSOR_AGENT_COMMAND,
18
18
  FEISHU_ENABLED,
@@ -44,10 +44,10 @@ const baseAppConfig: AppConfig = {
44
44
  allowInterrupt: false,
45
45
  claude: {
46
46
  enabled: true,
47
- defaultAgent: true,
48
- model: "initial-model",
49
- subagentModel: "initial-subagent-model",
50
- effort: "initial-effort",
47
+ defaultAgent: true,
48
+ model: "initial-model",
49
+ subagentModel: "initial-subagent-model",
50
+ effort: "initial-effort",
51
51
  apiKey: "sk-initial",
52
52
  baseUrl: "https://initial.gw/anthropic",
53
53
  },
@@ -80,22 +80,22 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
80
80
  expect(APP_SECRET).toBe("NEW_APP_SECRET");
81
81
  });
82
82
 
83
- it("更新 Claude 配置(model / subagentModel / effort / apiKey / baseUrl)", () => {
83
+ it("更新 Claude 配置(model / subagentModel / effort / apiKey / baseUrl)", () => {
84
84
  applyLoadedConfig({
85
85
  ...structuredClone(baseAppConfig),
86
86
  claude: {
87
87
  enabled: true,
88
- defaultAgent: true,
89
- model: "claude-sonnet-4-6",
90
- subagentModel: "claude-haiku-4-5-20251001",
91
- effort: "high",
88
+ defaultAgent: true,
89
+ model: "claude-sonnet-4-6",
90
+ subagentModel: "claude-haiku-4-5-20251001",
91
+ effort: "high",
92
92
  apiKey: "sk-newkey",
93
93
  baseUrl: "https://gw2.example/anthropic",
94
94
  },
95
95
  });
96
96
 
97
- expect(CLAUDE_MODEL).toBe("claude-sonnet-4-6");
98
- expect(CLAUDE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
97
+ expect(CLAUDE_MODEL).toBe("claude-sonnet-4-6");
98
+ expect(CLAUDE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
99
99
  expect(CLAUDE_EFFORT).toBe("high");
100
100
  expect(CLAUDE_API_KEY).toBe("sk-newkey");
101
101
  expect(CLAUDE_BASE_URL).toBe("https://gw2.example/anthropic");
@@ -6,25 +6,25 @@ import { describe, expect, it } from "vitest";
6
6
  describe("config.sample.json", () => {
7
7
  it("enables Feishu and WeChat iLink by default", () => {
8
8
  const configSamplePath = join(process.cwd(), "config.sample.json");
9
- const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
10
- platforms?: {
11
- feishu?: { enabled?: unknown };
12
- ilink?: { enabled?: unknown };
13
- };
14
- claude?: { model?: unknown; subagentModel?: unknown };
15
- };
16
-
17
- expect(sample.platforms?.feishu?.enabled).toBe(true);
18
- expect(sample.platforms?.ilink?.enabled).toBe(true);
19
- });
20
-
21
- it("uses an Anthropic official main model and leaves subagent override empty by default", () => {
22
- const configSamplePath = join(process.cwd(), "config.sample.json");
23
- const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
24
- claude?: { model?: unknown; subagentModel?: unknown };
25
- };
26
-
27
- expect(sample.claude?.model).toBe("claude-sonnet-4-6");
28
- expect(sample.claude?.subagentModel).toBe("");
29
- });
30
- });
9
+ const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
10
+ platforms?: {
11
+ feishu?: { enabled?: unknown };
12
+ ilink?: { enabled?: unknown };
13
+ };
14
+ claude?: { model?: unknown; subagentModel?: unknown };
15
+ };
16
+
17
+ expect(sample.platforms?.feishu?.enabled).toBe(true);
18
+ expect(sample.platforms?.ilink?.enabled).toBe(true);
19
+ });
20
+
21
+ it("uses an Anthropic official main model and leaves subagent override empty by default", () => {
22
+ const configSamplePath = join(process.cwd(), "config.sample.json");
23
+ const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
24
+ claude?: { model?: unknown; subagentModel?: unknown };
25
+ };
26
+
27
+ expect(sample.claude?.model).toBe("claude-sonnet-4-6");
28
+ expect(sample.claude?.subagentModel).toBe("");
29
+ });
30
+ });
@@ -0,0 +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,10 +1,10 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- applyClaudeApiMode,
4
- chooseStartPath,
5
- detectClaudeApiMode,
6
- unflattenConfig,
7
- } from "../web-ui.ts";
3
+ applyClaudeApiMode,
4
+ chooseStartPath,
5
+ detectClaudeApiMode,
6
+ unflattenConfig,
7
+ } from "../web-ui.ts";
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // detectClaudeApiMode — 加载已有 config 时如何判定 UI 初始模式
@@ -57,7 +57,7 @@ describe("detectClaudeApiMode", () => {
57
57
  // - mode 未传 默认按 official 兌底(不保留可能误填的密钥)
58
58
  // ---------------------------------------------------------------------------
59
59
 
60
- describe("applyClaudeApiMode", () => {
60
+ describe("applyClaudeApiMode", () => {
61
61
  it("mode=official 时清空 CLAUDE_API_KEY / CLAUDE_BASE_URL(即使 vars 没传)", () => {
62
62
  const out = applyClaudeApiMode({ CHATCCC_APP_ID: "x" }, "official");
63
63
  expect(out).toEqual({
@@ -128,23 +128,23 @@ describe("applyClaudeApiMode", () => {
128
128
  expect(input).toEqual({ CLAUDE_API_KEY: "sk-x" }); // 原对象未变
129
129
  expect(out).not.toBe(input);
130
130
  });
131
- });
132
-
133
- describe("unflattenConfig", () => {
134
- it("maps Claude subagent model into claude.subagentModel", () => {
135
- expect(
136
- unflattenConfig({
137
- CHATCCC_ANTHROPIC_MODEL: "claude-sonnet-4-6",
138
- CHATCCC_ANTHROPIC_SUBAGENT_MODEL: "claude-haiku-4-5-20251001",
139
- }),
140
- ).toEqual({
141
- claude: {
142
- model: "claude-sonnet-4-6",
143
- subagentModel: "claude-haiku-4-5-20251001",
144
- },
145
- });
146
- });
147
- });
131
+ });
132
+
133
+ describe("unflattenConfig", () => {
134
+ it("maps Claude subagent model into claude.subagentModel", () => {
135
+ expect(
136
+ unflattenConfig({
137
+ CHATCCC_ANTHROPIC_MODEL: "claude-sonnet-4-6",
138
+ CHATCCC_ANTHROPIC_SUBAGENT_MODEL: "claude-haiku-4-5-20251001",
139
+ }),
140
+ ).toEqual({
141
+ claude: {
142
+ model: "claude-sonnet-4-6",
143
+ subagentModel: "claude-haiku-4-5-20251001",
144
+ },
145
+ });
146
+ });
147
+ });
148
148
 
149
149
  // ---------------------------------------------------------------------------
150
150
  // chooseStartPath — /api/start 的路径选择
@@ -1,9 +1,17 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { buildHelpCard } from "../cards.ts";
4
- import { createWechatAdapter } from "../wechat-platform.ts";
4
+ import {
5
+ _flushPendingClawFinalTextForTest,
6
+ _resetWechatClawStateForTest,
7
+ createWechatAdapter,
8
+ } from "../wechat-platform.ts";
5
9
 
6
10
  describe("createWechatAdapter", () => {
11
+ beforeEach(() => {
12
+ _resetWechatClawStateForTest();
13
+ });
14
+
7
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"),
@@ -54,4 +62,38 @@ describe("createWechatAdapter", () => {
54
62
  "[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=11",
55
63
  );
56
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
+ });
57
99
  });
@@ -86,33 +86,33 @@ function buildSdkEnv(
86
86
  baseUrl: string | undefined,
87
87
  subagentModel: string | undefined,
88
88
  ): Record<string, string | undefined> | undefined {
89
- const apiKeyTrim = (apiKey ?? "").trim();
90
- const baseUrlTrim = (baseUrl ?? "").trim();
91
- const subagentModelTrim = (subagentModel ?? "").trim();
92
- const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
93
- if (!hasApiOverride) return undefined;
94
-
95
- const env: Record<string, string | undefined> = { ...process.env };
96
- // ChatCCC's third-party Claude API config is authoritative when present.
97
- // Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
98
- delete env.ANTHROPIC_AUTH_TOKEN;
99
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
100
- delete env.ANTHROPIC_MODEL;
101
- delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
102
- delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
103
- delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
104
- delete env.CLAUDE_CODE_SUBAGENT_MODEL;
105
- delete env.CLAUDE_CODE_EFFORT_LEVEL;
106
-
107
- if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
108
- if (baseUrlTrim) {
109
- env.ANTHROPIC_BASE_URL = baseUrlTrim;
110
- } else {
111
- delete env.ANTHROPIC_BASE_URL;
112
- }
113
- if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
114
- return env;
115
- }
89
+ const apiKeyTrim = (apiKey ?? "").trim();
90
+ const baseUrlTrim = (baseUrl ?? "").trim();
91
+ const subagentModelTrim = (subagentModel ?? "").trim();
92
+ const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
93
+ if (!hasApiOverride) return undefined;
94
+
95
+ const env: Record<string, string | undefined> = { ...process.env };
96
+ // ChatCCC's third-party Claude API config is authoritative when present.
97
+ // Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
98
+ delete env.ANTHROPIC_AUTH_TOKEN;
99
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
100
+ delete env.ANTHROPIC_MODEL;
101
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
102
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
103
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
104
+ delete env.CLAUDE_CODE_SUBAGENT_MODEL;
105
+ delete env.CLAUDE_CODE_EFFORT_LEVEL;
106
+
107
+ if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
108
+ if (baseUrlTrim) {
109
+ env.ANTHROPIC_BASE_URL = baseUrlTrim;
110
+ } else {
111
+ delete env.ANTHROPIC_BASE_URL;
112
+ }
113
+ if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
114
+ return env;
115
+ }
116
116
 
117
117
  function resolveSettingSources(
118
118
  _apiKey: string | undefined,
@@ -291,19 +291,19 @@ class ClaudeAdapter implements ToolAdapter {
291
291
  this.baseUrl,
292
292
  this.subagentModel,
293
293
  );
294
- const session = unstable_v2_resumeSession(
295
- sessionId,
296
- sessionOpts as any,
297
- );
298
-
299
- if (signal?.aborted) {
300
- session.close();
301
- return;
302
- }
303
- const onAbort = () => { session.close(); };
304
- signal?.addEventListener("abort", onAbort, { once: true });
305
-
306
- await session.send(userText);
294
+ const session = unstable_v2_resumeSession(
295
+ sessionId,
296
+ sessionOpts as any,
297
+ );
298
+
299
+ if (signal?.aborted) {
300
+ session.close();
301
+ return;
302
+ }
303
+ const onAbort = () => { session.close(); };
304
+ signal?.addEventListener("abort", onAbort, { once: true });
305
+
306
+ await session.send(userText);
307
307
 
308
308
  const stream = session.stream();
309
309
 
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
 
@@ -80,6 +80,14 @@ function isUntitledSessionChatName(name: string): boolean {
80
80
  return name === "新会话" || name.startsWith("新会话-");
81
81
  }
82
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
+
83
91
  // ---------------------------------------------------------------------------
84
92
  // handleCommand — 平台无关的命令分发
85
93
  // ---------------------------------------------------------------------------
@@ -988,6 +996,10 @@ export async function handleCommand(
988
996
  return;
989
997
  }
990
998
 
999
+ if (shouldSendWechatProcessingAck(platform, textLower, chatType)) {
1000
+ await platform.sendText(chatId, "生成中...").catch(() => {});
1001
+ }
1002
+
991
1003
  try {
992
1004
  logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
993
1005
  await resumeAndPrompt(
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,19 +211,20 @@ 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
- }
227
+ await sendWechatTextRaw(wire, chatId, text);
176
228
  const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
177
229
  log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
178
230
  return true;
@@ -467,6 +519,44 @@ export async function startWechatPlatform(
467
519
  );
468
520
  }
469
521
 
522
+ const WECHAT_IMAGE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "images", "downloads");
523
+
524
+ function extFromMimeOrName(mime?: string | null, fileName?: string | null): string {
525
+ if (mime) {
526
+ const map: Record<string, string> = {
527
+ "image/png": ".png",
528
+ "image/jpeg": ".jpg",
529
+ "image/webp": ".webp",
530
+ "image/gif": ".gif",
531
+ "image/bmp": ".bmp",
532
+ "image/svg+xml": ".svg",
533
+ };
534
+ const key = mime.split(";")[0].trim().toLowerCase();
535
+ if (map[key]) return map[key];
536
+ }
537
+ if (fileName) {
538
+ const ext = extname(fileName).toLowerCase();
539
+ if (ext) return ext;
540
+ }
541
+ return ".png";
542
+ }
543
+
544
+ async function downloadWechatImage(imageItem: ImageItem, msgId?: number): Promise<string> {
545
+ const wire = ilinkWire;
546
+ if (!wire) throw new Error("iLink wire not available");
547
+ if (!imageItem.media) throw new Error("image item has no media");
548
+
549
+ const data = await wire.downloadMedia(imageItem.media);
550
+ const mime = (imageItem as Record<string, unknown>).mime_type as string | undefined;
551
+ const ext = extFromMimeOrName(mime);
552
+ const key = imageItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
553
+ await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
554
+ const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}${ext}`);
555
+ writeFileSync(localPath, data);
556
+ platformLog(`图片已下载: ${localPath}`);
557
+ return localPath;
558
+ }
559
+
470
560
  async function handleWechatMessage(
471
561
  message: WeixinMessage,
472
562
  handler: MessageHandler,
@@ -492,29 +582,52 @@ async function handleWechatMessage(
492
582
  const text = extractText(message).trim();
493
583
  const msgTimestamp = message.create_time_ms ?? Date.now();
494
584
 
585
+ // 检测并下载图片
586
+ const imagePaths: string[] = [];
587
+ const items = message.item_list;
588
+ if (items) {
589
+ for (const item of items) {
590
+ if (item.image_item?.media) {
591
+ try {
592
+ const localPath = await downloadWechatImage(item.image_item, message.message_id);
593
+ imagePaths.push(localPath);
594
+ } catch (err) {
595
+ platformLog(`图片下载失败: ${(err as Error).message}`);
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ // 构建消息文本:文本内容 + 图片路径
602
+ let fullText = text;
603
+ if (imagePaths.length > 0) {
604
+ const imageLines = imagePaths.map((p) => `[图片] ${p}`).join("\n");
605
+ fullText = fullText ? `${fullText}\n${imageLines}` : imageLines;
606
+ }
607
+
608
+ // 纯图片且无文字时跳过(避免空消息触发会话)
609
+ if (!fullText.trim()) {
610
+ platformLog(`跳过纯媒体消息(无文本): chatId=${chatId}`);
611
+ return;
612
+ }
613
+
495
614
  platformLog(
496
- `收到消息: chatId=${chatId} text="${text.slice(0, 80)}"`,
615
+ `收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${imagePaths.length}`,
497
616
  );
498
- appendChatLog(chatId, chatId, text);
617
+ appendChatLog(chatId, chatId, fullText);
499
618
 
500
619
  // 用户回复,重置 claw 连发计数
501
620
  consecutiveSendCount.set(chatId, 0);
502
621
 
503
- // 非指令消息:立即发送"生成中...",让用户知道消息已收到并正在处理
504
- if (text && !text.startsWith("/") && ilinkWire) {
505
- const ctxToken = contextTokenMap.get(chatId);
506
- if (ctxToken) {
507
- ilinkWire.sendText(chatId, "生成中...", ctxToken).catch(() => {});
508
- } else {
509
- ilinkWire.push(chatId, "生成中...").catch(() => {});
510
- }
511
- // "生成中..." 计为第1条连发消息
512
- consecutiveSendCount.set(chatId, 1);
622
+ // 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
623
+ if (pendingClawFinalText.has(chatId)) {
624
+ await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
625
+ return;
513
626
  }
514
627
 
515
628
  // WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
516
629
  // 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
517
- handler(text, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
630
+ handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
518
631
  platformLog(`消息处理失败: ${(err as Error).stack ?? (err as Error).message}`);
519
632
  });
520
633
  }