chatccc 0.2.51 → 0.2.53

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.
Files changed (48) hide show
  1. package/README.md +103 -217
  2. package/bin/chatccc.mjs +23 -23
  3. package/config.sample.json +34 -30
  4. package/demo/ilink_echo_probe.ts +222 -222
  5. package/im-skills/feishu-skill/download-video.mjs +162 -162
  6. package/im-skills/feishu-skill/receive-send-file.md +66 -66
  7. package/im-skills/feishu-skill/receive-send-image.md +27 -27
  8. package/im-skills/feishu-skill/send-file.mjs +50 -50
  9. package/im-skills/feishu-skill/send-image.mjs +50 -50
  10. package/im-skills/feishu-skill/skill.md +10 -10
  11. package/package.json +59 -59
  12. package/src/__tests__/agent-image-rpc.test.ts +33 -33
  13. package/src/__tests__/agent-rpc-body.test.ts +41 -41
  14. package/src/__tests__/card-plain-text.test.ts +42 -0
  15. package/src/__tests__/claude-adapter.test.ts +54 -1
  16. package/src/__tests__/codex-adapter.test.ts +304 -304
  17. package/src/__tests__/config-reload.test.ts +58 -41
  18. package/src/__tests__/config-sample.test.ts +19 -0
  19. package/src/__tests__/crash-logging.test.ts +62 -62
  20. package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
  21. package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
  22. package/src/__tests__/im-skills.test.ts +46 -46
  23. package/src/__tests__/session.test.ts +95 -2
  24. package/src/__tests__/sim-agent.test.ts +173 -173
  25. package/src/__tests__/sim-platform.test.ts +76 -76
  26. package/src/__tests__/sim-store.test.ts +213 -213
  27. package/src/__tests__/stream-state.test.ts +134 -134
  28. package/src/__tests__/wechat-platform.test.ts +57 -0
  29. package/src/adapters/claude-adapter.ts +23 -2
  30. package/src/adapters/codex-adapter.ts +294 -294
  31. package/src/adapters/codex-session-meta-store.ts +130 -130
  32. package/src/agent-file-rpc.ts +156 -156
  33. package/src/agent-image-rpc.ts +152 -152
  34. package/src/agent-rpc-body.ts +48 -48
  35. package/src/card-plain-text.ts +108 -0
  36. package/src/cards.ts +4 -4
  37. package/src/config.ts +775 -742
  38. package/src/feishu-api.ts +6 -0
  39. package/src/im-skills.ts +109 -109
  40. package/src/index.ts +155 -788
  41. package/src/orchestrator.ts +1032 -0
  42. package/src/platform-adapter.ts +61 -0
  43. package/src/session-chat-binding.ts +7 -0
  44. package/src/session.ts +278 -113
  45. package/src/sim-agent.ts +167 -156
  46. package/src/trace.ts +50 -50
  47. package/src/web-ui.ts +1891 -1718
  48. package/src/wechat-platform.ts +517 -0
package/src/index.ts CHANGED
@@ -22,10 +22,7 @@
22
22
  * npm run demo:create-group -- --local (local relay mode)
23
23
  */
24
24
 
25
- import { spawn } from "node:child_process";
26
- import { readdir, stat } from "node:fs/promises";
27
25
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
28
- import { resolve, dirname } from "node:path";
29
26
 
30
27
  import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
31
28
  import WebSocket from "ws";
@@ -37,27 +34,20 @@ import {
37
34
  CHATCCC_PORT,
38
35
  APP_ID,
39
36
  APP_SECRET,
37
+ FEISHU_ENABLED,
38
+ ILINK_ENABLED,
39
+ ILINK_REUSE_TOKEN_ON_START,
40
40
  BASE_URL,
41
- CLAUDE_EFFORT,
42
- CLAUDE_MODEL,
43
- GIT_TIMEOUT_MS,
44
- reloadConfigFromDisk,
45
- anthropicConfigDisplay,
46
41
  LOCAL_RELAY_URL,
47
42
  PID_FILE,
48
43
  PROJECT_ROOT,
49
44
  USE_LOCAL,
50
45
  USE_SIMULATE,
51
46
  appendChatLog,
52
- explainMissingFeishuCredentialsAndExit,
53
47
  fileLog,
48
+ reloadConfigFromDisk,
54
49
  reportEnvironmentVariableReadout,
55
- getDefaultCwd,
56
50
  maskAppId,
57
- setDefaultCwd,
58
- getRecentDirs,
59
- addRecentDir,
60
- sessionPrefixForTool,
61
51
  resolveDefaultAgentTool,
62
52
  toolDisplayName,
63
53
  ts,
@@ -84,56 +74,86 @@ import {
84
74
  reportPermissionResults,
85
75
  setPlatform,
86
76
  } from "./feishu-platform.ts";
87
- import { buildHelpCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
88
- import { handleAgentImageRequest } from "./agent-image-rpc.ts";
89
- import { handleAgentFileRequest } from "./agent-file-rpc.ts";
90
77
  import { SimulatedPlatform, SIM_DEFAULT_CHAT_ID } from "./sim-platform.ts";
91
78
  import { setMessageHandler } from "./sim-store.ts";
92
- import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
79
+ import { handleAgentImageRequest } from "./agent-image-rpc.ts";
80
+ import { handleAgentFileRequest } from "./agent-file-rpc.ts";
81
+ import {
82
+ createCardKitCard,
83
+ sendCardKitMessage,
84
+ updateCardKitCard,
85
+ } from "./cardkit.ts";
93
86
  import {
94
87
  MAX_PROCESSED,
95
- getSessionStatus,
96
- getAllSessionsStatus,
97
- initClaudeSession,
98
- lastMsgTimestamps,
88
+ loadSessionRegistryForBinding,
99
89
  processedMessages,
100
90
  rebuildBindingsFromRegistry,
101
91
  resetState,
102
- resumeAndPrompt,
103
- sessionInfoMap,
104
- switchChatBinding,
105
- recordSessionRegistry,
106
- getAdapterForTool,
107
- stopSession,
108
- loadSessionRegistryForBinding,
109
- removeSessionRegistryRecord,
110
- saveSessionTool,
92
+ setSessionPlatform,
111
93
  } from "./session.ts";
112
94
  import {
113
- bindChatToSession,
114
- unbindChatFromSession,
115
- getChatsForSession,
116
- isSessionRunning,
117
- activePrompts,
118
95
  rebuildSessionChatsFromRegistry,
119
- displayCards,
120
- recordLastActiveChat,
121
96
  } from "./session-chat-binding.ts";
122
97
  import { fixStaleStreamStates } from "./stream-state.ts";
98
+ import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
99
+ import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
123
100
 
124
- export function cwdDisplayName(cwd: string): string {
125
- const trimmed = cwd.trim().replace(/[\\/]+$/, "");
126
- return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
127
- }
101
+ // ---------------------------------------------------------------------------
102
+ // Feishu 平台适配器
103
+ // ---------------------------------------------------------------------------
128
104
 
129
- export function sessionChatName(left: string, cwd: string): string {
130
- return `${left}-${cwdDisplayName(cwd)}`;
131
- }
105
+ function createFeishuAdapter(): PlatformAdapter {
106
+ const auth = () => getTenantAccessToken();
107
+ return {
108
+ // ---- 基础消息 ----
109
+ async sendText(chatId, text) {
110
+ return sendTextReply(await auth(), chatId, text);
111
+ },
112
+ async sendCard(chatId, title, content, template) {
113
+ return sendCardReply(await auth(), chatId, title, content, template);
114
+ },
115
+ async sendRawCard(chatId, cardJson) {
116
+ return sendRawCard(await auth(), chatId, cardJson);
117
+ },
118
+
119
+ // ---- 群聊管理 ----
120
+ async createGroup(name, userIds) {
121
+ return createGroupChat(await auth(), name, userIds);
122
+ },
123
+ async updateChatInfo(chatId, name, description) {
124
+ return updateChatInfo(await auth(), chatId, name, description);
125
+ },
126
+ async getChatInfo(chatId) {
127
+ return getChatInfo(await auth(), chatId);
128
+ },
129
+ async disbandChat(chatId) {
130
+ return disbandChat(await auth(), chatId);
131
+ },
132
+ async setChatAvatar(chatId, tool, status) {
133
+ return setChatAvatar(await auth(), chatId, tool, status);
134
+ },
132
135
 
133
- function isUntitledSessionChatName(name: string): boolean {
134
- return name === "新会话" || name.startsWith("新会话-");
136
+ extractSessionInfo(description) {
137
+ return extractSessionInfo(description);
138
+ },
139
+
140
+ // ---- 进度展示(CardKit 委托) ----
141
+ async cardCreate(cardJson) {
142
+ return createCardKitCard(await auth(), cardJson);
143
+ },
144
+ async cardSend(chatId, cardId) {
145
+ return sendCardKitMessage(await auth(), chatId, cardId);
146
+ },
147
+ async cardUpdate(cardId, cardJson, sequence) {
148
+ return updateCardKitCard(await auth(), cardId, cardJson, sequence);
149
+ },
150
+ };
135
151
  }
136
152
 
153
+ const feishuPlatform = createFeishuAdapter();
154
+ const wechatPlatform = createWechatAdapter();
155
+ setSessionPlatform(feishuPlatform);
156
+
137
157
  // ---------------------------------------------------------------------------
138
158
  // Event types
139
159
  // ---------------------------------------------------------------------------
@@ -271,6 +291,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
271
291
  // ---------------------------------------------------------------------------
272
292
 
273
293
  let broadcastToRelay: (data: unknown) => void = () => {};
294
+ const wechatSignal = { stopped: false };
274
295
 
275
296
  // ---------------------------------------------------------------------------
276
297
  // Simulate mode: inject message via HTTP
@@ -310,7 +331,7 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
310
331
  appendChatLog(chatId, openId, text);
311
332
 
312
333
  // Fire and forget: process command, respond 202 immediately
313
- handleCommand(text, chatId, openId, Date.now(), chatType).catch((err) =>
334
+ handleCommand(feishuPlatform, text, chatId, openId, Date.now(), chatType).catch((err) =>
314
335
  console.error(`[${ts()}] [SIM:INJECT] handleCommand error: ${(err as Error).message}`)
315
336
  );
316
337
 
@@ -320,699 +341,6 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
320
341
  }
321
342
 
322
343
  // ---------------------------------------------------------------------------
323
- // Command handler
324
- // ---------------------------------------------------------------------------
325
-
326
- async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
327
- const tid = traceId ?? makeTraceId();
328
- const textLower = text.toLowerCase();
329
- if (textLower === "/restart") {
330
- logTrace(tid, "BRANCH", { cmd: "/restart" });
331
- const restartToken = await getTenantAccessToken();
332
- await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
333
- logTrace(tid, "DONE", { outcome: "restart" });
334
- console.log(`[${ts()}] [RESTART] Spawning new process...`);
335
- const child = spawn("npx", ["tsx", "src/index.ts"], {
336
- cwd: PROJECT_ROOT,
337
- detached: true,
338
- stdio: "ignore",
339
- shell: true,
340
- });
341
- child.unref();
342
- setTimeout(() => process.exit(0), 200);
343
- return;
344
- }
345
-
346
- if (textLower === "/cd" || textLower.startsWith("/cd ")) {
347
- logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
348
- const cdToken = await getTenantAccessToken();
349
- const currentDir = await getDefaultCwd(chatId);
350
-
351
- // 获取当前会话的实际工作路径(若在会话群内)
352
- let sessionCwd: string | undefined;
353
- try {
354
- const chatInfo = await getChatInfo(cdToken, chatId);
355
- const sessionInfoResult = extractSessionInfo(chatInfo.description);
356
- if (sessionInfoResult) {
357
- const adapter = getAdapterForTool(sessionInfoResult.tool);
358
- const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
359
- sessionCwd = info?.cwd;
360
- }
361
- } catch { /* 非会话群或获取失败,不显示 */ }
362
-
363
- const arg = text.slice(3).trim(); // everything after "/cd" (may be empty)
364
-
365
- // Resolve target directory
366
- let targetDir: string;
367
- if (!arg) {
368
- targetDir = currentDir;
369
- } else if (arg === "..") {
370
- targetDir = dirname(currentDir);
371
- } else {
372
- targetDir = resolve(currentDir, arg);
373
- }
374
-
375
- // Verify the target exists and is a directory
376
- try {
377
- const s = await stat(targetDir);
378
- if (!s.isDirectory()) {
379
- logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
380
- await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
381
- return;
382
- }
383
- } catch {
384
- logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
385
- await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
386
- return;
387
- }
388
-
389
- // Change working dir if user provided a path
390
- const isUpdate = !!arg && targetDir !== currentDir;
391
- if (isUpdate) {
392
- await setDefaultCwd(targetDir, chatId);
393
- await addRecentDir(targetDir);
394
- }
395
-
396
- // Read directory entries
397
- let entries: string[];
398
- try {
399
- entries = await readdir(targetDir);
400
- } catch (err) {
401
- logTrace(tid, "DONE", { outcome: "cd_readdir_fail", error: (err as Error).message });
402
- await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
403
- return;
404
- }
405
-
406
- // Sort: directories first, then files, alphabetically within each group
407
- const withStats: { name: string; isDir: boolean }[] = [];
408
- for (const name of entries) {
409
- try {
410
- const s = await stat(resolve(targetDir, name));
411
- withStats.push({ name, isDir: s.isDirectory() });
412
- } catch { withStats.push({ name, isDir: false }); }
413
- }
414
- withStats.sort((a, b) => {
415
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
416
- return a.name.localeCompare(b.name);
417
- });
418
-
419
- if (!arg) {
420
- // /cd 无参数:展示卡片(含最近使用路径按钮)
421
- const recentDirs = await getRecentDirs();
422
- const card = buildCdCard(targetDir, withStats, recentDirs, sessionCwd);
423
- const ok = await sendRawCard(cdToken, chatId, card);
424
- console.log(`[${ts()}] [CD] card sent, ok=${ok}, recentDirs=${recentDirs.length}`);
425
- logTrace(tid, "DONE", { outcome: "cd_card", ok });
426
- } else {
427
- // /cd <path>:切换目录,发送文本卡片
428
- const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
429
- await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
430
- logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
431
- }
432
- return;
433
- }
434
-
435
- if (textLower === "/new" || textLower.startsWith("/new ")) {
436
- const toolArg = text.slice(5).trim().toLowerCase();
437
- const tool = toolArg || "claude";
438
- logTrace(tid, "BRANCH", { cmd: "/new", tool });
439
- const validTools = ["claude", "cursor", "codex"];
440
- if (!validTools.includes(tool)) {
441
- logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
442
- const warnToken = await getTenantAccessToken();
443
- await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex)。`, "red");
444
- return;
445
- }
446
- const toolLabel = toolDisplayName(tool);
447
-
448
- if (!openId) {
449
- logTrace(tid, "DONE", { outcome: "new_no_openid" });
450
- console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
451
- const warnToken = await getTenantAccessToken();
452
- await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
453
- return;
454
- }
455
-
456
- const freshToken = await getTenantAccessToken();
457
-
458
- let sessionId: string;
459
- let sessionCwd: string;
460
- try {
461
- const init = await initClaudeSession(tool, undefined, chatId);
462
- sessionId = init.sessionId;
463
- sessionCwd = init.cwd;
464
- console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
465
- } catch (err) {
466
- console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
467
- logTrace(tid, "DONE", { outcome: "new_session_fail", error: (err as Error).message });
468
- await sendCardReply(
469
- freshToken, chatId, "Error",
470
- `Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
471
- "red"
472
- );
473
- return;
474
- }
475
-
476
- const cwd = sessionCwd;
477
- const initialName = sessionChatName("新会话", cwd);
478
-
479
- // 私聊:不创建群,直接绑定 session 到当前私聊
480
- if (chatType === "p2p") {
481
- bindChatToSession(sessionId, chatId);
482
- sessionInfoMap.set(chatId, {
483
- sessionId,
484
- turnCount: 0,
485
- lastContextTokens: 0,
486
- startTime: Date.now(),
487
- tool,
488
- });
489
- await setDefaultCwd(cwd, chatId);
490
- await recordSessionRegistry({
491
- chatId,
492
- sessionId,
493
- tool,
494
- chatName: initialName,
495
- turnCount: 0,
496
- startTime: Date.now(),
497
- running: false,
498
- });
499
- await saveSessionTool(sessionId, tool, initialName);
500
- await sendCardReply(
501
- freshToken, chatId, `${toolLabel} Session Ready`,
502
- `这是你的 **${toolLabel}** 私聊会话。\n\n` +
503
- `**Session ID:** ${sessionId}\n` +
504
- `**工作目录:** \`${cwd}\`\n\n` +
505
- `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
506
- `发送 **/sessions** 查看所有会话状态。\n` +
507
- `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
508
- "green"
509
- );
510
- console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
511
- logTrace(tid, "DONE", { outcome: "session_ready_p2p", chatId, sessionId, tool });
512
- return;
513
- }
514
-
515
- let newChatId: string;
516
- try {
517
- newChatId = await createGroupChat(freshToken, initialName, [openId]);
518
- console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
519
- } catch (err) {
520
- console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
521
- logTrace(tid, "DONE", { outcome: "new_group_fail", error: (err as Error).message });
522
- await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
523
- return;
524
- }
525
-
526
- try {
527
- const descPrefix = sessionPrefixForTool(tool);
528
- await updateChatInfo(freshToken, newChatId, initialName, `${descPrefix} ${sessionId}`);
529
- console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
530
- } catch (err) {
531
- console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
532
- logTrace(tid, "DONE", { outcome: "new_rename_fail", error: (err as Error).message });
533
- await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
534
- return;
535
- }
536
-
537
- // 让新群的默认工作目录继承当前会话的 cwd
538
- await setDefaultCwd(cwd, newChatId);
539
- bindChatToSession(sessionId, newChatId);
540
- await recordSessionRegistry({
541
- chatId: newChatId,
542
- sessionId,
543
- tool,
544
- chatName: initialName,
545
- turnCount: 0,
546
- startTime: Date.now(),
547
- running: false,
548
- });
549
- await saveSessionTool(sessionId, tool, initialName);
550
-
551
- const adapter = getAdapterForTool(tool);
552
- await sendCardReply(
553
- freshToken, newChatId, `${toolLabel} Session Ready`,
554
- `群聊已创建,这是你的 **${toolLabel}** 会话群。\n\n` +
555
- `**Session ID:** ${sessionId}\n` +
556
- `**工作目录:** \`${cwd}\`\n\n` +
557
- `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
558
- `发送 **/sessions** 查看所有会话状态。\n` +
559
- `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
560
- "green"
561
- );
562
-
563
- console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
564
- logTrace(tid, "DONE", { outcome: "session_ready", newChatId, sessionId, tool });
565
- setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
566
- console.log(`${"=".repeat(60)}`);
567
- return;
568
- }
569
-
570
- // 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
571
- let sessionId: string | null = null;
572
- let descriptionTool: string | null = null;
573
- let toolLabel: string | null = null;
574
- let chatInfo: Awaited<ReturnType<typeof getChatInfo>> | undefined;
575
- let description: string | undefined;
576
-
577
- if (chatType !== "p2p") {
578
- try {
579
- const token = await getTenantAccessToken();
580
- chatInfo = await getChatInfo(token, chatId);
581
- description = chatInfo.description;
582
- const sessionInfo = extractSessionInfo(description);
583
- if (sessionInfo) {
584
- sessionId = sessionInfo.sessionId;
585
- descriptionTool = sessionInfo.tool;
586
- toolLabel = toolDisplayName(descriptionTool);
587
- }
588
- } catch (err) {
589
- logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
590
- console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
591
- }
592
- } else {
593
- // 私聊:从 session-registry.json 获取绑定的 session
594
- try {
595
- const registry = await loadSessionRegistryForBinding();
596
- const record = registry[chatId];
597
- if (record && record.sessionId && record.tool) {
598
- sessionId = record.sessionId;
599
- descriptionTool = record.tool;
600
- toolLabel = toolDisplayName(descriptionTool);
601
- // 确保 sessionInfoMap 中有该私聊的信息
602
- if (!sessionInfoMap.has(chatId)) {
603
- sessionInfoMap.set(chatId, {
604
- sessionId,
605
- turnCount: record.turnCount ?? 0,
606
- lastContextTokens: record.lastContextTokens ?? 0,
607
- startTime: record.startTime ?? Date.now(),
608
- tool: descriptionTool,
609
- });
610
- }
611
- bindChatToSession(sessionId, chatId);
612
- }
613
- } catch (err) {
614
- console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`);
615
- }
616
- }
617
-
618
- if (sessionId && descriptionTool && toolLabel) {
619
- // 有会话上下文 — 路由到命令处理或 prompt
620
- logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
621
- console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
622
-
623
- const freshToken = await getTenantAccessToken();
624
-
625
- if (chatType !== "p2p" && isUntitledSessionChatName(chatInfo!.name) && !textLower.startsWith("/")) {
626
- const MAX_PREFIX = 10;
627
- const prefix = text.slice(0, MAX_PREFIX);
628
- const adapter = getAdapterForTool(descriptionTool);
629
- const info = await adapter.getSessionInfo(sessionId).catch(() => undefined);
630
- const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
631
- const newName = sessionChatName(prefix, sessionCwd);
632
- try {
633
- await updateChatInfo(freshToken, chatId, newName, description!);
634
- console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
635
- await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
636
- await saveSessionTool(sessionId, descriptionTool, newName).catch(() => {});
637
- } catch (err) {
638
- console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
639
- }
640
- }
641
-
642
- if (textLower === "/stop") {
643
- logTrace(tid, "BRANCH", { cmd: "/stop" });
644
- if (stopSession(sessionId)) {
645
- console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
646
- await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
647
- logTrace(tid, "DONE", { outcome: "stopped" });
648
- } else {
649
- await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
650
- logTrace(tid, "DONE", { outcome: "stop_no_session" });
651
- }
652
- return;
653
- }
654
-
655
- if (textLower === "/status") {
656
- logTrace(tid, "BRANCH", { cmd: "/status" });
657
- const status = await getSessionStatus(chatId);
658
- const isActive = isSessionRunning(sessionId);
659
- const statusText = [
660
- `**群名:** ${status?.chatName || "—"}`,
661
- `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
662
- `**工具:** ${toolLabel}`,
663
- `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
664
- `**已对话轮数:** ${status?.turnCount ?? 0}`,
665
- `**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
666
- ];
667
- // effort 仅在该工具有此概念时显示(status?.effort 为 null 表示
668
- // 当前工具没有 effort,如 Cursor,应隐藏整行避免误导)
669
- if (status?.effort != null) {
670
- statusText.push(`**Effort:** ${status.effort}`);
671
- }
672
- if (isActive) {
673
- const elapsed = Math.floor((Date.now() - (status!.startTime)) / 1000);
674
- const mins = Math.floor(elapsed / 60);
675
- const secs = elapsed % 60;
676
- statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
677
- statusText.push(`**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`);
678
- }
679
- if (status?.lastContextTokens) {
680
- statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
681
- }
682
- const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
683
- const ok = await sendRawCard(freshToken, chatId, card);
684
- console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
685
- logTrace(tid, "DONE", { outcome: "status", ok });
686
- return;
687
- }
688
-
689
- if (textLower === "/sessions") {
690
- logTrace(tid, "BRANCH", { cmd: "/sessions" });
691
- const allSessions = await getAllSessionsStatus();
692
- const now = Date.now();
693
- const others = allSessions.filter(s => s.chatId !== chatId);
694
- const cardData = others.map(s => ({
695
- sessionId: s.sessionId,
696
- chatName: s.chatName,
697
- chatId: s.chatId,
698
- active: s.active,
699
- turnCount: s.turnCount,
700
- elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
701
- model: s.model,
702
- tool: s.tool,
703
- }));
704
- const card = buildSessionsCard(cardData);
705
- const ok = await sendRawCard(freshToken, chatId, card);
706
- console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${others.length}`);
707
- logTrace(tid, "DONE", { outcome: "sessions", ok, count: others.length });
708
- return;
709
- }
710
-
711
- if (textLower === "/newh") {
712
- logTrace(tid, "BRANCH", { cmd: "/newh" });
713
- const adapter = getAdapterForTool(descriptionTool);
714
- let cwd: string;
715
- try {
716
- const info = await adapter.getSessionInfo(sessionId);
717
- cwd = info?.cwd ?? (await getDefaultCwd(chatId));
718
- } catch {
719
- cwd = await getDefaultCwd(chatId);
720
- }
721
-
722
- // 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
723
- // 旧 session 状态完全保留)。
724
- // 注意不 abort 旧 session:旧 session 若仍在跑,display loop 会继续
725
- // 把卡片推到原群直到 prompt 自然结束(或被 /stop)。
726
- let newSessionId: string;
727
- try {
728
- const init = await initClaudeSession(descriptionTool, cwd);
729
- newSessionId = init.sessionId;
730
- } catch (err) {
731
- logTrace(tid, "DONE", { outcome: "newh_session_fail", error: (err as Error).message });
732
- await sendCardReply(freshToken, chatId, "Error", `Failed to create new session:\n${(err as Error).message}`, "red");
733
- return;
734
- }
735
-
736
- // 第二步:事务式切换 chat 绑定 —— 把 chat 从 oldSessionId 改嫁到
737
- // newSessionId。switchChatBinding 内部保证:
738
- // - 群聊先调 updateChatInfo 改群描述,失败则内存绑定完全不动
739
- // - 私聊跳过 updateChatInfo(私聊 chatId 调群 API 必然失败)
740
- // - API 成功后统一切换内存:unbind 旧 / clear displayCards / bind 新 /
741
- // recordLastActiveChat / sessionInfoMap.set + 持久化 registry/sessions
742
- // 详见 session.ts::switchChatBinding 注释。
743
- const descPrefix = sessionPrefixForTool(descriptionTool);
744
- const newName = sessionChatName("新会话", cwd);
745
- const switchResult = await switchChatBinding({
746
- chatId,
747
- chatType,
748
- oldSessionId: sessionId,
749
- newSessionId,
750
- tool: descriptionTool,
751
- chatName: newName,
752
- newDescription: `${descPrefix} ${newSessionId}`,
753
- updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
754
- });
755
- if (!switchResult.ok) {
756
- // 飞书 API 失败:内存仍指向旧 session,新 session 已创建但无人使用,
757
- // 留在 sessions.json(loadSessionTools)成为 orphan,可由 /sessions
758
- // 列表展示供人工清理或下次 /newh 覆盖。代价小于让群 description
759
- // 与内存绑定不一致。
760
- logTrace(tid, "DONE", { outcome: "newh_update_chat_fail", error: switchResult.error?.message });
761
- await sendCardReply(
762
- freshToken, chatId, "Error",
763
- `更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`,
764
- "red"
765
- );
766
- return;
767
- }
768
- if (chatType !== "p2p") {
769
- console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
770
- }
771
-
772
- setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
773
-
774
- // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到。
775
- // /newh 后通常是空 session,这里几乎走不到,但留作防御。
776
- if (isSessionRunning(newSessionId)) {
777
- const { ensureDisplayLoop } = await import("./session.ts");
778
- ensureDisplayLoop(newSessionId);
779
- }
780
-
781
- await sendCardReply(
782
- freshToken, chatId, `${toolLabel} Session Reset`,
783
- `会话已重置为新的 **${toolLabel}** 会话。\n\n` +
784
- `**Session ID:** ${newSessionId}\n` +
785
- `**工作目录:** \`${cwd}\`\n\n` +
786
- `直接在这里发消息即可继续对话。`,
787
- "green"
788
- );
789
-
790
- console.log(`[${ts()}] [NEWH] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`);
791
- logTrace(tid, "DONE", { outcome: "newh", newSessionId, cwd });
792
- return;
793
- }
794
-
795
- if (textLower === "/deleteg") {
796
- logTrace(tid, "BRANCH", { cmd: "/deleteg" });
797
- if (chatType === "p2p") {
798
- await sendTextReply(freshToken, chatId, "私聊无法使用 /deleteg,该指令仅用于群聊。").catch(() => {});
799
- logTrace(tid, "DONE", { outcome: "deleteg_p2p" });
800
- return;
801
- }
802
- console.log(`[${ts()}] [DELETEG] Disbanding group chat ${chatId}, session=${sessionId}`);
803
-
804
- // 先解绑 session(不删除 Agent 会话)
805
- unbindChatFromSession(sessionId, chatId);
806
- displayCards.delete(chatId);
807
- sessionInfoMap.delete(chatId);
808
- await removeSessionRegistryRecord(chatId);
809
-
810
- await sendTextReply(freshToken, chatId, "群聊已解散,Agent 会话保留。").catch(() => {});
811
-
812
- // 解散群聊(飞书 API)
813
- try {
814
- await disbandChat(freshToken, chatId);
815
- console.log(`[${ts()}] [DELETEG] Group disbanded: ${chatId}`);
816
- } catch (err) {
817
- console.error(`[${ts()}] [DELETEG] Disband API failed: ${(err as Error).message}`);
818
- }
819
-
820
- logTrace(tid, "DONE", { outcome: "deleteg", chatId, sessionId });
821
- return;
822
- }
823
-
824
- // /session <number>:切换到 /sessions 列表中的指定会话
825
- const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
826
- if (sessionMatch) {
827
- const index = parseInt(sessionMatch[1], 10) - 1;
828
- logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
829
- const allSessions = await getAllSessionsStatus();
830
- // 与 buildSessionsCard 保持一致的排序:Claude Code → Cursor → Codex,组内保持 updatedAt 降序
831
- const claudeOrdered = allSessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
832
- const cursorOrdered = allSessions.filter(s => s.tool === "cursor");
833
- const codexOrdered = allSessions.filter(s => s.tool === "codex");
834
- const ordered = [...claudeOrdered, ...cursorOrdered, ...codexOrdered].filter(s => s.chatId !== chatId);
835
- if (ordered.length === 0) {
836
- await sendCardReply(freshToken, chatId, "/session", "暂无历史会话。", "yellow");
837
- logTrace(tid, "DONE", { outcome: "session_no_sessions" });
838
- return;
839
- }
840
- if (index < 0 || index >= ordered.length) {
841
- await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${ordered.length} 个会话。`, "yellow");
842
- logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total: ordered.length });
843
- return;
844
- }
845
- const target = ordered[index];
846
-
847
- // 第一步:解析目标 session 的 cwd(adapter.getSessionInfo 失败则 fallback,
848
- // 这一步不动任何内存绑定,失败也不脏)。
849
- const targetAdapter = getAdapterForTool(target.tool);
850
- let cwd2: string;
851
- try {
852
- const targetInfo = await targetAdapter.getSessionInfo(target.sessionId);
853
- cwd2 = targetInfo?.cwd ?? (await getDefaultCwd(chatId));
854
- } catch {
855
- cwd2 = await getDefaultCwd(chatId);
856
- }
857
-
858
- // 第二步:事务式切换 chat 绑定到 target.sessionId。
859
- // switchChatBinding 内部行为同 /newh,详见 session.ts::switchChatBinding。
860
- // 注意:这里把 turnCount 沿用 target.turnCount(从 registry 读到的目标 chat
861
- // 历史轮数),而不是从 0 开始,这样 /status 显示的轮数延续历史。
862
- const descPrefix2 = sessionPrefixForTool(target.tool);
863
- const newName2 = target.chatName || sessionChatName("新会话", cwd2);
864
- const switchResult = await switchChatBinding({
865
- chatId,
866
- chatType,
867
- oldSessionId: sessionId,
868
- newSessionId: target.sessionId,
869
- tool: target.tool,
870
- chatName: newName2,
871
- newDescription: `${descPrefix2} ${target.sessionId}`,
872
- initialTurnCount: target.turnCount,
873
- initialContextTokens: 0,
874
- updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
875
- });
876
- if (!switchResult.ok) {
877
- logTrace(tid, "DONE", { outcome: "session_update_chat_fail", error: switchResult.error?.message });
878
- await sendCardReply(
879
- freshToken, chatId, "Error",
880
- `更新群描述失败,会话未切换:\n${switchResult.error?.message}`,
881
- "red"
882
- );
883
- return;
884
- }
885
- if (chatType !== "p2p") {
886
- console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
887
- }
888
-
889
- setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
890
-
891
- // 如果新 session 有活跃 prompt,加上 display loop。注意:此时
892
- // recordLastActiveChat 已经把 lastActive 改成当前 chat,会"抢走"
893
- // 原有群的 display 推送(见 review 中 corner case 5)。这是已知
894
- // 设计权衡:用户主动切换就是想"接管"该 session 的显示。
895
- if (isSessionRunning(target.sessionId)) {
896
- const { ensureDisplayLoop } = await import("./session.ts");
897
- ensureDisplayLoop(target.sessionId);
898
- }
899
-
900
- const targetToolLabel = toolDisplayName(target.tool);
901
- const busyNote = isSessionRunning(target.sessionId) ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。" : "";
902
- await sendCardReply(
903
- freshToken, chatId, `${targetToolLabel} Session Switched`,
904
- `已切换到 **${targetToolLabel}** 会话。\n\n` +
905
- `**序号:** ${index + 1}\n` +
906
- `**Session ID:** ${target.sessionId}\n` +
907
- `**工作目录:** \`${cwd2}\`\n\n` +
908
- `直接在这里发消息即可继续对话。${busyNote}`,
909
- "green"
910
- );
911
-
912
- logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
913
- return;
914
- }
915
-
916
- // /git <args>:在「当前会话工作目录」执行 git 命令,把输出回发到群里。
917
- // 注意 cwd 必须取自 adapter.getSessionInfo(会话真实 cwd),而非
918
- // getDefaultCwd(下一次 /new 才会使用的默认路径)。
919
- if (textLower.startsWith("/git ") || textLower === "/git") {
920
- const args = text === "/git" ? "" : text.slice(5).trim();
921
- logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
922
- if (!args) {
923
- logTrace(tid, "DONE", { outcome: "git_no_args" });
924
- await sendCardReply(
925
- freshToken, chatId, "/git",
926
- "用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
927
- "yellow"
928
- );
929
- return;
930
- }
931
-
932
- const adapter = getAdapterForTool(descriptionTool);
933
- let cwd: string | undefined;
934
- try {
935
- const info = await adapter.getSessionInfo(sessionId);
936
- cwd = info?.cwd;
937
- } catch (err) {
938
- console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`);
939
- }
940
- if (!cwd) {
941
- logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
942
- // Cursor 会话的 cwd 依赖 state/cursor-session-meta.json 持久化映射;
943
- // 升级前创建的旧会话或映射文件丢失时,向会话发送一次普通消息即可触发
944
- // adapter 自动学习并补全(resume 流首条 init 事件携带 cwd)。
945
- const isCursor = descriptionTool === "cursor";
946
- const hint = isCursor
947
- ? "无法获取当前 Cursor 会话的工作目录(缺少 sessionId→cwd 持久化映射)。请先在本群发送一条普通消息(让 adapter 从 cursor-agent 流中自动补回 cwd),然后再试 /git;若仍失败,可用 /new 重建会话。"
948
- : `无法获取当前会话的工作目录(${toolLabel} adapter 未返回 cwd)。请先与 AI 对话一次再试,或检查会话是否仍存在。`;
949
- await sendCardReply(freshToken, chatId, "/git", hint, "red");
950
- return;
951
- }
952
-
953
- console.log(`[${ts()}] [GIT] chat=${chatId} cwd=${cwd} cmd="git ${args}" timeoutMs=${GIT_TIMEOUT_MS}`);
954
- const result = await runGitCommand(args, cwd, { timeoutMs: GIT_TIMEOUT_MS });
955
- console.log(`[${ts()}] [GIT] exitCode=${result.exitCode}, durationMs=${result.durationMs}, truncated=${result.truncated}, timedOut=${result.timedOut}`);
956
- const content = formatGitResult(args, cwd, result);
957
- const template = gitResultHeaderTemplate(result);
958
- await sendCardReply(freshToken, chatId, "/git 输出", content, template);
959
- logTrace(tid, "DONE", { outcome: "git_result", exitCode: result.exitCode, durationMs: result.durationMs });
960
- return;
961
- }
962
-
963
- const lastTs = lastMsgTimestamps.get(chatId);
964
- if (lastTs !== undefined && msgTimestamp <= lastTs) {
965
- logTrace(tid, "DONE", { outcome: "skip_old_message_no_session", msgTimestamp, lastTimestamp: lastTs });
966
- console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${lastTs}), no active session, ignoring`);
967
- return;
968
- }
969
-
970
- // 并发检查:同一 session 只能有一个活跃 prompt
971
- if (isSessionRunning(sessionId)) {
972
- logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
973
- console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`);
974
- await sendCardReply(
975
- freshToken, chatId, "生成中",
976
- "该会话正在生成回复中,请等待完成后再发送新消息。",
977
- "yellow"
978
- );
979
- return;
980
- }
981
-
982
- try {
983
- logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
984
- await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool, tid);
985
- logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
986
- console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
987
- } catch (err) {
988
- logTrace(tid, "DONE", { outcome: "resume_fail", error: (err as Error).message });
989
- console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
990
- fileLog.flush();
991
- await sendCardReply(
992
- freshToken, chatId, "Error",
993
- `Failed to resume ${toolLabel} session:\n${(err as Error).message}`,
994
- "red"
995
- );
996
- }
997
- return;
998
- }
999
-
1000
- // 无会话上下文 → help card
1001
-
1002
- // 私聊或群聊无 session info → 发送 help card
1003
- logTrace(tid, "SEND", { method: "help_card", chatId });
1004
- const replyToken = await getTenantAccessToken();
1005
- const card = buildHelpCard(text);
1006
- const ok = await sendRawCard(replyToken, chatId, card);
1007
- if (!ok) {
1008
- console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId}`);
1009
- logTrace(tid, "DONE", { outcome: "help_card_fail" });
1010
- } else {
1011
- console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId}`);
1012
- logTrace(tid, "DONE", { outcome: "help_card_sent" });
1013
- }
1014
- }
1015
-
1016
344
  // ---------------------------------------------------------------------------
1017
345
  // startBotService — 飞书 service 的启动逻辑(独立于 main())
1018
346
  // ---------------------------------------------------------------------------
@@ -1149,7 +477,7 @@ async function startBotServiceCore(): Promise<void> {
1149
477
  const delayToken = await getTenantAccessToken();
1150
478
  await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
1151
479
  }
1152
- await handleCommand(text, chatId, openId, msgTimestamp, chatType, traceId);
480
+ await handleCommand(feishuPlatform, text, chatId, openId, msgTimestamp, chatType, traceId);
1153
481
  } catch (err) {
1154
482
  logTrace(traceId, "ERROR", { message: (err as Error).message });
1155
483
  console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
@@ -1189,7 +517,7 @@ async function startBotServiceCore(): Promise<void> {
1189
517
  const result = parseCardAction(data);
1190
518
  if (!result) return;
1191
519
  console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
1192
- handleCommand(result.text, result.chatId, result.openId, Date.now()).catch((err) =>
520
+ handleCommand(feishuPlatform, result.text, result.chatId, result.openId, Date.now()).catch((err) =>
1193
521
  console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
1194
522
  );
1195
523
  } catch (err) {
@@ -1214,7 +542,7 @@ async function startBotServiceCore(): Promise<void> {
1214
542
  const data = JSON.parse(raw.toString()) as Evt;
1215
543
  const action = parseCardAction(data);
1216
544
  if (action) {
1217
- handleCommand(action.text, action.chatId, action.openId, Date.now()).catch((err) =>
545
+ handleCommand(feishuPlatform, action.text, action.chatId, action.openId, Date.now()).catch((err) =>
1218
546
  console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
1219
547
  );
1220
548
  return;
@@ -1297,6 +625,40 @@ async function startBotServiceCore(): Promise<void> {
1297
625
  }
1298
626
  }
1299
627
 
628
+ // ---------------------------------------------------------------------------
629
+ // WeChat iLink supervisor — 自动重连
630
+ // ---------------------------------------------------------------------------
631
+
632
+ async function startWechatSupervisor(): Promise<void> {
633
+ if (!ILINK_ENABLED) {
634
+ console.log("[WX] 微信 iLink 未启用(platforms.ilink.enabled 不为 true),跳过。");
635
+ return;
636
+ }
637
+
638
+ console.log("\n[WX] 启动微信 iLink 平台...");
639
+
640
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
641
+
642
+ while (!wechatSignal.stopped) {
643
+ try {
644
+ await startWechatPlatform(
645
+ (text, chatId, openId, msgTimestamp, chatType, traceId) =>
646
+ handleCommand(wechatPlatform, text, chatId, openId, msgTimestamp, chatType, traceId),
647
+ wechatSignal,
648
+ ILINK_REUSE_TOKEN_ON_START,
649
+ );
650
+ } catch (err) {
651
+ console.error(
652
+ `[WX] 微信 iLink 崩溃: ${(err as Error).message}`,
653
+ );
654
+ }
655
+ if (wechatSignal.stopped) break;
656
+ console.log("[WX] 5 秒后重试...");
657
+ await sleep(5000);
658
+ }
659
+ console.log("[WX] 微信 iLink 平台已停止。");
660
+ }
661
+
1300
662
  // ---------------------------------------------------------------------------
1301
663
  // Main
1302
664
  // ---------------------------------------------------------------------------
@@ -1324,7 +686,7 @@ async function main(): Promise<void> {
1324
686
  // 注册消息处理器,让 SimAgent.sendMessage() 能进程内触发 handleCommand
1325
687
  setMessageHandler(
1326
688
  (text, chatId, openId, _ts, chatType, traceId) =>
1327
- handleCommand(text, chatId, openId, Date.now(), chatType, traceId),
689
+ handleCommand(feishuPlatform, text, chatId, openId, Date.now(), chatType, traceId),
1328
690
  );
1329
691
 
1330
692
  setExtraApiHandler(async (req, res) => {
@@ -1395,45 +757,43 @@ async function main(): Promise<void> {
1395
757
  reportEnvironmentVariableReadout();
1396
758
  console.log(` 工作目录: ${process.cwd()}`);
1397
759
  console.log(` 包根目录: ${PROJECT_ROOT}`);
1398
- appendStartupTrace("main: before feishu credential check", {
1399
- hasAppId: Boolean(APP_ID.trim()),
1400
- hasAppSecret: Boolean(APP_SECRET.trim()),
1401
- });
1402
- if (!APP_ID.trim() || !APP_SECRET.trim()) {
1403
- // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
1404
- // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
1405
- startSetupMode(CHATCCC_PORT, {
1406
- onActivate: async (httpServer: Server) => {
1407
- // 关键:用户刚把新凭证写入 config.json,需要先把进程内的 APP_ID 等
1408
- // 常量同步到磁盘最新值,否则 startBotService 拿到的是 chatccc 启动时
1409
- // 加载的(空)凭证。reloadConfigFromDisk 利用 ES module live binding
1410
- // 让所有"export let"消费方自动看到新值。
1411
- reloadConfigFromDisk();
1412
- appendStartupTrace("setup-activate: reloaded config from disk", {
1413
- appIdMaskAfterReload: maskAppId(APP_ID),
1414
- });
1415
- try {
1416
- await startBotService({ httpServer, port: CHATCCC_PORT });
1417
- // 切换成功:注册 SIGINT/SIGTERM 让 Ctrl-C 也能优雅退出
1418
- installShutdownHandlers(httpServer);
1419
- return { ok: true };
1420
- } catch (err) {
1421
- appendStartupTrace("setup-activate: startBotService failed", {
1422
- message: (err as Error).message,
1423
- });
1424
- // 不退出 chatccc 进程——setup HTTP server 还在监听,让用户改完
1425
- // config 再点一次。
1426
- return { ok: false, error: (err as Error).message };
1427
- }
1428
- },
760
+
761
+ if (FEISHU_ENABLED) {
762
+ appendStartupTrace("main: before feishu credential check", {
763
+ hasAppId: Boolean(APP_ID.trim()),
764
+ hasAppSecret: Boolean(APP_SECRET.trim()),
1429
765
  });
1430
- return;
766
+ if (!APP_ID.trim() || !APP_SECRET.trim()) {
767
+ // 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
768
+ // 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
769
+ startSetupMode(CHATCCC_PORT, {
770
+ onActivate: async (httpServer: Server) => {
771
+ reloadConfigFromDisk();
772
+ appendStartupTrace("setup-activate: reloaded config from disk", {
773
+ appIdMaskAfterReload: maskAppId(APP_ID),
774
+ });
775
+ try {
776
+ await startBotService({ httpServer, port: CHATCCC_PORT });
777
+ installShutdownHandlers(httpServer);
778
+ return { ok: true };
779
+ } catch (err) {
780
+ appendStartupTrace("setup-activate: startBotService failed", {
781
+ message: (err as Error).message,
782
+ });
783
+ return { ok: false, error: (err as Error).message };
784
+ }
785
+ },
786
+ });
787
+ return;
788
+ }
789
+ console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
790
+ appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
791
+ } else {
792
+ console.log(" 飞书平台未启用(platforms.feishu.enabled = false),跳过飞书凭证检查。\n");
793
+ appendStartupTrace("main: feishu disabled", {});
1431
794
  }
1432
- console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
1433
- appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
1434
795
 
1435
- // 凭证齐全:自己起 HTTP server(同时挂 UI router),再调 startBotService
1436
- // 把 WS 中继和飞书 SDK 挂上去。
796
+ // 启动 HTTP server(同时挂 UI router,供 dashboard / setup / agent image/file 使用)
1437
797
  appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
1438
798
  const killed = freeRelayListenPort(CHATCCC_PORT);
1439
799
  appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed });
@@ -1451,13 +811,20 @@ async function main(): Promise<void> {
1451
811
  process.exit(1);
1452
812
  });
1453
813
 
1454
- try {
1455
- await startBotService({ httpServer, port: CHATCCC_PORT });
1456
- } catch (err) {
1457
- printServiceDidNotStart((err as Error).message);
1458
- process.exit(1);
814
+ if (FEISHU_ENABLED) {
815
+ try {
816
+ await startBotService({ httpServer, port: CHATCCC_PORT });
817
+ } catch (err) {
818
+ printServiceDidNotStart((err as Error).message);
819
+ process.exit(1);
820
+ }
1459
821
  }
1460
822
 
823
+ // 启动微信 iLink 平台(后台运行,不阻塞飞书)
824
+ startWechatSupervisor().catch((err) =>
825
+ console.error(`[WX] 微信 supervisor 异常退出: ${(err as Error).message}`),
826
+ );
827
+
1461
828
  installShutdownHandlers(httpServer);
1462
829
  }
1463
830
 
@@ -1498,8 +865,8 @@ async function listenWithRetry(
1498
865
  * 先写盘,再走这里。
1499
866
  */
1500
867
  function installShutdownHandlers(httpServer: Server): void {
1501
- process.on("SIGINT", () => { console.log("\nShutting down..."); httpServer.close(); process.exit(0); });
1502
- process.on("SIGTERM", () => { httpServer.close(); process.exit(0); });
868
+ process.on("SIGINT", () => { console.log("\nShutting down..."); wechatSignal.stopped = true; httpServer.close(); process.exit(0); });
869
+ process.on("SIGTERM", () => { wechatSignal.stopped = true; httpServer.close(); process.exit(0); });
1503
870
  }
1504
871
 
1505
872
  main().catch((err: Error) => {