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.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * wechat-platform.ts — WeChat iLink 平台适配器
3
+ *
4
+ * 基于 @openilink/openilink-sdk-node,提供:
5
+ * - QR 登录(每次启动强制显示新 QR)
6
+ * - 长轮询消息接收
7
+ * - PlatformAdapter 实现(纯文本、不支持卡片/群管理)
8
+ * - 自动重连
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { dirname, join } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ import {
17
+ Client as OpenIlinkWire,
18
+ extractText,
19
+ type GetUpdatesResponse,
20
+ type WeixinMessage,
21
+ } from "@openilink/openilink-sdk-node";
22
+
23
+ import type { PlatformAdapter } from "./platform-adapter.ts";
24
+ import { setupFileLogging } from "./shared.ts";
25
+ import { appendChatLog } from "./config.ts";
26
+ import { cardJsonToPlainText } from "./card-plain-text.ts";
27
+
28
+ interface TerminalQrRenderer {
29
+ generate(
30
+ input: string,
31
+ opts: { small: boolean },
32
+ callback: (qrcode: string) => void,
33
+ ): void;
34
+ }
35
+
36
+ interface IlinkSnapshot {
37
+ token?: string;
38
+ baseUrl?: string;
39
+ pollCursor?: string;
40
+ botId?: string;
41
+ userId?: string;
42
+ lastSeenAt?: string;
43
+ lastChatId?: string;
44
+ contextToken?: string;
45
+ }
46
+
47
+ const USER_DATA_DIR = join(homedir(), ".chatccc");
48
+ const ILINK_AUTH_PATH = join(USER_DATA_DIR, "state", "ilink-auth.json");
49
+ const ILINK_LOG_DIR = join(USER_DATA_DIR, "logs");
50
+
51
+ const requireFromWechat = createRequire(import.meta.url);
52
+ const terminalQr = requireFromWechat("qrcode-terminal") as TerminalQrRenderer;
53
+
54
+ const { logPath: WECHAT_LOG_PATH } =
55
+ setupFileLogging(ILINK_LOG_DIR, "wechat");
56
+
57
+ let ilinkWire: OpenIlinkWire | null = null;
58
+ /** chatId → 最新 context_token */
59
+ const contextTokenMap = new Map<string, string>();
60
+ /** chatId → 用户未回复时已连发消息数 */
61
+ const consecutiveSendCount = new Map<string, number>();
62
+ const textCardMap = new Map<string, { chatId?: string; text: string; lastSentText: string; lastSentAt: number }>();
63
+ let textCardSeq = 0;
64
+ let platformLog: (msg: string) => void = () => {};
65
+
66
+ const TEXT_CARD_UPDATE_INTERVAL_MS = 30_000;
67
+
68
+ type WechatWireSender = Pick<OpenIlinkWire, "sendText" | "push">;
69
+
70
+ export interface WechatAdapterOptions {
71
+ getWire?: () => WechatWireSender | null;
72
+ log?: (msg: string) => void;
73
+ }
74
+
75
+ function isTerminalCardText(text: string): boolean {
76
+ return text.startsWith("# 完成") || text.startsWith("# 已停止");
77
+ }
78
+
79
+ function isFinalReplyText(text: string): boolean {
80
+ return text.includes("━━━ 回答结束 ━━━");
81
+ }
82
+
83
+ function compressGeneratingText(text: string): string {
84
+ const lines = text.split("\n");
85
+ if (lines.length <= 10) return text;
86
+ return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Snapshot 持久化
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function ensureParentDir(filePath: string): void {
94
+ mkdirSync(dirname(filePath), { recursive: true });
95
+ }
96
+
97
+ function readSnapshot(): IlinkSnapshot {
98
+ if (!existsSync(ILINK_AUTH_PATH)) return {};
99
+ try {
100
+ return JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8")) as IlinkSnapshot;
101
+ } catch {
102
+ return {};
103
+ }
104
+ }
105
+
106
+ function writeSnapshot(snapshot: IlinkSnapshot): void {
107
+ ensureParentDir(ILINK_AUTH_PATH);
108
+ writeFileSync(ILINK_AUTH_PATH, `${JSON.stringify(snapshot, null, 2)}\n`);
109
+ }
110
+
111
+ function updateSnapshot(patch: Partial<IlinkSnapshot>): IlinkSnapshot {
112
+ const next = { ...readSnapshot(), ...patch, lastSeenAt: new Date().toISOString() };
113
+ writeSnapshot(next);
114
+ return next;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // QR 终端输出
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function printScanMaterial(content: string): void {
122
+ platformLog("\n========== 微信 iLink 扫码内容 ==========");
123
+ platformLog(content);
124
+ platformLog("========== 终端二维码 ==========");
125
+ terminalQr.generate(content, { small: true }, (renderedQr) => {
126
+ platformLog(renderedQr);
127
+ });
128
+ platformLog("========== 请使用微信扫描上方二维码登录 ==========\n");
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // WeChat PlatformAdapter 工厂
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export function createWechatAdapter(
136
+ options: WechatAdapterOptions = {},
137
+ ): PlatformAdapter {
138
+ const getWire = options.getWire ?? (() => ilinkWire);
139
+ const log = options.log ?? ((msg: string) => platformLog(msg));
140
+
141
+ return {
142
+ kind: "wechat",
143
+
144
+ // ---- 基础消息 ----
145
+ async sendText(chatId, text) {
146
+ const wire = getWire();
147
+ if (!wire) return false;
148
+
149
+ // 微信 claw 连发限制:统计用户未回复时已连发条数
150
+ const count = (consecutiveSendCount.get(chatId) ?? 0) + 1;
151
+ consecutiveSendCount.set(chatId, count);
152
+
153
+ const isFinal = isFinalReplyText(text);
154
+
155
+ // 第10条且非最终消息:附加 claw 限制提示
156
+ if (count === 10 && !isFinal) {
157
+ text = text + "\n━━━━━━━━━━━━━━━━━━━━\n后台工作中,由于微信claw机制限制,请唤醒我才能继续发送消息";
158
+ }
159
+
160
+ // 超过10条后非最终消息不再发送(claw 限制)
161
+ if (count > 10 && !isFinal) {
162
+ log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
163
+ return true;
164
+ }
165
+
166
+ try {
167
+ const contextToken = contextTokenMap.get(chatId);
168
+ if (contextToken) {
169
+ await wire.sendText(chatId, text, contextToken);
170
+ } else {
171
+ await wire.push(chatId, text);
172
+ }
173
+ const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
174
+ log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
175
+ return true;
176
+ } catch (err) {
177
+ log(`[WECHAT] sendText failed: ${(err as Error).message}`);
178
+ return false;
179
+ }
180
+ },
181
+
182
+ async sendCard(chatId, title, content, _template) {
183
+ // WeChat has no card renderer here; send the same message as plain text.
184
+ const text = [title.trim(), content.trim()].filter(Boolean).join("\n\n");
185
+ return this.sendText(chatId, text);
186
+ },
187
+
188
+ async sendRawCard(chatId, cardJson) {
189
+ const text = cardJsonToPlainText(cardJson);
190
+ if (!text) {
191
+ log(`[WECHAT] sendRawCard text fallback failed: empty card text`);
192
+ return false;
193
+ }
194
+ log(`[WECHAT] sendRawCard degraded to text`);
195
+ return this.sendText(chatId, text);
196
+ },
197
+
198
+ // ---- 群聊管理 ----
199
+ async createGroup(_name, _userIds) {
200
+ throw new Error("微信不支持创建群聊");
201
+ },
202
+
203
+ async updateChatInfo(_chatId, _name, _description) {
204
+ // 微信不支持修改群信息,静默成功
205
+ },
206
+
207
+ async getChatInfo(_chatId) {
208
+ return { name: "微信会话", description: "" };
209
+ },
210
+
211
+ async disbandChat(_chatId) {
212
+ // 微信不支持解散群聊
213
+ },
214
+
215
+ async setChatAvatar(_chatId, _tool, _status) {
216
+ // 微信不支持设置头像
217
+ },
218
+
219
+ extractSessionInfo(_description) {
220
+ // 微信没有群描述机制,session 绑定走 session-registry
221
+ return null;
222
+ },
223
+
224
+ // ---- 进度展示(微信无卡片,降级为文本) ----
225
+ async cardCreate(cardJson) {
226
+ const text = cardJsonToPlainText(cardJson);
227
+ if (!text) {
228
+ log(`[WECHAT] cardCreate text fallback failed: empty card text`);
229
+ return null;
230
+ }
231
+ const cardId = `wechat-text-card-${Date.now()}-${++textCardSeq}`;
232
+ textCardMap.set(cardId, {
233
+ text,
234
+ lastSentText: "",
235
+ lastSentAt: 0,
236
+ });
237
+ log(`[WECHAT] cardCreate degraded to text card: ${cardId}`);
238
+ return cardId;
239
+ },
240
+
241
+ async cardSend(chatId, cardId) {
242
+ const entry = textCardMap.get(cardId);
243
+ if (!entry) {
244
+ log(`[WECHAT] cardSend text fallback failed: missing card ${cardId}`);
245
+ return "";
246
+ }
247
+ entry.chatId = chatId;
248
+ const compressed = compressGeneratingText(entry.text);
249
+ const ok = await this.sendText(chatId, compressed);
250
+ if (!ok) return "";
251
+ entry.lastSentText = entry.text;
252
+ entry.lastSentAt = Date.now();
253
+ log(`[WECHAT] cardSend degraded to text: ${cardId}`);
254
+ return `wechat-text-message-${cardId}`;
255
+ },
256
+
257
+ async cardUpdate(cardId, cardJson, _sequence) {
258
+ const entry = textCardMap.get(cardId);
259
+ if (!entry || !entry.chatId) {
260
+ log(`[WECHAT] cardUpdate text fallback skipped: missing sent card ${cardId}`);
261
+ return;
262
+ }
263
+ const text = cardJsonToPlainText(cardJson);
264
+ if (!text || text === entry.lastSentText) return;
265
+
266
+ const now = Date.now();
267
+ const terminal = isTerminalCardText(text);
268
+ if (!terminal && now - entry.lastSentAt < TEXT_CARD_UPDATE_INTERVAL_MS) {
269
+ entry.text = text;
270
+ return;
271
+ }
272
+
273
+ // 非终端卡片(生成中):只发送新增部分(delta),不重发已发送的内容
274
+ let sendText: string;
275
+ if (!terminal && entry.lastSentText && text.startsWith(entry.lastSentText)) {
276
+ sendText = text.slice(entry.lastSentText.length).trim();
277
+ if (!sendText) {
278
+ entry.text = text;
279
+ return;
280
+ }
281
+ } else {
282
+ sendText = text;
283
+ }
284
+
285
+ entry.text = text;
286
+ const compressed = compressGeneratingText(sendText);
287
+ const ok = await this.sendText(entry.chatId, compressed);
288
+ if (!ok) return;
289
+ entry.lastSentText = text;
290
+ entry.lastSentAt = now;
291
+ if (terminal) textCardMap.delete(cardId);
292
+ log(`[WECHAT] cardUpdate degraded to text: ${cardId}`);
293
+ },
294
+ };
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // 消息接收与路由
299
+ // ---------------------------------------------------------------------------
300
+
301
+ type MessageHandler = (
302
+ text: string,
303
+ chatId: string,
304
+ openId: string,
305
+ msgTimestamp: number,
306
+ chatType: string,
307
+ traceId?: string,
308
+ ) => Promise<void>;
309
+
310
+ /**
311
+ * 启动微信 iLink 平台。
312
+ *
313
+ * 若 reuseTokenOnStart 为 true 且上次保存的 token 仍然有效,则跳过 QR 直接复用;
314
+ * 否则显示 QR 码等待扫码登录。
315
+ * 接收到的消息统一交给 handler 处理(即 orchestrator.handleCommand)。
316
+ *
317
+ * 返回永不 resolve(除非主动停止或会话过期),由调用方 supervisor 管理。
318
+ */
319
+ export async function startWechatPlatform(
320
+ handler: MessageHandler,
321
+ signal: { stopped: boolean },
322
+ reuseTokenOnStart: boolean,
323
+ ): Promise<void> {
324
+ platformLog = (msg: string) => {
325
+ console.log(`[WX:${new Date().toISOString().slice(0, 19).replace("T", " ")}] ${msg}`);
326
+ };
327
+
328
+ platformLog(
329
+ `日志文件: ${WECHAT_LOG_PATH}`,
330
+ );
331
+
332
+ const saved = readSnapshot();
333
+ platformLog(`上次登录: ${saved.lastSeenAt ?? "无记录"}`);
334
+
335
+ // 尝试复用已保存的 token
336
+ if (reuseTokenOnStart && saved.token && saved.baseUrl) {
337
+ platformLog("检测到已保存的 token,尝试复用...");
338
+ ilinkWire = new OpenIlinkWire(saved.token, {
339
+ base_url: saved.baseUrl,
340
+ });
341
+
342
+ try {
343
+ const probeResp = await ilinkWire.getUpdates(saved.pollCursor ?? "", 5000);
344
+ if (probeResp.ret === 0 || probeResp.errcode === 0 || probeResp.msgs !== undefined) {
345
+ platformLog("token 有效,跳过扫码。");
346
+ updateSnapshot({ lastSeenAt: new Date().toISOString() });
347
+ const latest = readSnapshot();
348
+ // 恢复 context token 到内存
349
+ if (latest.contextToken && latest.lastChatId) {
350
+ contextTokenMap.set(latest.lastChatId, latest.contextToken);
351
+ }
352
+ await ilinkWire.monitor(
353
+ async (message: WeixinMessage) => {
354
+ try {
355
+ await handleWechatMessage(message, handler);
356
+ } catch (error) {
357
+ platformLog(
358
+ `消息处理失败: ${(error as Error).stack ?? (error as Error).message}`,
359
+ );
360
+ }
361
+ },
362
+ {
363
+ initial_buf: probeResp.sync_buf ?? probeResp.get_updates_buf ?? latest.pollCursor ?? "",
364
+ on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
365
+ on_response: (response: GetUpdatesResponse) => {
366
+ const pollCursor =
367
+ response.sync_buf ?? response.get_updates_buf;
368
+ if (pollCursor) {
369
+ updateSnapshot({ pollCursor });
370
+ }
371
+ },
372
+ on_error: (error) => {
373
+ platformLog(
374
+ `iLink 监听错误: ${error.stack ?? error.message}`,
375
+ );
376
+ },
377
+ on_session_expired: () => {
378
+ platformLog("微信 iLink 会话已过期,需要重新扫码登录。");
379
+ signal.stopped = true;
380
+ },
381
+ should_continue: () => !signal.stopped,
382
+ },
383
+ );
384
+ return;
385
+ }
386
+ platformLog(`token 验证失败 (ret=${probeResp.ret} errcode=${probeResp.errcode}),回退到扫码登录。`);
387
+ } catch (err) {
388
+ platformLog(`token 探测失败: ${(err as Error).message},回退到扫码登录。`);
389
+ }
390
+ }
391
+
392
+ // 请求新 QR
393
+ platformLog("启动微信 iLink QR 登录...");
394
+ ilinkWire = new OpenIlinkWire("", {
395
+ base_url: saved.baseUrl,
396
+ });
397
+
398
+ const loginResult = await ilinkWire.loginWithQr({
399
+ on_qrcode: (content) => {
400
+ printScanMaterial(content);
401
+ },
402
+ on_scanned: () => {
403
+ platformLog("QR 已扫描,请在微信中确认登录。");
404
+ },
405
+ on_expired: (attempt, maxAttempts) => {
406
+ platformLog(`QR 已过期,正在刷新 (${attempt}/${maxAttempts})。`);
407
+ },
408
+ });
409
+
410
+ if (!loginResult.connected) {
411
+ throw new Error(`微信 iLink QR 登录失败: ${loginResult.message}`);
412
+ }
413
+
414
+ updateSnapshot({
415
+ token: loginResult.bot_token ?? ilinkWire.token,
416
+ baseUrl: loginResult.base_url ?? ilinkWire.baseUrl,
417
+ botId: loginResult.bot_id,
418
+ userId: loginResult.user_id,
419
+ pollCursor: "",
420
+ });
421
+
422
+ platformLog(
423
+ `微信 iLink 登录成功。BotID=${loginResult.bot_id ?? ""} UserID=${loginResult.user_id ?? ""}`,
424
+ );
425
+
426
+ // 监听消息
427
+ const latest = readSnapshot();
428
+ // 恢复 context token 到内存
429
+ if (latest.contextToken && latest.lastChatId) {
430
+ contextTokenMap.set(latest.lastChatId, latest.contextToken);
431
+ }
432
+
433
+ await ilinkWire.monitor(
434
+ async (message: WeixinMessage) => {
435
+ try {
436
+ await handleWechatMessage(message, handler);
437
+ } catch (error) {
438
+ platformLog(
439
+ `消息处理失败: ${(error as Error).stack ?? (error as Error).message}`,
440
+ );
441
+ }
442
+ },
443
+ {
444
+ initial_buf: latest.pollCursor ?? "",
445
+ on_buf_update: (pollCursor) => updateSnapshot({ pollCursor }),
446
+ on_response: (response: GetUpdatesResponse) => {
447
+ const pollCursor =
448
+ response.sync_buf ?? response.get_updates_buf;
449
+ if (pollCursor) {
450
+ updateSnapshot({ pollCursor });
451
+ }
452
+ },
453
+ on_error: (error) => {
454
+ platformLog(
455
+ `iLink 监听错误: ${error.stack ?? error.message}`,
456
+ );
457
+ },
458
+ on_session_expired: () => {
459
+ platformLog("微信 iLink 会话已过期,需要重新扫码登录。");
460
+ signal.stopped = true;
461
+ },
462
+ should_continue: () => !signal.stopped,
463
+ },
464
+ );
465
+ }
466
+
467
+ async function handleWechatMessage(
468
+ message: WeixinMessage,
469
+ handler: MessageHandler,
470
+ ): Promise<void> {
471
+ const chatId = String(message.from_user_id ?? "");
472
+ if (!chatId) {
473
+ platformLog("跳过无 from_user_id 的消息");
474
+ return;
475
+ }
476
+
477
+ // 保存 lastChatId 供下次启动时发送通知
478
+ const current = readSnapshot();
479
+ if (current.lastChatId !== chatId) {
480
+ updateSnapshot({ lastChatId: chatId });
481
+ }
482
+
483
+ // 保存 context_token 到 snapshot(供重启后启动通知使用)和内存
484
+ if (message.context_token) {
485
+ contextTokenMap.set(chatId, message.context_token);
486
+ updateSnapshot({ contextToken: message.context_token, lastChatId: chatId });
487
+ }
488
+
489
+ const text = extractText(message).trim();
490
+ const msgTimestamp = message.create_time_ms ?? Date.now();
491
+
492
+ platformLog(
493
+ `收到消息: chatId=${chatId} text="${text.slice(0, 80)}"`,
494
+ );
495
+ appendChatLog(chatId, chatId, text);
496
+
497
+ // 用户回复,重置 claw 连发计数
498
+ consecutiveSendCount.set(chatId, 0);
499
+
500
+ // 非指令消息:立即发送"生成中...",让用户知道消息已收到并正在处理
501
+ if (text && !text.startsWith("/") && ilinkWire) {
502
+ const ctxToken = contextTokenMap.get(chatId);
503
+ if (ctxToken) {
504
+ ilinkWire.sendText(chatId, "生成中...", ctxToken).catch(() => {});
505
+ } else {
506
+ ilinkWire.push(chatId, "生成中...").catch(() => {});
507
+ }
508
+ // "生成中..." 计为第1条连发消息
509
+ consecutiveSendCount.set(chatId, 1);
510
+ }
511
+
512
+ // WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
513
+ // 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
514
+ handler(text, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
515
+ platformLog(`消息处理失败: ${(err as Error).stack ?? (err as Error).message}`);
516
+ });
517
+ }