chatccc 0.1.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/LICENSE +201 -0
- package/README.md +106 -0
- package/bin/chatccc.js +7 -0
- package/package.json +40 -0
- package/src/cardkit.ts +158 -0
- package/src/cards.ts +170 -0
- package/src/config.ts +70 -0
- package/src/feishu-api.ts +244 -0
- package/src/index.ts +600 -0
- package/src/session.ts +429 -0
- package/src/shared.ts +185 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
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
|
+
APP_ID,
|
|
34
|
+
APP_SECRET,
|
|
35
|
+
BASE_URL,
|
|
36
|
+
CLAUDE_MODEL,
|
|
37
|
+
LOCAL_RELAY_URL,
|
|
38
|
+
PID_FILE,
|
|
39
|
+
PROJECT_ROOT,
|
|
40
|
+
USE_LOCAL,
|
|
41
|
+
appendChatLog,
|
|
42
|
+
fileLog,
|
|
43
|
+
getWorkingDir,
|
|
44
|
+
setWorkingDir,
|
|
45
|
+
ts,
|
|
46
|
+
} from "./config.ts";
|
|
47
|
+
import {
|
|
48
|
+
addReaction,
|
|
49
|
+
createGroupChat,
|
|
50
|
+
extractSessionId,
|
|
51
|
+
getChatInfo,
|
|
52
|
+
getTenantAccessToken,
|
|
53
|
+
recallMessage,
|
|
54
|
+
sendCardReply,
|
|
55
|
+
sendTextReply,
|
|
56
|
+
updateCardMessage,
|
|
57
|
+
updateChatInfo,
|
|
58
|
+
sendRestartCard,
|
|
59
|
+
} from "./feishu-api.ts";
|
|
60
|
+
import { buildHelpCard, buildStatusCard, buildThinkingCardV2, buildCdContent } from "./cards.ts";
|
|
61
|
+
import { setCardKitSettings, updateCardKitCard } from "./cardkit.ts";
|
|
62
|
+
import {
|
|
63
|
+
MAX_PROCESSED,
|
|
64
|
+
chatSessionMap,
|
|
65
|
+
getSessionStatus,
|
|
66
|
+
initClaudeSession,
|
|
67
|
+
processedMessages,
|
|
68
|
+
resetState,
|
|
69
|
+
resumeAndPrompt,
|
|
70
|
+
sessionInfoMap,
|
|
71
|
+
} from "./session.ts";
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Event types
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
interface InnerEvent {
|
|
78
|
+
message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; create_time?: string };
|
|
79
|
+
sender?: { sender_id?: { open_id?: string; union_id?: string } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Evt = { event?: InnerEvent } & InnerEvent;
|
|
83
|
+
|
|
84
|
+
function getInnerEvent(data: Evt): InnerEvent {
|
|
85
|
+
return (data.event ?? data) as InnerEvent;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractText(message: { message_type?: string; content?: string }): string {
|
|
89
|
+
const contentStr = message.content ?? "{}";
|
|
90
|
+
let content: Record<string, unknown>;
|
|
91
|
+
try { content = JSON.parse(contentStr); } catch { return ""; }
|
|
92
|
+
|
|
93
|
+
if (message.message_type === "text") {
|
|
94
|
+
let text = (content.text ?? "") as string;
|
|
95
|
+
text = text.replace(/<\/?p[^>]*>/gi, "");
|
|
96
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
97
|
+
text = text.replace(/ /gi, " ");
|
|
98
|
+
return text.trim();
|
|
99
|
+
}
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Card action helper: parse button click into text command
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
interface CardActionResult {
|
|
108
|
+
text: string;
|
|
109
|
+
chatId: string;
|
|
110
|
+
openId: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseCardAction(data: unknown): CardActionResult | null {
|
|
114
|
+
const raw = (data as Record<string, unknown>)?.event ?? data;
|
|
115
|
+
const action = (raw as Record<string, unknown>)?.action as { value?: unknown } | undefined;
|
|
116
|
+
if (!action?.value) return null;
|
|
117
|
+
|
|
118
|
+
let cmd: string | undefined;
|
|
119
|
+
if (typeof action.value === "object" && action.value !== null) {
|
|
120
|
+
cmd = (action.value as Record<string, string>).action;
|
|
121
|
+
} else if (typeof action.value === "string") {
|
|
122
|
+
try {
|
|
123
|
+
let v: unknown = JSON.parse(action.value);
|
|
124
|
+
if (typeof v === "string") v = JSON.parse(v);
|
|
125
|
+
cmd = (v as { cmd?: string; action?: string }).cmd ?? (v as { action?: string }).action;
|
|
126
|
+
} catch { return null; }
|
|
127
|
+
}
|
|
128
|
+
if (!cmd) return null;
|
|
129
|
+
|
|
130
|
+
const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", restart: "/restart", status: "/status", cd: "/cd" };
|
|
131
|
+
const text = CMD_MAP[cmd] ?? "";
|
|
132
|
+
if (!text) return null;
|
|
133
|
+
|
|
134
|
+
const chatId =
|
|
135
|
+
((raw as Record<string, unknown>).open_chat_id as string) ??
|
|
136
|
+
((raw as Record<string, unknown>).context as Record<string, unknown>)?.open_chat_id as string ??
|
|
137
|
+
((raw as Record<string, unknown>).message as Record<string, unknown>)?.chat_id as string ??
|
|
138
|
+
"";
|
|
139
|
+
const openId =
|
|
140
|
+
((raw as Record<string, unknown>).operator as Record<string, unknown>)?.open_id as string ??
|
|
141
|
+
"";
|
|
142
|
+
|
|
143
|
+
return { text, chatId, openId };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// WebSocket relay broadcast
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
let broadcastToRelay: (data: unknown) => void = () => {};
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Command handler
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number): Promise<void> {
|
|
157
|
+
if (text === "/restart") {
|
|
158
|
+
const restartToken = await getTenantAccessToken();
|
|
159
|
+
await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
|
|
160
|
+
console.log(`[${ts()}] [RESTART] Spawning new process...`);
|
|
161
|
+
const child = spawn("npx", ["tsx", "--env-file=.env", "src/index.ts"], {
|
|
162
|
+
cwd: PROJECT_ROOT,
|
|
163
|
+
detached: true,
|
|
164
|
+
stdio: "ignore",
|
|
165
|
+
shell: true,
|
|
166
|
+
});
|
|
167
|
+
child.unref();
|
|
168
|
+
setTimeout(() => process.exit(0), 200);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (text === "/cd" || text.startsWith("/cd ")) {
|
|
173
|
+
const cdToken = await getTenantAccessToken();
|
|
174
|
+
const currentDir = await getWorkingDir();
|
|
175
|
+
const arg = text.slice(3).trim(); // everything after "/cd" (may be empty)
|
|
176
|
+
|
|
177
|
+
// Resolve target directory
|
|
178
|
+
let targetDir: string;
|
|
179
|
+
if (!arg) {
|
|
180
|
+
targetDir = currentDir;
|
|
181
|
+
} else if (arg === "..") {
|
|
182
|
+
targetDir = dirname(currentDir);
|
|
183
|
+
} else {
|
|
184
|
+
targetDir = resolve(currentDir, arg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Verify the target exists and is a directory
|
|
188
|
+
try {
|
|
189
|
+
const s = await stat(targetDir);
|
|
190
|
+
if (!s.isDirectory()) {
|
|
191
|
+
await sendCardReply(cdToken, chatId, "工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
await sendCardReply(cdToken, chatId, "工作路径", `路径不存在:\n\`${targetDir}\``, "red");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Change working dir if user provided a path
|
|
200
|
+
const isUpdate = !!arg && targetDir !== currentDir;
|
|
201
|
+
if (isUpdate) {
|
|
202
|
+
await setWorkingDir(targetDir);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Read directory entries
|
|
206
|
+
let entries: string[];
|
|
207
|
+
try {
|
|
208
|
+
entries = await readdir(targetDir);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
await sendCardReply(cdToken, chatId, "工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Sort: directories first, then files, alphabetically within each group
|
|
215
|
+
const withStats: { name: string; isDir: boolean }[] = [];
|
|
216
|
+
for (const name of entries) {
|
|
217
|
+
try {
|
|
218
|
+
const s = await stat(resolve(targetDir, name));
|
|
219
|
+
withStats.push({ name, isDir: s.isDirectory() });
|
|
220
|
+
} catch { withStats.push({ name, isDir: false }); }
|
|
221
|
+
}
|
|
222
|
+
withStats.sort((a, b) => {
|
|
223
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
224
|
+
return a.name.localeCompare(b.name);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const content = buildCdContent(targetDir, withStats, isUpdate);
|
|
228
|
+
await sendCardReply(cdToken, chatId, "工作路径", content, "blue");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (text === "/new") {
|
|
233
|
+
if (!openId) {
|
|
234
|
+
console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
|
|
235
|
+
const warnToken = await getTenantAccessToken();
|
|
236
|
+
await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const freshToken = await getTenantAccessToken();
|
|
241
|
+
|
|
242
|
+
let sessionId: string;
|
|
243
|
+
try {
|
|
244
|
+
sessionId = await initClaudeSession();
|
|
245
|
+
console.log(`[${ts()}] [STEP 1/4] Claude SDK session created: ${sessionId} → OK`);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
|
|
248
|
+
await sendCardReply(
|
|
249
|
+
freshToken, chatId, "Error",
|
|
250
|
+
`Failed to initialize Claude session:\n${(err as Error).message}`,
|
|
251
|
+
"red"
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let newChatId: string;
|
|
257
|
+
try {
|
|
258
|
+
newChatId = await createGroupChat(freshToken, `新会话-${sessionId}`, [openId]);
|
|
259
|
+
console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
|
|
262
|
+
await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const initialName = `新会话-${sessionId}`;
|
|
268
|
+
await updateChatInfo(freshToken, newChatId, initialName, `Claude Session: ${sessionId}`);
|
|
269
|
+
console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" → OK`);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
|
|
272
|
+
await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await sendCardReply(
|
|
277
|
+
freshToken, newChatId, "Claude Session Ready",
|
|
278
|
+
`群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n\n直接在这里发消息即可与 Claude 对话。`,
|
|
279
|
+
"green"
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const resumeCmd = `claude --resume ${sessionId}`;
|
|
283
|
+
await sendCardReply(
|
|
284
|
+
freshToken, chatId, "Group + Claude Session Ready",
|
|
285
|
+
`**Session ID:** ${sessionId}\n` +
|
|
286
|
+
`**Group:** created (check your chat list)\n\n` +
|
|
287
|
+
`Resume Claude session:\n\`\`\`\n${resumeCmd}\n\`\`\``,
|
|
288
|
+
"green"
|
|
289
|
+
);
|
|
290
|
+
console.log(`[${ts()}] [STEP 4/4] Replied to user → 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 ?? CLAUDE_MODEL}`,
|
|
341
|
+
`**Effort:** ${status?.effort ?? "N/A"}`,
|
|
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
|
+
const existing = chatSessionMap.get(chatId);
|
|
368
|
+
if (existing && !existing.stopped) {
|
|
369
|
+
if (msgTimestamp <= existing.msgTimestamp) {
|
|
370
|
+
console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
existing.stopped = true;
|
|
374
|
+
if (existing.spinnerTimer) { clearInterval(existing.spinnerTimer); existing.spinnerTimer = null; }
|
|
375
|
+
existing.close();
|
|
376
|
+
chatSessionMap.delete(chatId);
|
|
377
|
+
console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
|
|
378
|
+
if (existing.cardId) {
|
|
379
|
+
while (existing.cardBusy) {
|
|
380
|
+
await new Promise(r => setTimeout(r, 20));
|
|
381
|
+
}
|
|
382
|
+
const cardId = existing.cardId;
|
|
383
|
+
const thinking = existing.accumulatedThinking;
|
|
384
|
+
const interruptedCard = buildThinkingCardV2(
|
|
385
|
+
thinking || "新问题已提交,当前回复已中断。",
|
|
386
|
+
{ showStop: false, headerTitle: "已中断", headerTemplate: "yellow" }
|
|
387
|
+
);
|
|
388
|
+
let nextSeq = existing.sequence + 1;
|
|
389
|
+
await setCardKitSettings(freshToken, cardId, { streaming_mode: false }, nextSeq++).catch(() => {});
|
|
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, 18080);
|
|
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(18080);
|
|
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);
|
|
600
|
+
});
|