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.
- package/README.md +103 -217
- package/bin/chatccc.mjs +23 -23
- package/config.sample.json +34 -30
- package/demo/ilink_echo_probe.ts +222 -222
- package/im-skills/feishu-skill/download-video.mjs +162 -162
- package/im-skills/feishu-skill/receive-send-file.md +66 -66
- package/im-skills/feishu-skill/receive-send-image.md +27 -27
- package/im-skills/feishu-skill/send-file.mjs +50 -50
- package/im-skills/feishu-skill/send-image.mjs +50 -50
- package/im-skills/feishu-skill/skill.md +10 -10
- package/package.json +59 -59
- package/src/__tests__/agent-image-rpc.test.ts +33 -33
- package/src/__tests__/agent-rpc-body.test.ts +41 -41
- package/src/__tests__/card-plain-text.test.ts +42 -0
- package/src/__tests__/claude-adapter.test.ts +54 -1
- package/src/__tests__/codex-adapter.test.ts +304 -304
- package/src/__tests__/config-reload.test.ts +58 -41
- package/src/__tests__/config-sample.test.ts +19 -0
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
- package/src/__tests__/im-skills.test.ts +46 -46
- package/src/__tests__/session.test.ts +95 -2
- package/src/__tests__/sim-agent.test.ts +173 -173
- package/src/__tests__/sim-platform.test.ts +76 -76
- package/src/__tests__/sim-store.test.ts +213 -213
- package/src/__tests__/stream-state.test.ts +134 -134
- package/src/__tests__/wechat-platform.test.ts +57 -0
- package/src/adapters/claude-adapter.ts +23 -2
- package/src/adapters/codex-adapter.ts +294 -294
- package/src/adapters/codex-session-meta-store.ts +130 -130
- package/src/agent-file-rpc.ts +156 -156
- package/src/agent-image-rpc.ts +152 -152
- package/src/agent-rpc-body.ts +48 -48
- 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/im-skills.ts +109 -109
- package/src/index.ts +155 -788
- package/src/orchestrator.ts +1032 -0
- package/src/platform-adapter.ts +61 -0
- package/src/session-chat-binding.ts +7 -0
- package/src/session.ts +278 -113
- package/src/sim-agent.ts +167 -156
- package/src/trace.ts +50 -50
- 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,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 {
|
|
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,
|
|
100
90
|
rebuildBindingsFromRegistry,
|
|
101
91
|
resetState,
|
|
102
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Feishu 平台适配器
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
128
104
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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) => {
|