chatccc 0.2.60 → 0.2.61

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.
@@ -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": "",
17
+ "subagentModel": "",
18
+ "effort": "",
19
+ "apiKey": "",
20
+ "baseUrl": ""
21
21
  },
22
22
  "cursor": {
23
23
  "enabled": false,
package/package.json CHANGED
@@ -1,59 +1,59 @@
1
- {
2
- "name": "chatccc",
3
- "version": "0.2.60",
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.61",
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,18 +1,19 @@
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 () => {
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { buildHelpCard } from "../cards.ts";
4
+ import {
5
+ _resetWechatClawStateForTest,
6
+ _setWxMinSendIntervalMsForTest,
7
+ createWechatAdapter,
8
+ } from "../wechat-platform.ts";
9
+
10
+ describe("createWechatAdapter", () => {
11
+ beforeEach(() => {
12
+ _resetWechatClawStateForTest();
13
+ _setWxMinSendIntervalMsForTest(0); // 测试中禁用发送间隔限制
14
+ });
15
+
16
+ it("degrades raw cards to plain text messages", async () => {
16
17
  const wire = {
17
18
  push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
18
19
  sendText: vi.fn(
@@ -38,7 +39,7 @@ describe("createWechatAdapter", () => {
38
39
  expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
39
40
  });
40
41
 
41
- it("reports unsent non-final messages after the claw limit", async () => {
42
+ it("appends claw suffix on 9th non-final message and blocks further non-final messages", async () => {
42
43
  const wire = {
43
44
  push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
44
45
  sendText: vi.fn(
@@ -52,48 +53,59 @@ describe("createWechatAdapter", () => {
52
53
  log,
53
54
  });
54
55
 
55
- for (let i = 0; i < 10; i++) {
56
+ // 前8条正常发送
57
+ for (let i = 0; i < 8; i++) {
56
58
  await expect(platform.sendText("wx-chat-limit", `chunk ${i}`)).resolves.toBe(true);
57
59
  }
58
60
 
59
- await expect(platform.sendText("wx-chat-limit", "chunk 10")).resolves.toBe(false);
61
+ // 第9条非最终消息:应附加 claw 后缀
62
+ await expect(platform.sendText("wx-chat-limit", "chunk 8")).resolves.toBe(true);
63
+
64
+ // 第10条起非最终消息被阻止
65
+ await expect(platform.sendText("wx-chat-limit", "chunk 9")).resolves.toBe(false);
66
+
67
+ expect(wire.push).toHaveBeenCalledTimes(9);
68
+ // 验证第9条附带了 claw 后缀
69
+ const ninthCall = wire.push.mock.calls[8];
70
+ expect(ninthCall[1]).toContain("chunk 8");
71
+ expect(ninthCall[1]).toContain("由于微信claw机制限制,不再发送过程,稍后把最终结果发送给你");
72
+ expect(log).toHaveBeenCalledWith(
73
+ "[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=10",
74
+ );
75
+ });
76
+
77
+ it("allows final messages through after the 9th message claw limit", async () => {
78
+ const wire = {
79
+ push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
80
+ sendText: vi.fn(
81
+ async (_chatId: string, _text: string, _contextToken?: string) =>
82
+ "msg-id",
83
+ ),
84
+ };
85
+ const log = vi.fn();
86
+ const platform = createWechatAdapter({
87
+ getWire: () => wire,
88
+ log,
89
+ });
90
+ const chatId = "wx-chat-final-allow";
91
+ const finalText = "done\n━━━ 回答结束 ━━━";
92
+
93
+ // 前8条正常发送
94
+ for (let i = 0; i < 8; i++) {
95
+ await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
96
+ }
97
+
98
+ // 第9条非最终消息带后缀
99
+ await expect(platform.sendText(chatId, "chunk 8")).resolves.toBe(true);
100
+
101
+ // 第10条:最终消息应允许发送(不被阻止也不被 queuing)
102
+ await expect(platform.sendText(chatId, finalText)).resolves.toBe(true);
103
+
60
104
  expect(wire.push).toHaveBeenCalledTimes(10);
105
+ const lastCall = wire.push.mock.calls[9];
106
+ expect(lastCall[1]).toBe(finalText);
61
107
  expect(log).toHaveBeenCalledWith(
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
- });
108
+ expect.stringContaining("[WECHAT] sendText OK"),
109
+ );
110
+ });
111
+ });
package/src/session.ts CHANGED
@@ -30,6 +30,13 @@ import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
30
30
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
31
31
  import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
32
32
  import type { PlatformAdapter } from "./platform-adapter.ts";
33
+
34
+ // 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
35
+ function compressWechatDisplayText(text: string): string {
36
+ const lines = text.split("\n");
37
+ if (lines.length <= 10) return text;
38
+ return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
39
+ }
33
40
  import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
34
41
  import {
35
42
  bindChatToSession,
@@ -1015,7 +1022,7 @@ export function ensureDisplayLoop(sessionId: string): void {
1015
1022
 
1016
1023
  d.cardBusy = true;
1017
1024
  try {
1018
- const ok = await p.sendText(chatId, delta);
1025
+ const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1019
1026
  if (ok) {
1020
1027
  d.lastSentAccLen = state.accumulatedContent.length;
1021
1028
  d.lastSentFinalReply = state.finalReply;
@@ -66,14 +66,20 @@ export function getIlinkWire(): OpenIlinkWire | null {
66
66
  const contextTokenMap = new Map<string, string>();
67
67
  /** chatId → 用户未回复时已连发消息数 */
68
68
  const consecutiveSendCount = new Map<string, number>();
69
- /** chatId → claw 限制后等待用户唤醒再补发的最终消息 */
70
- const pendingClawFinalText = new Map<string, string>();
69
+ /** chatId → 上一次 sendText 成功的时间戳 */
70
+ const lastSendTimeMap = new Map<string, number>();
71
71
  const textCardMap = new Map<string, { chatId?: string; text: string; lastSentText: string; lastSentAt: number }>();
72
72
  let textCardSeq = 0;
73
73
  let platformLog: (msg: string) => void = () => {};
74
74
 
75
75
  const TEXT_CARD_UPDATE_INTERVAL_MS = 30_000;
76
76
 
77
+ /** 微信连发间隔阈值:第5条起限制 ≥ 此值(默认10秒),测试可覆写 */
78
+ let wxMinSendIntervalMs = 10_000;
79
+ export function _setWxMinSendIntervalMsForTest(ms: number): void {
80
+ wxMinSendIntervalMs = ms;
81
+ }
82
+
77
83
  type WechatWireSender = Pick<OpenIlinkWire, "sendText" | "push">;
78
84
 
79
85
  export interface WechatAdapterOptions {
@@ -89,7 +95,7 @@ function isFinalReplyText(text: string): boolean {
89
95
  return text.includes("━━━ 回答结束 ━━━");
90
96
  }
91
97
 
92
- function compressGeneratingText(text: string): string {
98
+ export function compressGeneratingText(text: string): string {
93
99
  const lines = text.split("\n");
94
100
  if (lines.length <= 10) return text;
95
101
  return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
@@ -104,38 +110,10 @@ async function sendWechatTextRaw(wire: WechatWireSender, chatId: string, text: s
104
110
  }
105
111
  }
106
112
 
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
113
  export function _resetWechatClawStateForTest(): void {
128
114
  consecutiveSendCount.clear();
129
- pendingClawFinalText.clear();
130
115
  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);
116
+ lastSendTimeMap.clear();
139
117
  }
140
118
 
141
119
  // ---------------------------------------------------------------------------
@@ -206,29 +184,31 @@ export function createWechatAdapter(
206
184
 
207
185
  const isFinal = isFinalReplyText(text);
208
186
 
209
- // 第10条且非最终消息:附加 claw 限制提示
210
- if (count === 10 && !isFinal) {
211
- text = text + "\n━━ 后台工作中,由于微信claw机制限制,请唤醒我才能继续发送消息";
187
+ // 第9条且非最终消息:附加 claw 限制提示,此后禁止继续发送生成消息
188
+ if (count === 9 && !isFinal) {
189
+ text = text + "\n---由于微信claw机制限制,不再发送过程,稍后把最终结果发送给你---";
190
+ }
191
+
192
+ // 超过9条后不允许发送非最终消息(生成消息),最终结果消息仍然允许
193
+ if (count > 9 && !isFinal) {
194
+ log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
195
+ return false;
212
196
  }
213
197
 
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;
198
+ // 第5条起限制发送间隔 10 秒(前4条保持原有 3s 显示循环节奏)
199
+ if (count > 4) {
200
+ const lastSentAt = lastSendTimeMap.get(chatId) ?? 0;
201
+ const elapsed = Date.now() - lastSentAt;
202
+ if (elapsed < wxMinSendIntervalMs) {
203
+ await new Promise((r) => setTimeout(r, wxMinSendIntervalMs - elapsed));
223
204
  }
224
205
  }
225
206
 
226
207
  try {
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}"`);
208
+ await sendWechatTextRaw(wire, chatId, text);
209
+ lastSendTimeMap.set(chatId, Date.now());
210
+ const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
211
+ log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
232
212
  return true;
233
213
  } catch (err) {
234
214
  log(`[WECHAT] sendText failed: ${(err as Error).message}`);
@@ -621,12 +601,6 @@ async function handleWechatMessage(
621
601
  // 用户回复,重置 claw 连发计数
622
602
  consecutiveSendCount.set(chatId, 0);
623
603
 
624
- // 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
625
- if (pendingClawFinalText.has(chatId)) {
626
- await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
627
- return;
628
- }
629
-
630
604
  // WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
631
605
  // 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
632
606
  handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {