@mrrisega/dsh-remote 0.6.10-beta.4 → 0.6.10-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +24 -1
- package/clients/dsh-remote/test/wechat-channel.test.mjs +87 -8
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +284 -4
- package/clients/dsh-remote/wechat-channel.mjs +138 -28
- package/clients/dsh-remote/wechat-runtime.mjs +328 -35
- package/dsh-setup.mjs +58 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +60 -8
- package/packages/dsh-remote-web/lib/index.js +32 -4
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +59 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +52 -8
|
@@ -1242,6 +1242,18 @@ function tagWeChat(m) {
|
|
|
1242
1242
|
*/
|
|
1243
1243
|
const WECHAT_TIER_TOKEN_TTL_MS = 5 * 60_000;
|
|
1244
1244
|
let WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1245
|
+
/**
|
|
1246
|
+
* 最近一次 `/api/me` 的账号快照(查档位时顺手记下来)。
|
|
1247
|
+
*
|
|
1248
|
+
* 为什么需要:微信通道要做"会员临近到期"提醒,而它**不该再单独打一次** `/api/me` ——
|
|
1249
|
+
* 档位每 10 分钟本来就会查一次,顺手记住即可。查失败时**保持上次的值**(与档位同一策略:
|
|
1250
|
+
* 一次网络抖动不该让提醒凭空消失或变成乱猜)。
|
|
1251
|
+
*/
|
|
1252
|
+
let WECHAT_ACCOUNT_SNAPSHOT = null;
|
|
1253
|
+
/** 供微信通道取账号快照(到期提醒用)。没查过就返回 null → 上层什么都不发。 */
|
|
1254
|
+
async function wechatAccountInfo() {
|
|
1255
|
+
return WECHAT_ACCOUNT_SNAPSHOT;
|
|
1256
|
+
}
|
|
1245
1257
|
|
|
1246
1258
|
async function resolveWechatTier() {
|
|
1247
1259
|
if (process.env.DSH_BRIDGE_LOCAL_KEY) return "pro_max";
|
|
@@ -1269,7 +1281,16 @@ async function resolveWechatTier() {
|
|
|
1269
1281
|
}
|
|
1270
1282
|
if (!r.ok) return "";
|
|
1271
1283
|
const j = await r.json().catch(() => null);
|
|
1272
|
-
|
|
1284
|
+
const u = (j && j.user) || null;
|
|
1285
|
+
// 顺手记账号快照(到期提醒用)。⚠️ 只在**真拿到了 user** 时覆盖 —— 否则保持上次。
|
|
1286
|
+
if (u) {
|
|
1287
|
+
WECHAT_ACCOUNT_SNAPSHOT = {
|
|
1288
|
+
plan: String(u.plan || ""),
|
|
1289
|
+
plan_ends_at: u.plan_ends_at ?? null,
|
|
1290
|
+
trial_expires_at: u.trial_expires_at ?? null
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
return String((u && u.plan) || "");
|
|
1273
1294
|
} catch {
|
|
1274
1295
|
return "";
|
|
1275
1296
|
}
|
|
@@ -1300,6 +1321,8 @@ function startWeChat() {
|
|
|
1300
1321
|
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1301
1322
|
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
1302
1323
|
tierProvider: resolveWechatTier,
|
|
1324
|
+
// 到期提醒的数据来源(复用上面那次 /api/me,不额外打网络)
|
|
1325
|
+
accountInfo: wechatAccountInfo,
|
|
1303
1326
|
appUrl: wechatAppUrl(),
|
|
1304
1327
|
// 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
|
|
1305
1328
|
// (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
|
|
@@ -29,6 +29,10 @@ import test from "node:test";
|
|
|
29
29
|
import {
|
|
30
30
|
BindSession,
|
|
31
31
|
COMMANDS,
|
|
32
|
+
COMPLETION_SUMMARY_MAX,
|
|
33
|
+
markdownToWechatText,
|
|
34
|
+
COMPLETION_TEXT_MAX,
|
|
35
|
+
SESSION_SUMMARY_MAX,
|
|
32
36
|
DEFAULT_ILINK_BASE_URL,
|
|
33
37
|
EventRegistry,
|
|
34
38
|
HELP_TEXT,
|
|
@@ -1763,9 +1767,68 @@ test("完成模板:头一行按 reason 区分,completed 不许读成失败", ()
|
|
|
1763
1767
|
assert.match(headers.get("error"), /出错/);
|
|
1764
1768
|
});
|
|
1765
1769
|
|
|
1770
|
+
test("★ 微信不渲染 Markdown:模型产出的表格/加粗/链接必须先转成纯文本", () => {
|
|
1771
|
+
// 业主原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」。
|
|
1772
|
+
// 根因:微信**不渲染** Markdown(腾讯自己的插件在出站路径主动剥离),所以模型的表格会以
|
|
1773
|
+
// 字面竖线、加粗会以字面星号出现在气泡里。这儿锁住"发送前必须已转换"。
|
|
1774
|
+
const md = [
|
|
1775
|
+
"## 修复结果",
|
|
1776
|
+
"",
|
|
1777
|
+
"| 项目 | 状态 |",
|
|
1778
|
+
"|---|---|",
|
|
1779
|
+
"| 黑窗 | 已修 |",
|
|
1780
|
+
"| 指纹 | 已修 |",
|
|
1781
|
+
"",
|
|
1782
|
+
"**关键**:见 [提交](https://example.com/c/1)。",
|
|
1783
|
+
"",
|
|
1784
|
+
"- 测试通过",
|
|
1785
|
+
"- `kill -9` 后自愈"
|
|
1786
|
+
].join("\n");
|
|
1787
|
+
const out = markdownToWechatText(md);
|
|
1788
|
+
|
|
1789
|
+
assert.doesNotMatch(out, /\*\*/u, "加粗标记必须剥掉(留着就是星号噪声)");
|
|
1790
|
+
assert.doesNotMatch(out, /\|/u, "★表格必须转成列表 —— 字面竖线在微信里就是一团乱码");
|
|
1791
|
+
assert.match(out, /· 项目:黑窗 | 状态:已修/u, "表格行要变成「列名:值 | 列名:值」");
|
|
1792
|
+
assert.match(out, /【修复结果】/u, "标题要变成【】独占一行");
|
|
1793
|
+
assert.ok(out.includes("https://example.com/c/1"), "★链接必须留下**裸 URL**,否则微信不会变成可点链接");
|
|
1794
|
+
assert.doesNotMatch(out, /\]\(/u, "不得残留 markdown 链接语法");
|
|
1795
|
+
assert.match(out, /^· 测试通过$/mu, "无序列表统一成 ·");
|
|
1796
|
+
assert.match(out, /kill -9 后自愈/u, "行内代码只去反引号,内容保留");
|
|
1797
|
+
assert.doesNotMatch(out, /`/u, "反引号必须剥掉");
|
|
1798
|
+
|
|
1799
|
+
// 不能把乘号吃掉:`2 * 3 * 4` 里的单星号不是强调
|
|
1800
|
+
assert.match(markdownToWechatText("结果 2 * 3 * 4 正确"), /2 \* 3 \* 4/,
|
|
1801
|
+
"单个 * 只在看起来像强调时才剥,不能误伤乘号");
|
|
1802
|
+
|
|
1803
|
+
// 代码围栏:去掉围栏行,内容原样保留
|
|
1804
|
+
const fenced = markdownToWechatText("```js\nconst a = 1;\n```");
|
|
1805
|
+
assert.doesNotMatch(fenced, /```/u, "围栏行要去掉");
|
|
1806
|
+
assert.match(fenced, /const a = 1;/u, "围栏内的内容要保留");
|
|
1807
|
+
|
|
1808
|
+
// 空输入/纯空白不炸
|
|
1809
|
+
assert.equal(markdownToWechatText(""), "");
|
|
1810
|
+
assert.equal(markdownToWechatText(null), "");
|
|
1811
|
+
assert.equal(markdownToWechatText(" \n\n "), "");
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
test("★★ 截断只压正文:头部与「下一步」必须活着(旧实现会把用户唯一能照做的那句话切掉)", () => {
|
|
1815
|
+
// 旧实现:整条 join 后盲切尾部。而「回复 N … / 下一步」恰好拼在最后 →
|
|
1816
|
+
// 一条超长消息会把**用户唯一能照做的指引**切掉(真机投诉过的形态)。
|
|
1817
|
+
const huge = "长".repeat(COMPLETION_SUMMARY_MAX * 5);
|
|
1818
|
+
const out = formatCompletion({ title: "超长任务", reason: "completed", summary: huge });
|
|
1819
|
+
assert.match(out.text, /【任务正常完成】/u, "头部必须活着");
|
|
1820
|
+
assert.match(out.text, /会话:超长任务/u, "会话名必须活着");
|
|
1821
|
+
assert.match(out.text, /—— 下一步 ——/u, "★「下一步」分区必须活着");
|
|
1822
|
+
assert.match(out.text, /直接回复一句话/u, "★那句可照做的指引必须活着");
|
|
1823
|
+
assert.ok(out.text.length <= COMPLETION_TEXT_MAX, `整条要限长,实际 ${out.text.length}`);
|
|
1824
|
+
|
|
1825
|
+
// 正文自身超长仍要**显式**标注(不静默丢)
|
|
1826
|
+
assert.match(out.text, /已截断/u, "正文被压时要显式标注");
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1766
1829
|
test("完成模板:会话名在/不在都不能打印 undefined", () => {
|
|
1767
1830
|
const named = formatCompletion({ title: "修登录跳转", reason: "completed", summary: "修好了。" });
|
|
1768
|
-
assert.match(named.text,
|
|
1831
|
+
assert.match(named.text, /会话:修登录跳转/, "会话名是用户最想看到的一行");
|
|
1769
1832
|
assert.equal(named.title, `任务${stopReasonText("completed")}`, "title 是展示标题(和 formatNotification 同义)");
|
|
1770
1833
|
|
|
1771
1834
|
for (const t of ["", " ", undefined, null]) {
|
|
@@ -1779,15 +1842,31 @@ test("完成模板:会话名在/不在都不能打印 undefined", () => {
|
|
|
1779
1842
|
assert.doesNotMatch(byId.text, /session-abcdef1234567890/, "别把整个 id 印给用户看");
|
|
1780
1843
|
});
|
|
1781
1844
|
|
|
1782
|
-
test("
|
|
1845
|
+
test("完成模板:结论要**尽量完整**(未超上限不截断),超长才显式标注;hanging 说的是'取不到'", () => {
|
|
1783
1846
|
const withSummary = formatCompletion({ title: "T", reason: "completed", summary: "结论正文" });
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1847
|
+
// 结论**独立成段**(不再挤在 "结论:" 前缀后面) —— 压成一行正是"密密麻麻"的来源
|
|
1848
|
+
assert.match(withSummary.text, /—— 结论 ——\n结论正文/, "结论要单独成段、原样展示");
|
|
1849
|
+
|
|
1850
|
+
// ★ 业主口径(2026-09-22):「结论被大量截断了,加长一些,更完整地展示 —— 这可能是用户关注的内容」
|
|
1851
|
+
// 旧上限 400 连一条正常的修复总结(实测 190+ 字)都快保不住。
|
|
1852
|
+
const fits = "长".repeat(1200);
|
|
1853
|
+
const kept = formatCompletion({ title: "T", reason: "completed", summary: fits });
|
|
1854
|
+
assert.ok(kept.text.includes(fits),
|
|
1855
|
+
`★未超上限的结论必须**整段**展示(旧上限 400 会砍掉它),实际整条长度 ${kept.text.length}`);
|
|
1856
|
+
assert.doesNotMatch(kept.text, /已截断/, "没超上限就不该出现截断标注");
|
|
1857
|
+
|
|
1858
|
+
// 远超上限时仍必须**显式**标注截断(不静默丢)
|
|
1859
|
+
const huge = "长".repeat(COMPLETION_SUMMARY_MAX * 3);
|
|
1860
|
+
const truncated = formatCompletion({ title: "T", reason: "completed", summary: huge });
|
|
1788
1861
|
assert.match(truncated.text, /已截断/, "超长必须有**显式**截断标注");
|
|
1789
|
-
assert.ok(truncated.text.length <=
|
|
1790
|
-
|
|
1862
|
+
assert.ok(truncated.text.length <= COMPLETION_TEXT_MAX,
|
|
1863
|
+
`整条要限在微信可读长度内,实际 ${truncated.text.length}`);
|
|
1864
|
+
assert.equal(truncated.text.includes(huge), false, "超长结论不得整段带出");
|
|
1865
|
+
|
|
1866
|
+
// ★ 两道闸的关系:取值侧(会话历史)不能比展示侧(通知模板)更小,
|
|
1867
|
+
// 否则结论会在拼接**之前**就被砍掉 —— 只盯 formatter 会找不到真正的截断点(历史踩过)
|
|
1868
|
+
assert.ok(SESSION_SUMMARY_MAX >= COMPLETION_SUMMARY_MAX,
|
|
1869
|
+
`取值上限(${SESSION_SUMMARY_MAX})必须 ≥ 展示上限(${COMPLETION_SUMMARY_MAX}),否则在更早处就被砍掉`);
|
|
1791
1870
|
|
|
1792
1871
|
const noSummary = formatCompletion({ title: "T", reason: "completed" });
|
|
1793
1872
|
assert.match(noSummary.text, /没有产出结论/);
|
|
@@ -102,6 +102,8 @@ async function boundRuntime(ilink, opts = {}) {
|
|
|
102
102
|
tier: opts.tier === undefined ? "pro" : opts.tier,
|
|
103
103
|
tierProvider: opts.tierProvider,
|
|
104
104
|
tierDenyThrottleMs: opts.tierDenyThrottleMs,
|
|
105
|
+
limitTable: opts.limitTable,
|
|
106
|
+
entitlements: opts.entitlements,
|
|
105
107
|
appUrl: opts.appUrl,
|
|
106
108
|
logger: quietLogger()
|
|
107
109
|
});
|
|
@@ -117,7 +119,9 @@ function makeFakeSubscriber() {
|
|
|
117
119
|
s.answer = async (id, outcome) => { s.calls.push({ m: "answer", id, outcome }); return { ok: true }; };
|
|
118
120
|
s.answerApproval = async (id, value) => { s.calls.push({ m: "answerApproval", id, value }); return { ok: true }; };
|
|
119
121
|
s.answerQuestion = async (id, answers) => { s.calls.push({ m: "answerQuestion", id, answers }); return { ok: true }; };
|
|
120
|
-
|
|
122
|
+
// ⚠️ 必须 `...d` 展开透传:真实实现是 `this.#emitLocal({ kind, specNode: 5, ...detail })`。
|
|
123
|
+
// 只挑 notified/answered 会把 `lines` 丢掉 → 日报退化成兜底文案,而测试还"通过"(假的失真)。
|
|
124
|
+
s.digestDue = (d = {}) => ({ kind: NODE_KINDS.DIGEST_DUE, ...d, at: 1 });
|
|
121
125
|
s.quotaLow = (d = {}) => ({ kind: NODE_KINDS.QUOTA_LOW, message: d.message, at: 1 });
|
|
122
126
|
s.membershipExpiring = (d = {}) => ({ kind: NODE_KINDS.MEMBERSHIP_EXPIRING, message: d.message, at: 1 });
|
|
123
127
|
s.start = () => {};
|
|
@@ -307,7 +311,7 @@ test("★ handleCommand 的字段是 replyText:回 /help 必须真的发出帮
|
|
|
307
311
|
});
|
|
308
312
|
|
|
309
313
|
test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按最低档", () => {
|
|
310
|
-
const { capabilitiesFor, WECHAT_CAPABILITY_TABLE } = rtmod;
|
|
314
|
+
const { capabilitiesFor, WECHAT_CAPABILITY_TABLE, grantsCap, limitsFor } = rtmod;
|
|
311
315
|
const free = capabilitiesFor("free");
|
|
312
316
|
// 免费档(业主口径):收得到通知、点得了审批、看得到状态、停得下任务 —— 但**遥控不了**。
|
|
313
317
|
for (const cap of ["notify", "approve", "status", "stop"]) {
|
|
@@ -318,8 +322,30 @@ test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按
|
|
|
318
322
|
}
|
|
319
323
|
// 付费档 = 免费档超集 + 遥控
|
|
320
324
|
const pro = capabilitiesFor("pro");
|
|
321
|
-
|
|
322
|
-
|
|
325
|
+
// ⚠️ 必须用 grantsCap(前缀规则)判超集:付费档写的是粗粒度 "assign",
|
|
326
|
+
// 它按前缀**覆盖** assign.continue —— 用裸 includes 会误判成"缺能力"。
|
|
327
|
+
for (const cap of free) assert.ok(grantsCap(pro, cap), `付费档必须授权免费档的每一项,缺 ${cap}`);
|
|
328
|
+
for (const cap of ["assign.new", "assign.continue", "sessions", "summary"]) {
|
|
329
|
+
assert.ok(grantsCap(pro, cap), `付费档必须有 ${cap}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ★★ 业主 2026-09-22 拍板的核心不变量:免费**能续接已有会话、不能开新会话**
|
|
333
|
+
assert.ok(grantsCap(free, "assign.continue"), "免费档必须能「继续已有会话」(回复即续接)");
|
|
334
|
+
assert.ok(!grantsCap(free, "assign.new"), "★免费档**不得**能开新会话(那是会员能力的差异点)");
|
|
335
|
+
assert.ok(!grantsCap(free, "sessions"), "免费档不得能切换会话");
|
|
336
|
+
|
|
337
|
+
// 前缀规则本身:粗粒度授权覆盖细粒度,细粒度不越权,未知能力一律不授权(fail-closed)
|
|
338
|
+
assert.ok(grantsCap(["assign"], "assign.new") && grantsCap(["assign"], "assign.continue"),
|
|
339
|
+
"写 assign 应同时覆盖 new 与 continue");
|
|
340
|
+
assert.ok(!grantsCap(["assign.continue"], "assign.new"), "写 assign.continue 不得越权到 assign.new");
|
|
341
|
+
assert.ok(!grantsCap(["assign.continue"], "assign"), "细粒度不得反向覆盖粗粒度");
|
|
342
|
+
assert.ok(!grantsCap(["assign"], "constructor"), "前缀规则不能被开头相同的无关词命中");
|
|
343
|
+
assert.ok(!grantsCap(free, "steer") && !grantsCap(["whatever"], "assign.new"), "未知能力不授权");
|
|
344
|
+
|
|
345
|
+
// 额度(业主:「免费给每月 N 条消息额度,超出再引导升级」;付费档不限)
|
|
346
|
+
assert.ok(Number(limitsFor("free").messages_per_month) > 0, "免费档必须有每月消息额度");
|
|
347
|
+
assert.equal(Number(limitsFor("pro").messages_per_month || 0), 0, "付费档不应限量(0 = 不限)");
|
|
348
|
+
assert.equal(Number(limitsFor("pro_max").messages_per_month || 0), 0, "最高档同样不限量");
|
|
323
349
|
assert.ok(pro.length > free.length, "付费档应比免费档多能力(否则钩子没意义)");
|
|
324
350
|
// ⚠️ 服务端的最高档取值是 **pro_max** —— 漏了它 capabilitiesFor 会回退 free,
|
|
325
351
|
// 把花了最多钱的用户当成免费用户挡在门外。
|
|
@@ -685,6 +711,260 @@ test("★ 分层:免费用户打字「允许」(而不是回数字)时,要先把
|
|
|
685
711
|
}
|
|
686
712
|
});
|
|
687
713
|
|
|
714
|
+
test("★★ 日报发送失败时**不能**把当天标记为已发(否则当天再也不会补发)", async () => {
|
|
715
|
+
// 旧实现先写 last_digest_day **再**发送 → 一旦这次没发出去(网络抖动),当天的简报就被烧掉。
|
|
716
|
+
const ilink = await fakeIlink();
|
|
717
|
+
try {
|
|
718
|
+
const sub = makeFakeSubscriber();
|
|
719
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
720
|
+
// 让"现在"正好等于简报时刻,真实路径才会通过时刻闸门
|
|
721
|
+
rt.digestHour = new Date().getHours();
|
|
722
|
+
const stateOf = () => JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
723
|
+
|
|
724
|
+
const origSend = rt.channel.client.sendMessage.bind(rt.channel.client);
|
|
725
|
+
const sends = () => ilink.state.sends.length;
|
|
726
|
+
|
|
727
|
+
// ① 发送失败 → 不得记账
|
|
728
|
+
rt.channel.client.sendMessage = async () => { throw new Error("boom"); };
|
|
729
|
+
await rt.runMaybeDigestNow();
|
|
730
|
+
assert.ok(!stateOf().last_digest_day,
|
|
731
|
+
"★发送失败时绝不能标记今天已发 —— 否则当天再也不会补发(旧实现的坑)");
|
|
732
|
+
|
|
733
|
+
// ② 恢复发送 → 必须能补发成功并记账
|
|
734
|
+
rt.channel.client.sendMessage = origSend;
|
|
735
|
+
const n = sends();
|
|
736
|
+
await rt.runMaybeDigestNow();
|
|
737
|
+
assert.equal(sends(), n + 1, "恢复后应当补发一条");
|
|
738
|
+
assert.ok(stateOf().last_digest_day, "补发成功后要记账");
|
|
739
|
+
|
|
740
|
+
// ③ 已经发过 → 同一天不再重复
|
|
741
|
+
await rt.runMaybeDigestNow();
|
|
742
|
+
assert.equal(sends(), n + 1, "同一天只发一次(它兼作 24h 窗口心跳,不能刷屏)");
|
|
743
|
+
} finally {
|
|
744
|
+
await ilink.close();
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("★ 日报:晚上发(不是早上)、内容是「今天干了啥」、带品牌露出", async () => {
|
|
749
|
+
// 业主:「每天早上发『今日干了啥事儿』,这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」
|
|
750
|
+
// 且一天只发一次(它兼作 24h 推送窗口的心跳,早晚各一次就太频繁了)。
|
|
751
|
+
assert.equal(rtmod.DEFAULT_DIGEST_HOUR, 18, "日报时刻必须是晚上 18 点,不能是早上 9 点");
|
|
752
|
+
const ilink = await fakeIlink();
|
|
753
|
+
try {
|
|
754
|
+
const sub = makeFakeSubscriber();
|
|
755
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
756
|
+
// 造出今天的数据:一个正常完成的任务 + 一次真的拍板
|
|
757
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "completed", at: 1 });
|
|
758
|
+
await rt.notify({
|
|
759
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "a1", toolName: "Read",
|
|
760
|
+
reason: "读配置", answerShape: "approval", at: 1
|
|
761
|
+
});
|
|
762
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
763
|
+
await rt.runDigestNow();
|
|
764
|
+
const text = lastText(ilink.state);
|
|
765
|
+
assert.match(text, /完成任务 1 个/, `日报要回顾**今天做了什么**,实际:${text}`);
|
|
766
|
+
assert.match(text, /你在微信里拍板 1 次/, "拍板次数也要回顾(频道价值可见)");
|
|
767
|
+
assert.match(text, /DSH 远程控制/, "要有品牌轻露出");
|
|
768
|
+
assert.match(text, /回复/, "必须保留 24h 窗口心跳那句(靠用户回消息续期)");
|
|
769
|
+
assert.doesNotMatch(text, /没有要做的事/, "不许再用「待办清单」的措辞");
|
|
770
|
+
} finally {
|
|
771
|
+
await ilink.close();
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("★★ 会员到期提醒:提前 2 天 / 1 天 / 到期当天各提醒一次,同一天不重复", async () => {
|
|
776
|
+
// 这条**以前根本不存在**:notifyMembershipExpiring() 只有定义、全仓零调用方 ——
|
|
777
|
+
// 业主以为"提前两天发、提前一天也发",实际一次都没发过。这里锁住补上的触发点。
|
|
778
|
+
const ilink = await fakeIlink();
|
|
779
|
+
try {
|
|
780
|
+
const DAY = 86_400_000;
|
|
781
|
+
let info = { plan: "pro", plan_ends_at: Date.now() + 2 * DAY, trial_expires_at: null };
|
|
782
|
+
const sub = makeFakeSubscriber();
|
|
783
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
784
|
+
rt.accountInfo = async () => info;
|
|
785
|
+
|
|
786
|
+
const before = ilink.state.sends.length;
|
|
787
|
+
await rt.runExpiryRemindNow();
|
|
788
|
+
assert.equal(ilink.state.sends.length, before + 1, "提前 2 天必须发一条提醒");
|
|
789
|
+
|
|
790
|
+
// 同一天、同一阈值:**不重复**
|
|
791
|
+
await rt.runExpiryRemindNow();
|
|
792
|
+
assert.equal(ilink.state.sends.length, before + 1, "同一个阈值一天只能提醒一次");
|
|
793
|
+
|
|
794
|
+
// 提前 5 天:保持安静(只在 2/1/到期当天三个节点说话)
|
|
795
|
+
info = { plan: "pro", plan_ends_at: Date.now() + 5 * DAY, trial_expires_at: null };
|
|
796
|
+
await rt.runExpiryRemindNow();
|
|
797
|
+
assert.equal(ilink.state.sends.length, before + 1, "提前 5 天不该打扰用户");
|
|
798
|
+
|
|
799
|
+
// 提前 1 天:是新阈值 → 再发一次
|
|
800
|
+
info = { plan: "pro", plan_ends_at: Date.now() + 1 * DAY, trial_expires_at: null };
|
|
801
|
+
await rt.runExpiryRemindNow();
|
|
802
|
+
assert.equal(ilink.state.sends.length, before + 2, "提前 1 天要再提醒一次");
|
|
803
|
+
|
|
804
|
+
// 没有到期信息 / 取不到账号 → 什么都不发(宁可不发,也不拿猜的到期日骚扰)
|
|
805
|
+
info = { plan: "pro", plan_ends_at: null, trial_expires_at: null };
|
|
806
|
+
const n = ilink.state.sends.length;
|
|
807
|
+
await rt.runExpiryRemindNow();
|
|
808
|
+
assert.equal(ilink.state.sends.length, n, "没有到期日就不该发");
|
|
809
|
+
|
|
810
|
+
rt.accountInfo = null;
|
|
811
|
+
await rt.runExpiryRemindNow();
|
|
812
|
+
assert.equal(ilink.state.sends.length, n, "没有 accountInfo 数据源就什么都不发");
|
|
813
|
+
} finally {
|
|
814
|
+
await ilink.close();
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("★★ A:免费档「能续接已有会话、不能开新会话」(业主拍板的分级)", async () => {
|
|
819
|
+
const ilink = await fakeIlink();
|
|
820
|
+
try {
|
|
821
|
+
// ① 免费 + **没有**当前会话 → 纯文本等于开新任务 → 必须被拒
|
|
822
|
+
const subA = makeFakeSubscriber();
|
|
823
|
+
const a = await boundRuntime(ilink, { subscriber: subA, tier: "free", appUrl: "https://app.example/x/" });
|
|
824
|
+
await a.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "帮我修个 bug" } }] });
|
|
825
|
+
assert.equal(subA.calls.filter((c) => c.m === "createSession").length, 0,
|
|
826
|
+
"免费 + 无当前会话:不得开新任务");
|
|
827
|
+
assert.match(lastText(ilink.state), /会员功能/, "要说明这是会员能力");
|
|
828
|
+
|
|
829
|
+
// ② 免费 + **有**当前会话 → 纯文本必须放行(这就是"能接着聊")
|
|
830
|
+
const subB = makeFakeSubscriber();
|
|
831
|
+
const b = await boundRuntime(ilink, { subscriber: subB, tier: "free" });
|
|
832
|
+
b.rt.currentSessionId = "session-x";
|
|
833
|
+
b.rt.currentSessionTitle = "已有任务";
|
|
834
|
+
await b.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续修" } }] });
|
|
835
|
+
const prompted = subB.calls.filter((c) => c.m === "promptSession");
|
|
836
|
+
assert.equal(prompted.length, 1, "★免费 + 有当前会话:必须能继续聊(业主要的就是这个)");
|
|
837
|
+
assert.equal(prompted[0].text, "继续修", "内容要原样下发");
|
|
838
|
+
|
|
839
|
+
// ③ 免费仍不能开新会话、不能切换会话
|
|
840
|
+
const subC = makeFakeSubscriber();
|
|
841
|
+
const c = await boundRuntime(ilink, { subscriber: subC, tier: "free" });
|
|
842
|
+
c.rt.currentSessionId = "session-x";
|
|
843
|
+
await c.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 新任务" } }] });
|
|
844
|
+
assert.equal(subC.calls.filter((m) => m.m === "createSession").length, 0, "免费不得开新会话");
|
|
845
|
+
await c.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
846
|
+
assert.equal(subC.calls.filter((m) => m.m === "listSessions").length, 0, "免费不得列/切会话");
|
|
847
|
+
} finally {
|
|
848
|
+
await ilink.close();
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("★★ B:免费档每月额度用完后引导升级;审批与指令不计数;付费档不限", async () => {
|
|
853
|
+
const LIMITS = { free: { messages_per_month: 2 }, pro: { messages_per_month: 0 }, pro_max: { messages_per_month: 0 } };
|
|
854
|
+
const ilink = await fakeIlink();
|
|
855
|
+
try {
|
|
856
|
+
const sub = makeFakeSubscriber();
|
|
857
|
+
const { rt } = await boundRuntime(ilink, {
|
|
858
|
+
subscriber: sub, tier: "free", appUrl: "https://app.example/x/", limitTable: LIMITS
|
|
859
|
+
});
|
|
860
|
+
rt.currentSessionId = "s";
|
|
861
|
+
rt.currentSessionTitle = "t";
|
|
862
|
+
const send = () => rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" } }] });
|
|
863
|
+
|
|
864
|
+
// 审批与指令**不**吃额度:先做一次审批,额度不受影响
|
|
865
|
+
await rt.notify({
|
|
866
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "q1", toolName: "Read",
|
|
867
|
+
reason: "读配置", answerShape: "approval", at: 1
|
|
868
|
+
});
|
|
869
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
870
|
+
assert.equal(sub.calls.filter((c) => c.m === "answerApproval").length, 1, "审批必须照常可用(安全阀不能吃额度)");
|
|
871
|
+
|
|
872
|
+
await send(); await send();
|
|
873
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 2, "额度内 2 条应放行");
|
|
874
|
+
|
|
875
|
+
await send();
|
|
876
|
+
assert.equal(sub.calls.filter((c) => c.m === "promptSession").length, 2, "★超出额度后不得再派活");
|
|
877
|
+
const text = lastText(ilink.state);
|
|
878
|
+
assert.match(text, /额度已经用完/, `要说清是用完额度而不是报错,实际:${text}`);
|
|
879
|
+
assert.ok(text.includes("https://app.example/x/"), "仍要给升级入口(App 链接)");
|
|
880
|
+
|
|
881
|
+
// 付费档:同样连发 5 条都不受限
|
|
882
|
+
const paid = makeFakeSubscriber();
|
|
883
|
+
const p = await boundRuntime(ilink, { subscriber: paid, tier: "pro" });
|
|
884
|
+
p.rt.currentSessionId = "s";
|
|
885
|
+
for (let i = 0; i < 5; i += 1) {
|
|
886
|
+
await p.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" + i } }] });
|
|
887
|
+
}
|
|
888
|
+
assert.equal(paid.calls.filter((c) => c.m === "promptSession").length, 5, "付费档不限量");
|
|
889
|
+
} finally {
|
|
890
|
+
await ilink.close();
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("★★ 权益包:服务端下发的 caps/limits 覆盖内置表(后台改权限无需发版)", async () => {
|
|
895
|
+
const ilink = await fakeIlink();
|
|
896
|
+
try {
|
|
897
|
+
// ① 服务端给免费档额外开了 assign.new → 免费用户当场就能开新会话
|
|
898
|
+
const subA = makeFakeSubscriber();
|
|
899
|
+
const a = await boundRuntime(ilink, {
|
|
900
|
+
subscriber: subA, tier: "free",
|
|
901
|
+
entitlements: { rev: "r1", plan: "free", caps: ["notify", "approve", "assign.new", "assign.continue"], limits: { messages_per_month: 0 } }
|
|
902
|
+
});
|
|
903
|
+
await a.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
904
|
+
assert.equal(subA.calls.filter((c) => c.m === "createSession").length, 1,
|
|
905
|
+
"服务端放开后,免费档也能开新会话(证明权限真的由配置决定)");
|
|
906
|
+
|
|
907
|
+
// ② 反向:服务端把付费档的 assign 收掉 → 同一个付费用户立刻不能派活
|
|
908
|
+
const subB = makeFakeSubscriber();
|
|
909
|
+
const b = await boundRuntime(ilink, {
|
|
910
|
+
subscriber: subB, tier: "pro",
|
|
911
|
+
entitlements: { rev: "r2", plan: "pro", caps: ["notify", "approve", "status", "stop"], limits: {} }
|
|
912
|
+
});
|
|
913
|
+
b.rt.currentSessionId = "s";
|
|
914
|
+
await b.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "派活" } }] });
|
|
915
|
+
assert.equal(subB.calls.filter((c) => c.m === "promptSession").length, 0,
|
|
916
|
+
"服务端收权后付费档也不该能派活(收权必须真的生效)");
|
|
917
|
+
} finally {
|
|
918
|
+
await ilink.close();
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("★★ 多条待审批:回数字必须答**最早那条**(先进先出),答完把下一条顶到底部", async () => {
|
|
923
|
+
// 业主报的真 bug:「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,
|
|
924
|
+
// 回应一个数字之后,流程就结束了。后面的几条消息没有让用户继续回应,直接提示为『未回应』」
|
|
925
|
+
// 成因:旧实现按**栈顶(最新)**匹配数字,而微信是**从上往下读**的、消息位置一发出去就固定
|
|
926
|
+
// → 用户看着第 1 条回「1」,系统答了最后一条,第 1 条永远没人回应,DSH 最终标它「未回应」。
|
|
927
|
+
const ilink = await fakeIlink();
|
|
928
|
+
try {
|
|
929
|
+
const sub = makeFakeSubscriber();
|
|
930
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
|
|
931
|
+
|
|
932
|
+
await rt.notify({
|
|
933
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "e1", toolName: "Read",
|
|
934
|
+
reason: "读配置", answerShape: "approval", at: 1
|
|
935
|
+
});
|
|
936
|
+
// 第二条到达时第一条还没被答 → 文案必须说明"不止这一条"
|
|
937
|
+
await rt.notify({
|
|
938
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "e2", toolName: "Write",
|
|
939
|
+
reason: "写文件", answerShape: "approval", at: 2
|
|
940
|
+
});
|
|
941
|
+
assert.equal(rt.pendingReplies.length, 2, "两条都该登记为待答");
|
|
942
|
+
assert.match(lastText(ilink.state), /还有 2 条待拍板/,
|
|
943
|
+
`★必须告诉用户"不止这一条",否则他以为答完就结束了,实际:${lastText(ilink.state)}`);
|
|
944
|
+
|
|
945
|
+
// 回「1」→ 必须答**最早**的 e1
|
|
946
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
947
|
+
const answered = sub.calls.filter((c) => c.m === "answerApproval");
|
|
948
|
+
assert.equal(answered.length, 1, "必须真的回执一条");
|
|
949
|
+
assert.equal(answered[0].id, "e1", "★必须答**最早那条**(e1),而不是最新那条(e2)");
|
|
950
|
+
|
|
951
|
+
// 答完还剩 1 条 → 必须把**下一条重发到底部**,用户直接在最新消息上回数字
|
|
952
|
+
const after = lastText(ilink.state);
|
|
953
|
+
assert.match(after, /还有 1 条待你拍板/, `★答完要把下一条顶到底部,实际:${after}`);
|
|
954
|
+
assert.match(after, /回复 1/, "重发的提醒要带可回数字的选项");
|
|
955
|
+
assert.match(after, /Write/, "要指明下一条是什么(工具名),用户才知道在批什么");
|
|
956
|
+
|
|
957
|
+
// 再回一次 → 答掉 e2;队列空了就不该再发多余提醒
|
|
958
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
959
|
+
const all = sub.calls.filter((c) => c.m === "answerApproval");
|
|
960
|
+
assert.equal(all.length, 2);
|
|
961
|
+
assert.equal(all[1].id, "e2", "第二条应是 e2");
|
|
962
|
+
assert.doesNotMatch(lastText(ilink.state), /待你拍板/, "队列空了就不该再发提醒");
|
|
963
|
+
} finally {
|
|
964
|
+
await ilink.close();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
688
968
|
test("★ v2 安全:破坏性审批不给回执选项,回数字也不产生任何回执", async () => {
|
|
689
969
|
const ilink = await fakeIlink();
|
|
690
970
|
try {
|