返回 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 if (identity.messageType && identity.messageType !== "text") {
99 await sendText(identity.chatId, "Only text messages are supported in this first bridge.");
100 return;
101 }
102
103 const rawText = parseTextContent(event.message?.content || "");
104 const scoped = stripGroupPrefix(rawText, {
105 chatType: identity.chatType,
106 requirePrefix: config.requirePrefixInGroup,
107 prefix: config.groupPrefix
108 });
109 if (!scoped.accepted) return;
110
111 if (identity.messageId && (await threadStore.recordMessage(identity.messageId))) {
112 return;
113 }
114
115 if (identity.chatType !== "p2p" && !config.allowGroups) {
116 await sendText(
117 identity.chatId,
118 "Group chat control is disabled for this bridge. DM the bot, or set FEISHU_ALLOW_GROUPS=true and allowlist this chat."
119 );
120 return;
121 }
122
123 if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
124 await sendText(identity.chatId, pairingRefusalText(identity));
125 return;
126 }
127
128 // Only an admitted sender may change delivery/recovery provenance.
129 const { chatId, chatType, openId, unionId, userId } = identity;
130 await threadStore.patchChat(chatId, {
131 authorizedIdentity: { chatId, chatType, openId, unionId, userId },
132 ...(identity.messageId ? { replyToMessageId: identity.messageId } : {}),
133 updatedAt: new Date().toISOString()
134 });
135
136 const command = parseCommand(scoped.text);
137 await handleCommand(identity.chatId, command);
138 }
139
140 async function handleCommand(chatId, command) {
141 const action = commandAction(command);
142 switch (action.kind) {
143 case "help":
144 await sendText(chatId, helpText());
145 return;
146 case "status":
147 await sendStatus(chatId);
148 return;
149 case "threads":
150 await sendThreads(chatId);
151 return;
152 case "new_thread": {
153 const state = await ensureThread(chatId, { forceNew: true });
154 await sendText(chatId, `Created thread ${state.threadId}`);
155 return;
156 }
157 case "resume":
158 await resumeThread(chatId, action.threadId);
159 return;
160 case "interrupt":
161 await interruptActiveTurn(chatId);
162 return;
163 case "compact":
164 await compactThread(chatId);
165 return;
166 case "approval":
167 await decideApproval(chatId, action);
168 return;
169 case "set_model":
170 await setChatModel(chatId, action.modelName);
171 return;
172 case "prompt":
173 await runPrompt(chatId, action.prompt);
174 return;
175 default:
176 await sendText(chatId, helpText());
177 }
178 }
179
180 async function ensureThread(chatId, { forceNew = false } = {}) {
181 const existing = await threadStore.getChat(chatId);
182 if (existing?.threadId && !forceNew) return existing;
183
184 // Use per-chat model if set, fall back to bridge-level default.
185 // / 优先使用 per-chat 模型(/model 命令设置),否则用桥接级别的默认模型。
186 const effectiveModel = existing?.model || config.model;
187
188 const thread = await runtimeJson("/v1/threads", {
189 method: "POST",
190 body: {
191 model: effectiveModel,
192 workspace: config.workspace,
193 mode: config.mode,
194 allow_shell: config.allowShell,
195 trust_mode: config.trustMode,
196 auto_approve: config.autoApprove,
197 archived: false,
198 system_prompt:
199 "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."
200 }
201 });
202
203 const state = {
204 ...preservedChatStateFields(existing),
205 threadId: thread.id,
206 lastSeq: 0,
207 activeTurnId: null,
208 updatedAt: new Date().toISOString()
209 };
210 await threadStore.setChat(chatId, state);
211 return state;
212 }
213
214 async function runPrompt(chatId, prompt) {
215 if (!prompt.trim()) {
216 await sendText(chatId, helpText());
217 return;
218 }
219 const state = await ensureThread(chatId);
220 // Use per-chat model for this turn (may differ from the thread's
221 // creation model if the user ran /model after the thread was created).
222 // / 使用 per-chat 模型执行本轮对话(如果用户在创建线程后切换过模型)。
223 const effectiveModel = state?.model || config.model;
224 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
225 const activeBlock = activeTurnBlock(detail, state);
226 if (activeBlock) {
227 await threadStore.patchChat(chatId, {
228 activeTurnId: activeBlock.turnId,
229 updatedAt: new Date().toISOString()
230 });
231 await sendText(chatId, activeBlock.message);
232 return;
233 }
234 if (state.activeTurnId) {
235 await threadStore.patchChat(chatId, { activeTurnId: null });
236 }
237 const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
238
239 const turnResponse = await runtimeJson(
240 `/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
241 {
242 method: "POST",
243 body: {
244 prompt,
245 input_summary: prompt.slice(0, 200),
246 model: effectiveModel,
247 mode: config.mode,
248 allow_shell: config.allowShell,
249 trust_mode: config.trustMode,
250 auto_approve: config.autoApprove
251 }
252 }
253 );
254
255 const turnId = turnResponse.turn?.id;
256 await threadStore.patchChat(chatId, {
257 activeTurnId: turnId || null,
258 lastSeq: sinceSeq,
259 updatedAt: new Date().toISOString()
260 });
261 await sendText(chatId, `Started turn ${turnId || "(unknown)"}`);
262
263 try {
264 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
265 } finally {
266 await threadStore.patchChat(chatId, {
267 activeTurnId: null,
268 updatedAt: new Date().toISOString()
269 });
270 }
271 }
272
273 async function reattachActiveTurns() {
274 for (const [chatId, state] of threadStore.listChats()) {
275 if (!state?.threadId || !state.activeTurnId) continue;
276 const identity = state.authorizedIdentity;
277 if (!identity || identity.chatId !== chatId ||
278 !["p2p", "group"].includes(identity.chatType) ||
279 (identity.chatType !== "p2p" && !config.allowGroups) ||
280 !isAllowed(identity, config.allowlist, config.allowUnlisted)) continue;
281
282
283 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
284 const runningTurn = latestRunningTurn(detail);
285 if (!runningTurn) {
286 await threadStore.patchChat(chatId, {
287 activeTurnId: null,
288 lastSeq: Number(detail.latest_seq || state.lastSeq || 0),
289 updatedAt: new Date().toISOString()
290 });
291 await sendText(chatId, `Bridge restarted. No active turn remains for ${state.threadId}.`);
292 continue;
293 }
294
295 const turnId = runningTurn.id || state.activeTurnId;
296 const sinceSeq = Number(state.lastSeq || 0);
297 await threadStore.patchChat(chatId, {
298 activeTurnId: turnId,
299 updatedAt: new Date().toISOString()
300 });
301 await sendText(
302 chatId,
303 `Bridge restarted. Reattaching to active turn ${turnId} from seq ${sinceSeq}.`
304 );
305 try {
306 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
307 } finally {
308 await threadStore.patchChat(chatId, {
309 activeTurnId: null,
310 updatedAt: new Date().toISOString()
311 });
312 }
313 }
314 }
315
316 async function streamTurnEvents(chatId, threadId, turnId, sinceSeq) {
317 const controller = new AbortController();
318 const timeout = setTimeout(() => controller.abort(), config.turnTimeoutMs);
319 let responseText = "";
320 let latestSeq = sinceSeq;
321 let sentProgressAt = Date.now();
322
323 try {
324 const response = await fetch(
325 `${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
326 {
327 headers: authHeaders(),
328 signal: controller.signal
329 }
330 );
331 if (!response.ok) {
332 const body = await readJsonSafe(response);
333 throw new Error(compactRuntimeError(response.status, body));
334 }
335
336 for await (const event of readSse(response)) {
337 if (!event.data) continue;
338 const record = JSON.parse(event.data);
339 latestSeq = Math.max(latestSeq, Number(record.seq || 0));
340 await threadStore.patchChat(chatId, { lastSeq: latestSeq });
341
342 if (turnId && record.turn_id && record.turn_id !== turnId) continue;
343
344 if (record.event === "item.delta" && record.payload?.kind === "agent_message") {
345 responseText += record.payload.delta || "";
346 const now = Date.now();
347 if (responseText.length > config.maxReplyChars && now - sentProgressAt > 15000) {
348 await sendText(chatId, responseText.slice(0, config.maxReplyChars));
349 responseText = responseText.slice(config.maxReplyChars);
350 sentProgressAt = now;
351 }
352 }
353
354 if (record.event === "approval.required") {
355 const approval = record.payload || {};
356 await sendText(
357 chatId,
358 [
359 "Approval required",
360 `tool=${approval.tool_name || "unknown"}`,
361 `approval_id=${approval.approval_id || approval.id}`,
362 approval.description || "",
363 "",
364 `Reply /allow ${approval.approval_id || approval.id}`,
365 `Reply /deny ${approval.approval_id || approval.id}`
366 ]
367 .filter(Boolean)
368 .join("\n")
369 );
370 }
371
372 if (record.event === "turn.completed") {
373 const turn = record.payload?.turn || {};
374 const status = turn.status || "completed";
375 const error = turn.error ? `\n${turn.error}` : "";
376 if (status !== "completed") {
377 await sendText(chatId, `Turn ${status}.${error}`.trim());
378 } else {
379 await sendText(chatId, responseText.trim() || "Turn completed.");
380 }
381 return;
382 }
383
384 if (record.event === "turn.lifecycle") {
385 const status = record.payload?.turn?.status || record.payload?.status;
386 if (["failed", "canceled", "interrupted"].includes(status)) {
387 await sendText(chatId, `Turn ${status}.`);
388 return;
389 }
390 }
391 }
392 } catch (error) {
393 if (error.name === "AbortError") {
394 await sendText(chatId, `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`);
395 return;
396 }
397 throw error;
398 } finally {
399 clearTimeout(timeout);
400 }
401 }
402
403 async function sendStatus(chatId) {
404 const [health, runtimeInfo, workspace] = await Promise.all([
405 runtimeJson("/health", { auth: false }),
406 runtimeJson("/v1/runtime/info"),
407 runtimeJson("/v1/workspace/status")
408 ]);
409 await sendText(
410 chatId,
411 [
412 `runtime=${health.status || "unknown"}`,
413 `version=${runtimeInfo.version || "unknown"}`,
414 `bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
415 `auth_required=${runtimeInfo.auth_required}`,
416 `workspace=${workspace.workspace}`,
417 `git_repo=${workspace.git_repo}`,
418 workspace.branch ? `branch=${workspace.branch}` : "",
419 `staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`
420 ]
421 .filter(Boolean)
422 .join("\n")
423 );
424 }
425
426 async function sendThreads(chatId) {
427 const threads = await runtimeJson("/v1/threads/summary?limit=8&include_archived=true");
428 if (!threads.length) {
429 await sendText(chatId, "No runtime threads yet.");
430 return;
431 }
432 await sendText(
433 chatId,
434 threads
435 .map((thread) => {
436 const status = thread.latest_turn_status || "none";
437 return `${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
438 })
439 .join("\n")
440 );
441 }
442
443 async function resumeThread(chatId, args) {
444 const threadId = args.trim();
445 if (!threadId) {
446 await sendText(chatId, "Usage: /resume <thread_id>");
447 return;
448 }
449 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
450 const existing = await threadStore.getChat(chatId);
451 await threadStore.setChat(chatId, {
452 ...preservedChatStateFields(existing),
453 threadId,
454 lastSeq: Number(detail.latest_seq || 0),
455 activeTurnId: null,
456 updatedAt: new Date().toISOString()
457 });
458 await sendText(chatId, `Resumed thread ${threadId}`);
459 }
460
461 async function interruptActiveTurn(chatId) {
462 const state = await threadStore.getChat(chatId);
463 if (!state?.threadId) {
464 await sendText(chatId, "No runtime thread recorded for this chat.");
465 return;
466 }
467 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
468 const runningTurn = latestRunningTurn(detail);
469 const turnId = state.activeTurnId || runningTurn?.id;
470 if (!turnId) {
471 await sendText(chatId, "No active turn recorded for this chat.");
472 return;
473 }
474 await runtimeJson(
475 `/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(
476 turnId
477 )}/interrupt`,
478 { method: "POST" }
479 );
480 await threadStore.patchChat(chatId, {
481 activeTurnId: turnId,
482 updatedAt: new Date().toISOString()
483 });
484 await sendText(chatId, `Interrupt requested for ${turnId}`);
485 }
486
487 async function compactThread(chatId) {
488 const state = await ensureThread(chatId);
489 const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
490 method: "POST",
491 body: { reason: "phone bridge request" }
492 });
493 await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`);
494 }
495
496 async function decideApproval(chatId, action) {
497 const decision = action.decision;
498 const { approvalId, remember } =
499 action.approvalId != null ? action : parseApprovalDecisionArgs(action.args);
500 if (!approvalId) {
501 await sendText(chatId, `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`);
502 return;
503 }
504 await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
505 method: "POST",
506 body: { decision, remember }
507 });
508 await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
509 }
510
511 async function setChatModel(chatId, modelName) {
512 // /model <name> — set per-chat model; "default" or empty resets to bridge default.
513 // / /model "default" 或空参数 — 恢复桥接级别的默认模型。
514 if (!modelName || modelName === "default") {
515 await threadStore.patchChat(chatId, {
516 model: null,
517 updatedAt: new Date().toISOString()
518 });
519 await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`);
520 return;
521 }
522 await threadStore.patchChat(chatId, {
523 model: modelName,
524 updatedAt: new Date().toISOString()
525 });
526 await sendText(chatId, `Per-chat model set to: ${modelName}`);
527 }
528
529 async function sendText(chatId, text) {
530 // Try reply API first — keeps bot responses inside the same Feishu
531 // thread/topic instead of spawning new standalone topics.
532 // / 优先使用 reply API,确保 bot 回复留在话题群的同一条话题内。
533 const state = await threadStore.getChat(chatId);
534 const replyToMessageId = state?.replyToMessageId || null;
535
536 const replyMessage =
537 replyToMessageId
538 ? client.im?.v1?.message?.reply?.bind(client.im.v1.message) ||
539 client.im?.message?.reply?.bind(client.im.message)
540 : null;
541 const createMessage =
542 client.im?.v1?.message?.create?.bind(client.im.v1.message) ||
543 client.im?.message?.create?.bind(client.im.message);
544 if (!createMessage) {
545 throw new Error("Lark SDK client does not expose im message create API");
546 }
547
548 let canReply = Boolean(replyMessage);
549 for (const chunk of splitMessage(text, config.maxReplyChars)) {
550 const body = {
551 msg_type: "text",
552 content: JSON.stringify({ text: chunk })
553 };
554 if (canReply) {
555 try {
556 await replyMessage({
557 path: { message_id: replyToMessageId },
558 data: body
559 });
560 continue;
561 } catch (error) {
562 canReply = false;
563 console.warn("Feishu reply API failed; falling back to message create", error);
564 }
565 }
566 await createMessage({
567 params: { receive_id_type: "chat_id" },
568 data: { ...body, receive_id: chatId }
569 });
570 }
571 }
572
573 function requiredEnv(name) {
574 const value = process.env[name];
575 if (!value || !value.trim()) {
576 throw new Error(`${name} is required`);
577 }
578 return value.trim();
579 }
580
581 function resolveLarkDomain(domain) {
582 const normalized = String(domain || "feishu").toLowerCase();
583 if (normalized === "lark") return Lark.Domain?.Lark || "https://open.larksuite.com";
584 if (normalized === "feishu") return Lark.Domain?.Feishu || "https://open.feishu.cn";
585 return domain;
586 }
587
587 lines Plain Text