chatccc 0.1.5 → 0.2.0

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/src/index.ts CHANGED
@@ -1,600 +1,709 @@
1
- /**
2
- * ChatCCC — Feishu Bot Bridge for Claude Code (TypeScript)
3
- * =================================================================
4
- * When a user sends "/new" to the bot:
5
- * 1. Create a Claude session via Agent SDK, get session ID from init event
6
- * 2. Create a new Feishu group chat and add the user
7
- * 3. Rename the group (name + description) to the session ID
8
- * 4. Stream SDK output to logs/session-<session-id>.jsonl
9
- * 5. Reply to the user with session ID and resume instructions
10
- *
11
- * Auto-resume: when any message is received in a Claude session group
12
- * (group description contains "Claude Session:"), the bot extracts the
13
- * session ID, resumes the session via SDK, sends the user's text, and
14
- * streams the response to the session's jsonl file.
15
- *
16
- * Buttons: thinking cards have a 停止 button; help messages have /new and /restart buttons.
17
- *
18
- * Usage:
19
- * npm run dev
20
- * npm run start
21
- * npm run demo:create-group -- --local (local relay mode)
22
- */
23
-
24
- import { spawn } from "node:child_process";
25
- import { readdir, stat } from "node:fs/promises";
26
- import { resolve, dirname, basename } from "node:path";
27
-
28
- import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
29
- import WebSocket from "ws";
30
-
31
- import { ensureSingleInstance, createRelayServer } from "./shared.ts";
32
- import {
33
- CHATCCC_PORT,
34
- APP_ID,
35
- APP_SECRET,
36
- BASE_URL,
37
- CLAUDE_MODEL,
38
- LOCAL_RELAY_URL,
39
- PID_FILE,
40
- PROJECT_ROOT,
41
- USE_LOCAL,
42
- appendChatLog,
43
- fileLog,
44
- getDefaultCwd,
45
- setDefaultCwd,
46
- ts,
47
- } from "./config.ts";
48
- import {
49
- addReaction,
50
- createGroupChat,
51
- extractSessionId,
52
- getChatInfo,
53
- getTenantAccessToken,
54
- recallMessage,
55
- sendCardReply,
56
- sendTextReply,
57
- updateCardMessage,
58
- updateChatInfo,
59
- sendRestartCard,
60
- } from "./feishu-api.ts";
61
- import { buildHelpCard, buildStatusCard, buildThinkingCardV2, buildCdContent } from "./cards.ts";
62
- import { updateCardKitCard } from "./cardkit.ts";
63
- import {
64
- MAX_PROCESSED,
65
- chatSessionMap,
66
- getSessionStatus,
67
- initClaudeSession,
68
- processedMessages,
69
- resetState,
70
- resumeAndPrompt,
71
- sessionInfoMap,
72
- } from "./session.ts";
73
-
74
- // ---------------------------------------------------------------------------
75
- // Event types
76
- // ---------------------------------------------------------------------------
77
-
78
- interface InnerEvent {
79
- message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; create_time?: string };
80
- sender?: { sender_id?: { open_id?: string; union_id?: string } };
81
- }
82
-
83
- type Evt = { event?: InnerEvent } & InnerEvent;
84
-
85
- function getInnerEvent(data: Evt): InnerEvent {
86
- return (data.event ?? data) as InnerEvent;
87
- }
88
-
89
- function extractText(message: { message_type?: string; content?: string }): string {
90
- const contentStr = message.content ?? "{}";
91
- let content: Record<string, unknown>;
92
- try { content = JSON.parse(contentStr); } catch { return ""; }
93
-
94
- if (message.message_type === "text") {
95
- let text = (content.text ?? "") as string;
96
- text = text.replace(/<\/?p[^>]*>/gi, "");
97
- text = text.replace(/<br\s*\/?>/gi, "\n");
98
- text = text.replace(/&nbsp;/gi, " ");
99
- return text.trim();
100
- }
101
- return "";
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Card action helper: parse button click into text command
106
- // ---------------------------------------------------------------------------
107
-
108
- interface CardActionResult {
109
- text: string;
110
- chatId: string;
111
- openId: string;
112
- }
113
-
114
- function parseCardAction(data: unknown): CardActionResult | null {
115
- const raw = (data as Record<string, unknown>)?.event ?? data;
116
- const action = (raw as Record<string, unknown>)?.action as { value?: unknown } | undefined;
117
- if (!action?.value) return null;
118
-
119
- let cmd: string | undefined;
120
- if (typeof action.value === "object" && action.value !== null) {
121
- cmd = (action.value as Record<string, string>).action;
122
- } else if (typeof action.value === "string") {
123
- try {
124
- let v: unknown = JSON.parse(action.value);
125
- if (typeof v === "string") v = JSON.parse(v);
126
- cmd = (v as { cmd?: string; action?: string }).cmd ?? (v as { action?: string }).action;
127
- } catch { return null; }
128
- }
129
- if (!cmd) return null;
130
-
131
- const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", restart: "/restart", status: "/status", cd: "/cd" };
132
- const text = CMD_MAP[cmd] ?? "";
133
- if (!text) return null;
134
-
135
- const chatId =
136
- ((raw as Record<string, unknown>).open_chat_id as string) ??
137
- ((raw as Record<string, unknown>).context as Record<string, unknown>)?.open_chat_id as string ??
138
- ((raw as Record<string, unknown>).message as Record<string, unknown>)?.chat_id as string ??
139
- "";
140
- const openId =
141
- ((raw as Record<string, unknown>).operator as Record<string, unknown>)?.open_id as string ??
142
- "";
143
-
144
- return { text, chatId, openId };
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // WebSocket relay broadcast
149
- // ---------------------------------------------------------------------------
150
-
151
- let broadcastToRelay: (data: unknown) => void = () => {};
152
-
153
- // ---------------------------------------------------------------------------
154
- // Command handler
155
- // ---------------------------------------------------------------------------
156
-
157
- async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number): Promise<void> {
158
- if (text === "/restart") {
159
- const restartToken = await getTenantAccessToken();
160
- await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
161
- console.log(`[${ts()}] [RESTART] Spawning new process...`);
162
- const child = spawn("npx", ["tsx", "--env-file=.env", "src/index.ts"], {
163
- cwd: PROJECT_ROOT,
164
- detached: true,
165
- stdio: "ignore",
166
- shell: true,
167
- });
168
- child.unref();
169
- setTimeout(() => process.exit(0), 200);
170
- return;
171
- }
172
-
173
- if (text === "/cd" || text.startsWith("/cd ")) {
174
- const cdToken = await getTenantAccessToken();
175
- const currentDir = await getDefaultCwd();
176
- const arg = text.slice(3).trim(); // everything after "/cd" (may be empty)
177
-
178
- // Resolve target directory
179
- let targetDir: string;
180
- if (!arg) {
181
- targetDir = currentDir;
182
- } else if (arg === "..") {
183
- targetDir = dirname(currentDir);
184
- } else {
185
- targetDir = resolve(currentDir, arg);
186
- }
187
-
188
- // Verify the target exists and is a directory
189
- try {
190
- const s = await stat(targetDir);
191
- if (!s.isDirectory()) {
192
- await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
193
- return;
194
- }
195
- } catch {
196
- await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
197
- return;
198
- }
199
-
200
- // Change working dir if user provided a path
201
- const isUpdate = !!arg && targetDir !== currentDir;
202
- if (isUpdate) {
203
- await setDefaultCwd(targetDir);
204
- }
205
-
206
- // Read directory entries
207
- let entries: string[];
208
- try {
209
- entries = await readdir(targetDir);
210
- } catch (err) {
211
- await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
212
- return;
213
- }
214
-
215
- // Sort: directories first, then files, alphabetically within each group
216
- const withStats: { name: string; isDir: boolean }[] = [];
217
- for (const name of entries) {
218
- try {
219
- const s = await stat(resolve(targetDir, name));
220
- withStats.push({ name, isDir: s.isDirectory() });
221
- } catch { withStats.push({ name, isDir: false }); }
222
- }
223
- withStats.sort((a, b) => {
224
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
225
- return a.name.localeCompare(b.name);
226
- });
227
-
228
- const content = buildCdContent(targetDir, withStats, isUpdate);
229
- await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
230
- return;
231
- }
232
-
233
- if (text === "/new") {
234
- if (!openId) {
235
- console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
236
- const warnToken = await getTenantAccessToken();
237
- await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
238
- return;
239
- }
240
-
241
- const freshToken = await getTenantAccessToken();
242
-
243
- let sessionId: string;
244
- try {
245
- sessionId = await initClaudeSession();
246
- console.log(`[${ts()}] [STEP 1/4] Claude SDK session created: ${sessionId} → OK`);
247
- } catch (err) {
248
- console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
249
- await sendCardReply(
250
- freshToken, chatId, "Error",
251
- `Failed to initialize Claude session:\n${(err as Error).message}`,
252
- "red"
253
- );
254
- return;
255
- }
256
-
257
- let newChatId: string;
258
- try {
259
- newChatId = await createGroupChat(freshToken, `新会话-${sessionId}`, [openId]);
260
- console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
261
- } catch (err) {
262
- console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
263
- await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
264
- return;
265
- }
266
-
267
- try {
268
- const initialName = `新会话-${sessionId}`;
269
- await updateChatInfo(freshToken, newChatId, initialName, `Claude Session: ${sessionId}`);
270
- console.log(`[${ts()}] [STEP 3/4] Renamed group name="${initialName}" → OK`);
271
- } catch (err) {
272
- console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
273
- await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
274
- return;
275
- }
276
-
277
- await sendCardReply(
278
- freshToken, newChatId, "Claude Session Ready",
279
- `群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n\n直接在这里发消息即可与 Claude 对话。`,
280
- "green"
281
- );
282
-
283
- const resumeCmd = `claude --resume ${sessionId}`;
284
- await sendCardReply(
285
- freshToken, chatId, "Group + Claude Session Ready",
286
- `**Session ID:** ${sessionId}\n` +
287
- `**Group:** created (check your chat list)\n\n` +
288
- `Resume Claude session:\n\`\`\`\n${resumeCmd}\n\`\`\``,
289
- "green"
290
- );
291
- console.log(`[${ts()}] [STEP 4/4] Replied to user → OK`);
292
- console.log(`${"=".repeat(60)}`);
293
- return;
294
- }
295
-
296
- try {
297
- const token = await getTenantAccessToken();
298
- const chatInfo = await getChatInfo(token, chatId);
299
- const description = chatInfo.description;
300
- const sessionId = extractSessionId(description);
301
-
302
- if (sessionId) {
303
- console.log(`[${ts()}] [RESUME] 克劳德会话群 detected, session=${sessionId}`);
304
-
305
- const freshToken = await getTenantAccessToken();
306
-
307
- if (chatInfo.name === `新会话-${sessionId}`) {
308
- const MAX_PREFIX = 20;
309
- const prefix = text.slice(0, MAX_PREFIX);
310
- const newName = `${prefix} ${sessionId}`;
311
- try {
312
- await updateChatInfo(freshToken, chatId, newName, description);
313
- console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
314
- } catch (err) {
315
- console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
316
- }
317
- }
318
-
319
- if (text === "/stop") {
320
- const cEntry = chatSessionMap.get(chatId);
321
- if (cEntry) {
322
- cEntry.stopped = true;
323
- if (cEntry.spinnerTimer) { clearInterval(cEntry.spinnerTimer); cEntry.spinnerTimer = null; }
324
- cEntry.close();
325
- console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
326
- await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
327
- } else {
328
- await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
329
- }
330
- return;
331
- }
332
-
333
- if (text === "/status") {
334
- const status = getSessionStatus(chatId);
335
- const running = chatSessionMap.get(chatId);
336
- const isActive = running && !running.stopped;
337
- const statusText = [
338
- `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
339
- `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
340
- `**已对话轮数:** ${status?.turnCount ?? 0}`,
341
- `**模型:** ${status?.model ?? CLAUDE_MODEL}`,
342
- `**Effort:** ${status?.effort ?? "N/A"}`,
343
- ];
344
- if (isActive) {
345
- const elapsed = Math.floor((Date.now() - (status!.startTime)) / 1000);
346
- const mins = Math.floor(elapsed / 60);
347
- const secs = elapsed % 60;
348
- statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
349
- statusText.push(`**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`);
350
- }
351
- if (status?.lastContextTokens) {
352
- statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
353
- }
354
- const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
355
- const statusResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
356
- method: "POST",
357
- headers: {
358
- Authorization: `Bearer ${freshToken}`,
359
- "Content-Type": "application/json",
360
- },
361
- body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
362
- });
363
- const statusRespData: Record<string, any> = await statusResp.json().catch(() => ({}));
364
- console.log(`[${ts()}] [STATUS] card sent, code=${statusRespData.code}, msgId=${statusRespData.data?.message_id ?? "N/A"}`);
365
- return;
366
- }
367
-
368
- const existing = chatSessionMap.get(chatId);
369
- if (existing && !existing.stopped) {
370
- if (msgTimestamp <= existing.msgTimestamp) {
371
- console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
372
- return;
373
- }
374
- existing.stopped = true;
375
- if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
376
- existing.close();
377
- chatSessionMap.delete(chatId);
378
- console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
379
- if (existing.cardId) {
380
- while (existing.cardBusy) {
381
- await new Promise(r => setTimeout(r, 20));
382
- }
383
- const cardId = existing.cardId;
384
- const thinking = existing.accumulatedThinking;
385
- const interruptedCard = buildThinkingCardV2(
386
- thinking || "新问题已提交,当前回复已中断。",
387
- { showStop: false, headerTitle: "已中断", headerTemplate: "yellow" }
388
- );
389
- let nextSeq = existing.sequence + 1;
390
- await updateCardKitCard(freshToken, cardId, interruptedCard, nextSeq).catch((err) => {
391
- console.error(`[${ts()}] [INTERRUPT] CardKit update failed: ${(err as Error).message}`);
392
- });
393
- }
394
- }
395
-
396
- try {
397
- await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp);
398
- console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
399
- } catch (err) {
400
- console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
401
- await sendCardReply(
402
- freshToken, chatId, "Error",
403
- `Failed to resume Claude session:\n${(err as Error).message}`,
404
- "red"
405
- );
406
- }
407
- return;
408
- }
409
- } catch (err) {
410
- console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
411
- }
412
-
413
- const replyToken = await getTenantAccessToken();
414
- const card = buildHelpCard(text);
415
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
416
- method: "POST",
417
- headers: {
418
- Authorization: `Bearer ${replyToken}`,
419
- "Content-Type": "application/json",
420
- },
421
- body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
422
- });
423
- }
424
-
425
- // ---------------------------------------------------------------------------
426
- // Main
427
- // ---------------------------------------------------------------------------
428
-
429
- async function main(): Promise<void> {
430
- ensureSingleInstance(PID_FILE, CHATCCC_PORT);
431
-
432
- if (!APP_ID || !APP_SECRET) {
433
- console.log("ERROR: FEISHU_CLAUDER_APP_ID / FEISHU_CLAUDER_APP_SECRET not set");
434
- process.exit(1);
435
- }
436
-
437
- const { server: relayServer, broadcast } = createRelayServer(CHATCCC_PORT);
438
- broadcastToRelay = broadcast;
439
-
440
- const modeTag = USE_LOCAL ? " (local relay mode)" : "";
441
- console.log(`${"=".repeat(60)}`);
442
- console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
443
- console.log(`${"=".repeat(60)}`);
444
- console.log(` Send "/new" to the bot to create a new group + Claude session.`);
445
- console.log(` In a Claude session group, send any message to resume & prompt.`);
446
- console.log(`${"=".repeat(60)}`);
447
-
448
- const token = await getTenantAccessToken();
449
- console.log(`[${ts()}] [AUTH] Token obtained`);
450
-
451
- const eventDispatcher = new EventDispatcher({});
452
- eventDispatcher.register({
453
- "im.message.receive_v1": async (data: Evt) => {
454
- try {
455
- broadcastToRelay(data);
456
-
457
- const event = getInnerEvent(data);
458
- const message = event.message;
459
- if (!message) return;
460
-
461
- const messageId = message.message_id;
462
- if (messageId) {
463
- if (processedMessages.has(messageId)) {
464
- console.log(`[MSG] Duplicate message ignored: ${messageId}`);
465
- return;
466
- }
467
- processedMessages.add(messageId);
468
- if (processedMessages.size > MAX_PROCESSED) {
469
- const it = processedMessages.values();
470
- for (let i = 0; i < 1000; i++) processedMessages.delete(it.next().value as string);
471
- }
472
- }
473
-
474
- const text = extractText(message);
475
- const sender = event.sender;
476
- const openId = sender?.sender_id?.open_id ?? "";
477
- const chatId = message.chat_id ?? "";
478
-
479
- console.log(`[MSG] sender=${openId} chat=${chatId} text="${text}"`);
480
- appendChatLog(chatId, openId, text);
481
-
482
- if (messageId) {
483
- addReaction(token, messageId).catch((err) =>
484
- console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
485
- );
486
- }
487
-
488
- if (!text) return;
489
- const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
490
- await handleCommand(text, chatId, openId, msgTimestamp);
491
- } catch (err) {
492
- console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
493
- }
494
- },
495
-
496
- "card.action.trigger": async (data: Evt) => {
497
- try {
498
- // 拦截关闭按钮:先置空内容,再尝试撤回(撤回结果不影响后续流程)
499
- const raw2 = (data as Record<string, unknown>).event ?? data;
500
- const action2 = (raw2 as Record<string, unknown>)?.action as { value?: unknown } | undefined;
501
- const actionVal2 = action2?.value as Record<string, unknown> | undefined;
502
- if (actionVal2?.action === "close") {
503
- console.log(`[${ts()}] [CLOSE] close button clicked, raw keys: ${Object.keys(raw2 as object).join(", ")}`);
504
- const messageId = (raw2 as Record<string, unknown>)?.open_message_id as string | undefined
505
- ?? ((raw2 as Record<string, unknown>)?.context as Record<string, unknown>)?.open_message_id as string | undefined;
506
- console.log(`[${ts()}] [CLOSE] open_message_id=${messageId ?? "MISSING"}`);
507
- if (messageId) {
508
- const closeToken = await getTenantAccessToken();
509
- updateCardMessage(closeToken, messageId, JSON.stringify({
510
- config: { wide_screen_mode: true },
511
- elements: [{ tag: "markdown", content: " " }],
512
- })).catch((err) => {
513
- console.error(`[${ts()}] [CLOSE] updateCardMessage failed: ${(err as Error).message}`);
514
- });
515
- recallMessage(closeToken, messageId).then((recalled) => {
516
- console.log(`[${ts()}] [CLOSE] recall result: ${recalled ? "OK" : "FAILED"}`);
517
- }).catch((err) => {
518
- console.error(`[${ts()}] [CLOSE] recall failed: ${(err as Error).message}`);
519
- });
520
- } else {
521
- console.error(`[${ts()}] [CLOSE] no open_message_id in event, cannot close`);
522
- }
523
- return;
524
- }
525
-
526
- const result = parseCardAction(data);
527
- if (!result) return;
528
- console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
529
- handleCommand(result.text, result.chatId, result.openId, Date.now()).catch((err) =>
530
- console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
531
- );
532
- } catch (err) {
533
- console.error(`[${ts()}] [FATAL] card.action.trigger handler crashed: ${(err as Error).message}`);
534
- }
535
- },
536
- });
537
-
538
- if (USE_LOCAL) {
539
- console.log(`[WS] Connecting to local relay: ${LOCAL_RELAY_URL}`);
540
- const ws = new WebSocket(LOCAL_RELAY_URL);
541
- ws.on("open", () => console.log("[WS] Connected to local relay"));
542
- ws.on("message", (raw: Buffer) => {
543
- try {
544
- const data = JSON.parse(raw.toString()) as Evt;
545
- const action = parseCardAction(data);
546
- if (action) {
547
- handleCommand(action.text, action.chatId, action.openId, Date.now()).catch((err) =>
548
- console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
549
- );
550
- return;
551
- }
552
- const event = getInnerEvent(data);
553
- const message = event.message;
554
- if (!message) return;
555
- const text = extractText(message);
556
- const openId = event.sender?.sender_id?.open_id ?? "";
557
- const chatId = message.chat_id ?? "";
558
- appendChatLog(chatId, openId, text);
559
- if (text === "/new" && openId) {
560
- console.log(`[MSG] /new from ${openId}, but local relay does not handle /new yet. Use SDK mode.`);
561
- }
562
- } catch { /* ignore */ }
563
- });
564
- ws.on("close", () => { console.log("[WS] Local relay disconnected"); process.exit(0); });
565
- ws.on("error", (err: Error) => console.error(`[WS] Local relay error: ${err.message}`));
566
- } else {
567
- resetState();
568
-
569
- const wsClient = new WSClient({
570
- appId: APP_ID,
571
- appSecret: APP_SECRET,
572
- onReady: () => resetState(),
573
- onReconnected: () => resetState(),
574
- });
575
-
576
- await wsClient.start({ eventDispatcher });
577
- console.log("[WS] Feishu WebSocket connected (SDK)");
578
-
579
- sendRestartCard(token).catch((err) =>
580
- console.error(`[${ts()}] [RESTART] sendRestartCard failed: ${(err as Error).message}`)
581
- );
582
- }
583
-
584
- process.on("SIGINT", () => { console.log("\nShutting down..."); relayServer.close(); process.exit(0); });
585
- process.on("SIGTERM", () => { relayServer.close(); process.exit(0); });
586
-
587
- process.on("uncaughtException", (err) => {
588
- console.error(`[FATAL] uncaughtException: ${err.message}\n${err.stack}`);
589
- fileLog.flush();
590
- });
591
- process.on("unhandledRejection", (reason) => {
592
- console.error(`[FATAL] unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
593
- fileLog.flush();
594
- });
595
- }
596
-
597
- main().catch((err: Error) => {
598
- console.error("Fatal error:", err);
599
- process.exit(1);
1
+ /**
2
+ * ChatCCC — Feishu Bot Bridge for Claude Code (TypeScript)
3
+ * =================================================================
4
+ * When a user sends "/new" to the bot:
5
+ * 1. Create a Claude session via Agent SDK, get session ID from init event
6
+ * 2. Create a new Feishu group chat and add the user
7
+ * 3. Rename the group (name + description) to the session ID
8
+ * 4. Stream SDK output to logs/session-<session-id>.jsonl
9
+ * 5. Reply to the new group with session info and welcome message
10
+ *
11
+ * Auto-resume: when any message is received in a Claude session group
12
+ * (group description contains "Claude Session:"), the bot extracts the
13
+ * session ID, resumes the session via SDK, sends the user's text, and
14
+ * streams the response to the session's jsonl file.
15
+ *
16
+ * Buttons: thinking cards have a 停止 button; help messages have /new and /restart buttons.
17
+ *
18
+ * Usage:
19
+ * npm run dev
20
+ * npm run start
21
+ * npm run demo:create-group -- --local (local relay mode)
22
+ */
23
+
24
+ import { spawn } from "node:child_process";
25
+ import { readdir, stat } from "node:fs/promises";
26
+ import { resolve, dirname, basename } from "node:path";
27
+
28
+ import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
29
+ import WebSocket from "ws";
30
+
31
+ import { appendStartupTrace, ensureSingleInstance, createRelayServer, freeRelayListenPort } from "./shared.ts";
32
+ import {
33
+ CHATCCC_PORT,
34
+ APP_ID,
35
+ APP_SECRET,
36
+ BASE_URL,
37
+ CLAUDE_EFFORT,
38
+ CLAUDE_MODEL,
39
+ anthropicConfigDisplay,
40
+ LOCAL_RELAY_URL,
41
+ PID_FILE,
42
+ PROJECT_ROOT,
43
+ USE_LOCAL,
44
+ appendChatLog,
45
+ explainMissingFeishuCredentialsAndExit,
46
+ fileLog,
47
+ reportEnvironmentVariableReadout,
48
+ getDefaultCwd,
49
+ maskAppId,
50
+ setDefaultCwd,
51
+ ts,
52
+ } from "./config.ts";
53
+ import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
54
+ import {
55
+ addReaction,
56
+ createGroupChat,
57
+ extractSessionId,
58
+ getChatInfo,
59
+ getTenantAccessToken,
60
+ recallMessage,
61
+ sendCardReply,
62
+ sendTextReply,
63
+ updateCardMessage,
64
+ updateChatInfo,
65
+ sendRestartCard,
66
+ } from "./feishu-api.ts";
67
+ import { buildHelpCard, buildStatusCard, buildThinkingCardV2, buildCdContent, buildSessionsCard } from "./cards.ts";
68
+ import { updateCardKitCard } from "./cardkit.ts";
69
+ import {
70
+ MAX_PROCESSED,
71
+ chatSessionMap,
72
+ getSessionStatus,
73
+ getAllSessionsStatus,
74
+ initClaudeSession,
75
+ processedMessages,
76
+ resetState,
77
+ resumeAndPrompt,
78
+ sessionInfoMap,
79
+ } from "./session.ts";
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Event types
83
+ // ---------------------------------------------------------------------------
84
+
85
+ interface InnerEvent {
86
+ message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; create_time?: string };
87
+ sender?: { sender_id?: { open_id?: string; union_id?: string } };
88
+ }
89
+
90
+ type Evt = { event?: InnerEvent } & InnerEvent;
91
+
92
+ function getInnerEvent(data: Evt): InnerEvent {
93
+ return (data.event ?? data) as InnerEvent;
94
+ }
95
+
96
+ function extractText(message: { message_type?: string; content?: string }): string {
97
+ const contentStr = message.content ?? "{}";
98
+ let content: Record<string, unknown>;
99
+ try { content = JSON.parse(contentStr); } catch { return ""; }
100
+
101
+ if (message.message_type === "text") {
102
+ let text = (content.text ?? "") as string;
103
+ text = text.replace(/<\/?p[^>]*>/gi, "");
104
+ text = text.replace(/<br\s*\/?>/gi, "\n");
105
+ text = text.replace(/&nbsp;/gi, " ");
106
+ return text.trim();
107
+ }
108
+ return "";
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Card action helper: parse button click into text command
113
+ // ---------------------------------------------------------------------------
114
+
115
+ interface CardActionResult {
116
+ text: string;
117
+ chatId: string;
118
+ openId: string;
119
+ }
120
+
121
+ function parseCardAction(data: unknown): CardActionResult | null {
122
+ const raw = (data as Record<string, unknown>)?.event ?? data;
123
+ const action = (raw as Record<string, unknown>)?.action as { value?: unknown } | undefined;
124
+ if (!action?.value) return null;
125
+
126
+ let cmd: string | undefined;
127
+ if (typeof action.value === "object" && action.value !== null) {
128
+ cmd = (action.value as Record<string, string>).action;
129
+ } else if (typeof action.value === "string") {
130
+ try {
131
+ let v: unknown = JSON.parse(action.value);
132
+ if (typeof v === "string") v = JSON.parse(v);
133
+ cmd = (v as { cmd?: string; action?: string }).cmd ?? (v as { action?: string }).action;
134
+ } catch { return null; }
135
+ }
136
+ if (!cmd) return null;
137
+
138
+ const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions" };
139
+ const text = CMD_MAP[cmd] ?? "";
140
+ if (!text) return null;
141
+
142
+ const chatId =
143
+ ((raw as Record<string, unknown>).open_chat_id as string) ??
144
+ ((raw as Record<string, unknown>).context as Record<string, unknown>)?.open_chat_id as string ??
145
+ ((raw as Record<string, unknown>).message as Record<string, unknown>)?.chat_id as string ??
146
+ "";
147
+ const openId =
148
+ ((raw as Record<string, unknown>).operator as Record<string, unknown>)?.open_id as string ??
149
+ "";
150
+
151
+ return { text, chatId, openId };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // WebSocket relay broadcast
156
+ // ---------------------------------------------------------------------------
157
+
158
+ let broadcastToRelay: (data: unknown) => void = () => {};
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Command handler
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number): Promise<void> {
165
+ if (text === "/restart") {
166
+ const restartToken = await getTenantAccessToken();
167
+ await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
168
+ console.log(`[${ts()}] [RESTART] Spawning new process...`);
169
+ const child = spawn("npx", ["tsx", "--env-file=.env", "src/index.ts"], {
170
+ cwd: PROJECT_ROOT,
171
+ detached: true,
172
+ stdio: "ignore",
173
+ shell: true,
174
+ });
175
+ child.unref();
176
+ setTimeout(() => process.exit(0), 200);
177
+ return;
178
+ }
179
+
180
+ if (text === "/cd" || text.startsWith("/cd ")) {
181
+ const cdToken = await getTenantAccessToken();
182
+ const currentDir = await getDefaultCwd();
183
+ const arg = text.slice(3).trim(); // everything after "/cd" (may be empty)
184
+
185
+ // Resolve target directory
186
+ let targetDir: string;
187
+ if (!arg) {
188
+ targetDir = currentDir;
189
+ } else if (arg === "..") {
190
+ targetDir = dirname(currentDir);
191
+ } else {
192
+ targetDir = resolve(currentDir, arg);
193
+ }
194
+
195
+ // Verify the target exists and is a directory
196
+ try {
197
+ const s = await stat(targetDir);
198
+ if (!s.isDirectory()) {
199
+ await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
200
+ return;
201
+ }
202
+ } catch {
203
+ await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
204
+ return;
205
+ }
206
+
207
+ // Change working dir if user provided a path
208
+ const isUpdate = !!arg && targetDir !== currentDir;
209
+ if (isUpdate) {
210
+ await setDefaultCwd(targetDir);
211
+ }
212
+
213
+ // Read directory entries
214
+ let entries: string[];
215
+ try {
216
+ entries = await readdir(targetDir);
217
+ } catch (err) {
218
+ await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
219
+ return;
220
+ }
221
+
222
+ // Sort: directories first, then files, alphabetically within each group
223
+ const withStats: { name: string; isDir: boolean }[] = [];
224
+ for (const name of entries) {
225
+ try {
226
+ const s = await stat(resolve(targetDir, name));
227
+ withStats.push({ name, isDir: s.isDirectory() });
228
+ } catch { withStats.push({ name, isDir: false }); }
229
+ }
230
+ withStats.sort((a, b) => {
231
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
232
+ return a.name.localeCompare(b.name);
233
+ });
234
+
235
+ const content = buildCdContent(targetDir, withStats, isUpdate);
236
+ await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
237
+ return;
238
+ }
239
+
240
+ if (text === "/new") {
241
+ if (!openId) {
242
+ console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
243
+ const warnToken = await getTenantAccessToken();
244
+ await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
245
+ return;
246
+ }
247
+
248
+ const freshToken = await getTenantAccessToken();
249
+
250
+ let sessionId: string;
251
+ try {
252
+ sessionId = await initClaudeSession();
253
+ console.log(`[${ts()}] [STEP 1/4] Claude SDK session created: ${sessionId} → OK`);
254
+ } catch (err) {
255
+ console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
256
+ await sendCardReply(
257
+ freshToken, chatId, "Error",
258
+ `Failed to initialize Claude session:\n${(err as Error).message}`,
259
+ "red"
260
+ );
261
+ return;
262
+ }
263
+
264
+ let newChatId: string;
265
+ try {
266
+ newChatId = await createGroupChat(freshToken, `新会话-${sessionId}`, [openId]);
267
+ console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
268
+ } catch (err) {
269
+ console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
270
+ await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
271
+ return;
272
+ }
273
+
274
+ try {
275
+ const initialName = `新会话-${sessionId}`;
276
+ await updateChatInfo(freshToken, newChatId, initialName, `Claude Session: ${sessionId}`);
277
+ console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" → OK`);
278
+ } catch (err) {
279
+ console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
280
+ await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
281
+ return;
282
+ }
283
+
284
+ await sendCardReply(
285
+ freshToken, newChatId, "Claude Session Ready",
286
+ `群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n\n直接在这里发消息即可与 Claude 对话。`,
287
+ "green"
288
+ );
289
+
290
+ console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
291
+ console.log(`${"=".repeat(60)}`);
292
+ return;
293
+ }
294
+
295
+ try {
296
+ const token = await getTenantAccessToken();
297
+ const chatInfo = await getChatInfo(token, chatId);
298
+ const description = chatInfo.description;
299
+ const sessionId = extractSessionId(description);
300
+
301
+ if (sessionId) {
302
+ console.log(`[${ts()}] [RESUME] 克劳德会话群 detected, session=${sessionId}`);
303
+
304
+ const freshToken = await getTenantAccessToken();
305
+
306
+ if (chatInfo.name === `新会话-${sessionId}`) {
307
+ const MAX_PREFIX = 20;
308
+ const prefix = text.slice(0, MAX_PREFIX);
309
+ const newName = `${prefix} ${sessionId}`;
310
+ try {
311
+ await updateChatInfo(freshToken, chatId, newName, description);
312
+ console.log(`[${ts()}] [RENAME] First message → group renamed to "${newName}"`);
313
+ } catch (err) {
314
+ console.error(`[${ts()}] [RENAME] Failed: ${(err as Error).message}`);
315
+ }
316
+ }
317
+
318
+ if (text === "/stop") {
319
+ const cEntry = chatSessionMap.get(chatId);
320
+ if (cEntry) {
321
+ cEntry.stopped = true;
322
+ if (cEntry.spinnerTimer) { clearInterval(cEntry.spinnerTimer); cEntry.spinnerTimer = null; }
323
+ cEntry.close();
324
+ console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
325
+ await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
326
+ } else {
327
+ await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
328
+ }
329
+ return;
330
+ }
331
+
332
+ if (text === "/status") {
333
+ const status = getSessionStatus(chatId);
334
+ const running = chatSessionMap.get(chatId);
335
+ const isActive = running && !running.stopped;
336
+ const statusText = [
337
+ `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
338
+ `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
339
+ `**已对话轮数:** ${status?.turnCount ?? 0}`,
340
+ `**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
341
+ `**Effort:** ${status?.effort ?? anthropicConfigDisplay(CLAUDE_EFFORT)}`,
342
+ ];
343
+ if (isActive) {
344
+ const elapsed = Math.floor((Date.now() - (status!.startTime)) / 1000);
345
+ const mins = Math.floor(elapsed / 60);
346
+ const secs = elapsed % 60;
347
+ statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
348
+ statusText.push(`**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`);
349
+ }
350
+ if (status?.lastContextTokens) {
351
+ statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
352
+ }
353
+ const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
354
+ const statusResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
355
+ method: "POST",
356
+ headers: {
357
+ Authorization: `Bearer ${freshToken}`,
358
+ "Content-Type": "application/json",
359
+ },
360
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
361
+ });
362
+ const statusRespData: Record<string, any> = await statusResp.json().catch(() => ({}));
363
+ console.log(`[${ts()}] [STATUS] card sent, code=${statusRespData.code}, msgId=${statusRespData.data?.message_id ?? "N/A"}`);
364
+ return;
365
+ }
366
+
367
+ if (text === "/sessions") {
368
+ const allSessions = getAllSessionsStatus();
369
+ const now = Date.now();
370
+ const cardData = allSessions.map(s => ({
371
+ sessionId: s.sessionId,
372
+ active: s.active,
373
+ turnCount: s.turnCount,
374
+ elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
375
+ model: s.model,
376
+ }));
377
+ const card = buildSessionsCard(cardData);
378
+ const sessionsResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
379
+ method: "POST",
380
+ headers: {
381
+ Authorization: `Bearer ${freshToken}`,
382
+ "Content-Type": "application/json",
383
+ },
384
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
385
+ });
386
+ const sessionsRespData: Record<string, any> = await sessionsResp.json().catch(() => ({}));
387
+ console.log(`[${ts()}] [SESSIONS] card sent, code=${sessionsRespData.code}, count=${allSessions.length}`);
388
+ return;
389
+ }
390
+
391
+ const existing = chatSessionMap.get(chatId);
392
+ if (existing && !existing.stopped) {
393
+ if (msgTimestamp <= existing.msgTimestamp) {
394
+ console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
395
+ return;
396
+ }
397
+ existing.stopped = true;
398
+ if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
399
+ existing.close();
400
+ chatSessionMap.delete(chatId);
401
+ console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
402
+ if (existing.cardId) {
403
+ while (existing.cardBusy) {
404
+ await new Promise(r => setTimeout(r, 20));
405
+ }
406
+ const cardId = existing.cardId;
407
+ const thinking = existing.accumulatedThinking;
408
+ const interruptedCard = buildThinkingCardV2(
409
+ thinking || "新问题已提交,当前回复已中断。",
410
+ { showStop: false, headerTitle: "已中断", headerTemplate: "yellow" }
411
+ );
412
+ let nextSeq = existing.sequence + 1;
413
+ await updateCardKitCard(freshToken, cardId, interruptedCard, nextSeq).catch((err) => {
414
+ console.error(`[${ts()}] [INTERRUPT] CardKit update failed: ${(err as Error).message}`);
415
+ });
416
+ }
417
+ }
418
+
419
+ try {
420
+ await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp);
421
+ console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
422
+ } catch (err) {
423
+ console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
424
+ await sendCardReply(
425
+ freshToken, chatId, "Error",
426
+ `Failed to resume Claude session:\n${(err as Error).message}`,
427
+ "red"
428
+ );
429
+ }
430
+ return;
431
+ }
432
+ } catch (err) {
433
+ console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
434
+ }
435
+
436
+ const replyToken = await getTenantAccessToken();
437
+ const card = buildHelpCard(text);
438
+ await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
439
+ method: "POST",
440
+ headers: {
441
+ Authorization: `Bearer ${replyToken}`,
442
+ "Content-Type": "application/json",
443
+ },
444
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
445
+ });
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Main
450
+ // ---------------------------------------------------------------------------
451
+
452
+ async function main(): Promise<void> {
453
+ appendStartupTrace("main: entered", {
454
+ argv: process.argv.join(" ").slice(0, 400),
455
+ CHATCCC_PORT,
456
+ PROJECT_ROOT,
457
+ });
458
+
459
+ if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
460
+ console.error("\n[启动] 预检失败: CHATCCC_PORT 不是有效端口号(1–65535)。");
461
+ console.error(` 当前解析结果: ${String(process.env.CHATCCC_PORT)} ${CHATCCC_PORT}`);
462
+ reportEnvironmentVariableReadout();
463
+ printServiceDidNotStart("CHATCCC_PORT 配置无效(须为 1–65535 的整数)");
464
+ process.exit(1);
465
+ }
466
+
467
+ console.log(`\n[启动 1/6] 单实例:按 PID 文件清理旧 ChatCCC 进程`);
468
+ console.log(` PID 文件: ${PID_FILE}`);
469
+ appendStartupTrace("main: before ensureSingleInstance", { PID_FILE, CHATCCC_PORT });
470
+ ensureSingleInstance(PID_FILE);
471
+ appendStartupTrace("main: after ensureSingleInstance");
472
+ console.log(" 完成。\n");
473
+
474
+ console.log(`[启动 2/6] 环境与凭证检查`);
475
+ reportEnvironmentVariableReadout();
476
+ console.log(` 工作目录: ${process.cwd()}`);
477
+ console.log(` 包根目录: ${PROJECT_ROOT}`);
478
+ appendStartupTrace("main: before feishu credential check", {
479
+ hasAppId: Boolean(APP_ID.trim()),
480
+ hasAppSecret: Boolean(APP_SECRET.trim()),
481
+ });
482
+ if (!APP_ID.trim() || !APP_SECRET.trim()) {
483
+ explainMissingFeishuCredentialsAndExit();
484
+ }
485
+ console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
486
+ appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
487
+
488
+ console.log(`[启动 3/6] 启动本地 WebSocket 中继(ws://127.0.0.1:${CHATCCC_PORT})…`);
489
+ appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
490
+ freeRelayListenPort(CHATCCC_PORT);
491
+ appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
492
+ const { server: relayServer, broadcast } = createRelayServer(CHATCCC_PORT);
493
+ broadcastToRelay = broadcast;
494
+ console.log(" 完成。\n");
495
+
496
+ const modeTag = USE_LOCAL ? " (local relay mode)" : "";
497
+ console.log(`${"=".repeat(60)}`);
498
+ console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
499
+ console.log(`${"=".repeat(60)}`);
500
+ console.log(` Send "/new" to the bot to create a new group + Claude session.`);
501
+ console.log(` In a Claude session group, send any message to resume & prompt.`);
502
+ console.log(`${"=".repeat(60)}`);
503
+
504
+ console.log(`\n[启动 4/6] 向飞书开放平台申请 tenant_access_token …`);
505
+ let token: string;
506
+ try {
507
+ token = await getTenantAccessToken();
508
+ } catch (err) {
509
+ const msg = err instanceof Error ? err.message : String(err);
510
+ console.error(" 失败:无法获取 tenant_access_token。");
511
+ console.error(` 接口: POST ${BASE_URL}/auth/v3/tenant_access_token/internal`);
512
+ console.error(" 常见原因:");
513
+ console.error(" - App ID / App Secret 与开放平台「凭证与基础信息」不一致");
514
+ console.error(" - 自建应用尚未创建/发布可用版本");
515
+ console.error(" - 本机网络无法访问 open.feishu.cn");
516
+ console.error(` 详情: ${msg}`);
517
+ printServiceDidNotStart("无法从飞书开放平台获取 tenant_access_token(凭证或网络问题)");
518
+ process.exit(1);
519
+ }
520
+ console.log(` 完成。当前 App ID 摘要: ${maskAppId(APP_ID)}\n`);
521
+
522
+ console.log(`[${ts()}] [AUTH] Token obtained`);
523
+
524
+ const eventDispatcher = new EventDispatcher({});
525
+ eventDispatcher.register({
526
+ "im.message.receive_v1": async (data: Evt) => {
527
+ try {
528
+ broadcastToRelay(data);
529
+
530
+ const event = getInnerEvent(data);
531
+ const message = event.message;
532
+ if (!message) return;
533
+
534
+ const messageId = message.message_id;
535
+ if (messageId) {
536
+ if (processedMessages.has(messageId)) {
537
+ console.log(`[MSG] Duplicate message ignored: ${messageId}`);
538
+ return;
539
+ }
540
+ processedMessages.add(messageId);
541
+ if (processedMessages.size > MAX_PROCESSED) {
542
+ const it = processedMessages.values();
543
+ for (let i = 0; i < 1000; i++) processedMessages.delete(it.next().value as string);
544
+ }
545
+ }
546
+
547
+ const text = extractText(message);
548
+ const sender = event.sender;
549
+ const openId = sender?.sender_id?.open_id ?? "";
550
+ const chatId = message.chat_id ?? "";
551
+
552
+ console.log(`[MSG] sender=${openId} chat=${chatId} text="${text}"`);
553
+ appendChatLog(chatId, openId, text);
554
+
555
+ if (messageId) {
556
+ getTenantAccessToken().then((freshToken) =>
557
+ addReaction(freshToken, messageId).catch((err) =>
558
+ console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
559
+ )
560
+ ).catch((err) =>
561
+ console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
562
+ );
563
+ }
564
+
565
+ if (!text) return;
566
+ const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
567
+ await handleCommand(text, chatId, openId, msgTimestamp);
568
+ } catch (err) {
569
+ console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
570
+ }
571
+ },
572
+
573
+ "card.action.trigger": async (data: Evt) => {
574
+ try {
575
+ // 拦截关闭按钮:先置空内容,再尝试撤回(撤回结果不影响后续流程)
576
+ const raw2 = (data as Record<string, unknown>).event ?? data;
577
+ const action2 = (raw2 as Record<string, unknown>)?.action as { value?: unknown } | undefined;
578
+ const actionVal2 = action2?.value as Record<string, unknown> | undefined;
579
+ if (actionVal2?.action === "close") {
580
+ console.log(`[${ts()}] [CLOSE] close button clicked, raw keys: ${Object.keys(raw2 as object).join(", ")}`);
581
+ const messageId = (raw2 as Record<string, unknown>)?.open_message_id as string | undefined
582
+ ?? ((raw2 as Record<string, unknown>)?.context as Record<string, unknown>)?.open_message_id as string | undefined;
583
+ console.log(`[${ts()}] [CLOSE] open_message_id=${messageId ?? "MISSING"}`);
584
+ if (messageId) {
585
+ const closeToken = await getTenantAccessToken();
586
+ updateCardMessage(closeToken, messageId, JSON.stringify({
587
+ config: { wide_screen_mode: true },
588
+ elements: [{ tag: "markdown", content: " " }],
589
+ })).catch((err) => {
590
+ console.error(`[${ts()}] [CLOSE] updateCardMessage failed: ${(err as Error).message}`);
591
+ });
592
+ recallMessage(closeToken, messageId).then((recalled) => {
593
+ console.log(`[${ts()}] [CLOSE] recall result: ${recalled ? "OK" : "FAILED"}`);
594
+ }).catch((err) => {
595
+ console.error(`[${ts()}] [CLOSE] recall failed: ${(err as Error).message}`);
596
+ });
597
+ } else {
598
+ console.error(`[${ts()}] [CLOSE] no open_message_id in event, cannot close`);
599
+ }
600
+ return;
601
+ }
602
+
603
+ const result = parseCardAction(data);
604
+ if (!result) return;
605
+ console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
606
+ handleCommand(result.text, result.chatId, result.openId, Date.now()).catch((err) =>
607
+ console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
608
+ );
609
+ } catch (err) {
610
+ console.error(`[${ts()}] [FATAL] card.action.trigger handler crashed: ${(err as Error).message}`);
611
+ }
612
+ },
613
+ });
614
+
615
+ if (USE_LOCAL) {
616
+ console.log(`\n[启动 5/6] 本地区 relay 模式:正在连接 ${LOCAL_RELAY_URL} …`);
617
+ console.log(" 若失败:请先在 SDK 模式下启动主进程,或确认本机中继已在该地址监听。");
618
+ let localRelayOpened = false;
619
+ const ws = new WebSocket(LOCAL_RELAY_URL);
620
+ ws.on("open", () => {
621
+ localRelayOpened = true;
622
+ console.log("[WS] Connected to local relay");
623
+ console.log("[启动 6/6] 已连接本地中继,可接收转发事件。\n");
624
+ printServiceRunningHint("local");
625
+ });
626
+ ws.on("message", (raw: Buffer) => {
627
+ try {
628
+ const data = JSON.parse(raw.toString()) as Evt;
629
+ const action = parseCardAction(data);
630
+ if (action) {
631
+ handleCommand(action.text, action.chatId, action.openId, Date.now()).catch((err) =>
632
+ console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
633
+ );
634
+ return;
635
+ }
636
+ const event = getInnerEvent(data);
637
+ const message = event.message;
638
+ if (!message) return;
639
+ const text = extractText(message);
640
+ const openId = event.sender?.sender_id?.open_id ?? "";
641
+ const chatId = message.chat_id ?? "";
642
+ appendChatLog(chatId, openId, text);
643
+ if (text === "/new" && openId) {
644
+ console.log(`[MSG] /new from ${openId}, but local relay does not handle /new yet. Use SDK mode.`);
645
+ }
646
+ } catch { /* ignore */ }
647
+ });
648
+ ws.on("close", () => { console.log("[WS] Local relay disconnected"); process.exit(0); });
649
+ ws.on("error", (err: Error) => {
650
+ if (!localRelayOpened) {
651
+ console.error(`[启动 5/6] 失败:无法连接本地中继。`);
652
+ console.error(` ${err.message}`);
653
+ console.error(` 目标: ${LOCAL_RELAY_URL}`);
654
+ printServiceDidNotStart(`无法连接本地中继 ${LOCAL_RELAY_URL}`);
655
+ process.exit(1);
656
+ }
657
+ console.error(`[WS] Local relay error: ${err.message}`);
658
+ });
659
+ } else {
660
+ resetState();
661
+
662
+ const wsClient = new WSClient({
663
+ appId: APP_ID,
664
+ appSecret: APP_SECRET,
665
+ onReady: () => resetState(),
666
+ onReconnected: () => resetState(),
667
+ });
668
+
669
+ console.log(`\n[启动 5/6] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
670
+ try {
671
+ await wsClient.start({ eventDispatcher });
672
+ } catch (err) {
673
+ const msg = err instanceof Error ? err.message : String(err);
674
+ console.error(" 失败:飞书 WebSocket 未能启动。");
675
+ console.error(" 常见原因: 应用权限未开通、事件订阅未配置、网络问题、或 SDK 内部错误。");
676
+ console.error(` 详情: ${msg}`);
677
+ printServiceDidNotStart("飞书 SDK WebSocket 未能建立(权限、事件订阅或网络)");
678
+ process.exit(1);
679
+ }
680
+ console.log("[WS] Feishu WebSocket connected (SDK)");
681
+ console.log("[启动 6/6] 服务已就绪,等待飞书消息(群聊 / 卡片回调)。\n");
682
+ printServiceRunningHint("sdk");
683
+
684
+ sendRestartCard(token).catch((err) =>
685
+ console.error(`[${ts()}] [RESTART] sendRestartCard failed: ${(err as Error).message}`)
686
+ );
687
+ }
688
+
689
+ process.on("SIGINT", () => { console.log("\nShutting down..."); relayServer.close(); process.exit(0); });
690
+ process.on("SIGTERM", () => { relayServer.close(); process.exit(0); });
691
+
692
+ process.on("uncaughtException", (err) => {
693
+ console.error(`[FATAL] uncaughtException: ${err.message}\n${err.stack}`);
694
+ fileLog.flush();
695
+ });
696
+ process.on("unhandledRejection", (reason) => {
697
+ console.error(`[FATAL] unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
698
+ fileLog.flush();
699
+ });
700
+ }
701
+
702
+ main().catch((err: Error) => {
703
+ appendStartupTrace("main: catch fatal", { message: err.message, stack: err.stack?.slice(0, 800) });
704
+ console.error("\n[启动] 未捕获的致命错误(main 异步链)");
705
+ console.error(` ${err.message}`);
706
+ if (err.stack) console.error(err.stack);
707
+ printServiceDidNotStart(`main() 异常: ${err.message}`);
708
+ process.exit(1);
600
709
  });