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