dsh-remote-web 0.7.6 → 0.7.9
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/lib/client.js +119 -16
- package/lib/index.js +555 -63
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +332 -19
- package/runtime/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/runtime/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/runtime/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/runtime/dsh-setup.mjs +297 -51
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
extractInboundText,
|
|
41
41
|
extractFromUserId,
|
|
42
42
|
extractContextToken,
|
|
43
|
+
inboundMessageId,
|
|
43
44
|
handleCommand,
|
|
44
45
|
classifyInbound,
|
|
45
46
|
formatCompletion,
|
|
@@ -218,6 +219,49 @@ const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
|
|
|
218
219
|
/** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
|
|
219
220
|
const OUTBOX_MARK = "(补发)";
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 单次补发**最多逐条重发几条**(2026-10-29 ClawBot 修复,CONTRACT §3.5)。
|
|
224
|
+
*
|
|
225
|
+
* 为什么在 TTL/上限之外还要再收一层:平台在"一次连打多条"时最容易触发
|
|
226
|
+
* `ret=-2 prepare failed`(真机现场就是这个循环:补发 → 失败 → 队列涨 → 再补发)。
|
|
227
|
+
* TTL 只把**老的**丢掉,挡不住"一分钟内攒了 5 条";单次最多 2 条 + 一条汇总,
|
|
228
|
+
* 既让用户知道"还漏了东西",又不至于把平台重新打到限流。剩下的汇总成一句,绝不静默丢。
|
|
229
|
+
*/
|
|
230
|
+
const MAX_OUTBOX_FLUSH = 2;
|
|
231
|
+
/** 补发时"较早的通知已省略"的汇总文案(N = 被省略的条数)。 */
|
|
232
|
+
const OUTBOX_OVERFLOW_TEXT = (n) => `${OUTBOX_MARK}另有 ${n} 条较早的通知未送达,已省略`;
|
|
233
|
+
/**
|
|
234
|
+
* 通道静默期间"又有一条推送被跳过/入队"的日志节流窗口。
|
|
235
|
+
*
|
|
236
|
+
* 静默时每收到一条通知都记一行,就是我们自己刷屏(真机一次积压几十条)——
|
|
237
|
+
* 10 分钟一条足够看清趋势,又不会把真正该看的日志淹掉。
|
|
238
|
+
*/
|
|
239
|
+
const PUSH_PAUSED_LOG_INTERVAL_MS = 10 * 60_000;
|
|
240
|
+
/**
|
|
241
|
+
* 日报失败/静默后的**重试退避**(15 分钟)。
|
|
242
|
+
*
|
|
243
|
+
* 旧实现每个 60 秒 tick 都重试一次,18 点那一小时真机日志里刷了 111/240/175 条
|
|
244
|
+
* `ret=-2 prepare failed`(CONTRACT §0)。退避不是"放弃":真正的补发机会在用户下次
|
|
245
|
+
* 说话时(入站 = 令牌刚刷新,见 #flushPendingDigest)。
|
|
246
|
+
*/
|
|
247
|
+
const DIGEST_RETRY_BACKOFF_MS = 15 * 60_000;
|
|
248
|
+
/**
|
|
249
|
+
* `/status` 在推送静默时追加的提示(文案与面板/CONTRACT §3.7 同源)。
|
|
250
|
+
* 用户最怕的是"机器人坏了"这种误解 —— 必须说清是平台的窗口限制、以及怎么恢复。
|
|
251
|
+
*/
|
|
252
|
+
const PUSH_PAUSED_HINT = "推送已静默:微信通道只在你最近发过消息后的一小段时间内允许机器人主动发消息。回任意一条消息即可恢复,未送达的通知会补发。";
|
|
253
|
+
/**
|
|
254
|
+
* 入站前**刚刚**静默过时,`/status` 追加的提示。
|
|
255
|
+
*
|
|
256
|
+
* 为什么需要单独一句:用户最可能的诊断动作就是发 `/status` 问"怎么不推送了",
|
|
257
|
+
* 而入站消息本身会清掉 `push_paused` —— 等 #cmdStatus 判断时已经"不静默"了。
|
|
258
|
+
* 若不快照"这条消息到达之前的状态",这个提示在真实入站路径下永远不会出现,
|
|
259
|
+
* 用户就得不到解释(Lead 2026-10-29 复审指出)。
|
|
260
|
+
*/
|
|
261
|
+
const PUSH_RECOVERED_HINT = "(刚才推送静默过:你这条消息已恢复推送;此前未送达的通知会补发)";
|
|
262
|
+
/** `lastSilence` 的有效期:10 分钟内发 `/status` 才认为"刚才静默过"(再久就不算刚发生了)。 */
|
|
263
|
+
const SILENCE_RECENT_MS = 10 * 60_000;
|
|
264
|
+
|
|
221
265
|
/**
|
|
222
266
|
* 把事件节点翻译成文案节点。
|
|
223
267
|
* @returns {{node: object|null, formatterKind: string}}
|
|
@@ -355,6 +399,20 @@ export class WeChatRuntime {
|
|
|
355
399
|
*/
|
|
356
400
|
#expectedFaultSeen = new Set();
|
|
357
401
|
|
|
402
|
+
/**
|
|
403
|
+
* 已 warn 过的**非绑定用户** from 集合 —— 见 handleInbound 的身份过滤。
|
|
404
|
+
* 陌生消息可能成片来(群/服务号回推),每个都 warn 就是把日志刷满;同一 from 说一次足够。
|
|
405
|
+
*/
|
|
406
|
+
#foreignSenderWarned = new Set();
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 本次"推送静默"期间是否已经 warn 过一次。
|
|
410
|
+
*
|
|
411
|
+
* 令牌过期不是网络抖动:同一段时间里每条发不出去的消息都会走到同一个 catch,
|
|
412
|
+
* 不节流就是几十条 warn。入站(令牌刷新)或发送成功后重置,下一次过期重新报一次。
|
|
413
|
+
*/
|
|
414
|
+
#pauseWarned = false;
|
|
415
|
+
|
|
358
416
|
/**
|
|
359
417
|
* @param {{
|
|
360
418
|
* relayDir: string,
|
|
@@ -414,6 +472,32 @@ export class WeChatRuntime {
|
|
|
414
472
|
this.channelTask = null;
|
|
415
473
|
this.digestTask = null;
|
|
416
474
|
|
|
475
|
+
/**
|
|
476
|
+
* 待补发队列的**单飞锁**(CONTRACT §3.5)。
|
|
477
|
+
* 入站的 fire-and-forget 补发与主动推送路径都会调 #flushOutbox —— 不加锁时
|
|
478
|
+
* 两条调用会同时读到同一份队列、把同一批通知各发一遍(用户看到"重复推送")。
|
|
479
|
+
*/
|
|
480
|
+
this.outboxFlushing = false;
|
|
481
|
+
/**
|
|
482
|
+
* 日报的下一次重试时间(clock 毫秒)。未到点直接 return —— 修掉"每小时 60 次猛打"。
|
|
483
|
+
* 0 = 没有退避在生效。
|
|
484
|
+
*/
|
|
485
|
+
this.digestNextTryAt = 0;
|
|
486
|
+
/**
|
|
487
|
+
* 日报发送的**单飞锁**:#maybeDigest(定时器)与 #flushPendingDigest(入站补发)
|
|
488
|
+
* 是两个入口,并发进来会各发一条 —— 真机上就是"简报发两遍"。
|
|
489
|
+
* 磁盘原子占位(#claimDigest)挡的是跨进程,这里挡的是同进程内的并发入口。
|
|
490
|
+
*/
|
|
491
|
+
this.digestFlushing = false;
|
|
492
|
+
/** "推送静默,本条已跳过/入队"日志的节流时间戳(见 #logPushPaused)。 */
|
|
493
|
+
this.pausedPushLogAt = 0;
|
|
494
|
+
/**
|
|
495
|
+
* 最近一次"入站前处于静默"的快照 `{ at, reason }`(见 handleInbound 第 ③ 步)。
|
|
496
|
+
* 只服务于 `/status`:入站会把 push_paused 清掉,不快照的话用户问"怎么不推送了"
|
|
497
|
+
* 永远得不到"刚才静默过、你这条消息已恢复"的答复。
|
|
498
|
+
*/
|
|
499
|
+
this.lastSilence = null;
|
|
500
|
+
|
|
417
501
|
/** 绑定会话(同一时刻至多一个)。 */
|
|
418
502
|
this.bind = null;
|
|
419
503
|
/**
|
|
@@ -488,6 +572,11 @@ export class WeChatRuntime {
|
|
|
488
572
|
const state = loadState(this.relayDir);
|
|
489
573
|
const acct = sanitizeAccount(this.channel.account);
|
|
490
574
|
const cooldownMs = this.channel.cooldown.remainingMs ? this.channel.cooldown.remainingMs() : 0;
|
|
575
|
+
// 待补发条数只报**有效**条数(按保质期过滤后):见下方 pending_outbox 的历史注释
|
|
576
|
+
const pendingOutboxCount = this.#pruneOutbox(state.pending_outbox).kept.length;
|
|
577
|
+
// 推送静默标记由 channel 落在 state(invalidateContextToken 写、发送成功/入站清)——
|
|
578
|
+
// 这里直接读 state,保证面板看到的与"发不发得出去"是**同一份事实**。
|
|
579
|
+
const pushPaused = state.push_paused && typeof state.push_paused === "object" ? state.push_paused : null;
|
|
491
580
|
return {
|
|
492
581
|
ok: true,
|
|
493
582
|
disabled: this.disabled,
|
|
@@ -517,6 +606,15 @@ export class WeChatRuntime {
|
|
|
517
606
|
: { active: false, failed: !!(this.bind && this.bind.done && !this.bind.result) },
|
|
518
607
|
channel_running: !!this.channelTask,
|
|
519
608
|
events_running: !!this.subscriber,
|
|
609
|
+
// ── 2026-10-29 ClawBot 修复:推送静默 / 令牌 / 待补发(面板要能解释"为什么没消息")──
|
|
610
|
+
// 静默不是故障而是平台的窗口限制(CONTRACT §0 真机实测:令牌分钟级过期、没有续期接口),
|
|
611
|
+
// 所以必须让面板/状态如实说明,而不是让用户以为机器人坏了。
|
|
612
|
+
push_paused: !!pushPaused,
|
|
613
|
+
push_paused_at: Number((pushPaused && pushPaused.at) || 0),
|
|
614
|
+
push_paused_reason: String((pushPaused && pushPaused.reason) || ""),
|
|
615
|
+
context_token_at: Number(state.context_token_at || 0),
|
|
616
|
+
pending_outbox_count: pendingOutboxCount,
|
|
617
|
+
digest_pending_day: String(state.pending_digest_day || ""),
|
|
520
618
|
// ── 诊断字段(2026-09-25 故障加的)────────────────────────────────────
|
|
521
619
|
// 那次「任务跑完一条微信都没收到,日志里也没有任何报错」暴露了三件必须能看见的事:
|
|
522
620
|
// ① 事件订阅到底 ready 了没、**正在 follow 哪些会话**(follow 集为空 = turn/end 收不到);
|
|
@@ -528,7 +626,7 @@ export class WeChatRuntime {
|
|
|
528
626
|
: [],
|
|
529
627
|
// 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
|
|
530
628
|
// 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
|
|
531
|
-
pending_outbox:
|
|
629
|
+
pending_outbox: pendingOutboxCount,
|
|
532
630
|
last_fault: state.last_fault || null,
|
|
533
631
|
last_completed_session_id: state.last_completed_session_id || "",
|
|
534
632
|
last_completed_at: Number(state.last_completed_at || 0),
|
|
@@ -709,6 +807,17 @@ export class WeChatRuntime {
|
|
|
709
807
|
}
|
|
710
808
|
this.channel.writeState({ connected_at: this.clock(), last_error: "" });
|
|
711
809
|
|
|
810
|
+
// ★ 恢复长轮询游标(2026-10-29 ClawBot bug 1,CONTRACT §3.1)。
|
|
811
|
+
// 旧实现只把 `get_updates_buf` 放在内存里 —— bridge 重启/重连后游标归零,
|
|
812
|
+
// 服务端会重投"未确认"的消息 → 同一条消息被派活两次(用户报的「会话发两遍」)。
|
|
813
|
+
// 官方实现按 bot 账号落盘;读写都在 channel 层,这里只负责启动时取一次。
|
|
814
|
+
// `||` 保留内存里已有的值:channel 若自己恢复过,就不必再读盘。
|
|
815
|
+
try {
|
|
816
|
+
this.channel.updatesBuf = this.channel.updatesBuf || this.channel.loadUpdatesBuf();
|
|
817
|
+
} catch (e) {
|
|
818
|
+
this.logger.warn(`[wechat] 恢复长轮询游标失败(从空游标开始):${redact(String(e && e.message ? e.message : e))}`);
|
|
819
|
+
}
|
|
820
|
+
|
|
712
821
|
while (!this.stopping) {
|
|
713
822
|
// ⚠️ SessionCooldown **没有** isActive() —— 它只有 remainingMs()/remainingMinutes()/arm()。
|
|
714
823
|
// 早先这里写成 `cooldown.isActive && cooldown.isActive()` 会静默短路成 false,
|
|
@@ -734,10 +843,40 @@ export class WeChatRuntime {
|
|
|
734
843
|
this.channel.noteSessionExpired("getupdates");
|
|
735
844
|
continue;
|
|
736
845
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
846
|
+
// ★ 游标**在本批消息处理完之后**才落盘(Lead 2026-10-29 决策,取代 CONTRACT §3.1 的"先存")。
|
|
847
|
+
//
|
|
848
|
+
// 取舍(为什么"先处理后存"而不是"先存后处理"):
|
|
849
|
+
// · 先存(at-most-once):getUpdates 成功后立刻推进游标,若进程在这批消息的处理中途崩溃,
|
|
850
|
+
// 游标已经前进 → **这批剩余的消息永久丢失**,而且没有任何补偿机会。
|
|
851
|
+
// · 后存(at-least-once):崩溃后重启会从旧游标重投**这一批**,同一条消息可能被再派一次 ——
|
|
852
|
+
// 但重复由 channel 的持久化去重表吸收(`isDuplicateMessage` 在派发**之前**就标记,
|
|
853
|
+
// 属于 before-dispatch 语义),所以重放是安全的。
|
|
854
|
+
// 结论:宁可极小概率下重放一批(被去重表吸收),也不要静默丢一批用户消息。
|
|
855
|
+
// 用 try/finally 保证"即使某条 handleInbound 抛错也照样保存"—— 否则一条坏消息会让游标
|
|
856
|
+
// 永久卡住,变成无限重投。循环内仍是 catch+log 继续,不中断整批。
|
|
857
|
+
try {
|
|
858
|
+
for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
|
|
859
|
+
// 每条入站一行 info,**不含正文**:正文里有用户的任务/隐私,日志只留可定位的元信息
|
|
860
|
+
// (id / 来源 / 类型 / 文本长度)—— 出问题时要能对上是哪条,又不能让日志成为泄密面。
|
|
861
|
+
this.logger.info(
|
|
862
|
+
`[wechat] 入站 id=${inboundMessageId(msg) || "?"} from=${extractFromUserId(msg) || "?"}`
|
|
863
|
+
+ ` type=${msg && msg.message_type != null ? msg.message_type : "?"}`
|
|
864
|
+
+ ` len=${String(extractInboundText(msg) || "").length}`
|
|
865
|
+
);
|
|
866
|
+
try { await this.handleInbound(msg); } catch (e) {
|
|
867
|
+
this.logger.warn(`[wechat] 处理入站消息失败:${redact(String(e && e.message ? e.message : e))}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} finally {
|
|
871
|
+
// 整批处理完(或中途抛错)才推进并落盘游标;内存值一起更新,否则下一轮会拿旧游标重投本批
|
|
872
|
+
if (typeof resp.get_updates_buf === "string") {
|
|
873
|
+
this.channel.updatesBuf = resp.get_updates_buf;
|
|
874
|
+
try {
|
|
875
|
+
this.channel.saveUpdatesBuf(resp.get_updates_buf);
|
|
876
|
+
} catch (e) {
|
|
877
|
+
// 游标落盘失败绝不能打断长轮询(否则通道直接停了)—— 最多退化成"重启后重投一批"
|
|
878
|
+
this.logger.warn(`[wechat] 保存长轮询游标失败(继续轮询):${redact(String(e && e.message ? e.message : e))}`);
|
|
879
|
+
}
|
|
741
880
|
}
|
|
742
881
|
}
|
|
743
882
|
}
|
|
@@ -797,26 +936,78 @@ export class WeChatRuntime {
|
|
|
797
936
|
|
|
798
937
|
/** 处理一条入站消息(回执 / 指令 / 交代任务)。公开以便 bridge 与测试直接驱动。 */
|
|
799
938
|
async handleInbound(msg) {
|
|
939
|
+
const account = this.channel.account;
|
|
800
940
|
const from = extractFromUserId(msg);
|
|
941
|
+
|
|
942
|
+
// ── ① 身份 / 类型过滤(严格排在去重之前,CONTRACT §3.2 第 1 步)────────────
|
|
943
|
+
// 为什么必须先过滤:这条通道能驱动用户电脑上的 agent(session/prompt),
|
|
944
|
+
// 不过滤就等于把"谁都能派活"写进协议层。以前完全没有这道闸。
|
|
945
|
+
// ①a 机器人自己的消息(平台会把 bot 发出去的消息回灌进 getupdates)
|
|
946
|
+
if (Number(msg && msg.message_type) === 2 || /@im\.bot$/i.test(from)) {
|
|
947
|
+
if (typeof this.logger.debug === "function") {
|
|
948
|
+
this.logger.debug(`[wechat] 跳过机器人消息 from=${from || "?"} type=${msg && msg.message_type != null ? msg.message_type : "?"}`);
|
|
949
|
+
}
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// ①b 不是绑定用户 → 跳过 + warn(同一 from 只 warn 一次:陌生来源可能成片来,
|
|
953
|
+
// 每条都 warn 就是把真正的告警淹掉)。
|
|
954
|
+
if (account && account.userId && from && from !== account.userId) {
|
|
955
|
+
if (!this.#foreignSenderWarned.has(from)) {
|
|
956
|
+
this.#foreignSenderWarned.add(from);
|
|
957
|
+
this.logger.warn(`[wechat] 忽略非绑定用户的消息 from=${from}(当前绑定的是 ${account.userId})`);
|
|
958
|
+
}
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ── ② 入站去重(channel 层按 bot 账号落盘,CONTRACT §3.2 第 2 步)──────────
|
|
963
|
+
// 游标丢失/服务端重投时同一条消息会再来一次;没有去重就会把同一条派活两次。
|
|
964
|
+
// fail-open 由 channel 保证:id 为空或去重表读/写失败 → 当"不重复"(宁可重复一次,
|
|
965
|
+
// 也绝不因为去重把消息吃掉)。
|
|
966
|
+
const id = inboundMessageId(msg);
|
|
967
|
+
if (this.channel.isDuplicateMessage(id)) {
|
|
968
|
+
this.logger.info(`[wechat] 跳过重复入站 id=${id}`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
801
972
|
const text = normalizeInput(extractInboundText(msg));
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
973
|
+
// ── ③ 记令牌(入站消息带来 `context_token`,出站必须回带它才发得出去)──────
|
|
974
|
+
// 放在 `if (!text) return` **之前**:图片/语音这类没有文本的消息同样携带新令牌,
|
|
975
|
+
// 同样是"用户刚说过话、推送窗口已打开"的信号 —— 绝不能因为 text 为空就被早退跳过。
|
|
976
|
+
// channel 侧在写盘记住令牌的同时会**清掉 push_paused**(入站 = 令牌已刷新 = 通道恢复)。
|
|
977
|
+
// 记不住不影响主流程(最坏就是这一条发不出去,与以前一样)。
|
|
978
|
+
// ⚠️ 反过来,**没带令牌**的入站不会解除静默:没有 context_token 发出去必然还是
|
|
979
|
+
// `prepare failed`(CONTRACT §0),提前解除只会让补发多失败一次、把日志再刷一遍。
|
|
980
|
+
// 等带令牌的消息(真实入站都带)到了再恢复,才是"能发出去就恢复"的如实语义。
|
|
981
|
+
// ★ 先快照"这条消息到达之前是否处于静默",再让 rememberContextToken 去清它。
|
|
982
|
+
// 用户的心理路径是「咦不推送了」→ 发 /status 问;而入站会当场解除静默,所以
|
|
983
|
+
// #cmdStatus 必须靠这份快照才能说出「刚才静默过、你这条消息已恢复」。
|
|
984
|
+
const silenceBefore = this.#pushPaused();
|
|
985
|
+
if (silenceBefore.paused) {
|
|
986
|
+
this.lastSilence = { at: this.clock(), reason: silenceBefore.reason };
|
|
987
|
+
}
|
|
806
988
|
try { this.channel.rememberContextToken(extractContextToken(msg), from); } catch { /* 忽略 */ }
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
void this.#flushOutbox(from).catch(() => 0);
|
|
810
|
-
if (!text) return;
|
|
811
|
-
|
|
812
|
-
// 内部统计:任何入站互动都算一次"窗口续期"事件
|
|
813
|
-
this.#bumpToday();
|
|
989
|
+
// 令牌已刷新 → 下一次过期属于"新的一次静默",warn 要重新允许
|
|
990
|
+
this.#pauseWarned = false;
|
|
814
991
|
// ★ 0.6.15:`context_token` 的形状一直没被验证过(§11)。真机日志里
|
|
815
992
|
// `ret=-2 prepare failed` 出现过 48 次,而状态文件里**从来没有** context_token ——
|
|
816
993
|
// 也就是说入站消息里没带它(或字段名和我们猜的不一样)。这里把**字段名**记一次
|
|
817
994
|
// (只有名字,没有值),下次再出这个问题就能一眼看出该取哪个键,不用再猜。
|
|
818
995
|
this.#noteInboundShapeIfNoToken(msg);
|
|
819
996
|
|
|
997
|
+
// ── ④ 补发(入站 = 平台推送窗口刚打开,是唯一能补送积压的时刻)──────────────
|
|
998
|
+
// 按契约顺序**日报优先**触发,再补待补发队列。两条都 fire-and-forget:
|
|
999
|
+
// 补发失败绝不能影响用户这条消息的处理;#flushOutbox 内部单飞,与推送路径不会并发重发。
|
|
1000
|
+
// ⚠️ 实现细节(别被顺序误导):#flushPendingDigest 走 notify → #pushProactive,而后者
|
|
1001
|
+
// **先补待补发队列再发这一条**(既有行为)。所以网络上的实际顺序可能是"积压 → 日报"。
|
|
1002
|
+
// 这不影响任何承诺(每条都发、各一次、有界),只是别在别处假设严格先后。
|
|
1003
|
+
void this.#flushPendingDigest(from).catch(() => false);
|
|
1004
|
+
void this.#flushOutbox(from).catch(() => 0);
|
|
1005
|
+
|
|
1006
|
+
if (!text) return;
|
|
1007
|
+
|
|
1008
|
+
// 内部统计:任何入站互动都算一次"窗口续期"事件
|
|
1009
|
+
this.#bumpToday();
|
|
1010
|
+
|
|
820
1011
|
// ── v2 分派:命令 → 数字 → 普通消息 ────────────────────────────────────
|
|
821
1012
|
// ⚠️ 顺序不能换:
|
|
822
1013
|
// · 命令必须最先 —— 否则「/new 修个 bug」会被当成一条发给会话的普通消息;
|
|
@@ -1347,7 +1538,18 @@ export class WeChatRuntime {
|
|
|
1347
1538
|
const line = this.currentSessionId
|
|
1348
1539
|
? `当前任务:${name || "(未命名)"} ${short}`
|
|
1349
1540
|
: "当前任务:未选中(发一句话即开新任务,/ls 可切换)";
|
|
1350
|
-
|
|
1541
|
+
const lines = [base, line];
|
|
1542
|
+
// ★ 推送静默时**必须如实追加一句**:否则用户看到"机器人不说话了"只会以为它坏了,
|
|
1543
|
+
// 而真实原因是平台的窗口限制(令牌过期、没有续期接口,见 CONTRACT §0)。
|
|
1544
|
+
// 这句同时告诉他怎么办(回任意一条消息即可恢复),比任何技术细节都有用。
|
|
1545
|
+
// 分两种说法:现在还静默(发不出去)/刚才静默过但这条入站已经恢复(见 lastSilence)。
|
|
1546
|
+
const pausedNow = this.#pushPaused();
|
|
1547
|
+
if (pausedNow.paused) {
|
|
1548
|
+
lines.push("", PUSH_PAUSED_HINT);
|
|
1549
|
+
} else if (this.lastSilence && this.clock() - Number(this.lastSilence.at || 0) < SILENCE_RECENT_MS) {
|
|
1550
|
+
lines.push("", PUSH_RECOVERED_HINT);
|
|
1551
|
+
}
|
|
1552
|
+
await this.reply(from, lines.join("\n"));
|
|
1351
1553
|
}
|
|
1352
1554
|
|
|
1353
1555
|
/** `/summary` —— 重发当前会话的最近结论。 */
|
|
@@ -1497,16 +1699,61 @@ export class WeChatRuntime {
|
|
|
1497
1699
|
// 让"想给多行却给了数组"退化成**正确**的多行文本,而不是一坨。
|
|
1498
1700
|
const body = Array.isArray(text) ? text.join("\n") : String(text == null ? "" : text);
|
|
1499
1701
|
if (!body) return false;
|
|
1702
|
+
// 发送前先看一眼静默标记:这条如果能发出去,说明令牌是好的 → 下面要把它清掉
|
|
1703
|
+
const pausedBefore = !!this.#pushPaused().paused;
|
|
1500
1704
|
try {
|
|
1501
1705
|
await this.channel.client.sendMessage({ to, text: body });
|
|
1502
1706
|
this.channel.markPush(true);
|
|
1707
|
+
// ★ 发送成功 = 令牌有效 = 通道恢复:把通道层的静默标记清掉,
|
|
1708
|
+
// 让面板/状态如实变回"正常",而不是一直挂着一条已经过期的警告。
|
|
1709
|
+
if (pausedBefore) {
|
|
1710
|
+
try { this.channel.clearPushPaused(); } catch { /* 清标记失败不影响这条已发出的消息 */ }
|
|
1711
|
+
this.#pauseWarned = false;
|
|
1712
|
+
this.logger.info("[wechat] 主动推送已恢复(令牌有效),静默标记已清除");
|
|
1713
|
+
}
|
|
1503
1714
|
return true;
|
|
1504
1715
|
} catch (e) {
|
|
1716
|
+
// 🔴 令牌过期(ret=-2 prepare failed)**不是网络抖动**:平台没有续期接口,
|
|
1717
|
+
// 同一条重发必然再失败(CONTRACT §0 真机实测:有效期约 10 分钟)。
|
|
1718
|
+
// 所以这里既不重试也不重写 last_error —— 立刻静默主动推送并如实记账,
|
|
1719
|
+
// 否则就是我们刷满日志、还让用户以为"只是慢"。
|
|
1720
|
+
if (e && e.code === "context_token_expired") {
|
|
1721
|
+
const reason = String(e && e.message ? e.message : e || "context_token 已过期");
|
|
1722
|
+
try { this.channel.invalidateContextToken(reason); } catch { /* 记账失败也必须保持静默 */ }
|
|
1723
|
+
if (!this.#pauseWarned) {
|
|
1724
|
+
this.#pauseWarned = true;
|
|
1725
|
+
this.logger.warn(`[wechat] 主动推送已暂停(上下文令牌过期):${redact(reason)}(等用户下次发消息刷新令牌后自动恢复)`);
|
|
1726
|
+
}
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1505
1729
|
this.#noteFailure(`sendmessage 失败:${redact(String(e && e.message ? e.message : e))}`);
|
|
1506
1730
|
return false;
|
|
1507
1731
|
}
|
|
1508
1732
|
}
|
|
1509
1733
|
|
|
1734
|
+
// ── 推送静默(2026-10-29 ClawBot 修复) ─────────────────────────────────
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* 读通道层的"主动推送是否已静默"。
|
|
1738
|
+
*
|
|
1739
|
+
* 静默 = 最近一次 sendMessage 撞上 `ret=-2 prepare failed`(context_token 过期,
|
|
1740
|
+
* channel 层落盘 `push_paused`)。它不是网络故障,平台也没有续期接口 —— 令牌只在
|
|
1741
|
+
* "最近有入站消息"的很短窗口内有效(CONTRACT §0 真机实测)。
|
|
1742
|
+
* 所以静默期间**不发请求、只入队**,等用户下次说话(令牌刷新)再补。
|
|
1743
|
+
*/
|
|
1744
|
+
#pushPaused() {
|
|
1745
|
+
const r = this.channel.pushPaused();
|
|
1746
|
+
return r && typeof r === "object" ? r : { paused: false, at: 0, reason: "" };
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** 静默期间"这条推送被跳过/入队"的日志:≥10 分钟一条,避免我们自己把日志刷满。 */
|
|
1750
|
+
#logPushPaused(text) {
|
|
1751
|
+
const now = this.clock();
|
|
1752
|
+
if (now - this.pausedPushLogAt < PUSH_PAUSED_LOG_INTERVAL_MS) return;
|
|
1753
|
+
this.pausedPushLogAt = now;
|
|
1754
|
+
this.logger.info(`[wechat] ${text}`);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1510
1757
|
/**
|
|
1511
1758
|
* 入站消息里到底有没有 `context_token`?没有的话,把**字段名**记一次(值一律不记)。
|
|
1512
1759
|
*
|
|
@@ -1608,40 +1855,86 @@ export class WeChatRuntime {
|
|
|
1608
1855
|
}
|
|
1609
1856
|
|
|
1610
1857
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1858
|
+
* 把待补发队列发出去(2026-10-29 收敛,CONTRACT §3.5)。
|
|
1859
|
+
*
|
|
1860
|
+
* 三条硬约束:
|
|
1861
|
+
* ① **单飞**:入站的 fire-and-forget 与主动推送路径会并发调用 —— 不加锁时两边会
|
|
1862
|
+
* 读到同一份队列、把同一批通知各发一遍(用户看到"重复推送");并发调用直接返回 0。
|
|
1863
|
+
* ② **单次最多逐条重发 MAX_OUTBOX_FLUSH 条**,取**最近**的几条(越早越没价值,
|
|
1864
|
+
* 而且一次连打正是平台限流的触发条件,见常量注释)。
|
|
1865
|
+
* ③ 还有更早的 → 再发**一条汇总**「另有 N 条较早的通知未送达,已省略」后清空队列:
|
|
1866
|
+
* 既不让用户以为"什么都没漏",也不逐条刷屏;被省略的条数**如实记日志**。
|
|
1867
|
+
* 补发路径**不看 push_paused** —— 它是被入站触发的,此刻令牌刚刚刷新(CONTRACT §3.4)。
|
|
1868
|
+
* @returns {Promise<number>} 这次补发成功的条数(含汇总那一条)
|
|
1613
1869
|
*/
|
|
1614
1870
|
async #flushOutbox(to) {
|
|
1615
|
-
//
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
sent
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1871
|
+
// 单飞:并发调用直接返回 0(另一条调用会把队列处理掉)
|
|
1872
|
+
if (this.outboxFlushing) return 0;
|
|
1873
|
+
if (!to) return 0;
|
|
1874
|
+
this.outboxFlushing = true;
|
|
1875
|
+
try {
|
|
1876
|
+
// 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
|
|
1877
|
+
// 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
|
|
1878
|
+
const raw = this.#outbox();
|
|
1879
|
+
const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
|
|
1880
|
+
if (list.length !== raw.length) this.#writeOutbox(list);
|
|
1881
|
+
if (!list.length) return 0;
|
|
1882
|
+
const target = list.slice(-MAX_OUTBOX_FLUSH); // 只逐条重发最近的几条
|
|
1883
|
+
const older = list.slice(0, Math.max(0, list.length - target.length));
|
|
1884
|
+
let sent = 0;
|
|
1885
|
+
for (const item of target) {
|
|
1886
|
+
const ok = await this.reply(to, `${OUTBOX_MARK}${String(item && item.text ? item.text : "")}`);
|
|
1887
|
+
if (!ok) {
|
|
1888
|
+
// 还是发不出去(例如令牌又过期了):**保留全部 older + 尚未送出的 target**,下次再试。
|
|
1889
|
+
// ⚠️ 别写成 `list.slice(sent)`:`sent` 只在 target 内计数,而 list 是 [older..., target...],
|
|
1890
|
+
// 那个写法会同时犯两个错 —— 最早的 older 被静默丢掉、已经发出去的 target 又被放回队列
|
|
1891
|
+
// 再发一遍(用户收到重复)。必须按两段拼回去。
|
|
1892
|
+
const rest = [...older, ...target.slice(sent)];
|
|
1893
|
+
this.#writeOutbox(rest);
|
|
1894
|
+
this.logger.warn(`[wechat] 补发中断:已送出 ${sent} 条,剩 ${rest.length} 条留到下次(不丢,含较早的 ${older.length} 条)`);
|
|
1895
|
+
return sent;
|
|
1896
|
+
}
|
|
1897
|
+
sent += 1;
|
|
1898
|
+
}
|
|
1899
|
+
if (older.length) {
|
|
1900
|
+
// 更早的通知不再逐条重发,但**必须让用户知道有东西被省略了**(绝不静默丢)。
|
|
1901
|
+
const ok = await this.reply(to, OUTBOX_OVERFLOW_TEXT(older.length));
|
|
1902
|
+
if (!ok) {
|
|
1903
|
+
// 汇总都没发出去 → 这些条目留着下次再试(不能因为已经"打算省略"就真丢了)
|
|
1904
|
+
this.#writeOutbox(older);
|
|
1905
|
+
this.logger.warn(`[wechat] 补发省略汇总没发出去,${older.length} 条较早的通知留到下次`);
|
|
1906
|
+
return sent;
|
|
1907
|
+
}
|
|
1908
|
+
sent += 1;
|
|
1909
|
+
this.logger.warn(`[wechat] 补发时省略了 ${older.length} 条较早的通知(单次最多逐条重发 ${MAX_OUTBOX_FLUSH} 条,避免一次连打触发平台限流)`);
|
|
1910
|
+
}
|
|
1911
|
+
this.#writeOutbox([]);
|
|
1912
|
+
this.logger.info(`[wechat] 待补发队列已送出 ${sent} 条并清空`);
|
|
1913
|
+
return sent;
|
|
1914
|
+
} finally {
|
|
1915
|
+
this.outboxFlushing = false;
|
|
1916
|
+
}
|
|
1635
1917
|
}
|
|
1636
1918
|
|
|
1637
1919
|
/**
|
|
1638
1920
|
* 主动推送(通知类):先补发积压,再发这一条;发不出去就入队,绝不丢。
|
|
1921
|
+
*
|
|
1922
|
+
* 2026-10-29 新增:通道**静默**期间(令牌过期)**不发请求** —— 发了必然 prepare failed,
|
|
1923
|
+
* 只会刷日志并把平台打成限流(真机现场正是这个循环)。此时只入队,等入站补发。
|
|
1639
1924
|
* @returns {Promise<{sent:boolean, queued:boolean}>} queued=true 表示"已排进待补发",
|
|
1640
1925
|
* 调用方据此仍可登记待拍板项(用户稍后看到时还能回执)。
|
|
1641
1926
|
*/
|
|
1642
1927
|
async #pushProactive(to, text, kind = "", { queue = true } = {}) {
|
|
1643
1928
|
const body = String(text || "");
|
|
1644
1929
|
if (!body) return { sent: false, queued: false };
|
|
1930
|
+
const paused = this.#pushPaused();
|
|
1931
|
+
if (paused.paused) {
|
|
1932
|
+
// 静默中:请求一个都不发(这是 CONTRACT §3.4 的核心)。
|
|
1933
|
+
if (!queue) return { sent: false, queued: false };
|
|
1934
|
+
const depth = this.#enqueueOutbox(body, kind);
|
|
1935
|
+
this.#logPushPaused(`主动推送已静默(${kind || "?"}):本条已入队(${depth} 条待补发,等入站补发)`);
|
|
1936
|
+
return { sent: false, queued: true };
|
|
1937
|
+
}
|
|
1645
1938
|
await this.#flushOutbox(to).catch(() => 0);
|
|
1646
1939
|
const ok = await this.reply(to, body);
|
|
1647
1940
|
if (ok) return { sent: true, queued: false };
|
|
@@ -2034,24 +2327,91 @@ export class WeChatRuntime {
|
|
|
2034
2327
|
const now = this.clock();
|
|
2035
2328
|
const hour = new Date(now).getHours();
|
|
2036
2329
|
if (hour !== this.digestHour) return;
|
|
2330
|
+
// ★ 退避(2026-10-29 修复,CONTRACT §3.6):上一次没成功(令牌过期/静默/网络失败)→
|
|
2331
|
+
// 15 分钟内不再试。旧实现每个 60 秒 tick 都猛打一次,真机 18 点那一小时刷了
|
|
2332
|
+
// 111/240/175 条 `ret=-2 prepare failed`。退避不是放弃 —— 补发改由入站触发
|
|
2333
|
+
// (见 #flushPendingDigest:用户一说话令牌就刷新,那才是能把简报送出去的时刻)。
|
|
2334
|
+
if (this.digestNextTryAt && now < this.digestNextTryAt) return;
|
|
2037
2335
|
const day = this.#today(now);
|
|
2038
2336
|
const state = loadState(this.relayDir);
|
|
2039
2337
|
if (state.last_digest_day === day) return; // 今天已发
|
|
2040
2338
|
// ★ 先原子占位再发送:两个实例/两次 tick 并存时,只有一个能拿到(见 #claimDigest)
|
|
2041
2339
|
const claim = this.#claimDigest(day);
|
|
2042
2340
|
if (!claim.ok) return;
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2341
|
+
// 占位拿到就先把"今天还没送达"落盘:进程若在发送途中被杀(占位会被当作陈旧接管,
|
|
2342
|
+
// 但 pending 标记能让下一次**入站**立刻补上,不必再等一天)。
|
|
2343
|
+
this.channel.writeState({ pending_digest_day: day });
|
|
2344
|
+
const paused = this.#pushPaused();
|
|
2345
|
+
let r;
|
|
2346
|
+
if (paused.paused) {
|
|
2347
|
+
// 静默期间发也必然失败(prepare failed)→ 干脆一个请求都不发,等入站补发。
|
|
2348
|
+
this.#logPushPaused(`日报已静默(${paused.reason || "context_token 过期"}),等下次入站补发`);
|
|
2349
|
+
r = { ok: false, reason: "push_paused" };
|
|
2350
|
+
} else {
|
|
2351
|
+
r = await this.#sendDigestNow();
|
|
2352
|
+
}
|
|
2353
|
+
// ★ 只有**真的发出去了**才记"今天已发"(见上面 ②)
|
|
2354
|
+
if (r && r.ok) {
|
|
2355
|
+
this.channel.writeState({ last_digest_day: day, pending_digest_day: "" });
|
|
2356
|
+
this.digestNextTryAt = 0;
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
// 没真发出去 → 释放占位(不能把当天烧掉)+ 退避 15 分钟(修掉"每小时 60 次猛打")
|
|
2360
|
+
this.#releaseDigest(claim);
|
|
2361
|
+
this.digestNextTryAt = this.clock() + DIGEST_RETRY_BACKOFF_MS;
|
|
2362
|
+
this.logger.warn(`[wechat] 日报未送达(${(r && r.reason) || "unknown"}):已记为待补发,${Math.round(DIGEST_RETRY_BACKOFF_MS / 60_000)} 分钟后或下次入站再试`);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/**
|
|
2366
|
+
* 真正发一次当日简报(`#maybeDigest` 与 `#flushPendingDigest` **共用**)。
|
|
2367
|
+
*
|
|
2368
|
+
* 单飞:两个入口都是异步发送,并发进来会各发一条(真机上就表现为"简报发两遍")。
|
|
2369
|
+
* 磁盘原子占位(`#claimDigest`)挡的是跨进程/跨实例,这里挡的是同进程内的并发入口。
|
|
2370
|
+
* @returns {Promise<{ok:boolean, reason?:string}>}
|
|
2371
|
+
*/
|
|
2372
|
+
async #sendDigestNow() {
|
|
2373
|
+
if (this.digestFlushing) return { ok: false, reason: "inflight" };
|
|
2374
|
+
this.digestFlushing = true;
|
|
2047
2375
|
try {
|
|
2048
|
-
|
|
2376
|
+
const s = this.todayStats;
|
|
2377
|
+
// ⚠️ 必须把 lines 一起传:只给 notified/answered 会让用户每天收到兜底句
|
|
2378
|
+
// 「今天暂时没有要做的事。」—— 那不是"今天干了啥"(见 #maybeDigest 的历史注释)。
|
|
2379
|
+
const node = this.subscriber.digestDue({ lines: this.#digestLines(), notified: s.notified, answered: s.answered });
|
|
2380
|
+
// queueFailedPush:false —— 日报自带 pending/退避语义,绝不能进待补发队列(否则双份)
|
|
2381
|
+
return await this.notify(node, { queueFailedPush: false });
|
|
2382
|
+
} catch (e) {
|
|
2383
|
+
return { ok: false, reason: redact(String(e && e.message ? e.message : e)) };
|
|
2049
2384
|
} finally {
|
|
2050
|
-
|
|
2051
|
-
if (!(r && r.ok)) this.#releaseDigest(claim);
|
|
2385
|
+
this.digestFlushing = false;
|
|
2052
2386
|
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
/**
|
|
2390
|
+
* 入站后补发"今天没能送达的日报"(CONTRACT §3.6)。
|
|
2391
|
+
*
|
|
2392
|
+
* 为什么必须由**入站**触发:平台只允许"最近有入站消息"的窗口内主动推送,
|
|
2393
|
+
* 日报失败/静默后靠定时器重试只会一直 prepare failed。用户一说话令牌刚刷新,
|
|
2394
|
+
* 这是唯一能把简报送出去的时刻 —— 所以它排在待补发队列**之前**(日报过期就没意义了)。
|
|
2395
|
+
* 这里**不看 push_paused**:入站意味着令牌已刷新(CONTRACT §3.4)。
|
|
2396
|
+
* @returns {Promise<boolean>} 是否补发成功
|
|
2397
|
+
*/
|
|
2398
|
+
async #flushPendingDigest(to) {
|
|
2399
|
+
if (!to || !this.subscriber || !this.channel.account) return false;
|
|
2400
|
+
const day = this.#today();
|
|
2401
|
+
const state = loadState(this.relayDir);
|
|
2402
|
+
if (state.last_digest_day === day) {
|
|
2403
|
+
// 今天已送达:顺手清掉可能残留的待补标记(异常退出/旧版本留下的,别让状态永远挂着)
|
|
2404
|
+
if (state.pending_digest_day) this.channel.writeState({ pending_digest_day: "" });
|
|
2405
|
+
return false;
|
|
2406
|
+
}
|
|
2407
|
+
if (String(state.pending_digest_day || "") !== day) return false;
|
|
2408
|
+
const r = await this.#sendDigestNow();
|
|
2409
|
+
if (!(r && r.ok)) return false;
|
|
2410
|
+
// ★ 只有真发出去才写 last_digest_day,并清掉待补标记("发过才算数")
|
|
2411
|
+
this.channel.writeState({ last_digest_day: day, pending_digest_day: "" });
|
|
2412
|
+
this.digestNextTryAt = 0;
|
|
2413
|
+
this.logger.info("[wechat] 未送达的日报已在入站后补发成功");
|
|
2414
|
+
return true;
|
|
2055
2415
|
}
|
|
2056
2416
|
|
|
2057
2417
|
/**
|