@unlinearity/cli-wechat-bridge 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,18 +4,21 @@ import { resolveDefaultAdapterCommand, } from "./bridge-adapters.js";
4
4
  import { delay } from "./bridge-adapters.shared.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { BridgeController } from "./bridge-controller.js";
7
- import { forwardWechatFinalReply } from "./bridge-final-reply.js";
8
7
  import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "./bridge-session-resume.js";
9
- import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
8
+ import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isWechatContextUnavailableError, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
10
9
  import { ensureWechatCredentials } from "../wechat/setup.js";
11
10
  import { BridgeStateStore } from "./bridge-state.js";
12
11
  import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
13
12
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
13
+ import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
14
+ import { routeBridgeMessage } from "../core/bridge-message-router.js";
15
+ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
16
+ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
14
17
  import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
15
18
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
19
+ import { getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
16
20
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
17
21
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
18
- import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "../daemon/emoji-bindings.js";
19
22
  const POLL_RETRY_BASE_MS = 1_000;
20
23
  const POLL_RETRY_MAX_MS = 30_000;
21
24
  const PARENT_PROCESS_POLL_MS = 5_000;
@@ -270,6 +273,7 @@ async function main() {
270
273
  initialResumeConversationId: stateStore.getState().resumeConversationId,
271
274
  initialTranscriptPath: stateStore.getState().transcriptPath,
272
275
  });
276
+ const pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(options.cwd));
273
277
  const resumeCoordinator = new ResumeSessionCoordinator({
274
278
  adapter: options.adapter,
275
279
  runtime: adapter,
@@ -295,48 +299,89 @@ async function main() {
295
299
  attachmentSendChain = run.then(() => undefined, () => undefined);
296
300
  return run;
297
301
  };
298
- const queueWechatMessage = (senderId, text, context = "message") => {
299
- return queueWechatTextAction(async () => {
300
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
301
- try {
302
- await transport.sendText(senderId, text);
303
- return true;
302
+ const sendWechatMessageNow = async (senderId, text, context = "message") => {
303
+ const startedAtMs = Date.now();
304
+ stateStore.appendLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
305
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
306
+ try {
307
+ await transport.sendText(senderId, text);
308
+ stateStore.appendLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
309
+ return { status: "sent" };
310
+ }
311
+ catch (err) {
312
+ if (err instanceof Error && err.name === "AbortError") {
313
+ stateStore.appendLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
304
314
  }
305
- catch (err) {
315
+ if (isWechatContextUnavailableError(err)) {
306
316
  if (isWechatContextTokenStaleError(err)) {
307
317
  transport.clearCachedContextToken(senderId);
308
- const hint = "WeChat conversation context is stale. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
309
- logError(`Failed to send WeChat ${context}: ${hint}`);
310
- stateStore.appendLog(formatWechatContextTokenStaleLogEntry({
318
+ }
319
+ const hint = "WeChat conversation context is stale or unavailable. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
320
+ logError(`Failed to send WeChat ${context}: ${hint}`);
321
+ stateStore.appendLog(isWechatContextTokenStaleError(err)
322
+ ? formatWechatContextTokenStaleLogEntry({
311
323
  context,
312
324
  recipientId: senderId,
313
325
  error: err,
314
- }));
315
- return false;
316
- }
317
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
318
- const delayMs = computeWechatSendRetryDelayMs(attempt);
319
- logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
320
- stateStore.appendLog(formatWechatSendRetryLogEntry({
326
+ })
327
+ : formatWechatSendFailureLogEntry({
321
328
  context,
322
329
  recipientId: senderId,
323
- attempt,
324
- delayMs,
325
330
  error: err,
326
331
  }));
327
- await delay(delayMs);
328
- continue;
329
- }
330
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
331
- stateStore.appendLog(formatWechatSendFailureLogEntry({
332
+ return { status: "context_unavailable", error: err };
333
+ }
334
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
335
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
336
+ logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
337
+ stateStore.appendLog(formatWechatSendRetryLogEntry({
332
338
  context,
333
339
  recipientId: senderId,
340
+ attempt,
341
+ delayMs,
334
342
  error: err,
335
343
  }));
336
- return false;
344
+ await delay(delayMs);
345
+ continue;
337
346
  }
347
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
348
+ stateStore.appendLog(formatWechatSendFailureLogEntry({
349
+ context,
350
+ recipientId: senderId,
351
+ error: err,
352
+ }));
353
+ return { status: "failed" };
354
+ }
355
+ }
356
+ return { status: "failed" };
357
+ };
358
+ const queueWechatMessage = (senderId, text, context = "message") => {
359
+ return queueWechatTextAction(async () => {
360
+ const result = await sendWechatMessageNow(senderId, text, context);
361
+ if (result.status === "context_unavailable") {
362
+ const pending = pendingWechatMessages.enqueue(senderId, text, context);
363
+ if (pending) {
364
+ stateStore.appendLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${pendingWechatMessages.list().length}`);
365
+ }
366
+ }
367
+ return result.status === "sent";
368
+ });
369
+ };
370
+ const flushPendingWechatMessages = () => {
371
+ return queueWechatTextAction(async () => {
372
+ for (const pending of pendingWechatMessages.list()) {
373
+ const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
374
+ if (result.status === "sent") {
375
+ pendingWechatMessages.remove(pending.id);
376
+ stateStore.appendLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
377
+ continue;
378
+ }
379
+ if (result.status === "context_unavailable") {
380
+ break;
381
+ }
382
+ stateStore.appendLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
383
+ break;
338
384
  }
339
- return false;
340
385
  });
341
386
  };
342
387
  const trackWechatForwardTask = (task) => {
@@ -583,10 +628,6 @@ async function main() {
583
628
  else if (options.adapter === "pi") {
584
629
  log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
585
630
  }
586
- loadEmojiBindings();
587
- // Keep the standalone bridge's WeChat welcome brief: command and emoji
588
- // reference material belongs to /status and /bindings, which users can
589
- // request on demand. The daemon sends its own, fuller welcome.
590
631
  const welcomeText = t("bridge.welcome", {
591
632
  adapter: options.adapter,
592
633
  cwd: options.cwd,
@@ -634,6 +675,9 @@ async function main() {
634
675
  log(`WeChat long poll recovered after ${recoveredFailures} transient error(s).`);
635
676
  stateStore.appendLog(`poll_recovered: failures=${recoveredFailures}`);
636
677
  }
678
+ if (pollResult.messages.length > 0 && pendingWechatMessages.list().length > 0) {
679
+ await flushPendingWechatMessages();
680
+ }
637
681
  if (pollResult.ignoredBacklogCount > 0) {
638
682
  stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
639
683
  stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
@@ -720,6 +764,21 @@ function syncSharedSessionState(stateStore, adapter) {
720
764
  }
721
765
  function wireAdapterEvents(params) {
722
766
  const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
767
+ const channelPort = new WechatChannelPort({
768
+ sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
769
+ sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
770
+ sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
771
+ sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
772
+ sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
773
+ onEmptyVisibleReply: (adapter, rawText) => {
774
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
775
+ },
776
+ onTextSent: (_adapter, text) => {
777
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
778
+ },
779
+ });
780
+ let lastFinalReplyAtMs = 0;
781
+ let eventForwardChain = Promise.resolve();
723
782
  adapter.setEventSink((event) => {
724
783
  syncSharedSessionState();
725
784
  syncLocalClientEndpoint();
@@ -732,56 +791,52 @@ function wireAdapterEvents(params) {
732
791
  stateStore.clearPendingUserInput();
733
792
  }
734
793
  const authorizedUserId = stateStore.getState().authorizedUserId;
735
- switch (event.type) {
736
- case "stdout":
737
- case "stderr":
738
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
739
- outputBatcher.push(event.text);
794
+ eventForwardChain = eventForwardChain
795
+ .then(() => forwardBridgeEvent(event, {
796
+ stdout: (next) => {
797
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
798
+ outputBatcher.push(next.text);
740
799
  }
741
- break;
742
- case "final_reply":
743
- stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
800
+ },
801
+ stderr: (next) => {
802
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
803
+ outputBatcher.push(next.text);
804
+ }
805
+ },
806
+ finalReply: (next) => {
807
+ lastFinalReplyAtMs = Date.now();
808
+ stateStore.appendLog(`final_reply: ${truncatePreview(next.text)}`);
744
809
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
745
- await forwardWechatFinalReply({
746
- adapter: options.adapter,
747
- rawText: event.text,
748
- onEmptyVisibleReply: ({ rawVisibleText }) => {
749
- stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
750
- },
751
- sender: {
752
- sendText: async (text) => {
753
- const sent = await queueWechatMessage(authorizedUserId, text, "final_reply");
754
- if (sent) {
755
- stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
756
- }
757
- return sent;
758
- },
759
- sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
760
- sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
761
- sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
762
- sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
810
+ await channelPort.send({
811
+ target: {
812
+ channelId: "wechat",
813
+ conversationId: authorizedUserId,
814
+ recipientId: authorizedUserId,
763
815
  },
816
+ kind: "final_reply",
817
+ text: next.text,
818
+ adapter: options.adapter,
764
819
  });
765
820
  }));
766
- break;
767
- case "status":
768
- if (event.message) {
769
- log(`${event.status}: ${event.message}`);
770
- stateStore.appendLog(`${event.status}: ${event.message}`);
821
+ },
822
+ status: (next) => {
823
+ if (next.message) {
824
+ log(`${next.status}: ${next.message}`);
825
+ stateStore.appendLog(`${next.status}: ${next.message}`);
771
826
  }
772
827
  void maybeDrainDeferredInboundMessages();
773
- break;
774
- case "notice":
775
- stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
776
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
828
+ },
829
+ notice: (next) => {
830
+ stateStore.appendLog(`${next.level}_notice: ${truncatePreview(next.text)}`);
831
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
777
832
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
778
- await queueWechatMessage(authorizedUserId, event.text, "notice");
833
+ await queueWechatMessage(authorizedUserId, next.text, "notice");
779
834
  }));
780
835
  }
781
- break;
782
- case "thinking":
783
- if (event.text) {
784
- const thinkingPreview = formatThinkingForWechat(event.text, 500);
836
+ },
837
+ thinking: (next) => {
838
+ if (next.text) {
839
+ const thinkingPreview = formatThinkingForWechat(next.text, 500);
785
840
  if (thinkingPreview) {
786
841
  stateStore.appendLog(`thinking: ${thinkingPreview}`);
787
842
  trackWechatForwardTask((async () => {
@@ -789,99 +844,98 @@ function wireAdapterEvents(params) {
789
844
  })());
790
845
  }
791
846
  }
792
- break;
793
- case "approval_required":
847
+ },
848
+ approvalRequired: (next) => {
794
849
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
795
- const pending = toPendingApproval(event.request);
850
+ const pending = toPendingApproval(next.request);
796
851
  stateStore.setPendingConfirmation(pending);
797
852
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
798
853
  await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
799
854
  }));
800
- break;
801
- case "user_input_required":
855
+ },
856
+ userInputRequired: (next) => {
802
857
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
803
- const pending = toPendingUserInput(event.request);
858
+ const pending = toPendingUserInput(next.request);
804
859
  stateStore.setPendingUserInput(pending);
805
860
  stateStore.appendLog(`User input requested: questions=${pending.questions.length}`);
806
861
  await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required");
807
862
  }));
808
- break;
809
- case "mirrored_user_input":
810
- stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
811
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
863
+ },
864
+ mirroredUserInput: (next) => {
865
+ stateStore.appendLog(`mirrored_local_input: ${truncatePreview(next.text)}`);
866
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
812
867
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
813
- await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
868
+ await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, next.text), "mirrored_user_input");
814
869
  }));
815
870
  }
816
- break;
817
- case "session_switched":
818
- if (event.source === "local") {
871
+ },
872
+ sessionSwitched: (next) => {
873
+ if (next.source === "local")
819
874
  resumeCoordinator.clear();
820
- }
821
- stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
822
- if (shouldForwardSessionSwitchEvent(event.reason) &&
823
- shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
875
+ stateStore.appendLog(`session_switched: ${next.sessionId} source=${next.source} reason=${next.reason}`);
876
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
824
877
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
825
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
826
- adapter: options.adapter,
827
- sessionId: event.sessionId,
828
- source: event.source,
829
- reason: event.reason,
830
- }), "session_switched");
878
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason }), "session_switched");
831
879
  }));
832
880
  }
833
- break;
834
- case "thread_switched":
835
- if (event.source === "local") {
881
+ },
882
+ threadSwitched: (next) => {
883
+ if (next.source === "local")
836
884
  resumeCoordinator.clear();
885
+ stateStore.appendLog(`thread_switched: ${next.threadId} source=${next.source} reason=${next.reason}`);
886
+ if (shouldSuppressCodexLocalThreadNotice({
887
+ adapter: options.adapter,
888
+ source: next.source,
889
+ activeTurnOrigin: adapter.getState().activeTurnOrigin,
890
+ lastFinalReplyAtMs,
891
+ })) {
892
+ return;
837
893
  }
838
- stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
839
- if (shouldForwardSessionSwitchEvent(event.reason) &&
840
- shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
894
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
841
895
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
842
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
843
- adapter: options.adapter,
844
- sessionId: event.threadId,
845
- source: event.source,
846
- reason: event.reason,
847
- }), "thread_switched");
896
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.threadId, source: next.source, reason: next.reason }), "thread_switched");
848
897
  }));
849
898
  }
850
899
  void maybeDrainDeferredInboundMessages();
851
- break;
852
- case "task_complete":
900
+ },
901
+ taskComplete: () => {
853
902
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
854
903
  stateStore.clearPendingConfirmation();
855
904
  stateStore.clearPendingUserInput();
856
905
  clearActiveTask();
857
906
  await maybeDrainDeferredInboundMessages();
858
907
  }));
859
- break;
860
- case "task_failed":
908
+ },
909
+ taskFailed: (next) => {
861
910
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
862
911
  stateStore.clearPendingConfirmation();
863
912
  stateStore.clearPendingUserInput();
864
913
  clearActiveTask();
865
- await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
914
+ await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, next.message), "task_failed");
866
915
  await maybeDrainDeferredInboundMessages();
867
916
  }));
868
- break;
869
- case "fatal_error":
870
- logError(event.message);
871
- stateStore.appendLog(`fatal_error: ${event.message}`);
917
+ },
918
+ fatalError: (next) => {
919
+ logError(next.message);
920
+ stateStore.appendLog(`fatal_error: ${next.message}`);
872
921
  stateStore.clearPendingConfirmation();
873
922
  stateStore.clearPendingUserInput();
874
923
  clearActiveTask();
875
924
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
876
- await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
925
+ await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(next.message), "fatal_error");
877
926
  await maybeDrainDeferredInboundMessages();
878
927
  }));
879
- break;
880
- case "shutdown_requested":
881
- stateStore.appendLog(`shutdown_requested: ${event.reason}`);
882
- requestShutdown(event.message, event.exitCode ?? 0);
883
- break;
884
- }
928
+ },
929
+ shutdownRequested: (next) => {
930
+ stateStore.appendLog(`shutdown_requested: ${next.reason}`);
931
+ requestShutdown(next.message, next.exitCode ?? 0);
932
+ },
933
+ }))
934
+ .catch((error) => {
935
+ const message = error instanceof Error ? error.message : String(error);
936
+ logError(`Bridge event handling failed: ${message}`);
937
+ stateStore.appendLog(`event_forward_failed: ${message}`);
938
+ });
885
939
  });
886
940
  }
887
941
  function formatInboundMessagePreview(message) {
@@ -896,44 +950,13 @@ function formatInboundMessagePreview(message) {
896
950
  return "(empty)";
897
951
  }
898
952
  async function handleInboundMessage(params) {
899
- let { message, } = params;
953
+ const { message, } = params;
900
954
  const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
901
955
  const state = stateStore.getState();
902
956
  if (message.senderId !== state.authorizedUserId) {
903
957
  await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
904
958
  return null;
905
959
  }
906
- // Emoji resolution: rewrite message text if it starts with a bound emoji
907
- const emojiMatch = resolveEmojiCommand(message.text);
908
- if (emojiMatch) {
909
- const rewritten = emojiMatch.remainder
910
- ? `${emojiMatch.command} ${emojiMatch.remainder}`
911
- : emojiMatch.command;
912
- message = { ...message, text: rewritten };
913
- }
914
- // Emoji binding management commands
915
- const bindingsCmd = parseEmojiBindingsCommand(message.text);
916
- if (bindingsCmd) {
917
- switch (bindingsCmd.type) {
918
- case "list":
919
- await queueWechatMessage(message.senderId, formatBindingsListMessage(listBindings()));
920
- break;
921
- case "bind":
922
- setBinding(bindingsCmd.emoji, bindingsCmd.command);
923
- await queueWechatMessage(message.senderId, `Bound ${bindingsCmd.emoji} → ${bindingsCmd.command}`);
924
- break;
925
- case "unbind": {
926
- const removed = removeBinding(bindingsCmd.emoji);
927
- await queueWechatMessage(message.senderId, removed ? `Unbound ${bindingsCmd.emoji}` : `No binding found for ${bindingsCmd.emoji}`);
928
- break;
929
- }
930
- }
931
- return null;
932
- }
933
- if (isBindCommandPrefix(message.text)) {
934
- await queueWechatMessage(message.senderId, formatBindCommandUsage());
935
- return null;
936
- }
937
960
  const systemCommand = parseWechatControlCommand(message.text, {
938
961
  adapter: options.adapter,
939
962
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
@@ -1056,40 +1079,54 @@ async function handleInboundMessage(params) {
1056
1079
  };
1057
1080
  }
1058
1081
  }
1059
- if (state.pendingConfirmation) {
1060
- await queueWechatMessage(message.senderId, formatPendingApprovalReminder(state.pendingConfirmation, adapter.getState()));
1061
- return null;
1062
- }
1063
- if (state.pendingUserInput) {
1064
- await queueWechatMessage(message.senderId, formatPendingUserInputReminder(state.pendingUserInput));
1065
- return null;
1066
- }
1067
1082
  const adapterState = adapter.getState();
1068
- if (shouldDeferCodexInboundMessage({
1069
- adapter: options.adapter,
1070
- status: adapterState.status,
1071
- activeTurnOrigin: adapterState.activeTurnOrigin,
1072
- hasPendingConfirmation: Boolean(state.pendingConfirmation),
1073
- hasSystemCommand: Boolean(systemCommand),
1074
- })) {
1075
- await deferInboundMessage(message);
1076
- return null;
1077
- }
1078
- if (adapterState.status === "busy") {
1079
- if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1080
- adapterState.activeTurnOrigin === "local") {
1081
- await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1082
- return null;
1083
- }
1084
- await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1085
- return null;
1086
- }
1087
- return dispatchInboundWechatText({
1088
- message,
1089
- options,
1090
- stateStore,
1091
- adapter,
1083
+ const routeResult = await routeBridgeMessage({
1084
+ message: toChannelInboundMessage(message),
1085
+ authorized: true,
1086
+ command: null,
1087
+ adapterState,
1088
+ hasPendingApproval: Boolean(state.pendingConfirmation),
1089
+ hasPendingUserInput: Boolean(state.pendingUserInput),
1090
+ shouldDefer: shouldDeferCodexInboundMessage({
1091
+ adapter: options.adapter,
1092
+ status: adapterState.status,
1093
+ activeTurnOrigin: adapterState.activeTurnOrigin,
1094
+ hasPendingConfirmation: Boolean(state.pendingConfirmation),
1095
+ hasSystemCommand: Boolean(systemCommand),
1096
+ }),
1097
+ onUnauthorized: async () => undefined,
1098
+ handleCommand: async () => false,
1099
+ remindPendingApproval: async () => {
1100
+ await queueWechatMessage(message.senderId, formatPendingApprovalReminder(stateStore.getState().pendingConfirmation, adapter.getState()));
1101
+ },
1102
+ remindPendingUserInput: async () => {
1103
+ const pendingUserInput = stateStore.getState().pendingUserInput;
1104
+ await queueWechatMessage(message.senderId, pendingUserInput
1105
+ ? formatPendingUserInputReminder(pendingUserInput)
1106
+ : `${options.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
1107
+ },
1108
+ remindBusy: async () => {
1109
+ const currentState = adapter.getState();
1110
+ if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1111
+ currentState.activeTurnOrigin === "local") {
1112
+ await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1113
+ return;
1114
+ }
1115
+ await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1116
+ },
1117
+ defer: async () => {
1118
+ await deferInboundMessage(message);
1119
+ },
1120
+ dispatch: async () => {
1121
+ return await dispatchInboundWechatText({
1122
+ message,
1123
+ options,
1124
+ stateStore,
1125
+ adapter,
1126
+ });
1127
+ },
1092
1128
  });
1129
+ return routeResult.kind === "dispatched" ? routeResult.result : null;
1093
1130
  }
1094
1131
  async function dispatchInboundWechatText(params) {
1095
1132
  const { message, options, stateStore, adapter } = params;
@@ -1,58 +1,2 @@
1
- import { truncatePreview } from "./bridge-utils.js";
2
- import { classifyWechatTransportError, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
3
- export const WECHAT_SEND_MAX_ATTEMPTS = 3;
4
- const WECHAT_SEND_RETRY_BASE_MS = 750;
5
- export function formatUserFacingBridgeFatalError(message) {
6
- return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
7
- }
8
- export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
9
- if (adapter !== "opencode" && adapter !== "pi") {
10
- return true;
11
- }
12
- switch (eventType) {
13
- case "stdout":
14
- case "stderr":
15
- case "thread_switched":
16
- return false;
17
- case "notice":
18
- return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
19
- case "mirrored_user_input":
20
- return true;
21
- default:
22
- return true;
23
- }
24
- }
25
- export function formatUserFacingInboundError(params) {
26
- const { adapter, cwd, errorText } = params;
27
- if (adapter === "opencode" &&
28
- /opencode companion is not connected/i.test(errorText)) {
29
- return cwd
30
- ? `OpenCode is not connected for workspace:\n${cwd}\nRun "wechat-opencode" in that directory to recreate or reconnect the visible terminal.`
31
- : 'OpenCode is not connected. Run "wechat-opencode" in this directory, then retry.';
32
- }
33
- return `Bridge error: ${errorText}`;
34
- }
35
- export function formatWechatSendFailureLogEntry(params) {
36
- return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
37
- }
38
- export function formatWechatContextTokenStaleLogEntry(params) {
39
- return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
40
- }
41
- export function formatWechatSendRetryLogEntry(params) {
42
- return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
43
- }
44
- export function isRetryableWechatSendError(error) {
45
- if (isWechatContextTokenStaleError(error)) {
46
- return false;
47
- }
48
- const classification = classifyWechatTransportError(error);
49
- if (classification.retryable) {
50
- return true;
51
- }
52
- const details = describeWechatTransportError(error);
53
- return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
54
- !/errcode=-14\b.*session timeout/i.test(details);
55
- }
56
- export function computeWechatSendRetryDelayMs(attempt) {
57
- return WECHAT_SEND_RETRY_BASE_MS * attempt;
58
- }
1
+ /** @deprecated Import from ../channels/wechat/wechat-forwarding.ts instead. */
2
+ export * from "../channels/wechat/wechat-forwarding.js";
@@ -0,0 +1,2 @@
1
+ /** @deprecated Import from ../channels/wechat/wechat-outbound-queue.ts instead. */
2
+ export * from "../channels/wechat/wechat-outbound-queue.js";