@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
@@ -39,6 +39,7 @@ import {
39
39
  extractDigits,
40
40
  extractInboundText,
41
41
  extractFromUserId,
42
+ extractContextToken,
42
43
  handleCommand,
43
44
  classifyInbound,
44
45
  formatCompletion,
@@ -197,6 +198,14 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
197
198
  */
198
199
  const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
199
200
 
201
+ /**
202
+ * 主动推送失败后的**待补发队列**上限(见 `#pushProactive`)。
203
+ * 为什么是 20:够放下"一轮里所有该告诉用户的事";再多下去,补发就变成刷屏了。
204
+ */
205
+ const MAX_OUTBOX = 20;
206
+ /** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
207
+ const OUTBOX_MARK = "(补发)";
208
+
200
209
  /**
201
210
  * 把事件节点翻译成文案节点。
202
211
  * @returns {{node: object|null, formatterKind: string}}
@@ -479,7 +488,21 @@ export class WeChatRuntime {
479
488
  ? { active: true, need_verify_code: !!this.bind.needVerifyCode }
480
489
  : { active: false, failed: !!(this.bind && this.bind.done && !this.bind.result) },
481
490
  channel_running: !!this.channelTask,
482
- events_running: !!this.subscriber
491
+ events_running: !!this.subscriber,
492
+ // ── 诊断字段(2026-09-25 事故加的)────────────────────────────────────
493
+ // 那次「任务跑完一条微信都没收到,日志里也没有任何报错」暴露了三件必须能看见的事:
494
+ // ① 事件订阅到底 ready 了没、**正在 follow 哪些会话**(follow 集为空 = turn/end 收不到);
495
+ // ② 有没有排进待补发队列的通知(推送失败不再等于消失);
496
+ // ③ 最近一次订阅异常是什么(以前 fault 只 emit、没人接,全静默)。
497
+ events_state: this.subscriber && typeof this.subscriber.state === "string" ? this.subscriber.state : "",
498
+ followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
499
+ ? this.subscriber.sessions()
500
+ : [],
501
+ pending_outbox: Array.isArray(state.pending_outbox) ? state.pending_outbox.length : 0,
502
+ last_fault: state.last_fault || null,
503
+ last_completed_session_id: state.last_completed_session_id || "",
504
+ last_completed_at: Number(state.last_completed_at || 0),
505
+ inbound_shape_null_token: state.inbound_shape || ""
483
506
  };
484
507
  }
485
508
 
@@ -746,10 +769,23 @@ export class WeChatRuntime {
746
769
  async handleInbound(msg) {
747
770
  const from = extractFromUserId(msg);
748
771
  const text = normalizeInput(extractInboundText(msg));
772
+ // ★ 每条入站消息都带上 `context_token`,而**出站必须回带它**才发得出去
773
+ // (不进它 → 真机 `ret=-2 errmsg=prepare failed`,业主「没收到微信通道」)。
774
+ // 放在 `if (!text) return` **之前**:图片/语音这类没有文本的消息同样会刷新上下文。
775
+ // 记不住不影响主流程(最坏就是这一条发不出去,与以前一样)。
776
+ try { this.channel.rememberContextToken(extractContextToken(msg), from); } catch { /* 忽略 */ }
777
+ // ★ 0.6.15:收到入站消息 = 平台侧这条会话刚刚"活过来",是补发积压通知的最好时机。
778
+ // fire-and-forget:补发失败不能影响用户这条消息的处理。
779
+ void this.#flushOutbox(from).catch(() => 0);
749
780
  if (!text) return;
750
781
 
751
782
  // 内部统计:任何入站互动都算一次"窗口续期"事件
752
783
  this.#bumpToday();
784
+ // ★ 0.6.15:`context_token` 的形状一直没被验证过(§11)。真机日志里
785
+ // `ret=-2 prepare failed` 出现过 48 次,而状态文件里**从来没有** context_token ——
786
+ // 也就是说入站消息里没带它(或字段名和我们猜的不一样)。这里把**字段名**记一次
787
+ // (只有名字,没有值),下次再出这个问题就能一眼看出该取哪个键,不用再猜。
788
+ this.#noteInboundShapeIfNoToken(msg);
753
789
 
754
790
  // ── v2 分派:命令 → 数字 → 普通消息 ────────────────────────────────────
755
791
  // ⚠️ 顺序不能换:
@@ -1082,7 +1118,59 @@ export class WeChatRuntime {
1082
1118
  #rememberSession(sessionId, title) {
1083
1119
  this.currentSessionId = sessionId || "";
1084
1120
  this.currentSessionTitle = title || "";
1085
- this.channel.writeState({ current_session_id: this.currentSessionId, current_session_title: this.currentSessionTitle });
1121
+ this.channel.writeState({
1122
+ current_session_id: this.currentSessionId,
1123
+ current_session_title: this.currentSessionTitle,
1124
+ // 「回复目标是什么时候定下来的」——回话前对表要用(见 #retargetToLatestCompletion)。
1125
+ current_session_set_at: this.clock(),
1126
+ });
1127
+ }
1128
+
1129
+ /**
1130
+ * 记下"最近完成的任务"(**无论那条完成推送有没有发出去**)。
1131
+ *
1132
+ * 为什么必须有它:`#rememberSession` 只在推送真的发出去了才会被调用 —— 一旦推送失败
1133
+ * (微信侧 ret=-2)或者订阅漏了 turn/end,当前会话指针就**停在旧会话上**,用户回话会发进
1134
+ * 那个旧会话(业主 2026-09-25 实测:「刚才我回了个话,而且回到了另外一个对话里面去了」)。
1135
+ * 有了这个记录,回话前就能对一次表(见 `#retargetToLatestCompletion`)。
1136
+ */
1137
+ #rememberCompletion(sessionId, title) {
1138
+ if (!sessionId) return;
1139
+ this.channel.writeState({
1140
+ last_completed_session_id: String(sessionId),
1141
+ last_completed_session_title: String(title || ""),
1142
+ last_completed_at: this.clock(),
1143
+ });
1144
+ }
1145
+
1146
+ /**
1147
+ * 回话前的"对表":当前会话指针是不是**落后于**最近一次完成?
1148
+ *
1149
+ * 判据严格且保守:只有「完成时间**晚于**我们最后一次把回复目标定下来的时间」才切换。
1150
+ * 绝不因为"还有个更活跃的会话"就改主意 —— 那会把用户正在聊的会话顶掉。
1151
+ * @returns {boolean} 是否切换了
1152
+ */
1153
+ async #retargetToLatestCompletion() {
1154
+ try {
1155
+ const st = loadState(this.relayDir);
1156
+ const sid = String(st.last_completed_session_id || "");
1157
+ if (!sid || sid === this.currentSessionId) return false;
1158
+ const doneAt = Number(st.last_completed_at || 0);
1159
+ const setAt = Number(st.current_session_set_at || 0);
1160
+ if (!doneAt || doneAt <= setAt) return false;
1161
+ let title = String(st.last_completed_session_title || "");
1162
+ if (!title) {
1163
+ // 静音路径只记了 id(没解析标题)→ 这里补一次,让确认文案里能出现人话的会话名
1164
+ try {
1165
+ const list = await this.#sessions();
1166
+ const hit = list ? list.find((s) => s.sessionId === sid) : null;
1167
+ if (hit && hit.title) title = hit.title;
1168
+ } catch { /* 取不到就用空标题 */ }
1169
+ }
1170
+ this.#rememberSession(sid, title);
1171
+ this.logger.info(`[wechat] 回话前对表:当前会话落后于最近完成的任务 ${sid},已自动切过去`);
1172
+ return true;
1173
+ } catch { return false; }
1086
1174
  }
1087
1175
 
1088
1176
  /** 拉一次会话列表(带标题),并记住 1-based 序号供 /use 使用。 */
@@ -1249,6 +1337,9 @@ export class WeChatRuntime {
1249
1337
  await this.reply(from, "暂不可用:DSH 会话服务未就绪。");
1250
1338
  return;
1251
1339
  }
1340
+ // ★ 0.6.15:回话前先对一次表 —— 完成推送**没发出去**时(平台 ret=-2 / 订阅漏了 turn/end),
1341
+ // 当前会话指针会停在旧会话上,这一句"再改一下"就会发给那个旧会话。
1342
+ const switched = await this.#retargetToLatestCompletion();
1252
1343
  // 每月消息额度闸(放在真正派活之前;审批/指令不计数也不拦)
1253
1344
  const gate = this.#msgQuotaGate();
1254
1345
  if (!gate.ok) {
@@ -1258,7 +1349,10 @@ export class WeChatRuntime {
1258
1349
  const r = await this.subscriber.promptSession({ sessionId: this.currentSessionId, text: body });
1259
1350
  if (r && r.ok) {
1260
1351
  this.#msgQuotaBump(); // 只在真的派出去之后扣额度(发失败不该扣)
1261
- await this.reply(from, `已补充给「${this.currentSessionTitle || "当前任务"}」,跑完推结论给你。`);
1352
+ // 「最后一次把回复目标定下来」的时间:下一次对表要看它,避免把用户正在聊的会话顶掉。
1353
+ this.channel.writeState({ current_session_set_at: this.clock() });
1354
+ const note = switched ? "(刚跑完的那个任务已自动切为回复对象)" : "";
1355
+ await this.reply(from, `已补充给「${this.currentSessionTitle || "当前任务"}」,跑完推结论给你。${note}`);
1262
1356
  // ★ 额度消耗**之后**才可能提醒(顺序不能反:提醒要看的是扣完之后还剩几条);
1263
1357
  // fire-and-forget,提醒失败不影响"已下发"这条主流程的结果。
1264
1358
  this.#maybeRemindQuota().catch(() => {});
@@ -1360,11 +1454,118 @@ export class WeChatRuntime {
1360
1454
  }
1361
1455
  }
1362
1456
 
1457
+ /**
1458
+ * 入站消息里到底有没有 `context_token`?没有的话,把**字段名**记一次(值一律不记)。
1459
+ *
1460
+ * 为什么需要:出站要回带 `context_token`,而没人验证过它的真实形状(§11)。
1461
+ * 真机证据(2026-09-25):bridge 日志里 `ret=-2 errmsg=prepare failed` 出现 48 次,
1462
+ * 而状态文件里**从来没有** context_token —— 说明入站消息里没带它,或者它在别的键上。
1463
+ * 猜字段名没有意义,让真机告诉我们:只记名字(不含任何内容/凭据),一次就够。
1464
+ */
1465
+ #noteInboundShapeIfNoToken(msg) {
1466
+ try {
1467
+ if (extractContextToken(msg)) return; // 有令牌就没什么可记的
1468
+ const shape = [];
1469
+ const walk = (obj, prefix, depth) => {
1470
+ if (!obj || typeof obj !== "object" || depth > 2 || shape.length >= 40) return;
1471
+ for (const k of Object.keys(obj)) {
1472
+ if (shape.length >= 40) return;
1473
+ const path = prefix ? `${prefix}.${k}` : k;
1474
+ shape.push(path);
1475
+ const v = obj[k];
1476
+ if (v && typeof v === "object" && !Array.isArray(v)) walk(v, path, depth + 1);
1477
+ }
1478
+ };
1479
+ walk(msg, "", 0);
1480
+ const joined = shape.join(",");
1481
+ const st = loadState(this.relayDir);
1482
+ if (String(st.inbound_shape || "") === joined) return; // 同形状只记一次
1483
+ this.channel.writeState({ inbound_shape: joined });
1484
+ this.logger.info(`[wechat] 入站消息里没有 context_token;字段名(不含值):${joined}`);
1485
+ } catch { /* 诊断失败不影响主流程 */ }
1486
+ }
1487
+
1363
1488
  #noteFailure(text) {
1364
1489
  this.channel.writeState({ last_error: String(text).slice(0, 200) });
1365
1490
  this.logger.warn(`[wechat] ${text}`);
1366
1491
  }
1367
1492
 
1493
+ // ── 待补发队列(主动推送失败不再静默丢失) ──────────────────────────────
1494
+ //
1495
+ // 【2026-09-25 事故】业主的任务跑完后**一条微信都没收到**,而日志里躺着 48 条
1496
+ // `sendmessage 失败:sendMessage: ret=-2 errmsg=prepare failed` —— 平台侧发送失败是
1497
+ // **间歇性**的(实测同一台机器上有的消息发得出去、有的发不出去),而旧实现失败即丢弃:
1498
+ // 用户永远不知道那条"任务跑完了"存在过。
1499
+ //
1500
+ // 做法:主动推送(通知类)失败就**入队**,在下一次成功发送/成功入站时按 FIFO 补发。
1501
+ // 补发时正文前加「(补发)」——迟到就要如实说,不能让用户以为刚刚才发生。
1502
+ // ⚠️ 队列落盘且**只存正文**(不含 token/eventId):状态文件是面板可读的,
1503
+ // 凭据一律不进(§8)。
1504
+ #outbox() {
1505
+ try {
1506
+ const st = loadState(this.relayDir);
1507
+ return Array.isArray(st.pending_outbox) ? st.pending_outbox : [];
1508
+ } catch { return []; }
1509
+ }
1510
+
1511
+ #writeOutbox(items) {
1512
+ const list = Array.isArray(items) ? items : [];
1513
+ this.channel.writeState({ pending_outbox: list, pending_outbox_count: list.length });
1514
+ return list;
1515
+ }
1516
+
1517
+ #enqueueOutbox(text, kind = "") {
1518
+ const body = String(text || "");
1519
+ if (!body) return 0;
1520
+ const list = this.#outbox();
1521
+ list.push({ text: body, kind: String(kind || ""), at: this.clock() });
1522
+ // 上限:丢最旧的(用户最需要的是"最近发生了什么",而无限攒下去只会让补发像刷屏)
1523
+ while (list.length > MAX_OUTBOX) list.shift();
1524
+ return this.#writeOutbox(list).length;
1525
+ }
1526
+
1527
+ /**
1528
+ * 把待补发队列按 FIFO 发出去(碰到第一条失败就停,保留剩下的下次再试)。
1529
+ * @returns {Promise<number>} 这次补发成功的条数
1530
+ */
1531
+ async #flushOutbox(to) {
1532
+ const list = this.#outbox();
1533
+ if (!list.length || !to) return 0;
1534
+ let sent = 0;
1535
+ const rest = [...list];
1536
+ while (rest.length) {
1537
+ const item = rest[0];
1538
+ const ok = await this.reply(to, `${OUTBOX_MARK}${String(item && item.text ? item.text : "")}`);
1539
+ if (!ok) break; // 还是发不出去:原样留着,下次再试
1540
+ rest.shift();
1541
+ sent += 1;
1542
+ }
1543
+ if (sent > 0) {
1544
+ this.#writeOutbox(rest);
1545
+ this.logger.info(`[wechat] 待补发队列已送出 ${sent} 条(剩 ${rest.length} 条)`);
1546
+ }
1547
+ return sent;
1548
+ }
1549
+
1550
+ /**
1551
+ * 主动推送(通知类):先补发积压,再发这一条;发不出去就入队,绝不丢。
1552
+ * @returns {Promise<{sent:boolean, queued:boolean}>} queued=true 表示"已排进待补发",
1553
+ * 调用方据此仍可登记待拍板项(用户稍后看到时还能回执)。
1554
+ */
1555
+ async #pushProactive(to, text, kind = "", { queue = true } = {}) {
1556
+ const body = String(text || "");
1557
+ if (!body) return { sent: false, queued: false };
1558
+ await this.#flushOutbox(to).catch(() => 0);
1559
+ const ok = await this.reply(to, body);
1560
+ if (ok) return { sent: true, queued: false };
1561
+ // queue=false:**自己有重试/占位语义**的通知(日报)绝不能排队 ——
1562
+ // 排队 + 它自己的下一次 tick 补发 = 用户收到两条,而且"当天已发"的占位账也会算错。
1563
+ if (!queue) return { sent: false, queued: false };
1564
+ const depth = this.#enqueueOutbox(body, kind);
1565
+ this.logger.warn(`[wechat] 主动推送失败(${kind || "?"}):已排进待补发队列(${depth} 条),下一条能发出去时会补上`);
1566
+ return { sent: false, queued: true };
1567
+ }
1568
+
1368
1569
  // ── 出站:DSH 事件 → 微信通知 ───────────────────────────────────────────
1369
1570
 
1370
1571
  #startSubscriber() {
@@ -1401,6 +1602,20 @@ export class WeChatRuntime {
1401
1602
  sub.on("auth-error", (info) => {
1402
1603
  this.logger.warn(`[wechat/events] 鉴权失败(${info && info.surface}):需要刷新 harness cookie`);
1403
1604
  });
1605
+ sub.on("fault", (info) => {
1606
+ // ★ 2026-09-25:订阅层的 fault **以前完全没人接**(`#emitFault` 只 emit、不 log),
1607
+ // 于是「follow 失败 / 发现失败 / 流上限」全都**静默消失** —— 用户看到的是
1608
+ // 「任务跑完没有任何推送,日志里也没有任何报错」,事后只能靠数日志行数考古。
1609
+ // 现在:一条 warn 落进 bridge 日志(带时间戳),并把**非预期**的那条记进状态文件供面板显示。
1610
+ // `expected: true`(子代理会话必然被拒 / 会话刚被删)只记日志,不当故障刷状态。
1611
+ const code = String((info && info.code) || "unknown");
1612
+ const message = String((info && info.message) || "").slice(0, 160);
1613
+ const sessionId = String((info && info.sessionId) || "");
1614
+ this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
1615
+ if (!(info && info.expected === true)) {
1616
+ this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
1617
+ }
1618
+ });
1404
1619
  }
1405
1620
 
1406
1621
  async #tellGap() {
@@ -1414,7 +1629,7 @@ export class WeChatRuntime {
1414
1629
  * 把一条**事件**节点发到微信。可回执的登记进待答队列。
1415
1630
  * 公开方法:bridge 与测试都直接调用它(不叫 #notify 是因为它是本模块的主要出口之一)。
1416
1631
  */
1417
- async notify(eventNode) {
1632
+ async notify(eventNode, opts = {}) {
1418
1633
  if (!eventNode || !this.channel.account) return { ok: false, reason: "not_bound" };
1419
1634
  const acct = this.channel.account;
1420
1635
 
@@ -1427,7 +1642,15 @@ export class WeChatRuntime {
1427
1642
  NODE_KINDS.PLAN_REVIEW,
1428
1643
  NODE_KINDS.SESSION_ERROR
1429
1644
  ].includes(eventNode.kind);
1430
- if (!important) return { ok: false, reason: "quiet" };
1645
+ if (!important) {
1646
+ // ★ 0.6.15:静音也要**记账**。完成推送被静音时,这条"最近完成的任务"仍然必须被记下来 ——
1647
+ // 否则用户下一秒回话就会发进上一次的会话(业主实测的那个"回到另一个对话")。
1648
+ // 静音只表示"不打扰",不表示"当它没发生过"。
1649
+ if (eventNode.kind === NODE_KINDS.TURN_END && eventNode.sessionId) {
1650
+ this.#rememberCompletion(eventNode.sessionId, "");
1651
+ }
1652
+ return { ok: false, reason: "quiet" };
1653
+ }
1431
1654
  }
1432
1655
 
1433
1656
  // 没有文案模板的节点:按设计另行处理,不是漏接线
@@ -1465,10 +1688,22 @@ export class WeChatRuntime {
1465
1688
  const list = await this.#sessions();
1466
1689
  const hit = list ? list.find((s) => s.sessionId === sid) : null;
1467
1690
  if (hit && hit.title) title = hit.title;
1468
- // 学到标题就记住,后面 /status 与 /ls 都能直接用
1469
- if (hit && sid === this.currentSessionId && title !== this.currentSessionTitle) {
1470
- this.#rememberSession(sid, title);
1471
- }
1691
+ }
1692
+ // ★ 0.6.15 修(业主实测):**完成推送要把"当前会话"切到刚跑完的那个**。
1693
+ // 旧行为只在 sid 已经是当前会话时才更新标题,于是:B 会话跑完推了结论,用户直接在微信里
1694
+ // 回一句"再改一下",那条消息却被 `#sendToSession` 发给了**上一次的当前会话 A** ——
1695
+ // A 莫名其妙多了一条指令,B 永远收不到。用户看到的正是"推送过来了,我准备回话,
1696
+ // 它还停留在我之前的会话里"。
1697
+ // 语义:**最近完成的任务 = 最近一次推送的对象 = 你现在回话的对象**。这是唯一不会让人踩空的解释。
1698
+ //
1699
+ // ★ 0.6.15 补:**先无条件记下"最近完成的任务"**(与推送成败无关)。
1700
+ // 推送失败(微信 ret=-2)或订阅漏了 turn/end 时,这一步是回话前对表的唯一依据,
1701
+ // 否则当前会话指针会停在旧会话上,用户回话直接发进旧会话(业主实测的第二个症状)。
1702
+ this.#rememberCompletion(sid, title);
1703
+ if (sid && sid !== this.currentSessionId) {
1704
+ this.#rememberSession(sid, title);
1705
+ } else if (sid && title && title !== this.currentSessionTitle) {
1706
+ this.#rememberSession(sid, title); // 学到标题就记住,后面 /status 与 /ls 都能直接用
1472
1707
  }
1473
1708
  const summary = sid ? await this.#sessionSummary(sid) : "";
1474
1709
  const c = formatCompletion({
@@ -1524,7 +1759,15 @@ export class WeChatRuntime {
1524
1759
  ? `${built.text}\n\n(你还有 ${othersWaiting + 1} 条待拍板,回完这条我会把下一条发到下面)`
1525
1760
  : built.text;
1526
1761
 
1527
- const sent = await this.reply(acct.userId, outgoing);
1762
+ // ★ 0.6.15:走 `#pushProactive`(先补发积压 → 再发这条 → 发不出去就入队),
1763
+ // 不再直接 `reply()`。旧实现失败即丢弃 —— 平台的发送失败是间歇性的,用户于是
1764
+ // "任务跑完了却一条都没收到",而且谁也不知道那条通知存在过。
1765
+ // 日报(`daily`)自带重试与"当天已发"占位:它一旦进了补发队列就会与下一次 tick 撞成两条,
1766
+ // 也会把占位账算错 —— 这类通知**明确不排队**(其余通知都排队,失败不再等于消失)。
1767
+ const queueFailedPush = opts.queueFailedPush !== false && formatterKind !== "daily";
1768
+ const pushed = await this.#pushProactive(acct.userId, outgoing, formatterKind, { queue: queueFailedPush });
1769
+ // 入队也算"已经安排了送达":用户稍后看到补发时,回执必须仍然认得出这条待办。
1770
+ const sent = pushed.sent || pushed.queued;
1528
1771
  if (sent && built.replyable && built.eventId) {
1529
1772
  this.pendingReplies.push({
1530
1773
  eventId: built.eventId,
@@ -170,6 +170,7 @@ function printHelp() {
170
170
  dsh-remote status 查看配置与服务状态
171
171
  dsh-remote plugin 手动安装 dsh web 远程访问插件(--uninstall 卸载)
172
172
  dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
173
+ dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
173
174
  dsh-remote --help 显示本用法(等同 dsh-remote help)
174
175
 
175
176
  自建模式(可选):
@@ -202,7 +203,11 @@ const KNOWN_FLAGS = new Set([
202
203
  process.exit(1);
203
204
  }
204
205
  }
205
- try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
206
+ // `doctor` 承诺"只读":它连运行时脚本同步都不做 —— 否则"自检"会先往用户磁盘写 9 个文件,
207
+ // 与承诺不符(也会把"我什么都没改却多了文件"变成新的困惑)。
208
+ if (process.argv[2] !== "doctor") {
209
+ try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
210
+ }
206
211
 
207
212
  /** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
208
213
  function runtimeSetupPath() {
@@ -764,12 +769,32 @@ function restartBridgeService() {
764
769
  return startBridgeDarwin(plistPath);
765
770
  }
766
771
  if (process.platform === "linux") {
767
- const r = sh(`systemctl --user restart dsh-bridge`);
768
- if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || "systemctl restart 失败" };
769
- const a = sh(`systemctl --user is-active dsh-bridge`);
770
- return a.ok && a.stdout.trim() === "active"
771
- ? { ok: true, status: "running", pid: null }
772
- : { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
772
+ // 【0.6.15】飞牛 fnOS / 群晖这类 NAS 上**没有 user systemd 会话**(插件跑在系统服务里,
773
+ // 没有 XDG_RUNTIME_DIR)→ `systemctl --user` 只会报 "Failed to connect to bus",
774
+ // 于是这一支原本必然失败、且没有任何兜底。现在:有会话就用 systemd(可自愈),
775
+ // 没有就退到脱离进程(spawnDetachedBridge,与 macOS 的兜底同一条路径)。
776
+ const hasUserSystemd = Boolean(process.env.XDG_RUNTIME_DIR) && sh("systemctl --user show-environment").ok;
777
+ if (hasUserSystemd) {
778
+ const r = sh(`systemctl --user restart dsh-bridge`);
779
+ if (r.ok) {
780
+ const a = sh(`systemctl --user is-active dsh-bridge`);
781
+ if (a.ok && a.stdout.trim() === "active") return { ok: true, status: "running", pid: null };
782
+ return { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
783
+ }
784
+ // 有会话却重启失败:继续往下走脱离进程兜底,绝不留一个"重启失败但没有任何进程"的空档
785
+ }
786
+ const d = spawnDetachedBridge();
787
+ if (d.ok) {
788
+ return {
789
+ ok: true,
790
+ status: "degraded-detached",
791
+ pid: d.pid,
792
+ detail: hasUserSystemd
793
+ ? "systemd --user 重启失败,已改为后台进程运行(现在可用;systemd 恢复后下一次重启会回到托管)"
794
+ : "本机没有 user systemd 会话(NAS/容器常见),已改为后台进程运行(现在可用,但不会开机自启)",
795
+ };
796
+ }
797
+ return { ok: false, status: "failed", detail: d.error || "systemd 与后台进程均启动失败" };
773
798
  }
774
799
  return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
775
800
  }
@@ -2332,6 +2357,134 @@ async function pluginCmd(argv) {
2332
2357
  * resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
2333
2358
  * client = dsh.client.platform 声明在不在
2334
2359
  */
2360
+ /**
2361
+ * `dsh-remote doctor` —— **一条命令回答「为什么连不上」**(只读,不改任何东西)。
2362
+ *
2363
+ * 【为什么产品上必须有它】2026-09-25 排查两条用户反馈(fb_7b4ee6ec9862 / fb_4cc2c9df749c)时,
2364
+ * 判断"bridge 到底有没有被拉起来"花了大量时间,而且**只能靠翻日志行数估时间** ——
2365
+ * 因为当时的日志连时间戳都没有,用户端也没有任何一个入口能把
2366
+ * 「上游端口 / 守护状态 / 事件订阅 / 待补发队列」一次性说清楚。
2367
+ * 那两个用户都是 linux + 「starting / bridge 未运行」,而我们的自愈当时在 Linux 上**根本没有实现**
2368
+ * (见 0.6.15 的 startBridgeLinux):如果有一份这样的自检,用户自己 10 秒就能看出问题在哪。
2369
+ *
2370
+ * 输出四段:① 身份与版本 ② 上游(含来源与探测结论) ③ 进程与自启动 ④ 通道(微信/加密/待补发)
2371
+ * ⑤ 日志尾部(带时间戳)。任何一项失败都只影响那一行,绝不中断整份自检。
2372
+ *
2373
+ * @param {string[]} argv 额外参数(--profile <dir> 透传给插件挂载诊断)
2374
+ */
2375
+ async function doctorCmd(argv = []) {
2376
+ const mask = (s) => (s ? String(s).slice(0, 3) + "****" + String(s).slice(-2) : "");
2377
+ const ms = (t) => (t ? new Date(Number(t)).toISOString().replace("T", " ").slice(0, 19) : "-");
2378
+ const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } };
2379
+ const readText = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return ""; } };
2380
+ const alive = (pid) => {
2381
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2382
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
2383
+ };
2384
+
2385
+ console.log("dsh-remote 自检(doctor,只读,不改任何东西)");
2386
+ console.log("时间:", new Date().toISOString(), "| 平台:", `${process.platform}/${process.arch}`, "| node:", process.version);
2387
+ console.log("配置目录:", CONFIG_DIR);
2388
+
2389
+ // ── ① 插件与运行环境版本 ────────────────────────────────────────────────
2390
+ const pluginPkg = readJson(path.join(CONFIG_DIR, "node_modules", PLUGIN_ID, "package.json"))
2391
+ || readJson(new URL("./packages/dsh-remote-web/package.json", import.meta.url));
2392
+ const setupVersion = readText(path.join(CONFIG_DIR, ".dsh-setup-version")).trim();
2393
+ console.log("\n① 版本");
2394
+ console.log(` 插件 : ${pluginPkg && pluginPkg.version ? "v" + pluginPkg.version : "(读不到)"}`);
2395
+ console.log(` 运行环境 : ${setupVersion ? "v" + setupVersion : "(未固化 / 未知)"}`);
2396
+ console.log(` 运行环境入口 : ${fs.existsSync(runtimeSetupPath()) ? runtimeSetupPath() : "缺失!"}`);
2397
+ try {
2398
+ const profileDir = resolveProfileDir(argv);
2399
+ if (fs.existsSync(path.join(profileDir, "package.json"))) printPluginDiagnostics(profileDir);
2400
+ else console.log(` [plugin] 未找到 profile(${profileDir})`);
2401
+ } catch (e) { console.log(` [plugin] 诊断失败:${e.message}`); }
2402
+
2403
+ // ── ② 上游 dsh web(bridge 要连的那个本机地址) ──────────────────────────
2404
+ console.log("\n② 上游 dsh web");
2405
+ const hint = resolveUpstreamHint({ relayDir: CONFIG_DIR });
2406
+ const probed = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(` ${m}`) });
2407
+ console.log(` 提示来源 : ${hint.url ? `${hint.url}(${hint.source})` : "(无)"}`);
2408
+ console.log(` 最终采用 : ${probed.url}(${probed.source})`);
2409
+ console.log(` 身份校验 : ${probed.source === "fallback" ? "⚠️ 未找到在听的 dsh web(会一直重试)" : "✅ 已确认为本机 dsh web"}`);
2410
+ if (probed.probed && probed.probed.length) console.log(` 探测过的端口 : ${probed.probed.join(", ")}`);
2411
+
2412
+ // ── ③ 守护与 bridge 进程 ───────────────────────────────────────────────
2413
+ console.log("\n③ 进程与自启动");
2414
+ const watcherPid = readPidFile(WATCHER_PID_FILE);
2415
+ const bridgePid = readPidFile(BRIDGE_PID_FILE);
2416
+ console.log(` watcher pid : ${watcherPid || "-"}${watcherPid ? (alive(watcherPid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
2417
+ console.log(` bridge pid : ${bridgePid || "-"}${bridgePid ? (alive(bridgePid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
2418
+ // 进程扫描(平台各自的方式):只报数量,不猜
2419
+ try {
2420
+ const r = process.platform === "win32"
2421
+ ? sh("tasklist /FI \"IMAGENAME eq node.exe\" /FO CSV 2>NUL")
2422
+ : sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
2423
+ const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
2424
+ const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
2425
+ const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
2426
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
2427
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
2428
+ if (!watchers.length && !bridges.length) {
2429
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
2430
+ }
2431
+ } catch (e) { console.log(` 实测进程 : 扫描失败(${e.message})`); }
2432
+ if (process.platform === "linux") {
2433
+ const hasUser = Boolean(process.env.XDG_RUNTIME_DIR);
2434
+ console.log(` 自启动 : systemd --user ${hasUser ? "(有 runtime dir)" : "(无 XDG_RUNTIME_DIR → 本机没有 user systemd,插件会用后台进程兜底)"}`);
2435
+ if (hasUser) {
2436
+ const r = sh("systemctl --user is-active dsh-bridge");
2437
+ console.log(` systemd 状态 : ${r.ok ? r.stdout.trim() : (r.stderr || "").trim() || "查不到"}`);
2438
+ }
2439
+ }
2440
+
2441
+ // ── ④ 通道:E2EE / 微信 / 待补发 ───────────────────────────────────────
2442
+ console.log("\n④ 通道");
2443
+ const e2ee = readJson(path.join(CONFIG_DIR, ".e2ee-state.json"));
2444
+ console.log(` 端到端加密 : ${e2ee ? (e2ee.enabled ? `已启用(${e2ee.reason || "ok"})` : `未启用(${e2ee.reason || "?"})`) : "(无状态文件 = 明文)"}`);
2445
+ const wx = readJson(path.join(CONFIG_DIR, ".wechat-state.json"));
2446
+ if (!wx) {
2447
+ console.log(" 微信通道 : 未使用(无状态文件)");
2448
+ } else {
2449
+ const outbox = Array.isArray(wx.pending_outbox) ? wx.pending_outbox.length : 0;
2450
+ const faults = wx.last_fault && wx.last_fault.code ? `${wx.last_fault.code}(${ms(wx.last_fault.at)})` : "无";
2451
+ console.log(` 微信通道 : ${wx.bound ? "已绑定" : "未绑定"} | 最近成功推送 ${ms(wx.last_push_ok_at)}`);
2452
+ console.log(` 待补发通知 : ${outbox ? `⚠️ ${outbox} 条(下次在微信里发一句话就会补上)` : "0 条"}`);
2453
+ console.log(` 最近订阅异常 : ${faults}`);
2454
+ console.log(` 当前会话 : ${wx.current_session_title || "-"}(${wx.current_session_id || "-"})`);
2455
+ console.log(` 最近完成 : ${wx.last_completed_session_title || "-"}(${ms(wx.last_completed_at)})`);
2456
+ if (outbox) console.log(` ℹ️ 入站消息的形状(字段名,用于定位 context_token 缺失):${wx.inbound_shape || "(还没记到)"}`);
2457
+ }
2458
+ const cfg = loadConfig();
2459
+ console.log(` 账号 : ${mask(cfg.phone) || (cfg.local_key ? "自建(本地密钥)" : "(未登录)")}`);
2460
+ console.log(` 中继/API : ${cfg.tunnel_url || "-"} ${cfg.api_url ? `| ${cfg.api_url}` : ""}`);
2461
+
2462
+ // ── ⑤ 日志尾部(现在每行都有时间戳,直接能对时间) ─────────────────────
2463
+ console.log("\n⑤ 日志尾部(bridge + 安装)");
2464
+ for (const name of [".dsh-bridge.log", ".dsh-setup-install.log"]) {
2465
+ const p = path.join(CONFIG_DIR, name);
2466
+ if (!fs.existsSync(p)) { console.log(` ${name}: (无)`); continue; }
2467
+ let tail = "";
2468
+ try {
2469
+ const st = fs.statSync(p);
2470
+ const len = Math.min(st.size, 8 * 1024);
2471
+ const fd = fs.openSync(p, "r");
2472
+ const buf = Buffer.alloc(len);
2473
+ fs.readSync(fd, buf, 0, len, Math.max(0, st.size - len));
2474
+ fs.closeSync(fd);
2475
+ tail = buf.toString("utf8");
2476
+ } catch { /* 读不了就当没有 */ }
2477
+ const lines = tail.split("\n").map((l) => l.trim()).filter(Boolean).slice(-12);
2478
+ console.log(` --- ${name}(末尾 ${lines.length} 行 / ${p})---`);
2479
+ for (const l of lines) console.log(" " + l.slice(0, 220));
2480
+ console.log("");
2481
+ }
2482
+ console.log("结论怎么读:① 版本对不上 → 面板点「一键修复 / 重装运行环境」;");
2483
+ console.log(" ② 上游 fallback → 把 dsh web 起在本机可回环访问的端口(插件会自动发现);");
2484
+ console.log(" ③ 没有任何进程 → 先手动 `dsh-remote run` 看前台输出;");
2485
+ console.log(" ④ 待补发 > 0 → 在微信里随便发一句话即可补收。");
2486
+ }
2487
+
2335
2488
  function printPluginDiagnostics(profileDir) {
2336
2489
  const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
2337
2490
  const link = describePluginLink(profileDir);
@@ -2417,6 +2570,7 @@ else if (cmd === "settings") settingsHint();
2417
2570
  else if (cmd === "run") await runBridge();
2418
2571
  else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
2419
2572
  else if (cmd === "repair") repairProfile(process.argv.slice(3));
2573
+ else if (cmd === "doctor") await doctorCmd(process.argv.slice(3));
2420
2574
  else if (cmd === "status") {
2421
2575
  const cfg = loadConfig();
2422
2576
  const local = Boolean(cfg.local_key);