cli-wechat-bridge 1.1.6 → 1.1.7

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.
Files changed (34) hide show
  1. package/README.md +163 -198
  2. package/bin/wecom-check-update.mjs +5 -0
  3. package/bin/wecom-claude.mjs +5 -0
  4. package/bin/wecom-codex.mjs +5 -0
  5. package/bin/wecom-daemon.mjs +5 -0
  6. package/bin/wecom-opencode.mjs +5 -0
  7. package/bin/wecom-pi.mjs +5 -0
  8. package/bin/wecom-setup.mjs +5 -0
  9. package/dist/bridge/bridge-adapters.claude.js +126 -3
  10. package/dist/bridge/bridge-process-reaper.js +35 -2
  11. package/dist/bridge/bridge-state.js +10 -3
  12. package/dist/bridge/wechat-bridge.js +277 -44
  13. package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
  14. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  15. package/dist/channels/wecom/setup-cli.js +6 -0
  16. package/dist/channels/wecom/setup.js +176 -0
  17. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  18. package/dist/channels/wecom/wecom-config.js +66 -0
  19. package/dist/channels/wecom/wecom-media.js +141 -0
  20. package/dist/channels/wecom/wecom-message.js +149 -0
  21. package/dist/channels/wecom/wecom-transport.js +308 -0
  22. package/dist/companion/codex-remote-client.js +2 -1
  23. package/dist/companion/local-companion-start.js +49 -9
  24. package/dist/companion/local-companion.js +2 -1
  25. package/dist/core/channel-types.js +3 -1
  26. package/dist/core/direct-run.js +17 -0
  27. package/dist/core/inbound-message-claims.js +61 -0
  28. package/dist/daemon/daemon-link.js +3 -0
  29. package/dist/daemon/wechat-daemon.js +265 -36
  30. package/dist/i18n/messages-en.js +2 -0
  31. package/dist/i18n/messages-zh.js +2 -0
  32. package/dist/utils/doctor.js +30 -10
  33. package/dist/wechat/setup.js +113 -13
  34. package/package.json +20 -10
@@ -13,10 +13,15 @@ import { createRuntimeHost } from "../runtime/create-runtime-host.js";
13
13
  import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
14
14
  import { routeBridgeMessage } from "../core/bridge-message-router.js";
15
15
  import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
16
+ import { isDirectModuleRun } from "../core/direct-run.js";
16
17
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
18
+ import { ensureWecomAccount } from "../channels/wecom/setup.js";
19
+ import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
20
+ import { WecomTransport } from "../channels/wecom/wecom-transport.js";
21
+ import { buildWecomInboundPrompt, formatWecomVisibleText, } from "../channels/wecom/wecom-message.js";
17
22
  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";
18
23
  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";
24
+ import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
20
25
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
21
26
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
22
27
  const POLL_RETRY_BASE_MS = 1_000;
@@ -105,6 +110,7 @@ export function parseCliArgs(argv) {
105
110
  let profile;
106
111
  let lifecycle = "persistent";
107
112
  let sessionStartMode = "restore";
113
+ let channelId = "wechat";
108
114
  for (let i = 0; i < argv.length; i += 1) {
109
115
  const arg = argv[i];
110
116
  const next = argv[i + 1];
@@ -151,6 +157,13 @@ export function parseCliArgs(argv) {
151
157
  sessionStartMode = next;
152
158
  i += 1;
153
159
  break;
160
+ case "--channel":
161
+ if (!next || (next !== "wechat" && next !== "wecom")) {
162
+ throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
163
+ }
164
+ channelId = next;
165
+ i += 1;
166
+ break;
154
167
  case "--shutdown-on-parent-exit":
155
168
  lifecycle = "companion_bound";
156
169
  break;
@@ -173,6 +186,51 @@ export function parseCliArgs(argv) {
173
186
  profile,
174
187
  lifecycle,
175
188
  sessionStartMode,
189
+ channelId,
190
+ };
191
+ }
192
+ function toWecomOutputKind(context) {
193
+ const known = new Set([
194
+ "status",
195
+ "notice",
196
+ "thinking",
197
+ "final_reply",
198
+ "approval_required",
199
+ "user_input_required",
200
+ "mirrored_input",
201
+ "task_failed",
202
+ "fatal_error",
203
+ ]);
204
+ return known.has(context)
205
+ ? context
206
+ : context === "mirrored_user_input"
207
+ ? "mirrored_input"
208
+ : "notice";
209
+ }
210
+ function toWechatSendContext(kind) {
211
+ if (kind === "mirrored_input") {
212
+ return "mirrored_user_input";
213
+ }
214
+ if (kind === "status") {
215
+ return "message";
216
+ }
217
+ return kind;
218
+ }
219
+ function toLegacyInboundWechatMessage(message) {
220
+ return {
221
+ senderId: message.senderId,
222
+ sender: message.senderId,
223
+ sessionId: message.conversation.conversationId,
224
+ text: message.text,
225
+ attachments: message.attachments.map((attachment) => ({
226
+ kind: attachment.kind === "image" ? "image" : "file",
227
+ path: attachment.path,
228
+ fileName: attachment.fileName || path.basename(attachment.path),
229
+ sizeBytes: attachment.sizeBytes ?? 0,
230
+ })),
231
+ contextToken: message.conversation.opaqueRef,
232
+ createdAt: message.createdAt,
233
+ createdAtMs: Date.parse(message.createdAt),
176
234
  };
177
235
  }
178
236
  function printUsageAndExit() {
@@ -180,7 +238,7 @@ function printUsageAndExit() {
180
238
  "Internal bridge runtime usage:",
181
239
  " npm run bridge -- --adapter <codex|claude|opencode|pi> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
182
240
  "",
183
- "This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
241
+ "This entry is internal. Users should run a wechat-* or wecom-* launcher.",
184
242
  "",
185
243
  ].join("\n"));
186
244
  process.exit(0);
@@ -194,21 +252,37 @@ async function main() {
194
252
  const options = parseCliArgs(process.argv.slice(2));
195
253
  const daemonEndpoint = readDaemonEndpoint();
196
254
  if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
197
- throw new Error(`wechat-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
255
+ throw new Error(`${daemonEndpoint.channelId ?? "wechat"}-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
198
256
  }
199
257
  if (daemonEndpoint) {
200
258
  clearDaemonEndpoint(daemonEndpoint.pid);
201
259
  log(`Cleared stale wechat-daemon endpoint for pid=${daemonEndpoint.pid}.`);
202
260
  }
203
- const credentials = await ensureWechatCredentials({
204
- requireUserId: true,
205
- validateExisting: true,
206
- log,
207
- });
261
+ const wecomAccount = options.channelId === "wecom"
262
+ ? await ensureWecomAccount({ log })
263
+ : null;
264
+ const credentials = wecomAccount
265
+ ? { userId: wecomAccount.operatorUserId }
266
+ : await ensureWechatCredentials({
267
+ requireUserId: true,
268
+ validateExisting: true,
269
+ log,
270
+ });
208
271
  if (!credentials.userId) {
209
- throw new Error("Saved WeChat credentials are missing userId.");
272
+ throw new Error(options.channelId === "wecom"
273
+ ? "Saved WeCom credentials are missing operatorUserId."
274
+ : "Saved WeChat credentials are missing userId.");
210
275
  }
211
276
  const transport = new WeChatTransport({ log, logError });
277
+ const wecomTransport = wecomAccount
278
+ ? new WecomTransport({
279
+ account: wecomAccount,
280
+ logger: {
281
+ log: (message) => log(message),
282
+ error: (message) => logError(message),
283
+ },
284
+ })
285
+ : null;
212
286
  // 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
213
287
  // unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
214
288
  // 否则会与强制退出 teardown 竞态。
@@ -227,6 +301,7 @@ async function main() {
227
301
  const stateStore = new BridgeStateStore({
228
302
  ...options,
229
303
  authorizedUserId: credentials.userId,
304
+ channelId: options.channelId,
230
305
  });
231
306
  const reapedPeerPids = await reapPeerBridgeProcesses({
232
307
  logger: (message) => stateStore.appendLog(message),
@@ -273,7 +348,9 @@ async function main() {
273
348
  initialResumeConversationId: stateStore.getState().resumeConversationId,
274
349
  initialTranscriptPath: stateStore.getState().transcriptPath,
275
350
  });
276
- const pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(options.cwd));
351
+ const pendingWechatMessages = new PendingWechatMessageStore(options.channelId === "wechat"
352
+ ? getPendingWechatMessagesFile(options.cwd)
353
+ : getPendingChannelMessagesFile(options.cwd, options.channelId));
277
354
  const resumeCoordinator = new ResumeSessionCoordinator({
278
355
  adapter: options.adapter,
279
356
  runtime: adapter,
@@ -285,6 +362,15 @@ async function main() {
285
362
  let attachmentSendChain = Promise.resolve();
286
363
  const pendingWechatForwardTasks = new Set();
287
364
  let activeTask = null;
365
+ let currentWecomConversation = null;
366
+ let activeWecomConversation = null;
367
+ let lastWecomConversation = {
368
+ channelId: "wecom",
369
+ accountId: wecomAccount?.botId,
370
+ conversationId: credentials.userId,
371
+ recipientId: credentials.userId,
372
+ metadata: { chatType: "direct" },
373
+ };
288
374
  const deferredInboundMessages = [];
289
375
  let drainingDeferredInboundMessages = false;
290
376
  let consecutivePollFailures = 0;
@@ -299,7 +385,34 @@ async function main() {
299
385
  attachmentSendChain = run.then(() => undefined, () => undefined);
300
386
  return run;
301
387
  };
302
- const sendWechatMessageNow = async (senderId, text, context = "message") => {
388
+ const sendWechatMessageNow = async (senderId, text, context = "message", targetOverride) => {
389
+ if (options.channelId === "wecom") {
390
+ const target = targetOverride ??
391
+ (senderId === credentials.userId
392
+ ? currentWecomConversation ??
393
+ activeWecomConversation ??
394
+ lastWecomConversation
395
+ : {
396
+ channelId: "wecom",
397
+ accountId: wecomAccount?.botId,
398
+ conversationId: senderId,
399
+ recipientId: senderId,
400
+ metadata: { chatType: "direct" },
401
+ });
402
+ const startedAtMs = Date.now();
403
+ stateStore.appendLog(`wecom_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
404
+ try {
405
+ await wecomTransport.sendText(target, formatWecomVisibleText(text), toWecomOutputKind(context));
406
+ stateStore.appendLog(`wecom_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
407
+ return { status: "sent" };
408
+ }
409
+ catch (error) {
410
+ const message = error instanceof Error ? error.message : String(error);
411
+ stateStore.appendLog(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
412
+ logError(`Failed to send WeCom ${context}: ${message}`);
413
+ return { status: "context_unavailable", error, target };
414
+ }
415
+ }
303
416
  const startedAtMs = Date.now();
304
417
  stateStore.appendLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
305
418
  for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
@@ -355,11 +468,11 @@ async function main() {
355
468
  }
356
469
  return { status: "failed" };
357
470
  };
358
- const queueWechatMessage = (senderId, text, context = "message") => {
471
+ const queueWechatMessage = (senderId, text, context = "message", targetOverride) => {
359
472
  return queueWechatTextAction(async () => {
360
- const result = await sendWechatMessageNow(senderId, text, context);
473
+ const result = await sendWechatMessageNow(senderId, text, context, targetOverride);
361
474
  if (result.status === "context_unavailable") {
362
- const pending = pendingWechatMessages.enqueue(senderId, text, context);
475
+ const pending = pendingWechatMessages.enqueue(senderId, text, context, result.target);
363
476
  if (pending) {
364
477
  stateStore.appendLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${pendingWechatMessages.list().length}`);
365
478
  }
@@ -370,7 +483,7 @@ async function main() {
370
483
  const flushPendingWechatMessages = () => {
371
484
  return queueWechatTextAction(async () => {
372
485
  for (const pending of pendingWechatMessages.list()) {
373
- const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
486
+ const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
374
487
  if (result.status === "sent") {
375
488
  pendingWechatMessages.remove(pending.id);
376
489
  stateStore.appendLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
@@ -427,6 +540,10 @@ async function main() {
427
540
  drainingDeferredInboundMessages = true;
428
541
  try {
429
542
  stateStore.appendLog(`draining_deferred_inbound_input: remaining=${deferredInboundMessages.length} text=${truncatePreview(nextDeferred.message.text)}`);
543
+ if (nextDeferred.channelMessage) {
544
+ activeWecomConversation = nextDeferred.channelMessage.conversation;
545
+ lastWecomConversation = nextDeferred.channelMessage.conversation;
546
+ }
430
547
  const nextTask = await dispatchInboundWechatText({
431
548
  message: nextDeferred.message,
432
549
  options,
@@ -515,6 +632,12 @@ async function main() {
515
632
  catch {
516
633
  // Best effort shutdown.
517
634
  }
635
+ try {
636
+ wecomTransport?.stop();
637
+ }
638
+ catch {
639
+ // Best effort shutdown.
640
+ }
518
641
  controller.clearLocalClientEndpoint();
519
642
  stateStore.releaseLock();
520
643
  };
@@ -594,6 +717,7 @@ async function main() {
594
717
  maybeDrainDeferredInboundMessages,
595
718
  clearActiveTask: () => {
596
719
  activeTask = null;
720
+ activeWecomConversation = null;
597
721
  },
598
722
  syncSharedSessionState: () => {
599
723
  syncSharedSessionState(stateStore, adapter);
@@ -602,6 +726,8 @@ async function main() {
602
726
  controller.syncLocalClientEndpoint();
603
727
  },
604
728
  requestShutdown,
729
+ wecomTransport,
730
+ getWecomTarget: () => activeWecomConversation ?? lastWecomConversation,
605
731
  });
606
732
  await adapter.start();
607
733
  if (!ensureRuntimeOwnership()) {
@@ -609,13 +735,98 @@ async function main() {
609
735
  }
610
736
  syncSharedSessionState(stateStore, adapter);
611
737
  controller.syncLocalClientEndpoint();
612
- stateStore.appendLog(`Bridge started with adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
613
- log(`WeChat bridge is ready for adapter "${options.adapter}".`);
738
+ stateStore.appendLog(`Bridge started with channel=${options.channelId} adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
739
+ if (wecomTransport) {
740
+ wecomTransport.setHandlers({
741
+ onMessage: async (channelMessage) => {
742
+ if (shutdownPromise || !ensureRuntimeOwnership()) {
743
+ return;
744
+ }
745
+ currentWecomConversation = channelMessage.conversation;
746
+ lastWecomConversation = channelMessage.conversation;
747
+ try {
748
+ if (pendingWechatMessages.list().length > 0) {
749
+ await flushPendingWechatMessages();
750
+ }
751
+ const message = toLegacyInboundWechatMessage(channelMessage);
752
+ stateStore.touchActivity(message.createdAt);
753
+ let nextTask = null;
754
+ try {
755
+ nextTask = await handleInboundMessage({
756
+ message,
757
+ channelMessage,
758
+ options,
759
+ stateStore,
760
+ adapter,
761
+ resumeCoordinator,
762
+ queueWechatMessage,
763
+ outputBatcher,
764
+ clearActiveTask: () => {
765
+ activeTask = null;
766
+ activeWecomConversation = null;
767
+ },
768
+ deferInboundMessage: async (nextMessage) => {
769
+ deferredInboundMessages.push({
770
+ message: nextMessage,
771
+ channelMessage,
772
+ });
773
+ stateStore.appendLog(`deferred_inbound_input: position=${deferredInboundMessages.length} text=${truncatePreview(nextMessage.text)}`);
774
+ await queueWechatMessage(nextMessage.senderId, formatDeferredCodexInboundQueueMessage(deferredInboundMessages.length));
775
+ },
776
+ });
777
+ }
778
+ catch (error) {
779
+ const errorText = error instanceof Error ? error.message : String(error);
780
+ logError(errorText);
781
+ stateStore.appendLog(`inbound_error: ${errorText}`);
782
+ await queueWechatMessage(message.senderId, formatUserFacingInboundError({
783
+ adapter: options.adapter,
784
+ cwd: options.cwd,
785
+ errorText,
786
+ }), "inbound_error");
787
+ }
788
+ if (nextTask) {
789
+ activeTask = nextTask;
790
+ activeWecomConversation = channelMessage.conversation;
791
+ }
792
+ syncSharedSessionState(stateStore, adapter);
793
+ await maybeDrainDeferredInboundMessages();
794
+ }
795
+ finally {
796
+ currentWecomConversation = null;
797
+ }
798
+ },
799
+ onUnauthorized: async (senderId, chatType) => {
800
+ stateStore.appendLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
801
+ if (chatType === "direct") {
802
+ await wecomTransport.sendText({
803
+ channelId: "wecom",
804
+ accountId: wecomAccount.botId,
805
+ conversationId: senderId,
806
+ recipientId: senderId,
807
+ metadata: { chatType: "direct" },
808
+ }, "Unauthorized.", "notice");
809
+ }
810
+ },
811
+ onFatal: async (error) => {
812
+ stateStore.appendLog(`wecom_fatal_error: ${error.message}`);
813
+ requestShutdown(error.message, 1);
814
+ },
815
+ onConnected: async () => {
816
+ if (pendingWechatMessages.list().length > 0) {
817
+ await flushPendingWechatMessages();
818
+ }
819
+ },
820
+ });
821
+ wecomTransport.start();
822
+ await wecomTransport.waitUntilConnected();
823
+ }
824
+ log(`${options.channelId === "wecom" ? "WeCom" : "WeChat"} bridge is ready for adapter "${options.adapter}".`);
614
825
  log(`Working directory: ${options.cwd}`);
615
826
  if (options.profile) {
616
827
  log(`Profile: ${options.profile}`);
617
828
  }
618
- log(`Authorized WeChat user: ${credentials.userId}`);
829
+ log(`Authorized ${options.channelId === "wecom" ? "WeCom" : "WeChat"} user: ${credentials.userId}`);
619
830
  if (options.adapter === "codex") {
620
831
  log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
621
832
  }
@@ -633,6 +844,12 @@ async function main() {
633
844
  cwd: options.cwd,
634
845
  });
635
846
  await queueWechatMessage(credentials.userId, welcomeText);
847
+ if (options.channelId === "wecom") {
848
+ while (!shutdownPromise && ensureRuntimeOwnership()) {
849
+ await delay(1_000);
850
+ }
851
+ return;
852
+ }
636
853
  while (true) {
637
854
  if (shutdownPromise) {
638
855
  // Shutdown (e.g. SIGINT) was requested while we were awaiting the
@@ -763,20 +980,28 @@ function syncSharedSessionState(stateStore, adapter) {
763
980
  }
764
981
  }
765
982
  function wireAdapterEvents(params) {
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
- });
983
+ const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, wecomTransport, getWecomTarget, } = params;
984
+ const channelPort = options.channelId === "wecom"
985
+ ? new WecomChannelPort({
986
+ transport: wecomTransport,
987
+ sendText: (target, text, kind) => queueWechatMessage(stateStore.getState().authorizedUserId, text, toWechatSendContext(kind), target),
988
+ onEmptyVisibleReply: (adapter, rawText) => {
989
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
990
+ },
991
+ })
992
+ : new WechatChannelPort({
993
+ sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
994
+ sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
995
+ sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
996
+ sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
997
+ sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
998
+ onEmptyVisibleReply: (adapter, rawText) => {
999
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
1000
+ },
1001
+ onTextSent: (_adapter, text) => {
1002
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
1003
+ },
1004
+ });
780
1005
  let lastFinalReplyAtMs = 0;
781
1006
  let eventForwardChain = Promise.resolve();
782
1007
  adapter.setEventSink((event) => {
@@ -808,11 +1033,13 @@ function wireAdapterEvents(params) {
808
1033
  stateStore.appendLog(`final_reply: ${truncatePreview(next.text)}`);
809
1034
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
810
1035
  await channelPort.send({
811
- target: {
812
- channelId: "wechat",
813
- conversationId: authorizedUserId,
814
- recipientId: authorizedUserId,
815
- },
1036
+ target: options.channelId === "wecom"
1037
+ ? getWecomTarget()
1038
+ : {
1039
+ channelId: "wechat",
1040
+ conversationId: authorizedUserId,
1041
+ recipientId: authorizedUserId,
1042
+ },
816
1043
  kind: "final_reply",
817
1044
  text: next.text,
818
1045
  adapter: options.adapter,
@@ -840,7 +1067,9 @@ function wireAdapterEvents(params) {
840
1067
  if (thinkingPreview) {
841
1068
  stateStore.appendLog(`thinking: ${thinkingPreview}`);
842
1069
  trackWechatForwardTask((async () => {
843
- await queueWechatMessage(authorizedUserId, `思考: ${thinkingPreview}`, "thinking");
1070
+ await queueWechatMessage(authorizedUserId, options.channelId === "wecom"
1071
+ ? `Processing: ${thinkingPreview}`
1072
+ : `思考: ${thinkingPreview}`, "thinking");
844
1073
  })());
845
1074
  }
846
1075
  }
@@ -950,11 +1179,13 @@ function formatInboundMessagePreview(message) {
950
1179
  return "(empty)";
951
1180
  }
952
1181
  async function handleInboundMessage(params) {
953
- const { message, } = params;
1182
+ const { message, channelMessage, } = params;
954
1183
  const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
955
1184
  const state = stateStore.getState();
956
1185
  if (message.senderId !== state.authorizedUserId) {
957
- await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
1186
+ await queueWechatMessage(message.senderId, options.channelId === "wecom"
1187
+ ? "Unauthorized. This bridge only accepts messages from the paired WeCom operator."
1188
+ : "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
958
1189
  return null;
959
1190
  }
960
1191
  const systemCommand = parseWechatControlCommand(message.text, {
@@ -968,7 +1199,7 @@ async function handleInboundMessage(params) {
968
1199
  return null;
969
1200
  case "resume": {
970
1201
  if (!isWechatResumeEnabled(options.adapter)) {
971
- await queueWechatMessage(message.senderId, `WeChat /resume is disabled in ${options.adapter} mode. Use /resume directly inside "wechat-${options.adapter}"; WeChat will follow the active local session.`);
1202
+ await queueWechatMessage(message.senderId, `${options.channelId === "wecom" ? "WeCom" : "WeChat"} /resume is disabled in ${options.adapter} mode. Use /resume directly inside "${options.channelId}-${options.adapter}"; the remote channel will follow the active local session.`);
972
1203
  return null;
973
1204
  }
974
1205
  try {
@@ -1081,7 +1312,7 @@ async function handleInboundMessage(params) {
1081
1312
  }
1082
1313
  const adapterState = adapter.getState();
1083
1314
  const routeResult = await routeBridgeMessage({
1084
- message: toChannelInboundMessage(message),
1315
+ message: channelMessage ?? toChannelInboundMessage(message),
1085
1316
  authorized: true,
1086
1317
  command: null,
1087
1318
  adapterState,
@@ -1136,10 +1367,12 @@ async function dispatchInboundWechatText(params) {
1136
1367
  inputPreview: truncatePreview(preview, 180),
1137
1368
  };
1138
1369
  stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(preview)}`);
1139
- await adapter.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
1370
+ await adapter.sendInput(options.channelId === "wecom"
1371
+ ? buildWecomInboundPrompt(message.text, message.attachments)
1372
+ : buildWechatInboundPrompt(message.text, message.attachments));
1140
1373
  return activeTask;
1141
1374
  }
1142
- const isDirectRun = Boolean(import.meta.main);
1375
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
1143
1376
  if (isDirectRun) {
1144
1377
  main().catch((err) => {
1145
1378
  logError(describeWechatTransportError(err));
@@ -4,7 +4,10 @@ import path from "node:path";
4
4
  import { getWorkspaceChannelPaths } from "../../wechat/channel-config.js";
5
5
  import { writeJsonFileAtomic } from "../../utils/atomic-file.js";
6
6
  export function getPendingWechatMessagesFile(cwd) {
7
- return path.join(getWorkspaceChannelPaths(cwd).workspaceDir, "pending-wechat-messages.json");
7
+ return getPendingChannelMessagesFile(cwd, "wechat");
8
+ }
9
+ export function getPendingChannelMessagesFile(cwd, channelId) {
10
+ return path.join(getWorkspaceChannelPaths(cwd).workspaceDir, `pending-${channelId}-messages.json`);
8
11
  }
9
12
  function readPendingMessages(filePath) {
10
13
  try {
@@ -38,9 +41,12 @@ export class PendingWechatMessageStore {
38
41
  this.messages = readPendingMessages(filePath);
39
42
  }
40
43
  list() {
41
- return this.messages.map((message) => ({ ...message }));
44
+ return this.messages.map((message) => ({
45
+ ...message,
46
+ target: message.target ? { ...message.target } : undefined,
47
+ }));
42
48
  }
43
- enqueue(recipientId, text, context) {
49
+ enqueue(recipientId, text, context, target) {
44
50
  const normalizedRecipientId = recipientId.trim();
45
51
  const normalizedText = text.trim();
46
52
  if (!normalizedRecipientId || !normalizedText) {
@@ -52,6 +58,7 @@ export class PendingWechatMessageStore {
52
58
  text: normalizedText,
53
59
  context,
54
60
  queuedAt: new Date().toISOString(),
61
+ target: target ? { ...target } : undefined,
55
62
  };
56
63
  this.messages.push(message);
57
64
  this.persist();
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+ import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "../../bridge/bridge-utils.js";
3
+ export async function forwardWecomFinalReply(params) {
4
+ const parsed = parseWechatFinalReply(params.rawText);
5
+ const sanitized = sanitizeWechatFinalReplyText(params.adapter, parsed.visibleText);
6
+ const visibleText = formatFinalReplyMessage(params.adapter, sanitized).trim();
7
+ if (visibleText) {
8
+ await params.sender.sendText(visibleText);
9
+ }
10
+ else if (params.adapter === "opencode" && parsed.visibleText.trim()) {
11
+ params.onEmptyVisibleReply?.(parsed.visibleText);
12
+ await params.sender.sendText("OpenCode did not produce a visible reply. Check the local terminal output or retry the message.");
13
+ }
14
+ for (const attachment of parsed.attachments) {
15
+ try {
16
+ await params.sender.sendAttachment({
17
+ kind: attachment.kind,
18
+ path: attachment.path,
19
+ });
20
+ }
21
+ catch (error) {
22
+ const errorText = error instanceof Error ? error.message : String(error);
23
+ await params.sender.sendText(`Failed to send ${attachment.kind} attachment ${path.basename(attachment.path)}: ${errorText}`);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { runWecomSetup } from "./setup.js";
3
+ runWecomSetup().catch((error) => {
4
+ console.error(error instanceof Error ? error.message : String(error));
5
+ process.exit(1);
6
+ });