cli-wechat-bridge 1.1.7 → 1.2.0

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 (82) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +461 -434
  3. package/bin/_run-entry.mjs +29 -6
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +6 -1
  12. package/bin/wecom-claude.mjs +6 -1
  13. package/bin/wecom-codex.mjs +6 -1
  14. package/bin/wecom-daemon.mjs +6 -1
  15. package/bin/wecom-opencode.mjs +6 -1
  16. package/bin/wecom-pi.mjs +6 -1
  17. package/bin/wecom-setup.mjs +6 -1
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +35 -1
  20. package/dist/bridge/bridge-adapters.codex.js +118 -7
  21. package/dist/bridge/bridge-adapters.core.js +25 -1
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +45 -0
  24. package/dist/bridge/bridge-adapters.shared.js +6 -0
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +6 -0
  27. package/dist/bridge/bridge-process-reaper.js +6 -0
  28. package/dist/bridge/bridge-state.js +6 -0
  29. package/dist/bridge/bridge-utils.js +6 -31
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +133 -0
  32. package/dist/bridge/native-terminal-control.js +259 -0
  33. package/dist/bridge/wechat-bridge.js +250 -274
  34. package/dist/bridge/wechat-forwarding.js +6 -0
  35. package/dist/bridge/wechat-outbound-queue.js +6 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +13 -22
  37. package/dist/channels/wechat/index.js +6 -0
  38. package/dist/channels/wechat/wechat-driver.js +191 -0
  39. package/dist/channels/wechat/wechat-forwarding.js +18 -0
  40. package/dist/channels/wechat/wechat-outbound-queue.js +39 -0
  41. package/dist/channels/wecom/bridge-final-reply.js +6 -0
  42. package/dist/channels/wecom/setup-cli.js +6 -0
  43. package/dist/channels/wecom/setup.js +6 -0
  44. package/dist/channels/wecom/wecom-config.js +6 -0
  45. package/dist/channels/wecom/wecom-driver.js +106 -0
  46. package/dist/channels/wecom/wecom-media.js +6 -0
  47. package/dist/channels/wecom/wecom-transport.js +6 -0
  48. package/dist/commands/check-update.js +6 -0
  49. package/dist/companion/codex-remote-client.js +28 -2
  50. package/dist/companion/codex-visible-client-link.js +107 -0
  51. package/dist/companion/local-companion-link.js +6 -0
  52. package/dist/companion/local-companion-start.js +13 -2
  53. package/dist/companion/local-companion.js +23 -0
  54. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  55. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  56. package/dist/core/bridge-control.js +10 -0
  57. package/dist/core/bridge-defer.js +25 -0
  58. package/dist/core/channel-driver.js +1 -0
  59. package/dist/core/conversation-routing.js +58 -0
  60. package/dist/core/direct-run.js +6 -0
  61. package/dist/core/inbound-message-claims.js +6 -0
  62. package/dist/core/outbound-target.js +32 -0
  63. package/dist/core/text-utils.js +6 -0
  64. package/dist/core/turn-coordinator.js +85 -0
  65. package/dist/daemon/daemon-link.js +6 -0
  66. package/dist/daemon/emoji-bindings.js +6 -0
  67. package/dist/daemon/wechat-daemon.js +529 -205
  68. package/dist/i18n/index.js +6 -0
  69. package/dist/i18n/messages-en.js +7 -1
  70. package/dist/i18n/messages-zh.js +7 -1
  71. package/dist/runtime/create-runtime-host.js +6 -0
  72. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  73. package/dist/runtime/runtime-types.js +3 -0
  74. package/dist/utils/atomic-file.js +6 -0
  75. package/dist/utils/doctor.js +6 -0
  76. package/dist/utils/version-checker.js +17 -11
  77. package/dist/wechat/channel-config.js +6 -0
  78. package/dist/wechat/setup.js +6 -0
  79. package/dist/wechat/wechat-channel.js +6 -0
  80. package/dist/wechat/wechat-transport.js +169 -58
  81. package/package.json +108 -106
  82. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import { messages as zhMessages } from "./messages-zh.js";
2
8
  import { messages as enMessages } from "./messages-en.js";
3
9
  const catalogs = {
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  export const messages = {
2
8
  // === Bridge welcome & control ===
3
9
  "bridge.welcome": "WeChat Bridge ready ({adapter}).\nCWD: {cwd}",
@@ -11,7 +17,7 @@ export const messages = {
11
17
  "bridge.fatalError": "Bridge error: {message}",
12
18
  "bridge.backlogIgnored": "Ignored {count} backlog message(s) sent before the bridge started (they will not be processed).\nIf you actually sent them after the bridge started, your system clock is likely skewed by more than {graceSeconds}s — sync the clock and restart the bridge.",
13
19
  // === Daemon welcome & control ===
14
- "daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
20
+ "daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status, /model, /plan, /all <prompt>\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
15
21
  "daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, /opencode, or /pi to start one.",
16
22
  "daemon.switchResult.new": "Started a new visible CLI.",
17
23
  "daemon.switchResult.reused": "Reused the existing visible CLI.",
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  export const messages = {
2
8
  // === 桥接欢迎 & 控制 ===
3
9
  "bridge.welcome": "微信桥接就绪 ({adapter})。\n工作目录: {cwd}",
@@ -11,7 +17,7 @@ export const messages = {
11
17
  "bridge.fatalError": "桥接错误: {message}",
12
18
  "bridge.backlogIgnored": "已忽略 {count} 条桥接启动前的积压消息(不会自动处理)。\n如果你是在桥接启动后才发送的消息却看到此提示,说明本机系统时钟可能不准(偏差超过 {graceSeconds} 秒),请校准时间后重启桥接。",
13
19
  // === Daemon 欢迎 & 控制 ===
14
- "daemon.welcome": "微信 Daemon 就绪。\n工作目录: {cwd}\n当前: {adapter}\n\n命令: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status\n{bindings}\n\n管理: /bind [表情] 命令, /unbind [表情], /bindings",
20
+ "daemon.welcome": "微信 Daemon 就绪。\n工作目录: {cwd}\n当前: {adapter}\n\n命令: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status, /model, /plan, /all <prompt>\n{bindings}\n\n管理: /bind [表情] 命令, /unbind [表情], /bindings",
15
21
  "daemon.noActiveAdapter": "未选择活跃终端。发送 /codex、/claude、/opencode 或 /pi 启动一个。",
16
22
  "daemon.switchResult.new": "已启动新的可见 CLI。",
17
23
  "daemon.switchResult.reused": "已复用现有的可见 CLI。",
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import { createBridgeAdapter } from "../bridge/bridge-adapters.js";
2
8
  import { CodexPtyAdapter } from "../bridge/bridge-adapters.codex.js";
3
9
  import { LegacyAdapterRuntime } from "./legacy-adapter-runtime.js";
@@ -25,6 +25,21 @@ export class LegacyAdapterRuntime {
25
25
  }
26
26
  await this.adapter.createSession();
27
27
  }
28
+ async listModels() {
29
+ if (!this.adapter.listModels)
30
+ throw new Error("Model listing is not supported by this companion.");
31
+ return this.adapter.listModels();
32
+ }
33
+ async selectModel(modelId) {
34
+ if (!this.adapter.selectModel)
35
+ throw new Error("Model selection is not supported by this companion.");
36
+ return this.adapter.selectModel(modelId);
37
+ }
38
+ async setPlanMode(enabled) {
39
+ if (!this.adapter.setPlanMode)
40
+ throw new Error("Plan mode is not supported by this companion.");
41
+ return this.adapter.setPlanMode(enabled);
42
+ }
28
43
  async interrupt() {
29
44
  return await this.adapter.interrupt();
30
45
  }
@@ -3,3 +3,6 @@ export const CODEX_REMOTE_AUTH_TOKEN_ENV = "WECHAT_BRIDGE_CODEX_REMOTE_AUTH_TOKE
3
3
  export function hasLocalClientEndpointProvider(runtime) {
4
4
  return typeof runtime.getLocalClientEndpoint === "function";
5
5
  }
6
+ export function hasVisibleClientSessionPreparer(runtime) {
7
+ return typeof runtime.prepareVisibleClientSession === "function";
8
+ }
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import fs from "node:fs";
2
8
  import { ensureChannelDataDir } from "../wechat/channel-config.js";
3
9
  /**
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import { execFileSync } from "node:child_process";
2
8
  import fs from "node:fs";
3
9
  import net from "node:net";
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import fs from "node:fs";
2
8
  import path from "node:path";
3
9
  import { CHANNEL_DATA_DIR, ensureChannelDataDir } from "../wechat/channel-config.js";
@@ -179,16 +185,16 @@ export async function checkForUpdate(forceCheck = false) {
179
185
  */
180
186
  export function formatUpdateMessage(versionInfo) {
181
187
  const { current, latest } = versionInfo;
182
- return `
183
- [Update Available] Version ${latest} is available (current: ${current})
184
-
185
- Update instructions:
186
- cd CLI-WeChat-Bridge
187
- git pull
188
- bun install
189
- npm install -g .
190
-
191
- For more information:
192
- https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
188
+ return `
189
+ [Update Available] Version ${latest} is available (current: ${current})
190
+
191
+ Update instructions:
192
+ cd CLI-WeChat-Bridge
193
+ git pull
194
+ bun install
195
+ npm install -g .
196
+
197
+ For more information:
198
+ https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases
193
199
  `;
194
200
  }
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import crypto from "node:crypto";
2
8
  import fs from "node:fs";
3
9
  import os from "node:os";
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
2
8
  /**
3
9
  * CLI WeChat Bridge setup.
4
10
  *
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
2
8
  /**
3
9
  * WeChat MCP server.
4
10
  *
@@ -1,3 +1,9 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
1
7
  import crypto from "node:crypto";
2
8
  import { createCipheriv, createDecipheriv } from "node:crypto";
3
9
  import fs from "node:fs";
@@ -332,6 +338,15 @@ async function apiFetch(params) {
332
338
  const url = new URL(params.endpoint, base).toString();
333
339
  const controller = new AbortController();
334
340
  const timer = setTimeout(() => controller.abort(), params.timeoutMs);
341
+ const abortFromCaller = () => controller.abort(params.signal?.reason);
342
+ if (params.signal) {
343
+ if (params.signal.aborted) {
344
+ abortFromCaller();
345
+ }
346
+ else {
347
+ params.signal.addEventListener("abort", abortFromCaller, { once: true });
348
+ }
349
+ }
335
350
  try {
336
351
  const res = await fetch(url, {
337
352
  method: "POST",
@@ -353,6 +368,9 @@ async function apiFetch(params) {
353
368
  clearTimeout(timer);
354
369
  throw err;
355
370
  }
371
+ finally {
372
+ params.signal?.removeEventListener("abort", abortFromCaller);
373
+ }
356
374
  }
357
375
  function encryptAesEcb(plaintext, key) {
358
376
  const cipher = createCipheriv("aes-128-ecb", key, null);
@@ -806,12 +824,15 @@ function buildInboundAttachmentFilePath(params) {
806
824
  const uniquePrefix = `${timestamp.replace(/[:.]/g, "-")}-${crypto.randomBytes(4).toString("hex")}`;
807
825
  return path.join(directory, `${uniquePrefix}-${safeFileName}`);
808
826
  }
827
+ const WECHAT_TYPING_TIMEOUT_MS = 8_000;
809
828
  export class WeChatTransport {
810
829
  logger;
811
830
  recentMessageKeys = new Set();
812
831
  recentMessageOrder = [];
813
832
  contextTokenCache;
814
833
  syncBuffer = "";
834
+ stopped = false;
835
+ activePollControllers = new Set();
815
836
  constructor(logger) {
816
837
  this.logger = logger;
817
838
  migrateLegacyChannelFiles((message) => this.logger.log(message));
@@ -855,73 +876,96 @@ export class WeChatTransport {
855
876
  ? "Reset sync state and cleared cached context tokens."
856
877
  : "Reset sync state.";
857
878
  }
879
+ stop() {
880
+ if (this.stopped) {
881
+ return;
882
+ }
883
+ this.stopped = true;
884
+ for (const controller of this.activePollControllers) {
885
+ controller.abort();
886
+ }
887
+ this.activePollControllers.clear();
888
+ }
858
889
  async pollMessages(options = {}) {
890
+ if (this.stopped) {
891
+ return { messages: [], ignoredBacklogCount: 0 };
892
+ }
859
893
  const timeoutMs = options.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
860
894
  const account = this.requireAccount();
861
- let response = await this.getUpdates(account, timeoutMs);
862
- if (isWechatSyncSessionTimeout(response) && this.syncBuffer) {
863
- this.logger.log("WeChat sync session timed out. Clearing local sync cursor and retrying once.");
864
- this.clearSyncBuffer();
865
- response = await this.getUpdates(account, timeoutMs);
866
- }
867
- if (isWechatSyncSessionTimeout(response)) {
868
- throw new Error('WeChat session timed out. Run "wechat-setup" to log in again.');
869
- }
870
- const isError = (response.ret !== undefined && response.ret !== 0) ||
871
- (response.errcode !== undefined && response.errcode !== 0);
872
- if (isError) {
873
- throw new Error(`getUpdates failed: ret=${response.ret} errcode=${response.errcode} errmsg=${response.errmsg ?? ""}`);
874
- }
875
- if (response.get_updates_buf) {
876
- this.syncBuffer = response.get_updates_buf;
877
- this.saveSyncBuffer(this.syncBuffer);
878
- }
879
- const messages = [];
880
- let ignoredBacklogCount = 0;
881
- for (const rawMessage of response.msgs ?? []) {
882
- if (rawMessage.message_type !== MSG_TYPE_USER) {
883
- continue;
895
+ const controller = new AbortController();
896
+ this.activePollControllers.add(controller);
897
+ try {
898
+ let response = await this.getUpdates(account, timeoutMs, controller.signal);
899
+ if (isWechatSyncSessionTimeout(response) && this.syncBuffer && !this.stopped) {
900
+ this.logger.log("WeChat sync session timed out. Clearing local sync cursor and retrying once.");
901
+ this.clearSyncBuffer();
902
+ response = await this.getUpdates(account, timeoutMs, controller.signal);
884
903
  }
885
- const extracted = extractInboundMessageContent(rawMessage);
886
- if (!extracted.text && extracted.attachments.length === 0) {
887
- continue;
904
+ if (this.stopped) {
905
+ return { messages: [], ignoredBacklogCount: 0 };
888
906
  }
889
- const senderId = rawMessage.from_user_id ?? "unknown";
890
- if (rawMessage.context_token) {
891
- this.cacheContextToken(senderId, rawMessage.context_token);
907
+ if (isWechatSyncSessionTimeout(response)) {
908
+ throw new Error('WeChat session timed out. Run "wechat-setup" to log in again.');
892
909
  }
893
- // Filter pre-start backlog BEFORE claiming/remembering the message so a
894
- // skipped message is not permanently locked away from other processes.
895
- // A missing create_time_ms is treated as fresh rather than dropped.
896
- const createdAtMs = rawMessage.create_time_ms ?? 0;
897
- if (typeof options.minCreatedAtMs === "number" &&
898
- Number.isFinite(createdAtMs) &&
899
- createdAtMs > 0 &&
900
- createdAtMs < options.minCreatedAtMs) {
901
- ignoredBacklogCount += 1;
902
- continue;
910
+ const isError = (response.ret !== undefined && response.ret !== 0) ||
911
+ (response.errcode !== undefined && response.errcode !== 0);
912
+ if (isError) {
913
+ throw new Error(`getUpdates failed: ret=${response.ret} errcode=${response.errcode} errmsg=${response.errmsg ?? ""}`);
903
914
  }
904
- const messageKey = buildMessageKey(rawMessage);
905
- if (!this.rememberMessage(messageKey)) {
906
- continue;
915
+ if (response.get_updates_buf) {
916
+ this.syncBuffer = response.get_updates_buf;
917
+ this.saveSyncBuffer(this.syncBuffer);
907
918
  }
908
- if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
909
- continue;
919
+ const messages = [];
920
+ let ignoredBacklogCount = 0;
921
+ for (const rawMessage of response.msgs ?? []) {
922
+ if (rawMessage.message_type !== MSG_TYPE_USER) {
923
+ continue;
924
+ }
925
+ const extracted = extractInboundMessageContent(rawMessage);
926
+ if (!extracted.text && extracted.attachments.length === 0) {
927
+ continue;
928
+ }
929
+ const senderId = rawMessage.from_user_id ?? "unknown";
930
+ if (rawMessage.context_token) {
931
+ this.cacheContextToken(senderId, rawMessage.context_token);
932
+ }
933
+ // Filter pre-start backlog BEFORE claiming/remembering the message so a
934
+ // skipped message is not permanently locked away from other processes.
935
+ // A missing create_time_ms is treated as fresh rather than dropped.
936
+ const createdAtMs = rawMessage.create_time_ms ?? 0;
937
+ if (typeof options.minCreatedAtMs === "number" &&
938
+ Number.isFinite(createdAtMs) &&
939
+ createdAtMs > 0 &&
940
+ createdAtMs < options.minCreatedAtMs) {
941
+ ignoredBacklogCount += 1;
942
+ continue;
943
+ }
944
+ const messageKey = buildMessageKey(rawMessage);
945
+ if (!this.rememberMessage(messageKey)) {
946
+ continue;
947
+ }
948
+ if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
949
+ continue;
950
+ }
951
+ const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
952
+ const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
953
+ messages.push({
954
+ senderId,
955
+ sender: normalizeSender(senderId),
956
+ sessionId: rawMessage.session_id ?? "",
957
+ text,
958
+ attachments,
959
+ contextToken: rawMessage.context_token,
960
+ createdAt: formatTimestamp(rawMessage.create_time_ms),
961
+ createdAtMs,
962
+ });
910
963
  }
911
- const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
912
- const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
913
- messages.push({
914
- senderId,
915
- sender: normalizeSender(senderId),
916
- sessionId: rawMessage.session_id ?? "",
917
- text,
918
- attachments,
919
- contextToken: rawMessage.context_token,
920
- createdAt: formatTimestamp(rawMessage.create_time_ms),
921
- createdAtMs,
922
- });
964
+ return { messages, ignoredBacklogCount };
965
+ }
966
+ finally {
967
+ this.activePollControllers.delete(controller);
923
968
  }
924
- return { messages, ignoredBacklogCount };
925
969
  }
926
970
  async downloadInboundAttachments(descriptors, rawMessage) {
927
971
  const attachments = [];
@@ -963,6 +1007,72 @@ export class WeChatTransport {
963
1007
  }
964
1008
  return { attachments, failureLines };
965
1009
  }
1010
+ /** Typing status values for {@link sendTyping}. 1 = typing, 2 = cancel. */
1011
+ static TYPING_STATUS = { TYPING: 1, CANCEL: 2 };
1012
+ /**
1013
+ * Fetch the per-user typing ticket from ilink/bot/getconfig.
1014
+ * Returns "" when the endpoint fails or reports no ticket.
1015
+ */
1016
+ async fetchTypingTicket(recipientId) {
1017
+ const account = this.requireAccount();
1018
+ const resolvedRecipientId = recipientId?.trim() ||
1019
+ [...this.contextTokenCache.keys()].at(-1);
1020
+ if (!resolvedRecipientId) {
1021
+ return "";
1022
+ }
1023
+ try {
1024
+ const raw = await apiFetch({
1025
+ baseUrl: account.baseUrl,
1026
+ endpoint: "ilink/bot/getconfig",
1027
+ body: JSON.stringify({
1028
+ ilink_user_id: resolvedRecipientId,
1029
+ ...(this.contextTokenCache.has(resolvedRecipientId)
1030
+ ? { context_token: this.contextTokenCache.get(resolvedRecipientId) }
1031
+ : {}),
1032
+ }),
1033
+ token: account.token,
1034
+ timeoutMs: WECHAT_TYPING_TIMEOUT_MS,
1035
+ });
1036
+ const resp = JSON.parse(raw);
1037
+ return resp.ret === 0 ? (resp.typing_ticket ?? "") : "";
1038
+ }
1039
+ catch {
1040
+ return "";
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Send or cancel a "typing…" indicator. Failures are logged only; the
1045
+ * indicator is best-effort and never blocks message flow.
1046
+ */
1047
+ async sendTyping(recipientId, typingTicket, status) {
1048
+ if (!typingTicket) {
1049
+ return false;
1050
+ }
1051
+ const account = this.requireAccount();
1052
+ try {
1053
+ const raw = await apiFetch({
1054
+ baseUrl: account.baseUrl,
1055
+ endpoint: "ilink/bot/sendtyping",
1056
+ body: JSON.stringify({
1057
+ ilink_user_id: recipientId,
1058
+ typing_ticket: typingTicket,
1059
+ status,
1060
+ }),
1061
+ token: account.token,
1062
+ timeoutMs: WECHAT_TYPING_TIMEOUT_MS,
1063
+ });
1064
+ const resp = JSON.parse(raw);
1065
+ if (resp.ret !== undefined && resp.ret !== 0) {
1066
+ this.logger.logError(`sendTyping ret=${resp.ret} errmsg=${resp.errmsg ?? "(none)"}`);
1067
+ return false;
1068
+ }
1069
+ return true;
1070
+ }
1071
+ catch (error) {
1072
+ this.logger.logError(`sendTyping failed: ${error instanceof Error ? error.message : String(error)}`);
1073
+ return false;
1074
+ }
1075
+ }
966
1076
  async sendText(senderId, text) {
967
1077
  const trimmed = text.trim();
968
1078
  if (!trimmed) {
@@ -1166,7 +1276,7 @@ export class WeChatTransport {
1166
1276
  }
1167
1277
  return stat;
1168
1278
  }
1169
- async getUpdates(account, timeoutMs) {
1279
+ async getUpdates(account, timeoutMs, signal) {
1170
1280
  try {
1171
1281
  const raw = await apiFetch({
1172
1282
  baseUrl: account.baseUrl,
@@ -1177,6 +1287,7 @@ export class WeChatTransport {
1177
1287
  }),
1178
1288
  token: account.token,
1179
1289
  timeoutMs,
1290
+ signal,
1180
1291
  });
1181
1292
  return JSON.parse(raw);
1182
1293
  }