@mrrisega/dsh-remote 0.7.7 → 0.7.9-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/clients/dsh-remote/test/wechat-channel.test.mjs +452 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +568 -29
- package/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/dsh-setup.mjs +200 -48
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +37 -2
- package/packages/dsh-remote-web/lib/index.js +347 -53
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +200 -48
- package/packages/dsh-remote-web/test/setup-win-scan.test.mjs +455 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +50 -0
- package/packages/dsh-remote-web/test/windows-process-discovery.test.mjs +427 -0
|
@@ -253,7 +253,14 @@ function clearBindError() {
|
|
|
253
253
|
* bridge 运行状态文件:供 dsh web 插件面板判定「设备是否已在中继注册成功」(连接阶段 online)。
|
|
254
254
|
* 面板用它把「bridge 进程在跑」与「设备已注册、手机端真的能用」区分开——后者才是用户关心的状态,
|
|
255
255
|
* 只按进程存活判断会谎报可用。
|
|
256
|
-
* { device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
|
|
256
|
+
* { pid, heartbeat_at, device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
|
|
257
|
+
*
|
|
258
|
+
* `pid` / `heartbeat_at` 是 **bridge 自证存活**的第二判据(2026-10 `bridge_wedged` 事故):
|
|
259
|
+
* · 进程启动时写一次 `pid: process.pid`;
|
|
260
|
+
* · 心跳拍里节流刷新 `heartbeat_at`(≥10s 一次,失败静默)。
|
|
261
|
+
* 插件侧可以据此用「state.pid 活着 + heartbeat 新鲜」判断桥是否在跑,**不必**依赖
|
|
262
|
+
* 按镜像名(node.exe)扫描 —— Electron 宿主拉起的进程名压根不是 node.exe,扫描会漏。
|
|
263
|
+
*
|
|
257
264
|
* 每次进程启动都 reset 一次:上一轮进程的成功记录不能代表当前这一轮。
|
|
258
265
|
*/
|
|
259
266
|
const BRIDGE_STATE_FILE = path.join(path.dirname(CONFIG_PATH), ".dsh-bridge-state.json");
|
|
@@ -268,6 +275,22 @@ function persistBridgeState(patch, opts = {}) {
|
|
|
268
275
|
} catch { /* 非关键:面板侧还有日志/账号兜底判据 */ }
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
/**
|
|
279
|
+
* 状态文件心跳:把「我这一秒确实还活着」写进 `.dsh-bridge-state.json`(节流 ≥10s)。
|
|
280
|
+
*
|
|
281
|
+
* 为什么要节流:心跳拍的默认间隔是 20s(TUNNEL_HEARTBEAT_MS),但 RTT 好时自适应会缩短;
|
|
282
|
+
* 每拍都整文件重写既没必要(面板轮询 2~3s,10s 粒度足够"新鲜")又增加与读侧撞车的机会。
|
|
283
|
+
* 失败一律静默:状态文件只是**附加**判据,写不进去不得影响隧道(旧行为保持)。
|
|
284
|
+
*/
|
|
285
|
+
const STATE_HEARTBEAT_MIN_MS = 10_000;
|
|
286
|
+
let lastStateHeartbeatAt = 0;
|
|
287
|
+
function stampBridgeHeartbeat() {
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
if (now - lastStateHeartbeatAt < STATE_HEARTBEAT_MIN_MS) return;
|
|
290
|
+
lastStateHeartbeatAt = now;
|
|
291
|
+
persistBridgeState({ pid: process.pid, heartbeat_at: now });
|
|
292
|
+
}
|
|
293
|
+
|
|
271
294
|
// ---------- 安装来源 / 版本(服务端设备行统计约定) ----------
|
|
272
295
|
// DSH_BRIDGE_INSTALL_SOURCE 由一键安装器(dsh-setup.mjs)注入 = npx;未设置表示这台电脑走的是
|
|
273
296
|
// 「插件市场装面板插件 + 插件自愈补装运行环境」那条路径 = plugin_market;白名单外一律 unknown。
|
|
@@ -2485,6 +2508,10 @@ function connectTunnel(token) {
|
|
|
2485
2508
|
// 事件循环延迟逐拍自适应(见 adaptiveHeartbeat 长注释)。
|
|
2486
2509
|
const beat = () => {
|
|
2487
2510
|
heartbeat = null;
|
|
2511
|
+
// 自证存活:每一拍(节流 ≥10s)刷新状态文件里的 pid/heartbeat_at —— 插件用它做
|
|
2512
|
+
// 「state.pid 活着 + heartbeat 新鲜」的第二判据,不依赖按镜像名扫描进程。
|
|
2513
|
+
// 放在判定之前:即使这一拍即将判死重连,只要进程还活着,状态文件就该是新鲜的。
|
|
2514
|
+
stampBridgeHeartbeat();
|
|
2488
2515
|
const plan = adaptiveHeartbeat({
|
|
2489
2516
|
pongReceived,
|
|
2490
2517
|
missedPongs,
|
|
@@ -2956,8 +2983,10 @@ async function runTunnel() {
|
|
|
2956
2983
|
// (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
|
|
2957
2984
|
// 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
|
|
2958
2985
|
WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
|
|
2959
|
-
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
|
|
2960
|
-
|
|
2986
|
+
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)。
|
|
2987
|
+
// `pid` 一并写入 = **bridge 自证身份**:插件可以用「state.pid 活着 + heartbeat_at 新鲜」
|
|
2988
|
+
// 判断桥是否真的在跑,不必依赖按镜像名(node.exe)扫描 —— 桌面端宿主拉起的进程名不是 node.exe。
|
|
2989
|
+
persistBridgeState({ pid: process.pid, heartbeat_at: Date.now(), device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
|
|
2961
2990
|
await initE2ee(token);
|
|
2962
2991
|
const reg = await registerDeviceInAccount(token);
|
|
2963
2992
|
if (reg && reg.status === "locked") {
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
formatNotification,
|
|
61
61
|
handleCommand,
|
|
62
62
|
hardenFile,
|
|
63
|
+
inboundMessageId,
|
|
63
64
|
isDestructiveTool,
|
|
64
65
|
isSessionExpired,
|
|
65
66
|
loadAccount,
|
|
@@ -2110,3 +2111,454 @@ test("★ 回归:Windows 上取不到用户名时必须告警,而不是静默跳
|
|
|
2110
2111
|
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2111
2112
|
}
|
|
2112
2113
|
});
|
|
2114
|
+
|
|
2115
|
+
// ===========================================================================
|
|
2116
|
+
// 13. ClawBot 修复回归锁(错误分类 / 游标持久化 / 入站去重 / 令牌生命周期)
|
|
2117
|
+
// ===========================================================================
|
|
2118
|
+
//
|
|
2119
|
+
// 对应 CONTRACT §2.1–§2.4 与 §5 的 channel 部分。全部走本地 mock / 临时目录,
|
|
2120
|
+
// 不打真接口(scripts/test-net-guard.cjs 会拦掉一切非 loopback 请求)。
|
|
2121
|
+
//
|
|
2122
|
+
// 背景(用户 2026-10 报的两个 bug):
|
|
2123
|
+
// · 「AI 不能主动回消息 / 每日简报不发 / 发「你好」后补发一堆」→ context_token 分钟级过期,
|
|
2124
|
+
// 而旧实现把 `ret=-2 prepare failed` 当"间歇性网络失败"每小时猛打 60 次;
|
|
2125
|
+
// · 「会话发两遍 / 收不到 Agent 返回」→ 游标只在内存(重启即重放)+ 入站没有按 id 去重。
|
|
2126
|
+
|
|
2127
|
+
/** 找出按 bot 账号落盘的状态文件(命名约定由 CONTRACT §2.2/§2.3 规定)。 */
|
|
2128
|
+
function botStateFiles(dir, prefix) {
|
|
2129
|
+
return fs.readdirSync(dir).filter((f) => f.startsWith(prefix) && f.endsWith(".json"));
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// ── §2.1 错误分类 ─────────────────────────────────────────────────────────────
|
|
2133
|
+
|
|
2134
|
+
test("ClawBot §2.1:sendMessage ret=-2 + 'prepare failed' → WeChatError(context_token_expired)", async () => {
|
|
2135
|
+
const mock = await withMockServer((rec, send) => {
|
|
2136
|
+
if (rec.path === "/ilink/bot/sendmessage") return send(200, { ret: -2, errmsg: "prepare failed" });
|
|
2137
|
+
return send(404, {});
|
|
2138
|
+
});
|
|
2139
|
+
try {
|
|
2140
|
+
const client = new IlinkClient({ baseUrl: mock.baseUrl, token: SECRET_TOKEN, logger: silentLogger() });
|
|
2141
|
+
await assert.rejects(
|
|
2142
|
+
() => client.sendMessage({ to: "user-1", text: "任务完成了" }),
|
|
2143
|
+
(e) => {
|
|
2144
|
+
assert.ok(e instanceof WeChatError, "必须是 WeChatError 而不是普通 Error");
|
|
2145
|
+
assert.equal(e.code, "context_token_expired", "★ 必须是一等错误码:调用方据此暂停主动推送,而不是重试");
|
|
2146
|
+
assert.equal(e.detail.ret, -2);
|
|
2147
|
+
assert.match(String(e.detail.errmsg), /prepare failed/i);
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
);
|
|
2151
|
+
} finally {
|
|
2152
|
+
await mock.close();
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
test("ClawBot §2.1:只有 ret=-2 且 errmsg 是 prepare failed 才判为令牌过期(不误伤其它错误)", async () => {
|
|
2157
|
+
const bodies = [
|
|
2158
|
+
{ ret: -2, errmsg: "Prepare Failed" }, // 大小写不同,仍是同一类(平台文案不保证大小写)
|
|
2159
|
+
{ ret: -2, errmsg: "unknown error" }, // ret 相同但语义不同 → 不能当令牌过期(否则会错误地静默推送)
|
|
2160
|
+
{ ret: 5, errmsg: "prepare failed" } // errmsg 相同但 ret 不同 → 也不能当令牌过期
|
|
2161
|
+
];
|
|
2162
|
+
let i = 0;
|
|
2163
|
+
const mock = await withMockServer((rec, send) => {
|
|
2164
|
+
if (rec.path === "/ilink/bot/sendmessage") {
|
|
2165
|
+
send(200, bodies[i]);
|
|
2166
|
+
i += 1;
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
return send(404, {});
|
|
2170
|
+
});
|
|
2171
|
+
try {
|
|
2172
|
+
const client = new IlinkClient({ baseUrl: mock.baseUrl, token: SECRET_TOKEN, logger: silentLogger() });
|
|
2173
|
+
const codes = [];
|
|
2174
|
+
for (let n = 0; n < bodies.length; n += 1) {
|
|
2175
|
+
try {
|
|
2176
|
+
await client.sendMessage({ to: "user-1", text: "x" });
|
|
2177
|
+
codes.push("ok");
|
|
2178
|
+
} catch (e) {
|
|
2179
|
+
codes.push(e.code);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
assert.deepEqual(codes, ["context_token_expired", "http_error", "http_error"]);
|
|
2183
|
+
} finally {
|
|
2184
|
+
await mock.close();
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
test("ClawBot §2.1:errcode -14 仍保持 session_expired(旧防护不能被新分类吃掉)", async () => {
|
|
2189
|
+
const mock = await withMockServer((rec, send) => {
|
|
2190
|
+
if (rec.path === "/ilink/bot/sendmessage") return send(200, { ret: 0, errcode: -14 });
|
|
2191
|
+
return send(404, {});
|
|
2192
|
+
});
|
|
2193
|
+
try {
|
|
2194
|
+
const client = new IlinkClient({ baseUrl: mock.baseUrl, token: SECRET_TOKEN, logger: silentLogger() });
|
|
2195
|
+
await assert.rejects(
|
|
2196
|
+
() => client.sendMessage({ to: "user-1", text: "x" }),
|
|
2197
|
+
(e) => {
|
|
2198
|
+
assert.equal(e.code, "session_expired");
|
|
2199
|
+
return true;
|
|
2200
|
+
}
|
|
2201
|
+
);
|
|
2202
|
+
} finally {
|
|
2203
|
+
await mock.close();
|
|
2204
|
+
}
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
// ── §2.2 游标持久化 ───────────────────────────────────────────────────────────
|
|
2208
|
+
|
|
2209
|
+
test("ClawBot §2.2:游标落盘 → 新实例 load 回同值(按 bot 账号,0600)", async () => {
|
|
2210
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-cursor-"));
|
|
2211
|
+
try {
|
|
2212
|
+
const clock = fakeClock();
|
|
2213
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2214
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-cursor", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2215
|
+
ch.saveUpdatesBuf("cursor-1");
|
|
2216
|
+
assert.equal(ch.updatesBuf, "cursor-1", "内存游标要同步更新(不必再读盘)");
|
|
2217
|
+
|
|
2218
|
+
const files = botStateFiles(dir, ".wechat-sync-");
|
|
2219
|
+
assert.equal(files.length, 1, `应恰好落一个游标文件,实际 ${JSON.stringify(files)}`);
|
|
2220
|
+
assert.ok(files[0].includes("bot-cursor"), `文件名必须按 bot 账号派生,实际 ${files[0]}`);
|
|
2221
|
+
const file = path.join(dir, files[0]);
|
|
2222
|
+
assert.equal(fileMode(file), 0o600, "游标文件必须 0600");
|
|
2223
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2224
|
+
assert.equal(raw.get_updates_buf, "cursor-1");
|
|
2225
|
+
assert.equal(raw.bot_id, "bot-cursor");
|
|
2226
|
+
assert.equal(raw.at, clock(), "要记下保存时间(排障用)");
|
|
2227
|
+
|
|
2228
|
+
// bridge 重启:凭据在盘上 → 必须读回同一个游标(否则服务端会把已处理的消息重投一遍)
|
|
2229
|
+
const ch2 = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2230
|
+
assert.equal(ch2.loadUpdatesBuf(), "cursor-1", "★ 重启后必须接着上次的游标");
|
|
2231
|
+
|
|
2232
|
+
// 空游标绝不能覆盖已有进度(超时路径会原样返回空串)
|
|
2233
|
+
ch2.saveUpdatesBuf("");
|
|
2234
|
+
assert.equal(ch2.updatesBuf, "cursor-1", "空游标不得把内存值清掉");
|
|
2235
|
+
assert.equal(new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock }).loadUpdatesBuf(), "cursor-1");
|
|
2236
|
+
|
|
2237
|
+
ch2.clearUpdatesBuf();
|
|
2238
|
+
assert.equal(ch2.updatesBuf, "");
|
|
2239
|
+
assert.equal(fs.existsSync(file), false, "clear 必须删掉文件");
|
|
2240
|
+
assert.equal(new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock }).loadUpdatesBuf(), "");
|
|
2241
|
+
} finally {
|
|
2242
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
test("ClawBot §2.2:换绑清掉旧 bot 的本地状态(旧游标/去重表不残留,新 bot 从空开始)", async () => {
|
|
2247
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-cursor2-"));
|
|
2248
|
+
try {
|
|
2249
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock: fakeClock() });
|
|
2250
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-A", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2251
|
+
ch.saveUpdatesBuf("cursor-A");
|
|
2252
|
+
assert.equal(ch.isDuplicateMessage("m-A"), false);
|
|
2253
|
+
const oldSync = botStateFiles(dir, ".wechat-sync-")[0];
|
|
2254
|
+
const oldSeen = botStateFiles(dir, ".wechat-seen-")[0];
|
|
2255
|
+
assert.ok(oldSync && oldSync.includes("bot-A"), "前置:bot-A 的游标已落盘");
|
|
2256
|
+
assert.ok(oldSeen && oldSeen.includes("bot-A"), "前置:bot-A 的去重表已落盘");
|
|
2257
|
+
|
|
2258
|
+
// 换绑到另一个 bot:旧 bot 的文件必须按**旧**账号指纹删掉(不是在改成新账号之后去删新账号的)
|
|
2259
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-B", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2260
|
+
assert.equal(ch.loadUpdatesBuf(), "", "★ 换绑后游标必须从空开始(旧 bot 的游标会让服务端重放)");
|
|
2261
|
+
assert.equal(fs.existsSync(path.join(dir, oldSync)), false, "★ 旧 bot 的游标文件必须被清掉,不能残留");
|
|
2262
|
+
assert.equal(fs.existsSync(path.join(dir, oldSeen)), false, "★ 旧 bot 的去重表必须被清掉(上一个用户的消息指纹)");
|
|
2263
|
+
|
|
2264
|
+
// 新 bot 落盘时用的是**新**账号的名字,不会覆盖回旧文件
|
|
2265
|
+
ch.saveUpdatesBuf("cursor-B");
|
|
2266
|
+
const files = botStateFiles(dir, ".wechat-sync-");
|
|
2267
|
+
assert.equal(files.length, 1, `换绑后只应剩新 bot 的一份游标,实际 ${JSON.stringify(files)}`);
|
|
2268
|
+
assert.ok(files[0].includes("bot-B"), `文件名必须按新 bot 账号派生,实际 ${files[0]}`);
|
|
2269
|
+
// 新实例(按盘上凭据 = bot-B)读回的必须是自己的
|
|
2270
|
+
const ch2 = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock: fakeClock() });
|
|
2271
|
+
assert.equal(ch2.loadUpdatesBuf(), "cursor-B");
|
|
2272
|
+
} finally {
|
|
2273
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2274
|
+
}
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
test("ClawBot §2.2:adoptConfirmed() 清空游标(重绑不得沿用旧游标)", async () => {
|
|
2278
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-cursor3-"));
|
|
2279
|
+
try {
|
|
2280
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock: fakeClock() });
|
|
2281
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-rebind", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2282
|
+
ch.saveUpdatesBuf("stale-cursor");
|
|
2283
|
+
assert.equal(botStateFiles(dir, ".wechat-sync-").length, 1, "前置:游标已落盘");
|
|
2284
|
+
|
|
2285
|
+
// 重新绑定同一个 bot → 必须从空游标开始(旧游标属于上一次会话)
|
|
2286
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-rebind", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2287
|
+
assert.equal(ch.updatesBuf, "", "内存游标必须清空");
|
|
2288
|
+
assert.equal(ch.loadUpdatesBuf(), "", "★ adoptConfirmed 必须清空游标");
|
|
2289
|
+
assert.equal(botStateFiles(dir, ".wechat-sync-").length, 0, "游标文件必须被删掉");
|
|
2290
|
+
} finally {
|
|
2291
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2292
|
+
}
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
test("ClawBot §2.2/§2.3:unbind() 清空游标与去重表", async () => {
|
|
2296
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-cursor4-"));
|
|
2297
|
+
try {
|
|
2298
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock: fakeClock() });
|
|
2299
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-unbind", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2300
|
+
ch.saveUpdatesBuf("cursor-x");
|
|
2301
|
+
assert.equal(ch.isDuplicateMessage("m-unbind"), false);
|
|
2302
|
+
assert.equal(botStateFiles(dir, ".wechat-sync-").length, 1, "前置:游标已落盘");
|
|
2303
|
+
assert.equal(botStateFiles(dir, ".wechat-seen-").length, 1, "前置:去重表已落盘");
|
|
2304
|
+
|
|
2305
|
+
const r = await ch.unbind();
|
|
2306
|
+
assert.equal(r.ok, true);
|
|
2307
|
+
assert.equal(ch.updatesBuf, "", "内存游标必须清空");
|
|
2308
|
+
assert.equal(botStateFiles(dir, ".wechat-sync-").length, 0, "★ 解绑必须删掉游标文件");
|
|
2309
|
+
assert.equal(botStateFiles(dir, ".wechat-seen-").length, 0, "★ 解绑必须清空去重表(上一个人的指纹没有保留理由)");
|
|
2310
|
+
} finally {
|
|
2311
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
// ── §2.3 入站去重 ────────────────────────────────────────────────────────────
|
|
2316
|
+
|
|
2317
|
+
test("ClawBot §2.3:inboundMessageId 取值优先级(message_id > seq > 指纹),都没有 → 空", () => {
|
|
2318
|
+
assert.equal(inboundMessageId({ message_id: "m-1" }), "m-1");
|
|
2319
|
+
assert.equal(inboundMessageId({ message_id: 12345 }), "12345", "数字 message_id 要转成字符串");
|
|
2320
|
+
assert.equal(inboundMessageId({ seq: 7 }), "7", "没有 message_id 时退回 seq");
|
|
2321
|
+
const msg = { from_user_id: "u1", create_time_ms: 1_700_000_000_000, item_list: [{ type: 1, text_item: { text: "你好" } }] };
|
|
2322
|
+
const idA = inboundMessageId(msg);
|
|
2323
|
+
const idB = inboundMessageId({
|
|
2324
|
+
from_user_id: "u1",
|
|
2325
|
+
create_time_ms: 1_700_000_000_000,
|
|
2326
|
+
item_list: [{ type: 1, text_item: { text: "你好" } }]
|
|
2327
|
+
});
|
|
2328
|
+
assert.match(idA, /^sha1:[0-9a-f]{40}$/, "两者都没有时要退到 sha1 指纹");
|
|
2329
|
+
assert.equal(idA, idB, "同一条消息必须得到同一个 id(否则去重失效)");
|
|
2330
|
+
assert.notEqual(
|
|
2331
|
+
idA,
|
|
2332
|
+
inboundMessageId({ from_user_id: "u1", create_time_ms: 1_700_000_000_001, item_list: [{ type: 1, text_item: { text: "你好" } }] }),
|
|
2333
|
+
"时间不同 → 不是同一条消息"
|
|
2334
|
+
);
|
|
2335
|
+
assert.equal(inboundMessageId({}), "");
|
|
2336
|
+
assert.equal(inboundMessageId({ message_id: " " }), "", "纯空白当没有");
|
|
2337
|
+
assert.equal(inboundMessageId(null), "");
|
|
2338
|
+
assert.equal(inboundMessageId("hello"), "");
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
test("ClawBot §2.3:同 id 第二次判重;重启后仍认得;空 id 不去重", async () => {
|
|
2342
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-seen-"));
|
|
2343
|
+
try {
|
|
2344
|
+
const clock = fakeClock();
|
|
2345
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2346
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-seen", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2347
|
+
|
|
2348
|
+
assert.equal(ch.isDuplicateMessage("m-1"), false, "第一次见 → 不是重复");
|
|
2349
|
+
assert.equal(ch.isDuplicateMessage("m-1"), true, "★ 第二次必须判重(重投的同一条消息)");
|
|
2350
|
+
assert.equal(ch.isDuplicateMessage(""), false, "★ 空 id 不去重:宁可多派一次,也不能吞掉用户消息");
|
|
2351
|
+
|
|
2352
|
+
const files = botStateFiles(dir, ".wechat-seen-");
|
|
2353
|
+
assert.equal(files.length, 1, "去重表要落盘");
|
|
2354
|
+
assert.ok(files[0].includes("bot-seen"), `文件名必须按 bot 账号派生,实际 ${files[0]}`);
|
|
2355
|
+
const file = path.join(dir, files[0]);
|
|
2356
|
+
assert.equal(fileMode(file), 0o600, "去重表必须 0600");
|
|
2357
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2358
|
+
assert.equal(raw.bot_id, "bot-seen");
|
|
2359
|
+
assert.ok(raw.ids.some((e) => e.id === "m-1"), "id 要记进去");
|
|
2360
|
+
|
|
2361
|
+
// bridge 重启 → 仍然认得这条消息,不能一重启就把重投的消息再派一次
|
|
2362
|
+
const ch2 = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2363
|
+
assert.equal(ch2.isDuplicateMessage("m-1"), true, "★ 重启后仍要判重");
|
|
2364
|
+
assert.equal(ch2.isDuplicateMessage("m-2"), false, "没见过的仍要放行");
|
|
2365
|
+
} finally {
|
|
2366
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2367
|
+
}
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
test("ClawBot §2.3:去重表上限 200 条(丢最旧)且只保留 7 天", async () => {
|
|
2371
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-seen2-"));
|
|
2372
|
+
try {
|
|
2373
|
+
const clock = fakeClock();
|
|
2374
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2375
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-seen2", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2376
|
+
for (let i = 0; i < 250; i += 1) {
|
|
2377
|
+
assert.equal(ch.isDuplicateMessage(`id-${i}`), false);
|
|
2378
|
+
clock.advance(1000); // 每条间隔 1s,保证"越靠前越旧"
|
|
2379
|
+
}
|
|
2380
|
+
const file = path.join(dir, botStateFiles(dir, ".wechat-seen-")[0]);
|
|
2381
|
+
let raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2382
|
+
assert.equal(raw.ids.length, 200, `★ 去重表上限必须是 200 条,实际 ${raw.ids.length}`);
|
|
2383
|
+
assert.equal(raw.ids.some((e) => e.id === "id-0"), false, "★ 超过上限要丢最旧的");
|
|
2384
|
+
assert.equal(raw.ids.some((e) => e.id === "id-249"), true, "最新的一条必须保留");
|
|
2385
|
+
|
|
2386
|
+
// 保质期:手工塞一条 8 天前的,下一次写入时它必须被清掉(去重表不能无限留隐私指纹)
|
|
2387
|
+
const eightDays = 8 * 24 * 60 * 60 * 1000;
|
|
2388
|
+
raw.ids.unshift({ id: "too-old", at: clock() - eightDays });
|
|
2389
|
+
fs.writeFileSync(file, JSON.stringify(raw), { mode: 0o600 });
|
|
2390
|
+
ch.isDuplicateMessage("after-old");
|
|
2391
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2392
|
+
assert.equal(raw.ids.some((e) => e.id === "too-old"), false, "★ 超过 7 天的条目必须被清掉");
|
|
2393
|
+
assert.equal(raw.ids.some((e) => e.id === "after-old"), true);
|
|
2394
|
+
} finally {
|
|
2395
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
test("ClawBot §2.3:去重表读写失败 fail-open(当没见过,绝不丢消息),且只 warn 一次", async () => {
|
|
2400
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-seen3-"));
|
|
2401
|
+
try {
|
|
2402
|
+
const logger = createLogger();
|
|
2403
|
+
const ch = new WeChatChannel({ relayDir: dir, logger, clock: fakeClock() });
|
|
2404
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-seen3", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2405
|
+
ch.isDuplicateMessage("seed"); // 建立文件,拿到路径
|
|
2406
|
+
const file = path.join(dir, botStateFiles(dir, ".wechat-seen-")[0]);
|
|
2407
|
+
// 用同名**目录**占住路径 → 读(EISDIR)和写都失败
|
|
2408
|
+
fs.rmSync(file, { force: true });
|
|
2409
|
+
fs.mkdirSync(file);
|
|
2410
|
+
|
|
2411
|
+
assert.equal(ch.isDuplicateMessage("never-seen-before"), false, "★ 写盘失败必须 fail-open(放行),不能因此丢消息");
|
|
2412
|
+
assert.equal(ch.isDuplicateMessage("another-one"), false, "持续失败也继续放行(不抛)");
|
|
2413
|
+
|
|
2414
|
+
const warnLines = logger.lines.filter((l) => /去重表写盘失败/.test(l));
|
|
2415
|
+
assert.equal(warnLines.length, 1, `落盘失败只 warn 一次(避免刷屏),实际 ${warnLines.length} 条`);
|
|
2416
|
+
fs.rmSync(file, { recursive: true, force: true });
|
|
2417
|
+
} finally {
|
|
2418
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
|
|
2422
|
+
// ── §2.4 context_token 生命周期 ───────────────────────────────────────────────
|
|
2423
|
+
|
|
2424
|
+
test("ClawBot §2.4:invalidateContextToken 清令牌 + 写 push_paused(脱敏截断,不含 token)", async () => {
|
|
2425
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-token-"));
|
|
2426
|
+
try {
|
|
2427
|
+
const clock = fakeClock();
|
|
2428
|
+
const logger = createLogger({ secrets: [SECRET_TOKEN] });
|
|
2429
|
+
const ch = new WeChatChannel({ relayDir: dir, logger, clock });
|
|
2430
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-token", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2431
|
+
|
|
2432
|
+
assert.equal(ch.rememberContextToken("CT-fresh-1", "u1"), true);
|
|
2433
|
+
let st = loadState(dir);
|
|
2434
|
+
assert.equal(st.context_token, "CT-fresh-1");
|
|
2435
|
+
assert.equal(st.context_token_peer, "u1");
|
|
2436
|
+
assert.equal(st.context_token_at, clock(), "rememberContextToken 必须写 context_token_at");
|
|
2437
|
+
|
|
2438
|
+
// 真实失败原文里可能带着 token,而 push_paused 会落进**面板可读**的状态文件
|
|
2439
|
+
const reason = `sendMessage: ret=-2 errmsg=prepare failed token=${SECRET_TOKEN} ${"x".repeat(200)}`;
|
|
2440
|
+
const paused = ch.invalidateContextToken(reason);
|
|
2441
|
+
st = loadState(dir);
|
|
2442
|
+
assert.equal(st.context_token, "", "★ state 里的令牌必须清掉(过期令牌留着只会继续撞 prepare failed)");
|
|
2443
|
+
assert.equal(st.context_token_peer, "");
|
|
2444
|
+
assert.equal(st.context_token_at, 0);
|
|
2445
|
+
assert.equal(typeof st.push_paused, "object");
|
|
2446
|
+
assert.equal(st.push_paused.at, clock());
|
|
2447
|
+
assert.ok(st.push_paused.reason.length <= 120, `reason 必须截断到 120 字以内,实际 ${st.push_paused.reason.length}`);
|
|
2448
|
+
assert.match(st.push_paused.reason, /prepare failed/, "要保留可诊断的信息(不能一脱敏就什么都不剩)");
|
|
2449
|
+
assert.equal(st.push_paused.reason.includes(SECRET_TOKEN), false, "★ reason 里绝不能出现 token");
|
|
2450
|
+
assert.equal(ch.client.contextToken, "", "内存令牌也要清");
|
|
2451
|
+
assert.equal(paused.paused, true);
|
|
2452
|
+
assert.deepEqual(ch.pushPaused(), { paused: true, at: clock(), reason: st.push_paused.reason });
|
|
2453
|
+
for (const line of logger.lines) assert.equal(line.includes(SECRET_TOKEN), false, `日志泄漏 token: ${line}`);
|
|
2454
|
+
|
|
2455
|
+
// 状态要能跨重启(否则 bridge 一重启就又开始猛打)
|
|
2456
|
+
const ch2 = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2457
|
+
assert.equal(ch2.pushPaused().paused, true, "★ push_paused 必须落盘:重启后仍在静默状态");
|
|
2458
|
+
assert.equal(ch2.pushPaused().reason.includes(SECRET_TOKEN), false);
|
|
2459
|
+
} finally {
|
|
2460
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2461
|
+
}
|
|
2462
|
+
});
|
|
2463
|
+
|
|
2464
|
+
test("ClawBot §2.4:静默期间的重复失败只 warn 一次(断线重连不刷屏),at 保持首次进入静默的时间", async () => {
|
|
2465
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-token1b-"));
|
|
2466
|
+
try {
|
|
2467
|
+
const clock = fakeClock();
|
|
2468
|
+
const logger = createLogger({ secrets: [SECRET_TOKEN] });
|
|
2469
|
+
const ch = new WeChatChannel({ relayDir: dir, logger, clock });
|
|
2470
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-token1b", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2471
|
+
ch.rememberContextToken("CT-1", "u1");
|
|
2472
|
+
|
|
2473
|
+
// ① 第一次失败:进入静默 → 必须有一条 warn
|
|
2474
|
+
const first = ch.invalidateContextToken("prepare failed #1");
|
|
2475
|
+
assert.equal(first.paused, true);
|
|
2476
|
+
const pausedAt = first.at;
|
|
2477
|
+
const warnLines = () => logger.lines.filter((l) => /推送静默/.test(l));
|
|
2478
|
+
assert.equal(warnLines().length, 1, "第一次进入静默必须示警(否则用户/排障完全看不到)");
|
|
2479
|
+
|
|
2480
|
+
// ② 静默期间又失败两次(重连风暴 / replay 提示 / 非主动推送的 reply 路径):
|
|
2481
|
+
// 状态仍照写(reason 更新、令牌照清),但**不许再 warn** —— 每次一条就是刷屏。
|
|
2482
|
+
clock.advance(1_000);
|
|
2483
|
+
const second = ch.invalidateContextToken("prepare failed #2");
|
|
2484
|
+
clock.advance(1_000);
|
|
2485
|
+
const third = ch.invalidateContextToken("prepare failed #3");
|
|
2486
|
+
assert.equal(warnLines().length, 1, `★ 静默期间只 warn 一次,实际 ${warnLines().length} 条`);
|
|
2487
|
+
assert.equal(second.paused, true);
|
|
2488
|
+
assert.equal(third.paused, true);
|
|
2489
|
+
// 落盘状态仍是"静默中",但 at 保持第一次进入静默的时间(它回答"从什么时候开始发不出去",
|
|
2490
|
+
// 每次都刷新会让面板/日志节流窗口永远显示"刚刚发生",反而看不出持续了多久)。
|
|
2491
|
+
const st = loadState(dir);
|
|
2492
|
+
assert.equal(st.push_paused.at, pausedAt, "★ 重复失败不得刷新 push_paused.at");
|
|
2493
|
+
assert.match(st.push_paused.reason, /#3/, "reason 要更新成最近一次失败(排障看最新原因)");
|
|
2494
|
+
assert.equal(ch.client.contextToken, "", "令牌仍必须清掉");
|
|
2495
|
+
for (const line of logger.lines) assert.equal(line.includes(SECRET_TOKEN), false, `日志泄漏 token: ${line}`);
|
|
2496
|
+
|
|
2497
|
+
// ③ 恢复后再失败 → 是新的一次静默,必须重新示警(不能因为"这辈子只报一次"而漏掉)
|
|
2498
|
+
ch.rememberContextToken("CT-2", "u1");
|
|
2499
|
+
assert.equal(ch.pushPaused().paused, false);
|
|
2500
|
+
clock.advance(1_000);
|
|
2501
|
+
ch.invalidateContextToken("prepare failed #4");
|
|
2502
|
+
assert.equal(warnLines().length, 2, "解除静默后再次进入,必须重新 warn 一次");
|
|
2503
|
+
} finally {
|
|
2504
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
test("ClawBot §2.4:rememberContextToken 清 push_paused 并刷新 context_token_at;clearPushPaused 幂等", async () => {
|
|
2509
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-token2-"));
|
|
2510
|
+
try {
|
|
2511
|
+
const clock = fakeClock();
|
|
2512
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock });
|
|
2513
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-token2", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2514
|
+
|
|
2515
|
+
ch.rememberContextToken("CT-old", "u1");
|
|
2516
|
+
ch.invalidateContextToken("sendMessage: ret=-2 prepare failed(上下文令牌已过期)");
|
|
2517
|
+
assert.equal(ch.pushPaused().paused, true, "前置:已进入静默");
|
|
2518
|
+
|
|
2519
|
+
clock.advance(5_000);
|
|
2520
|
+
assert.equal(ch.rememberContextToken("CT-new", "u1"), true);
|
|
2521
|
+
let st = loadState(dir);
|
|
2522
|
+
assert.equal("push_paused" in st, false, "★ 入站=令牌已刷新,静默必须当场解除");
|
|
2523
|
+
assert.equal(st.context_token, "CT-new");
|
|
2524
|
+
assert.equal(st.context_token_at, clock(), "时间戳必须刷新");
|
|
2525
|
+
assert.equal(ch.pushPaused().paused, false);
|
|
2526
|
+
|
|
2527
|
+
// 令牌没变、也没静默 → 不重复写盘(入站频率不低,每次写会放大磁盘 IO)
|
|
2528
|
+
assert.equal(ch.rememberContextToken("CT-new", "u1"), false);
|
|
2529
|
+
// 但"令牌没变 + 正在静默"仍必须解除:入站本身就是刷新信号
|
|
2530
|
+
ch.writeState({ push_paused: { at: clock(), reason: "stale pause" } });
|
|
2531
|
+
assert.equal(ch.rememberContextToken("CT-new", "u1"), true, "★ 即使令牌不变,也要解除静默");
|
|
2532
|
+
assert.equal(ch.pushPaused().paused, false);
|
|
2533
|
+
|
|
2534
|
+
// 解除静默必须幂等:本来就没静默时不能凭空造出这个键
|
|
2535
|
+
ch.clearPushPaused();
|
|
2536
|
+
ch.clearPushPaused();
|
|
2537
|
+
assert.equal(ch.pushPaused().paused, false);
|
|
2538
|
+
assert.equal("push_paused" in loadState(dir), false);
|
|
2539
|
+
|
|
2540
|
+
// 绑定成功 = 全新 bot_token + 新会话 → 旧的静默结论作废
|
|
2541
|
+
ch.invalidateContextToken("prepare failed");
|
|
2542
|
+
assert.equal(ch.pushPaused().paused, true);
|
|
2543
|
+
ch.adoptConfirmed({ token: "another-token", accountId: "bot-token3", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2544
|
+
assert.equal(ch.pushPaused().paused, false, "★ 重绑后不得残留上一个会话的静默");
|
|
2545
|
+
} finally {
|
|
2546
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
test("ClawBot §2.4:push_paused 里不得出现任何明文凭据(状态文件是面板可读的)", async () => {
|
|
2551
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-token3-"));
|
|
2552
|
+
try {
|
|
2553
|
+
const ch = new WeChatChannel({ relayDir: dir, logger: silentLogger(), clock: fakeClock() });
|
|
2554
|
+
ch.adoptConfirmed({ token: SECRET_TOKEN, accountId: "bot-token4", baseUrl: "http://127.0.0.1:1", userId: "u1" });
|
|
2555
|
+
ch.rememberContextToken("CT-secret-value-999", "u1");
|
|
2556
|
+
ch.invalidateContextToken(`prepare failed context_token=CT-secret-value-999 bot_token=${SECRET_TOKEN}`);
|
|
2557
|
+
const raw = fs.readFileSync(path.join(dir, ".wechat-state.json"), "utf8");
|
|
2558
|
+
assert.equal(raw.includes(SECRET_TOKEN), false, "状态文件里不能出现 bot_token");
|
|
2559
|
+
assert.equal(raw.includes("CT-secret-value-999"), false, "状态文件里不能出现 context_token 明文");
|
|
2560
|
+
assert.match(raw, /push_paused/);
|
|
2561
|
+
} finally {
|
|
2562
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
2563
|
+
}
|
|
2564
|
+
});
|