chatccc 0.2.50 → 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 +51 -4
- package/config.sample.json +34 -30
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +42 -0
- package/src/__tests__/claude-adapter.test.ts +54 -1
- package/src/__tests__/config-reload.test.ts +64 -47
- package/src/__tests__/config-sample.test.ts +19 -0
- package/src/__tests__/session.test.ts +346 -1
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/__tests__/wechat-platform.test.ts +32 -0
- package/src/adapters/claude-adapter.ts +23 -2
- package/src/card-plain-text.ts +108 -0
- package/src/cards.ts +4 -4
- package/src/config.ts +775 -742
- package/src/feishu-api.ts +6 -0
- package/src/index.ts +172 -781
- package/src/orchestrator.ts +1032 -0
- package/src/platform-adapter.ts +61 -0
- package/src/session-chat-binding.ts +2 -0
- package/src/session.ts +431 -114
- package/src/sim-agent.ts +42 -31
- package/src/stream-state.ts +140 -93
- package/src/web-ui.ts +1891 -1718
- 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,54 +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 {
|
|
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
|
-
|
|
96
|
-
getAllSessionsStatus,
|
|
97
|
-
initClaudeSession,
|
|
98
|
-
lastMsgTimestamps,
|
|
88
|
+
loadSessionRegistryForBinding,
|
|
99
89
|
processedMessages,
|
|
90
|
+
rebuildBindingsFromRegistry,
|
|
100
91
|
resetState,
|
|
101
|
-
|
|
102
|
-
sessionInfoMap,
|
|
103
|
-
recordSessionRegistry,
|
|
104
|
-
getAdapterForTool,
|
|
105
|
-
stopSession,
|
|
106
|
-
loadSessionRegistryForBinding,
|
|
107
|
-
removeSessionRegistryRecord,
|
|
108
|
-
saveSessionTool,
|
|
92
|
+
setSessionPlatform,
|
|
109
93
|
} from "./session.ts";
|
|
110
94
|
import {
|
|
111
|
-
bindChatToSession,
|
|
112
|
-
unbindChatFromSession,
|
|
113
|
-
getChatsForSession,
|
|
114
|
-
isSessionRunning,
|
|
115
|
-
activePrompts,
|
|
116
95
|
rebuildSessionChatsFromRegistry,
|
|
117
|
-
displayCards,
|
|
118
|
-
recordLastActiveChat,
|
|
119
96
|
} from "./session-chat-binding.ts";
|
|
120
97
|
import { fixStaleStreamStates } from "./stream-state.ts";
|
|
98
|
+
import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
|
|
99
|
+
import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
|
|
121
100
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Feishu 平台适配器
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
126
104
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
},
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
};
|
|
133
151
|
}
|
|
134
152
|
|
|
153
|
+
const feishuPlatform = createFeishuAdapter();
|
|
154
|
+
const wechatPlatform = createWechatAdapter();
|
|
155
|
+
setSessionPlatform(feishuPlatform);
|
|
156
|
+
|
|
135
157
|
// ---------------------------------------------------------------------------
|
|
136
158
|
// Event types
|
|
137
159
|
// ---------------------------------------------------------------------------
|
|
@@ -269,6 +291,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
269
291
|
// ---------------------------------------------------------------------------
|
|
270
292
|
|
|
271
293
|
let broadcastToRelay: (data: unknown) => void = () => {};
|
|
294
|
+
const wechatSignal = { stopped: false };
|
|
272
295
|
|
|
273
296
|
// ---------------------------------------------------------------------------
|
|
274
297
|
// Simulate mode: inject message via HTTP
|
|
@@ -308,7 +331,7 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
|
|
|
308
331
|
appendChatLog(chatId, openId, text);
|
|
309
332
|
|
|
310
333
|
// Fire and forget: process command, respond 202 immediately
|
|
311
|
-
handleCommand(text, chatId, openId, Date.now(), chatType).catch((err) =>
|
|
334
|
+
handleCommand(feishuPlatform, text, chatId, openId, Date.now(), chatType).catch((err) =>
|
|
312
335
|
console.error(`[${ts()}] [SIM:INJECT] handleCommand error: ${(err as Error).message}`)
|
|
313
336
|
);
|
|
314
337
|
|
|
@@ -318,686 +341,6 @@ async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse)
|
|
|
318
341
|
}
|
|
319
342
|
|
|
320
343
|
// ---------------------------------------------------------------------------
|
|
321
|
-
// Command handler
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
|
|
324
|
-
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
|
|
325
|
-
const tid = traceId ?? makeTraceId();
|
|
326
|
-
const textLower = text.toLowerCase();
|
|
327
|
-
if (textLower === "/restart") {
|
|
328
|
-
logTrace(tid, "BRANCH", { cmd: "/restart" });
|
|
329
|
-
const restartToken = await getTenantAccessToken();
|
|
330
|
-
await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
|
|
331
|
-
logTrace(tid, "DONE", { outcome: "restart" });
|
|
332
|
-
console.log(`[${ts()}] [RESTART] Spawning new process...`);
|
|
333
|
-
const child = spawn("npx", ["tsx", "src/index.ts"], {
|
|
334
|
-
cwd: PROJECT_ROOT,
|
|
335
|
-
detached: true,
|
|
336
|
-
stdio: "ignore",
|
|
337
|
-
shell: true,
|
|
338
|
-
});
|
|
339
|
-
child.unref();
|
|
340
|
-
setTimeout(() => process.exit(0), 200);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (textLower === "/cd" || textLower.startsWith("/cd ")) {
|
|
345
|
-
logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
|
|
346
|
-
const cdToken = await getTenantAccessToken();
|
|
347
|
-
const currentDir = await getDefaultCwd(chatId);
|
|
348
|
-
|
|
349
|
-
// 获取当前会话的实际工作路径(若在会话群内)
|
|
350
|
-
let sessionCwd: string | undefined;
|
|
351
|
-
try {
|
|
352
|
-
const chatInfo = await getChatInfo(cdToken, chatId);
|
|
353
|
-
const sessionInfoResult = extractSessionInfo(chatInfo.description);
|
|
354
|
-
if (sessionInfoResult) {
|
|
355
|
-
const adapter = getAdapterForTool(sessionInfoResult.tool);
|
|
356
|
-
const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
|
|
357
|
-
sessionCwd = info?.cwd;
|
|
358
|
-
}
|
|
359
|
-
} catch { /* 非会话群或获取失败,不显示 */ }
|
|
360
|
-
|
|
361
|
-
const arg = text.slice(3).trim(); // everything after "/cd" (may be empty)
|
|
362
|
-
|
|
363
|
-
// Resolve target directory
|
|
364
|
-
let targetDir: string;
|
|
365
|
-
if (!arg) {
|
|
366
|
-
targetDir = currentDir;
|
|
367
|
-
} else if (arg === "..") {
|
|
368
|
-
targetDir = dirname(currentDir);
|
|
369
|
-
} else {
|
|
370
|
-
targetDir = resolve(currentDir, arg);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Verify the target exists and is a directory
|
|
374
|
-
try {
|
|
375
|
-
const s = await stat(targetDir);
|
|
376
|
-
if (!s.isDirectory()) {
|
|
377
|
-
logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
|
|
378
|
-
await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
} catch {
|
|
382
|
-
logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
|
|
383
|
-
await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Change working dir if user provided a path
|
|
388
|
-
const isUpdate = !!arg && targetDir !== currentDir;
|
|
389
|
-
if (isUpdate) {
|
|
390
|
-
await setDefaultCwd(targetDir, chatId);
|
|
391
|
-
await addRecentDir(targetDir);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Read directory entries
|
|
395
|
-
let entries: string[];
|
|
396
|
-
try {
|
|
397
|
-
entries = await readdir(targetDir);
|
|
398
|
-
} catch (err) {
|
|
399
|
-
logTrace(tid, "DONE", { outcome: "cd_readdir_fail", error: (err as Error).message });
|
|
400
|
-
await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Sort: directories first, then files, alphabetically within each group
|
|
405
|
-
const withStats: { name: string; isDir: boolean }[] = [];
|
|
406
|
-
for (const name of entries) {
|
|
407
|
-
try {
|
|
408
|
-
const s = await stat(resolve(targetDir, name));
|
|
409
|
-
withStats.push({ name, isDir: s.isDirectory() });
|
|
410
|
-
} catch { withStats.push({ name, isDir: false }); }
|
|
411
|
-
}
|
|
412
|
-
withStats.sort((a, b) => {
|
|
413
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
414
|
-
return a.name.localeCompare(b.name);
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
if (!arg) {
|
|
418
|
-
// /cd 无参数:展示卡片(含最近使用路径按钮)
|
|
419
|
-
const recentDirs = await getRecentDirs();
|
|
420
|
-
const card = buildCdCard(targetDir, withStats, recentDirs, sessionCwd);
|
|
421
|
-
const ok = await sendRawCard(cdToken, chatId, card);
|
|
422
|
-
console.log(`[${ts()}] [CD] card sent, ok=${ok}, recentDirs=${recentDirs.length}`);
|
|
423
|
-
logTrace(tid, "DONE", { outcome: "cd_card", ok });
|
|
424
|
-
} else {
|
|
425
|
-
// /cd <path>:切换目录,发送文本卡片
|
|
426
|
-
const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
|
|
427
|
-
await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
|
|
428
|
-
logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
|
|
429
|
-
}
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (textLower === "/new" || textLower.startsWith("/new ")) {
|
|
434
|
-
const toolArg = text.slice(5).trim().toLowerCase();
|
|
435
|
-
const tool = toolArg || "claude";
|
|
436
|
-
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
437
|
-
const validTools = ["claude", "cursor", "codex"];
|
|
438
|
-
if (!validTools.includes(tool)) {
|
|
439
|
-
logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
|
|
440
|
-
const warnToken = await getTenantAccessToken();
|
|
441
|
-
await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex)。`, "red");
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
const toolLabel = toolDisplayName(tool);
|
|
445
|
-
|
|
446
|
-
if (!openId) {
|
|
447
|
-
logTrace(tid, "DONE", { outcome: "new_no_openid" });
|
|
448
|
-
console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
|
|
449
|
-
const warnToken = await getTenantAccessToken();
|
|
450
|
-
await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
const freshToken = await getTenantAccessToken();
|
|
455
|
-
|
|
456
|
-
let sessionId: string;
|
|
457
|
-
let sessionCwd: string;
|
|
458
|
-
try {
|
|
459
|
-
const init = await initClaudeSession(tool, undefined, chatId);
|
|
460
|
-
sessionId = init.sessionId;
|
|
461
|
-
sessionCwd = init.cwd;
|
|
462
|
-
console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
|
|
463
|
-
} catch (err) {
|
|
464
|
-
console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
|
|
465
|
-
logTrace(tid, "DONE", { outcome: "new_session_fail", error: (err as Error).message });
|
|
466
|
-
await sendCardReply(
|
|
467
|
-
freshToken, chatId, "Error",
|
|
468
|
-
`Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
|
|
469
|
-
"red"
|
|
470
|
-
);
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const cwd = sessionCwd;
|
|
475
|
-
const initialName = sessionChatName("新会话", cwd);
|
|
476
|
-
|
|
477
|
-
// 私聊:不创建群,直接绑定 session 到当前私聊
|
|
478
|
-
if (chatType === "p2p") {
|
|
479
|
-
bindChatToSession(sessionId, chatId);
|
|
480
|
-
sessionInfoMap.set(chatId, {
|
|
481
|
-
sessionId,
|
|
482
|
-
turnCount: 0,
|
|
483
|
-
lastContextTokens: 0,
|
|
484
|
-
startTime: Date.now(),
|
|
485
|
-
tool,
|
|
486
|
-
});
|
|
487
|
-
await setDefaultCwd(cwd, chatId);
|
|
488
|
-
await recordSessionRegistry({
|
|
489
|
-
chatId,
|
|
490
|
-
sessionId,
|
|
491
|
-
tool,
|
|
492
|
-
chatName: initialName,
|
|
493
|
-
turnCount: 0,
|
|
494
|
-
startTime: Date.now(),
|
|
495
|
-
running: false,
|
|
496
|
-
});
|
|
497
|
-
await saveSessionTool(sessionId, tool, initialName);
|
|
498
|
-
await sendCardReply(
|
|
499
|
-
freshToken, chatId, `${toolLabel} Session Ready`,
|
|
500
|
-
`这是你的 **${toolLabel}** 私聊会话。\n\n` +
|
|
501
|
-
`**Session ID:** ${sessionId}\n` +
|
|
502
|
-
`**工作目录:** \`${cwd}\`\n\n` +
|
|
503
|
-
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
504
|
-
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
505
|
-
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
|
|
506
|
-
"green"
|
|
507
|
-
);
|
|
508
|
-
console.log(`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`);
|
|
509
|
-
logTrace(tid, "DONE", { outcome: "session_ready_p2p", chatId, sessionId, tool });
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
let newChatId: string;
|
|
514
|
-
try {
|
|
515
|
-
newChatId = await createGroupChat(freshToken, initialName, [openId]);
|
|
516
|
-
console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
|
|
517
|
-
} catch (err) {
|
|
518
|
-
console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
|
|
519
|
-
logTrace(tid, "DONE", { outcome: "new_group_fail", error: (err as Error).message });
|
|
520
|
-
await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
try {
|
|
525
|
-
const descPrefix = sessionPrefixForTool(tool);
|
|
526
|
-
await updateChatInfo(freshToken, newChatId, initialName, `${descPrefix} ${sessionId}`);
|
|
527
|
-
console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
|
|
528
|
-
} catch (err) {
|
|
529
|
-
console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
|
|
530
|
-
logTrace(tid, "DONE", { outcome: "new_rename_fail", error: (err as Error).message });
|
|
531
|
-
await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// 让新群的默认工作目录继承当前会话的 cwd
|
|
536
|
-
await setDefaultCwd(cwd, newChatId);
|
|
537
|
-
bindChatToSession(sessionId, newChatId);
|
|
538
|
-
await recordSessionRegistry({
|
|
539
|
-
chatId: newChatId,
|
|
540
|
-
sessionId,
|
|
541
|
-
tool,
|
|
542
|
-
chatName: initialName,
|
|
543
|
-
turnCount: 0,
|
|
544
|
-
startTime: Date.now(),
|
|
545
|
-
running: false,
|
|
546
|
-
});
|
|
547
|
-
await saveSessionTool(sessionId, tool, initialName);
|
|
548
|
-
|
|
549
|
-
const adapter = getAdapterForTool(tool);
|
|
550
|
-
await sendCardReply(
|
|
551
|
-
freshToken, newChatId, `${toolLabel} Session Ready`,
|
|
552
|
-
`群聊已创建,这是你的 **${toolLabel}** 会话群。\n\n` +
|
|
553
|
-
`**Session ID:** ${sessionId}\n` +
|
|
554
|
-
`**工作目录:** \`${cwd}\`\n\n` +
|
|
555
|
-
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
556
|
-
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
557
|
-
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
|
|
558
|
-
"green"
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
|
|
562
|
-
logTrace(tid, "DONE", { outcome: "session_ready", newChatId, sessionId, tool });
|
|
563
|
-
setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
|
|
564
|
-
console.log(`${"=".repeat(60)}`);
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// 检测会话上下文:群聊从 description 获取,私聊从 session-registry 获取
|
|
569
|
-
let sessionId: string | null = null;
|
|
570
|
-
let descriptionTool: string | null = null;
|
|
571
|
-
let toolLabel: string | null = null;
|
|
572
|
-
let chatInfo: Awaited<ReturnType<typeof getChatInfo>> | undefined;
|
|
573
|
-
let description: string | undefined;
|
|
574
|
-
|
|
575
|
-
if (chatType !== "p2p") {
|
|
576
|
-
try {
|
|
577
|
-
const token = await getTenantAccessToken();
|
|
578
|
-
chatInfo = await getChatInfo(token, chatId);
|
|
579
|
-
description = chatInfo.description;
|
|
580
|
-
const sessionInfo = extractSessionInfo(description);
|
|
581
|
-
if (sessionInfo) {
|
|
582
|
-
sessionId = sessionInfo.sessionId;
|
|
583
|
-
descriptionTool = sessionInfo.tool;
|
|
584
|
-
toolLabel = toolDisplayName(descriptionTool);
|
|
585
|
-
}
|
|
586
|
-
} catch (err) {
|
|
587
|
-
logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
|
|
588
|
-
console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
|
|
589
|
-
}
|
|
590
|
-
} else {
|
|
591
|
-
// 私聊:从 session-registry.json 获取绑定的 session
|
|
592
|
-
try {
|
|
593
|
-
const registry = await loadSessionRegistryForBinding();
|
|
594
|
-
const record = registry[chatId];
|
|
595
|
-
if (record && record.sessionId && record.tool) {
|
|
596
|
-
sessionId = record.sessionId;
|
|
597
|
-
descriptionTool = record.tool;
|
|
598
|
-
toolLabel = toolDisplayName(descriptionTool);
|
|
599
|
-
// 确保 sessionInfoMap 中有该私聊的信息
|
|
600
|
-
if (!sessionInfoMap.has(chatId)) {
|
|
601
|
-
sessionInfoMap.set(chatId, {
|
|
602
|
-
sessionId,
|
|
603
|
-
turnCount: record.turnCount ?? 0,
|
|
604
|
-
lastContextTokens: record.lastContextTokens ?? 0,
|
|
605
|
-
startTime: record.startTime ?? Date.now(),
|
|
606
|
-
tool: descriptionTool,
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
bindChatToSession(sessionId, chatId);
|
|
610
|
-
}
|
|
611
|
-
} catch (err) {
|
|
612
|
-
console.log(`[${ts()}] [INFO] Cannot load registry for p2p ${chatId}: ${(err as Error).message}`);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (sessionId && descriptionTool && toolLabel) {
|
|
617
|
-
// 有会话上下文 — 路由到命令处理或 prompt
|
|
618
|
-
logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
|
|
619
|
-
console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
|
|
620
|
-
|
|
621
|
-
const freshToken = await getTenantAccessToken();
|
|
622
|
-
|
|
623
|
-
if (chatType !== "p2p" && isUntitledSessionChatName(chatInfo!.name) && !textLower.startsWith("/")) {
|
|
624
|
-
const MAX_PREFIX = 10;
|
|
625
|
-
const prefix = text.slice(0, MAX_PREFIX);
|
|
626
|
-
const adapter = getAdapterForTool(descriptionTool);
|
|
627
|
-
const info = await adapter.getSessionInfo(sessionId).catch(() => undefined);
|
|
628
|
-
const sessionCwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
629
|
-
const newName = sessionChatName(prefix, sessionCwd);
|
|
630
|
-
try {
|
|
631
|
-
await updateChatInfo(freshToken, chatId, newName, description!);
|
|
632
|
-
console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
|
|
633
|
-
await recordSessionRegistry({ chatId, sessionId, tool: descriptionTool, chatName: newName }).catch(() => {});
|
|
634
|
-
await saveSessionTool(sessionId, descriptionTool, newName).catch(() => {});
|
|
635
|
-
} catch (err) {
|
|
636
|
-
console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (textLower === "/stop") {
|
|
641
|
-
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
642
|
-
if (stopSession(sessionId)) {
|
|
643
|
-
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
644
|
-
await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
|
|
645
|
-
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
646
|
-
} else {
|
|
647
|
-
await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
|
|
648
|
-
logTrace(tid, "DONE", { outcome: "stop_no_session" });
|
|
649
|
-
}
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
if (textLower === "/status") {
|
|
654
|
-
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
655
|
-
const status = await getSessionStatus(chatId);
|
|
656
|
-
const isActive = isSessionRunning(sessionId);
|
|
657
|
-
const statusText = [
|
|
658
|
-
`**群名:** ${status?.chatName || "—"}`,
|
|
659
|
-
`**Session ID:** \`${status?.sessionId ?? sessionId}\``,
|
|
660
|
-
`**工具:** ${toolLabel}`,
|
|
661
|
-
`**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
|
|
662
|
-
`**已对话轮数:** ${status?.turnCount ?? 0}`,
|
|
663
|
-
`**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
|
|
664
|
-
];
|
|
665
|
-
// effort 仅在该工具有此概念时显示(status?.effort 为 null 表示
|
|
666
|
-
// 当前工具没有 effort,如 Cursor,应隐藏整行避免误导)
|
|
667
|
-
if (status?.effort != null) {
|
|
668
|
-
statusText.push(`**Effort:** ${status.effort}`);
|
|
669
|
-
}
|
|
670
|
-
if (isActive) {
|
|
671
|
-
const elapsed = Math.floor((Date.now() - (status!.startTime)) / 1000);
|
|
672
|
-
const mins = Math.floor(elapsed / 60);
|
|
673
|
-
const secs = elapsed % 60;
|
|
674
|
-
statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
|
|
675
|
-
statusText.push(`**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`);
|
|
676
|
-
}
|
|
677
|
-
if (status?.lastContextTokens) {
|
|
678
|
-
statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
|
|
679
|
-
}
|
|
680
|
-
const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
|
|
681
|
-
const ok = await sendRawCard(freshToken, chatId, card);
|
|
682
|
-
console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
|
|
683
|
-
logTrace(tid, "DONE", { outcome: "status", ok });
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
if (textLower === "/sessions") {
|
|
688
|
-
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
689
|
-
const allSessions = await getAllSessionsStatus();
|
|
690
|
-
const now = Date.now();
|
|
691
|
-
const others = allSessions.filter(s => s.chatId !== chatId);
|
|
692
|
-
const cardData = others.map(s => ({
|
|
693
|
-
sessionId: s.sessionId,
|
|
694
|
-
chatName: s.chatName,
|
|
695
|
-
chatId: s.chatId,
|
|
696
|
-
active: s.active,
|
|
697
|
-
turnCount: s.turnCount,
|
|
698
|
-
elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
|
|
699
|
-
model: s.model,
|
|
700
|
-
tool: s.tool,
|
|
701
|
-
}));
|
|
702
|
-
const card = buildSessionsCard(cardData);
|
|
703
|
-
const ok = await sendRawCard(freshToken, chatId, card);
|
|
704
|
-
console.log(`[${ts()}] [SESSIONS] card sent, ok=${ok}, count=${others.length}`);
|
|
705
|
-
logTrace(tid, "DONE", { outcome: "sessions", ok, count: others.length });
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
if (textLower === "/newh") {
|
|
710
|
-
logTrace(tid, "BRANCH", { cmd: "/newh" });
|
|
711
|
-
const adapter = getAdapterForTool(descriptionTool);
|
|
712
|
-
let cwd: string;
|
|
713
|
-
try {
|
|
714
|
-
const info = await adapter.getSessionInfo(sessionId);
|
|
715
|
-
cwd = info?.cwd ?? (await getDefaultCwd(chatId));
|
|
716
|
-
} catch {
|
|
717
|
-
cwd = await getDefaultCwd(chatId);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// 不 abort 旧 session,只解绑当前 chat
|
|
721
|
-
// 旧 session 如果正在跑,display loop 继续服务其他群
|
|
722
|
-
unbindChatFromSession(sessionId, chatId);
|
|
723
|
-
displayCards.delete(chatId);
|
|
724
|
-
|
|
725
|
-
let newSessionId: string;
|
|
726
|
-
try {
|
|
727
|
-
const init = await initClaudeSession(descriptionTool, cwd);
|
|
728
|
-
newSessionId = init.sessionId;
|
|
729
|
-
} catch (err) {
|
|
730
|
-
logTrace(tid, "DONE", { outcome: "newh_session_fail", error: (err as Error).message });
|
|
731
|
-
await sendCardReply(freshToken, chatId, "Error", `Failed to create new session:\n${(err as Error).message}`, "red");
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// 绑定新 session
|
|
736
|
-
bindChatToSession(newSessionId, chatId);
|
|
737
|
-
recordLastActiveChat(newSessionId, chatId);
|
|
738
|
-
|
|
739
|
-
const descPrefix = sessionPrefixForTool(descriptionTool);
|
|
740
|
-
const newName = sessionChatName("新会话", cwd);
|
|
741
|
-
await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
|
|
742
|
-
console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
|
|
743
|
-
|
|
744
|
-
sessionInfoMap.set(chatId, {
|
|
745
|
-
sessionId: newSessionId,
|
|
746
|
-
turnCount: 0,
|
|
747
|
-
lastContextTokens: 0,
|
|
748
|
-
startTime: Date.now(),
|
|
749
|
-
tool: descriptionTool,
|
|
750
|
-
});
|
|
751
|
-
await recordSessionRegistry({
|
|
752
|
-
chatId,
|
|
753
|
-
sessionId: newSessionId,
|
|
754
|
-
tool: descriptionTool,
|
|
755
|
-
chatName: newName,
|
|
756
|
-
turnCount: 0,
|
|
757
|
-
lastContextTokens: 0,
|
|
758
|
-
startTime: Date.now(),
|
|
759
|
-
running: false,
|
|
760
|
-
});
|
|
761
|
-
await saveSessionTool(newSessionId, descriptionTool, newName);
|
|
762
|
-
|
|
763
|
-
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
764
|
-
|
|
765
|
-
// 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
|
|
766
|
-
if (isSessionRunning(newSessionId)) {
|
|
767
|
-
const { ensureDisplayLoop } = await import("./session.ts");
|
|
768
|
-
ensureDisplayLoop(newSessionId);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
await sendCardReply(
|
|
772
|
-
freshToken, chatId, `${toolLabel} Session Reset`,
|
|
773
|
-
`会话已重置为新的 **${toolLabel}** 会话。\n\n` +
|
|
774
|
-
`**Session ID:** ${newSessionId}\n` +
|
|
775
|
-
`**工作目录:** \`${cwd}\`\n\n` +
|
|
776
|
-
`直接在这里发消息即可继续对话。`,
|
|
777
|
-
"green"
|
|
778
|
-
);
|
|
779
|
-
|
|
780
|
-
console.log(`[${ts()}] [NEWH] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`);
|
|
781
|
-
logTrace(tid, "DONE", { outcome: "newh", newSessionId, cwd });
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (textLower === "/deleteg") {
|
|
786
|
-
logTrace(tid, "BRANCH", { cmd: "/deleteg" });
|
|
787
|
-
if (chatType === "p2p") {
|
|
788
|
-
await sendTextReply(freshToken, chatId, "私聊无法使用 /deleteg,该指令仅用于群聊。").catch(() => {});
|
|
789
|
-
logTrace(tid, "DONE", { outcome: "deleteg_p2p" });
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
console.log(`[${ts()}] [DELETEG] Disbanding group chat ${chatId}, session=${sessionId}`);
|
|
793
|
-
|
|
794
|
-
// 先解绑 session(不删除 Agent 会话)
|
|
795
|
-
unbindChatFromSession(sessionId, chatId);
|
|
796
|
-
displayCards.delete(chatId);
|
|
797
|
-
sessionInfoMap.delete(chatId);
|
|
798
|
-
await removeSessionRegistryRecord(chatId);
|
|
799
|
-
|
|
800
|
-
await sendTextReply(freshToken, chatId, "群聊已解散,Agent 会话保留。").catch(() => {});
|
|
801
|
-
|
|
802
|
-
// 解散群聊(飞书 API)
|
|
803
|
-
try {
|
|
804
|
-
await disbandChat(freshToken, chatId);
|
|
805
|
-
console.log(`[${ts()}] [DELETEG] Group disbanded: ${chatId}`);
|
|
806
|
-
} catch (err) {
|
|
807
|
-
console.error(`[${ts()}] [DELETEG] Disband API failed: ${(err as Error).message}`);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
logTrace(tid, "DONE", { outcome: "deleteg", chatId, sessionId });
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// /session <number>:切换到 /sessions 列表中的指定会话
|
|
815
|
-
const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
|
|
816
|
-
if (sessionMatch) {
|
|
817
|
-
const index = parseInt(sessionMatch[1], 10) - 1;
|
|
818
|
-
logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
|
|
819
|
-
const allSessions = await getAllSessionsStatus();
|
|
820
|
-
// 与 buildSessionsCard 保持一致的排序:Claude Code → Cursor → Codex,组内保持 updatedAt 降序
|
|
821
|
-
const claudeOrdered = allSessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
|
|
822
|
-
const cursorOrdered = allSessions.filter(s => s.tool === "cursor");
|
|
823
|
-
const codexOrdered = allSessions.filter(s => s.tool === "codex");
|
|
824
|
-
const ordered = [...claudeOrdered, ...cursorOrdered, ...codexOrdered].filter(s => s.chatId !== chatId);
|
|
825
|
-
if (ordered.length === 0) {
|
|
826
|
-
await sendCardReply(freshToken, chatId, "/session", "暂无历史会话。", "yellow");
|
|
827
|
-
logTrace(tid, "DONE", { outcome: "session_no_sessions" });
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
if (index < 0 || index >= ordered.length) {
|
|
831
|
-
await sendCardReply(freshToken, chatId, "/session", `序号超出范围,当前共 ${ordered.length} 个会话。`, "yellow");
|
|
832
|
-
logTrace(tid, "DONE", { outcome: "session_out_of_range", index: index + 1, total: ordered.length });
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
const target = ordered[index];
|
|
836
|
-
|
|
837
|
-
// 不 abort 当前 chat 的旧 session,只解绑再重新绑定
|
|
838
|
-
if (sessionId) {
|
|
839
|
-
unbindChatFromSession(sessionId, chatId);
|
|
840
|
-
displayCards.delete(chatId);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
const targetAdapter = getAdapterForTool(target.tool);
|
|
844
|
-
let cwd2: string;
|
|
845
|
-
try {
|
|
846
|
-
const targetInfo = await targetAdapter.getSessionInfo(target.sessionId);
|
|
847
|
-
cwd2 = targetInfo?.cwd ?? (await getDefaultCwd(chatId));
|
|
848
|
-
} catch {
|
|
849
|
-
cwd2 = await getDefaultCwd(chatId);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// 绑定到新 session
|
|
853
|
-
bindChatToSession(target.sessionId, chatId);
|
|
854
|
-
recordLastActiveChat(target.sessionId, chatId);
|
|
855
|
-
|
|
856
|
-
const descPrefix2 = sessionPrefixForTool(target.tool);
|
|
857
|
-
const newName2 = target.chatName || sessionChatName("新会话", cwd2);
|
|
858
|
-
await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
|
|
859
|
-
console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
|
|
860
|
-
|
|
861
|
-
sessionInfoMap.set(chatId, {
|
|
862
|
-
sessionId: target.sessionId,
|
|
863
|
-
turnCount: target.turnCount,
|
|
864
|
-
lastContextTokens: 0,
|
|
865
|
-
startTime: Date.now(),
|
|
866
|
-
tool: target.tool,
|
|
867
|
-
});
|
|
868
|
-
await recordSessionRegistry({
|
|
869
|
-
chatId,
|
|
870
|
-
sessionId: target.sessionId,
|
|
871
|
-
tool: target.tool,
|
|
872
|
-
chatName: newName2,
|
|
873
|
-
running: false,
|
|
874
|
-
});
|
|
875
|
-
await saveSessionTool(target.sessionId, target.tool, newName2);
|
|
876
|
-
|
|
877
|
-
setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
|
|
878
|
-
|
|
879
|
-
// 如果新 session 有活跃 prompt,加上 display loop
|
|
880
|
-
if (isSessionRunning(target.sessionId)) {
|
|
881
|
-
const { ensureDisplayLoop } = await import("./session.ts");
|
|
882
|
-
ensureDisplayLoop(target.sessionId);
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
const targetToolLabel = toolDisplayName(target.tool);
|
|
886
|
-
const busyNote = isSessionRunning(target.sessionId) ? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。" : "";
|
|
887
|
-
await sendCardReply(
|
|
888
|
-
freshToken, chatId, `${targetToolLabel} Session Switched`,
|
|
889
|
-
`已切换到 **${targetToolLabel}** 会话。\n\n` +
|
|
890
|
-
`**序号:** ${index + 1}\n` +
|
|
891
|
-
`**Session ID:** ${target.sessionId}\n` +
|
|
892
|
-
`**工作目录:** \`${cwd2}\`\n\n` +
|
|
893
|
-
`直接在这里发消息即可继续对话。${busyNote}`,
|
|
894
|
-
"green"
|
|
895
|
-
);
|
|
896
|
-
|
|
897
|
-
logTrace(tid, "DONE", { outcome: "session_switch", sessionId: target.sessionId, index: index + 1, cwd: cwd2 });
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// /git <args>:在「当前会话工作目录」执行 git 命令,把输出回发到群里。
|
|
902
|
-
// 注意 cwd 必须取自 adapter.getSessionInfo(会话真实 cwd),而非
|
|
903
|
-
// getDefaultCwd(下一次 /new 才会使用的默认路径)。
|
|
904
|
-
if (textLower.startsWith("/git ") || textLower === "/git") {
|
|
905
|
-
const args = text === "/git" ? "" : text.slice(5).trim();
|
|
906
|
-
logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
|
|
907
|
-
if (!args) {
|
|
908
|
-
logTrace(tid, "DONE", { outcome: "git_no_args" });
|
|
909
|
-
await sendCardReply(
|
|
910
|
-
freshToken, chatId, "/git",
|
|
911
|
-
"用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
|
|
912
|
-
"yellow"
|
|
913
|
-
);
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
const adapter = getAdapterForTool(descriptionTool);
|
|
918
|
-
let cwd: string | undefined;
|
|
919
|
-
try {
|
|
920
|
-
const info = await adapter.getSessionInfo(sessionId);
|
|
921
|
-
cwd = info?.cwd;
|
|
922
|
-
} catch (err) {
|
|
923
|
-
console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`);
|
|
924
|
-
}
|
|
925
|
-
if (!cwd) {
|
|
926
|
-
logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
|
|
927
|
-
// Cursor 会话的 cwd 依赖 state/cursor-session-meta.json 持久化映射;
|
|
928
|
-
// 升级前创建的旧会话或映射文件丢失时,向会话发送一次普通消息即可触发
|
|
929
|
-
// adapter 自动学习并补全(resume 流首条 init 事件携带 cwd)。
|
|
930
|
-
const isCursor = descriptionTool === "cursor";
|
|
931
|
-
const hint = isCursor
|
|
932
|
-
? "无法获取当前 Cursor 会话的工作目录(缺少 sessionId→cwd 持久化映射)。请先在本群发送一条普通消息(让 adapter 从 cursor-agent 流中自动补回 cwd),然后再试 /git;若仍失败,可用 /new 重建会话。"
|
|
933
|
-
: `无法获取当前会话的工作目录(${toolLabel} adapter 未返回 cwd)。请先与 AI 对话一次再试,或检查会话是否仍存在。`;
|
|
934
|
-
await sendCardReply(freshToken, chatId, "/git", hint, "red");
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
console.log(`[${ts()}] [GIT] chat=${chatId} cwd=${cwd} cmd="git ${args}" timeoutMs=${GIT_TIMEOUT_MS}`);
|
|
939
|
-
const result = await runGitCommand(args, cwd, { timeoutMs: GIT_TIMEOUT_MS });
|
|
940
|
-
console.log(`[${ts()}] [GIT] exitCode=${result.exitCode}, durationMs=${result.durationMs}, truncated=${result.truncated}, timedOut=${result.timedOut}`);
|
|
941
|
-
const content = formatGitResult(args, cwd, result);
|
|
942
|
-
const template = gitResultHeaderTemplate(result);
|
|
943
|
-
await sendCardReply(freshToken, chatId, "/git 输出", content, template);
|
|
944
|
-
logTrace(tid, "DONE", { outcome: "git_result", exitCode: result.exitCode, durationMs: result.durationMs });
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const lastTs = lastMsgTimestamps.get(chatId);
|
|
949
|
-
if (lastTs !== undefined && msgTimestamp <= lastTs) {
|
|
950
|
-
logTrace(tid, "DONE", { outcome: "skip_old_message_no_session", msgTimestamp, lastTimestamp: lastTs });
|
|
951
|
-
console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${lastTs}), no active session, ignoring`);
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
956
|
-
if (isSessionRunning(sessionId)) {
|
|
957
|
-
logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
|
|
958
|
-
console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`);
|
|
959
|
-
await sendCardReply(
|
|
960
|
-
freshToken, chatId, "生成中",
|
|
961
|
-
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
962
|
-
"yellow"
|
|
963
|
-
);
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
try {
|
|
968
|
-
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
969
|
-
await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool, tid);
|
|
970
|
-
logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
|
|
971
|
-
console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
|
|
972
|
-
} catch (err) {
|
|
973
|
-
logTrace(tid, "DONE", { outcome: "resume_fail", error: (err as Error).message });
|
|
974
|
-
console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
|
|
975
|
-
fileLog.flush();
|
|
976
|
-
await sendCardReply(
|
|
977
|
-
freshToken, chatId, "Error",
|
|
978
|
-
`Failed to resume ${toolLabel} session:\n${(err as Error).message}`,
|
|
979
|
-
"red"
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// 无会话上下文 → help card
|
|
986
|
-
|
|
987
|
-
// 私聊或群聊无 session info → 发送 help card
|
|
988
|
-
logTrace(tid, "SEND", { method: "help_card", chatId });
|
|
989
|
-
const replyToken = await getTenantAccessToken();
|
|
990
|
-
const card = buildHelpCard(text);
|
|
991
|
-
const ok = await sendRawCard(replyToken, chatId, card);
|
|
992
|
-
if (!ok) {
|
|
993
|
-
console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId}`);
|
|
994
|
-
logTrace(tid, "DONE", { outcome: "help_card_fail" });
|
|
995
|
-
} else {
|
|
996
|
-
console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId}`);
|
|
997
|
-
logTrace(tid, "DONE", { outcome: "help_card_sent" });
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
344
|
// ---------------------------------------------------------------------------
|
|
1002
345
|
// startBotService — 飞书 service 的启动逻辑(独立于 main())
|
|
1003
346
|
// ---------------------------------------------------------------------------
|
|
@@ -1134,7 +477,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1134
477
|
const delayToken = await getTenantAccessToken();
|
|
1135
478
|
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
1136
479
|
}
|
|
1137
|
-
await handleCommand(text, chatId, openId, msgTimestamp, chatType, traceId);
|
|
480
|
+
await handleCommand(feishuPlatform, text, chatId, openId, msgTimestamp, chatType, traceId);
|
|
1138
481
|
} catch (err) {
|
|
1139
482
|
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
1140
483
|
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
@@ -1174,7 +517,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1174
517
|
const result = parseCardAction(data);
|
|
1175
518
|
if (!result) return;
|
|
1176
519
|
console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
|
|
1177
|
-
handleCommand(result.text, result.chatId, result.openId, Date.now()).catch((err) =>
|
|
520
|
+
handleCommand(feishuPlatform, result.text, result.chatId, result.openId, Date.now()).catch((err) =>
|
|
1178
521
|
console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
|
|
1179
522
|
);
|
|
1180
523
|
} catch (err) {
|
|
@@ -1199,7 +542,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1199
542
|
const data = JSON.parse(raw.toString()) as Evt;
|
|
1200
543
|
const action = parseCardAction(data);
|
|
1201
544
|
if (action) {
|
|
1202
|
-
handleCommand(action.text, action.chatId, action.openId, Date.now()).catch((err) =>
|
|
545
|
+
handleCommand(feishuPlatform, action.text, action.chatId, action.openId, Date.now()).catch((err) =>
|
|
1203
546
|
console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
|
|
1204
547
|
);
|
|
1205
548
|
return;
|
|
@@ -1231,25 +574,34 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1231
574
|
console.error(`[WS] Local relay error: ${err.message}`);
|
|
1232
575
|
});
|
|
1233
576
|
} else {
|
|
577
|
+
// 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
|
|
578
|
+
// 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
|
|
1234
579
|
resetState();
|
|
1235
|
-
// 启动时修正残留的 running 状态并重建 session→chat 映射
|
|
1236
580
|
fixStaleStreamStates().then(async () => {
|
|
1237
581
|
const registry = await loadSessionRegistryForBinding();
|
|
1238
582
|
rebuildSessionChatsFromRegistry(registry);
|
|
1239
583
|
}).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
|
|
1240
584
|
|
|
585
|
+
// ⚠️ 关键设计:onReady / onReconnected 只重建只读映射,**绝不**清空运行时
|
|
586
|
+
// 状态(activePrompts、displayLoops、sessionInfoMap、processedMessages)。
|
|
587
|
+
// SDK 重连只是底层 WebSocket 抖动,业务层不受影响:
|
|
588
|
+
// - 后台 generator 仍在跑、stream-state 仍在被写
|
|
589
|
+
// - display loop 仍在向群推送
|
|
590
|
+
// - 已处理消息的去重 set 必须保留,避免 SDK 重推老消息时 prompt 跑两遍
|
|
591
|
+
// 历史 bug:此处曾误调 resetState() 导致重连即让所有后台任务变孤儿,
|
|
592
|
+
// 同一 session 还可能双开 prompt(详见 session.ts::resetState 注释)。
|
|
1241
593
|
const wsClient = new WSClient({
|
|
1242
594
|
appId: APP_ID,
|
|
1243
595
|
appSecret: APP_SECRET,
|
|
1244
596
|
onReady: async () => {
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
597
|
+
await rebuildBindingsFromRegistry().catch((err) =>
|
|
598
|
+
console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
|
|
599
|
+
);
|
|
1248
600
|
},
|
|
1249
601
|
onReconnected: async () => {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
602
|
+
await rebuildBindingsFromRegistry().catch((err) =>
|
|
603
|
+
console.error(`[${ts()}] [SDK RECONNECT] rebuild bindings failed: ${(err as Error).message}`)
|
|
604
|
+
);
|
|
1253
605
|
},
|
|
1254
606
|
});
|
|
1255
607
|
|
|
@@ -1273,6 +625,40 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
1273
625
|
}
|
|
1274
626
|
}
|
|
1275
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
|
+
|
|
1276
662
|
// ---------------------------------------------------------------------------
|
|
1277
663
|
// Main
|
|
1278
664
|
// ---------------------------------------------------------------------------
|
|
@@ -1300,7 +686,7 @@ async function main(): Promise<void> {
|
|
|
1300
686
|
// 注册消息处理器,让 SimAgent.sendMessage() 能进程内触发 handleCommand
|
|
1301
687
|
setMessageHandler(
|
|
1302
688
|
(text, chatId, openId, _ts, chatType, traceId) =>
|
|
1303
|
-
handleCommand(text, chatId, openId, Date.now(), chatType, traceId),
|
|
689
|
+
handleCommand(feishuPlatform, text, chatId, openId, Date.now(), chatType, traceId),
|
|
1304
690
|
);
|
|
1305
691
|
|
|
1306
692
|
setExtraApiHandler(async (req, res) => {
|
|
@@ -1371,45 +757,43 @@ async function main(): Promise<void> {
|
|
|
1371
757
|
reportEnvironmentVariableReadout();
|
|
1372
758
|
console.log(` 工作目录: ${process.cwd()}`);
|
|
1373
759
|
console.log(` 包根目录: ${PROJECT_ROOT}`);
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
// 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
|
|
1380
|
-
// 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
|
|
1381
|
-
startSetupMode(CHATCCC_PORT, {
|
|
1382
|
-
onActivate: async (httpServer: Server) => {
|
|
1383
|
-
// 关键:用户刚把新凭证写入 config.json,需要先把进程内的 APP_ID 等
|
|
1384
|
-
// 常量同步到磁盘最新值,否则 startBotService 拿到的是 chatccc 启动时
|
|
1385
|
-
// 加载的(空)凭证。reloadConfigFromDisk 利用 ES module live binding
|
|
1386
|
-
// 让所有"export let"消费方自动看到新值。
|
|
1387
|
-
reloadConfigFromDisk();
|
|
1388
|
-
appendStartupTrace("setup-activate: reloaded config from disk", {
|
|
1389
|
-
appIdMaskAfterReload: maskAppId(APP_ID),
|
|
1390
|
-
});
|
|
1391
|
-
try {
|
|
1392
|
-
await startBotService({ httpServer, port: CHATCCC_PORT });
|
|
1393
|
-
// 切换成功:注册 SIGINT/SIGTERM 让 Ctrl-C 也能优雅退出
|
|
1394
|
-
installShutdownHandlers(httpServer);
|
|
1395
|
-
return { ok: true };
|
|
1396
|
-
} catch (err) {
|
|
1397
|
-
appendStartupTrace("setup-activate: startBotService failed", {
|
|
1398
|
-
message: (err as Error).message,
|
|
1399
|
-
});
|
|
1400
|
-
// 不退出 chatccc 进程——setup HTTP server 还在监听,让用户改完
|
|
1401
|
-
// config 再点一次。
|
|
1402
|
-
return { ok: false, error: (err as Error).message };
|
|
1403
|
-
}
|
|
1404
|
-
},
|
|
760
|
+
|
|
761
|
+
if (FEISHU_ENABLED) {
|
|
762
|
+
appendStartupTrace("main: before feishu credential check", {
|
|
763
|
+
hasAppId: Boolean(APP_ID.trim()),
|
|
764
|
+
hasAppSecret: Boolean(APP_SECRET.trim()),
|
|
1405
765
|
});
|
|
1406
|
-
|
|
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", {});
|
|
1407
794
|
}
|
|
1408
|
-
console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
|
|
1409
|
-
appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
|
|
1410
795
|
|
|
1411
|
-
//
|
|
1412
|
-
// 把 WS 中继和飞书 SDK 挂上去。
|
|
796
|
+
// 启动 HTTP server(同时挂 UI router,供 dashboard / setup / agent image/file 使用)
|
|
1413
797
|
appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
|
|
1414
798
|
const killed = freeRelayListenPort(CHATCCC_PORT);
|
|
1415
799
|
appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed });
|
|
@@ -1427,13 +811,20 @@ async function main(): Promise<void> {
|
|
|
1427
811
|
process.exit(1);
|
|
1428
812
|
});
|
|
1429
813
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
+
}
|
|
1435
821
|
}
|
|
1436
822
|
|
|
823
|
+
// 启动微信 iLink 平台(后台运行,不阻塞飞书)
|
|
824
|
+
startWechatSupervisor().catch((err) =>
|
|
825
|
+
console.error(`[WX] 微信 supervisor 异常退出: ${(err as Error).message}`),
|
|
826
|
+
);
|
|
827
|
+
|
|
1437
828
|
installShutdownHandlers(httpServer);
|
|
1438
829
|
}
|
|
1439
830
|
|
|
@@ -1474,8 +865,8 @@ async function listenWithRetry(
|
|
|
1474
865
|
* 先写盘,再走这里。
|
|
1475
866
|
*/
|
|
1476
867
|
function installShutdownHandlers(httpServer: Server): void {
|
|
1477
|
-
process.on("SIGINT", () => { console.log("\nShutting down..."); httpServer.close(); process.exit(0); });
|
|
1478
|
-
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); });
|
|
1479
870
|
}
|
|
1480
871
|
|
|
1481
872
|
main().catch((err: Error) => {
|