chatccc 0.2.51 → 0.2.52

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
@@ -1,6 +1,6 @@
1
1
  # ChatCCC
2
2
 
3
- **飞书(Lark)聊天控制 Claude Code / Cursor / Codex**
3
+ **飞书(推荐,体验丝滑)/ 微信 聊天控制 Claude Code / Cursor / Codex**
4
4
 
5
5
  ---
6
6
 
@@ -8,9 +8,10 @@
8
8
 
9
9
  传统的 Claude Code(尤其是使用第三方 API 的,如 DeepSeek),需要坐在电脑桌前才能用。**离开电脑就没法用了。**
10
10
 
11
- ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
11
+ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊和微信私聊:
12
12
 
13
- - **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
13
+ - **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群或微信里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里(微信为文本模式)
14
+ - **双平台支持** —— 飞书(群聊 + 卡片)和微信 iLink(私聊 + 文本),一套代码同时服务两个平台
14
15
  - **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
15
16
  - **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
16
17
 
@@ -32,6 +33,45 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
32
33
  - **零配置成本** —— 单一 `config.json`,没有 TOML 也没有零散环境变量。`npm i -g chatccc` 后**任意目录**直接 `chatccc` 就跑,首次启动自动弹出本地 Web 配置向导
33
34
  - **群里能跑 git** —— `/git status`、`/git pull`、`/git log` 在飞书群里直接执行 stdout/stderr 回发,不用回电脑
34
35
  - **代码极简易改** —— 纯 TypeScript 实现,核心只有 20 多个文件,统一 `ToolAdapter` 接口屏蔽 Claude / Cursor / Codex 差异,看得懂、改得动
36
+ - **微信 iLink 支持** —— 扫码登录微信个人号,即可在微信中与 AI 对话。微信端为纯文本模式(无卡片),生成过程中的内容以增量方式推送,避免重复刷屏。
37
+
38
+ ---
39
+
40
+ ## 微信 iLink 平台
41
+
42
+ 除了飞书,ChatCCC 也支持通过 [微信 iLink](https://github.com/openilink/openilink-sdk-node) 接入微信个人号。**微信模式无需任何配置,启动后扫码即可使用。**
43
+
44
+ ### 快速开始(微信)
45
+
46
+ ```bash
47
+ chatccc # 启动 ChatCCC
48
+ # → 控制台打印出微信扫码二维码
49
+ # → 打开微信扫一扫 → 关注机器人
50
+ # → 发送 /new 开始对话
51
+ ```
52
+
53
+ 就这么简单。**不用创建飞书应用、不用配置权限、不用订阅回调。** 扫一下控制台上的二维码就能用。
54
+
55
+ ### 微信与飞书的差异
56
+
57
+ | 特性 | 飞书 | 微信 |
58
+ | --- | --- | --- |
59
+ | 会话类型 | 群聊(一群一会话) | 私聊(一对一) |
60
+ | 消息展示 | CardKit 卡片(流式更新) | 纯文本(增量推送,结尾标记"━━━ 回答结束 ━━━") |
61
+ | `/new` 行为 | 创建新群聊 | 在当前私聊中创建新会话 |
62
+ | `/newh` 行为 | 群内重置会话 | 私聊内重置会话 |
63
+ | 非指令消息 | 走卡片进度展示 | 立即回复"生成中...",增量推送结果 |
64
+ | 群管理 | 创建/重命名/解散/设置头像 | 不支持 |
65
+ | 启动方式 | 飞书应用凭证 + WebSocket | 扫码登录(QR 码) |
66
+ | 连接保活 | WebSocket 长连接 | HTTP 长轮询 |
67
+
68
+ ### 启用微信
69
+
70
+ `config.json` 中 `platforms.ilink.enabled` 设为 `true`(默认已开启)。启动 ChatCCC 后,终端会打印微信扫码登录二维码,使用微信扫描即可登录。
71
+
72
+ > 微信 iLink 登录的 token 和 context 会持久化到 `state/ilink-auth.json`,下次启动时会自动尝试复用。token 过期后重新扫码即可。
73
+
74
+ > 微信模式下无须配置飞书应用、无须订阅事件回调。只需启动 ChatCCC → 扫码 → 在微信中找到机器人即可对话。
35
75
 
36
76
  ---
37
77
 
@@ -229,6 +269,10 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
229
269
  "appId": "cli_xxxxxxxxxxxx",
230
270
  "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
231
271
  },
272
+ "platforms": {
273
+ "feishu": { "enabled": true },
274
+ "ilink": { "enabled": true }
275
+ },
232
276
  "port": 18080,
233
277
  "gitTimeoutSeconds": 180,
234
278
  "claude": {
@@ -260,6 +304,7 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
260
304
  | 字段 | 必填 | 说明 |
261
305
  | --- | --- | --- |
262
306
  | `feishu.appId` / `feishu.appSecret` | 是 | 飞书应用「凭证与基础信息」中的 App ID / App Secret |
307
+ | `platforms.feishu.enabled` / `platforms.ilink.enabled` | 否 | IM 平台开关;默认同时开启飞书和微信 iLink。开启微信后启动时会显示扫码登录二维码 |
263
308
  | `port` | 否 | 本地 WebSocket 中继 + Web 向导监听端口,默认 `18080`;同机多实例时改成不同值即可 |
264
309
  | `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
265
310
  | `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
@@ -309,7 +354,9 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
309
354
 
310
355
  ### 5. 开始使用
311
356
 
312
- 在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
357
+ **飞书:** 在飞书中找到你的机器人,发送 `/new`(使用默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
358
+
359
+ **微信:** 启动 `chatccc` 后,控制台会打印微信扫码二维码。打开微信扫一扫关注机器人,直接发送 `/new` 或 `/new claude` 等指令即可在私聊中开始对话。所有命令与飞书端完全一致。
313
360
 
314
361
  ### 可用指令
315
362
 
@@ -1,30 +1,34 @@
1
- {
2
- "feishu": {
3
- "appId": "",
4
- "appSecret": ""
5
- },
6
- "port": 18080,
7
- "gitTimeoutSeconds": 180,
8
- "allowInterrupt": false,
9
- "claude": {
10
- "enabled": false,
11
- "defaultAgent": true,
12
- "model": "",
13
- "effort": "",
14
- "apiKey": "",
15
- "baseUrl": ""
16
- },
17
- "cursor": {
18
- "enabled": false,
19
- "defaultAgent": false,
20
- "path": "",
21
- "model": ""
22
- },
23
- "codex": {
24
- "enabled": false,
25
- "defaultAgent": false,
26
- "path": "",
27
- "model": "",
28
- "effort": ""
29
- }
30
- }
1
+ {
2
+ "feishu": {
3
+ "appId": "",
4
+ "appSecret": ""
5
+ },
6
+ "platforms": {
7
+ "feishu": { "enabled": true },
8
+ "ilink": { "enabled": true, "reuseTokenOnStart": true }
9
+ },
10
+ "port": 18080,
11
+ "gitTimeoutSeconds": 180,
12
+ "allowInterrupt": false,
13
+ "claude": {
14
+ "enabled": false,
15
+ "defaultAgent": true,
16
+ "model": "",
17
+ "effort": "",
18
+ "apiKey": "",
19
+ "baseUrl": ""
20
+ },
21
+ "cursor": {
22
+ "enabled": false,
23
+ "defaultAgent": false,
24
+ "path": "",
25
+ "model": ""
26
+ },
27
+ "codex": {
28
+ "enabled": false,
29
+ "defaultAgent": false,
30
+ "path": "",
31
+ "model": "",
32
+ "effort": ""
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.51",
3
+ "version": "0.2.52",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { cardJsonToPlainText } from "../card-plain-text.ts";
4
+ import {
5
+ buildHelpCard,
6
+ buildProgressCard,
7
+ buildStatusCard,
8
+ } from "../cards.ts";
9
+
10
+ describe("cardJsonToPlainText", () => {
11
+ it("converts help cards to readable text with commands", () => {
12
+ const text = cardJsonToPlainText(buildHelpCard("hello"));
13
+
14
+ expect(text).toContain("# ChatCCC");
15
+ expect(text).toContain("hello");
16
+ expect(text).toContain("/new");
17
+ expect(text).toContain("/new cursor");
18
+ expect(text).toContain("/new codex");
19
+ expect(text).toContain("/restart");
20
+ expect(text).toContain("/cd");
21
+ });
22
+
23
+ it("converts status cards from v1 card format", () => {
24
+ const text = cardJsonToPlainText(buildStatusCard("status body", "green"));
25
+
26
+ expect(text).toContain("# 会话状态");
27
+ expect(text).toContain("status body");
28
+ });
29
+
30
+ it("converts schema 2.0 progress cards", () => {
31
+ const text = cardJsonToPlainText(buildProgressCard("stream body"));
32
+
33
+ expect(text).toContain("# 生成中...");
34
+ expect(text).toContain("stream body");
35
+ expect(text).toContain("/status");
36
+ expect(text).toContain("/stop");
37
+ });
38
+
39
+ it("returns null for invalid card json", () => {
40
+ expect(cardJsonToPlainText("{bad json")).toBeNull();
41
+ });
42
+ });
@@ -685,6 +685,14 @@ describe("createClaudeAdapter — env 注入", () => {
685
685
  // 确保每个用例从干净的 process.env 起步
686
686
  delete process.env.ANTHROPIC_API_KEY;
687
687
  delete process.env.ANTHROPIC_BASE_URL;
688
+ delete process.env.ANTHROPIC_AUTH_TOKEN;
689
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
690
+ delete process.env.ANTHROPIC_MODEL;
691
+ delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
692
+ delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
693
+ delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
694
+ delete process.env.CLAUDE_CODE_SUBAGENT_MODEL;
695
+ delete process.env.CLAUDE_CODE_EFFORT_LEVEL;
688
696
  });
689
697
 
690
698
  // 用例之间清掉我们写入的 env,避免互相污染
@@ -780,6 +788,51 @@ describe("createClaudeAdapter — env 注入", () => {
780
788
  expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://gateway.example/anthropic");
781
789
  });
782
790
 
791
+ it("ChatCCC API config is isolated from Claude settings auth/model env", async () => {
792
+ setupMockCreateSession();
793
+ process.env.ANTHROPIC_AUTH_TOKEN = "token-from-claude-settings";
794
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-claude-settings";
795
+ process.env.ANTHROPIC_MODEL = "model-from-claude-settings";
796
+ process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "sonnet-from-claude-settings";
797
+ process.env.CLAUDE_CODE_SUBAGENT_MODEL = "subagent-from-claude-settings";
798
+ process.env.CLAUDE_CODE_EFFORT_LEVEL = "max";
799
+ const adapter = createClaudeAdapter({
800
+ model: "chatccc-model",
801
+ effort: "high",
802
+ isEmpty: (v) => v.trim() === "",
803
+ apiKey: "sk-chatccc",
804
+ baseUrl: "https://chatccc-gateway.example/anthropic",
805
+ });
806
+
807
+ await adapter.createSession("/cwd");
808
+
809
+ const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
810
+ expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-chatccc");
811
+ expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://chatccc-gateway.example/anthropic");
812
+ expect(opts.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
813
+ expect(opts.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
814
+ expect(opts.env.ANTHROPIC_MODEL).toBeUndefined();
815
+ expect(opts.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeUndefined();
816
+ expect(opts.env.CLAUDE_CODE_SUBAGENT_MODEL).toBeUndefined();
817
+ expect(opts.env.CLAUDE_CODE_EFFORT_LEVEL).toBeUndefined();
818
+ });
819
+
820
+ it("第三方 API 配置时仍然加载 CLAUDE.md(settingSources 始终为 project+local)", async () => {
821
+ setupMockCreateSession();
822
+ const adapter = createClaudeAdapter({
823
+ model: "",
824
+ effort: "",
825
+ isEmpty: (v) => v.trim() === "",
826
+ apiKey: "sk-chatccc",
827
+ baseUrl: "https://chatccc-gateway.example/anthropic",
828
+ });
829
+
830
+ await adapter.createSession("/cwd");
831
+
832
+ const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
833
+ expect(opts.settingSources).toEqual(["project", "local"]);
834
+ });
835
+
783
836
  it("不修改主进程 process.env(永不污染)", async () => {
784
837
  setupMockCreateSession();
785
838
  const before = { ...process.env };
@@ -817,4 +870,4 @@ describe("createClaudeAdapter — env 注入", () => {
817
870
  expect(opts.env.ANTHROPIC_API_KEY).toBe("from-system-env");
818
871
  expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://override.example/anthropic");
819
872
  });
820
- });
873
+ });
@@ -12,15 +12,17 @@ import {
12
12
  CLAUDE_BASE_URL,
13
13
  CLAUDE_EFFORT,
14
14
  CLAUDE_MODEL,
15
- CURSOR_AGENT_ARGS,
16
- CURSOR_AGENT_COMMAND,
17
- GIT_TIMEOUT_MS,
18
- GIT_TIMEOUT_SECONDS,
15
+ CURSOR_AGENT_ARGS,
16
+ CURSOR_AGENT_COMMAND,
17
+ FEISHU_ENABLED,
18
+ GIT_TIMEOUT_MS,
19
+ GIT_TIMEOUT_SECONDS,
20
+ ILINK_ENABLED,
19
21
  applyLoadedConfig,
20
22
  config,
21
- resolveDefaultAgentTool,
22
- type AppConfig,
23
- } from "../config.ts";
23
+ resolveDefaultAgentTool,
24
+ type AppConfig,
25
+ } from "../config.ts";
24
26
 
25
27
  // ---------------------------------------------------------------------------
26
28
  // applyLoadedConfig — setup → service「在线切换」时刷新进程内 config 的核心机制
@@ -33,20 +35,22 @@ import {
33
35
  // 原地切换复用同一 server,重新读端口只会引入混乱)
34
36
  // ---------------------------------------------------------------------------
35
37
 
36
- const baseAppConfig: AppConfig = {
37
- feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
38
+ const baseAppConfig: AppConfig = {
39
+ feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
40
+ platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
38
41
  port: 18080,
39
42
  gitTimeoutSeconds: 180,
40
- claude: {
41
- enabled: true,
42
- defaultAgent: true,
43
- model: "initial-model",
43
+ allowInterrupt: false,
44
+ claude: {
45
+ enabled: true,
46
+ defaultAgent: true,
47
+ model: "initial-model",
44
48
  effort: "initial-effort",
45
49
  apiKey: "sk-initial",
46
50
  baseUrl: "https://initial.gw/anthropic",
47
51
  },
48
- cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
49
- codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
52
+ cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
53
+ codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
50
54
  };
51
55
 
52
56
  // 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
@@ -77,10 +81,10 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
77
81
  it("更新 Claude 配置(model / effort / apiKey / baseUrl)", () => {
78
82
  applyLoadedConfig({
79
83
  ...structuredClone(baseAppConfig),
80
- claude: {
81
- enabled: true,
82
- defaultAgent: true,
83
- model: "deepseek-v4-pro",
84
+ claude: {
85
+ enabled: true,
86
+ defaultAgent: true,
87
+ model: "deepseek-v4-pro",
84
88
  effort: "high",
85
89
  apiKey: "sk-newkey",
86
90
  baseUrl: "https://gw2.example/anthropic",
@@ -93,7 +97,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
93
97
  expect(CLAUDE_BASE_URL).toBe("https://gw2.example/anthropic");
94
98
  });
95
99
 
96
- it("更新 GIT_TIMEOUT_SECONDS 与 GIT_TIMEOUT_MS(毫秒派生值同步刷新)", () => {
100
+ it("更新 GIT_TIMEOUT_SECONDS 与 GIT_TIMEOUT_MS(毫秒派生值同步刷新)", () => {
97
101
  applyLoadedConfig({
98
102
  ...structuredClone(baseAppConfig),
99
103
  gitTimeoutSeconds: 240,
@@ -102,12 +106,25 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
102
106
  expect(GIT_TIMEOUT_SECONDS).toBe(240);
103
107
  // 派生值必须跟着更新,否则 /git 仍然按旧 timeout 运行
104
108
  expect(GIT_TIMEOUT_MS).toBe(240 * 1000);
105
- });
109
+ });
110
+
111
+ it("更新平台开关(默认飞书和微信都开启)", () => {
112
+ expect(FEISHU_ENABLED).toBe(true);
113
+ expect(ILINK_ENABLED).toBe(true);
114
+
115
+ applyLoadedConfig({
116
+ ...structuredClone(baseAppConfig),
117
+ platforms: { feishu: { enabled: false }, ilink: { enabled: true } },
118
+ });
119
+
120
+ expect(FEISHU_ENABLED).toBe(false);
121
+ expect(ILINK_ENABLED).toBe(true);
122
+ });
106
123
 
107
124
  it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
108
125
  applyLoadedConfig({
109
126
  ...structuredClone(baseAppConfig),
110
- cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
127
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
111
128
  });
112
129
 
113
130
  // CURSOR_AGENT_ARGS 是 ['-p', '--force', ..., '--model', 'claude-3.7-sonnet']
@@ -118,7 +135,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
118
135
  it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
119
136
  applyLoadedConfig({
120
137
  ...structuredClone(baseAppConfig),
121
- cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
138
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
122
139
  });
123
140
 
124
141
  expect(CURSOR_AGENT_ARGS).not.toContain("--model");
@@ -127,7 +144,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
127
144
  it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
128
145
  applyLoadedConfig({
129
146
  ...structuredClone(baseAppConfig),
130
- cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
147
+ cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
131
148
  });
132
149
 
133
150
  expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
@@ -145,14 +162,14 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
145
162
  });
146
163
  });
147
164
 
148
- describe("applyLoadedConfig — config 对象引用契约", () => {
165
+ describe("applyLoadedConfig — config 对象引用契约", () => {
149
166
  it("config 引用保持不变(就地更新),但字段被刷新", () => {
150
167
  const refBefore = config;
151
168
 
152
169
  applyLoadedConfig({
153
170
  ...structuredClone(baseAppConfig),
154
171
  feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
155
- codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
172
+ codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
156
173
  });
157
174
 
158
175
  // 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
@@ -170,24 +187,24 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
170
187
  expect(APP_ID).toBe("");
171
188
  expect(APP_SECRET).toBe("");
172
189
  expect(config.feishu.appId).toBe("");
173
- });
174
- });
175
-
176
- describe("resolveDefaultAgentTool", () => {
177
- it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
178
- const cfg = structuredClone(baseAppConfig);
179
- cfg.claude.defaultAgent = false;
180
- cfg.cursor.defaultAgent = true;
181
-
182
- expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
183
- });
184
-
185
- it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
186
- const cfg = structuredClone(baseAppConfig);
187
- cfg.claude.enabled = false;
188
- cfg.claude.defaultAgent = true;
189
- cfg.cursor.defaultAgent = false;
190
-
191
- expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
192
- });
193
- });
190
+ });
191
+ });
192
+
193
+ describe("resolveDefaultAgentTool", () => {
194
+ it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
195
+ const cfg = structuredClone(baseAppConfig);
196
+ cfg.claude.defaultAgent = false;
197
+ cfg.cursor.defaultAgent = true;
198
+
199
+ expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
200
+ });
201
+
202
+ it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
203
+ const cfg = structuredClone(baseAppConfig);
204
+ cfg.claude.enabled = false;
205
+ cfg.claude.defaultAgent = true;
206
+ cfg.cursor.defaultAgent = false;
207
+
208
+ expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
209
+ });
210
+ });
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ describe("config.sample.json", () => {
7
+ it("enables Feishu and WeChat iLink by default", () => {
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
+ };
15
+
16
+ expect(sample.platforms?.feishu?.enabled).toBe(true);
17
+ expect(sample.platforms?.ilink?.enabled).toBe(true);
18
+ });
19
+ });
@@ -38,6 +38,9 @@ import {
38
38
  _resetSessionToolsFileForTest,
39
39
  _setAdapterForToolForTest,
40
40
  _clearAdapterCacheForTest,
41
+ setSessionPlatform,
42
+ recordChatPlatform,
43
+ _getPlatformForChatForTest,
41
44
  } from "../session.ts";
42
45
  import {
43
46
  activePrompts,
@@ -52,6 +55,7 @@ import {
52
55
  } from "../session-chat-binding.ts";
53
56
  import type { AccumulatorState } from "../session.ts";
54
57
  import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
58
+ import type { PlatformAdapter } from "../platform-adapter.ts";
55
59
 
56
60
  // Helper to create a mock active session entry
57
61
  function mockActiveSession(chatId: string, overrides: Partial<{
@@ -116,6 +120,23 @@ function mockAdapter(getInfo: (sid: string) => SessionInfo | undefined): ToolAda
116
120
  };
117
121
  }
118
122
 
123
+ function mockPlatform(name: string): PlatformAdapter {
124
+ return {
125
+ sendText: vi.fn(async () => true),
126
+ sendCard: vi.fn(async () => true),
127
+ sendRawCard: vi.fn(async () => true),
128
+ createGroup: vi.fn(async () => `${name}-group`),
129
+ updateChatInfo: vi.fn(async () => {}),
130
+ getChatInfo: vi.fn(async () => ({ name, description: "" })),
131
+ disbandChat: vi.fn(async () => {}),
132
+ setChatAvatar: vi.fn(async () => {}),
133
+ extractSessionInfo: vi.fn(() => null),
134
+ cardCreate: vi.fn(async () => `${name}-card`),
135
+ cardSend: vi.fn(async () => `${name}-message`),
136
+ cardUpdate: vi.fn(async () => {}),
137
+ };
138
+ }
139
+
119
140
  describe("resetState", () => {
120
141
  it("clears all maps and sets", () => {
121
142
  chatSessionMap.set("chat1", {
@@ -137,6 +158,23 @@ describe("resetState", () => {
137
158
  });
138
159
  });
139
160
 
161
+ describe("chat platform routing", () => {
162
+ beforeEach(() => {
163
+ resetState();
164
+ });
165
+
166
+ it("uses the platform recorded for the chat before falling back to the default platform", () => {
167
+ const feishu = mockPlatform("feishu");
168
+ const wechat = mockPlatform("wechat");
169
+
170
+ setSessionPlatform(feishu);
171
+ recordChatPlatform("wx-chat", wechat);
172
+
173
+ expect(_getPlatformForChatForTest("wx-chat")).toBe(wechat);
174
+ expect(_getPlatformForChatForTest("feishu-chat")).toBe(feishu);
175
+ });
176
+ });
177
+
140
178
  // ---------------------------------------------------------------------------
141
179
  // rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
142
180
  //
@@ -0,0 +1,32 @@
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 () => {
8
+ const wire = {
9
+ push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
10
+ sendText: vi.fn(
11
+ async (_chatId: string, _text: string, _contextToken?: string) =>
12
+ "msg-id",
13
+ ),
14
+ };
15
+ const log = vi.fn();
16
+ const platform = createWechatAdapter({
17
+ getWire: () => wire,
18
+ log,
19
+ });
20
+
21
+ const ok = await platform.sendRawCard("wx-chat-help", buildHelpCard("Hello"));
22
+
23
+ expect(ok).toBe(true);
24
+ expect(wire.push).toHaveBeenCalledTimes(1);
25
+ expect(wire.sendText).not.toHaveBeenCalled();
26
+ const [, text] = wire.push.mock.calls[0];
27
+ expect(text).toContain("# ChatCCC");
28
+ expect(text).toContain("Hello");
29
+ expect(text).toContain("/new");
30
+ expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
31
+ });
32
+ });
@@ -89,11 +89,32 @@ function buildSdkEnv(
89
89
  if (!apiKeyTrim && !baseUrlTrim) return undefined;
90
90
 
91
91
  const env: Record<string, string | undefined> = { ...process.env };
92
+ // ChatCCC's Claude API config is authoritative when present. Remove Claude
93
+ // Code/user settings env that can silently override gateway/auth/model choice.
94
+ delete env.ANTHROPIC_AUTH_TOKEN;
95
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
96
+ delete env.ANTHROPIC_MODEL;
97
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
98
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
99
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
100
+ delete env.CLAUDE_CODE_SUBAGENT_MODEL;
101
+ delete env.CLAUDE_CODE_EFFORT_LEVEL;
102
+
92
103
  if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
93
104
  if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
105
+ else delete env.ANTHROPIC_BASE_URL;
94
106
  return env;
95
107
  }
96
108
 
109
+ function resolveSettingSources(
110
+ _apiKey: string | undefined,
111
+ _baseUrl: string | undefined,
112
+ ): Array<"project" | "local"> {
113
+ // CLAUDE.md / CLAUDE.local.md 是 Agent 指令文件,与 API 来源无关,
114
+ // 无论使用官方 Anthropic 还是第三方网关都应加载。
115
+ return ["project", "local"];
116
+ }
117
+
97
118
  // ---------------------------------------------------------------------------
98
119
  // buildSessionOptions — 还原 claudeSdkSessionOptions 的精确行为
99
120
  // ---------------------------------------------------------------------------
@@ -111,7 +132,7 @@ function buildSessionOptions(
111
132
  permissionMode: "bypassPermissions",
112
133
  allowDangerouslySkipPermissions: true,
113
134
  autoCompactEnabled: true,
114
- settingSources: ["project", "local"],
135
+ settingSources: resolveSettingSources(apiKey, baseUrl),
115
136
  };
116
137
  if (!isEmpty(model)) o.model = model;
117
138
  if (!isEmpty(effort)) o.effort = effort;
@@ -304,4 +325,4 @@ export function createClaudeAdapter(
304
325
  options: ClaudeAdapterOptions,
305
326
  ): ToolAdapter {
306
327
  return new ClaudeAdapter(options);
307
- }
328
+ }