@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-beta.2
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/dingtalk-bot.mjs +1318 -0
- package/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/clients/dsh-remote/dsh-bridge.mjs +398 -80
- package/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/clients/dsh-remote/im-format.mjs +372 -0
- package/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
- package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
- package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +550 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +839 -0
- package/clients/dsh-remote/test/im-format.test.mjs +229 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
- package/clients/dsh-remote/test/wecom-bot.test.mjs +281 -1
- package/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/dsh-setup.mjs +78 -7
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +703 -115
- package/packages/dsh-remote-web/lib/index.js +487 -31
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +94 -8
- package/packages/dsh-remote-web/test/im-bots.test.mjs +265 -2
- package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +4 -1
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -34
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -113
- package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +13 -5
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
|
@@ -17,13 +17,19 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import assert from "node:assert/strict";
|
|
19
19
|
import http from "node:http";
|
|
20
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
|
-
import test from "node:test";
|
|
23
|
+
import test, { beforeEach } from "node:test";
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
__resetUpstreamCachesForTest,
|
|
27
|
+
candidatePlan,
|
|
26
28
|
candidatePorts,
|
|
29
|
+
discoverUpstreamFast,
|
|
30
|
+
discoverUpstreamFull,
|
|
31
|
+
isDshLikeProcessName,
|
|
32
|
+
isIgnoredListenProcess,
|
|
27
33
|
discoverUpstream,
|
|
28
34
|
listeningPortsOfPid,
|
|
29
35
|
looksLikeDshWeb,
|
|
@@ -33,10 +39,16 @@ import {
|
|
|
33
39
|
DESKTOP_DEFAULT_PORT,
|
|
34
40
|
DESKTOP_PORT_SCAN,
|
|
35
41
|
FALLBACK_UPSTREAM,
|
|
42
|
+
MAX_PROBE_CANDIDATES,
|
|
43
|
+
MAX_PROBE_CONCURRENCY,
|
|
44
|
+
PROBE_TIMEOUT_MS,
|
|
36
45
|
UPSTREAM_FILE
|
|
37
46
|
} from "../upstream-discovery.mjs";
|
|
38
47
|
|
|
39
48
|
const DIR = mkdtempSync(path.join(os.tmpdir(), "dsh-upstream-"));
|
|
49
|
+
// 发现模块有进程内缓存(负缓存/已验证/监听表):用例之间必须清零,否则会互相污染
|
|
50
|
+
// (真实验证:前一个用例缓存了已关闭的假 dsh 端口,后面的"回退 3080"用例读到了它)。
|
|
51
|
+
beforeEach(() => { __resetUpstreamCachesForTest(); });
|
|
40
52
|
process.on("exit", () => { try { rmSync(DIR, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
41
53
|
|
|
42
54
|
/** 假的 dsh web:回一段带官方特征标记的 HTML(身份校验靠它)。 */
|
|
@@ -207,3 +219,192 @@ test("实测端口:能从本进程真实监听端口里读出来(macOS/Linux 用
|
|
|
207
219
|
await close(srv);
|
|
208
220
|
}
|
|
209
221
|
});
|
|
222
|
+
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
// 2026-11-07:不再乱敲本机所有端口(微信 CPU 高 + 登录慢)
|
|
225
|
+
// ===========================================================================
|
|
226
|
+
|
|
227
|
+
test("★ 进程名过滤:只保留像 dsh 宿主的端口,IM 客户端/无关进程全部跳过并计数", () => {
|
|
228
|
+
// 黑名单优先:名字里带 wechat 的即使像 node 也拒绝
|
|
229
|
+
assert.equal(isIgnoredListenProcess("WeChat"), true);
|
|
230
|
+
assert.equal(isIgnoredListenProcess("企业微信"), true);
|
|
231
|
+
assert.equal(isIgnoredListenProcess("wecom-node"), true);
|
|
232
|
+
assert.equal(isDshLikeProcessName("wechat-node"), false, "黑名单优先于白名单");
|
|
233
|
+
assert.equal(isDshLikeProcessName("QQ"), false);
|
|
234
|
+
assert.equal(isDshLikeProcessName("node"), true);
|
|
235
|
+
assert.equal(isDshLikeProcessName("nodejs"), true);
|
|
236
|
+
assert.equal(isDshLikeProcessName("dsh-web"), true);
|
|
237
|
+
assert.equal(isDshLikeProcessName("deepseek-harness"), true);
|
|
238
|
+
assert.equal(isDshLikeProcessName("Electron"), true);
|
|
239
|
+
assert.equal(isDshLikeProcessName("python3"), false);
|
|
240
|
+
assert.equal(isDshLikeProcessName(""), false);
|
|
241
|
+
|
|
242
|
+
const plan = candidatePlan({
|
|
243
|
+
relayDir: "", // 不读端口文件:本用例只关心进程名过滤
|
|
244
|
+
env: {},
|
|
245
|
+
platform: "win32",
|
|
246
|
+
includeStatic: false,
|
|
247
|
+
listeners: [
|
|
248
|
+
{ port: 40001, name: "WeChat", pid: 1 },
|
|
249
|
+
{ port: 40002, name: "企业微信", pid: 2 },
|
|
250
|
+
{ port: 40003, name: "node", pid: 3 },
|
|
251
|
+
{ port: 40004, name: "Electron", pid: 4 },
|
|
252
|
+
{ port: 40005, name: "deepseek-harness", pid: 5 },
|
|
253
|
+
{ port: 40006, name: "python3", pid: 6 },
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
const ports = plan.entries.map((e) => e.port).sort((a, b) => a - b);
|
|
257
|
+
assert.deepEqual(ports, [40003, 40004, 40005], "★ 只保留 node/electron/deepseek 的端口;微信/企微/python 全部排除");
|
|
258
|
+
assert.equal(plan.skipped, 3, "★ skipped 必须统计被跳过的非 node 进程端口(微信、企微、python)");
|
|
259
|
+
assert.ok(plan.entries.every((e) => e.source === "listen"));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("★ 拿不到进程名时**绝不退化成全量探测**(SKIP 整层,不瞎敲)", () => {
|
|
263
|
+
// listeners:false = 模拟 lsof/ss 不可用(如 NAS/精简系统)
|
|
264
|
+
const plan = candidatePlan({ relayDir: "", env: {}, platform: "win32", includeStatic: false, listeners: false });
|
|
265
|
+
assert.equal(plan.entries.length, 0, "拿不到进程名 → 本层一个候选都不收");
|
|
266
|
+
assert.equal(plan.skipped, 0, "这不是'跳过',是'整层不可用'");
|
|
267
|
+
// 旧实现会把所有监听端口都收进来(listenPorts 注入路径保持兼容,供测试/特殊部署显式使用)
|
|
268
|
+
const injected = candidatePlan({ relayDir: "", env: {}, platform: "win32", includeStatic: false, listenPorts: [40003, 40004] });
|
|
269
|
+
assert.deepEqual(injected.entries.map((e) => e.port).sort(), [40003, 40004], "显式注入路径保持既有语义");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("★ 负缓存:判定'不是 dsh'的端口 10 分钟内不再探测", async () => {
|
|
273
|
+
const stranger = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); });
|
|
274
|
+
writeHint("not-a-url");
|
|
275
|
+
try {
|
|
276
|
+
const first = await discoverUpstreamFull({ relayDir: DIR, env: {}, extraPorts: [stranger.port], listeners: false, platform: "win32", fetchImpl: blockedFetch, timeoutMs: 300 });
|
|
277
|
+
assert.ok(first.probed.includes(stranger.port), "第一次必须探测该端口");
|
|
278
|
+
const second = await discoverUpstreamFull({ relayDir: DIR, env: {}, extraPorts: [stranger.port], listeners: false, platform: "win32", fetchImpl: blockedFetch, timeoutMs: 300 });
|
|
279
|
+
assert.ok(!second.probed.includes(stranger.port), "★ 负缓存内不得重复探测(微信 CPU 的根因之一就是反复敲)");
|
|
280
|
+
} finally {
|
|
281
|
+
await close(stranger);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("★ fast 路径:没有 hint/缓存/进程端口时**一个请求都不发**(<50ms 语义)", async () => {
|
|
286
|
+
writeHint("not-a-url");
|
|
287
|
+
let calls = 0;
|
|
288
|
+
const fetchImpl = () => { calls += 1; return Promise.reject(new Error("fast 不该探测")); };
|
|
289
|
+
const t0 = Date.now();
|
|
290
|
+
const fast = await discoverUpstreamFast({
|
|
291
|
+
relayDir: DIR, env: {}, platform: "win32", listeners: false, fetchImpl
|
|
292
|
+
});
|
|
293
|
+
const cost = Date.now() - t0;
|
|
294
|
+
assert.equal(fast.url, "", "没有可用来源时返回空(由调用方决定回退)");
|
|
295
|
+
assert.equal(calls, 0, "★ fast 不得探测未知端口");
|
|
296
|
+
assert.ok(cost < 50, `fast 必须很快(实际 ${cost}ms)`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("★ 探测成本:超时 300ms;150ms 才回的 dsh 仍能命中,>300ms 的不再等", async () => {
|
|
300
|
+
assert.equal(PROBE_TIMEOUT_MS, 300, "默认探测超时必须是 300ms");
|
|
301
|
+
let pending = [];
|
|
302
|
+
const slowOk = await listen((_req, res) => {
|
|
303
|
+
pending.push(setTimeout(() => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); }, 150));
|
|
304
|
+
});
|
|
305
|
+
const tooSlow = await listen((_req, res) => {
|
|
306
|
+
pending.push(setTimeout(() => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); }, 450));
|
|
307
|
+
});
|
|
308
|
+
try {
|
|
309
|
+
assert.equal(await probeDshWeb(slowOk.url), true, "150ms 内响应 → 必须命中(缩短超时不能把慢机器误杀)");
|
|
310
|
+
assert.equal(await probeDshWeb(tooSlow.url), false, "超过 300ms 的静默端口不再等");
|
|
311
|
+
} finally {
|
|
312
|
+
for (const t of pending) clearTimeout(t);
|
|
313
|
+
pending = [];
|
|
314
|
+
await close(slowOk); await close(tooSlow);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("★ 并发上限:候选再多也最多 16 个并发探测", async () => {
|
|
319
|
+
const servers = [];
|
|
320
|
+
try {
|
|
321
|
+
for (let i = 0; i < 20; i++) {
|
|
322
|
+
servers.push(await listen((_req, res) => {
|
|
323
|
+
setTimeout(() => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); }, 40);
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
const inflight = { now: 0, max: 0 };
|
|
327
|
+
const countingFetch = (input, init) => {
|
|
328
|
+
inflight.now += 1;
|
|
329
|
+
inflight.max = Math.max(inflight.max, inflight.now);
|
|
330
|
+
return Promise.resolve(blockedFetch(input, init)).finally(() => { inflight.now -= 1; });
|
|
331
|
+
};
|
|
332
|
+
writeHint("not-a-url");
|
|
333
|
+
await discoverUpstreamFull({
|
|
334
|
+
relayDir: DIR, env: {}, platform: "win32", listeners: false,
|
|
335
|
+
extraPorts: servers.map((s) => s.port), fetchImpl: countingFetch, timeoutMs: 300
|
|
336
|
+
});
|
|
337
|
+
assert.ok(inflight.max <= MAX_PROBE_CONCURRENCY, `★ 并发不得超过 ${MAX_PROBE_CONCURRENCY}(实际 ${inflight.max})`);
|
|
338
|
+
assert.ok(inflight.max >= 2, "应当确实并行(否则发现会串行变慢)");
|
|
339
|
+
} finally {
|
|
340
|
+
for (const s of servers) await close(s);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("★ 行为不回归:过滤后仍能发现非默认端口上的 dsh web(能力保留)", async () => {
|
|
345
|
+
const dsh = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
|
|
346
|
+
writeHint("not-a-url");
|
|
347
|
+
try {
|
|
348
|
+
const found = await discoverUpstreamFull({
|
|
349
|
+
relayDir: DIR, env: {}, platform: "win32", listeners: false, fetchImpl: blockedFetch,
|
|
350
|
+
extraPorts: [dsh.port], timeoutMs: 300
|
|
351
|
+
});
|
|
352
|
+
assert.equal(found.url, dsh.url, "非默认端口仍必须能发现(过滤只排除非 dsh 进程,不排除位置)");
|
|
353
|
+
assert.equal(found.source, "extra");
|
|
354
|
+
} finally {
|
|
355
|
+
await close(dsh);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("★ 每个候选最多 1 次请求:连 HTTP 都不通的端口不再打第二条探测", async () => {
|
|
360
|
+
// ① 死端口:旧实现 / + /dsh-remote/self = 2 个请求;现在 1 个
|
|
361
|
+
let calls = 0;
|
|
362
|
+
const deadFetch = () => { calls += 1; return Promise.reject(Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })); };
|
|
363
|
+
assert.equal(await probeDshWeb("http://127.0.0.1:1", { fetchImpl: deadFetch }), false);
|
|
364
|
+
assert.equal(calls, 1, "★ 首探连 HTTP 响应都没拿到 → 不再打 /dsh-remote/self(省一半连接)");
|
|
365
|
+
|
|
366
|
+
// ② 正常 HTTP 的陌生服务:首页特征不中时仍要问一次 /dsh-remote/self(保持识别能力)
|
|
367
|
+
const stranger = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); });
|
|
368
|
+
let n = 0;
|
|
369
|
+
const real = (input, init) => { n += 1; return realFetch(input, init); };
|
|
370
|
+
try {
|
|
371
|
+
assert.equal(await probeDshWeb(stranger.url, { fetchImpl: real }), false);
|
|
372
|
+
assert.equal(n, 2, "拿到了 HTTP 响应(只是特征不中)→ 允许第二次探测 /dsh-remote/self");
|
|
373
|
+
} finally {
|
|
374
|
+
await close(stranger);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("★ 候选上限:超过 20 个时整层放弃 listen 扫描(只探 hint/进程/静态)", async () => {
|
|
379
|
+
// 注入 24 个"像 node"的监听端口(超过 LISTEN_PROBE_LIMIT 与 MAX_PROBE_CANDIDATES)
|
|
380
|
+
const listeners = Array.from({ length: 24 }, (_, i) => ({ port: 45000 + i, name: "node", pid: 1000 + i }));
|
|
381
|
+
let calls = 0;
|
|
382
|
+
const fetchImpl = () => { calls += 1; return Promise.reject(new Error("blocked")); };
|
|
383
|
+
writeHint("not-a-url");
|
|
384
|
+
const found = await discoverUpstreamFull({
|
|
385
|
+
relayDir: DIR, env: {}, platform: "win32", listeners, fetchImpl, timeoutMs: 60
|
|
386
|
+
});
|
|
387
|
+
assert.ok(MAX_PROBE_CANDIDATES === 20);
|
|
388
|
+
assert.ok(!found.probed.some((p) => p >= 45000 && p < 45024), "★ 超限时不得探测 listen 层的端口");
|
|
389
|
+
// 静态候选仍会探(3080 + Desktop 区间),所以请求数应为 13 而不是 13+24
|
|
390
|
+
assert.ok(calls <= DESKTOP_PORT_SCAN + 1, `请求数应被压到静态候选量级(实际 ${calls})`);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("微信通道与上游发现互不相干:不枚举/不探测本机端口(源级断言,Lead 补充报告第 3 条)", () => {
|
|
394
|
+
for (const f of ["wechat-channel.mjs", "wechat-runtime.mjs"]) {
|
|
395
|
+
const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
|
|
396
|
+
assert.ok(!/upstream-discovery/.test(src), `${f} 不得 import 上游发现(微信通道只跟 ilinkai.weixin.qq.com 通信)`);
|
|
397
|
+
assert.ok(!/allLoopbackListeningPorts|candidatePorts|listeningPortsWithProcess/.test(src), `${f} 不得枚举本机监听端口`);
|
|
398
|
+
assert.ok(!/lsof|netstat/.test(src), `${f} 不得调用 lsof/netstat 扫本机端口`);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("dsh-setup watcher:失败指数退避 + 端口文件事件驱动(源级契约)", () => {
|
|
403
|
+
const setup = readFileSync(new URL("../../../dsh-setup.mjs", import.meta.url), "utf8");
|
|
404
|
+
assert.match(setup, /UPSTREAM_DISCOVERY_BACKOFF_MS\s*=\s*\[15_000,\s*30_000,\s*60_000,\s*300_000\]/,
|
|
405
|
+
"连续失败退避必须是 15s→30s→60s→300s");
|
|
406
|
+
assert.match(setup, /if \(now < upstreamDiscoveryNextAt\) return upstreamUrl\(\);/, "退避检查必须在 force 分支之外(force 不得打穿退避)");
|
|
407
|
+
assert.match(setup, /watchUpstreamFile\(ensureBridge\)/, "watcher 必须监听 .dsh-upstream 的变化(事件驱动替代全量扫描)");
|
|
408
|
+
assert.match(setup, /fs\.watchFile\(file, \{ interval: 1000, persistent: false \}/, "监听用 1s stat(不扫端口)");
|
|
409
|
+
assert.ok(/upstreamDiscoveryFails = 0;\s*\n\s*upstreamDiscoveryNextAt = 0;/.test(setup), "端口文件变化时必须清零退避,立即采用新端口");
|
|
410
|
+
});
|
|
@@ -1165,7 +1165,8 @@ test("★ 会话绑定优先级:配置固定 → 最近活跃 → sessions[0]
|
|
|
1165
1165
|
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); } });
|
|
1166
1166
|
const bot = createWecomBot({
|
|
1167
1167
|
botId: BOT_ID, secret: SECRET, url: server.url, relayDir: relayDir1, logger: () => {},
|
|
1168
|
-
subscriber: sub, configuredSessionId: "s-fixed", configuredSessionTitle: "固定会话"
|
|
1168
|
+
subscriber: sub, configuredSessionId: "s-fixed", configuredSessionTitle: "固定会话",
|
|
1169
|
+
tier: "pro" // 固定会话是会员能力(sessions);免费档会被忽略(另有用例覆盖)
|
|
1169
1170
|
});
|
|
1170
1171
|
try {
|
|
1171
1172
|
bot.start();
|
|
@@ -1277,6 +1278,285 @@ test("控制器:只改 sessionId 也会重建 bot(配置热更新后转发
|
|
|
1277
1278
|
}
|
|
1278
1279
|
});
|
|
1279
1280
|
|
|
1281
|
+
// ─────────────────────── ⑩f IM 通道会员判定(2026-11-07)───────────────────────
|
|
1282
|
+
|
|
1283
|
+
// 复用微信通道那套能力表(单一真相源):免费 = 通知/审批/状态/急停 + assign.continue(20 条/月)
|
|
1284
|
+
const FREE_CAPS = ["notify", "approve", "status", "stop", "assign.continue"];
|
|
1285
|
+
const PAID_CAPS = ["notify", "approve", "status", "stop", "assign", "sessions", "summary", "steer", "attach", "multi"];
|
|
1286
|
+
const tierFreeObj = () => ({ plan: "free", caps: FREE_CAPS, limits: { messages_per_month: 20 }, rev: "rev-free" });
|
|
1287
|
+
const tierProObj = () => ({ plan: "pro", caps: PAID_CAPS, limits: { messages_per_month: 0 }, rev: "rev-pro" });
|
|
1288
|
+
|
|
1289
|
+
/** 会员用例的 bot:接入一个可控 capsProvider,返回常用断言面。 */
|
|
1290
|
+
async function setupMemberBot(over = {}) {
|
|
1291
|
+
const server = await startFakeServer({ onFrame: (f, ws) => { autoAckAuth(f, ws); autoAckPing(f, ws); autoAckSend(f, ws); } });
|
|
1292
|
+
const sub = fakeSubscriber(over.sub || {
|
|
1293
|
+
sessionIds: ["s-live", "s-other"],
|
|
1294
|
+
sessions: [
|
|
1295
|
+
{ sessionId: "s-live", title: "活跃会话", running: true, updatedAt: 200 },
|
|
1296
|
+
{ sessionId: "s-other", title: "另一个", updatedAt: 100 }
|
|
1297
|
+
]
|
|
1298
|
+
});
|
|
1299
|
+
const lines = [];
|
|
1300
|
+
const decisions = [];
|
|
1301
|
+
const bot = createWecomBot({
|
|
1302
|
+
botId: BOT_ID, secret: SECRET, url: server.url,
|
|
1303
|
+
logger: (level, line) => lines.push(line),
|
|
1304
|
+
subscriber: sub,
|
|
1305
|
+
capsProvider: over.capsProvider,
|
|
1306
|
+
appUrl: "https://app.test/",
|
|
1307
|
+
onApprovalDecision: async (requestId, decision) => { decisions.push({ requestId, decision }); return { ok: true }; },
|
|
1308
|
+
tierDenyThrottleMs: 0,
|
|
1309
|
+
...(over.bot || {})
|
|
1310
|
+
});
|
|
1311
|
+
const replies = () => server.state.frames.filter((f) => f.cmd === "aibot_respond_msg");
|
|
1312
|
+
const markdowns = () => server.state.frames.filter((f) => f.cmd === "aibot_send_msg" && f.body && f.body.markdown);
|
|
1313
|
+
const cards = () => server.state.frames.filter((f) => f.cmd === "aibot_send_msg" && f.body && f.body.msgtype === "template_card");
|
|
1314
|
+
let seq = 0;
|
|
1315
|
+
return {
|
|
1316
|
+
server, sub, bot, lines, decisions, replies, markdowns, cards,
|
|
1317
|
+
async start() { bot.start(); await waitFor(() => bot.status().authenticated, { label: "认证(member)" }); },
|
|
1318
|
+
/** 发一条入站文本;返回是否真的被转发(promptSession)。 */
|
|
1319
|
+
async inbound(text = "hi", userid = "user-a") {
|
|
1320
|
+
seq += 1;
|
|
1321
|
+
const beforeP = sub.promptCalls.length;
|
|
1322
|
+
const beforeR = replies().length;
|
|
1323
|
+
server.push({
|
|
1324
|
+
cmd: "aibot_msg_callback", headers: { req_id: `min-${seq}` },
|
|
1325
|
+
body: { msgid: `mm-${seq}`, msgtype: "text", text: { content: text }, from: { userid } }
|
|
1326
|
+
});
|
|
1327
|
+
await waitFor(() => sub.promptCalls.length > beforeP || replies().length > beforeR, { label: `入站 ${seq} 处理完` });
|
|
1328
|
+
await sleep(10);
|
|
1329
|
+
return sub.promptCalls.length > beforeP;
|
|
1330
|
+
},
|
|
1331
|
+
lastReplyText() {
|
|
1332
|
+
const r = replies()[replies().length - 1];
|
|
1333
|
+
return r && r.body && r.body.stream ? String(r.body.stream.content || "") : "";
|
|
1334
|
+
},
|
|
1335
|
+
async close() { await bot.stop(); await server.close(); }
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
test("★ 会员·免费档:通知照常、审批卡可点且**不计数**、额度不变", async () => {
|
|
1340
|
+
const relayDir = tmpRelayDir();
|
|
1341
|
+
const rt = await setupMemberBot({ capsProvider: async () => tierFreeObj(), bot: { relayDir } });
|
|
1342
|
+
try {
|
|
1343
|
+
await rt.start();
|
|
1344
|
+
await waitFor(() => rt.bot.status().tier === "free", { label: "档位 free" });
|
|
1345
|
+
// 入站绑定(同时消耗 1 条免费额度)
|
|
1346
|
+
assert.equal(await rt.inbound("你好"), true);
|
|
1347
|
+
await waitFor(() => rt.bot.status().usage.messages === 1, { label: "用量 1" });
|
|
1348
|
+
// 通知照常(免费)
|
|
1349
|
+
rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", turn: 1, seq: 1, at: Date.now() });
|
|
1350
|
+
await waitFor(() => rt.markdowns().find((f) => /任务完成/.test(f.body.markdown.content)), { label: "免费通知" });
|
|
1351
|
+
// 审批卡:可点、不计数
|
|
1352
|
+
const card = rt.bot.sendApprovalCard("user-a", { requestId: "ev-free-1", toolName: "Bash" });
|
|
1353
|
+
await waitFor(() => rt.cards().length === 1, { label: "免费审批卡" });
|
|
1354
|
+
pushCardClick(rt.server, { reqId: "free-click-1", taskId: card.taskId, eventKey: "allow:ev-free-1" });
|
|
1355
|
+
await waitFor(() => rt.decisions.length === 1, { label: "免费审批回执" });
|
|
1356
|
+
assert.equal(rt.bot.status().usage.messages, 1, "★ 审批回执不得计数(安全阀优先)");
|
|
1357
|
+
const st = readStatus(relayDir);
|
|
1358
|
+
assert.deepEqual(st.wecom.usage, { month: st.wecom.usage.month, messages: 1 }, "状态文件 usage 只记 1(入站那条)");
|
|
1359
|
+
assert.equal(st.wecom.tier, "free");
|
|
1360
|
+
assert.ok(Array.isArray(st.wecom.caps) && st.wecom.caps.includes("assign.continue"));
|
|
1361
|
+
assert.equal(st.wecom.messagesPerMonth, 20);
|
|
1362
|
+
assert.equal(st.wecom.remainingMessages, 19);
|
|
1363
|
+
} finally {
|
|
1364
|
+
await rt.close();
|
|
1365
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("★ 会员·免费档额度:前 20 条正常派活,第 21 条回「本月免费指令已用完」+ 升级链接且不调 promptSession", async () => {
|
|
1370
|
+
const relayDir = tmpRelayDir();
|
|
1371
|
+
const rt = await setupMemberBot({ capsProvider: async () => tierFreeObj(), bot: { relayDir } });
|
|
1372
|
+
try {
|
|
1373
|
+
await rt.start();
|
|
1374
|
+
for (let i = 1; i <= 20; i++) {
|
|
1375
|
+
assert.equal(await rt.inbound(`第 ${i} 条`), true, `第 ${i} 条应允许`);
|
|
1376
|
+
}
|
|
1377
|
+
await waitFor(() => rt.bot.status().usage.messages === 20, { label: "用满 20" });
|
|
1378
|
+
const promptsBefore = rt.sub.promptCalls.length;
|
|
1379
|
+
assert.equal(await rt.inbound("第 21 条"), false, "★ 第 21 条不得派活");
|
|
1380
|
+
assert.equal(rt.sub.promptCalls.length, promptsBefore, "第 21 条不得调用 promptSession");
|
|
1381
|
+
const reply = rt.lastReplyText();
|
|
1382
|
+
assert.match(reply, /本月免费指令已用完/);
|
|
1383
|
+
assert.match(reply, /20/);
|
|
1384
|
+
assert.match(reply, /升级 Pro/);
|
|
1385
|
+
assert.match(reply, /https:\/\/app\.test\/promo/, "要带升级链接 /app/promo");
|
|
1386
|
+
assert.equal(rt.bot.status().usage.messages, 20, "被拦的消息不计数");
|
|
1387
|
+
// 超额后通知仍然正常(不能把已有通道静默停掉)
|
|
1388
|
+
rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", turn: 9, seq: 9, at: Date.now() });
|
|
1389
|
+
await waitFor(() => rt.markdowns().find((f) => /任务完成/.test(f.body.markdown.content)), { label: "超额后通知仍在" });
|
|
1390
|
+
} finally {
|
|
1391
|
+
await rt.close();
|
|
1392
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("★ 会员·跨月清零:上月用满 20 条 → 本月第一条仍可派活", async () => {
|
|
1397
|
+
const relayDir = tmpRelayDir();
|
|
1398
|
+
// 预置"上个月已经用满"的状态
|
|
1399
|
+
fs.writeFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), JSON.stringify({
|
|
1400
|
+
version: 1, wecom: { usage: { month: "2000-01", messages: 25 } }
|
|
1401
|
+
}), { mode: 0o600 });
|
|
1402
|
+
const rt = await setupMemberBot({ capsProvider: async () => tierFreeObj(), bot: { relayDir } });
|
|
1403
|
+
try {
|
|
1404
|
+
await rt.start();
|
|
1405
|
+
await waitFor(() => rt.bot.status().tier === "free", { label: "档位" });
|
|
1406
|
+
assert.equal(await rt.inbound("新月份第一条"), true, "★ 跨月必须清零");
|
|
1407
|
+
const st = readStatus(relayDir);
|
|
1408
|
+
const d = new Date();
|
|
1409
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
1410
|
+
assert.equal(st.wecom.usage.month, month);
|
|
1411
|
+
assert.equal(st.wecom.usage.messages, 1);
|
|
1412
|
+
} finally {
|
|
1413
|
+
await rt.close();
|
|
1414
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test("★ 会员·fail-closed:没有 capsProvider 按 free 处理(额度用满即拦);档位刷新后放行", async () => {
|
|
1419
|
+
const relayDir = tmpRelayDir();
|
|
1420
|
+
const d = new Date();
|
|
1421
|
+
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
1422
|
+
fs.writeFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), JSON.stringify({
|
|
1423
|
+
version: 1, wecom: { usage: { month, messages: 20 } }
|
|
1424
|
+
}), { mode: 0o600 });
|
|
1425
|
+
// ① 完全没有 provider
|
|
1426
|
+
{
|
|
1427
|
+
const rt = await setupMemberBot({ bot: { relayDir } });
|
|
1428
|
+
try {
|
|
1429
|
+
await rt.start();
|
|
1430
|
+
await sleep(30);
|
|
1431
|
+
assert.equal(rt.bot.status().tier, "free", "★ 没有 capsProvider → free(fail-closed)");
|
|
1432
|
+
assert.equal(await rt.inbound("没有 provider"), false);
|
|
1433
|
+
assert.match(rt.lastReplyText(), /本月免费指令已用完/);
|
|
1434
|
+
} finally {
|
|
1435
|
+
await rt.close();
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
// ② provider 先取不到(空),随后返回 pro → 下一次放行
|
|
1439
|
+
{
|
|
1440
|
+
let plan = "";
|
|
1441
|
+
const rt = await setupMemberBot({ capsProvider: async () => (plan ? tierProObj() : ""), bot: { relayDir } });
|
|
1442
|
+
try {
|
|
1443
|
+
await rt.start();
|
|
1444
|
+
await sleep(30);
|
|
1445
|
+
assert.equal(await rt.inbound("空档位时"), false, "取不到档位 → 按 free 拦");
|
|
1446
|
+
plan = "pro";
|
|
1447
|
+
assert.equal(await rt.inbound("刚升级后"), true, "★ 档位刷新后必须放行(不用重启)");
|
|
1448
|
+
assert.equal(rt.bot.status().tier, "pro");
|
|
1449
|
+
} finally {
|
|
1450
|
+
await rt.close();
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("★ 会员·免费档忽略配置固定会话(bindingGated);paid 档全放开(不限量/固定会话生效)", async () => {
|
|
1457
|
+
// ① 免费:配置了 sessionId 也不生效,回退活跃会话;状态标注 bindingGated
|
|
1458
|
+
{
|
|
1459
|
+
const relayDir = tmpRelayDir();
|
|
1460
|
+
const rt = await setupMemberBot({
|
|
1461
|
+
capsProvider: async () => tierFreeObj(),
|
|
1462
|
+
bot: { relayDir, configuredSessionId: "s-fixed", configuredSessionTitle: "固定会话" }
|
|
1463
|
+
});
|
|
1464
|
+
try {
|
|
1465
|
+
await rt.start();
|
|
1466
|
+
await waitFor(() => rt.bot.status().tier === "free", { label: "free" });
|
|
1467
|
+
const st0 = rt.bot.status();
|
|
1468
|
+
assert.equal(st0.bindingGated, true, "★ 免费档固定会话被门槛忽略");
|
|
1469
|
+
assert.equal(await rt.inbound("免费档"), true);
|
|
1470
|
+
assert.equal(rt.sub.promptCalls[0].sessionId, "s-live", "免费档回退到活跃会话,而不是 s-fixed");
|
|
1471
|
+
await waitFor(() => { const st = readStatus(relayDir); return st && st.wecom && st.wecom.bindingGated === true; }, { label: "状态文件 bindingGated" });
|
|
1472
|
+
const st = readStatus(relayDir);
|
|
1473
|
+
assert.equal(st.wecom.boundSessionId, "s-live");
|
|
1474
|
+
assert.equal(st.wecom.bindingGated, true);
|
|
1475
|
+
} finally {
|
|
1476
|
+
await rt.close();
|
|
1477
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
// ② paid:不限量 + 固定会话生效 + bindingGated=false
|
|
1481
|
+
{
|
|
1482
|
+
const relayDir = tmpRelayDir();
|
|
1483
|
+
const rt = await setupMemberBot({
|
|
1484
|
+
capsProvider: async () => tierProObj(),
|
|
1485
|
+
bot: { relayDir, configuredSessionId: "s-fixed", configuredSessionTitle: "固定会话" }
|
|
1486
|
+
});
|
|
1487
|
+
try {
|
|
1488
|
+
await rt.start();
|
|
1489
|
+
await waitFor(() => rt.bot.status().tier === "pro", { label: "pro" });
|
|
1490
|
+
assert.equal(rt.bot.status().bindingGated, false);
|
|
1491
|
+
for (let i = 1; i <= 25; i++) assert.equal(await rt.inbound(`付费 ${i}`), true, `付费第 ${i} 条`);
|
|
1492
|
+
assert.equal(rt.bot.status().usage.messages, 25, "付费也统计用量(但不限量)");
|
|
1493
|
+
assert.equal(rt.bot.status().messagesPerMonth, 0, "0 = 不限");
|
|
1494
|
+
assert.equal(rt.sub.promptCalls.every((c) => c.sessionId === "s-fixed"), true, "★ 付费档固定会话生效");
|
|
1495
|
+
const st = readStatus(relayDir);
|
|
1496
|
+
assert.equal(st.wecom.tier, "pro");
|
|
1497
|
+
assert.equal(st.wecom.remainingMessages, null, "不限量时 remaining=null");
|
|
1498
|
+
} finally {
|
|
1499
|
+
await rt.close();
|
|
1500
|
+
fs.rmSync(relayDir, { recursive: true, force: true });
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
test("★ 会员·服务端 caps 清空(空数组=权威)→ 互动全关但通知照常;审批卡仍可点", async () => {
|
|
1506
|
+
const rt = await setupMemberBot({
|
|
1507
|
+
capsProvider: async () => ({ plan: "free", caps: [], limits: { messages_per_month: 20 }, rev: "rev-empty" })
|
|
1508
|
+
});
|
|
1509
|
+
try {
|
|
1510
|
+
await rt.start();
|
|
1511
|
+
await waitFor(() => rt.bot.status().caps.length === 0, { label: "caps 清空" });
|
|
1512
|
+
assert.equal(rt.bot.status().caps.length, 0, "空数组 = 服务端明确清空,不得回退内置表");
|
|
1513
|
+
assert.equal(await rt.inbound("被清空的档位"), false, "没有 assign.continue → 不得派活");
|
|
1514
|
+
assert.match(rt.lastReplyText(), /会员功能|升级 Pro/);
|
|
1515
|
+
// 通知不受能力表影响(通知是免费能力,不能静默停掉)
|
|
1516
|
+
rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-x", reason: "completed", turn: 3, seq: 3, at: Date.now() });
|
|
1517
|
+
await waitFor(() => rt.markdowns().find((f) => /任务完成/.test(f.body.markdown.content)), { label: "通知仍在" });
|
|
1518
|
+
// 审批卡也不受互动闸影响(点得动、不计数)
|
|
1519
|
+
const card = rt.bot.sendApprovalCard("user-a", { requestId: "ev-empty-1", toolName: "Bash" });
|
|
1520
|
+
await waitFor(() => rt.cards().length === 1, { label: "卡" });
|
|
1521
|
+
pushCardClick(rt.server, { reqId: "empty-click", taskId: card.taskId, eventKey: "allow:ev-empty-1" });
|
|
1522
|
+
await waitFor(() => rt.decisions.length === 1, { label: "审批回执" });
|
|
1523
|
+
assert.equal(rt.bot.status().usage.messages, 0);
|
|
1524
|
+
} finally {
|
|
1525
|
+
await rt.close();
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
// ─────────────────────── ⑩g 通道格式化接入(2026-11-08)───────────────────────
|
|
1530
|
+
|
|
1531
|
+
test("★ 通道格式化接入:回答回传含 Markdown 表格时,发出的报文里没有裸表格(转成「• 列:值」)", async () => {
|
|
1532
|
+
const answerWithTable = [
|
|
1533
|
+
"修复完成,改动如下:",
|
|
1534
|
+
"",
|
|
1535
|
+
"| 文件 | 改动 | 行数 |",
|
|
1536
|
+
"|---|---|---|",
|
|
1537
|
+
"| clients/dsh-remote/a.mjs | 修端口探测 | 10 |",
|
|
1538
|
+
"| clients/dsh-remote/b.mjs | 加负缓存 | 20 |",
|
|
1539
|
+
"",
|
|
1540
|
+
"以上。"
|
|
1541
|
+
].join("\n");
|
|
1542
|
+
const rt = await setupRelayBot({ assistantText: { ok: true, text: answerWithTable } });
|
|
1543
|
+
try {
|
|
1544
|
+
rt.bot.start();
|
|
1545
|
+
await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
|
|
1546
|
+
await rt.inbound("relay-table-1", "给我看下改动");
|
|
1547
|
+
rt.turnEnd({ turn: 1, seq: 41 });
|
|
1548
|
+
const send = await waitFor(() => rt.sends()[0], { label: "回答回传" });
|
|
1549
|
+
const content = send.body.markdown.content;
|
|
1550
|
+
assert.ok(!/\|\s*-{2,}/.test(content), "★ 报文里不得出现 |--- 分隔行");
|
|
1551
|
+
assert.ok(!/^\s*\|.*\|\s*$/m.test(content), "★ 报文里不得出现裸表格行");
|
|
1552
|
+
assert.match(content, /^• 文件:clients\/dsh-remote\/a\.mjs · 改动:修端口探测 · 行数:10$/m);
|
|
1553
|
+
assert.match(content, /修复完成/);
|
|
1554
|
+
assert.match(content, /以上。/);
|
|
1555
|
+
} finally {
|
|
1556
|
+
await rt.close();
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1280
1560
|
// ─────────────────────────── ⑪ 配置契约 ───────────────────────────
|
|
1281
1561
|
|
|
1282
1562
|
test("配置契约:normalizeImBotsConfig 缺字段补默认、非法值当未配置;readImBotsConfig 文件不存在返回 null", () => {
|