@mrrisega/dsh-remote 0.6.10-beta.6 → 0.6.10-beta.8
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 +56 -1
- package/clients/dsh-remote/test/wechat-channel.test.mjs +87 -8
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +656 -4
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +168 -6
- package/clients/dsh-remote/wechat-channel.mjs +138 -28
- package/clients/dsh-remote/wechat-runtime.mjs +467 -44
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +83 -8
- package/packages/dsh-remote-web/lib/index.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +140 -8
|
@@ -1230,6 +1230,15 @@ function tagWeChat(m) {
|
|
|
1230
1230
|
* 取值 = 账号的**生效套餐**,服务端只有三种:free / pro / pro_max
|
|
1231
1231
|
* (见企业端 auth.js 的 PLAN_PRIORITY;试用期服务端已折算成 pro,这里不必自己判断)。
|
|
1232
1232
|
*
|
|
1233
|
+
* ★ 返回**两种契约**(见 docs/entitlements-contract.md §5/§6.1):
|
|
1234
|
+
* · 服务端 `/api/me` 带了 `entitlements`(后台可配置的权益包)→ 返回**对象**
|
|
1235
|
+
* `{plan, caps, limits, rev}`,runtime 以它为准判定能力 —— 这样「后台改配置 = 用户侧实时生效」
|
|
1236
|
+
* 才成立(无需发版)。caps/limits **原样透传**:权限判定以服务端为唯一真相源,
|
|
1237
|
+
* 客户端任何"二次加工/本地过滤"都会让展示与判定分叉,也会让后台配置失效(红线 §8.1/§8.2)。
|
|
1238
|
+
* · 服务端**没带** `entitlements`(灰度期的旧服务端)→ 保持今天的行为、返回**字符串**档位:
|
|
1239
|
+
* runtime 有上次的包就继续用它,一份包都没有才按内置表解析。新旧服务端都要能跑,
|
|
1240
|
+
* 所以这里不能只认新契约。
|
|
1241
|
+
*
|
|
1233
1242
|
* ⚠️ 取不到时返回**空串**,而不是 "free":空串让 runtime 沿用上次成功取到的档位。
|
|
1234
1243
|
* 一次网络抖动就把付过钱的用户降级成免费、还提示他去升级,比多给几分钟权限糟糕得多。
|
|
1235
1244
|
* ⚠️ 自建部署直接给最高档:用户跑的是自己的服务器,不存在"会员"这回事,
|
|
@@ -1242,6 +1251,18 @@ function tagWeChat(m) {
|
|
|
1242
1251
|
*/
|
|
1243
1252
|
const WECHAT_TIER_TOKEN_TTL_MS = 5 * 60_000;
|
|
1244
1253
|
let WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1254
|
+
/**
|
|
1255
|
+
* 最近一次 `/api/me` 的账号快照(查档位时顺手记下来)。
|
|
1256
|
+
*
|
|
1257
|
+
* 为什么需要:微信通道要做"会员临近到期"提醒,而它**不该再单独打一次** `/api/me` ——
|
|
1258
|
+
* 档位每 10 分钟本来就会查一次,顺手记住即可。查失败时**保持上次的值**(与档位同一策略:
|
|
1259
|
+
* 一次网络抖动不该让提醒凭空消失或变成乱猜)。
|
|
1260
|
+
*/
|
|
1261
|
+
let WECHAT_ACCOUNT_SNAPSHOT = null;
|
|
1262
|
+
/** 供微信通道取账号快照(到期提醒用)。没查过就返回 null → 上层什么都不发。 */
|
|
1263
|
+
async function wechatAccountInfo() {
|
|
1264
|
+
return WECHAT_ACCOUNT_SNAPSHOT;
|
|
1265
|
+
}
|
|
1245
1266
|
|
|
1246
1267
|
async function resolveWechatTier() {
|
|
1247
1268
|
if (process.env.DSH_BRIDGE_LOCAL_KEY) return "pro_max";
|
|
@@ -1269,7 +1290,39 @@ async function resolveWechatTier() {
|
|
|
1269
1290
|
}
|
|
1270
1291
|
if (!r.ok) return "";
|
|
1271
1292
|
const j = await r.json().catch(() => null);
|
|
1272
|
-
|
|
1293
|
+
const u = (j && j.user) || null;
|
|
1294
|
+
// 顺手记账号快照(到期提醒用)。⚠️ 只在**真拿到了 user** 时覆盖 —— 否则保持上次。
|
|
1295
|
+
if (u) {
|
|
1296
|
+
WECHAT_ACCOUNT_SNAPSHOT = {
|
|
1297
|
+
plan: String(u.plan || ""),
|
|
1298
|
+
plan_ends_at: u.plan_ends_at ?? null,
|
|
1299
|
+
trial_expires_at: u.trial_expires_at ?? null
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
// ★ 服务端权益包(契约 §5):有它就返回对象,runtime 以服务端 caps/limits 为准。
|
|
1303
|
+
//
|
|
1304
|
+
// 🔴 判据是「`caps` 是**数组**」,**空数组也算给了**(2026-09-23 修):
|
|
1305
|
+
// 后台「明确清空」某档(契约 §4.1 的 `EMPTY_CAPS_VALUE`,下发即 `caps: []`)表达的是
|
|
1306
|
+
// **这个档位什么能力都没有**。如果这里把空数组当成"没给",runtime 就会沿用上次那份包
|
|
1307
|
+
// (一份都没有时回退**内置表**)—— 于是"我清空了 pro"变成"pro 拿回内置的一整套付费能力",
|
|
1308
|
+
// 比后台意图**更多**权限。那是 fail-open,方向正好反了(契约 §8 红线 2)。
|
|
1309
|
+
// 所以:数组(含空) = 权威;不是数组/字段缺失 = 这次没拿到 → 退回字符串契约沿用上次。
|
|
1310
|
+
const ent = j && j.entitlements;
|
|
1311
|
+
if (ent && typeof ent === "object" && Array.isArray(ent.caps)) {
|
|
1312
|
+
return {
|
|
1313
|
+
// plan 也原样取服务端的值;只有权益包里缺 plan 时才退回 user.plan(同一份真相的旧字段)。
|
|
1314
|
+
plan: String(ent.plan || (u && u.plan) || ""),
|
|
1315
|
+
caps: ent.caps,
|
|
1316
|
+
// ⚠️ limits 缺失时必须给 null,**不能补 `{}`**:runtime 里 `{}` 是"有效的一张限制表",
|
|
1317
|
+
// 于是 messages_per_month 读成 0 = 不限 → 免费用户当场变成无限额度(等于放权)。
|
|
1318
|
+
// null 才会让 runtime 沿用上次的值 / 回退内置表 —— 缺字段绝不等于放开(契约 §8.2 fail-closed)。
|
|
1319
|
+
limits: ent.limits && typeof ent.limits === "object" ? ent.limits : null,
|
|
1320
|
+
rev: String(ent.rev || "")
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
// 没有权益包(旧服务端 / 灰度期)→ 保持旧契约:只给档位字符串。
|
|
1324
|
+
// runtime 收到字符串后:有上次的包就继续用它,一份包都没有才按内置表解析(冷启动兜底)。
|
|
1325
|
+
return String((u && u.plan) || "");
|
|
1273
1326
|
} catch {
|
|
1274
1327
|
return "";
|
|
1275
1328
|
}
|
|
@@ -1300,6 +1353,8 @@ function startWeChat() {
|
|
|
1300
1353
|
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1301
1354
|
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
1302
1355
|
tierProvider: resolveWechatTier,
|
|
1356
|
+
// 到期提醒的数据来源(复用上面那次 /api/me,不额外打网络)
|
|
1357
|
+
accountInfo: wechatAccountInfo,
|
|
1303
1358
|
appUrl: wechatAppUrl(),
|
|
1304
1359
|
// 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
|
|
1305
1360
|
// (真机日志里出现过 `[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, /没有产出结论/);
|