@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.
- package/clients/dsh-remote/dsh-bridge.mjs +64 -13
- package/clients/dsh-remote/dsh-events.mjs +22 -2
- package/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/clients/dsh-remote/mobile-adapter.mjs +172 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
- package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
- package/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/dsh-setup.mjs +161 -7
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +31 -1
- package/packages/dsh-remote-web/lib/index.js +392 -49
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +172 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
- package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
- package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
- package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
|
@@ -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:
|
|
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
|
-
|
|
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();
|
|
@@ -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({
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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
|
-
|
|
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,
|