返回 CodeWhale
index.mjs
1 import * as Lark from "@larksuiteoapi/node-sdk";
2
3 import {
4 activeTurnBlock,
5 commandAction,
6 compactRuntimeError,
7 helpText,
8 incomingIdentity,
9 isAllowed,
10 latestRunningTurn,
11 pairingRefusalText,
12 parseBool,
13 parseCommand,
14 parseList,
15 parseApprovalDecisionArgs,
16 parseTextContent,
17 preservedChatStateFields,
18 splitMessage,
19 stripGroupPrefix
20 } from "./lib.mjs";
21 import {
22 createRuntimeClient,
23 readJsonSafe,
24 readSse,
25 ThreadStore as CoreThreadStore
26 } from "../../bridge-core/src/lib.mjs";
27
28 class ThreadStore extends CoreThreadStore {
29 constructor(filePath) {
30 super(filePath, { messageLimit: 200 });
31 }
32 }
33
34 const config = {
35 appId: requiredEnv("FEISHU_APP_ID"),
36 appSecret: requiredEnv("FEISHU_APP_SECRET"),
37 domain: process.env.FEISHU_DOMAIN || "feishu",
38 runtimeUrl: (process.env.CODEWHALE_RUNTIME_URL || process.env.DEEPSEEK_RUNTIME_URL || "http://127.0.0.1:7878").replace(/\/+$/, ""),
39 runtimeToken: process.env.CODEWHALE_RUNTIME_TOKEN || process.env.DEEPSEEK_RUNTIME_TOKEN || requiredEnv("CODEWHALE_RUNTIME_TOKEN"),
40 workspace: process.env.CODEWHALE_WORKSPACE || process.env.DEEPSEEK_WORKSPACE || process.cwd(),
41 model: process.env.CODEWHALE_MODEL || process.env.DEEPSEEK_MODEL || "auto",
42 mode: process.env.CODEWHALE_MODE || process.env.DEEPSEEK_MODE || "agent",
43 allowShell: parseBool(process.env.CODEWHALE_ALLOW_SHELL ?? process.env.DEEPSEEK_ALLOW_SHELL, true),
44 trustMode: parseBool(process.env.CODEWHALE_TRUST_MODE ?? process.env.DEEPSEEK_TRUST_MODE, false),
45 autoApprove: parseBool(process.env.CODEWHALE_AUTO_APPROVE ?? process.env.DEEPSEEK_AUTO_APPROVE, false),
46 allowlist: parseList(process.env.CODEWHALE_CHAT_ALLOWLIST || process.env.DEEPSEEK_CHAT_ALLOWLIST),
47 allowUnlisted: parseBool(process.env.CODEWHALE_ALLOW_UNLISTED ?? process.env.DEEPSEEK_ALLOW_UNLISTED, false),
48 threadMapPath:
49 process.env.FEISHU_THREAD_MAP_PATH ||
50 "/var/lib/codewhale-feishu-bridge/thread-map.json",
51 allowGroups: parseBool(process.env.FEISHU_ALLOW_GROUPS, false),
52 requirePrefixInGroup: parseBool(process.env.FEISHU_REQUIRE_PREFIX_IN_GROUP, true),
53 groupPrefix: process.env.FEISHU_GROUP_PREFIX || "/ds",
54 maxReplyChars: Number(process.env.FEISHU_MAX_REPLY_CHARS || 3500),
55 turnTimeoutMs: Number(process.env.CODEWHALE_TURN_TIMEOUT_MS || process.env.DEEPSEEK_TURN_TIMEOUT_MS || 900000)
56 };
57
58 const { runtimeJson, authHeaders } = createRuntimeClient(config);
59
60 const sdkConfig = {
61 appId: config.appId,
62 appSecret: config.appSecret,
63 domain: resolveLarkDomain(config.domain)
64 };
65
66 const client = new Lark.Client(sdkConfig);
67 const wsClient = new Lark.WSClient({
68 ...sdkConfig,
69 loggerLevel: Lark.LoggerLevel?.info
70 });
71
72 const threadStore = await ThreadStore.open(config.threadMapPath);
73
74 const dispatcher = new Lark.EventDispatcher({}).register({
75 "im.message.receive_v1": async (data) => {
76 void handleIncomingMessage(data).catch((error) => {
77 console.error("failed to handle incoming Feishu message", error);
78 });
79 }
80 });
81
82 console.log("Starting DeepSeek Feishu bridge");
83 console.log(`Runtime: ${config.runtimeUrl}`);
84 console.log(`Workspace: ${config.workspace}`);
85 if (!config.allowlist.length && !config.allowUnlisted) {
86 console.log("No allowlist configured. Incoming chats will receive their IDs and be refused.");
87 }
88
89 wsClient.start({ eventDispatcher: dispatcher });
90 void reattachActiveTurns().catch((error) => {
91 console.error("failed to reattach active Feishu bridge turns", error);
92 });
93
94 async function handleIncomingMessage(event) {
95 const identity = incomingIdentity(event);
96 if (!identity.chatId) return;
97
98 // Store the incoming message ID so sendText() can reply inside the same
99 // Feishu thread/topic — without this, every bot message creates a new
100 // standalone topic in thread-enabled groups.
101 // / 缓存入站消息 ID,让 sendText 能通过 reply API 在同一话题内回复。
102 // / 否则每条 bot 消息都会在话题群中创建独立的新话题(见 #1710)。
103 if (identity.messageId) {
104 const existing = await threadStore.getChat(identity.chatId);
105 if (existing) {
106 await threadStore.patchChat(identity.chatId, {
107 replyToMessageId: identity.messageId,
108 updatedAt: new Date().toISOString()
109 });
110 } else {
111 await threadStore.setChat(identity.chatId, {
112 replyToMessageId: identity.messageId,
113 threadId: null,
114 lastSeq: 0,
115 activeTurnId: null,
116 updatedAt: new Date().toISOString()
117 });
118 }
119 }
120
121 if (identity.messageType && identity.messageType !== "text") {
122 await sendText(identity.chatId, "Only text messages are supported in this first bridge.");
123 return;
124 }
125
126 const rawText = parseTextContent(event.message?.content || "");
127 const scoped = stripGroupPrefix(rawText, {
128 chatType: identity.chatType,
129 requirePrefix: config.requirePrefixInGroup,
130 prefix: config.groupPrefix
131 });
132 if (!scoped.accepted) return;
133
134 if (identity.messageId && (await threadStore.recordMessage(identity.messageId))) {
135 return;
136 }
137
138 if (identity.chatType !== "p2p" && !config.allowGroups) {
139 await sendText(
140 identity.chatId,
141 "Group chat control is disabled for this bridge. DM the bot, or set FEISHU_ALLOW_GROUPS=true and allowlist this chat."
142 );
143 return;
144 }
145
146 if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
147 await sendText(identity.chatId, pairingRefusalText(identity));
148 return;
149 }
150
151 const command = parseCommand(scoped.text);
152 await handleCommand(identity.chatId, command);
153 }
154
155 async function handleCommand(chatId, command) {
156 const action = commandAction(command);
157 switch (action.kind) {
158 case "help":
159 await sendText(chatId, helpText());
160 return;
161 case "status":
162 await sendStatus(chatId);
163 return;
164 case "threads":
165 await sendThreads(chatId);
166 return;
167 case "new_thread": {
168 const state = await ensureThread(chatId, { forceNew: true });
169 await sendText(chatId, `Created thread ${state.threadId}`);
170 return;
171 }
172 case "resume":
173 await resumeThread(chatId, action.threadId);
174 return;
175 case "interrupt":
176 await interruptActiveTurn(chatId);
177 return;
178 case "compact":
179 await compactThread(chatId);
180 return;
181 case "approval":
182 await decideApproval(chatId, action);
183 return;
184 case "set_model":
185 await setChatModel(chatId, action.modelName);
186 return;
187 case "prompt":
188 await runPrompt(chatId, action.prompt);
189 return;
190 default:
191 await sendText(chatId, helpText());
192 }
193 }
194
195 async function ensureThread(chatId, { forceNew = false } = {}) {
196 const existing = await threadStore.getChat(chatId);
197 if (existing?.threadId && !forceNew) return existing;
198
199 // Use per-chat model if set, fall back to bridge-level default.
200 // / 优先使用 per-chat 模型(/model 命令设置),否则用桥接级别的默认模型。
201 const effectiveModel = existing?.model || config.model;
202
203 const thread = await runtimeJson("/v1/threads", {
204 method: "POST",
205 body: {
206 model: effectiveModel,
207 workspace: config.workspace,
208 mode: config.mode,
209 allow_shell: config.allowShell,
210 trust_mode: config.trustMode,
211 auto_approve: config.autoApprove,
212 archived: false,
213 system_prompt:
214 "You are being controlled from a Feishu/Lark phone chat. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval."
215 }
216 });
217
218 const state = {
219 ...preservedChatStateFields(existing),
220 threadId: thread.id,
221 lastSeq: 0,
222 activeTurnId: null,
223 updatedAt: new Date().toISOString()
224 };
225 await threadStore.setChat(chatId, state);
226 return state;
227 }
228
229 async function runPrompt(chatId, prompt) {
230 if (!prompt.trim()) {
231 await sendText(chatId, helpText());
232 return;
233 }
234 const state = await ensureThread(chatId);
235 // Use per-chat model for this turn (may differ from the thread's
236 // creation model if the user ran /model after the thread was created).
237 // / 使用 per-chat 模型执行本轮对话(如果用户在创建线程后切换过模型)。
238 const effectiveModel = state?.model || config.model;
239 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
240 const activeBlock = activeTurnBlock(detail, state);
241 if (activeBlock) {
242 await threadStore.patchChat(chatId, {
243 activeTurnId: activeBlock.turnId,
244 updatedAt: new Date().toISOString()
245 });
246 await sendText(chatId, activeBlock.message);
247 return;
248 }
249 if (state.activeTurnId) {
250 await threadStore.patchChat(chatId, { activeTurnId: null });
251 }
252 const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
253
254 const turnResponse = await runtimeJson(
255 `/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
256 {
257 method: "POST",
258 body: {
259 prompt,
260 input_summary: prompt.slice(0, 200),
261 model: effectiveModel,
262 mode: config.mode,
263 allow_shell: config.allowShell,
264 trust_mode: config.trustMode,
265 auto_approve: config.autoApprove
266 }
267 }
268 );
269
270 const turnId = turnResponse.turn?.id;
271 await threadStore.patchChat(chatId, {
272 activeTurnId: turnId || null,
273 lastSeq: sinceSeq,
274 updatedAt: new Date().toISOString()
275 });
276 await sendText(chatId, `Started turn ${turnId || "(unknown)"}`);
277
278 try {
279 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
280 } finally {
281 await threadStore.patchChat(chatId, {
282 activeTurnId: null,
283 updatedAt: new Date().toISOString()
284 });
285 }
286 }
287
288 async function reattachActiveTurns() {
289 for (const [chatId, state] of threadStore.listChats()) {
290 if (!state?.threadId || !state.activeTurnId) continue;
291
292 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
293 const runningTurn = latestRunningTurn(detail);
294 if (!runningTurn) {
295 await threadStore.patchChat(chatId, {
296 activeTurnId: null,
297 lastSeq: Number(detail.latest_seq || state.lastSeq || 0),
298 updatedAt: new Date().toISOString()
299 });
300 await sendText(chatId, `Bridge restarted. No active turn remains for ${state.threadId}.`);
301 continue;
302 }
303
304 const turnId = runningTurn.id || state.activeTurnId;
305 const sinceSeq = Number(state.lastSeq || 0);
306 await threadStore.patchChat(chatId, {
307 activeTurnId: turnId,
308 updatedAt: new Date().toISOString()
309 });
310 await sendText(
311 chatId,
312 `Bridge restarted. Reattaching to active turn ${turnId} from seq ${sinceSeq}.`
313 );
314 try {
315 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
316 } finally {
317 await threadStore.patchChat(chatId, {
318 activeTurnId: null,
319 updatedAt: new Date().toISOString()
320 });
321 }
322 }
323 }
324
325 async function streamTurnEvents(chatId, threadId, turnId, sinceSeq) {
326 const controller = new AbortController();
327 const timeout = setTimeout(() => controller.abort(), config.turnTimeoutMs);
328 let responseText = "";
329 let latestSeq = sinceSeq;
330 let sentProgressAt = Date.now();
331
332 try {
333 const response = await fetch(
334 `${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
335 {
336 headers: authHeaders(),
337 signal: controller.signal
338 }
339 );
340 if (!response.ok) {
341 const body = await readJsonSafe(response);
342 throw new Error(compactRuntimeError(response.status, body));
343 }
344
345 for await (const event of readSse(response)) {
346 if (!event.data) continue;
347 const record = JSON.parse(event.data);
348 latestSeq = Math.max(latestSeq, Number(record.seq || 0));
349 await threadStore.patchChat(chatId, { lastSeq: latestSeq });
350
351 if (turnId && record.turn_id && record.turn_id !== turnId) continue;
352
353 if (record.event === "item.delta" && record.payload?.kind === "agent_message") {
354 responseText += record.payload.delta || "";
355 const now = Date.now();
356 if (responseText.length > config.maxReplyChars && now - sentProgressAt > 15000) {
357 await sendText(chatId, responseText.slice(0, config.maxReplyChars));
358 responseText = responseText.slice(config.maxReplyChars);
359 sentProgressAt = now;
360 }
361 }
362
363 if (record.event === "approval.required") {
364 const approval = record.payload || {};
365 await sendText(
366 chatId,
367 [
368 "Approval required",
369 `tool=${approval.tool_name || "unknown"}`,
370 `approval_id=${approval.approval_id || approval.id}`,
371 approval.description || "",
372 "",
373 `Reply /allow ${approval.approval_id || approval.id}`,
374 `Reply /deny ${approval.approval_id || approval.id}`
375 ]
376 .filter(Boolean)
377 .join("\n")
378 );
379 }
380
381 if (record.event === "turn.completed") {
382 const turn = record.payload?.turn || {};
383 const status = turn.status || "completed";
384 const error = turn.error ? `\n${turn.error}` : "";
385 if (status !== "completed") {
386 await sendText(chatId, `Turn ${status}.${error}`.trim());
387 } else {
388 await sendText(chatId, responseText.trim() || "Turn completed.");
389 }
390 return;
391 }
392
393 if (record.event === "turn.lifecycle") {
394 const status = record.payload?.turn?.status || record.payload?.status;
395 if (["failed", "canceled", "interrupted"].includes(status)) {
396 await sendText(chatId, `Turn ${status}.`);
397 return;
398 }
399 }
400 }
401 } catch (error) {
402 if (error.name === "AbortError") {
403 await sendText(chatId, `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`);
404 return;
405 }
406 throw error;
407 } finally {
408 clearTimeout(timeout);
409 }
410 }
411
412 async function sendStatus(chatId) {
413 const [health, runtimeInfo, workspace] = await Promise.all([
414 runtimeJson("/health", { auth: false }),
415 runtimeJson("/v1/runtime/info"),
416 runtimeJson("/v1/workspace/status")
417 ]);
418 await sendText(
419 chatId,
420 [
421 `runtime=${health.status || "unknown"}`,
422 `version=${runtimeInfo.version || "unknown"}`,
423 `bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
424 `auth_required=${runtimeInfo.auth_required}`,
425 `workspace=${workspace.workspace}`,
426 `git_repo=${workspace.git_repo}`,
427 workspace.branch ? `branch=${workspace.branch}` : "",
428 `staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`
429 ]
430 .filter(Boolean)
431 .join("\n")
432 );
433 }
434
435 async function sendThreads(chatId) {
436 const threads = await runtimeJson("/v1/threads/summary?limit=8&include_archived=true");
437 if (!threads.length) {
438 await sendText(chatId, "No runtime threads yet.");
439 return;
440 }
441 await sendText(
442 chatId,
443 threads
444 .map((thread) => {
445 const status = thread.latest_turn_status || "none";
446 return `${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
447 })
448 .join("\n")
449 );
450 }
451
452 async function resumeThread(chatId, args) {
453 const threadId = args.trim();
454 if (!threadId) {
455 await sendText(chatId, "Usage: /resume <thread_id>");
456 return;
457 }
458 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
459 const existing = await threadStore.getChat(chatId);
460 await threadStore.setChat(chatId, {
461 ...preservedChatStateFields(existing),
462 threadId,
463 lastSeq: Number(detail.latest_seq || 0),
464 activeTurnId: null,
465 updatedAt: new Date().toISOString()
466 });
467 await sendText(chatId, `Resumed thread ${threadId}`);
468 }
469
470 async function interruptActiveTurn(chatId) {
471 const state = await threadStore.getChat(chatId);
472 if (!state?.threadId) {
473 await sendText(chatId, "No runtime thread recorded for this chat.");
474 return;
475 }
476 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
477 const runningTurn = latestRunningTurn(detail);
478 const turnId = state.activeTurnId || runningTurn?.id;
479 if (!turnId) {
480 await sendText(chatId, "No active turn recorded for this chat.");
481 return;
482 }
483 await runtimeJson(
484 `/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(
485 turnId
486 )}/interrupt`,
487 { method: "POST" }
488 );
489 await threadStore.patchChat(chatId, {
490 activeTurnId: turnId,
491 updatedAt: new Date().toISOString()
492 });
493 await sendText(chatId, `Interrupt requested for ${turnId}`);
494 }
495
496 async function compactThread(chatId) {
497 const state = await ensureThread(chatId);
498 const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
499 method: "POST",
500 body: { reason: "phone bridge request" }
501 });
502 await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`);
503 }
504
505 async function decideApproval(chatId, action) {
506 const decision = action.decision;
507 const { approvalId, remember } =
508 action.approvalId != null ? action : parseApprovalDecisionArgs(action.args);
509 if (!approvalId) {
510 await sendText(chatId, `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`);
511 return;
512 }
513 await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
514 method: "POST",
515 body: { decision, remember }
516 });
517 await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
518 }
519
520 async function setChatModel(chatId, modelName) {
521 // /model <name> — set per-chat model; "default" or empty resets to bridge default.
522 // / /model "default" 或空参数 — 恢复桥接级别的默认模型。
523 if (!modelName || modelName === "default") {
524 await threadStore.patchChat(chatId, {
525 model: null,
526 updatedAt: new Date().toISOString()
527 });
528 await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`);
529 return;
530 }
531 await threadStore.patchChat(chatId, {
532 model: modelName,
533 updatedAt: new Date().toISOString()
534 });
535 await sendText(chatId, `Per-chat model set to: ${modelName}`);
536 }
537
538 async function sendText(chatId, text) {
539 // Try reply API first — keeps bot responses inside the same Feishu
540 // thread/topic instead of spawning new standalone topics.
541 // / 优先使用 reply API,确保 bot 回复留在话题群的同一条话题内。
542 const state = await threadStore.getChat(chatId);
543 const replyToMessageId = state?.replyToMessageId || null;
544
545 const replyMessage =
546 replyToMessageId
547 ? client.im?.v1?.message?.reply?.bind(client.im.v1.message) ||
548 client.im?.message?.reply?.bind(client.im.message)
549 : null;
550 const createMessage =
551 client.im?.v1?.message?.create?.bind(client.im.v1.message) ||
552 client.im?.message?.create?.bind(client.im.message);
553 if (!createMessage) {
554 throw new Error("Lark SDK client does not expose im message create API");
555 }
556
557 let canReply = Boolean(replyMessage);
558 for (const chunk of splitMessage(text, config.maxReplyChars)) {
559 const body = {
560 msg_type: "text",
561 content: JSON.stringify({ text: chunk })
562 };
563 if (canReply) {
564 try {
565 await replyMessage({
566 path: { message_id: replyToMessageId },
567 data: body
568 });
569 continue;
570 } catch (error) {
571 canReply = false;
572 console.warn("Feishu reply API failed; falling back to message create", error);
573 }
574 }
575 await createMessage({
576 params: { receive_id_type: "chat_id" },
577 data: { ...body, receive_id: chatId }
578 });
579 }
580 }
581
582 function requiredEnv(name) {
583 const value = process.env[name];
584 if (!value || !value.trim()) {
585 throw new Error(`${name} is required`);
586 }
587 return value.trim();
588 }
589
590 function resolveLarkDomain(domain) {
591 const normalized = String(domain || "feishu").toLowerCase();
592 if (normalized === "lark") return Lark.Domain?.Lark || "https://open.larksuite.com";
593 if (normalized === "feishu") return Lark.Domain?.Feishu || "https://open.feishu.cn";
594 return domain;
595 }
596
596 lines Plain Text