@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.10
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/README.md +2 -1
- package/clients/dsh-remote/dsh-bridge.mjs +270 -9
- package/clients/dsh-remote/dsh-events.mjs +1981 -0
- package/clients/dsh-remote/src/lifecycle.mjs +42 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
- package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
- package/clients/dsh-remote/test/wechat-channel.test.mjs +2112 -0
- package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +1613 -0
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +287 -0
- package/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/clients/dsh-remote/wechat-runtime.mjs +1910 -0
- package/docs/telemetry.md +31 -2
- package/dsh-setup.mjs +192 -43
- package/package.json +6 -5
- package/packages/dsh-remote-web/lib/client.js +877 -65
- package/packages/dsh-remote-web/lib/index.js +511 -14
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +59 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
- package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +1002 -0
- package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
|
@@ -0,0 +1,1613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信通道**编排层**测试(wechat-runtime.mjs)。
|
|
3
|
+
*
|
|
4
|
+
* 这一层的存在理由就是"把两个并行开发的模块拼起来",所以测试的重点也在这里:
|
|
5
|
+
*
|
|
6
|
+
* ① **词表翻译**:事件侧(`dsh-events.mjs`)与文案侧(`wechat-channel.mjs`)的 kind 名不同
|
|
7
|
+
* (`approval-request` vs `approval`),两边各自单测全绿、拼起来却互不认识。
|
|
8
|
+
* 这里锁死每一个 kind 都有归宿,并且上游**新增** kind 时会当场失败。
|
|
9
|
+
* ② **-14 冷却真的生效**:腾讯侧 token 失效时若不退避会猛打接口。
|
|
10
|
+
* ⚠️ 这里守的是一个**真实踩过的坑**:SessionCooldown 没有 isActive(),
|
|
11
|
+
* 写成 `cooldown.isActive && cooldown.isActive()` 会静默短路成 false,冷却形同虚设。
|
|
12
|
+
* ③ **回执编码分派**:审批用 answerApproval(值),提问用 answerQuestion(答案数组),
|
|
13
|
+
* 用错会把审批值塞进提问信封。且选项字段是 `value` 不是 outcome。
|
|
14
|
+
* ④ **控制面安全**:仅回环 + 必须带密钥;**永不回显 bot_token**。
|
|
15
|
+
* ⑤ §6 的两条产品硬约束:过期**绝不**当同意;断线窗口要如实告知。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { EventEmitter } from "node:events";
|
|
25
|
+
|
|
26
|
+
import * as rtmod from "../wechat-runtime.mjs";
|
|
27
|
+
import {
|
|
28
|
+
createWeChatRuntime,
|
|
29
|
+
readControlFile,
|
|
30
|
+
assertKindCoverage,
|
|
31
|
+
toFormatterNode,
|
|
32
|
+
CONTROL_HEADER
|
|
33
|
+
} from "../wechat-runtime.mjs";
|
|
34
|
+
import { saveAccount, loadState, formatNotification, SessionCooldown } from "../wechat-channel.mjs";
|
|
35
|
+
import { NODE_KINDS } from "../dsh-events.mjs";
|
|
36
|
+
|
|
37
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "wc-rt-"));
|
|
38
|
+
const SECRET = "s3cret-bridge";
|
|
39
|
+
|
|
40
|
+
/** 静默 logger。⚠️ 必须带 addSecret():WeChatChannel 构造时会调用它给 token 脱敏。 */
|
|
41
|
+
const quietLogger = () => ({ info() {}, warn() {}, error() {}, debug() {}, addSecret() {} });
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/** 本地假 ilink 服务 —— 测试期网络护栏只允许回环,所以必须自带假上游。 */
|
|
45
|
+
function fakeIlink(handler) {
|
|
46
|
+
const state = { sends: [], notifyStart: 0, notifyStop: 0, getUpdates: 0, queue: [] };
|
|
47
|
+
const server = http.createServer((req, res) => {
|
|
48
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
49
|
+
let body = "";
|
|
50
|
+
req.on("data", (c) => (body += c));
|
|
51
|
+
req.on("end", () => {
|
|
52
|
+
const reply = (obj) => {
|
|
53
|
+
res.writeHead(200, { "content-type": "application/octet-stream" });
|
|
54
|
+
res.end(JSON.stringify(obj));
|
|
55
|
+
};
|
|
56
|
+
if (url.pathname.endsWith("/getupdates")) {
|
|
57
|
+
state.getUpdates += 1;
|
|
58
|
+
if (handler && handler.onGetUpdates) return handler.onGetUpdates(state, reply);
|
|
59
|
+
const msgs = state.queue.splice(0);
|
|
60
|
+
return reply({ ret: 0, msgs, get_updates_buf: "buf-" + state.getUpdates, longpolling_timeout_ms: 1 });
|
|
61
|
+
}
|
|
62
|
+
if (url.pathname.endsWith("/sendmessage")) {
|
|
63
|
+
try { state.sends.push(JSON.parse(body)); } catch { state.sends.push({ raw: body }); }
|
|
64
|
+
return reply({ ret: 0, message_id: "m" + state.sends.length });
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname.endsWith("/notifystart")) { state.notifyStart += 1; return reply({ ret: 0 }); }
|
|
67
|
+
if (url.pathname.endsWith("/notifystop")) { state.notifyStop += 1; return reply({ ret: 0 }); }
|
|
68
|
+
if (url.pathname.endsWith("/get_bot_qrcode")) return reply({ ret: 0, qrcode: "Q1", qrcode_img_content: "https://liteapp.weixin.qq.com/q/abc?qrcode=Q1&bot_type=3" });
|
|
69
|
+
if (url.pathname.endsWith("/get_qrcode_status")) return reply({ status: "wait" });
|
|
70
|
+
return reply({ ret: 0 });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
server.listen(0, "127.0.0.1", () =>
|
|
75
|
+
resolve({
|
|
76
|
+
server,
|
|
77
|
+
state,
|
|
78
|
+
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
|
79
|
+
// ⚠️ 只 close() 不够:Node 的 fetch(undici)默认 keep-alive,连接不释放会让
|
|
80
|
+
// server.close() 一直等下去 → 整个测试挂死(本文件真的踩过一次,排查成本很高)。
|
|
81
|
+
close: () =>
|
|
82
|
+
new Promise((r) => {
|
|
83
|
+
try { server.closeAllConnections?.(); } catch { /* 老版本无此 API */ }
|
|
84
|
+
server.close(() => r());
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 直接构造一个"已绑定"的运行时(绕过扫码),便于测通道与回执。 */
|
|
92
|
+
async function boundRuntime(ilink, opts = {}) {
|
|
93
|
+
const relayDir = tmp();
|
|
94
|
+
saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "user-1", boundAt: 1 });
|
|
95
|
+
const rt = createWeChatRuntime({
|
|
96
|
+
relayDir,
|
|
97
|
+
upstream: "http://127.0.0.1:1",
|
|
98
|
+
secret: SECRET,
|
|
99
|
+
cooldownMs: opts.cooldownMs,
|
|
100
|
+
// 默认按**付费档**跑:本文件绝大多数用例测的是遥控能力(交代任务/选会话)。
|
|
101
|
+
// 免费档的门槛由「分层」那组用例专门守 —— 它们显式传 tier:"free"。
|
|
102
|
+
tier: opts.tier === undefined ? "pro" : opts.tier,
|
|
103
|
+
tierProvider: opts.tierProvider,
|
|
104
|
+
tierDenyThrottleMs: opts.tierDenyThrottleMs,
|
|
105
|
+
limitTable: opts.limitTable,
|
|
106
|
+
entitlements: opts.entitlements,
|
|
107
|
+
appUrl: opts.appUrl,
|
|
108
|
+
// 可控时钟:额度用量按自然月归零,「跨月再提醒」必须能确定性地测(不能真等一个月)
|
|
109
|
+
clock: opts.clock,
|
|
110
|
+
logger: quietLogger()
|
|
111
|
+
});
|
|
112
|
+
// 替换订阅器:本文件测编排,不测 DSH 协议(那是 dsh-events.test.mjs 的职责)
|
|
113
|
+
rt.subscriber = opts.subscriber || makeFakeSubscriber();
|
|
114
|
+
return { rt, relayDir };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 假的 DSH 订阅器:记录回执调用,便于断言分派正确。 */
|
|
118
|
+
function makeFakeSubscriber() {
|
|
119
|
+
const s = new EventEmitter();
|
|
120
|
+
s.calls = [];
|
|
121
|
+
s.answer = async (id, outcome) => { s.calls.push({ m: "answer", id, outcome }); return { ok: true }; };
|
|
122
|
+
s.answerApproval = async (id, value) => { s.calls.push({ m: "answerApproval", id, value }); return { ok: true }; };
|
|
123
|
+
s.answerQuestion = async (id, answers) => { s.calls.push({ m: "answerQuestion", id, answers }); return { ok: true }; };
|
|
124
|
+
// ⚠️ 必须 `...d` 展开透传:真实实现是 `this.#emitLocal({ kind, specNode: 5, ...detail })`。
|
|
125
|
+
// 只挑 notified/answered 会把 `lines` 丢掉 → 日报退化成兜底文案,而测试还"通过"(假的失真)。
|
|
126
|
+
s.digestDue = (d = {}) => ({ kind: NODE_KINDS.DIGEST_DUE, ...d, at: 1 });
|
|
127
|
+
s.quotaLow = (d = {}) => ({ kind: NODE_KINDS.QUOTA_LOW, message: d.message, at: 1 });
|
|
128
|
+
s.membershipExpiring = (d = {}) => ({ kind: NODE_KINDS.MEMBERSHIP_EXPIRING, message: d.message, at: 1 });
|
|
129
|
+
s.start = () => {};
|
|
130
|
+
s.close = () => {};
|
|
131
|
+
// ── v2:会话遥控。记录调用,让编排层的分派可被断言 ──
|
|
132
|
+
s.sessionsFixture = [];
|
|
133
|
+
s.summaryFixture = "";
|
|
134
|
+
s.listSessions = async () => { s.calls.push({ m: "listSessions" }); return { ok: true, sessions: s.sessionsFixture }; };
|
|
135
|
+
s.createSession = async (o = {}) => { s.calls.push({ m: "createSession", opts: o }); return { ok: true, sessionId: "session-new-1" }; };
|
|
136
|
+
s.promptSession = async (o = {}) => { s.calls.push({ m: "promptSession", sessionId: o.sessionId, text: o.text }); return { ok: true, accepted: true }; };
|
|
137
|
+
s.cancelSession = async (o = {}) => { s.calls.push({ m: "cancelSession", sessionId: o.sessionId }); return { ok: true }; };
|
|
138
|
+
s.lastAssistantText = async () => ({ ok: true, text: s.summaryFixture });
|
|
139
|
+
return s;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lastText = (state) => {
|
|
143
|
+
const last = state.sends[state.sends.length - 1];
|
|
144
|
+
return last && last.msg && last.msg.item_list && last.msg.item_list[0] && last.msg.item_list[0].text_item
|
|
145
|
+
? last.msg.item_list[0].text_item.text
|
|
146
|
+
: "";
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** 已经发出去的全部文本(按发送顺序)。用于"这类消息发过几条"的断言。 */
|
|
150
|
+
const allTexts = (state) =>
|
|
151
|
+
state.sends
|
|
152
|
+
.map((s) => (s && s.msg && s.msg.item_list && s.msg.item_list[0] && s.msg.item_list[0].text_item
|
|
153
|
+
? s.msg.item_list[0].text_item.text
|
|
154
|
+
: ""))
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
|
|
157
|
+
/** 等一个"最终会成立"的条件(有上限,不会挂死)。fire-and-forget 的路径用它消掉时序抖动。 */
|
|
158
|
+
async function waitFor(pred, { tries = 100, ms = 10 } = {}) {
|
|
159
|
+
for (let i = 0; i < tries; i += 1) {
|
|
160
|
+
if (pred()) return true;
|
|
161
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
162
|
+
}
|
|
163
|
+
return pred();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 给"**不该**发生的事"留一段结算时间(证明否定命题时用)。 */
|
|
167
|
+
const settle = () => new Promise((r) => setTimeout(r, 60));
|
|
168
|
+
|
|
169
|
+
// ── ① 词表翻译 ────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
test("★ 词表覆盖:事件侧每个 kind 都必须有归宿(上游新增节点会当场暴露)", () => {
|
|
172
|
+
assert.equal(assertKindCoverage(), true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("★ 翻译:事件 kind → 文案 kind 全部命中,不再渲染出「未知节点」", async () => {
|
|
176
|
+
const expect = {
|
|
177
|
+
[NODE_KINDS.APPROVAL_REQUEST]: "approval",
|
|
178
|
+
[NODE_KINDS.USER_QUESTION]: "question",
|
|
179
|
+
[NODE_KINDS.PLAN_REVIEW]: "plan",
|
|
180
|
+
[NODE_KINDS.SESSION_ERROR]: "error",
|
|
181
|
+
[NODE_KINDS.TURN_END]: "stopped",
|
|
182
|
+
[NODE_KINDS.DIGEST_DUE]: "daily",
|
|
183
|
+
[NODE_KINDS.QUOTA_LOW]: "quota",
|
|
184
|
+
[NODE_KINDS.MEMBERSHIP_EXPIRING]: "membership"
|
|
185
|
+
};
|
|
186
|
+
for (const [eventKind, formatterKind] of Object.entries(expect)) {
|
|
187
|
+
const { node } = toFormatterNode({ kind: eventKind });
|
|
188
|
+
assert.ok(node, `${eventKind} 没有翻译出文案节点`);
|
|
189
|
+
assert.equal(node.kind, formatterKind);
|
|
190
|
+
const text = formatNotification(node, {}).text;
|
|
191
|
+
assert.ok(!text.includes("未知节点"), `${eventKind} 渲染成了「未知节点」:${text}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("★ 翻译:字段名对齐(toolName→tool / questions→prompt+options / 简报 lines / 停止原因)", () => {
|
|
196
|
+
const a = toFormatterNode({ kind: NODE_KINDS.APPROVAL_REQUEST, toolName: "Bash", reason: "rm -rf" }).node;
|
|
197
|
+
assert.equal(a.tool, "Bash", "toolName 必须映射到 tool,否则文案显示「(未提供)」");
|
|
198
|
+
assert.match(formatNotification(a, {}).text, /工具: Bash/);
|
|
199
|
+
|
|
200
|
+
const q = toFormatterNode({
|
|
201
|
+
kind: NODE_KINDS.USER_QUESTION,
|
|
202
|
+
questions: [{ id: "q1", question: "选哪个?", options: ["A", "B"] }]
|
|
203
|
+
}).node;
|
|
204
|
+
assert.equal(q.prompt, "选哪个?");
|
|
205
|
+
assert.deepEqual(q.options, ["A", "B"], "提问的选项必须被抬到 options,否则用户看不到可回什么");
|
|
206
|
+
const qt = formatNotification(q, {});
|
|
207
|
+
assert.equal(qt.replyable, true);
|
|
208
|
+
assert.equal(qt.options.length, 2);
|
|
209
|
+
|
|
210
|
+
const d = toFormatterNode({ kind: NODE_KINDS.DIGEST_DUE, notified: 3, answered: 1 }).node;
|
|
211
|
+
assert.ok(Array.isArray(d.lines) && d.lines.length, "简报必须有 lines,否则文案是空的");
|
|
212
|
+
|
|
213
|
+
const t = toFormatterNode({ kind: NODE_KINDS.TURN_END, reason: "aborted" }).node;
|
|
214
|
+
assert.match(formatNotification(t, {}).text, /被中断|中止|停止原因/, "停止原因要翻译给用户看");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("特殊节点不该被当成「未接线」:event-expired / gap / fault 走各自的路径", () => {
|
|
218
|
+
for (const k of [NODE_KINDS.EVENT_EXPIRED, NODE_KINDS.GAP, NODE_KINDS.FAULT]) {
|
|
219
|
+
assert.equal(toFormatterNode({ kind: k }).node, null, `${k} 不应有文案模板`);
|
|
220
|
+
assert.equal(assertKindCoverage(), true, `${k} 必须被 SPECIAL 列表认领,否则覆盖自检会误报`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── ② -14 冷却 ────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
test("★ -14 冷却真的生效:命中 session timeout 后不得猛打接口", async () => {
|
|
227
|
+
const ilink = await fakeIlink({ onGetUpdates: (_s, reply) => reply({ ret: 0, errcode: -14, errmsg: "session timeout" }) });
|
|
228
|
+
try {
|
|
229
|
+
const { rt } = await boundRuntime(ilink, { cooldownMs: 300 });
|
|
230
|
+
await rt.startChannel();
|
|
231
|
+
await new Promise((r) => setTimeout(r, 1400));
|
|
232
|
+
// 冷却 300ms + 循环下限 1s → 1.4s 内应该只打了极少数几次;
|
|
233
|
+
// 若 isActive 短路 bug 回归,这里会变成几十次。
|
|
234
|
+
assert.ok(ilink.state.getUpdates <= 3, `冷却未生效:1.4s 内打了 ${ilink.state.getUpdates} 次 getupdates`);
|
|
235
|
+
// 断言「冷却被触发并记了账」而不是「此刻冷却未结束」——用 300ms 短冷却时,
|
|
236
|
+
// 1.4s 后冷却本来就该结束了;原先断言 remainingMs>0 是测试自己写错了。
|
|
237
|
+
assert.match(
|
|
238
|
+
rt.status().last_error,
|
|
239
|
+
/冷却|token 失效|会话过期/,
|
|
240
|
+
`-14 应被登记为冷却状态,实际:${JSON.stringify(rt.status().last_error)}`
|
|
241
|
+
);
|
|
242
|
+
await rt.stop();
|
|
243
|
+
} finally {
|
|
244
|
+
await ilink.close();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("SessionCooldown 没有 isActive():编排层不得依赖它(防静默短路回归)", () => {
|
|
249
|
+
const sc = new SessionCooldown({ cooldownMs: 1000 });
|
|
250
|
+
assert.equal(typeof sc.isActive, "undefined", "上游确实没有 isActive —— 若将来加了,请同步改运行时并用本测试提醒");
|
|
251
|
+
assert.equal(sc.remainingMs(), 0, "未 arm 时 remainingMs 必须是 0(不能是 undefined,否则比较会出错)");
|
|
252
|
+
sc.arm("test");
|
|
253
|
+
assert.ok(sc.remainingMs() > 0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── ③ 回执分派 ────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
test("★ 审批回执走 answerApproval,提问回执走 answerQuestion(不能混)", async () => {
|
|
259
|
+
const ilink = await fakeIlink();
|
|
260
|
+
try {
|
|
261
|
+
const sub = makeFakeSubscriber();
|
|
262
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
263
|
+
rt.channel.account = { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "user-1" };
|
|
264
|
+
|
|
265
|
+
// 1) 推一条审批
|
|
266
|
+
const sent = await rt.notify({
|
|
267
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-1", toolName: "Bash", reason: "rm", answerShape: "approval", at: 1
|
|
268
|
+
});
|
|
269
|
+
assert.equal(sent.ok, true);
|
|
270
|
+
assert.match(lastText(ilink.state), /需要你拍板/);
|
|
271
|
+
|
|
272
|
+
// 用户回 1 → 应走 answerApproval 且值为 allowed-once
|
|
273
|
+
ilink.state.queue.push({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
274
|
+
await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
275
|
+
const call = sub.calls[sub.calls.length - 1];
|
|
276
|
+
assert.equal(call.m, "answerApproval", "审批必须走 answerApproval,否则编码不对");
|
|
277
|
+
assert.equal(call.value, "allowed-once", "选项字段是 value,不是 outcome");
|
|
278
|
+
|
|
279
|
+
// 2) 推一条提问
|
|
280
|
+
await rt.notify({
|
|
281
|
+
kind: NODE_KINDS.USER_QUESTION, eventId: "ev-2", answerShape: "question", at: 1,
|
|
282
|
+
questions: [{ id: "q1", question: "选哪个?", options: ["甲", "乙"] }]
|
|
283
|
+
});
|
|
284
|
+
await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "2" } }] });
|
|
285
|
+
const call2 = sub.calls[sub.calls.length - 1];
|
|
286
|
+
assert.equal(call2.m, "answerQuestion", "提问必须走 answerQuestion");
|
|
287
|
+
assert.deepEqual(call2.answers, [{ id: "q1", selected: ["乙"] }]);
|
|
288
|
+
} finally {
|
|
289
|
+
await ilink.close();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("★ 过期绝不等于同意:没有待答通知时回数字,必须明说「没有代你做决定」", async () => {
|
|
294
|
+
const ilink = await fakeIlink();
|
|
295
|
+
try {
|
|
296
|
+
const sub = makeFakeSubscriber();
|
|
297
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
298
|
+
await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
299
|
+
assert.equal(sub.calls.length, 0, "不得在没有待答通知时发出任何回执");
|
|
300
|
+
assert.match(lastText(ilink.state), /没有代你做出任何选择/);
|
|
301
|
+
} finally {
|
|
302
|
+
await ilink.close();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("同一条通知不得被回复两次(consume 后再次回复应被拒)", async () => {
|
|
307
|
+
const ilink = await fakeIlink();
|
|
308
|
+
try {
|
|
309
|
+
const sub = makeFakeSubscriber();
|
|
310
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
311
|
+
await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-x", toolName: "B", answerShape: "approval", at: 1 });
|
|
312
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
313
|
+
const before = sub.calls.length;
|
|
314
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
315
|
+
assert.equal(sub.calls.length, before, "第二次回复不得再产生一次回执");
|
|
316
|
+
} finally {
|
|
317
|
+
await ilink.close();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── ④ 指令 / 帮助文案 ─────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
test("★ handleCommand 的字段是 replyText:回 /help 必须真的发出帮助(防静默发空)", async () => {
|
|
324
|
+
const ilink = await fakeIlink();
|
|
325
|
+
try {
|
|
326
|
+
const { rt } = await boundRuntime(ilink);
|
|
327
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
|
|
328
|
+
const text = lastText(ilink.state);
|
|
329
|
+
assert.ok(text.includes("/status") && text.includes("/unbind"), `帮助文案没发出来,实际:${JSON.stringify(text)}`);
|
|
330
|
+
} finally {
|
|
331
|
+
await ilink.close();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按最低档", () => {
|
|
336
|
+
const { capabilitiesFor, WECHAT_CAPABILITY_TABLE, grantsCap, limitsFor } = rtmod;
|
|
337
|
+
const free = capabilitiesFor("free");
|
|
338
|
+
// 免费档(业主口径):收得到通知、点得了审批、看得到状态、停得下任务 —— 但**遥控不了**。
|
|
339
|
+
for (const cap of ["notify", "approve", "status", "stop"]) {
|
|
340
|
+
assert.ok(free.includes(cap), `免费档必须有 ${cap}`);
|
|
341
|
+
}
|
|
342
|
+
for (const cap of ["assign", "sessions", "summary"]) {
|
|
343
|
+
assert.ok(!free.includes(cap), `免费档**不得**有 ${cap}(业主:那是付费能力)`);
|
|
344
|
+
}
|
|
345
|
+
// 付费档 = 免费档超集 + 遥控
|
|
346
|
+
const pro = capabilitiesFor("pro");
|
|
347
|
+
// ⚠️ 必须用 grantsCap(前缀规则)判超集:付费档写的是粗粒度 "assign",
|
|
348
|
+
// 它按前缀**覆盖** assign.continue —— 用裸 includes 会误判成"缺能力"。
|
|
349
|
+
for (const cap of free) assert.ok(grantsCap(pro, cap), `付费档必须授权免费档的每一项,缺 ${cap}`);
|
|
350
|
+
for (const cap of ["assign.new", "assign.continue", "sessions", "summary"]) {
|
|
351
|
+
assert.ok(grantsCap(pro, cap), `付费档必须有 ${cap}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ★★ 业主 2026-09-22 拍板的核心不变量:免费**能续接已有会话、不能开新会话**
|
|
355
|
+
assert.ok(grantsCap(free, "assign.continue"), "免费档必须能「继续已有会话」(回复即续接)");
|
|
356
|
+
assert.ok(!grantsCap(free, "assign.new"), "★免费档**不得**能开新会话(那是会员能力的差异点)");
|
|
357
|
+
assert.ok(!grantsCap(free, "sessions"), "免费档不得能切换会话");
|
|
358
|
+
|
|
359
|
+
// 前缀规则本身:粗粒度授权覆盖细粒度,细粒度不越权,未知能力一律不授权(fail-closed)
|
|
360
|
+
assert.ok(grantsCap(["assign"], "assign.new") && grantsCap(["assign"], "assign.continue"),
|
|
361
|
+
"写 assign 应同时覆盖 new 与 continue");
|
|
362
|
+
assert.ok(!grantsCap(["assign.continue"], "assign.new"), "写 assign.continue 不得越权到 assign.new");
|
|
363
|
+
assert.ok(!grantsCap(["assign.continue"], "assign"), "细粒度不得反向覆盖粗粒度");
|
|
364
|
+
assert.ok(!grantsCap(["assign"], "constructor"), "前缀规则不能被开头相同的无关词命中");
|
|
365
|
+
assert.ok(!grantsCap(free, "steer") && !grantsCap(["whatever"], "assign.new"), "未知能力不授权");
|
|
366
|
+
|
|
367
|
+
// 额度(业主:「免费给每月 N 条消息额度,超出再引导升级」;付费档不限)
|
|
368
|
+
assert.ok(Number(limitsFor("free").messages_per_month) > 0, "免费档必须有每月消息额度");
|
|
369
|
+
assert.equal(Number(limitsFor("pro").messages_per_month || 0), 0, "付费档不应限量(0 = 不限)");
|
|
370
|
+
assert.equal(Number(limitsFor("pro_max").messages_per_month || 0), 0, "最高档同样不限量");
|
|
371
|
+
assert.ok(pro.length > free.length, "付费档应比免费档多能力(否则钩子没意义)");
|
|
372
|
+
// ⚠️ 服务端的最高档取值是 **pro_max** —— 漏了它 capabilitiesFor 会回退 free,
|
|
373
|
+
// 把花了最多钱的用户当成免费用户挡在门外。
|
|
374
|
+
assert.deepEqual(capabilitiesFor("pro_max"), pro, "pro_max 必须与 pro 同权(服务端 plan 取值就是 pro_max)");
|
|
375
|
+
// 未知/空档位按最低档处理 —— 宁可少给,不可误放
|
|
376
|
+
assert.deepEqual(capabilitiesFor("nonexistent"), free);
|
|
377
|
+
assert.deepEqual(capabilitiesFor(""), free);
|
|
378
|
+
assert.deepEqual(capabilitiesFor(undefined), free);
|
|
379
|
+
// 表是冻结的(防止运行期被改)
|
|
380
|
+
assert.ok(Object.isFrozen(WECHAT_CAPABILITY_TABLE));
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("★ 分层:免费档被挡且回复里带 App 链接;付费档放行;未知档位按免费", async () => {
|
|
384
|
+
// 这条守的是两件事:(1) 门槛是**活代码**,不是文档;(2) 免费用户越界时拿到的是
|
|
385
|
+
// 「解释 + 可点击的 App 链接」,而不是一句干巴巴的拒绝 —— 那是把用户引回主功能的入口。
|
|
386
|
+
const APP_URL = "https://app.example/x/";
|
|
387
|
+
const ilink = await fakeIlink();
|
|
388
|
+
try {
|
|
389
|
+
const send = (rt) => rt.handleInbound({
|
|
390
|
+
from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }]
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ① 免费档:必须拒绝,且回复里要有 App 链接
|
|
394
|
+
const free = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "free", appUrl: APP_URL });
|
|
395
|
+
await send(free.rt);
|
|
396
|
+
assert.equal(free.rt.subscriber.calls.filter((c) => c.m === "createSession").length, 0,
|
|
397
|
+
"免费档不得真的建会话");
|
|
398
|
+
const freeText = lastText(ilink.state);
|
|
399
|
+
assert.match(freeText, /会员功能/, `要说清为什么被拒,实际:${freeText}`);
|
|
400
|
+
assert.ok(freeText.includes(APP_URL),
|
|
401
|
+
`★回复必须带上可点击的 App 链接(业主:免费用户要去做别的,就给他访问 App 的链接),实际:${freeText}`);
|
|
402
|
+
assert.match(freeText, /\/help/, "还要告诉他现在能做什么,而不是死路一条");
|
|
403
|
+
|
|
404
|
+
// ② 付费档:同一句话必须放行
|
|
405
|
+
const pro = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro", appUrl: APP_URL });
|
|
406
|
+
await send(pro.rt);
|
|
407
|
+
assert.equal(pro.rt.subscriber.calls.filter((c) => c.m === "createSession").length, 1,
|
|
408
|
+
"付费档必须能交代任务");
|
|
409
|
+
|
|
410
|
+
// ③ 未知档位按最低档 —— 宁可少给,不可误放
|
|
411
|
+
const weird = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "谁", appUrl: APP_URL });
|
|
412
|
+
await send(weird.rt);
|
|
413
|
+
assert.equal(weird.rt.subscriber.calls.filter((c) => c.m === "createSession").length, 0,
|
|
414
|
+
"未知档位必须按免费档处理");
|
|
415
|
+
} finally {
|
|
416
|
+
await ilink.close();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("★ 分层:免费档仍能收通知、点审批、看状态、急停(不能把免费版做成废的)", async () => {
|
|
421
|
+
const ilink = await fakeIlink();
|
|
422
|
+
try {
|
|
423
|
+
const sub = makeFakeSubscriber();
|
|
424
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free" });
|
|
425
|
+
|
|
426
|
+
// ① 通知 + 审批一步回执 —— 免费档的核心价值,绝不能因为分层被误伤
|
|
427
|
+
await rt.notify({
|
|
428
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "evt-free", toolName: "Read",
|
|
429
|
+
reason: "读取文件", answerShape: "approval", at: 1
|
|
430
|
+
});
|
|
431
|
+
assert.ok(rt.pendingReplies.length > 0, "免费档必须收到可回执的审批通知");
|
|
432
|
+
assert.match(lastText(ilink.state), /回复 1/, "非破坏性审批必须给编号选项");
|
|
433
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
434
|
+
assert.equal(sub.calls.filter((c) => String(c.m).startsWith("answer")).length, 1,
|
|
435
|
+
"免费档必须能一步回执审批");
|
|
436
|
+
|
|
437
|
+
// ② /status 是信息类:人人可用(也是免费用户的转化入口)
|
|
438
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
439
|
+
assert.ok(lastText(ilink.state).length > 0, "/status 必须对免费档可用");
|
|
440
|
+
|
|
441
|
+
// ③ /stop 是急停阀:免费档也必须能停。
|
|
442
|
+
// 免费用户不能主动 /use 切会话,但指针会在他收到事件时自动落上(见 #rememberSession),
|
|
443
|
+
// 这里直接把它置上,模拟"本来就在跑某个任务"。
|
|
444
|
+
rt.currentSessionId = "session-running";
|
|
445
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/stop" } }] });
|
|
446
|
+
assert.equal(sub.calls.filter((c) => c.m === "cancelSession").length, 1,
|
|
447
|
+
"免费档必须能急停(一个只会通知、连停都停不了的通道会让人觉得被挟持)");
|
|
448
|
+
} finally {
|
|
449
|
+
await ilink.close();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("★ 分层:档位取不到时沿用上次已知档位(绝不因一次网络抖动把付费用户降级)", async () => {
|
|
454
|
+
const ilink = await fakeIlink();
|
|
455
|
+
try {
|
|
456
|
+
let answer = "pro";
|
|
457
|
+
const sub1 = makeFakeSubscriber();
|
|
458
|
+
const { rt } = await boundRuntime(ilink, {
|
|
459
|
+
subscriber: sub1, tier: "", tierProvider: async () => answer, tierDenyThrottleMs: 0
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// 首次判档位时缓存为空(按免费)→ 被拒路径触发刷新 → 取到 pro → **同一条指令立即放行**
|
|
463
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
464
|
+
assert.equal(sub1.calls.filter((c) => c.m === "listSessions").length, 1,
|
|
465
|
+
"首次取到 pro 后就该放行(不能因为'一开始不知道'就把付费用户挡掉)");
|
|
466
|
+
|
|
467
|
+
// 此后账号 API 一直取不到(返回空串)→ 必须**沿用 pro**。
|
|
468
|
+
// ⚠️ 必须**主动**触发一次校准:光靠派发测不到 —— 缓存已是 pro 时 `#can` 直接放行,
|
|
469
|
+
// 根本不会再走刷新那条路径(变异测试实测:不加这一步,"取不到就降级"的变异抓不到)。
|
|
470
|
+
answer = "";
|
|
471
|
+
assert.equal(await rt.refreshTierNow(), "pro",
|
|
472
|
+
"★取不到档位时必须沿用上次的 pro —— 付过钱的用户不该因一次网络失败被判成免费");
|
|
473
|
+
|
|
474
|
+
// 而且能力也真的还在(不只是内部字段好看)
|
|
475
|
+
const sub2 = makeFakeSubscriber();
|
|
476
|
+
rt.subscriber = sub2;
|
|
477
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
478
|
+
assert.equal(sub2.calls.filter((c) => c.m === "listSessions").length, 1,
|
|
479
|
+
"沿用 pro 期间,遥控能力必须仍然可用");
|
|
480
|
+
} finally {
|
|
481
|
+
await ilink.close();
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("★ 分层:刚升级完立刻重试必须能进(不必重启 bridge)", async () => {
|
|
486
|
+
const ilink = await fakeIlink();
|
|
487
|
+
try {
|
|
488
|
+
// 一开始服务端说 free → 交代任务被拒;用户买完后服务端改口 pro → 同一条指令必须能进。
|
|
489
|
+
// tierDenyThrottleMs:0 把节流关掉,让这条路径确定性地可测(生产默认 5 秒,注释已写明)。
|
|
490
|
+
let plan = "free";
|
|
491
|
+
const sub = makeFakeSubscriber();
|
|
492
|
+
const { rt } = await boundRuntime(ilink, {
|
|
493
|
+
subscriber: sub, tier: "", tierProvider: async () => plan, tierDenyThrottleMs: 0
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
497
|
+
assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 0, "免费时先拒");
|
|
498
|
+
|
|
499
|
+
plan = "pro"; // 用户升级
|
|
500
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
501
|
+
assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 1,
|
|
502
|
+
"★升级后必须能进:否则用户付了钱还要重启 bridge 才能用");
|
|
503
|
+
} finally {
|
|
504
|
+
await ilink.close();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("★ v2:纯文本 = 交代任务 —— 没有当前会话时自动开新会话并下发", async () => {
|
|
509
|
+
const ilink = await fakeIlink();
|
|
510
|
+
try {
|
|
511
|
+
const sub = makeFakeSubscriber();
|
|
512
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
513
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "帮我修个 bug" } }] });
|
|
514
|
+
const created = sub.calls.find((c) => c.m === "createSession");
|
|
515
|
+
const prompted = sub.calls.find((c) => c.m === "promptSession");
|
|
516
|
+
assert.ok(created, "没有当前会话时,第一条普通消息应开新会话");
|
|
517
|
+
assert.equal(prompted && prompted.text, "帮我修个 bug", "任务文本必须原样下发");
|
|
518
|
+
assert.match(lastText(ilink.state), /已开新任务/);
|
|
519
|
+
} finally {
|
|
520
|
+
await ilink.close();
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("★ v2:有当前会话时纯文本进当前会话(不是每次开新的)", async () => {
|
|
525
|
+
const ilink = await fakeIlink();
|
|
526
|
+
try {
|
|
527
|
+
const sub = makeFakeSubscriber();
|
|
528
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
529
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 第一件事" } }] });
|
|
530
|
+
sub.calls.length = 0;
|
|
531
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "再补充一句" } }] });
|
|
532
|
+
const created = sub.calls.find((c) => c.m === "createSession");
|
|
533
|
+
const prompted = sub.calls.find((c) => c.m === "promptSession");
|
|
534
|
+
assert.ok(!created, "已有当前会话时**不得**再开新会话");
|
|
535
|
+
assert.equal(prompted && prompted.sessionId, "session-new-1");
|
|
536
|
+
assert.equal(prompted && prompted.text, "再补充一句");
|
|
537
|
+
} finally {
|
|
538
|
+
await ilink.close();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("★ v2 安全边界(业主拍板放宽):看不到内容照给一键回执,只看得出破坏性才降级", async () => {
|
|
543
|
+
// 背景:DSH 的审批节点常常只下发 toolName、没有命令原文。原先「看不到内容也算破坏性」
|
|
544
|
+
// 会把绝大多数正常 Bash 审批都降级成"只能回电脑确认" —— 过严。业主明确:DSH 自身有
|
|
545
|
+
// 权限控制,安全边界可以松一点。所以现在**只看内容**。
|
|
546
|
+
const ilink = await fakeIlink();
|
|
547
|
+
try {
|
|
548
|
+
const sub = makeFakeSubscriber();
|
|
549
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
550
|
+
|
|
551
|
+
// (a) 没有内容 → 按普通审批,保留一步回执
|
|
552
|
+
await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "d2", toolName: "Bash", answerShape: "approval", at: 1 });
|
|
553
|
+
const a = lastText(ilink.state);
|
|
554
|
+
assert.match(a, /需要你拍板/, `看不到内容应是普通审批,实际:${a}`);
|
|
555
|
+
assert.match(a, /回复 1/, "看不到内容必须仍给编号选项(放宽后的行为)");
|
|
556
|
+
assert.equal(rt.pendingReplies.length, 1, "应登记为待答,用户能一键处理");
|
|
557
|
+
|
|
558
|
+
// 回 1 必须真的能放行 —— 这是放宽后要保证的核心路径
|
|
559
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
560
|
+
const answered = sub.calls.filter((c) => c.m === "answerApproval");
|
|
561
|
+
assert.equal(answered.length, 1, "普通审批必须能被微信一键放行");
|
|
562
|
+
assert.equal(answered[0].value, "allowed-once");
|
|
563
|
+
|
|
564
|
+
// (b) 内容里看得出破坏性 → 仍然降级,不给一键回执
|
|
565
|
+
sub.calls.length = 0;
|
|
566
|
+
await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "d1", toolName: "Bash", reason: "rm -rf /x", answerShape: "approval", at: 1 });
|
|
567
|
+
const b = lastText(ilink.state);
|
|
568
|
+
assert.match(b, /到电脑上确认/, `破坏性内容必须要求回电脑,实际:${b}`);
|
|
569
|
+
assert.match(b, /内容.*破坏性/, "有内容时应说是**内容**被判定破坏性");
|
|
570
|
+
assert.ok(!/回复 1/.test(b), "破坏性操作不得给编号选项");
|
|
571
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
572
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerApproval").length, 0, "破坏性操作绝不能被一键放行");
|
|
573
|
+
} finally {
|
|
574
|
+
await ilink.close();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("★ v2:/ls 列出会话(带名称),/use N 切换并落盘", async () => {
|
|
579
|
+
const ilink = await fakeIlink();
|
|
580
|
+
try {
|
|
581
|
+
const sub = makeFakeSubscriber();
|
|
582
|
+
sub.sessionsFixture = [
|
|
583
|
+
{ sessionId: "session-aaa", title: "修复 Windows 兼容性", running: true, cwd: "/p" },
|
|
584
|
+
{ sessionId: "session-bbb", title: "写周报", running: false, cwd: "/p" }
|
|
585
|
+
];
|
|
586
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
587
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
588
|
+
const listed = lastText(ilink.state);
|
|
589
|
+
assert.match(listed, /修复 Windows 兼容性/, `列表必须带会话名称,实际:${listed}`);
|
|
590
|
+
assert.match(listed, /写周报/);
|
|
591
|
+
|
|
592
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
|
|
593
|
+
assert.equal(rt.currentSessionId, "session-bbb");
|
|
594
|
+
// 必须落盘 —— 否则 bridge 一重启,用户选好的任务就丢了,下一条消息会静默开成新任务
|
|
595
|
+
assert.equal(loadState(relayDir).current_session_id, "session-bbb", "当前会话指针必须落盘");
|
|
596
|
+
} finally {
|
|
597
|
+
await ilink.close();
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("★ v2:当前会话指针在重启后恢复(否则用户下一条消息会静默开成新任务)", async () => {
|
|
602
|
+
const ilink = await fakeIlink();
|
|
603
|
+
try {
|
|
604
|
+
const sub = makeFakeSubscriber();
|
|
605
|
+
sub.sessionsFixture = [{ sessionId: "session-zzz", title: "长任务", running: false, cwd: "/p" }];
|
|
606
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
607
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
608
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 1" } }] });
|
|
609
|
+
|
|
610
|
+
// 模拟 bridge 重启:用同一个 relayDir 新建一个运行时
|
|
611
|
+
const sub2 = makeFakeSubscriber();
|
|
612
|
+
const rt2 = createWeChatRuntime({
|
|
613
|
+
relayDir, upstream: "http://127.0.0.1:1", secret: SECRET,
|
|
614
|
+
tier: "pro", // 本用例测的是"指针持久化",不是分层;按付费档跑才有"发消息"这条能力
|
|
615
|
+
logger: quietLogger()
|
|
616
|
+
});
|
|
617
|
+
rt2.subscriber = sub2;
|
|
618
|
+
assert.equal(rt2.currentSessionId, "session-zzz", "重启后必须还记得当前会话");
|
|
619
|
+
await rt2.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续" } }] });
|
|
620
|
+
const prompted = sub2.calls.find((c) => c.m === "promptSession");
|
|
621
|
+
assert.equal(prompted && prompted.sessionId, "session-zzz", "重启后必须接着原会话,不能开新的");
|
|
622
|
+
} finally {
|
|
623
|
+
await ilink.close();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("★ v2:完成推送带会话名称 + 结论,而不只是「任务已停止」", async () => {
|
|
628
|
+
const ilink = await fakeIlink();
|
|
629
|
+
try {
|
|
630
|
+
const sub = makeFakeSubscriber();
|
|
631
|
+
sub.sessionsFixture = [{ sessionId: "session-s1", title: "修复 Windows 兼容性", running: false, cwd: "/p" }];
|
|
632
|
+
sub.summaryFixture = "定位到 PATH 分隔符问题,已修复并验证。";
|
|
633
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
634
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 1" } }] });
|
|
635
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
|
|
636
|
+
const text = lastText(ilink.state);
|
|
637
|
+
assert.match(text, /修复 Windows 兼容性/, `必须带会话名称,实际:${text}`);
|
|
638
|
+
assert.match(text, /PATH 分隔符/, `必须带结论,实际:${text}`);
|
|
639
|
+
assert.match(text, /完成/, "正常完成不能读起来像失败");
|
|
640
|
+
} finally {
|
|
641
|
+
await ilink.close();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("★ 分层:免费档的完成推送不能承诺「回复就能接着做」,要改成会员说明 + App 链接", async () => {
|
|
646
|
+
// 默认那句「回复这条消息就能接着这个会话往下做」对免费用户是**空头承诺**:
|
|
647
|
+
// 他回复纯文本只会拿到付费引导。文案必须跟着档位走,否则用户会以为产品坏了。
|
|
648
|
+
const APP_URL = "https://app.example/x/";
|
|
649
|
+
const ilink = await fakeIlink();
|
|
650
|
+
try {
|
|
651
|
+
const sub = makeFakeSubscriber();
|
|
652
|
+
sub.sessionsFixture = [{ sessionId: "session-s1", title: "跑个长任务", running: false, cwd: "/p" }];
|
|
653
|
+
sub.summaryFixture = "已完成。";
|
|
654
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free", appUrl: APP_URL });
|
|
655
|
+
rt.currentSessionId = "session-s1";
|
|
656
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
|
|
657
|
+
const text = lastText(ilink.state);
|
|
658
|
+
|
|
659
|
+
// 免费档照样要看得到"完成了 + 结论"(这是他的核心价值)
|
|
660
|
+
assert.match(text, /完成/, "免费档必须照样收到完成通知");
|
|
661
|
+
assert.match(text, /已完成/, "结论也要给");
|
|
662
|
+
assert.ok(!/就能接着这个会话/.test(text),
|
|
663
|
+
`★不得对免费用户承诺"回复就能接着做"(他做不到),实际:${text}`);
|
|
664
|
+
assert.ok(text.includes(APP_URL), `★收尾要给出 App 链接,实际:${text}`);
|
|
665
|
+
|
|
666
|
+
// 付费档:保留原话术(产品核心,别被这条改动误伤)
|
|
667
|
+
const ilink2 = await fakeIlink();
|
|
668
|
+
try {
|
|
669
|
+
const sub2 = makeFakeSubscriber();
|
|
670
|
+
sub2.sessionsFixture = [{ sessionId: "session-s1", title: "跑个长任务", running: false, cwd: "/p" }];
|
|
671
|
+
sub2.summaryFixture = "已完成。";
|
|
672
|
+
const { rt: rt2 } = await boundRuntime(ilink2, { subscriber: sub2, tier: "pro", appUrl: APP_URL });
|
|
673
|
+
rt2.currentSessionId = "session-s1";
|
|
674
|
+
await rt2.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
|
|
675
|
+
assert.match(lastText(ilink2.state), /就能接着这个会话/, "付费档必须保留「回复即续接」这句");
|
|
676
|
+
} finally {
|
|
677
|
+
await ilink2.close();
|
|
678
|
+
}
|
|
679
|
+
} finally {
|
|
680
|
+
await ilink.close();
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("★ 分层:/help 必须按档位如实分层(免费用户看到的清单里不能有他做不到的指令)", async () => {
|
|
685
|
+
const APP_URL = "https://app.example/x/";
|
|
686
|
+
const ilink = await fakeIlink();
|
|
687
|
+
try {
|
|
688
|
+
const free = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "free", appUrl: APP_URL });
|
|
689
|
+
await free.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
|
|
690
|
+
const t = lastText(ilink.state);
|
|
691
|
+
assert.match(t, /通知版/, "免费档的帮助要说明自己是通知版");
|
|
692
|
+
assert.ok(t.includes(APP_URL), "免费档的帮助里必须带开通入口(App 链接)");
|
|
693
|
+
assert.match(t, /会员功能/, "会员能力必须单独分组,不能混进「现在能用的」");
|
|
694
|
+
// /new 只能出现在「会员功能」那一段之后 —— 混在前面就是骗人
|
|
695
|
+
const memberAt = t.indexOf("会员功能");
|
|
696
|
+
assert.ok(memberAt > -1 && t.indexOf("/new") > memberAt, "/new 不得出现在免费能力段里");
|
|
697
|
+
|
|
698
|
+
const pro = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro", appUrl: APP_URL });
|
|
699
|
+
await pro.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
|
|
700
|
+
const pt = lastText(ilink.state);
|
|
701
|
+
assert.match(pt, /DSH 微信通道/, "付费档仍用完整帮助");
|
|
702
|
+
assert.ok(!/通知版/.test(pt), "付费档不该被标成通知版");
|
|
703
|
+
} finally {
|
|
704
|
+
await ilink.close();
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("★ 分层:免费用户打字「允许」(而不是回数字)时,要先把「回数字就能拍板」说在前面", async () => {
|
|
709
|
+
// 回执只认纯数字(classifyInbound),而免费档的核心价值就是审批 ——
|
|
710
|
+
// 用户很可能打字「允许」。这时只回一句付费提示,他会以为免费版什么都干不了。
|
|
711
|
+
const ilink = await fakeIlink();
|
|
712
|
+
try {
|
|
713
|
+
const sub = makeFakeSubscriber();
|
|
714
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free", appUrl: "https://app.example/x/" });
|
|
715
|
+
await rt.notify({
|
|
716
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "evt-1", toolName: "Read",
|
|
717
|
+
reason: "读取文件", answerShape: "approval", at: 1
|
|
718
|
+
});
|
|
719
|
+
assert.ok(rt.pendingReplies.length > 0, "先得有一条待回执的消息");
|
|
720
|
+
|
|
721
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "允许" } }] });
|
|
722
|
+
const text = lastText(ilink.state);
|
|
723
|
+
assert.match(text, /回一个数字/, `必须先告诉他怎么拍板,实际:${text}`);
|
|
724
|
+
assert.match(text, /待你拍板/, "要说明确实有东西在等他决定");
|
|
725
|
+
assert.ok(text.includes("https://app.example/x/"), "仍然要带上 App 链接");
|
|
726
|
+
|
|
727
|
+
// 而回数字本身必须真的能拍板(免费档的核心价值不能被这条改动带坏)
|
|
728
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
729
|
+
assert.equal(sub.calls.filter((c) => String(c.m).startsWith("answer")).length, 1,
|
|
730
|
+
"回数字必须照常完成审批");
|
|
731
|
+
} finally {
|
|
732
|
+
await ilink.close();
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("★★ 日报发送失败时**不能**把当天标记为已发(否则当天再也不会补发)", async () => {
|
|
737
|
+
// 旧实现先写 last_digest_day **再**发送 → 一旦这次没发出去(网络抖动),当天的简报就被烧掉。
|
|
738
|
+
const ilink = await fakeIlink();
|
|
739
|
+
try {
|
|
740
|
+
const sub = makeFakeSubscriber();
|
|
741
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
742
|
+
// 让"现在"正好等于简报时刻,真实路径才会通过时刻闸门
|
|
743
|
+
rt.digestHour = new Date().getHours();
|
|
744
|
+
const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
745
|
+
|
|
746
|
+
const origSend = rt.channel.client.sendMessage.bind(rt.channel.client);
|
|
747
|
+
const sends = () => ilink.state.sends.length;
|
|
748
|
+
|
|
749
|
+
// ① 发送失败 → 不得记账
|
|
750
|
+
rt.channel.client.sendMessage = async () => { throw new Error("boom"); };
|
|
751
|
+
await rt.runMaybeDigestNow();
|
|
752
|
+
assert.ok(!stateOf().last_digest_day,
|
|
753
|
+
"★发送失败时绝不能标记今天已发 —— 否则当天再也不会补发(旧实现的坑)");
|
|
754
|
+
|
|
755
|
+
// ② 恢复发送 → 必须能补发成功并记账
|
|
756
|
+
rt.channel.client.sendMessage = origSend;
|
|
757
|
+
const n = sends();
|
|
758
|
+
await rt.runMaybeDigestNow();
|
|
759
|
+
assert.equal(sends(), n + 1, "恢复后应当补发一条");
|
|
760
|
+
assert.ok(stateOf().last_digest_day, "补发成功后要记账");
|
|
761
|
+
|
|
762
|
+
// ③ 已经发过 → 同一天不再重复
|
|
763
|
+
await rt.runMaybeDigestNow();
|
|
764
|
+
assert.equal(sends(), n + 1, "同一天只发一次(它兼作 24h 窗口心跳,不能刷屏)");
|
|
765
|
+
} finally {
|
|
766
|
+
await ilink.close();
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("★ 日报:晚上发(不是早上)、内容是「今天干了啥」、带品牌露出", async () => {
|
|
771
|
+
// 业主:「每天早上发『今日干了啥事儿』,这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」
|
|
772
|
+
// 且一天只发一次(它兼作 24h 推送窗口的心跳,早晚各一次就太频繁了)。
|
|
773
|
+
assert.equal(rtmod.DEFAULT_DIGEST_HOUR, 18, "日报时刻必须是晚上 18 点,不能是早上 9 点");
|
|
774
|
+
const ilink = await fakeIlink();
|
|
775
|
+
try {
|
|
776
|
+
const sub = makeFakeSubscriber();
|
|
777
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
778
|
+
// 造出今天的数据:一个正常完成的任务 + 一次真的拍板
|
|
779
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "completed", at: 1 });
|
|
780
|
+
await rt.notify({
|
|
781
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "a1", toolName: "Read",
|
|
782
|
+
reason: "读配置", answerShape: "approval", at: 1
|
|
783
|
+
});
|
|
784
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
785
|
+
await rt.runDigestNow();
|
|
786
|
+
const text = lastText(ilink.state);
|
|
787
|
+
assert.match(text, /完成任务 1 个/, `日报要回顾**今天做了什么**,实际:${text}`);
|
|
788
|
+
assert.match(text, /你在微信里拍板 1 次/, "拍板次数也要回顾(频道价值可见)");
|
|
789
|
+
assert.match(text, /DSH 远程控制/, "要有品牌轻露出");
|
|
790
|
+
assert.match(text, /回复/, "必须保留 24h 窗口心跳那句(靠用户回消息续期)");
|
|
791
|
+
assert.doesNotMatch(text, /没有要做的事/, "不许再用「待办清单」的措辞");
|
|
792
|
+
} finally {
|
|
793
|
+
await ilink.close();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("★★ 会员到期提醒:提前 2 天 / 1 天 / 到期当天各提醒一次,同一天不重复", async () => {
|
|
798
|
+
// 这条**以前根本不存在**:notifyMembershipExpiring() 只有定义、全仓零调用方 ——
|
|
799
|
+
// 业主以为"提前两天发、提前一天也发",实际一次都没发过。这里锁住补上的触发点。
|
|
800
|
+
const ilink = await fakeIlink();
|
|
801
|
+
try {
|
|
802
|
+
const DAY = 86_400_000;
|
|
803
|
+
let info = { plan: "pro", plan_ends_at: Date.now() + 2 * DAY, trial_expires_at: null };
|
|
804
|
+
const sub = makeFakeSubscriber();
|
|
805
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
806
|
+
rt.accountInfo = async () => info;
|
|
807
|
+
|
|
808
|
+
const before = ilink.state.sends.length;
|
|
809
|
+
await rt.runExpiryRemindNow();
|
|
810
|
+
assert.equal(ilink.state.sends.length, before + 1, "提前 2 天必须发一条提醒");
|
|
811
|
+
|
|
812
|
+
// 同一天、同一阈值:**不重复**
|
|
813
|
+
await rt.runExpiryRemindNow();
|
|
814
|
+
assert.equal(ilink.state.sends.length, before + 1, "同一个阈值一天只能提醒一次");
|
|
815
|
+
|
|
816
|
+
// 提前 5 天:保持安静(只在 2/1/到期当天三个节点说话)
|
|
817
|
+
info = { plan: "pro", plan_ends_at: Date.now() + 5 * DAY, trial_expires_at: null };
|
|
818
|
+
await rt.runExpiryRemindNow();
|
|
819
|
+
assert.equal(ilink.state.sends.length, before + 1, "提前 5 天不该打扰用户");
|
|
820
|
+
|
|
821
|
+
// 提前 1 天:是新阈值 → 再发一次
|
|
822
|
+
info = { plan: "pro", plan_ends_at: Date.now() + 1 * DAY, trial_expires_at: null };
|
|
823
|
+
await rt.runExpiryRemindNow();
|
|
824
|
+
assert.equal(ilink.state.sends.length, before + 2, "提前 1 天要再提醒一次");
|
|
825
|
+
|
|
826
|
+
// 没有到期信息 / 取不到账号 → 什么都不发(宁可不发,也不拿猜的到期日骚扰)
|
|
827
|
+
info = { plan: "pro", plan_ends_at: null, trial_expires_at: null };
|
|
828
|
+
const n = ilink.state.sends.length;
|
|
829
|
+
await rt.runExpiryRemindNow();
|
|
830
|
+
assert.equal(ilink.state.sends.length, n, "没有到期日就不该发");
|
|
831
|
+
|
|
832
|
+
rt.accountInfo = null;
|
|
833
|
+
await rt.runExpiryRemindNow();
|
|
834
|
+
assert.equal(ilink.state.sends.length, n, "没有 accountInfo 数据源就什么都不发");
|
|
835
|
+
} finally {
|
|
836
|
+
await ilink.close();
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("★★ A:免费档「能续接已有会话、不能开新会话」(业主拍板的分级)", async () => {
|
|
841
|
+
const ilink = await fakeIlink();
|
|
842
|
+
try {
|
|
843
|
+
// ① 免费 + **没有**当前会话 → 纯文本等于开新任务 → 必须被拒
|
|
844
|
+
const subA = makeFakeSubscriber();
|
|
845
|
+
const a = await boundRuntime(ilink, { subscriber: subA, tier: "free", appUrl: "https://app.example/x/" });
|
|
846
|
+
await a.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "帮我修个 bug" } }] });
|
|
847
|
+
assert.equal(subA.calls.filter((c) => c.m === "createSession").length, 0,
|
|
848
|
+
"免费 + 无当前会话:不得开新任务");
|
|
849
|
+
assert.match(lastText(ilink.state), /会员功能/, "要说明这是会员能力");
|
|
850
|
+
|
|
851
|
+
// ② 免费 + **有**当前会话 → 纯文本必须放行(这就是"能接着聊")
|
|
852
|
+
const subB = makeFakeSubscriber();
|
|
853
|
+
const b = await boundRuntime(ilink, { subscriber: subB, tier: "free" });
|
|
854
|
+
b.rt.currentSessionId = "session-x";
|
|
855
|
+
b.rt.currentSessionTitle = "已有任务";
|
|
856
|
+
await b.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续修" } }] });
|
|
857
|
+
const prompted = subB.calls.filter((c) => c.m === "promptSession");
|
|
858
|
+
assert.equal(prompted.length, 1, "★免费 + 有当前会话:必须能继续聊(业主要的就是这个)");
|
|
859
|
+
assert.equal(prompted[0].text, "继续修", "内容要原样下发");
|
|
860
|
+
|
|
861
|
+
// ③ 免费仍不能开新会话、不能切换会话
|
|
862
|
+
const subC = makeFakeSubscriber();
|
|
863
|
+
const c = await boundRuntime(ilink, { subscriber: subC, tier: "free" });
|
|
864
|
+
c.rt.currentSessionId = "session-x";
|
|
865
|
+
await c.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 新任务" } }] });
|
|
866
|
+
assert.equal(subC.calls.filter((m) => m.m === "createSession").length, 0, "免费不得开新会话");
|
|
867
|
+
await c.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
868
|
+
assert.equal(subC.calls.filter((m) => m.m === "listSessions").length, 0, "免费不得列/切会话");
|
|
869
|
+
} finally {
|
|
870
|
+
await ilink.close();
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("★★ B:免费档每月额度用完后引导升级;审批与指令不计数;付费档不限", async () => {
|
|
875
|
+
const LIMITS = { free: { messages_per_month: 2 }, pro: { messages_per_month: 0 }, pro_max: { messages_per_month: 0 } };
|
|
876
|
+
const ilink = await fakeIlink();
|
|
877
|
+
try {
|
|
878
|
+
const sub = makeFakeSubscriber();
|
|
879
|
+
const { rt } = await boundRuntime(ilink, {
|
|
880
|
+
subscriber: sub, tier: "free", appUrl: "https://app.example/x/", limitTable: LIMITS
|
|
881
|
+
});
|
|
882
|
+
rt.currentSessionId = "s";
|
|
883
|
+
rt.currentSessionTitle = "t";
|
|
884
|
+
const send = () => rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" } }] });
|
|
885
|
+
|
|
886
|
+
// 审批与指令**不**吃额度:先做一次审批,额度不受影响
|
|
887
|
+
await rt.notify({
|
|
888
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "q1", toolName: "Read",
|
|
889
|
+
reason: "读配置", answerShape: "approval", at: 1
|
|
890
|
+
});
|
|
891
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
892
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerApproval").length, 1, "审批必须照常可用(安全阀不能吃额度)");
|
|
893
|
+
|
|
894
|
+
await send(); await send();
|
|
895
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 2, "额度内 2 条应放行");
|
|
896
|
+
|
|
897
|
+
await send();
|
|
898
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 2, "★超出额度后不得再派活");
|
|
899
|
+
const text = lastText(ilink.state);
|
|
900
|
+
assert.match(text, /额度已经用完/, `要说清是用完额度而不是报错,实际:${text}`);
|
|
901
|
+
assert.ok(text.includes("https://app.example/x/"), "仍要给升级入口(App 链接)");
|
|
902
|
+
|
|
903
|
+
// 付费档:同样连发 5 条都不受限
|
|
904
|
+
const paid = makeFakeSubscriber();
|
|
905
|
+
const p = await boundRuntime(ilink, { subscriber: paid, tier: "pro" });
|
|
906
|
+
p.rt.currentSessionId = "s";
|
|
907
|
+
for (let i = 0; i < 5; i += 1) {
|
|
908
|
+
await p.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" + i } }] });
|
|
909
|
+
}
|
|
910
|
+
assert.equal(paid.calls.filter((c) => c.m === "promptSession").length, 5, "付费档不限量");
|
|
911
|
+
} finally {
|
|
912
|
+
await ilink.close();
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("★★ 权益包:服务端下发的 caps/limits 覆盖内置表(后台改权限无需发版)", async () => {
|
|
917
|
+
const ilink = await fakeIlink();
|
|
918
|
+
try {
|
|
919
|
+
// ① 服务端给免费档额外开了 assign.new → 免费用户当场就能开新会话
|
|
920
|
+
const subA = makeFakeSubscriber();
|
|
921
|
+
const a = await boundRuntime(ilink, {
|
|
922
|
+
subscriber: subA, tier: "free",
|
|
923
|
+
entitlements: { rev: "r1", plan: "free", caps: ["notify", "approve", "assign.new", "assign.continue"], limits: { messages_per_month: 0 } }
|
|
924
|
+
});
|
|
925
|
+
await a.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
926
|
+
assert.equal(subA.calls.filter((c) => c.m === "createSession").length, 1,
|
|
927
|
+
"服务端放开后,免费档也能开新会话(证明权限真的由配置决定)");
|
|
928
|
+
|
|
929
|
+
// ② 反向:服务端把付费档的 assign 收掉 → 同一个付费用户立刻不能派活
|
|
930
|
+
const subB = makeFakeSubscriber();
|
|
931
|
+
const b = await boundRuntime(ilink, {
|
|
932
|
+
subscriber: subB, tier: "pro",
|
|
933
|
+
entitlements: { rev: "r2", plan: "pro", caps: ["notify", "approve", "status", "stop"], limits: {} }
|
|
934
|
+
});
|
|
935
|
+
b.rt.currentSessionId = "s";
|
|
936
|
+
await b.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" } }] });
|
|
937
|
+
assert.equal(subB.calls.filter((c) => c.m === "promptSession").length, 0,
|
|
938
|
+
"服务端收权后付费档也不该能派活(收权必须真的生效)");
|
|
939
|
+
} finally {
|
|
940
|
+
await ilink.close();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
test("★★ 多条待审批:回数字必须答**最早那条**(先进先出),答完把下一条顶到底部", async () => {
|
|
945
|
+
// 业主报的真 bug:「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,
|
|
946
|
+
// 回应一个数字之后,流程就结束了。后面的几条消息没有让用户继续回应,直接提示为『未回应』」
|
|
947
|
+
// 成因:旧实现按**栈顶(最新)**匹配数字,而微信是**从上往下读**的、消息位置一发出去就固定
|
|
948
|
+
// → 用户看着第 1 条回「1」,系统答了最后一条,第 1 条永远没人回应,DSH 最终标它「未回应」。
|
|
949
|
+
const ilink = await fakeIlink();
|
|
950
|
+
try {
|
|
951
|
+
const sub = makeFakeSubscriber();
|
|
952
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
953
|
+
|
|
954
|
+
await rt.notify({
|
|
955
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "e1", toolName: "Read",
|
|
956
|
+
reason: "读配置", answerShape: "approval", at: 1
|
|
957
|
+
});
|
|
958
|
+
// 第二条到达时第一条还没被答 → 文案必须说明"不止这一条"
|
|
959
|
+
await rt.notify({
|
|
960
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "e2", toolName: "Write",
|
|
961
|
+
reason: "写文件", answerShape: "approval", at: 2
|
|
962
|
+
});
|
|
963
|
+
assert.equal(rt.pendingReplies.length, 2, "两条都该登记为待答");
|
|
964
|
+
assert.match(lastText(ilink.state), /还有 2 条待拍板/,
|
|
965
|
+
`★必须告诉用户"不止这一条",否则他以为答完就结束了,实际:${lastText(ilink.state)}`);
|
|
966
|
+
|
|
967
|
+
// 回「1」→ 必须答**最早**的 e1
|
|
968
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
969
|
+
const answered = sub.calls.filter((c) => c.m === "answerApproval");
|
|
970
|
+
assert.equal(answered.length, 1, "必须真的回执一条");
|
|
971
|
+
assert.equal(answered[0].id, "e1", "★必须答**最早那条**(e1),而不是最新那条(e2)");
|
|
972
|
+
|
|
973
|
+
// 答完还剩 1 条 → 必须把**下一条重发到底部**,用户直接在最新消息上回数字
|
|
974
|
+
const after = lastText(ilink.state);
|
|
975
|
+
assert.match(after, /还有 1 条待你拍板/, `★答完要把下一条顶到底部,实际:${after}`);
|
|
976
|
+
assert.match(after, /回复 1/, "重发的提醒要带可回数字的选项");
|
|
977
|
+
assert.match(after, /Write/, "要指明下一条是什么(工具名),用户才知道在批什么");
|
|
978
|
+
|
|
979
|
+
// 再回一次 → 答掉 e2;队列空了就不该再发多余提醒
|
|
980
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
981
|
+
const all = sub.calls.filter((c) => c.m === "answerApproval");
|
|
982
|
+
assert.equal(all.length, 2);
|
|
983
|
+
assert.equal(all[1].id, "e2", "第二条应是 e2");
|
|
984
|
+
assert.doesNotMatch(lastText(ilink.state), /待你拍板/, "队列空了就不该再发提醒");
|
|
985
|
+
} finally {
|
|
986
|
+
await ilink.close();
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("★ v2 安全:破坏性审批不给回执选项,回数字也不产生任何回执", async () => {
|
|
991
|
+
const ilink = await fakeIlink();
|
|
992
|
+
try {
|
|
993
|
+
const sub = makeFakeSubscriber();
|
|
994
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
995
|
+
await rt.notify({
|
|
996
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "evt-d", toolName: "Bash",
|
|
997
|
+
reason: "rm -rf /important", answerShape: "approval", at: 1
|
|
998
|
+
});
|
|
999
|
+
const text = lastText(ilink.state);
|
|
1000
|
+
assert.match(text, /到电脑上确认/);
|
|
1001
|
+
assert.ok(!/回复 1/.test(text), "破坏性操作不得给编号选项");
|
|
1002
|
+
assert.equal(rt.pendingReplies.length, 0, "不得登记为待答");
|
|
1003
|
+
|
|
1004
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
1005
|
+
const answers = sub.calls.filter((c) => String(c.m).startsWith("answer"));
|
|
1006
|
+
assert.equal(answers.length, 0, "破坏性操作绝不能被微信一键放行");
|
|
1007
|
+
} finally {
|
|
1008
|
+
await ilink.close();
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("/quiet 后只推「要你决定」与报错,日常节点被静音", async () => {
|
|
1013
|
+
const ilink = await fakeIlink();
|
|
1014
|
+
try {
|
|
1015
|
+
const { rt, relayDir } = await boundRuntime(ilink);
|
|
1016
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/quiet" } }] });
|
|
1017
|
+
assert.equal(loadState(relayDir).quiet, true);
|
|
1018
|
+
|
|
1019
|
+
const before = ilink.state.sends.length;
|
|
1020
|
+
const quieted = await rt.notify({ kind: NODE_KINDS.DIGEST_DUE, notified: 1, answered: 0, at: 1 });
|
|
1021
|
+
assert.equal(quieted.ok, false);
|
|
1022
|
+
assert.equal(quieted.reason, "quiet");
|
|
1023
|
+
assert.equal(ilink.state.sends.length, before, "免打扰期间日常节点不得发出");
|
|
1024
|
+
|
|
1025
|
+
const urgent = await rt.notify({ kind: NODE_KINDS.SESSION_ERROR, detail: "boom", at: 1 });
|
|
1026
|
+
assert.equal(urgent.ok, true, "报错属于「必须放行」");
|
|
1027
|
+
} finally {
|
|
1028
|
+
await ilink.close();
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// ── ⑤ 控制面安全 ──────────────────────────────────────────────────────────
|
|
1033
|
+
|
|
1034
|
+
test("★ 控制面:无密钥一律 401,且只 bind 回环", async () => {
|
|
1035
|
+
const ilink = await fakeIlink();
|
|
1036
|
+
try {
|
|
1037
|
+
const { rt, relayDir } = await boundRuntime(ilink);
|
|
1038
|
+
const port = await rt.startControl();
|
|
1039
|
+
const base = `http://127.0.0.1:${port}`;
|
|
1040
|
+
|
|
1041
|
+
const noAuth = await fetch(`${base}/wechat/status`);
|
|
1042
|
+
assert.equal(noAuth.status, 401, "无密钥必须 401");
|
|
1043
|
+
const badAuth = await fetch(`${base}/wechat/status`, { headers: { [CONTROL_HEADER]: "wrong" } });
|
|
1044
|
+
assert.equal(badAuth.status, 401);
|
|
1045
|
+
|
|
1046
|
+
const ok = await fetch(`${base}/wechat/status`, { headers: { [CONTROL_HEADER]: SECRET } });
|
|
1047
|
+
assert.equal(ok.status, 200);
|
|
1048
|
+
|
|
1049
|
+
// 发现文件里只有端口,没有密钥
|
|
1050
|
+
const disc = readControlFile(relayDir);
|
|
1051
|
+
assert.equal(disc.port, port);
|
|
1052
|
+
assert.equal(disc.header, CONTROL_HEADER);
|
|
1053
|
+
assert.ok(!JSON.stringify(disc).includes(SECRET), "发现文件绝不能包含密钥");
|
|
1054
|
+
|
|
1055
|
+
// 服务确实只监听回环
|
|
1056
|
+
const addr = rt.controlServer.address();
|
|
1057
|
+
assert.equal(addr.address, "127.0.0.1", "控制面必须只 bind 127.0.0.1");
|
|
1058
|
+
await rt.stopControl();
|
|
1059
|
+
} finally {
|
|
1060
|
+
await ilink.close();
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("★ 面板状态永不回显 bot_token", async () => {
|
|
1065
|
+
const ilink = await fakeIlink();
|
|
1066
|
+
try {
|
|
1067
|
+
const { rt } = await boundRuntime(ilink);
|
|
1068
|
+
const s = rt.status();
|
|
1069
|
+
const json = JSON.stringify(s);
|
|
1070
|
+
assert.ok(!json.includes("tok-abc"), `状态里泄漏了 token:${json}`);
|
|
1071
|
+
assert.ok(!("token" in s), "状态里不得出现 token 字段");
|
|
1072
|
+
assert.equal(s.bound, true, "绑定状态要如实反映");
|
|
1073
|
+
} finally {
|
|
1074
|
+
await ilink.close();
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("没有 bridge_secret 时控制面拒绝一切(不能变成开放后门)", async () => {
|
|
1079
|
+
const ilink = await fakeIlink();
|
|
1080
|
+
try {
|
|
1081
|
+
const relayDir = tmp();
|
|
1082
|
+
saveAccount(relayDir, { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
|
|
1083
|
+
const rt = createWeChatRuntime({ relayDir, secret: "", logger: quietLogger() });
|
|
1084
|
+
const port = await rt.startControl();
|
|
1085
|
+
const r = await fetch(`http://127.0.0.1:${port}/wechat/status`, { headers: { [CONTROL_HEADER]: "" } });
|
|
1086
|
+
assert.equal(r.status, 403);
|
|
1087
|
+
await rt.stopControl();
|
|
1088
|
+
} finally {
|
|
1089
|
+
await ilink.close();
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// ── ⑥ 断线窗口要如实告知(§6 ②) ───────────────────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
test("★ 断线窗口:订阅报 gap 时必须主动告知「可能漏了」,不能装作没事", async () => {
|
|
1096
|
+
const ilink = await fakeIlink();
|
|
1097
|
+
try {
|
|
1098
|
+
const { rt } = await boundRuntime(ilink);
|
|
1099
|
+
await rt.startChannel();
|
|
1100
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
1101
|
+
const before = ilink.state.sends.length;
|
|
1102
|
+
rt.subscriber.emit("gap", { from: 1, to: 2 });
|
|
1103
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
1104
|
+
assert.ok(ilink.state.sends.length > before, "gap 之后必须发出一条告知");
|
|
1105
|
+
assert.match(lastText(ilink.state), /补发|漏/);
|
|
1106
|
+
await rt.stop();
|
|
1107
|
+
} finally {
|
|
1108
|
+
await ilink.close();
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("绑定态只有 bound/unbound 两态:未绑定时通道不启动、绑定后启动", async () => {
|
|
1113
|
+
const ilink = await fakeIlink();
|
|
1114
|
+
try {
|
|
1115
|
+
const relayDir = tmp();
|
|
1116
|
+
const rt = createWeChatRuntime({ relayDir, secret: SECRET, logger: quietLogger() });
|
|
1117
|
+
assert.equal(rt.status().bound, false);
|
|
1118
|
+
assert.equal(rt.status().channel_running, false);
|
|
1119
|
+
assert.equal(await rt.startChannel(), false, "未绑定不得启动长轮询");
|
|
1120
|
+
|
|
1121
|
+
saveAccount(relayDir, { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
|
|
1122
|
+
const rt2 = createWeChatRuntime({ relayDir, secret: SECRET, logger: quietLogger() });
|
|
1123
|
+
rt2.subscriber = makeFakeSubscriber();
|
|
1124
|
+
assert.equal(await rt2.startChannel(), true);
|
|
1125
|
+
await rt2.stop();
|
|
1126
|
+
} finally {
|
|
1127
|
+
await ilink.close();
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// ── ⑦ 权益包端到端(服务端 → runtime) + 额度提醒 ───────────────────────────
|
|
1132
|
+
//
|
|
1133
|
+
// 背景:服务端 `/api/me` 新增 `entitlements`(契约 §5),bridge 原样透传给 runtime。这里锁的是
|
|
1134
|
+
// **展示 == 判定**:status() 下发给面板的那份 caps,必须就是拦截派活时用的那一份 ——
|
|
1135
|
+
// 一旦两者分叉,面板就会承诺一个用户实际做不了的能力(契约 §8 红线 1)。
|
|
1136
|
+
|
|
1137
|
+
/** 服务端说:免费版只有 assign.continue,没有 assign.new。 */
|
|
1138
|
+
const SERVER_FREE_ENT = Object.freeze({
|
|
1139
|
+
plan: "free",
|
|
1140
|
+
caps: ["notify", "approve", "status", "stop", "assign.continue"],
|
|
1141
|
+
limits: { messages_per_month: 20 },
|
|
1142
|
+
rev: "rev-e2e",
|
|
1143
|
+
source: "configured"
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
test("★★ 权益包端到端:服务端说免费只有 assign.continue → 判定与面板展示一致(不多给 assign.new)", async () => {
|
|
1147
|
+
const ilink = await fakeIlink();
|
|
1148
|
+
try {
|
|
1149
|
+
const sub = makeFakeSubscriber();
|
|
1150
|
+
const { rt } = await boundRuntime(ilink, {
|
|
1151
|
+
subscriber: sub, tier: "", tierProvider: async () => SERVER_FREE_ENT
|
|
1152
|
+
});
|
|
1153
|
+
await rt.refreshTierNow(); // bridge 启动时 / 每 10 分钟做的那次校准
|
|
1154
|
+
|
|
1155
|
+
const s = rt.status();
|
|
1156
|
+
assert.equal(s.entitlements_source, "server", "★有服务端权益包时来源必须是 server(否则面板会展示内置表)");
|
|
1157
|
+
assert.equal(s.plan, "free");
|
|
1158
|
+
assert.deepEqual(s.caps, SERVER_FREE_ENT.caps, "★面板展示的 caps 必须与服务端逐字一致");
|
|
1159
|
+
assert.deepEqual(s.limits, SERVER_FREE_ENT.limits, "限额同样来自服务端");
|
|
1160
|
+
|
|
1161
|
+
// #can 是私有方法:它只做 `grantsCap(#caps(), need)`,所以用同一份 caps 走同一个判定函数,
|
|
1162
|
+
// 等价于直接问 #can —— 再加上下面的行为断言,展示与判定被同一条测试锁死。
|
|
1163
|
+
assert.equal(rtmod.grantsCap(s.caps, "assign.new"), false, "★服务端没给 assign.new → 判定必须为假");
|
|
1164
|
+
assert.equal(rtmod.grantsCap(s.caps, "assign.continue"), true, "★assign.continue 必须为真(免费能接着聊)");
|
|
1165
|
+
assert.equal(rtmod.grantsCap(s.caps, "assign"), false, "只写 assign.continue 不等于授权整个 assign.*");
|
|
1166
|
+
|
|
1167
|
+
// 行为层:服务端没给 assign.new → /new 必须被拒
|
|
1168
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
1169
|
+
assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 0,
|
|
1170
|
+
"★服务端没给 assign.new,就绝不能开新会话(判定必须真的生效,不只是展示)");
|
|
1171
|
+
assert.match(lastText(ilink.state), /会员/, "被拒时要如实说明这是会员能力");
|
|
1172
|
+
|
|
1173
|
+
// 行为层:有当前会话 → 续接必须放行(assign.continue 在 caps 里)
|
|
1174
|
+
rt.currentSessionId = "session-x";
|
|
1175
|
+
rt.currentSessionTitle = "已有任务";
|
|
1176
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "接着做" } }] });
|
|
1177
|
+
const prompted = sub.calls.filter((c) => c.m === "promptSession");
|
|
1178
|
+
assert.equal(prompted.length, 1, "★assign.continue 必须真的放行(免费用户要能接着聊)");
|
|
1179
|
+
assert.equal(prompted[0].text, "接着做");
|
|
1180
|
+
} finally {
|
|
1181
|
+
await ilink.close();
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
test("★ 权益包:服务端缺字段时保留上次的值(不因缺字段清空能力)", async () => {
|
|
1186
|
+
const ilink = await fakeIlink();
|
|
1187
|
+
try {
|
|
1188
|
+
const sub = makeFakeSubscriber();
|
|
1189
|
+
let got = { plan: "free", caps: ["notify", "assign.continue"], limits: { messages_per_month: 20 }, rev: "r1" };
|
|
1190
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "", tierProvider: async () => got });
|
|
1191
|
+
await rt.refreshTierNow();
|
|
1192
|
+
assert.deepEqual(rt.status().caps, ["notify", "assign.continue"]);
|
|
1193
|
+
|
|
1194
|
+
// ② 这次只给了 caps(缺 limits)→ caps 跟着变,limits 保留上次的 20
|
|
1195
|
+
got = { plan: "free", caps: ["notify", "approve", "assign.continue"], rev: "r2" };
|
|
1196
|
+
await rt.refreshTierNow();
|
|
1197
|
+
assert.deepEqual(rt.status().caps, ["notify", "approve", "assign.continue"], "caps 变了就要跟着变");
|
|
1198
|
+
assert.equal(Number(rt.status().limits.messages_per_month), 20,
|
|
1199
|
+
"★缺 limits 时必须保留上次的值(补一个 {} 会被读成「不限」= 放权)");
|
|
1200
|
+
|
|
1201
|
+
// ③ 服务端没带 entitlements(bridge 退回字符串档位)→ 整份老包保留,不因缺包清空能力
|
|
1202
|
+
got = "pro";
|
|
1203
|
+
await rt.refreshTierNow();
|
|
1204
|
+
const s = rt.status();
|
|
1205
|
+
assert.equal(s.plan, "pro", "档位字符串照旧生效");
|
|
1206
|
+
assert.equal(s.entitlements_source, "server", "★旧契约下仍沿用上次的服务端包,不回退内置表(否则能力会被重置)");
|
|
1207
|
+
assert.deepEqual(s.caps, ["notify", "approve", "assign.continue"], "★缺 caps 不得清空能力");
|
|
1208
|
+
assert.equal(Number(s.limits.messages_per_month), 20, "limits 同样保留");
|
|
1209
|
+
// fail-closed:档位显示 pro,但能力仍以服务端包为准 —— 不能因为"档位名像付费"就放权
|
|
1210
|
+
assert.equal(rtmod.grantsCap(s.caps, "assign.new"), false, "★档位名不得覆盖权益包(显示 pro 也不能凭空多给能力)");
|
|
1211
|
+
} finally {
|
|
1212
|
+
await ilink.close();
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
/** 额度提醒的公共脚手架:免费档、每月 20 条、有 App 入口、可控时钟。 */
|
|
1217
|
+
async function quotaRuntime(ilink, { limits, caps = SERVER_FREE_ENT.caps, clock, appUrl = "https://app.example/x/" } = {}) {
|
|
1218
|
+
const sub = makeFakeSubscriber();
|
|
1219
|
+
const { rt, relayDir } = await boundRuntime(ilink, {
|
|
1220
|
+
subscriber: sub,
|
|
1221
|
+
tier: "",
|
|
1222
|
+
tierProvider: async () => ({ plan: "free", caps, limits, rev: "rev-q" }),
|
|
1223
|
+
appUrl,
|
|
1224
|
+
clock
|
|
1225
|
+
});
|
|
1226
|
+
await rt.refreshTierNow();
|
|
1227
|
+
rt.currentSessionId = "session-q";
|
|
1228
|
+
rt.currentSessionTitle = "在跑的任务";
|
|
1229
|
+
return { rt, relayDir, sub };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** 走"真的派活"这条路(这是额度被消耗的唯一路径)。 */
|
|
1233
|
+
const sendTask = (rt, text = "继续做") =>
|
|
1234
|
+
rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text } }] });
|
|
1235
|
+
|
|
1236
|
+
/** 额度提醒的识别标记:「还剩 X 条」只在提醒里出现(拒绝文案说的是「已经用完」)。 */
|
|
1237
|
+
const quotaNotices = (state) => allTexts(state).filter((t) => /还剩 \d+ 条/.test(t));
|
|
1238
|
+
|
|
1239
|
+
test("★★ 额度提醒:用掉 80% 触发一次、同月不重复、跨月可再提醒", async () => {
|
|
1240
|
+
const ilink = await fakeIlink();
|
|
1241
|
+
try {
|
|
1242
|
+
let now = new Date("2026-03-05T10:00:00Z").getTime();
|
|
1243
|
+
const { rt, relayDir } = await quotaRuntime(ilink, {
|
|
1244
|
+
limits: { messages_per_month: 20 }, clock: () => now
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// 用掉 15 条(75%)→ 还没到线,不打扰用户
|
|
1248
|
+
for (let i = 0; i < 15; i += 1) await sendTask(rt, `派活${i}`);
|
|
1249
|
+
await settle();
|
|
1250
|
+
assert.equal(quotaNotices(ilink.state).length, 0, "才 75% 不该提醒(提醒太早 = 骚扰)");
|
|
1251
|
+
|
|
1252
|
+
// 第 16 条 = 80% → 必须提醒(而且是由"派活成功"自动触发的,不需要谁来点一下)
|
|
1253
|
+
await sendTask(rt, "第 16 条");
|
|
1254
|
+
const fired = await waitFor(() => quotaNotices(ilink.state).length === 1);
|
|
1255
|
+
assert.ok(fired, "★额度用到 80% 必须自动提醒(notifyQuotaLow 以前是死代码,一次都不会发)");
|
|
1256
|
+
const text = quotaNotices(ilink.state)[0];
|
|
1257
|
+
assert.match(text, /还剩 4 条/, `要说清还剩几条,实际:${text}`);
|
|
1258
|
+
assert.match(text, /下个月 1 号/, "要说清什么时候恢复(否则用户以为永久没了)");
|
|
1259
|
+
assert.ok(text.includes("https://app.example/x/"), "要给 App 入口(免费用户越界时的转化链接)");
|
|
1260
|
+
|
|
1261
|
+
// 同一个自然月:再发消息 / 再手动检查都不重复
|
|
1262
|
+
await rt.runQuotaRemindNow();
|
|
1263
|
+
for (let i = 0; i < 4; i += 1) await sendTask(rt, `继续${i}`);
|
|
1264
|
+
await settle();
|
|
1265
|
+
assert.equal(quotaNotices(ilink.state).length, 1,
|
|
1266
|
+
"★每个自然月最多提醒一次(每发一条念一遍额度是骚扰)");
|
|
1267
|
+
assert.equal(Number(loadState(relayDir).last_quota_notice ? 1 : 0), 1, "去重状态要落盘(重启后不重复)");
|
|
1268
|
+
|
|
1269
|
+
// 跨月:用量按自然月归零 → 又能再提醒一次
|
|
1270
|
+
now = new Date("2026-04-05T10:00:00Z").getTime();
|
|
1271
|
+
await rt.runQuotaRemindNow();
|
|
1272
|
+
assert.equal(quotaNotices(ilink.state).length, 1, "新月还没用额度,不该提醒");
|
|
1273
|
+
for (let i = 0; i < 16; i += 1) await sendTask(rt, `四月派活${i}`);
|
|
1274
|
+
const again = await waitFor(() => quotaNotices(ilink.state).length === 2);
|
|
1275
|
+
assert.ok(again, "★跨月可以再提醒(用 #monthKey() 而不是「一辈子只提醒一次」)");
|
|
1276
|
+
} finally {
|
|
1277
|
+
await ilink.close();
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test("★ 额度提醒:messages_per_month = 0(不限)永不提醒", async () => {
|
|
1282
|
+
// 付费档的限制就是 0 = 不限。若这里漏判 0,"不限"会被当成"额度 0 条"或"没有额度概念却催额度",
|
|
1283
|
+
// 付费用户每个月都被念一遍 —— 这是最不该发生的一类骚扰。
|
|
1284
|
+
const ilink = await fakeIlink();
|
|
1285
|
+
try {
|
|
1286
|
+
const PAID_CAPS = ["notify", "approve", "status", "stop", "assign", "sessions", "summary", "steer", "attach", "multi"];
|
|
1287
|
+
const { rt, sub } = await quotaRuntime(ilink, {
|
|
1288
|
+
limits: { messages_per_month: 0 }, caps: PAID_CAPS
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
for (let i = 0; i < 30; i += 1) await sendTask(rt, `派活${i}`);
|
|
1292
|
+
await settle();
|
|
1293
|
+
assert.equal(quotaNotices(ilink.state).length, 0, "★不限量就永远不该出现额度提醒");
|
|
1294
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 30, "不限量必须真的不限(30 条都放行)");
|
|
1295
|
+
|
|
1296
|
+
// 直接调检查入口也不该发(不是"恰好没触发",而是判据里就排除了 0)
|
|
1297
|
+
await rt.runQuotaRemindNow();
|
|
1298
|
+
assert.equal(quotaNotices(ilink.state).length, 0, "★0 = 不限,检查入口也必须直接返回");
|
|
1299
|
+
} finally {
|
|
1300
|
+
await ilink.close();
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
test("★ 额度提醒不消耗消息额度(提醒自己不是「派活」)", async () => {
|
|
1305
|
+
const ilink = await fakeIlink();
|
|
1306
|
+
try {
|
|
1307
|
+
const now = new Date("2026-05-05T10:00:00Z").getTime();
|
|
1308
|
+
const { rt, sub } = await quotaRuntime(ilink, {
|
|
1309
|
+
limits: { messages_per_month: 20 }, clock: () => now
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
for (let i = 0; i < 16; i += 1) await sendTask(rt, `派活${i}`);
|
|
1313
|
+
assert.ok(await waitFor(() => quotaNotices(ilink.state).length === 1), "第 16 条应触发提醒");
|
|
1314
|
+
|
|
1315
|
+
// ★ 关键:提醒发出后用量必须还是 16。若提醒走了 #msgQuotaBump,这里会是 17。
|
|
1316
|
+
assert.equal(rt.status().messages_used_this_month, 16,
|
|
1317
|
+
"★提醒本身绝不能计入消息额度(否则越提醒越少,提醒成了惩罚)");
|
|
1318
|
+
assert.equal(Number(rt.msgUsage.count), 16, "内部用量计数同样不能变");
|
|
1319
|
+
|
|
1320
|
+
// 剩余 4 条仍然可用(提醒没有偷偷多扣)
|
|
1321
|
+
for (let i = 0; i < 4; i += 1) await sendTask(rt, `再发${i}`);
|
|
1322
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 20, "剩余额度必须照常可用");
|
|
1323
|
+
assert.equal(rt.status().messages_used_this_month, 20);
|
|
1324
|
+
|
|
1325
|
+
// 第 21 条才是"用完"的拒绝(证明上面的 20 条是真的被放行了)
|
|
1326
|
+
await sendTask(rt, "第 21 条");
|
|
1327
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 20, "额度用完必须拦");
|
|
1328
|
+
assert.match(lastText(ilink.state), /额度已经用完/);
|
|
1329
|
+
} finally {
|
|
1330
|
+
await ilink.close();
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
// ---------------------------------------------------------------------------
|
|
1335
|
+
// 空 caps 是**权威**的"这一档什么都不能做"(2026-09-23 修)
|
|
1336
|
+
//
|
|
1337
|
+
// 真实缺陷:`#caps()` 与 `#refreshTier` 原先用 `caps.length` 判"服务端有没有给权益包",
|
|
1338
|
+
// 于是后台「明确清空某档」(契约 §4.1 下发 `caps: []`)被当成"没给" →
|
|
1339
|
+
// 沿用上次那份包、一份都没有时回退**内置表** → 本该"什么都不能做"的档位
|
|
1340
|
+
// 拿回内置的一整套能力(付费档尤其严重)。方向正好是 **fail-open**,与契约 §8 红线 2 相反。
|
|
1341
|
+
// ---------------------------------------------------------------------------
|
|
1342
|
+
|
|
1343
|
+
test("★★ 权益包:服务端下发 caps: [] (后台明确清空) → 该档真的什么都不能做,绝不回退内置表", async () => {
|
|
1344
|
+
const ilink = await fakeIlink();
|
|
1345
|
+
try {
|
|
1346
|
+
const sub = makeFakeSubscriber();
|
|
1347
|
+
const { rt } = await boundRuntime(ilink, {
|
|
1348
|
+
subscriber: sub, tier: "",
|
|
1349
|
+
tierProvider: async () => ({ plan: "pro", caps: [], limits: { messages_per_month: 0 }, rev: "rev-empty" })
|
|
1350
|
+
});
|
|
1351
|
+
await rt.refreshTierNow();
|
|
1352
|
+
const s = rt.status();
|
|
1353
|
+
assert.equal(s.plan, "pro");
|
|
1354
|
+
assert.deepEqual(s.caps, [], "★空数组是权威:这一档就是什么能力都没有");
|
|
1355
|
+
assert.equal(s.entitlements_source, "server", "★空数组 ≠ 没给:来源仍必须是 server");
|
|
1356
|
+
// 内置 pro 表里的能力现在一个都不能有(回退内置表就会全变真 = fail-open)
|
|
1357
|
+
for (const cap of ["notify", "approve", "status", "stop", "sessions", "summary", "assign"]) {
|
|
1358
|
+
assert.equal(rtmod.grantsCap(s.caps, cap), false,
|
|
1359
|
+
`★清空后 ${cap} 必须为假(若回退内置表就会变真)`);
|
|
1360
|
+
}
|
|
1361
|
+
// 行为层:连续接都不行(该档什么都没授权)
|
|
1362
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
1363
|
+
assert.equal(sub.calls.filter((c) => c.m === "listSessions").length, 0,
|
|
1364
|
+
"★没授权 sessions 就不能列会话(判定必须真的生效,不只是展示)");
|
|
1365
|
+
} finally {
|
|
1366
|
+
await ilink.close();
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test("★权益包:先给一份含 assign 的包,再下发 caps: [] → 必须真的清空(沿用上次那份就是 fail-open)", async () => {
|
|
1371
|
+
const ilink = await fakeIlink();
|
|
1372
|
+
try {
|
|
1373
|
+
const sub = makeFakeSubscriber();
|
|
1374
|
+
let got = {
|
|
1375
|
+
plan: "pro", caps: ["notify", "approve", "status", "stop", "assign"],
|
|
1376
|
+
limits: { messages_per_month: 0 }, rev: "rev-1"
|
|
1377
|
+
};
|
|
1378
|
+
const { rt } = await boundRuntime(ilink, {
|
|
1379
|
+
subscriber: sub, tier: "", tierProvider: async () => got
|
|
1380
|
+
});
|
|
1381
|
+
await rt.refreshTierNow();
|
|
1382
|
+
assert.ok(rt.status().caps.includes("assign"), "前提:第一次下发的是含 assign 的包");
|
|
1383
|
+
|
|
1384
|
+
// 后台清空该档
|
|
1385
|
+
got = { plan: "pro", caps: [], limits: { messages_per_month: 0 }, rev: "rev-2" };
|
|
1386
|
+
await rt.refreshTierNow();
|
|
1387
|
+
assert.deepEqual(rt.status().caps, [],
|
|
1388
|
+
"★第二次下发空数组必须真的清空 —— 若沿用上次那份,后台的「清空」就静默失效了(fail-open)");
|
|
1389
|
+
assert.equal(rtmod.grantsCap(rt.status().caps, "assign"), false, "assign 必须随之失效");
|
|
1390
|
+
} finally {
|
|
1391
|
+
await ilink.close();
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
// ---------------------------------------------------------------------------
|
|
1396
|
+
// 提醒(额度 / 会员到期)不得抢占审批的回执位
|
|
1397
|
+
//
|
|
1398
|
+
// 真机问题(2026-09-23 定位):提醒在文案层是**可回执**的(带「邀请好友 / 知道了」选项),
|
|
1399
|
+
// 于是被塞进审批队列;而数字回执按 FIFO 认领 —— 用户看着**底部最新那条审批**回「1」,
|
|
1400
|
+
// 回执却落到了更早的额度提醒上(还会被派给一个根本不存在的 question id)。
|
|
1401
|
+
// 结果:审批没人答,用户得再回一次才轮到,而他会以为"审批失灵了"。
|
|
1402
|
+
// 修法:队列分「决策」与「提醒」两类,数字优先给决策;提醒只在没有决策时才接数字。
|
|
1403
|
+
// ---------------------------------------------------------------------------
|
|
1404
|
+
|
|
1405
|
+
test("★★ 提醒不得抢占审批的回执位:额度提醒在前、审批在后 → 回「1」必须答到审批上", async () => {
|
|
1406
|
+
const ilink = await fakeIlink();
|
|
1407
|
+
try {
|
|
1408
|
+
const sub = makeFakeSubscriber();
|
|
1409
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
1410
|
+
|
|
1411
|
+
// ① 先来一条额度提醒(它带「邀请好友 / 知道了」,所以是可回执的)
|
|
1412
|
+
await rt.notifyQuotaLow({ message: "本月 20 条额度已用 16 条" });
|
|
1413
|
+
assert.equal(rt.pendingReplies.length, 1, "前提:提醒确实进了队列");
|
|
1414
|
+
assert.equal(rt.status().pending_replies, 0, "★但提醒不算「待拍板」——面板数字要与用户真要做的事一致");
|
|
1415
|
+
|
|
1416
|
+
// ② 再来一条真审批
|
|
1417
|
+
await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-late", toolName: "Bash", answerShape: "approval", at: 2 });
|
|
1418
|
+
assert.equal(rt.status().pending_replies, 1, "待拍板数 = 1(只有那条审批)");
|
|
1419
|
+
|
|
1420
|
+
// ③ 用户看到的是底部那条审批,回「1」→ 必须答到审批上
|
|
1421
|
+
sub.calls.length = 0;
|
|
1422
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
1423
|
+
const ap = sub.calls.filter((c) => c.m === "answerApproval");
|
|
1424
|
+
assert.equal(ap.length, 1, "★回执必须落到审批上(旧实现会落到更早的额度提醒上 → 审批没人答)");
|
|
1425
|
+
assert.equal(ap[0].id, "ev-late", "答的必须是那条审批");
|
|
1426
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerQuestion").length, 0,
|
|
1427
|
+
"★绝不能把提醒的「邀请好友」当成 DSH 提问派出去(那个 eventId 根本不是提问)");
|
|
1428
|
+
} finally {
|
|
1429
|
+
await ilink.close();
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("★ 提醒的 CTA 就地消化:回「1」给邀请入口、回「2」确认知道了,都不派给 DSH", async () => {
|
|
1434
|
+
const ilink = await fakeIlink();
|
|
1435
|
+
try {
|
|
1436
|
+
const sub = makeFakeSubscriber();
|
|
1437
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, appUrl: "https://x.test/app/" });
|
|
1438
|
+
|
|
1439
|
+
// ① 只有提醒一条决策都没有 → 数字仍然可用(它的「邀请好友」这时才有意义)
|
|
1440
|
+
await rt.notifyQuotaLow({ message: "本月 20 条额度已用 16 条" });
|
|
1441
|
+
sub.calls.length = 0;
|
|
1442
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
1443
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerQuestion").length, 0,
|
|
1444
|
+
"★提醒不是 DSH 提问,不能拿不存在的 id 去 answerQuestion(旧实现必然失败并回「没能替你完成」)");
|
|
1445
|
+
assert.match(lastText(ilink.state), /邀请/, "应给出邀请入口");
|
|
1446
|
+
assert.match(lastText(ilink.state), /x\.test/, "应带上 App 入口(用户点得到的才算数)");
|
|
1447
|
+
|
|
1448
|
+
// ② 「知道了」= 2
|
|
1449
|
+
await rt.notifyQuotaLow({ message: "再提醒一次" });
|
|
1450
|
+
sub.calls.length = 0;
|
|
1451
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "2" } }] });
|
|
1452
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerQuestion").length, 0);
|
|
1453
|
+
assert.equal(rt.pendingReplies.length, 0, "选完就该出队");
|
|
1454
|
+
} finally {
|
|
1455
|
+
await ilink.close();
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
test("★ 多审批 + 提醒混排:回执仍按 FIFO 走审批(提醒不参与排队)", async () => {
|
|
1460
|
+
const ilink = await fakeIlink();
|
|
1461
|
+
try {
|
|
1462
|
+
const sub = makeFakeSubscriber();
|
|
1463
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub });
|
|
1464
|
+
|
|
1465
|
+
await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-1", toolName: "B1", answerShape: "approval", at: 1 });
|
|
1466
|
+
await rt.notifyQuotaLow({ message: "提醒夹在两条审批中间" });
|
|
1467
|
+
await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-2", toolName: "B2", answerShape: "approval", at: 3 });
|
|
1468
|
+
assert.equal(rt.status().pending_replies, 2, "两条审批 = 2 条待拍板(提醒不计)");
|
|
1469
|
+
|
|
1470
|
+
sub.calls.length = 0;
|
|
1471
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
1472
|
+
assert.equal(sub.calls.find((c) => c.m === "answerApproval").id, "ev-1", "先答最早那条审批");
|
|
1473
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
1474
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerApproval")[1].id, "ev-2", "再答第二条审批");
|
|
1475
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerQuestion").length, 0, "提醒全程不参与回执");
|
|
1476
|
+
} finally {
|
|
1477
|
+
await ilink.close();
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
// ---------------------------------------------------------------------------
|
|
1482
|
+
// 未知指令:绝不静默,而且帮助文案必须**按档位分层**
|
|
1483
|
+
//
|
|
1484
|
+
// 原先这条路径透传上游的静态 HELP_TEXT —— 那份清单把 /new、/ls、/use、/summary 与
|
|
1485
|
+
// "直接发一句话"一并列成"你能用的"。免费用户敲错一个字,拿到的就是这张**会员清单**,
|
|
1486
|
+
// 照着做再吃一次拒绝,于是"打错命令"被理解成"这功能坏了"。
|
|
1487
|
+
// 业主红线:话术与权限必须一致、不多承诺。
|
|
1488
|
+
// ---------------------------------------------------------------------------
|
|
1489
|
+
|
|
1490
|
+
test("★ 免费用户敲未知指令:要回显命令 + 给**分层**帮助,不得把会员指令列成『你能用的』", async () => {
|
|
1491
|
+
const ilink = await fakeIlink();
|
|
1492
|
+
try {
|
|
1493
|
+
const sub = makeFakeSubscriber();
|
|
1494
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free" });
|
|
1495
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/reusme" } }] });
|
|
1496
|
+
const t = lastText(ilink.state);
|
|
1497
|
+
assert.ok(t, "★未知指令绝不能静默(一个字都不回 = 用户以为机器人死了)");
|
|
1498
|
+
assert.match(t, /不认识这条指令:\/reusme/, "必须原样回显他敲的那条,他才知道哪个字打错了");
|
|
1499
|
+
assert.match(t, /现在能用的/, "必须给分层帮助(免费档的『现在能用的』)");
|
|
1500
|
+
assert.match(t, /会员功能/, "会员指令要明确归到『会员功能』下");
|
|
1501
|
+
// 关键:不能把会员指令混进"能用"的那一段
|
|
1502
|
+
const usable = t.split("会员功能")[0];
|
|
1503
|
+
assert.ok(!/\/new/.test(usable), "★『现在能用的』里不得出现 /new(免费档做不到)");
|
|
1504
|
+
assert.ok(!/\/ls/.test(usable), "★『现在能用的』里不得出现 /ls");
|
|
1505
|
+
} finally {
|
|
1506
|
+
await ilink.close();
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
test("★ 付费用户敲未知指令:给完整清单(不因修免费档把付费档的信息也砍掉)", async () => {
|
|
1511
|
+
const ilink = await fakeIlink();
|
|
1512
|
+
try {
|
|
1513
|
+
const sub = makeFakeSubscriber();
|
|
1514
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
1515
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/reusme" } }] });
|
|
1516
|
+
const t = lastText(ilink.state);
|
|
1517
|
+
assert.match(t, /不认识这条指令:\/reusme/);
|
|
1518
|
+
assert.match(t, /\/new/, "付费档应当看到 /new");
|
|
1519
|
+
assert.match(t, /\/ls/, "付费档应当看到 /ls");
|
|
1520
|
+
assert.ok(!/会员功能/.test(t), "付费档不该再出现『会员功能』这段(他就是会员)");
|
|
1521
|
+
} finally {
|
|
1522
|
+
await ilink.close();
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// ---------------------------------------------------------------------------
|
|
1527
|
+
// 细粒度权益(后台可配权限之后才可能出现)
|
|
1528
|
+
//
|
|
1529
|
+
// 管理员可以只勾细粒度 `assign.new` 而**不勾**粗粒度根 `assign`。旧代码拿
|
|
1530
|
+
// `#can("assign")` 当"是不是付费用户"的代理 —— 那种配置下返回假,于是**付费用户**
|
|
1531
|
+
// 拿到免费档的帮助、完成小结后被塞"接着交代属于会员功能"、额度提醒里被告知
|
|
1532
|
+
// "派活是会员能力"。三处都在对已经付了钱的人说不成立的话。
|
|
1533
|
+
// ---------------------------------------------------------------------------
|
|
1534
|
+
|
|
1535
|
+
/** 只给细粒度 assign.new、不给粗根 assign 的付费权益包。 */
|
|
1536
|
+
const FINE_GRAINED_PRO = Object.freeze({
|
|
1537
|
+
rev: "rev-fine", plan: "pro",
|
|
1538
|
+
caps: ["notify", "approve", "status", "stop", "assign.new", "sessions"],
|
|
1539
|
+
limits: { messages_per_month: 0 }
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
test("★★ 细粒度权益(只有 assign.new、没有粗根 assign)的付费用户:帮助不得降级成免费档", async () => {
|
|
1543
|
+
const ilink = await fakeIlink();
|
|
1544
|
+
try {
|
|
1545
|
+
const { rt } = await boundRuntime(ilink, {
|
|
1546
|
+
subscriber: makeFakeSubscriber(), tier: "pro", entitlements: FINE_GRAINED_PRO
|
|
1547
|
+
});
|
|
1548
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
|
|
1549
|
+
const t = lastText(ilink.state);
|
|
1550
|
+
assert.ok(!/通知版/.test(t), "★他能开新任务,不该被标成通知版(旧判据看粗根 assign 会误判)");
|
|
1551
|
+
assert.match(t, /\/new/, "他确实有 assign.new,帮助里必须出现 /new");
|
|
1552
|
+
assert.match(t, /\/ls/, "他确实有 sessions,帮助里必须出现 /ls");
|
|
1553
|
+
// 确实没有的才落在会员区
|
|
1554
|
+
const locked = t.split("会员功能")[1] || "";
|
|
1555
|
+
assert.match(locked, /summary/, "没有 summary 的才该落在会员区");
|
|
1556
|
+
assert.ok(!/\/new/.test(locked), "★已经有的能力绝不能落在会员区(那是少承诺,同样是不一致)");
|
|
1557
|
+
} finally {
|
|
1558
|
+
await ilink.close();
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
test("★ 细粒度权益用户的完成小结:不得塞『接着交代下一步属于会员功能』", async () => {
|
|
1563
|
+
const ilink = await fakeIlink();
|
|
1564
|
+
try {
|
|
1565
|
+
const { rt } = await boundRuntime(ilink, {
|
|
1566
|
+
subscriber: makeFakeSubscriber(), tier: "pro", entitlements: FINE_GRAINED_PRO
|
|
1567
|
+
});
|
|
1568
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "completed", at: 1 });
|
|
1569
|
+
const t = lastText(ilink.state);
|
|
1570
|
+
assert.ok(!/属于会员功能/.test(t), "★他有 assign.new,不该被告知接着交代是会员功能");
|
|
1571
|
+
} finally {
|
|
1572
|
+
await ilink.close();
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
test("★ 细粒度权益用户的额度提醒:不得把他已经有的能力说成会员能力", async () => {
|
|
1577
|
+
const ilink = await fakeIlink();
|
|
1578
|
+
try {
|
|
1579
|
+
// limits=2 → 阈值 min(ceil(1.6)=2, max(1,0)=1) = 1,发一条就到线(不必真发 85 条)
|
|
1580
|
+
// ⚠️ caps 必须含 assign.continue:quotaRuntime 预设了当前会话,纯文本派活走的是"接着聊"这条路;
|
|
1581
|
+
// 缺了它会被直接拒掉、用量不涨,提醒根本不会触发(前提就不成立)。
|
|
1582
|
+
const { rt } = await quotaRuntime(ilink, {
|
|
1583
|
+
caps: ["notify", "approve", "status", "stop", "assign.continue", "assign.new", "sessions"],
|
|
1584
|
+
limits: { messages_per_month: 2 }
|
|
1585
|
+
});
|
|
1586
|
+
await sendTask(rt, "派活");
|
|
1587
|
+
await waitFor(() => quotaNotices(ilink.state).length === 1);
|
|
1588
|
+
const t = quotaNotices(ilink.state)[0] || "";
|
|
1589
|
+
assert.ok(t, "前提:提醒确实发出来了");
|
|
1590
|
+
assert.ok(!/开新任务属于会员能力/.test(t), "★他有 assign.new,不该被告知开新任务是会员能力");
|
|
1591
|
+
assert.ok(!/派活属于会员能力/.test(t), "★同上");
|
|
1592
|
+
} finally {
|
|
1593
|
+
await ilink.close();
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test("★ 免费档(有 assign.continue 没 assign.new)的额度提醒:只说缺的那一项,不否定他还能接着聊", async () => {
|
|
1598
|
+
const ilink = await fakeIlink();
|
|
1599
|
+
try {
|
|
1600
|
+
const { rt } = await quotaRuntime(ilink, {
|
|
1601
|
+
caps: ["notify", "approve", "status", "stop", "assign.continue"],
|
|
1602
|
+
limits: { messages_per_month: 2 }
|
|
1603
|
+
});
|
|
1604
|
+
await sendTask(rt, "接着聊");
|
|
1605
|
+
await waitFor(() => quotaNotices(ilink.state).length === 1);
|
|
1606
|
+
const t = quotaNotices(ilink.state)[0] || "";
|
|
1607
|
+
assert.ok(t, "前提:提醒确实发出来了");
|
|
1608
|
+
assert.match(t, /开新任务属于会员能力/, "缺的是开新任务,就只说这一项");
|
|
1609
|
+
assert.match(t, /接着当前任务回复仍然可用/, "★他确实还能接着聊 —— 不说不成立的话");
|
|
1610
|
+
} finally {
|
|
1611
|
+
await ilink.close();
|
|
1612
|
+
}
|
|
1613
|
+
});
|