返回 CodeWhale
index.mjs
1 import fs from "node:fs/promises";
2 import path from "node:path";
3 import crypto from "node:crypto";
4
5 import {
6 getLoginQR,
7 waitForLogin,
8 getUpdates,
9 sendMessage,
10 getConfig,
11 notifyStart,
12 notifyStop,
13 ILinkLoginBase,
14 parseList,
15 parseBool,
16 envFirst,
17 extractText,
18 parseCommand,
19 commandAction,
20 preservedChatStateFields,
21 splitMessage,
22 compactRuntimeError,
23 latestRunningTurn,
24 activeTurnBlock,
25 helpText,
26 } from "./lib.mjs";
27 import { ThreadStore as CoreThreadStore } from "../../bridge-core/src/lib.mjs";
28
29 // ============================================================================
30 // ThreadStore — JSON 文件持久化(与 feishu/telegram/wechat bridge 一致)
31 // ============================================================================
32
33 class ThreadStore extends CoreThreadStore {
34 constructor(filePath) {
35 super(filePath, { messageLimit: 500 });
36 }
37 }
38
39 // ============================================================================
40 // 账号持久化
41 // ============================================================================
42
43 function resolveAccountPath(stateDir) {
44 return path.join(stateDir, "account.json");
45 }
46
47 async function loadAccount(stateDir) {
48 const p = resolveAccountPath(stateDir);
49 try {
50 const raw = await fs.readFile(p, "utf8");
51 return JSON.parse(raw);
52 } catch (error) {
53 if (error.code !== "ENOENT") throw error;
54 return null;
55 }
56 }
57
58 async function saveAccount(stateDir, account) {
59 const p = resolveAccountPath(stateDir);
60 await fs.mkdir(path.dirname(p), { recursive: true, mode: 0o700 });
61 const tmp = `${p}.tmp`;
62 await fs.writeFile(tmp, `${JSON.stringify(account, null, 2)}\n`, {
63 mode: 0o600,
64 });
65 await fs.rename(tmp, p);
66 }
67
68 // ============================================================================
69 // 配置
70 // ============================================================================
71
72 function requiredEnv(name) {
73 const value = process.env[name];
74 if (!value || !value.trim()) {
75 console.error(`Missing required env: ${name}`);
76 process.exit(1);
77 }
78 return value.trim();
79 }
80
81 function requiredEnvFirst(...names) {
82 const value = envFirst(process.env, ...names);
83 if (!value) {
84 console.error(`Missing required env: one of ${names.join(", ")}`);
85 process.exit(1);
86 }
87 return value;
88 }
89
90 // WEIXIN_* is the canonical spelling; the historical WEXIN_* typo names are
91 // still honored as deprecated aliases (each warns once per process).
92 const warnedEnvAliases = new Set();
93
94 function weixinEnv(name) {
95 const legacy = `WEXIN_${name.slice("WEIXIN_".length)}`;
96 const primary = envFirst(process.env, name);
97 if (primary) return primary;
98 const fallback = envFirst(process.env, legacy);
99 if (fallback && !warnedEnvAliases.has(legacy)) {
100 warnedEnvAliases.add(legacy);
101 console.warn(`${legacy} is deprecated; rename it to ${name}.`);
102 }
103 return fallback;
104 }
105
106 const config = {
107 runtimeUrl: (
108 envFirst(process.env, "CODEWHALE_RUNTIME_URL", "DEEPSEEK_RUNTIME_URL") ||
109 "http://127.0.0.1:7878"
110 ).replace(/\/+$/, ""),
111 runtimeToken: requiredEnvFirst(
112 "CODEWHALE_RUNTIME_TOKEN",
113 "DEEPSEEK_RUNTIME_TOKEN"
114 ),
115 workspace:
116 envFirst(process.env, "CODEWHALE_WORKSPACE", "DEEPSEEK_WORKSPACE") ||
117 process.cwd(),
118 model:
119 envFirst(process.env, "CODEWHALE_MODEL", "DEEPSEEK_MODEL") || "auto",
120 mode:
121 envFirst(process.env, "CODEWHALE_MODE", "DEEPSEEK_MODE") || "agent",
122 allowShell: parseBool(
123 envFirst(
124 process.env,
125 "CODEWHALE_ALLOW_SHELL",
126 "DEEPSEEK_ALLOW_SHELL"
127 ),
128 true
129 ),
130 trustMode: parseBool(
131 envFirst(
132 process.env,
133 "CODEWHALE_TRUST_MODE",
134 "DEEPSEEK_TRUST_MODE"
135 ),
136 false
137 ),
138 autoApprove: parseBool(
139 envFirst(
140 process.env,
141 "CODEWHALE_AUTO_APPROVE",
142 "DEEPSEEK_AUTO_APPROVE"
143 ),
144 false
145 ),
146 allowlist: parseList(
147 weixinEnv("WEIXIN_CHAT_ALLOWLIST") ||
148 envFirst(
149 process.env,
150 "CODEWHALE_CHAT_ALLOWLIST",
151 "DEEPSEEK_CHAT_ALLOWLIST"
152 )
153 ),
154 allowUnlisted: parseBool(
155 weixinEnv("WEIXIN_ALLOW_UNLISTED") ||
156 envFirst(
157 process.env,
158 "CODEWHALE_ALLOW_UNLISTED",
159 "DEEPSEEK_ALLOW_UNLISTED"
160 ),
161 false
162 ),
163 stateDir:
164 weixinEnv("WEIXIN_STATE_DIR") ||
165 "/var/lib/codewhale-weixin-bot-bridge",
166 threadMapPath:
167 weixinEnv("WEIXIN_THREAD_MAP_PATH") ||
168 "/var/lib/codewhale-weixin-bot-bridge/thread-map.json",
169 maxReplyChars: Number(weixinEnv("WEIXIN_MAX_REPLY_CHARS") || 3500),
170 longPollTimeoutMs: Number(
171 weixinEnv("WEIXIN_LONGPOLL_TIMEOUT_MS") || 35000
172 ),
173 turnTimeoutMs: Number(
174 envFirst(
175 process.env,
176 "CODEWHALE_TURN_TIMEOUT_MS",
177 "DEEPSEEK_TURN_TIMEOUT_MS"
178 ) || 900000
179 ),
180 };
181
182 // ============================================================================
183 // Runtime API 工具
184 // ============================================================================
185
186 function authHeaders() {
187 return {
188 Authorization: `Bearer ${config.runtimeToken}`,
189 "Content-Type": "application/json",
190 };
191 }
192
193 async function readJsonSafe(response) {
194 try {
195 return await response.json();
196 } catch {
197 return null;
198 }
199 }
200
201 async function runtimeJson(subPath, { method = "GET", body = null, auth = true } = {}) {
202 const url = `${config.runtimeUrl}${subPath}`;
203 const options = { method, headers: auth ? authHeaders() : {} };
204 if (body) options.body = JSON.stringify(body);
205 const response = await fetch(url, options);
206 const result = await readJsonSafe(response);
207 if (!response.ok) {
208 throw new Error(compactRuntimeError(response.status, result));
209 }
210 return result;
211 }
212
213 async function* readSse(response) {
214 let buffer = "";
215 for await (const chunk of response.body) {
216 buffer += new TextDecoder().decode(chunk, { stream: true });
217 const lines = buffer.split("\n");
218 buffer = lines.pop() || "";
219 for (const line of lines) {
220 const trimmed = line.trim();
221 if (!trimmed) continue;
222 if (trimmed.startsWith("data:")) {
223 yield { data: trimmed.slice(5).trim() };
224 } else if (trimmed.startsWith("event:")) {
225 yield { event: trimmed.slice(6).trim() };
226 } else if (trimmed.startsWith("id:")) {
227 yield { id: trimmed.slice(3).trim() };
228 }
229 }
230 }
231 }
232
233 // ============================================================================
234 // 消息发送 — 通过 iLink sendMessage
235 // ============================================================================
236
237 async function sendText(chatId, text) {
238 if (!botAccount) {
239 console.error("sendText: bot not logged in");
240 return;
241 }
242 const chunks = splitMessage(text, config.maxReplyChars);
243 for (const chunk of chunks) {
244 await sendMessage({
245 baseUrl: botAccount.baseUrl,
246 token: botAccount.token,
247 body: {
248 msg: {
249 to_user_id: chatId,
250 client_id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
251 message_type: 2, // BOT
252 message_state: 2, // FINISH
253 item_list: [{ type: 1, text_item: { text: chunk } }],
254 context_token: await getContextToken(chatId),
255 },
256 },
257 });
258 }
259 }
260
261 async function getContextToken(chatId) {
262 const state = await threadStore.getChat(chatId);
263 return state?.contextToken || undefined;
264 }
265
266 // ============================================================================
267 // 命令处理(与 feishu/telegram/wechat bridge 一致)
268 // ============================================================================
269
270 async function handleCommand(chatId, command) {
271 const action = commandAction(command);
272 switch (action.kind) {
273 case "help":
274 await sendText(chatId, helpText());
275 return;
276 case "status":
277 await sendStatus(chatId);
278 return;
279 case "threads":
280 await sendThreads(chatId);
281 return;
282 case "new_thread": {
283 const state = await ensureThread(chatId, { forceNew: true });
284 await sendText(chatId, `Created thread ${state.threadId}`);
285 return;
286 }
287 case "resume":
288 await resumeThread(chatId, action.threadId);
289 return;
290 case "interrupt":
291 await interruptActiveTurn(chatId);
292 return;
293 case "compact":
294 await compactThread(chatId);
295 return;
296 case "approval":
297 await decideApproval(chatId, action);
298 return;
299 case "set_model":
300 await setChatModel(chatId, action.modelName);
301 return;
302 case "prompt":
303 await runPrompt(chatId, action.prompt);
304 return;
305 default:
306 await sendText(chatId, helpText());
307 }
308 }
309
310 async function ensureThread(chatId, { forceNew = false } = {}) {
311 const existing = await threadStore.getChat(chatId);
312 if (existing?.threadId && !forceNew) return existing;
313
314 const effectiveModel = existing?.model || config.model;
315
316 const thread = await runtimeJson("/v1/threads", {
317 method: "POST",
318 body: {
319 model: effectiveModel,
320 workspace: config.workspace,
321 mode: config.mode,
322 allow_shell: config.allowShell,
323 trust_mode: config.trustMode,
324 auto_approve: config.autoApprove,
325 archived: false,
326 system_prompt:
327 "You are being controlled from a WeChat phone chat via iLink Bot. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval.",
328 },
329 });
330
331 const state = {
332 ...preservedChatStateFields(existing),
333 threadId: thread.id,
334 lastSeq: 0,
335 activeTurnId: null,
336 updatedAt: new Date().toISOString(),
337 };
338 await threadStore.setChat(chatId, state);
339 return state;
340 }
341
342 async function runPrompt(chatId, prompt) {
343 if (!prompt.trim()) {
344 await sendText(chatId, helpText());
345 return;
346 }
347 const state = await ensureThread(chatId);
348 const effectiveModel = state?.model || config.model;
349 const detail = await runtimeJson(
350 `/v1/threads/${encodeURIComponent(state.threadId)}`
351 );
352 const activeBlock = activeTurnBlock(detail, state);
353 if (activeBlock) {
354 await threadStore.patchChat(chatId, {
355 activeTurnId: activeBlock.turnId,
356 updatedAt: new Date().toISOString(),
357 });
358 await sendText(chatId, activeBlock.message);
359 return;
360 }
361 if (state.activeTurnId) {
362 await threadStore.patchChat(chatId, { activeTurnId: null });
363 }
364 const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
365
366 const turnResponse = await runtimeJson(
367 `/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
368 {
369 method: "POST",
370 body: {
371 prompt,
372 input_summary: prompt.slice(0, 200),
373 model: effectiveModel,
374 mode: config.mode,
375 allow_shell: config.allowShell,
376 trust_mode: config.trustMode,
377 auto_approve: config.autoApprove,
378 },
379 }
380 );
381
382 const turnId = turnResponse.turn?.id;
383 await threadStore.patchChat(chatId, {
384 activeTurnId: turnId || null,
385 lastSeq: sinceSeq,
386 updatedAt: new Date().toISOString(),
387 });
388 await sendText(chatId, `Started turn ${turnId || "(unknown)"}`);
389
390 try {
391 await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
392 } finally {
393 await threadStore.patchChat(chatId, {
394 activeTurnId: null,
395 updatedAt: new Date().toISOString(),
396 });
397 }
398 }
399
400 async function streamTurnEvents(chatId, threadId, turnId, sinceSeq) {
401 const controller = new AbortController();
402 const timeout = setTimeout(
403 () => controller.abort(),
404 config.turnTimeoutMs
405 );
406 let responseText = "";
407 let latestSeq = sinceSeq;
408 let sentProgressAt = Date.now();
409
410 try {
411 const response = await fetch(
412 `${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
413 {
414 headers: authHeaders(),
415 signal: controller.signal,
416 }
417 );
418 if (!response.ok) {
419 const body = await readJsonSafe(response);
420 throw new Error(compactRuntimeError(response.status, body));
421 }
422
423 for await (const event of readSse(response)) {
424 if (!event.data) continue;
425 const record = JSON.parse(event.data);
426 latestSeq = Math.max(latestSeq, Number(record.seq || 0));
427 await threadStore.patchChat(chatId, { lastSeq: latestSeq });
428
429 if (turnId && record.turn_id && record.turn_id !== turnId) continue;
430
431 if (
432 record.event === "item.delta" &&
433 record.payload?.kind === "agent_message"
434 ) {
435 responseText += record.payload.delta || "";
436 const now = Date.now();
437 if (
438 responseText.length > config.maxReplyChars &&
439 now - sentProgressAt > 15000
440 ) {
441 await sendText(chatId, responseText.slice(0, config.maxReplyChars));
442 responseText = responseText.slice(config.maxReplyChars);
443 sentProgressAt = now;
444 }
445 }
446
447 if (record.event === "approval.required") {
448 const approval = record.payload || {};
449 const approvalId = approval.approval_id || approval.id;
450 if (!approvalId) {
451 await sendText(
452 chatId,
453 [
454 "Approval required",
455 `tool=${approval.tool_name || "unknown"}`,
456 approval.description || "",
457 "",
458 "No approval_id was provided by the runtime; use /status and retry from the TUI.",
459 ]
460 .filter(Boolean)
461 .join("\n")
462 );
463 } else {
464 await sendText(
465 chatId,
466 [
467 "Approval required",
468 `tool=${approval.tool_name || "unknown"}`,
469 `approval_id=${approvalId}`,
470 approval.description || "",
471 "",
472 `Reply /allow ${approvalId} or /deny ${approvalId}`,
473 ]
474 .filter(Boolean)
475 .join("\n")
476 );
477 }
478 }
479
480 if (record.event === "turn.completed") {
481 const turn = record.payload?.turn || {};
482 const status = turn.status || "completed";
483 const error = turn.error ? `\n${turn.error}` : "";
484 if (status !== "completed") {
485 await sendText(chatId, `Turn ${status}.${error}`.trim());
486 } else {
487 await sendText(
488 chatId,
489 responseText.trim() || "Turn completed."
490 );
491 }
492 return;
493 }
494
495 if (record.event === "turn.lifecycle") {
496 const status =
497 record.payload?.turn?.status || record.payload?.status;
498 if (["failed", "canceled", "interrupted"].includes(status)) {
499 await sendText(chatId, `Turn ${status}.`);
500 return;
501 }
502 }
503 }
504 } catch (error) {
505 if (error.name === "AbortError") {
506 await sendText(
507 chatId,
508 `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`
509 );
510 return;
511 }
512 throw error;
513 } finally {
514 clearTimeout(timeout);
515 }
516 }
517
518 async function sendStatus(chatId) {
519 try {
520 const [health, runtimeInfo, workspace] = await Promise.all([
521 runtimeJson("/health", { auth: false }),
522 runtimeJson("/v1/runtime/info"),
523 runtimeJson("/v1/workspace/status"),
524 ]);
525 await sendText(
526 chatId,
527 [
528 `runtime=${health.status || "unknown"}`,
529 `version=${runtimeInfo.version || "unknown"}`,
530 `bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
531 `auth_required=${runtimeInfo.auth_required}`,
532 `workspace=${workspace.workspace}`,
533 `git_repo=${workspace.git_repo}`,
534 workspace.branch ? `branch=${workspace.branch}` : "",
535 `staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`,
536 ]
537 .filter(Boolean)
538 .join("\n")
539 );
540 } catch (error) {
541 await sendText(chatId, `Status check failed: ${error.message}`);
542 }
543 }
544
545 async function sendThreads(chatId) {
546 try {
547 const threads = await runtimeJson(
548 "/v1/threads/summary?limit=8&include_archived=true"
549 );
550 if (!threads.length) {
551 await sendText(chatId, "No runtime threads yet.");
552 return;
553 }
554 await sendText(
555 chatId,
556 threads
557 .map((thread) => {
558 const status = thread.latest_turn_status || "none";
559 return `${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
560 })
561 .join("\n")
562 );
563 } catch (error) {
564 await sendText(chatId, `Thread listing failed: ${error.message}`);
565 }
566 }
567
568 async function resumeThread(chatId, args) {
569 const threadId = args.trim();
570 if (!threadId) {
571 await sendText(chatId, "Usage: /resume <thread_id>");
572 return;
573 }
574 try {
575 const detail = await runtimeJson(
576 `/v1/threads/${encodeURIComponent(threadId)}`
577 );
578 const existing = await threadStore.getChat(chatId);
579 await threadStore.setChat(chatId, {
580 ...preservedChatStateFields(existing),
581 threadId,
582 lastSeq: Number(detail.latest_seq || 0),
583 activeTurnId: null,
584 updatedAt: new Date().toISOString(),
585 });
586 await sendText(chatId, `Resumed thread ${threadId}`);
587 } catch (error) {
588 await sendText(chatId, `Resume failed: ${error.message}`);
589 }
590 }
591
592 async function interruptActiveTurn(chatId) {
593 const state = await threadStore.getChat(chatId);
594 if (!state?.threadId) {
595 await sendText(chatId, "No runtime thread recorded for this chat.");
596 return;
597 }
598 try {
599 const detail = await runtimeJson(
600 `/v1/threads/${encodeURIComponent(state.threadId)}`
601 );
602 const runningTurn = latestRunningTurn(detail);
603 const turnId = state.activeTurnId || runningTurn?.id;
604 if (!turnId) {
605 await sendText(chatId, "No active turn recorded for this chat.");
606 return;
607 }
608 await runtimeJson(
609 `/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`,
610 { method: "POST" }
611 );
612 await threadStore.patchChat(chatId, {
613 activeTurnId: turnId,
614 updatedAt: new Date().toISOString(),
615 });
616 await sendText(chatId, `Interrupt requested for ${turnId}`);
617 } catch (error) {
618 await sendText(chatId, `Interrupt failed: ${error.message}`);
619 }
620 }
621
622 async function compactThread(chatId) {
623 try {
624 const state = await ensureThread(chatId);
625 const result = await runtimeJson(
626 `/v1/threads/${encodeURIComponent(state.threadId)}/compact`,
627 {
628 method: "POST",
629 body: { reason: "weixin-bot bridge request" },
630 }
631 );
632 await sendText(
633 chatId,
634 `Compaction started: ${result.turn?.id || "unknown turn"}`
635 );
636 } catch (error) {
637 await sendText(chatId, `Compact failed: ${error.message}`);
638 }
639 }
640
641 async function decideApproval(chatId, action) {
642 const decision = action.decision;
643 const { approvalId, remember } = action;
644 if (!approvalId) {
645 await sendText(
646 chatId,
647 `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`
648 );
649 return;
650 }
651 try {
652 await runtimeJson(
653 `/v1/approvals/${encodeURIComponent(approvalId)}`,
654 {
655 method: "POST",
656 body: { decision, remember },
657 }
658 );
659 await sendText(
660 chatId,
661 `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`
662 );
663 } catch (error) {
664 await sendText(chatId, `Approval failed: ${error.message}`);
665 }
666 }
667
668 async function setChatModel(chatId, modelName) {
669 if (!modelName || modelName === "default") {
670 await threadStore.patchChat(chatId, {
671 model: null,
672 updatedAt: new Date().toISOString(),
673 });
674 await sendText(
675 chatId,
676 `Reset per-chat model. Using bridge default: ${config.model}`
677 );
678 return;
679 }
680 await threadStore.patchChat(chatId, {
681 model: modelName,
682 updatedAt: new Date().toISOString(),
683 });
684 await sendText(chatId, `Per-chat model set to: ${modelName}`);
685 }
686
687 // ============================================================================
688 // 主循环 — 长轮询 getUpdates
689 // ============================================================================
690
691 let botAccount = null;
692 let stopping = false;
693 let threadStore;
694 let stopSignal = null;
695
696 function resolveSyncBufPath(stateDir) {
697 return path.join(stateDir, "sync-buf.txt");
698 }
699
700 async function loadSyncBuf(stateDir) {
701 const p = resolveSyncBufPath(stateDir);
702 try {
703 return await fs.readFile(p, "utf8");
704 } catch {
705 return "";
706 }
707 }
708
709 async function saveSyncBuf(stateDir, buf) {
710 const p = resolveSyncBufPath(stateDir);
711 const tmp = `${p}.tmp`;
712 await fs.writeFile(tmp, buf, { mode: 0o600 });
713 await fs.rename(tmp, p);
714 }
715
716 async function monitorLoop() {
717 const { baseUrl, token } = botAccount;
718 let getUpdatesBuf = await loadSyncBuf(config.stateDir);
719 let nextTimeoutMs = config.longPollTimeoutMs;
720 let consecutiveFailures = 0;
721
722 console.log(`Monitor started: baseUrl=${baseUrl} timeoutMs=${nextTimeoutMs}`);
723
724 while (!stopping) {
725 try {
726 const abortController = new AbortController();
727 const timer = setTimeout(
728 () => abortController.abort(),
729 nextTimeoutMs + 5000
730 );
731
732 const resp = await getUpdates({
733 baseUrl,
734 token,
735 get_updates_buf: getUpdatesBuf,
736 timeoutMs: nextTimeoutMs,
737 signal: abortController.signal,
738 });
739
740 clearTimeout(timer);
741
742 if (resp.longpolling_timeout_ms) {
743 nextTimeoutMs = resp.longpolling_timeout_ms;
744 }
745
746 // 检查错误
747 const isApiError =
748 (resp.ret !== undefined && resp.ret !== 0) ||
749 (resp.errcode !== undefined && resp.errcode !== 0);
750
751 if (isApiError) {
752 consecutiveFailures += 1;
753 console.error(
754 `getUpdates error: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg}`
755 );
756 if (consecutiveFailures >= 3) {
757 console.error("3 consecutive failures, backing off 30s");
758 await sleep(30000);
759 consecutiveFailures = 0;
760 } else {
761 await sleep(2000);
762 }
763 continue;
764 }
765
766 consecutiveFailures = 0;
767
768 // 保存游标
769 if (resp.get_updates_buf) {
770 getUpdatesBuf = resp.get_updates_buf;
771 await saveSyncBuf(config.stateDir, getUpdatesBuf);
772 }
773
774 // 处理消息
775 const msgs = resp.msgs || [];
776 for (const msg of msgs) {
777 const fromUser = msg.from_user_id || "";
778 const messageId = String(msg.message_id || "");
779
780 if (!fromUser) continue;
781
782 const msgKey = `${fromUser}:${messageId}`;
783 if (await threadStore.recordMessage(msgKey)) continue;
784
785 // 保存 context_token
786 if (msg.context_token) {
787 await threadStore.patchChat(fromUser, {
788 contextToken: msg.context_token,
789 updatedAt: new Date().toISOString(),
790 });
791 }
792
793 // 提取文本
794 const text = extractText(msg.item_list);
795
796 if (!text) {
797 await sendText(
798 fromUser,
799 "仅支持文本消息。图片/语音/视频/文件暂不支持。"
800 );
801 continue;
802 }
803
804 console.log(
805 `[inbound] from=${fromUser} text=${text.slice(0, 100)}`
806 );
807
808 // 白名单检查
809 if (!isAllowed(fromUser)) {
810 await sendText(
811 fromUser,
812 [
813 "This WeChat user is not in WEIXIN_CHAT_ALLOWLIST.",
814 `user_id=${fromUser}`,
815 "",
816 "For first pairing, add this user_id to WEIXIN_CHAT_ALLOWLIST, or temporarily set WEIXIN_ALLOW_UNLISTED=true.",
817 ].join("\n")
818 );
819 continue;
820 }
821
822 // 命令路由
823 const command = parseCommand(text);
824 await handleCommand(fromUser, command).catch((error) => {
825 console.error(
826 `failed to handle command from=${fromUser} text=${text.slice(0, 100)}`,
827 error
828 );
829 });
830 }
831 } catch (error) {
832 if (error.name === "AbortError" || error.message?.includes("abort")) {
833 // 长轮询超时是正常的,立即重试
834 continue;
835 }
836 if (stopping) break;
837
838 consecutiveFailures += 1;
839 console.error(
840 `getUpdates exception (${consecutiveFailures}/3):`,
841 error.message
842 );
843 if (consecutiveFailures >= 3) {
844 console.error("3 consecutive exceptions, backing off 30s");
845 await sleep(30000);
846 consecutiveFailures = 0;
847 } else {
848 await sleep(2000);
849 }
850 }
851 }
852 }
853
854 function isAllowed(fromUser) {
855 if (config.allowUnlisted) return true;
856 const allowed = new Set(config.allowlist);
857 return allowed.has(fromUser);
858 }
859
860 function sleep(ms) {
861 return new Promise((resolve) => setTimeout(resolve, ms));
862 }
863
864 // ============================================================================
865 // 启动流程 — QR 登录 → 长轮询
866 // ============================================================================
867
868 async function main() {
869 console.log("Starting CodeWhale Weixin Bot Bridge");
870 console.log(`Runtime: ${config.runtimeUrl}`);
871 console.log(`Workspace: ${config.workspace}`);
872 console.log(`State dir: ${config.stateDir}`);
873
874 // 初始化 ThreadStore
875 threadStore = await ThreadStore.open(config.threadMapPath);
876
877 // 尝试加载已有账号
878 botAccount = await loadAccount(config.stateDir);
879
880 if (botAccount?.token) {
881 console.log("Loaded existing bot account, trying to resume...");
882 console.log(` accountId: ${botAccount.accountId}`);
883 console.log(` baseUrl: ${botAccount.baseUrl}`);
884 } else {
885 // QR 登录
886 console.log("No bot account found. Starting QR login...");
887 console.log("");
888
889 const { qrcodeUrl, sessionKey } = await getLoginQR();
890 console.log("请用微信扫描以下二维码登录:");
891 console.log(qrcodeUrl);
892 console.log("");
893
894 const result = await waitForLogin({ sessionKey, timeoutMs: 300_000 });
895
896 if (!result.connected) {
897 console.error(`Login failed: ${result.message}`);
898 process.exit(1);
899 }
900
901 botAccount = {
902 accountId: result.accountId,
903 token: result.botToken,
904 baseUrl: result.baseUrl,
905 userId: result.userId,
906 };
907
908 await saveAccount(config.stateDir, botAccount);
909 console.log(`✅ Login successful! accountId=${botAccount.accountId}`);
910 }
911
912 // 通知上线
913 try {
914 const startResp = await notifyStart({
915 baseUrl: botAccount.baseUrl,
916 token: botAccount.token,
917 });
918 if (startResp.ret && startResp.ret !== 0) {
919 console.warn(`notifyStart: ret=${startResp.ret} errmsg=${startResp.errmsg}`);
920 } else {
921 console.log("notifyStart: OK");
922 }
923 } catch (error) {
924 console.error("notifyStart failed:", error.message);
925 }
926
927 // 信号处理
928 process.once("SIGINT", shutdown);
929 process.once("SIGTERM", shutdown);
930
931 if (!config.allowlist.length && !config.allowUnlisted) {
932 console.log(
933 "No allowlist configured. Incoming chats will receive their user IDs and be refused."
934 );
935 }
936
937 // 进入长轮询循环
938 await monitorLoop();
939
940 console.log("Bridge stopped.");
941 }
942
943 async function shutdown() {
944 if (stopping) return;
945 stopping = true;
946 console.log("Shutting down...");
947
948 if (botAccount?.token) {
949 try {
950 const stopResp = await notifyStop({
951 baseUrl: botAccount.baseUrl,
952 token: botAccount.token,
953 });
954 console.log(
955 `notifyStop: ret=${stopResp.ret} errmsg=${stopResp.errmsg ?? "OK"}`
956 );
957 } catch (error) {
958 console.error("notifyStop failed:", error.message);
959 }
960 }
961
962 setTimeout(() => process.exit(0), 2000);
963 }
964
965 main().catch((error) => {
966 console.error("Fatal error:", error);
967 process.exit(1);
968 });
969
969 lines Plain Text