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
|
@@ -148,6 +148,34 @@ export const QR_STATUSES = Object.freeze([
|
|
|
148
148
|
export const ACCOUNT_FILE = ".wechat-account.json";
|
|
149
149
|
export const STATE_FILE = ".wechat-state.json";
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* 游标 / 去重表落盘文件名前缀 —— **按 bot 账号隔离**(后面拼 botKey)。
|
|
153
|
+
*
|
|
154
|
+
* 为什么必须落盘(用户 2026-10 报「发给 Agent 的会话会发两遍」):
|
|
155
|
+
* `get_updates_buf` 以前只存在内存里,bridge 一重启/断线重连游标就回到空串,
|
|
156
|
+
* 服务端于是把"还没确认"的消息**重投**一遍 → 同一条消息被派活两次。
|
|
157
|
+
* 腾讯官方实现就是按 bot 账号把游标落盘的,这里对齐。
|
|
158
|
+
* 为什么按账号分隔:换绑会换 bot,旧 bot 的游标对新的毫无意义(反过来还会重放旧消息);
|
|
159
|
+
* 两个 bot 共用一个文件则会互相覆盖,等于没存。
|
|
160
|
+
*/
|
|
161
|
+
export const UPDATES_BUF_FILE_PREFIX = ".wechat-sync-";
|
|
162
|
+
export const SEEN_MESSAGES_FILE_PREFIX = ".wechat-seen-";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 入站去重表上限 / 保质期。
|
|
166
|
+
* 有界是硬要求:去重表只增不减会变成永不回收的隐私残留(里面是消息指纹),
|
|
167
|
+
* 而且文件越大每次同步读写的成本越高。200 条 / 7 天足够覆盖"重连后服务端重投"的实际窗口。
|
|
168
|
+
*/
|
|
169
|
+
export const SEEN_MESSAGES_MAX = 200;
|
|
170
|
+
export const SEEN_MESSAGES_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `push_paused.reason` 的落盘上限。
|
|
174
|
+
* 这个字段**面板可读**,而 reason 多半来自错误原文(里面可能回显 URL/token);
|
|
175
|
+
* 过长还会把状态文件/面板撑爆 —— 脱敏 + 截断在这里一次做完。
|
|
176
|
+
*/
|
|
177
|
+
export const PUSH_PAUSE_REASON_MAX = 120;
|
|
178
|
+
|
|
151
179
|
// ===========================================================================
|
|
152
180
|
// 1. 错误类型 + 脱敏
|
|
153
181
|
// ===========================================================================
|
|
@@ -156,7 +184,11 @@ export const STATE_FILE = ".wechat-state.json";
|
|
|
156
184
|
* 协议/流程错误。`code` 是稳定的机器可读标识,调用方据此分支:
|
|
157
185
|
* bad_options | http_error | bad_response_shape | unexpected_status |
|
|
158
186
|
* bind_missing_bot_id | bind_expired | verify_blocked | verify_timeout |
|
|
159
|
-
* session_expired | cooldown | network
|
|
187
|
+
* session_expired | cooldown | network | context_token_expired
|
|
188
|
+
*
|
|
189
|
+
* `context_token_expired` 是本轮新增的一等错误码(见 sendMessage 的注释):
|
|
190
|
+
* 它**不是**网络抖动,重试同一条消息永远不会成功;调用方必须据此暂停主动推送、
|
|
191
|
+
* 等下一次入站刷新令牌后再补发,而不是按"间歇性失败"猛打接口。
|
|
160
192
|
*/
|
|
161
193
|
export class WeChatError extends Error {
|
|
162
194
|
constructor(code, message, detail) {
|
|
@@ -729,7 +761,31 @@ export class IlinkClient {
|
|
|
729
761
|
const obj = IlinkClient.expectObject(json, "sendMessage");
|
|
730
762
|
if (obj.ret && obj.ret !== 0) {
|
|
731
763
|
// 注意:errmsg 可能带上下文信息,过一遍 redact 再抛(内容里可能回显 token)。
|
|
732
|
-
|
|
764
|
+
const safeErrmsg = redact(obj.errmsg || "(none)", [this.token]);
|
|
765
|
+
/**
|
|
766
|
+
* 🔴 `ret=-2 && errmsg="prepare failed"` 必须单独分类(2026-10 真机实测的根因):
|
|
767
|
+
*
|
|
768
|
+
* ilink 的 `context_token` 是**分钟级过期**的(入站后几分钟内可用,约 10 分钟后即失效),
|
|
769
|
+
* 而平台**没有续期接口**(getconfig / sendtyping / notifystart 都试过,救不回来)。
|
|
770
|
+
* 同一个 token 过期后、伪造 token、空串、不带该字段,拿到的响应**完全相同**:
|
|
771
|
+
* `{"ret":-2,"errmsg":"prepare failed"}`。
|
|
772
|
+
*
|
|
773
|
+
* 所以这个响应只有一种解释:**我们手上的上下文令牌过期了,这条消息发不出去**,
|
|
774
|
+
* 重试同一条永远不会成功 —— 它**不是**网络抖动。旧实现把它归进 `http_error`,
|
|
775
|
+
* 调用方于是当"间歇性失败"处理:日报每小时重试 60 次(真机日志里 18 点各 111/240/175 条
|
|
776
|
+
* `prepare failed`),通知入队 120 分钟后等用户下次说话再一股脑补发(用户看到的"补发一堆")。
|
|
777
|
+
*
|
|
778
|
+
* 分类成独立错误码后,调用方才能:立即暂停主动推送 → 保留少量通知 → 下次入站立即补送。
|
|
779
|
+
* `detail` 带上原始 ret/errmsg,便于日志与排障(已脱敏)。
|
|
780
|
+
*/
|
|
781
|
+
if (obj.ret === -2 && /prepare failed/i.test(String(obj.errmsg || ""))) {
|
|
782
|
+
throw new WeChatError(
|
|
783
|
+
"context_token_expired",
|
|
784
|
+
"sendMessage: ret=-2 prepare failed(上下文令牌已过期)",
|
|
785
|
+
{ ret: obj.ret, errmsg: safeErrmsg }
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
throw new WeChatError("http_error", `sendMessage: ret=${obj.ret} errmsg=${safeErrmsg}`);
|
|
733
789
|
}
|
|
734
790
|
if (obj.errcode === STALE_TOKEN_ERRCODE) {
|
|
735
791
|
throw new WeChatError("session_expired", "sendMessage: session timeout(errcode -14)", obj);
|
|
@@ -1643,6 +1699,26 @@ export async function startBind(init = {}) {
|
|
|
1643
1699
|
export const accountPath = (relayDir) => path.join(relayDir, ACCOUNT_FILE);
|
|
1644
1700
|
export const statePath = (relayDir) => path.join(relayDir, STATE_FILE);
|
|
1645
1701
|
|
|
1702
|
+
/**
|
|
1703
|
+
* 由 bot 账号派生**安全文件名**(botKey),给游标文件 / 去重表用。
|
|
1704
|
+
*
|
|
1705
|
+
* 为什么不能直接用 accountId:它是服务端给的字符串,可能是任意字符(含 `/`、`..`、空格、中文),
|
|
1706
|
+
* 直接拼进路径会写到意外位置甚至跳出 relayDir。这里把不安全字符统一替换成 `_`。
|
|
1707
|
+
* 为什么替换后还要补一段哈希:替换是**多对一**的(`a/b` 与 `a_b` 会落到同一个文件名),
|
|
1708
|
+
* 不同 bot 互相覆盖游标/去重表 = 悄悄重放或丢消息。所以只要发生过替换(或被截断),
|
|
1709
|
+
* 就再拼上原文 sha1 前 8 位,保证不同 accountId 必得不同文件名。
|
|
1710
|
+
*
|
|
1711
|
+
* 没有账号 → 返回 ""(调用方据此判断"还落不了盘",只留内存)。
|
|
1712
|
+
*/
|
|
1713
|
+
function botKeyFor(account) {
|
|
1714
|
+
const raw = String((account && account.accountId) || "").trim();
|
|
1715
|
+
if (!raw) return "";
|
|
1716
|
+
const safe = raw.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
|
|
1717
|
+
if (!safe) return "";
|
|
1718
|
+
if (safe === raw) return safe;
|
|
1719
|
+
return `${safe}-${crypto.createHash("sha1").update(raw).digest("hex").slice(0, 8)}`;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1646
1722
|
/**
|
|
1647
1723
|
* 读凭据。返回 {token, accountId, baseUrl, userId, boundAt} 或 null。
|
|
1648
1724
|
* ⚠️ **只有 bridge 内部能用它** —— 面板接口一律走 sanitizeAccount()(§8:永不回显 token)。
|
|
@@ -2448,6 +2524,42 @@ export function extractContextToken(msg) {
|
|
|
2448
2524
|
return "";
|
|
2449
2525
|
}
|
|
2450
2526
|
|
|
2527
|
+
/**
|
|
2528
|
+
* 一条入站消息的**稳定去重 id**。
|
|
2529
|
+
*
|
|
2530
|
+
* 为什么需要(用户 2026-10 报「发给 Agent 的会话会发两遍」):
|
|
2531
|
+
* `get_updates_buf` 落盘之前,重连会让服务端重投未确认消息;即便游标修好,
|
|
2532
|
+
* 同一次 `getUpdates` 响应里也可能出现两条一模一样的消息。没有按消息 id 去重 =
|
|
2533
|
+
* 同一条消息被派活两次(真机日志里 `getupdates 失败` 出现过 117 次)。
|
|
2534
|
+
*
|
|
2535
|
+
* 取值优先级(从最可靠到"只能猜"):
|
|
2536
|
+
* 1. `message_id`(数字转字符串)—— 平台给的唯一 id,最可靠;
|
|
2537
|
+
* 2. `seq` —— 协议里的序号,同账号内单调,退而求其次;
|
|
2538
|
+
* 3. `sha1(from|create_time_ms|文本)` —— 两者都没有时的指纹(§11 形状未验证,
|
|
2539
|
+
* 某些响应确实不带 id;指纹碰撞概率可忽略,且只在 7 天窗口内使用);
|
|
2540
|
+
* 4. 什么都没有 → ""(调用方**不去重**:宁可多派一次,也不能把用户消息吞掉)。
|
|
2541
|
+
*/
|
|
2542
|
+
export function inboundMessageId(msg) {
|
|
2543
|
+
if (!msg || typeof msg !== "object") return "";
|
|
2544
|
+
const scalar = (v) => {
|
|
2545
|
+
if (typeof v === "string") return v.trim();
|
|
2546
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
2547
|
+
if (typeof v === "bigint") return String(v);
|
|
2548
|
+
return "";
|
|
2549
|
+
};
|
|
2550
|
+
for (const k of ["message_id", "messageId", "msg_id"]) {
|
|
2551
|
+
const s = scalar(msg[k]);
|
|
2552
|
+
if (s) return s;
|
|
2553
|
+
}
|
|
2554
|
+
const seq = scalar(msg.seq);
|
|
2555
|
+
if (seq) return seq;
|
|
2556
|
+
const from = extractFromUserId(msg);
|
|
2557
|
+
const ts = scalar(msg.create_time_ms) || scalar(msg.createTimeMs) || scalar(msg.create_time);
|
|
2558
|
+
const text = extractInboundText(msg);
|
|
2559
|
+
if (!from && !ts && !text) return "";
|
|
2560
|
+
return `sha1:${crypto.createHash("sha1").update(`${from}|${ts}|${text}`).digest("hex")}`;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2451
2563
|
/**
|
|
2452
2564
|
* 别名 → 规范指令名。
|
|
2453
2565
|
* ⚠️ 编排层是按**规范名**分派的(`cmd === "/ls"`),所以别名必须在 `classifyInbound()`
|
|
@@ -2757,6 +2869,9 @@ export class WeChatChannel {
|
|
|
2757
2869
|
this.cooldown = new SessionCooldown({ cooldownMs: opts.cooldownMs, clock: this.clock });
|
|
2758
2870
|
this.registry = new EventRegistry({ clock: this.clock });
|
|
2759
2871
|
this.updatesBuf = "";
|
|
2872
|
+
// 落盘失败只 warn 一次:游标/去重表写不进去时,每个 tick 刷一行日志反而会把真正的故障淹掉。
|
|
2873
|
+
this.syncWarned = false;
|
|
2874
|
+
this.seenWarned = false;
|
|
2760
2875
|
// ★ 恢复上次记住的 context_token:不恢复的话,bridge 一重启就要等用户先发一条消息
|
|
2761
2876
|
// 才重新具备"能发出去"的能力 —— 而重启后的第一条通知恰恰是最需要送达的那条。
|
|
2762
2877
|
this.#restoreContextToken();
|
|
@@ -2770,21 +2885,245 @@ export class WeChatChannel {
|
|
|
2770
2885
|
} catch { /* 状态文件读不了就当没有:不影响主流程 */ }
|
|
2771
2886
|
}
|
|
2772
2887
|
|
|
2888
|
+
/** 按 bot 账号拼出某个状态文件名;还没有账号 → ""(说明此刻只能留内存,落不了盘)。 */
|
|
2889
|
+
#stateFilePath(prefix) {
|
|
2890
|
+
const key = botKeyFor(this.account);
|
|
2891
|
+
return key ? path.join(this.relayDir, `${prefix}${key}.json`) : "";
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/** 落盘类故障只 warn 一次(kind 区分游标 / 去重表,各自只报一次)。 */
|
|
2895
|
+
#warnOnce(kind, message) {
|
|
2896
|
+
if (kind === "sync") {
|
|
2897
|
+
if (this.syncWarned) return;
|
|
2898
|
+
this.syncWarned = true;
|
|
2899
|
+
} else {
|
|
2900
|
+
if (this.seenWarned) return;
|
|
2901
|
+
this.seenWarned = true;
|
|
2902
|
+
}
|
|
2903
|
+
try {
|
|
2904
|
+
this.logger.warn(message);
|
|
2905
|
+
} catch { /* 日志失败不能影响业务 */ }
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/** 删掉某个按 bot 账号命名的状态文件(游标 / 去重表共用;失败静默)。 */
|
|
2909
|
+
#removeStateFile(prefix, label) {
|
|
2910
|
+
const file = this.#stateFilePath(prefix);
|
|
2911
|
+
if (!file) return false;
|
|
2912
|
+
try {
|
|
2913
|
+
fs.rmSync(file, { force: true });
|
|
2914
|
+
return true;
|
|
2915
|
+
} catch (e) {
|
|
2916
|
+
this.#warnOnce(label, `${label}: 删除 ${path.basename(file)} 失败(下次启动可能沿用旧数据)${e && e.message ? `:${e.message}` : ""}`);
|
|
2917
|
+
return false;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// ── 游标持久化(get_updates_buf,按 bot 账号) ────────────────────────────────
|
|
2922
|
+
//
|
|
2923
|
+
// 背景见 UPDATES_BUF_FILE_PREFIX 的注释:游标只在内存 → bridge 重启/重连即丢 →
|
|
2924
|
+
// 服务端重投未确认消息 → 同一条消息派活两次。官方实现同样按 bot 账号落盘。
|
|
2925
|
+
|
|
2926
|
+
/**
|
|
2927
|
+
* 读回上次保存的 `get_updates_buf`。
|
|
2928
|
+
*
|
|
2929
|
+
* 同步读盘(调用点在 #channelLoop 的循环开头,那里是同步代码);
|
|
2930
|
+
* 读不到 / 形状不认识 / 属于另一个 bot → `""`(从"服务端给的起点"重新开始,不猜)。
|
|
2931
|
+
* 已经有过内存值就直接返回 —— 内存比盘新。
|
|
2932
|
+
* @returns {string}
|
|
2933
|
+
*/
|
|
2934
|
+
loadUpdatesBuf() {
|
|
2935
|
+
if (this.updatesBuf) return this.updatesBuf;
|
|
2936
|
+
const file = this.#stateFilePath(UPDATES_BUF_FILE_PREFIX);
|
|
2937
|
+
if (!file) return "";
|
|
2938
|
+
let data;
|
|
2939
|
+
try {
|
|
2940
|
+
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2941
|
+
} catch {
|
|
2942
|
+
return "";
|
|
2943
|
+
}
|
|
2944
|
+
if (!data || typeof data !== "object") return "";
|
|
2945
|
+
// 文件名已按账号分隔,这里再核一次 bot_id:状态文件可能被手工拷进别的账号目录,
|
|
2946
|
+
// 拿旧 bot 的游标去轮询只会让服务端从头重放(甚至报错)。
|
|
2947
|
+
const botId = this.account ? String(this.account.accountId || "") : "";
|
|
2948
|
+
if (data.bot_id && botId && String(data.bot_id) !== botId) return "";
|
|
2949
|
+
const buf = typeof data.get_updates_buf === "string" ? data.get_updates_buf : "";
|
|
2950
|
+
this.updatesBuf = buf;
|
|
2951
|
+
return buf;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* 保存游标(同步落盘,0600)。
|
|
2956
|
+
*
|
|
2957
|
+
* 空游标**不写**:服务端偶尔会回空串(或超时路径原样返回),拿空串覆盖掉上次的有效游标,
|
|
2958
|
+
* 下次重连就等于从头开始 —— 反而制造重放。写失败只 warn 一次,**不影响轮询主流程**。
|
|
2959
|
+
* @param {string} buf getUpdates 响应里的 get_updates_buf
|
|
2960
|
+
*/
|
|
2961
|
+
saveUpdatesBuf(buf) {
|
|
2962
|
+
const b = typeof buf === "string" ? buf : String(buf ?? "");
|
|
2963
|
+
if (!b) return;
|
|
2964
|
+
this.updatesBuf = b;
|
|
2965
|
+
const file = this.#stateFilePath(UPDATES_BUF_FILE_PREFIX);
|
|
2966
|
+
if (!file) {
|
|
2967
|
+
this.#warnOnce("sync", "saveUpdatesBuf: 还没有已绑定的 bot 账号,游标只保留在内存(重启后会重放未确认消息)");
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
try {
|
|
2971
|
+
writePrivateJson(file, { bot_id: this.account ? String(this.account.accountId || "") : "", get_updates_buf: b, at: this.clock() }, (m) => this.#warnOnce("sync", m));
|
|
2972
|
+
} catch (e) {
|
|
2973
|
+
this.#warnOnce("sync", `saveUpdatesBuf: 写盘失败(重启后可能重放未确认消息)${e && e.message ? `:${e.message}` : ""}`);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
/** 清空游标(内存 + 文件)。换绑 / 解绑必须调用:旧 bot 的游标对新账号毫无意义。 */
|
|
2978
|
+
clearUpdatesBuf() {
|
|
2979
|
+
this.updatesBuf = "";
|
|
2980
|
+
this.#removeStateFile(UPDATES_BUF_FILE_PREFIX, "sync");
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// ── 入站去重(按 bot 账号) ──────────────────────────────────────────────────
|
|
2984
|
+
|
|
2985
|
+
/**
|
|
2986
|
+
* 这条入站消息是不是"见过了"。
|
|
2987
|
+
*
|
|
2988
|
+
* 语义:**第一次见 → 记录并返回 false;见过 → true**。调用方(handleInbound)据此跳过重复消息,
|
|
2989
|
+
* 但它自己不是幂等的 —— 同一进程内对同一 id 连调两次,第二次一定是 true。
|
|
2990
|
+
*
|
|
2991
|
+
* id 为空 → 返回 false(不去重):拿不到消息标识时,宁可多派一次,也不能把用户消息丢掉。
|
|
2992
|
+
* 读/写盘失败一律 **fail-open**(当"没见过"),理由同上 —— 去重是优化,不是准入门槛。
|
|
2993
|
+
*/
|
|
2994
|
+
isDuplicateMessage(id) {
|
|
2995
|
+
const key = typeof id === "string" ? id.trim() : "";
|
|
2996
|
+
if (!key) return false;
|
|
2997
|
+
const file = this.#stateFilePath(SEEN_MESSAGES_FILE_PREFIX);
|
|
2998
|
+
if (!file) return false; // 还没绑定:没有可落盘的位置,只能不去重
|
|
2999
|
+
|
|
3000
|
+
const now = this.clock();
|
|
3001
|
+
const botId = this.account ? String(this.account.accountId || "") : "";
|
|
3002
|
+
let ids = [];
|
|
3003
|
+
try {
|
|
3004
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3005
|
+
if (data && typeof data === "object" && Array.isArray(data.ids)) {
|
|
3006
|
+
// 手改/半写坏的文件:只留形状正确的条目,别让一条脏数据把整张表带崩
|
|
3007
|
+
if (!data.bot_id || !botId || String(data.bot_id) === botId) {
|
|
3008
|
+
ids = data.ids.filter((e) => e && typeof e === "object" && typeof e.id === "string" && e.id);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
} catch {
|
|
3012
|
+
ids = []; // 文件不存在 / 损坏 → 当空表(fail-open)
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
if (ids.some((e) => e.id === key)) return true;
|
|
3016
|
+
|
|
3017
|
+
ids.push({ id: key, at: now });
|
|
3018
|
+
// 有界:先按保质期丢过期,再只留最近 SEEN_MESSAGES_MAX 条(数组按写入顺序,越靠前越旧)
|
|
3019
|
+
ids = ids.filter((e) => Number.isFinite(e.at) && e.at >= now - SEEN_MESSAGES_TTL_MS);
|
|
3020
|
+
if (ids.length > SEEN_MESSAGES_MAX) ids = ids.slice(ids.length - SEEN_MESSAGES_MAX);
|
|
3021
|
+
try {
|
|
3022
|
+
writePrivateJson(file, { bot_id: botId, ids }, (m) => this.#warnOnce("seen", m));
|
|
3023
|
+
} catch (e) {
|
|
3024
|
+
// 写不进去只影响"重启后还认得",本次仍然放行(fail-open)
|
|
3025
|
+
this.#warnOnce("seen", `isDuplicateMessage: 去重表写盘失败(重启后可能重派同一条消息)${e && e.message ? `:${e.message}` : ""}`);
|
|
3026
|
+
}
|
|
3027
|
+
return false;
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
/** 清空去重表(解绑时调用:上一个人的消息指纹没有留着的理由)。 */
|
|
3031
|
+
#clearSeenMessages() {
|
|
3032
|
+
this.#removeStateFile(SEEN_MESSAGES_FILE_PREFIX, "seen");
|
|
3033
|
+
}
|
|
3034
|
+
|
|
2773
3035
|
/**
|
|
2774
3036
|
* 记下入站消息带来的 `context_token`(发送时回带它才发得出去,见 sendMessage 注释)。
|
|
2775
3037
|
* 落盘持久化:重启/换实例后仍然能发;同时**按对端成对保存**,绝不跨对端复用。
|
|
3038
|
+
*
|
|
3039
|
+
* 扩展(2026-10):同时写 `context_token_at`,并**清掉 push_paused**。
|
|
3040
|
+
* 为什么:`push_paused` 表达的是"我们手上的令牌已过期,别再打接口了";
|
|
3041
|
+
* 收到入站消息 = 平台刚发了一个新令牌,那个结论当场作废。不清的后果是
|
|
3042
|
+
* 用户明明回了消息(token 已刷新),主动推送却还被静默着,直到某次发送"意外成功"才解除。
|
|
2776
3043
|
* @returns {boolean} 是否有变化(无变化不重复写盘)
|
|
2777
3044
|
*/
|
|
2778
3045
|
rememberContextToken(token, peerUserId) {
|
|
2779
3046
|
const t = String(token || "").trim();
|
|
2780
3047
|
if (!t) return false;
|
|
2781
3048
|
const peer = String(peerUserId || "");
|
|
2782
|
-
|
|
3049
|
+
const unchanged = this.client.contextToken === t && this.client.contextTokenPeer === peer;
|
|
3050
|
+
// 令牌没变、也没处在"推送静默"里 → 不必重复写盘(入站频率不低,每次写会放大磁盘 IO)。
|
|
3051
|
+
// 处在静默里时必须写:入站就是"令牌已刷新"的信号,静默要当场解除。
|
|
3052
|
+
if (unchanged && !this.pushPaused().paused) return false;
|
|
2783
3053
|
this.client.setContextToken(t, peer);
|
|
2784
|
-
this.writeState({
|
|
3054
|
+
this.writeState({
|
|
3055
|
+
context_token: t,
|
|
3056
|
+
context_token_peer: peer,
|
|
3057
|
+
context_token_at: this.clock(),
|
|
3058
|
+
// saveState 用 JSON.stringify 落盘,undefined 值会被省略 → 等价于把键从状态文件里删掉。
|
|
3059
|
+
// 用"置 undefined"而不是"置 false":状态文件里"没有这个键"才是"从未静默",不会被误读成已暂停。
|
|
3060
|
+
push_paused: undefined
|
|
3061
|
+
});
|
|
2785
3062
|
return true;
|
|
2786
3063
|
}
|
|
2787
3064
|
|
|
3065
|
+
/**
|
|
3066
|
+
* 标记"上下文令牌已过期" → 清令牌 + 进入推送静默(push_paused)。
|
|
3067
|
+
*
|
|
3068
|
+
* 为什么需要它(见 sendMessage 里 context_token_expired 的注释):
|
|
3069
|
+
* ilink 的 context_token 只有分钟级寿命且无法续期。撞到 `ret=-2 prepare failed` 之后,
|
|
3070
|
+
* 继续拿同一条消息重试永远失败 —— 只会把日志和接口打爆。正确做法是:
|
|
3071
|
+
* 立刻承认"现在发不出去",清掉过期令牌,记下"静默中",等下一次入站(新令牌)再恢复补发。
|
|
3072
|
+
*
|
|
3073
|
+
* @param {string} reason 失败原因(通常来自错误原文)。**会脱敏 + 截断**后写进状态文件,
|
|
3074
|
+
* 因为 `.wechat-state.json` 是面板可读的;它也可能被日志/排障包带走。绝不把 token 写进去。
|
|
3075
|
+
* @returns {{paused:boolean, at:number, reason:string}}
|
|
3076
|
+
*/
|
|
3077
|
+
invalidateContextToken(reason = "") {
|
|
3078
|
+
// 脱敏:reason 常是 sendMessage 的错误原文,里面可能回显 token/URL。
|
|
3079
|
+
// 用"当前所有已知密钥"抹一遍(账号 token + 即将被清的上下文令牌),再压成单行、截断。
|
|
3080
|
+
const known = [this.account && this.account.token, this.client.contextToken].filter((s) => typeof s === "string" && s.length >= 4);
|
|
3081
|
+
let safe = maskFieldsInText(String(redact(String(reason ?? ""), known)))
|
|
3082
|
+
.replace(/\s*\n\s*/g, " ")
|
|
3083
|
+
.trim();
|
|
3084
|
+
if (safe.length > PUSH_PAUSE_REASON_MAX) safe = `${safe.slice(0, PUSH_PAUSE_REASON_MAX - 1)}…`;
|
|
3085
|
+
// 先看是不是**已经**在静默里:这决定 warn 与 push_paused.at 怎么处理(见下)。
|
|
3086
|
+
const wasPaused = this.pushPaused();
|
|
3087
|
+
// 内存令牌立刻清掉:留着它只会让后面每条主动推送再撞一次同样的 prepare failed。
|
|
3088
|
+
this.client.clearContextToken();
|
|
3089
|
+
this.writeState({
|
|
3090
|
+
context_token: "",
|
|
3091
|
+
context_token_peer: "",
|
|
3092
|
+
context_token_at: 0,
|
|
3093
|
+
// 已经静默时**保留第一次进入静默的时间**:它回答的是"从什么时候开始发不出去"。
|
|
3094
|
+
// 每次失败都刷新会让面板/日志节流窗口看起来永远"刚刚发生",反而看不出这次静默持续了多久。
|
|
3095
|
+
push_paused: { at: wasPaused.paused ? wasPaused.at : this.clock(), reason: safe }
|
|
3096
|
+
});
|
|
3097
|
+
// ⚠️ 静默期间这个函数仍会被反复调用(断线重连、replay/订阅 gap 的提示、非主动推送的
|
|
3098
|
+
// reply() 路径……),每次失败都 warn 一条 → 断线风暴时就是刷屏,真正的故障反而被淹掉。
|
|
3099
|
+
// 所以只在"非静默 → 静默"这一次报;状态仍然每次都如实写(令牌清空 + reason 更新)。
|
|
3100
|
+
if (!wasPaused.paused) {
|
|
3101
|
+
this.logger.warn(`推送静默:上下文令牌已过期(${safe || "prepare failed"}),等下一次入站消息再恢复`);
|
|
3102
|
+
}
|
|
3103
|
+
return this.pushPaused();
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
/** 解除推送静默(发送成功后调用)。幂等:本来就没静默时也不会造出这个键。 */
|
|
3107
|
+
clearPushPaused() {
|
|
3108
|
+
return this.writeState({ push_paused: undefined });
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
/**
|
|
3112
|
+
* 当前是否处在"主动推送静默"里(读状态文件;损坏/缺失一律当"没静默",不能凭空挡住推送)。
|
|
3113
|
+
* @returns {{paused:boolean, at:number, reason:string}}
|
|
3114
|
+
*/
|
|
3115
|
+
pushPaused() {
|
|
3116
|
+
const st = this.readState();
|
|
3117
|
+
const p = st.push_paused;
|
|
3118
|
+
if (!p || typeof p !== "object") return { paused: false, at: 0, reason: "" };
|
|
3119
|
+
const at = Number(p.at);
|
|
3120
|
+
return {
|
|
3121
|
+
paused: true,
|
|
3122
|
+
at: Number.isFinite(at) ? at : 0,
|
|
3123
|
+
reason: typeof p.reason === "string" ? p.reason : ""
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
|
|
2788
3127
|
/** 面板接口用:永不回显 token(§8)。 */
|
|
2789
3128
|
uiAccount() {
|
|
2790
3129
|
return sanitizeAccount(this.account);
|
|
@@ -2812,6 +3151,17 @@ export class WeChatChannel {
|
|
|
2812
3151
|
},
|
|
2813
3152
|
(m) => this.logger.warn(m)
|
|
2814
3153
|
);
|
|
3154
|
+
// ★ 换绑 = 换了一个 bot:上一个 bot 落盘的游标 / 去重表必须清掉。
|
|
3155
|
+
// 游标:旧游标会让服务端从头重放未确认消息(用户看到的"补发一堆"),甚至因窗口不匹配直接报错;
|
|
3156
|
+
// 去重表:里面是上一个用户的消息指纹,换人后没有任何保留理由(隐私 + 避免误判重复)。
|
|
3157
|
+
// ⚠️ 顺序:必须在 `this.account = account` **之前**清 —— 这两个文件的文件名由账号派生,
|
|
3158
|
+
// 先赋值新账号就变成去删**新**账号的文件,而旧 bot 的文件永远残留(换绑/隐私清理不彻底)。
|
|
3159
|
+
if (this.account) {
|
|
3160
|
+
this.clearUpdatesBuf(); // 清内存游标 + 删旧 bot 的游标文件
|
|
3161
|
+
this.#clearSeenMessages(); // 删旧 bot 的去重表
|
|
3162
|
+
}
|
|
3163
|
+
// 内存游标属于上一个 bot:哪怕旧 account 为空(未绑定态)也一并清掉,绝不让它带到新账号上。
|
|
3164
|
+
this.updatesBuf = "";
|
|
2815
3165
|
this.account = account;
|
|
2816
3166
|
this.logger.addSecret(account.token); // 之后任何日志行都会自动脱敏
|
|
2817
3167
|
this.client = new IlinkClient({
|
|
@@ -2828,7 +3178,10 @@ export class WeChatChannel {
|
|
|
2828
3178
|
bot_id: account.accountId,
|
|
2829
3179
|
bound_at: account.boundAt,
|
|
2830
3180
|
last_error: "",
|
|
2831
|
-
connected_at: this.clock()
|
|
3181
|
+
connected_at: this.clock(),
|
|
3182
|
+
// 绑定成功 = 全新 bot_token + 新会话:之前"推送静默"的结论作废,
|
|
3183
|
+
// 否则用户重绑后第一条推送会被一个已经不存在的旧结论挡掉。
|
|
3184
|
+
push_paused: undefined
|
|
2832
3185
|
});
|
|
2833
3186
|
// ★ 绑定成功必须**清掉冷却**(2026-09-22 实测 bug)。
|
|
2834
3187
|
// 冷却原本只按"撞到 -14"计时,但**换绑会拿到全新的 bot_token** ——
|
|
@@ -2854,13 +3207,32 @@ export class WeChatChannel {
|
|
|
2854
3207
|
this.logger.warn(`unbind: notifystop 失败(继续删凭据)${notifyError}`);
|
|
2855
3208
|
}
|
|
2856
3209
|
}
|
|
3210
|
+
// ★ 解绑必须清掉按 bot 账号落盘的两份本地状态:
|
|
3211
|
+
// ① 游标:旧 bot 的游标对新账号毫无意义,留着还会让服务端重放旧消息;
|
|
3212
|
+
// ② 去重表:里面是上一个用户的消息指纹,换人后没有任何保留理由(隐私 + 避免误判重复)。
|
|
3213
|
+
// ⚠️ 顺序:必须在 `this.account = null` **之前**清 —— 文件名是由账号派生的,
|
|
3214
|
+
// 账号置空后就再也算不出该删哪个文件了(会静默留下垃圾)。
|
|
3215
|
+
this.clearUpdatesBuf();
|
|
3216
|
+
this.#clearSeenMessages();
|
|
2857
3217
|
const cleared = clearAccount(this.relayDir);
|
|
2858
3218
|
this.account = null;
|
|
2859
3219
|
this.client.setToken("");
|
|
2860
3220
|
// ★ 会话上下文令牌属于**上一个人/上一个会话**:解绑必须一并清掉,
|
|
2861
3221
|
// 否则换绑后第一条推送会拿旧上下文去发 —— 表现还是"发不出去"。
|
|
2862
3222
|
this.client.clearContextToken();
|
|
2863
|
-
|
|
3223
|
+
// 解绑后"推送静默"这个结论也没有意义了(静默是给"令牌过期"用的,而令牌已删);
|
|
3224
|
+
// 不清的话,重新绑定后 state 里残留的 push_paused 会让新会话一开始就被静默。
|
|
3225
|
+
this.writeState({
|
|
3226
|
+
bound: false,
|
|
3227
|
+
bot_id: "",
|
|
3228
|
+
bound_at: 0,
|
|
3229
|
+
connected_at: 0,
|
|
3230
|
+
last_error: notifyError,
|
|
3231
|
+
context_token: "",
|
|
3232
|
+
context_token_peer: "",
|
|
3233
|
+
context_token_at: 0,
|
|
3234
|
+
push_paused: undefined
|
|
3235
|
+
});
|
|
2864
3236
|
// ★ 解绑同样清冷却:凭据都删了,再"退避"没有任何意义 ——
|
|
2865
3237
|
// 留着只会让用户重新绑定时继续被挡(见 adoptConfirmed 的注释)。
|
|
2866
3238
|
this.cooldown.clear();
|