@mrrisega/dsh-remote 0.6.14 → 0.6.15

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/clients/dsh-remote/dsh-bridge.mjs +64 -13
  2. package/clients/dsh-remote/dsh-events.mjs +22 -2
  3. package/clients/dsh-remote/e2ee-client.mjs +97 -13
  4. package/clients/dsh-remote/e2ee-shim-script.js +31 -3
  5. package/clients/dsh-remote/mobile-adapter.mjs +172 -0
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  7. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  8. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  9. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  11. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  12. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  13. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  14. package/clients/dsh-remote/upstream-discovery.mjs +186 -30
  15. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  16. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  17. package/dsh-setup.mjs +161 -7
  18. package/package.json +1 -1
  19. package/packages/dsh-remote-web/lib/client.js +31 -1
  20. package/packages/dsh-remote-web/lib/index.js +392 -49
  21. package/packages/dsh-remote-web/package.json +1 -1
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  30. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
  31. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  32. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  33. package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
  34. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
@@ -158,13 +158,140 @@ export function dshProcessPids(platform = process.platform) {
158
158
  .filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
159
159
  }
160
160
 
161
+ // ---------- 「本机所有回环监听端口」:NAS 上的救命稻草(0.6.15) ----------
162
+ //
163
+ // 【为什么必须加这一层】飞牛 fnOS 用户诊断(2026-09-25,插件 0.6.14):
164
+ // 上游 dsh web: http://127.0.0.1:3080(默认值 3080(**未取到 dsh web 实际监听端口**),⚠️ 不可达)
165
+ // 他的 dsh web 实际在 **2298**(fnOS 应用里配的内部监听端口),而上面五条发现路径**全都没命中**:
166
+ // · 插件半的 ctx.webServer.port 没取到(该环境下拿不到);
167
+ // · `lsof` 在 NAS 上**根本没装**;`pgrep -f dsh` 也可能没有;
168
+ // · 静态候选只有 3080 与 Desktop 的 43120 区间 —— 2298 永远不在里面。
169
+ // 于是用户去改端口号(改成 20xx、又改回 3080)当然都没用:**没有任何一条路径会去试 2298**。
170
+ //
171
+ // 做法:直接把"这台机器上正在 LISTEN 的回环端口"全列出来当候选,再逐个做**身份校验**。
172
+ // 为什么敢全列:身份校验(`looksLikeDshWeb` / `looksLikeDshRemoteSelf`)会拒掉所有陌生服务,
173
+ // 多探几个端口只是几十毫秒;而漏掉一个端口就是"用户永远连不上"。
174
+
175
+ /** 解析 /proc/net/tcp 一行里的本地地址与状态(Linux 无依赖:不需要 ss/netstat/lsof)。 */
176
+ function parseProcNetLine(line) {
177
+ const cols = String(line || "").trim().split(/\s+/);
178
+ if (cols.length < 4) return null;
179
+ const local = cols[1];
180
+ const state = cols[3];
181
+ const m = /^([0-9A-Fa-f]{1,32}):([0-9A-Fa-f]{1,4})$/.exec(local || "");
182
+ if (!m) return null;
183
+ return { hex: m[1], port: parseInt(m[2], 16), state: String(state).toUpperCase() };
184
+ }
185
+
186
+ /** 32 位小端 hex(/proc/net/tcp)→ IPv4 点分串。 */
187
+ function ipv4OfHex(hex) {
188
+ const h = String(hex).padStart(8, "0").slice(-8);
189
+ const bytes = [h.slice(6, 8), h.slice(4, 6), h.slice(2, 4), h.slice(0, 2)].map((x) => parseInt(x, 16));
190
+ return bytes.join(".");
191
+ }
192
+
193
+ /** 128 位 hex(/proc/net/tcp6)→ 是否回环(::1)。 */
194
+ function isIpv6LoopbackHex(hex) {
195
+ const h = String(hex).toLowerCase().padStart(32, "0");
196
+ // ::1 在 /proc 里按 4 组小端 32 位存放 → 末组为 01000000,其余全 0
197
+ return /^0{24}01000000$/.test(h);
198
+ }
199
+
200
+ /** Linux:直接读 /proc/net/tcp{,6}(内核真值,NAS 上不需要任何外部命令)。 */
201
+ export function procNetLoopbackPorts() {
202
+ const ports = new Set();
203
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
204
+ let text = "";
205
+ try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
206
+ for (const line of text.split("\n").slice(1)) {
207
+ const row = parseProcNetLine(line);
208
+ if (!row || row.state !== "0A") continue; // 0A = LISTEN
209
+ const loop = file.endsWith("tcp6")
210
+ ? isIpv6LoopbackHex(row.hex)
211
+ : ipv4OfHex(row.hex).startsWith("127.");
212
+ // 通配绑定(0.0.0.0 / ::)也收:dsh web 绑 0.0.0.0 时回环照样能连
213
+ const wildcard = file.endsWith("tcp6") ? /^0+$/.test(String(row.hex)) : ipv4OfHex(row.hex) === "0.0.0.0";
214
+ if (loop || wildcard) ports.add(row.port);
215
+ }
216
+ }
217
+ return [...ports];
218
+ }
219
+
220
+ /** `ss -ltnH` 输出里的本地端口(Linux;部分精简系统没有 /proc,或反之)。 */
221
+ function ssLoopbackPorts() {
222
+ const ports = new Set();
223
+ const out = tryExec("ss", ["-ltnH"], 5000) || tryExec("ss", ["-ltn"], 5000);
224
+ for (const line of out.split("\n")) {
225
+ const cols = line.trim().split(/\s+/);
226
+ if (cols.length < 4) continue;
227
+ // ss 的本地地址是第 4 列(State Recv-Q Send-Q Local Address:Port Peer Address:Port)
228
+ const m = /[:.](\d+)$/.exec(cols[3] || "");
229
+ if (m) ports.add(Number(m[1]));
230
+ }
231
+ return [...ports];
232
+ }
233
+
234
+ /** `netstat -ltn` 输出里的本地端口(BSD/GNU 两种格式都认)。 */
235
+ function netstatLoopbackPorts() {
236
+ const ports = new Set();
237
+ const out = (tryExec("netstat", ["-ltn"], 5000) || tryExec("netstat", ["-an"], 6000));
238
+ for (const line of out.split("\n")) {
239
+ if (!/listen/i.test(line) && !/^tcp/i.test(line)) continue;
240
+ const m = /(?:^|\s)(?:\d{1,3}\.){3}\d{1,3}[:.](\d+)|\[?::1?\]?[:.](\d+)/.exec(line);
241
+ const p = m ? Number(m[1] || m[2]) : 0;
242
+ if (p > 0 && p < 65536) ports.add(p);
243
+ }
244
+ return [...ports];
245
+ }
246
+
247
+ /** `lsof -nP -iTCP -sTCP:LISTEN` 输出里的本地端口(macOS/装了 lsof 的 Linux)。 */
248
+ function lsofLoopbackPorts() {
249
+ const ports = new Set();
250
+ const out = tryExec("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], 6000);
251
+ for (const line of out.split("\n")) {
252
+ const m = /(?:TCP\s+)?(?:\d{1,3}\.){3}\d{1,3}:(\d+)\s*\(LISTEN\)/.exec(line) || /TCP\s+\*:(\d+)/.exec(line);
253
+ if (m) ports.add(Number(m[1]));
254
+ }
255
+ return [...ports];
256
+ }
257
+
258
+ /**
259
+ * 本机所有回环(含通配绑定)LISTEN 端口,去重。
260
+ * 多来源合并而不是"取第一个成功的":NAS 上 lsof/ss/netstat/pgrep 各有缺失,
261
+ * 只有合起来才够全;重复端口由 Set 消化,代价只是多读几个文件。
262
+ */
263
+ export function allLoopbackListeningPorts(platform = process.platform) {
264
+ const ports = new Set();
265
+ const push = (list) => { for (const p of list) { const n = Number(p); if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n); } };
266
+ if (platform === "win32") {
267
+ for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
268
+ if (!/LISTENING/i.test(line)) continue;
269
+ const cols = line.trim().split(/\s+/);
270
+ const m = /:(\d+)$/.exec(cols[1] || "");
271
+ if (m) push([Number(m[1])]);
272
+ }
273
+ return [...ports];
274
+ }
275
+ if (platform === "linux") {
276
+ push(procNetLoopbackPorts()); // 无依赖,优先
277
+ push(ssLoopbackPorts());
278
+ }
279
+ push(lsofLoopbackPorts());
280
+ push(netstatLoopbackPorts());
281
+ return [...ports];
282
+ }
283
+
161
284
  /**
162
285
  * 候选列表(已去重,按"可能性"排序),每项带**来源标注**(用于日志与测试断言)。
163
286
  * 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
164
- * → 3080 → Desktop 默认端口区间。
287
+ * → **本机所有回环监听端口** → 3080 → Desktop 默认端口区间。
288
+ *
289
+ * 「所有回环监听端口」排在静态候选之前:NAS 上 dsh web 的端口(实测 2298)**只可能**从这一层找到,
290
+ * 排在 3080 之后就等于白加(3080 探测失败之前不会去试它,而静态候选有 12 个 Desktop 端口)。
291
+ * 最后仍留静态候选兜底(宿主把端口藏得很深、/proc 也没有的极端情况)。
165
292
  * @returns {Array<{port:number, source:string}>}
166
293
  */
167
- export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform } = {}) {
294
+ export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform, listenPorts = null } = {}) {
168
295
  const out = [];
169
296
  const seen = new Set();
170
297
  const push = (p, source) => {
@@ -179,6 +306,10 @@ export function candidatePorts({ relayDir = "", env = process.env, extraPorts =
179
306
  push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
180
307
  const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
181
308
  for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
309
+ // ★ 本机所有回环 LISTEN 端口(0.6.15):NAS 上 dsh web 在 2298 这类非默认端口时唯一的发现路径。
310
+ // 调用方可以注入 listenPorts(测试用),避免用例依赖开发机真实监听表。
311
+ const listen = Array.isArray(listenPorts) ? listenPorts : allLoopbackListeningPorts(platform);
312
+ for (const p of listen) push(p, "listen");
182
313
  push(3080, "static");
183
314
  for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
184
315
  return out;
@@ -208,8 +339,28 @@ export function looksLikeDshWeb(status, text) {
208
339
  return DSH_WEB_MARKERS.some((m) => body.includes(m));
209
340
  }
210
341
 
342
+ /** 本插件自己的路由(`GET /dsh-remote/self`)—— 只有"宿主这台 dsh web"才会应答。 */
343
+ export const DSH_REMOTE_SELF_PATH = "/dsh-remote/self";
344
+
345
+ /**
346
+ * 身份校验的**第二判据**:本插件自己的 `/dsh-remote/self`。
347
+ *
348
+ * 为什么需要它:`looksLikeDshWeb` 依赖 dsh web 首页的 HTML 特征(`__ModuleLoader__` 等),
349
+ * 而首页在有些部署下是**跳转/登录页/自定义门户**,特征一个字都不出现 —— 于是明明端口就是对的,
350
+ * 却被判成"陌生服务"。`/dsh-remote/self` 由**本插件**注册在同一个 web server 上,
351
+ * 返回 `{"ok":true,"version":…,"channel":…,"relayDir":…}`,是本机最硬的同一性证据
352
+ * (它跑在同一条隧道上,能应答就说明这个端口就是宿主的 dsh web)。
353
+ */
354
+ export function looksLikeDshRemoteSelf(status, text) {
355
+ if (Number(status) !== 200) return false;
356
+ const body = String(text || "");
357
+ if (!body.includes("relayDir")) return false;
358
+ return body.includes('"channel"') || body.includes('"runtimeReady"') || body.includes("dsh-remote");
359
+ }
360
+
211
361
  /**
212
- * 探测一个候选:GET `/`,带身份校验。
362
+ * 探测一个候选:先 GET `/`(dsh web 首页特征),不中再 GET `/dsh-remote/self`(本插件特征)。
363
+ * 两条判据任一命中即通过。
213
364
  * @returns {Promise<boolean>}
214
365
  */
215
366
  export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
@@ -217,34 +368,38 @@ export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl
217
368
  if (!target) return false;
218
369
  const doFetch = fetchImpl || globalThis.fetch;
219
370
  if (typeof doFetch !== "function") return false;
220
- try {
221
- const ac = new AbortController();
222
- const timer = setTimeout(() => ac.abort(), timeoutMs);
223
- let res;
224
- try {
225
- // redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
226
- res = await doFetch(`${target}/`, { signal: ac.signal, redirect: "manual" });
227
- } finally {
228
- clearTimeout(timer);
229
- }
230
- const status = Number(res && res.status) || 0;
231
- if (status >= 500) return false;
232
- // 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
233
- let text = "";
371
+ const once = async (path, check) => {
234
372
  try {
235
- if (res.body && typeof res.body.getReader === "function") {
236
- const reader = res.body.getReader();
237
- const { value } = await reader.read();
238
- text = value ? Buffer.from(value).toString("utf8") : "";
239
- try { await reader.cancel(); } catch { /* ignore */ }
240
- } else if (typeof res.text === "function") {
241
- text = await res.text();
373
+ const ac = new AbortController();
374
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
375
+ let res;
376
+ try {
377
+ // redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
378
+ res = await doFetch(`${target}${path}`, { signal: ac.signal, redirect: "manual" });
379
+ } finally {
380
+ clearTimeout(timer);
242
381
  }
243
- } catch { /* 读失败 → text 为空 → 判否 */ }
244
- return looksLikeDshWeb(status, text.slice(0, 8192));
245
- } catch {
246
- return false;
247
- }
382
+ const status = Number(res && res.status) || 0;
383
+ if (status >= 500) return false;
384
+ // 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
385
+ let text = "";
386
+ try {
387
+ if (res.body && typeof res.body.getReader === "function") {
388
+ const reader = res.body.getReader();
389
+ const { value } = await reader.read();
390
+ text = value ? Buffer.from(value).toString("utf8") : "";
391
+ try { await reader.cancel(); } catch { /* ignore */ }
392
+ } else if (typeof res.text === "function") {
393
+ text = await res.text();
394
+ }
395
+ } catch { /* 读失败 → text 为空 → 判否 */ }
396
+ return check(status, text.slice(0, 8192));
397
+ } catch {
398
+ return false;
399
+ }
400
+ };
401
+ if (await once("/", looksLikeDshWeb)) return true;
402
+ return once(DSH_REMOTE_SELF_PATH, looksLikeDshRemoteSelf);
248
403
  }
249
404
 
250
405
  /**
@@ -261,6 +416,7 @@ export async function discoverUpstream({
261
416
  fetchImpl,
262
417
  platform = process.platform,
263
418
  extraPorts = [],
419
+ listenPorts = null,
264
420
  log
265
421
  } = {}) {
266
422
  const hint = resolveUpstreamHint({ relayDir, env });
@@ -273,7 +429,7 @@ export async function discoverUpstream({
273
429
 
274
430
  // ③ 候选并行探测(本地端口,拒绝是瞬时的;并行是为了不让 watcher 卡住)
275
431
  const hintPort = portOf(hint.url);
276
- const entries = candidatePorts({ relayDir, env, platform, extraPorts }).filter((e) => e.port !== hintPort);
432
+ const entries = candidatePorts({ relayDir, env, platform, extraPorts, listenPorts }).filter((e) => e.port !== hintPort);
277
433
  const results = await Promise.all(entries.map(async (e) => ({ ...e, ok: await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl }) })));
278
434
  const hit = results.find((r) => r.ok);
279
435
  if (hit) {
@@ -429,9 +429,32 @@ export class IlinkClient {
429
429
  this.clientVersion = resolveClientVersion(opts.clientVersion);
430
430
  this.logger = opts.logger || createLogger();
431
431
  this.fetchImpl = opts.fetch || ((...a) => globalThis.fetch(...a));
432
+ /**
433
+ * 出站要回带的会话上下文令牌(来自**最近一条入站消息**的 `context_token`)。
434
+ * 与 `contextTokenPeer` 成对使用:只回带给**同一个对端**,避免把 A 的上下文发给 B。
435
+ * 由 WeChatChannel 在收到入站消息时写入并在启动时从状态文件恢复。
436
+ */
437
+ this.contextToken = String(opts.contextToken || "");
438
+ this.contextTokenPeer = String(opts.contextTokenPeer || "");
432
439
  if (this.token) this.logger.addSecret(this.token);
433
440
  }
434
441
 
442
+ /** 清掉会话上下文令牌(解绑/换绑时必须调用:旧令牌属于上一个人/上一个会话)。 */
443
+ clearContextToken() {
444
+ this.contextToken = "";
445
+ this.contextTokenPeer = "";
446
+ }
447
+
448
+ /** 记下/更新会话上下文令牌(入站消息带来;sendMessage 会回带)。 */
449
+ setContextToken(token, peerUserId) {
450
+ const t = String(token || "").trim();
451
+ if (!t) return false;
452
+ this.contextToken = t;
453
+ this.contextTokenPeer = String(peerUserId || "");
454
+ this.logger.addSecret(t); // 日志同样按密文处理(它等价于会话凭据)
455
+ return true;
456
+ }
457
+
435
458
  /** 换基址(scaned_but_redirect 用)。 */
436
459
  setBaseUrl(url) {
437
460
  this.baseUrl = String(url).replace(/\/+$/, "");
@@ -675,13 +698,25 @@ export class IlinkClient {
675
698
  */
676
699
  async sendMessage({ to, text, clientId, timeoutMs = DEFAULT_API_TIMEOUT_MS, signal } = {}) {
677
700
  if (!to) throw new WeChatError("bad_options", "sendMessage: 缺少 to(to_user_id)");
701
+ // ★ 回带 `context_token`:平台要求出站消息带上"这条会话的上下文令牌",而它只出现在
702
+ // **入站消息**里(官方实现见 messaging/send.js 的 buildTextMessageReq:`msg.context_token`;
703
+ // 腾讯自家插件同样是「从入站取出 → 按 (账号, 用户) 缓存 → 发送时回带」)。
704
+ // 🔴 我们以前**从不发它** → 真机症状就是日志里的 `ret=-2 errmsg=prepare failed`:
705
+ // 消息发不出去(业主 2026-09-23:「没收到微信通道」)。
706
+ // ⚠️ 只在"有令牌且属于同一个对端"时带上,绝不拿 A 的上下文发给 B。
707
+ const peer = String(to);
708
+ // 本产品一个 bot 只绑定一个微信用户,所以只要有令牌就回带;`contextTokenPeer` 仅作诊断。
709
+ // ⚠️ 换绑会换人 → unbind 时必须**清掉**旧令牌(见 clearContextToken),否则会拿旧上下文发。
710
+ const ctxToken = this.contextToken || "";
678
711
  const msg = {
679
712
  from_user_id: "",
680
- to_user_id: String(to),
713
+ to_user_id: peer,
681
714
  client_id: clientId || newClientId(),
682
715
  message_type: MessageType.BOT,
683
716
  message_state: MessageState.FINISH,
684
- item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : []
717
+ item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : [],
718
+ // 没有就**不出现这个键**(不是空串)—— 与官方 `contextToken ?? undefined` 同口径
719
+ ...(ctxToken ? { context_token: ctxToken } : {})
685
720
  };
686
721
  const { json } = await this.request({
687
722
  method: "POST",
@@ -2396,6 +2431,23 @@ export function extractFromUserId(msg) {
2396
2431
  return "";
2397
2432
  }
2398
2433
 
2434
+ /**
2435
+ * 取出入站消息里的会话上下文令牌 `context_token`。
2436
+ *
2437
+ * 为什么必须取:平台要求**出站**消息回带它(官方实现 `msg.context_token`),
2438
+ * 而它只出现在入站消息里 —— 不回带的后果真机实测是
2439
+ * `sendMessage: ret=-2 errmsg=prepare failed`(消息根本发不出去)。
2440
+ * 形状未验证(§11),所以防御式:非字符串/空串一律当"没有",绝不猜。
2441
+ */
2442
+ export function extractContextToken(msg) {
2443
+ if (!msg || typeof msg !== "object") return "";
2444
+ for (const k of ["context_token", "contextToken"]) {
2445
+ const v = msg[k];
2446
+ if (typeof v === "string" && v.trim()) return v.trim();
2447
+ }
2448
+ return "";
2449
+ }
2450
+
2399
2451
  /**
2400
2452
  * 别名 → 规范指令名。
2401
2453
  * ⚠️ 编排层是按**规范名**分派的(`cmd === "/ls"`),所以别名必须在 `classifyInbound()`
@@ -2705,6 +2757,32 @@ export class WeChatChannel {
2705
2757
  this.cooldown = new SessionCooldown({ cooldownMs: opts.cooldownMs, clock: this.clock });
2706
2758
  this.registry = new EventRegistry({ clock: this.clock });
2707
2759
  this.updatesBuf = "";
2760
+ // ★ 恢复上次记住的 context_token:不恢复的话,bridge 一重启就要等用户先发一条消息
2761
+ // 才重新具备"能发出去"的能力 —— 而重启后的第一条通知恰恰是最需要送达的那条。
2762
+ this.#restoreContextToken();
2763
+ }
2764
+
2765
+ /** 从状态文件恢复 context_token(启动时 / 换绑后各调一次)。 */
2766
+ #restoreContextToken() {
2767
+ try {
2768
+ const st = loadState(this.relayDir);
2769
+ if (st.context_token) this.client.setContextToken(st.context_token, st.context_token_peer || "");
2770
+ } catch { /* 状态文件读不了就当没有:不影响主流程 */ }
2771
+ }
2772
+
2773
+ /**
2774
+ * 记下入站消息带来的 `context_token`(发送时回带它才发得出去,见 sendMessage 注释)。
2775
+ * 落盘持久化:重启/换实例后仍然能发;同时**按对端成对保存**,绝不跨对端复用。
2776
+ * @returns {boolean} 是否有变化(无变化不重复写盘)
2777
+ */
2778
+ rememberContextToken(token, peerUserId) {
2779
+ const t = String(token || "").trim();
2780
+ if (!t) return false;
2781
+ const peer = String(peerUserId || "");
2782
+ if (this.client.contextToken === t && this.client.contextTokenPeer === peer) return false;
2783
+ this.client.setContextToken(t, peer);
2784
+ this.writeState({ context_token: t, context_token_peer: peer });
2785
+ return true;
2708
2786
  }
2709
2787
 
2710
2788
  /** 面板接口用:永不回显 token(§8)。 */
@@ -2743,6 +2821,8 @@ export class WeChatChannel {
2743
2821
  logger: this.logger,
2744
2822
  fetch: this.client.fetchImpl
2745
2823
  });
2824
+ // 换绑会**新建 client** → 必须把会话上下文令牌重新挂上(否则换绑后第一条推送就发不出去)
2825
+ this.#restoreContextToken();
2746
2826
  this.writeState({
2747
2827
  bound: true,
2748
2828
  bot_id: account.accountId,
@@ -2777,7 +2857,10 @@ export class WeChatChannel {
2777
2857
  const cleared = clearAccount(this.relayDir);
2778
2858
  this.account = null;
2779
2859
  this.client.setToken("");
2780
- this.writeState({ bound: false, bot_id: "", bound_at: 0, connected_at: 0, last_error: notifyError });
2860
+ // ★ 会话上下文令牌属于**上一个人/上一个会话**:解绑必须一并清掉,
2861
+ // 否则换绑后第一条推送会拿旧上下文去发 —— 表现还是"发不出去"。
2862
+ this.client.clearContextToken();
2863
+ this.writeState({ bound: false, bot_id: "", bound_at: 0, connected_at: 0, last_error: notifyError, context_token: "", context_token_peer: "" });
2781
2864
  // ★ 解绑同样清冷却:凭据都删了,再"退避"没有任何意义 ——
2782
2865
  // 留着只会让用户重新绑定时继续被挡(见 adoptConfirmed 的注释)。
2783
2866
  this.cooldown.clear();