@mrrisega/dsh-remote 0.7.7 → 0.7.9-beta.1

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.
@@ -44,7 +44,9 @@ const quietLogger = () => ({ info() {}, warn() {}, error() {}, debug() {}, addSe
44
44
 
45
45
  /** 本地假 ilink 服务 —— 测试期网络护栏只允许回环,所以必须自带假上游。 */
46
46
  function fakeIlink(handler) {
47
- const state = { sends: [], notifyStart: 0, notifyStop: 0, getUpdates: 0, queue: [] };
47
+ // `bufs` 记录**每次 getupdates 请求里带的游标** —— 长轮询游标恢复/重投必须能被断言
48
+ // (旧实现游标只在内存,重启后从空游标开始,服务端就会重投已确认消息)。
49
+ const state = { sends: [], notifyStart: 0, notifyStop: 0, getUpdates: 0, queue: [], bufs: [] };
48
50
  const server = http.createServer((req, res) => {
49
51
  const url = new URL(req.url, "http://127.0.0.1");
50
52
  let body = "";
@@ -56,6 +58,7 @@ function fakeIlink(handler) {
56
58
  };
57
59
  if (url.pathname.endsWith("/getupdates")) {
58
60
  state.getUpdates += 1;
61
+ try { state.bufs.push(JSON.parse(body).get_updates_buf); } catch { state.bufs.push(""); }
59
62
  if (handler && handler.onGetUpdates) return handler.onGetUpdates(state, reply);
60
63
  const msgs = state.queue.splice(0);
61
64
  return reply({ ret: 0, msgs, get_updates_buf: "buf-" + state.getUpdates, longpolling_timeout_ms: 1 });
@@ -95,7 +98,9 @@ function fakeIlink(handler) {
95
98
  /** 直接构造一个"已绑定"的运行时(绕过扫码),便于测通道与回执。 */
96
99
  async function boundRuntime(ilink, opts = {}) {
97
100
  const relayDir = tmp();
98
- saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "user-1", boundAt: 1 });
101
+ // ⚠️ `userId` 就是**绑定用户本人**:runtime 的入站身份过滤会拿它比对 from_user_id
102
+ // (非本人/机器人消息一律忽略)。本文件绝大多数用例用 "u" 当用户,所以这里也用它。
103
+ saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
99
104
  const rt = createWeChatRuntime({
100
105
  relayDir,
101
106
  upstream: "http://127.0.0.1:1",
@@ -264,7 +269,7 @@ test("★ 审批回执走 answerApproval,提问回执走 answerQuestion(不能
264
269
  try {
265
270
  const sub = makeFakeSubscriber();
266
271
  const { rt } = await boundRuntime(ilink, { subscriber: sub });
267
- rt.channel.account = { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "user-1" };
272
+ rt.channel.account = { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "u" };
268
273
 
269
274
  // 1) 推一条审批
270
275
  const sent = await rt.notify({
@@ -274,8 +279,8 @@ test("★ 审批回执走 answerApproval,提问回执走 answerQuestion(不能
274
279
  assert.match(lastText(ilink.state), /需要你拍板/);
275
280
 
276
281
  // 用户回 1 → 应走 answerApproval 且值为 allowed-once
277
- ilink.state.queue.push({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
278
- await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
282
+ ilink.state.queue.push({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
283
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
279
284
  const call = sub.calls[sub.calls.length - 1];
280
285
  assert.equal(call.m, "answerApproval", "审批必须走 answerApproval,否则编码不对");
281
286
  assert.equal(call.value, "allowed-once", "选项字段是 value,不是 outcome");
@@ -285,7 +290,7 @@ test("★ 审批回执走 answerApproval,提问回执走 answerQuestion(不能
285
290
  kind: NODE_KINDS.USER_QUESTION, eventId: "ev-2", answerShape: "question", at: 1,
286
291
  questions: [{ id: "q1", question: "选哪个?", options: ["甲", "乙"] }]
287
292
  });
288
- await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "2" } }] });
293
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "2" } }] });
289
294
  const call2 = sub.calls[sub.calls.length - 1];
290
295
  assert.equal(call2.m, "answerQuestion", "提问必须走 answerQuestion");
291
296
  assert.deepEqual(call2.answers, [{ id: "q1", selected: ["乙"] }]);
@@ -299,7 +304,7 @@ test("★ 过期绝不等于同意:没有待答通知时回数字,必须明说
299
304
  try {
300
305
  const sub = makeFakeSubscriber();
301
306
  const { rt } = await boundRuntime(ilink, { subscriber: sub });
302
- await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
307
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
303
308
  assert.equal(sub.calls.length, 0, "不得在没有待答通知时发出任何回执");
304
309
  assert.match(lastText(ilink.state), /没有代你做出任何选择/);
305
310
  } finally {
@@ -313,9 +318,9 @@ test("同一条通知不得被回复两次(consume 后再次回复应被拒)", a
313
318
  const sub = makeFakeSubscriber();
314
319
  const { rt } = await boundRuntime(ilink, { subscriber: sub });
315
320
  await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-x", toolName: "B", answerShape: "approval", at: 1 });
316
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
321
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-dup-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
317
322
  const before = sub.calls.length;
318
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
323
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-dup-2", item_list: [{ type: 1, text_item: { text: "1" } }] });
319
324
  assert.equal(sub.calls.length, before, "第二次回复不得再产生一次回执");
320
325
  } finally {
321
326
  await ilink.close();
@@ -464,7 +469,7 @@ test("★ 分层:档位取不到时沿用上次已知档位(绝不因一次网
464
469
  });
465
470
 
466
471
  // 首次判档位时缓存为空(按免费)→ 被拒路径触发刷新 → 取到 pro → **同一条指令立即放行**
467
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
472
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-ls-1", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
468
473
  assert.equal(sub1.calls.filter((c) => c.m === "listSessions").length, 1,
469
474
  "首次取到 pro 后就该放行(不能因为'一开始不知道'就把付费用户挡掉)");
470
475
 
@@ -478,7 +483,7 @@ test("★ 分层:档位取不到时沿用上次已知档位(绝不因一次网
478
483
  // 而且能力也真的还在(不只是内部字段好看)
479
484
  const sub2 = makeFakeSubscriber();
480
485
  rt.subscriber = sub2;
481
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
486
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-ls-2", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
482
487
  assert.equal(sub2.calls.filter((c) => c.m === "listSessions").length, 1,
483
488
  "沿用 pro 期间,遥控能力必须仍然可用");
484
489
  } finally {
@@ -497,11 +502,11 @@ test("★ 分层:刚升级完立刻重试必须能进(不必重启 bridge)", asy
497
502
  subscriber: sub, tier: "", tierProvider: async () => plan, tierDenyThrottleMs: 0
498
503
  });
499
504
 
500
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
505
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-new-retry-1", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
501
506
  assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 0, "免费时先拒");
502
507
 
503
508
  plan = "pro"; // 用户升级
504
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
509
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-new-retry-2", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
505
510
  assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 1,
506
511
  "★升级后必须能进:否则用户付了钱还要重启 bridge 才能用");
507
512
  } finally {
@@ -560,7 +565,7 @@ test("★ v2 安全边界(用户拍板放宽):看不到内容照给一键回执,
560
565
  assert.equal(rt.pendingReplies.length, 1, "应登记为待答,用户能一键处理");
561
566
 
562
567
  // 回 1 必须真的能放行 —— 这是放宽后要保证的核心路径
563
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
568
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-safe-a", item_list: [{ type: 1, text_item: { text: "1" } }] });
564
569
  const answered = sub.calls.filter((c) => c.m === "answerApproval");
565
570
  assert.equal(answered.length, 1, "普通审批必须能被微信一键放行");
566
571
  assert.equal(answered[0].value, "allowed-once");
@@ -572,7 +577,7 @@ test("★ v2 安全边界(用户拍板放宽):看不到内容照给一键回执,
572
577
  assert.match(b, /到电脑上确认/, `破坏性内容必须要求回电脑,实际:${b}`);
573
578
  assert.match(b, /内容.*破坏性/, "有内容时应说是**内容**被判定破坏性");
574
579
  assert.ok(!/回复 1/.test(b), "破坏性操作不得给编号选项");
575
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
580
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-safe-b", item_list: [{ type: 1, text_item: { text: "1" } }] });
576
581
  assert.equal(sub.calls.filter((c) => c.m === "answerApproval").length, 0, "破坏性操作绝不能被一键放行");
577
582
  } finally {
578
583
  await ilink.close();
@@ -802,6 +807,10 @@ test("★★ 日报发送失败时**不能**把当天标记为已发(否则当
802
807
  await rt.runMaybeDigestNow();
803
808
  assert.ok(!stateOf().last_digest_day,
804
809
  "★发送失败时绝不能标记今天已发 —— 否则当天再也不会补发(旧实现的坑)");
810
+ // 失败后必须退避(修掉"每小时 60 次猛打",CONTRACT §3.6)。这里清零 = 模拟 15 分钟已过,
811
+ // 让下一条断言仍能验"恢复后能补发";退避本身的用例是下面单独那条。
812
+ assert.ok(rt.digestNextTryAt > Date.now(), "失败后必须设退避(否则定时器每分钟猛打一次)");
813
+ rt.digestNextTryAt = 0;
805
814
 
806
815
  // ② 恢复发送 → 必须能补发成功并记账
807
816
  rt.channel.client.sendMessage = origSend;
@@ -932,7 +941,10 @@ test("★★ B:免费档每月额度用完后引导升级;审批与指令不计
932
941
  });
933
942
  rt.currentSessionId = "s";
934
943
  rt.currentSessionTitle = "t";
935
- const send = () => rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" } }] });
944
+ // ⚠️ 每次派活都要给独立 message_id:入站去重按 message_id 判重,
945
+ // 三条一模一样的 fixture 若不区分,第二/三条会被当成平台重投而跳过。
946
+ let sendSeq = 0;
947
+ const send = () => rt.handleInbound({ from_user_id: "u", message_id: `m-quota-${sendSeq += 1}`, item_list: [{ type: 1, text_item: { text: "派活" } }] });
936
948
 
937
949
  // 审批与指令**不**吃额度:先做一次审批,额度不受影响
938
950
  await rt.notify({
@@ -1016,7 +1028,7 @@ test("★★ 多条待审批:回数字必须答**最早那条**(先进先出),
1016
1028
  `★必须告诉用户"不止这一条",否则他以为答完就结束了,实际:${lastText(ilink.state)}`);
1017
1029
 
1018
1030
  // 回「1」→ 必须答**最早**的 e1
1019
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
1031
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-fifo-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
1020
1032
  const answered = sub.calls.filter((c) => c.m === "answerApproval");
1021
1033
  assert.equal(answered.length, 1, "必须真的回执一条");
1022
1034
  assert.equal(answered[0].id, "e1", "★必须答**最早那条**(e1),而不是最新那条(e2)");
@@ -1028,7 +1040,7 @@ test("★★ 多条待审批:回数字必须答**最早那条**(先进先出),
1028
1040
  assert.match(after, /Write/, "要指明下一条是什么(工具名),用户才知道在批什么");
1029
1041
 
1030
1042
  // 再回一次 → 答掉 e2;队列空了就不该再发多余提醒
1031
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
1043
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-fifo-2", item_list: [{ type: 1, text_item: { text: "1" } }] });
1032
1044
  const all = sub.calls.filter((c) => c.m === "answerApproval");
1033
1045
  assert.equal(all.length, 2);
1034
1046
  assert.equal(all[1].id, "e2", "第二条应是 e2");
@@ -1519,9 +1531,9 @@ test("★ 多审批 + 提醒混排:回执仍按 FIFO 走审批(提醒不参与
1519
1531
  assert.equal(rt.status().pending_replies, 2, "两条审批 = 2 条待拍板(提醒不计)");
1520
1532
 
1521
1533
  sub.calls.length = 0;
1522
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
1534
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-mix-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
1523
1535
  assert.equal(sub.calls.find((c) => c.m === "answerApproval").id, "ev-1", "先答最早那条审批");
1524
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
1536
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-mix-2", item_list: [{ type: 1, text_item: { text: "1" } }] });
1525
1537
  assert.equal(sub.calls.filter((c) => c.m === "answerApproval")[1].id, "ev-2", "再答第二条审批");
1526
1538
  assert.equal(sub.calls.filter((c) => c.m === "answerQuestion").length, 0, "提醒全程不参与回执");
1527
1539
  } finally {
@@ -1730,7 +1742,7 @@ test("★ /help 必须逐行分隔:付费档也不许退化成一坨(无换行/
1730
1742
  /** 两个 runtime **共用同一个 relayDir**(模拟更新期间新旧 bridge 短暂共存)。 */
1731
1743
  async function twoRuntimesSameDir(ilink) {
1732
1744
  const relayDir = tmp();
1733
- saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "user-1", boundAt: 1 });
1745
+ saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
1734
1746
  const mk = () => {
1735
1747
  const rt = createWeChatRuntime({
1736
1748
  relayDir,
@@ -1812,13 +1824,13 @@ test("★★ 出站必须回带 context_token(入站带来什么就回什么)",
1812
1824
  const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1813
1825
  // 真实形状:入站消息带顶层 context_token;发送目标是账号的 userId
1814
1826
  await rt.handleInbound({
1815
- from_user_id: "user-1", context_token: "CT-abc-123",
1827
+ from_user_id: "u", context_token: "CT-abc-123",
1816
1828
  item_list: [{ type: 1, text_item: { text: "/status" } }]
1817
1829
  });
1818
1830
  const last = ilink.state.sends.at(-1);
1819
1831
  assert.equal(last.msg.context_token, "CT-abc-123",
1820
1832
  "★出站 msg 必须回带 context_token —— 不带就是 ret=-2 prepare failed(消息根本发不出去)");
1821
- assert.equal(last.msg.to_user_id, "user-1");
1833
+ assert.equal(last.msg.to_user_id, "u");
1822
1834
  } finally {
1823
1835
  await ilink.close();
1824
1836
  }
@@ -1828,7 +1840,7 @@ test("★ 没有 context_token 时**完全不带这个键**(而不是空串)", a
1828
1840
  const ilink = await fakeIlink();
1829
1841
  try {
1830
1842
  const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1831
- await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "/status" } }] });
1843
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
1832
1844
  const last = ilink.state.sends.at(-1);
1833
1845
  assert.ok(!("context_token" in last.msg),
1834
1846
  "★拿不到令牌时不得塞空串(与官方 `contextToken ?? undefined` 同约定)");
@@ -1842,7 +1854,7 @@ test("★ context_token 落盘持久化:bridge 重启后不必等用户先说话
1842
1854
  try {
1843
1855
  const first = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1844
1856
  await first.rt.handleInbound({
1845
- from_user_id: "user-1", context_token: "CT-persist-9",
1857
+ from_user_id: "u", context_token: "CT-persist-9",
1846
1858
  item_list: [{ type: 1, text_item: { text: "/status" } }]
1847
1859
  });
1848
1860
  // 同一个 relayDir 上新建一个 runtime(= bridge 重启)
@@ -1850,7 +1862,7 @@ test("★ context_token 落盘持久化:bridge 重启后不必等用户先说话
1850
1862
  relayDir: first.relayDir, upstream: "http://127.0.0.1:1", secret: SECRET, tier: "pro", logger: quietLogger()
1851
1863
  });
1852
1864
  rt2.subscriber = makeFakeSubscriber();
1853
- await rt2.reply("user-1", "重启后的第一条推送");
1865
+ await rt2.reply("u", "重启后的第一条推送");
1854
1866
  assert.equal(ilink.state.sends.at(-1).msg.context_token, "CT-persist-9",
1855
1867
  "★重启后必须从状态文件恢复令牌 —— 否则重启后的第一条通知(最该送达的那条)发不出去");
1856
1868
  } finally {
@@ -1863,7 +1875,7 @@ test("★ 解绑必须清掉 context_token(换绑后不能拿上一个人的上
1863
1875
  try {
1864
1876
  const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1865
1877
  await rt.handleInbound({
1866
- from_user_id: "user-1", context_token: "CT-old-user",
1878
+ from_user_id: "u", context_token: "CT-old-user",
1867
1879
  item_list: [{ type: 1, text_item: { text: "/status" } }]
1868
1880
  });
1869
1881
  await rt.unbind();
@@ -1968,12 +1980,12 @@ test("★ 回归锁:用户主动 /use 切走的会话不能被「最近完成
1968
1980
  ];
1969
1981
  sub.summaryFixture = "完成。";
1970
1982
  const { rt } = await boundRuntime(ilink, { subscriber: sub });
1971
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
1983
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-use-1", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
1972
1984
  assert.equal(rt.currentSessionId, "session-mine", "前置条件:/use 切到了 2 号");
1973
1985
  await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: 3 });
1974
1986
 
1975
1987
  // 完成事件在处理时会把它自己设为回复目标(这是产品约定);但用户**之后**再切回去要算数:
1976
- await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
1988
+ await rt.handleInbound({ from_user_id: "u", message_id: "m-use-2", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
1977
1989
  await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续聊我的" } }] });
1978
1990
  const prompt = sub.calls.filter((c) => c.m === "promptSession").pop();
1979
1991
  assert.equal(prompt.sessionId, "session-mine",
@@ -2098,3 +2110,530 @@ test("★ 回归锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
2098
2110
  await ilink.close();
2099
2111
  }
2100
2112
  });
2113
+
2114
+ // ═════════════ 2026-10-29 ClawBot 通道修复(CONTRACT §3 / §5)═════════════
2115
+ //
2116
+ // 用户报的两个 bug:
2117
+ // · bug 1:「发给 Agent 的会话会发两遍」—— 游标只在内存 + 没有 message_id 去重 +
2118
+ // 不过滤非本人/机器人消息 → 服务端重投时同一条消息被派活两次。
2119
+ // · bug 2:「AI 不能主动回消息 / 日报不发 / 说句『你好』补发一堆」——
2120
+ // context_token 分钟级过期且没有续期接口(CONTRACT §0 真机实测:约 10 分钟后
2121
+ // `ret=-2 prepare failed`),旧实现把它当间歇性网络失败 → 每小时重试 60 次 + 积压后一次补发。
2122
+ // 修复原则:识别过期 → 立即静默主动推送(不再猛打)→ 有界入队 → 下次入站立即补送(日报优先)
2123
+ // → 其余汇总成一条。下面每条用例锁住其中一条承诺。
2124
+
2125
+ /** 造一条入站消息(带 message_id,模拟真实 ilink 形状;去重按它判)。 */
2126
+ const inbound = (id, text, extra = {}) => ({
2127
+ message_id: id,
2128
+ from_user_id: "u",
2129
+ item_list: [{ type: 1, text_item: { text } }],
2130
+ ...extra
2131
+ });
2132
+
2133
+ test("★★ bug1 去重:同一个 message_id 重投(同批 + 跨批)只派活一次", async () => {
2134
+ // 平台在"游标丢失/消息未确认"时会重投同一条消息。修复前没有按 message_id 去重,
2135
+ // 于是用户在微信里交代的一件事会被派两遍(用户报的「会话会发两遍」)。
2136
+ const same = inbound(9001, "帮我修个 bug");
2137
+ const script = [
2138
+ { msgs: [same, same], buf: "buf-dedup-1" }, // 一次 getUpdates 里出现两次
2139
+ { msgs: [{ ...same }], buf: "buf-dedup-2" } // 下一次 getUpdates 又重投一次
2140
+ ];
2141
+ const ilink = await fakeIlink({
2142
+ onGetUpdates: (state, reply) => {
2143
+ const step = script[state.getUpdates - 1];
2144
+ if (step) return reply({ ret: 0, msgs: step.msgs, get_updates_buf: step.buf, longpolling_timeout_ms: 1 });
2145
+ // 脚本用完后**延迟**回空响应:既让出 CPU(否则轮询会自旋成上万次请求),又保持循环活着
2146
+ return setTimeout(() => reply({ ret: 0, msgs: [], get_updates_buf: "buf-dedup-2", longpolling_timeout_ms: 1 }), 20);
2147
+ }
2148
+ });
2149
+ try {
2150
+ const sub = makeFakeSubscriber();
2151
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
2152
+ await rt.startChannel();
2153
+ try {
2154
+ await waitFor(() => sub.calls.filter((c) => c.m === "promptSession").length >= 1);
2155
+ await waitFor(() => ilink.state.getUpdates >= 2); // 确认第二/第三次重投已被处理过
2156
+ await settle();
2157
+ assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 1,
2158
+ "★ 同一个 message_id 重投只能派活一次(否则用户那条指令会被执行两遍)");
2159
+ assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 1,
2160
+ "★ 也不该因为重投多开一个会话");
2161
+ assert.equal(ilink.state.sends.filter((s) => s && s.msg && s.msg.item_list && s.msg.item_list[0]).length, 1,
2162
+ "重投的消息不该再回执一次(用户会看到两条回复)");
2163
+ // 落盘去重表必须真的用上了(否则重启后照样会重复)
2164
+ assert.ok(fs.readdirSync(rt.relayDir).some((n) => n.startsWith(".wechat-seen-")),
2165
+ "去重 id 必须落盘(重启后仍然认得同一条消息)");
2166
+ } finally {
2167
+ await rt.stopChannel();
2168
+ }
2169
+ } finally {
2170
+ await ilink.close();
2171
+ }
2172
+ });
2173
+
2174
+ test("★★ bug1 游标:整批处理完才落盘(抛错也落盘);重启后从上次游标继续", async () => {
2175
+ // 旧实现把 `get_updates_buf` 只放在内存 → bridge 重启后游标归零 → 服务端重投未确认消息。
2176
+ // 落盘顺序按 Lead 2026-10-29 决策改为「先处理、后存」:
2177
+ // · 先存 = at-most-once:处理中途崩溃 → 这批剩余消息**永久丢失**(没有任何补偿机会);
2178
+ // · 后存 = at-least-once:崩溃后重投这一批,重复由 channel 的持久化去重表吸收。
2179
+ // 宁可极小概率重放一批(被去重吸收),也绝不静默丢一批用户消息。
2180
+ // 三条要锁:① 正常批次处理完后游标落盘正确;② 某条 handleInbound 抛错时游标仍然保存(finally);
2181
+ // ③ 重启后的第一个请求带的是上次保存的游标。
2182
+ const script = [
2183
+ {
2184
+ msgs: [inbound(7001, "第一件活"), inbound(7002, "第二件活")],
2185
+ buf: "buf-cur-1"
2186
+ }
2187
+ ];
2188
+ const ilink = await fakeIlink({
2189
+ onGetUpdates: (state, reply) => {
2190
+ const step = script[state.getUpdates - 1];
2191
+ if (step) return reply({ ret: 0, msgs: step.msgs, get_updates_buf: step.buf, longpolling_timeout_ms: 1 });
2192
+ return setTimeout(() => reply({ ret: 0, msgs: [], get_updates_buf: "buf-cur-1", longpolling_timeout_ms: 1 }), 20);
2193
+ }
2194
+ });
2195
+ try {
2196
+ const sub = makeFakeSubscriber();
2197
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2198
+ /** 直接读盘(不走 channel 内存缓存)——要验的正是"落没落盘"。 */
2199
+ const syncBufOnDisk = () => {
2200
+ const f = fs.readdirSync(relayDir).find((n) => n.startsWith(".wechat-sync-"));
2201
+ if (!f) return "";
2202
+ try { return JSON.parse(fs.readFileSync(path.join(relayDir, f), "utf8")).get_updates_buf || ""; } catch { return ""; }
2203
+ };
2204
+ // 第一条消息派活时**故意抛错**:既验证"循环内 catch 后继续处理下一条",
2205
+ // 又验证"即使抛错,finally 里仍把整批的游标保存下来"(否则一条坏消息会让游标永久卡死、无限重投)。
2206
+ const origPrompt = sub.promptSession;
2207
+ let promptCalls = 0;
2208
+ sub.promptSession = async (o) => {
2209
+ promptCalls += 1;
2210
+ if (promptCalls === 1) throw new Error("模拟派活失败");
2211
+ return origPrompt(o);
2212
+ };
2213
+
2214
+ await rt.startChannel();
2215
+ try {
2216
+ // 第二条消息仍被处理 → 证明一条抛错不会中断整批
2217
+ await waitFor(() => promptCalls >= 2);
2218
+ assert.ok(promptCalls >= 2, "★ 某条 handleInbound 抛错后,同批的后续消息仍要处理(catch+log+继续)");
2219
+ // 整批处理完才落盘(注意:处理过程中盘上是空的 —— 这是「先处理后存」的取舍)
2220
+ await waitFor(() => syncBufOnDisk() === "buf-cur-1");
2221
+ assert.equal(syncBufOnDisk(), "buf-cur-1",
2222
+ "★ 即使中途抛错,整批处理完之后游标也必须落盘(finally)");
2223
+ } finally {
2224
+ await rt.stopChannel();
2225
+ }
2226
+
2227
+ // 重启(同 relayDir):第一个 getUpdates 必须带上上次保存的游标
2228
+ const n = ilink.state.bufs.length;
2229
+ const rt2 = createWeChatRuntime({
2230
+ relayDir, upstream: "http://127.0.0.1:1", secret: SECRET, tier: "pro", logger: quietLogger()
2231
+ });
2232
+ rt2.subscriber = makeFakeSubscriber();
2233
+ await rt2.startChannel();
2234
+ try {
2235
+ await waitFor(() => ilink.state.bufs.length > n);
2236
+ assert.equal(ilink.state.bufs[n], "buf-cur-1",
2237
+ "★ 重启后必须从上次游标继续 —— 否则服务端会把已确认的消息再投一遍、同一条被派两次");
2238
+ } finally {
2239
+ await rt2.stop();
2240
+ }
2241
+ } finally {
2242
+ await ilink.close();
2243
+ }
2244
+ });
2245
+
2246
+ test("★★ bug2 令牌过期:连续 3 条通知最多 2 次发送尝试,记 push_paused,且静默期间一个请求都不发", async () => {
2247
+ // CONTRACT §0:`ret=-2 prepare failed` 不是网络抖动,是 context_token 过期(平台没有续期接口)。
2248
+ // 修复前把它当间歇失败 → 每条通知都重试、日志刷满、平台被限流。
2249
+ let sendAttempts = 0;
2250
+ const ilink = await fakeIlink({
2251
+ onSendMessage: (_state, reply) => { sendAttempts += 1; return reply({ ret: -2, errmsg: "prepare failed" }); }
2252
+ });
2253
+ try {
2254
+ const sub = makeFakeSubscriber();
2255
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2256
+ const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2257
+
2258
+ for (let i = 1; i <= 3; i += 1) {
2259
+ await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: `session-${i}`, reason: "completed", at: i });
2260
+ }
2261
+ assert.ok(sendAttempts <= 2,
2262
+ `★ 连续 3 条通知最多 2 次发送尝试(第一条撞上过期就要立刻静默),实际 ${sendAttempts} 次`);
2263
+ const paused = stateOf().push_paused;
2264
+ assert.ok(paused && paused.at > 0, "★ 必须在 state 落 push_paused(面板与静默判定都读它)");
2265
+ assert.ok(typeof paused.reason === "string" && paused.reason.length > 0,
2266
+ "push_paused.reason 要能解释为什么静默(且已脱敏截断)");
2267
+ assert.equal((stateOf().pending_outbox || []).length, 3,
2268
+ "★ 静默期间的通知全部入队(不静默丢,等入站补发)");
2269
+
2270
+ // status() 必须把这些如实暴露出去(面板靠它解释"为什么没消息")
2271
+ const s = rt.status();
2272
+ assert.equal(s.push_paused, true);
2273
+ assert.ok(s.push_paused_at > 0);
2274
+ assert.ok(s.push_paused_reason.length > 0);
2275
+ assert.equal(s.pending_outbox_count, 3, "status 要报有效待补发条数");
2276
+
2277
+ // 「不每分钟重试」:日报 tick 再来两次,静默期间一个请求都不该发
2278
+ rt.digestHour = new Date().getHours();
2279
+ const before = sendAttempts;
2280
+ await rt.runMaybeDigestNow();
2281
+ await rt.runMaybeDigestNow();
2282
+ assert.equal(sendAttempts, before, "★ 静默期间日报不得再打平台(旧实现每小时 60 次 prepare failed)");
2283
+ assert.ok(stateOf().pending_digest_day, "没送达的日报要记 pending_digest_day(当天可补)");
2284
+ assert.ok(!fs.readdirSync(relayDir).some((n) => n.startsWith(".digest-")),
2285
+ "占位必须释放 —— 否则那天就再也不会补发(旧实现的坑)");
2286
+ } finally {
2287
+ await ilink.close();
2288
+ }
2289
+ });
2290
+
2291
+ test("★★ bug2 补发有界:入站后最多逐条补 2 条 + 1 条省略汇总,然后清空(如实记日志)", async () => {
2292
+ // 用户原话是「说句『你好』补发一堆」—— 所以补发必须**有界**:
2293
+ // 最近 2 条逐条发,更早的攒成一句"另有 N 条已省略",绝不一次连打十几条(那正是限流的成因)。
2294
+ let failSends = true;
2295
+ const ilink = await fakeIlink({
2296
+ onSendMessage: (_state, reply) => (failSends
2297
+ ? reply({ ret: -2, errmsg: "prepare failed" })
2298
+ : reply({ ret: 0, message_id: "ok" }))
2299
+ });
2300
+ try {
2301
+ const sub = makeFakeSubscriber();
2302
+ sub.sessionsFixture = [{ sessionId: "session-x", title: "跑完的活", running: false, cwd: "/p" }];
2303
+ sub.summaryFixture = "做完了。";
2304
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2305
+ const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2306
+
2307
+ // 先让通道真的进入静默:一条完成通知撞上 prepare failed
2308
+ await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", at: 1 });
2309
+ assert.equal(stateOf().push_paused && stateOf().push_paused.at > 0, true, "前提:通道已静默");
2310
+
2311
+ // 造 5 条积压(用固定正文,方便断言"补的是最近两条")
2312
+ const at = Date.now();
2313
+ rt.channel.writeState({
2314
+ pending_outbox: [1, 2, 3, 4, 5].map((i) => ({ text: `积压通知${i}`, kind: "stopped", at: at - (5 - i) * 1000 }))
2315
+ });
2316
+ const infos = [];
2317
+ const warns = [];
2318
+ rt.logger = {
2319
+ info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {}
2320
+ };
2321
+
2322
+ // 平台恢复 + 用户说一句话 → 令牌刷新 → 有界补发
2323
+ failSends = false;
2324
+ await rt.handleInbound(inbound(5001, "/status", { context_token: "CT-fresh-1" }));
2325
+ await waitFor(() => (stateOf().pending_outbox || []).length === 0);
2326
+
2327
+ const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
2328
+ assert.equal(resent.length, 3, `★ 最多补 2 条 + 1 条汇总,实际 ${resent.length} 条`);
2329
+ const detail = resent.filter((t) => !/另有 \d+ 条/.test(t));
2330
+ assert.equal(detail.length, 2, "逐条补发的只能是 2 条");
2331
+ assert.ok(detail.some((t) => t.includes("积压通知4")) && detail.some((t) => t.includes("积压通知5")),
2332
+ `★ 补的必须是**最近**两条,实际:${JSON.stringify(detail)}`);
2333
+ assert.ok(!resent.some((t) => /积压通知[123]/.test(t)), "较早的绝不逐条重发(否则就是「补发一堆」)");
2334
+ const summary = resent.find((t) => /另有 \d+ 条/.test(t)) || "";
2335
+ assert.match(summary, /另有 3 条较早的通知未送达,已省略/, `汇总要如实说省略了几条,实际:${summary}`);
2336
+ assert.equal((stateOf().pending_outbox || []).length, 0, "★ 补完必须清空队列(否则下次入站又补一遍)");
2337
+ // 省略是 warn(丢信息必须如实记,且要能被运维搜到)
2338
+ assert.ok(warns.some((m) => /省略了 3 条较早的通知/.test(m)),
2339
+ `省略必须如实记日志(不得静默),实际:${JSON.stringify(warns)}`);
2340
+ } finally {
2341
+ await ilink.close();
2342
+ }
2343
+ });
2344
+
2345
+ test("★★ bug2 日报:失败不烧当天 → 下一次入站立即补发,成功后才写 last_digest_day", async () => {
2346
+ let failSends = true;
2347
+ const ilink = await fakeIlink({
2348
+ onSendMessage: (_state, reply) => (failSends
2349
+ ? reply({ ret: -2, errmsg: "prepare failed" })
2350
+ : reply({ ret: 0, message_id: "ok" }))
2351
+ });
2352
+ try {
2353
+ const sub = makeFakeSubscriber();
2354
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
2355
+ const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2356
+ rt.digestHour = new Date().getHours();
2357
+
2358
+ // ① 发送失败 → 记待补,但**绝不能**写"今天已发"
2359
+ await rt.runMaybeDigestNow();
2360
+ const day = stateOf().pending_digest_day;
2361
+ assert.ok(day, "★ 失败必须落 pending_digest_day(当天还能补)");
2362
+ assert.ok(!stateOf().last_digest_day, "★ 没送达绝不能写 last_digest_day(否则当天被烧掉)");
2363
+ assert.equal((stateOf().pending_outbox || []).length, 0, "日报自带补发语义,不得进待补发队列(否则两份)");
2364
+
2365
+ // ② 平台恢复 + 用户说话 → 用入站补发,成功后才记账
2366
+ failSends = false;
2367
+ const before = ilink.state.sends.length;
2368
+ await rt.handleInbound(inbound(6001, "你好", { context_token: "CT-digest-1" }));
2369
+ await waitFor(() => stateOf().last_digest_day === day);
2370
+ assert.equal(stateOf().last_digest_day, day, "★ 补发成功才写 last_digest_day");
2371
+ assert.ok(!stateOf().pending_digest_day, "补发成功要清掉待补标记");
2372
+ assert.ok(ilink.state.sends.length > before, "必须真的发出去一条");
2373
+ assert.ok(allTexts(ilink.state).some((t) => /DSH 远程控制/.test(t)),
2374
+ "补发的就是那份日报(带品牌露出)");
2375
+ assert.equal(rt.digestNextTryAt, 0, "补发成功后清掉退避(下一个自然日照常发)");
2376
+ } finally {
2377
+ await ilink.close();
2378
+ }
2379
+ });
2380
+
2381
+ test("★★ bug1 身份过滤:非本人 / 机器人消息一律忽略(不派活、不回执),同一 from 只 warn 一次", async () => {
2382
+ const ilink = await fakeIlink();
2383
+ try {
2384
+ const sub = makeFakeSubscriber();
2385
+ const warns = [];
2386
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
2387
+ rt.logger = {
2388
+ info() {}, warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {}
2389
+ };
2390
+ const sendsBefore = ilink.state.sends.length;
2391
+
2392
+ // ① 非绑定用户:可能成片来(群/服务号回推)—— 一个都不能驱动这台电脑
2393
+ await rt.handleInbound(inbound(1, "帮我删库", { from_user_id: "stranger-1" }));
2394
+ await rt.handleInbound(inbound(2, "再删一次", { from_user_id: "stranger-1" }));
2395
+ assert.equal(sub.calls.length, 0, "★ 非绑定用户不得派活");
2396
+ assert.equal(ilink.state.sends.length, sendsBefore, "★ 也不得回执(否则等于给陌生人开了响应)");
2397
+ assert.equal(warns.filter((m) => /忽略非绑定用户/.test(m)).length, 1,
2398
+ "同一 from 只 warn 一次(否则陌生来源会把日志刷满)");
2399
+
2400
+ // ② 机器人自己的消息(message_type=2)即使 from 是本人也必须忽略
2401
+ await rt.handleInbound({ message_id: 3, from_user_id: "u", message_type: 2, item_list: [{ type: 1, text_item: { text: "机器人自己说的话" } }] });
2402
+ // ③ @im.bot 来源(另一种机器人形状)
2403
+ await rt.handleInbound(inbound(4, "别的机器人", { from_user_id: "bot-9@im.bot" }));
2404
+ assert.equal(sub.calls.length, 0, "机器人消息不得派活");
2405
+ assert.equal(ilink.state.sends.length, sendsBefore, "机器人消息也不得回执");
2406
+
2407
+ // ④ 本人消息照常派活 —— 证明过滤没有误伤主链路
2408
+ await rt.handleInbound(inbound(5, "帮我修个 bug"));
2409
+ assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 1, "★ 本人消息必须照常派活");
2410
+ } finally {
2411
+ await ilink.close();
2412
+ }
2413
+ });
2414
+
2415
+ test("★ status() 暴露推送静默 / 令牌时间 / 待补发 / 日报待补字段(面板不再靠猜)", async () => {
2416
+ const ilink = await fakeIlink();
2417
+ try {
2418
+ const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber() });
2419
+ const s = rt.status();
2420
+ for (const k of ["push_paused", "push_paused_at", "push_paused_reason", "context_token_at", "pending_outbox_count", "digest_pending_day"]) {
2421
+ assert.ok(Object.prototype.hasOwnProperty.call(s, k), `status() 必须暴露 ${k}(CONTRACT §3.7)`);
2422
+ }
2423
+ assert.equal(s.push_paused, false, "没静默时必须是 false(不是 undefined)");
2424
+ assert.equal(s.push_paused_at, 0);
2425
+ assert.equal(s.push_paused_reason, "");
2426
+ assert.equal(s.pending_outbox_count, 0);
2427
+ assert.equal(s.digest_pending_day, "");
2428
+ assert.equal(typeof s.context_token_at, "number");
2429
+
2430
+ // 入站带来令牌 → 落盘时间必须可见(面板/排查要知道"上次令牌刷新是什么时候")
2431
+ await rt.handleInbound(inbound(8001, "/status", { context_token: "CT-status-1" }));
2432
+ assert.ok(rt.status().context_token_at > 0, "★ 入站后 context_token_at 必须落盘(否则无从判断令牌新旧)");
2433
+ } finally {
2434
+ await ilink.close();
2435
+ }
2436
+ });
2437
+
2438
+ test("★ 推送静默时 /status 必须说明原因与恢复方式;发送成功后静默标记自动清除", async () => {
2439
+ const ilink = await fakeIlink();
2440
+ try {
2441
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber() });
2442
+ // 直接制造静默(等价于一条推送撞上 prepare failed,见 reply() 的 catch)
2443
+ rt.channel.invalidateContextToken("sendMessage: ret=-2 prepare failed");
2444
+ assert.equal(rt.status().push_paused, true, "前提:通道处于静默");
2445
+
2446
+ await rt.handleInbound(inbound(8101, "/status"));
2447
+ const text = lastText(ilink.state);
2448
+ assert.match(text, /推送已静默/, `★ /status 必须如实说清"为什么没消息",实际:${text}`);
2449
+ assert.match(text, /回任意一条消息即可恢复/, "还要告诉他怎么办(比技术细节有用)");
2450
+ assert.ok(!JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8")).push_paused,
2451
+ "★ 发送成功 = 令牌有效 → 静默标记必须自动清除(否则面板永远挂着过期警告)");
2452
+ } finally {
2453
+ await ilink.close();
2454
+ }
2455
+ });
2456
+
2457
+ test("★ 图片/语音入站(无文本、不派活)也必须刷新令牌并解除静默", async () => {
2458
+ // CONTRACT §3.2 要求 rememberContextToken 排在 `if (!text) return` **之前**:
2459
+ // 图片/语音没有文本,但同样是"用户刚说过话、平台推送窗口已打开"的信号。
2460
+ // 若被 early-return 跳过,用户发张图后机器人仍然静默 —— 体验上就是"它还没恢复",
2461
+ // 而积压的通知还要再等下一次带文本的消息才能补发。
2462
+ const ilink = await fakeIlink();
2463
+ try {
2464
+ const sub = makeFakeSubscriber();
2465
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2466
+ const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2467
+ // 先进入静默(等价于一次 prepare failed),并留一条待补发通知
2468
+ rt.channel.writeState({
2469
+ pending_outbox: [{ text: "一条待在补发的通知", kind: "stopped", at: Date.now() }],
2470
+ push_paused: { at: Date.now(), reason: "sendMessage: ret=-2 prepare failed" }
2471
+ });
2472
+ assert.equal(rt.status().push_paused, true, "前提:通道处于静默");
2473
+
2474
+ // 图片消息:**没有文本**、带新 context_token
2475
+ await rt.handleInbound({
2476
+ message_id: 7101,
2477
+ from_user_id: "u",
2478
+ context_token: "CT-image-1",
2479
+ item_list: [{ type: 2, image_item: { url: "https://example.invalid/x.jpg" } }]
2480
+ });
2481
+ assert.equal(rt.status().push_paused, false,
2482
+ "★ 图片入站同样要解除静默(不能被 text 为空的早退跳过)");
2483
+ assert.ok(rt.status().context_token_at > 0, "★ 令牌也要刷新(context_token_at 落盘)");
2484
+ assert.equal(sub.calls.length, 0, "没有文本就不该派活");
2485
+
2486
+ await waitFor(() => allTexts(ilink.state).filter((t) => t.startsWith("(补发)")).length === 1);
2487
+ const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
2488
+ assert.equal(resent.length, 1, "★ 窗口刚打开,积压要立刻补发(不能等下一次带文本的消息)");
2489
+ const last = ilink.state.sends.at(-1);
2490
+ assert.equal(last.msg.context_token, "CT-image-1",
2491
+ "补发必须回带入站带来的新令牌(否则还是 prepare failed)");
2492
+ } finally {
2493
+ await ilink.close();
2494
+ }
2495
+ });
2496
+
2497
+ test("★★ 补发单飞:入站 fire-and-forget 与推送路径并发触发时,同一批通知只发一遍", async () => {
2498
+ // verifier 在修复前的 HEAD 上复现过「同一批被发 6 遍」:入站补发与主动推送各自读到
2499
+ // 同一份队列、各发一遍。修复靠 #flushOutbox 的**单飞锁**(outboxFlushing):
2500
+ // 并发调用直接返回 0,由已在跑的那次把队列处理掉。这里用并发触发把它锁死。
2501
+ const ilink = await fakeIlink();
2502
+ try {
2503
+ const sub = makeFakeSubscriber();
2504
+ sub.sessionsFixture = [{ sessionId: "session-x", title: "跑完的活", running: false, cwd: "/p" }];
2505
+ sub.summaryFixture = "做完了。";
2506
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2507
+ const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2508
+ const at = Date.now();
2509
+ rt.channel.writeState({
2510
+ pending_outbox: [
2511
+ { text: "积压通知A", kind: "stopped", at },
2512
+ { text: "积压通知B", kind: "stopped", at },
2513
+ { text: "积压通知C", kind: "stopped", at }
2514
+ ]
2515
+ });
2516
+
2517
+ // 同时触发:3 条入站(每条都 fire-and-forget 补发)+ 2 条主动推送(内部也会先补发)
2518
+ await Promise.all([
2519
+ rt.handleInbound(inbound(9201, "/status")),
2520
+ rt.handleInbound(inbound(9202, "/status")),
2521
+ rt.handleInbound(inbound(9203, "/status")),
2522
+ rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", at: 1 }),
2523
+ rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", at: 2 })
2524
+ ]);
2525
+ await waitFor(() => (stateOf().pending_outbox || []).length === 0);
2526
+ await settle();
2527
+
2528
+ const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
2529
+ assert.equal(resent.length, 3,
2530
+ `★ 同一批只能补发一遍(2 条 + 1 条汇总),实际 ${resent.length} 条(并发没锁住就会翻倍)`);
2531
+ assert.equal(new Set(resent).size, resent.length, "★ 补发内容不得重复(同一条通知只能出现一次)");
2532
+ const detail = resent.filter((t) => !/另有 \d+ 条/.test(t));
2533
+ assert.equal(detail.length, 2, "逐条补的只能是最新 2 条");
2534
+ assert.equal((stateOf().pending_outbox || []).length, 0, "并发跑完后队列必须是空的");
2535
+ } finally {
2536
+ await ilink.close();
2537
+ }
2538
+ });
2539
+
2540
+ test("★★ 补发失败的保留语义:older 不丢、已发出的 target 不重放(Lead 复审抓到的 bug)", async () => {
2541
+ // 旧写法 `writeOutbox(list.slice(sent))` 在 list=[o1,o2,o3,t1,t2]、t1 成功 t2 失败时
2542
+ // 会写出 [o2,o3,t1,t2] —— o1 被静默丢掉(既没发也没进汇总)、t1 被放回队列下次**再发一遍**。
2543
+ // 正确语义:**全部 older + 尚未送出的 target** = [o1,o2,o3,t2]。
2544
+ const ilink = await fakeIlink();
2545
+ try {
2546
+ const sub = makeFakeSubscriber();
2547
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
2548
+ const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
2549
+ const at = Date.now();
2550
+ rt.channel.writeState({
2551
+ pending_outbox: ["o1", "o2", "o3", "t1", "t2"].map((text) => ({ text, kind: "stopped", at }))
2552
+ });
2553
+
2554
+ // 只让 t2 发失败(模拟还在抖动的网络);t1 必须成功
2555
+ const sentTexts = [];
2556
+ rt.channel.client.sendMessage = async ({ text }) => {
2557
+ sentTexts.push(text);
2558
+ if (text === "(补发)t2") { const e = new Error("网络抖动"); e.code = "network"; throw e; }
2559
+ return { ret: 0, message_id: "ok" };
2560
+ };
2561
+ const warns = [];
2562
+ rt.logger = { info() {}, warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
2563
+
2564
+ // 图片消息(无文本 → 只走补发,不派活/不回执,断言不受别的发送干扰)
2565
+ await rt.handleInbound({
2566
+ message_id: 9301, from_user_id: "u", item_list: [{ type: 2, image_item: { url: "https://example.invalid/x.jpg" } }]
2567
+ });
2568
+ await waitFor(() => (stateOf().pending_outbox || []).length === 4);
2569
+ await settle();
2570
+
2571
+ const rest = (stateOf().pending_outbox || []).map((q) => String(q.text));
2572
+ assert.deepEqual(rest, ["o1", "o2", "o3", "t2"],
2573
+ `★ 失败后队列必须是「全部 older + 未送出的 target」,实际 ${JSON.stringify(rest)}`);
2574
+ assert.ok(rest.includes("o1"), "★ 最早的 old 条目绝不能被静默丢掉(既没发、也没进汇总)");
2575
+ assert.ok(!rest.includes("t1"), "★ 已经发出去的 t1 绝不能被放回队列(否则用户收到重复)");
2576
+ assert.equal(sentTexts.filter((t) => t.startsWith("(补发)")).length, 2, "只尝试了 target 的两条");
2577
+ assert.ok(!sentTexts.some((t) => /另有 \d+ 条/.test(t)), "失败时不得发省略汇总(汇总同样发不出去)");
2578
+ assert.ok(warns.some((m) => /补发中断:已送出 1 条,剩 4 条/.test(m)),
2579
+ `中断必须如实记日志,实际:${JSON.stringify(warns)}`);
2580
+ } finally {
2581
+ await ilink.close();
2582
+ }
2583
+ });
2584
+
2585
+ // ── /status 的静默说明(Lead 2026-10-29 复审:真实入站会先清静默,必须靠快照)──
2586
+
2587
+ test("★ /status:入站前刚静默过(这条消息已恢复)必须说明「刚才静默过」,而不是只字不提", async () => {
2588
+ // 用户最可能的诊断动作就是发 /status 问"怎么不推送了"。而入站消息本身会清掉 push_paused,
2589
+ // 若不在 rememberContextToken 之前快照,这个提示在真实入站路径下**永远不会出现**。
2590
+ const ilink = await fakeIlink();
2591
+ try {
2592
+ const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber() });
2593
+ rt.channel.invalidateContextToken("sendMessage: ret=-2 prepare failed");
2594
+ assert.equal(rt.status().push_paused, true, "前提:入站前通道处于静默");
2595
+
2596
+ // 带令牌的 /status —— 入站当场解除静默
2597
+ await rt.handleInbound(inbound(8301, "/status", { context_token: "CT-recover-1" }));
2598
+ assert.equal(rt.status().push_paused, false, "入站后静默已解除");
2599
+ const text = lastText(ilink.state);
2600
+ assert.match(text, /刚才推送静默过/, `★ 必须告知"刚才静默过"(靠 lastSilence 快照),实际:${text}`);
2601
+ assert.match(text, /你这条消息已恢复推送/, "要说清是这条消息恢复的,不要含糊");
2602
+ assert.match(text, /未送达的通知会补发/, "并说明积压会补发");
2603
+ assert.ok(!/推送已静默:/.test(text), "既然已经恢复,就不该再用「当前静默」的说法");
2604
+ } finally {
2605
+ await ilink.close();
2606
+ }
2607
+ });
2608
+
2609
+ test("★ /status:从未静默过时不出现任何静默字样(不能拿不成立的话吓用户)", async () => {
2610
+ const ilink = await fakeIlink();
2611
+ try {
2612
+ const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber() });
2613
+ assert.equal(rt.lastSilence, null, "前提:从未静默过");
2614
+ await rt.handleInbound(inbound(8302, "/status"));
2615
+ const text = lastText(ilink.state);
2616
+ assert.ok(!/推送已静默|刚才推送静默过/.test(text), `★ 没静默过就不许提,实际:${text}`);
2617
+ assert.match(text, /DSH 微信通道状态/, "正常状态文案照旧");
2618
+ } finally {
2619
+ await ilink.close();
2620
+ }
2621
+ });
2622
+
2623
+ test("★ /status:静默快照只在最近 10 分钟内有效(再久就不算「刚才」)", async () => {
2624
+ const ilink = await fakeIlink();
2625
+ try {
2626
+ let now = Date.now();
2627
+ const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), clock: () => now });
2628
+ rt.channel.invalidateContextToken("sendMessage: ret=-2 prepare failed");
2629
+ await rt.handleInbound(inbound(8303, "/status", { context_token: "CT-recover-2" }));
2630
+ assert.match(lastText(ilink.state), /刚才推送静默过/, "前提:刚静默过 → 提示出现");
2631
+
2632
+ now += 11 * 60_000; // 过了 11 分钟
2633
+ await rt.handleInbound(inbound(8304, "/status"));
2634
+ const text = lastText(ilink.state);
2635
+ assert.ok(!/刚才推送静默过/.test(text), `★ 超过 10 分钟就不该再说「刚才」,实际:${text}`);
2636
+ } finally {
2637
+ await ilink.close();
2638
+ }
2639
+ });