@mrrisega/dsh-remote 0.6.10-beta.7 → 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 +32 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +372 -0
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +168 -6
- package/clients/dsh-remote/wechat-runtime.mjs +145 -15
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +28 -5
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +88 -0
|
@@ -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
|
* ⚠️ 自建部署直接给最高档:用户跑的是自己的服务器,不存在"会员"这回事,
|
|
@@ -1290,6 +1299,29 @@ async function resolveWechatTier() {
|
|
|
1290
1299
|
trial_expires_at: u.trial_expires_at ?? null
|
|
1291
1300
|
};
|
|
1292
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 收到字符串后:有上次的包就继续用它,一份包都没有才按内置表解析(冷启动兜底)。
|
|
1293
1325
|
return String((u && u.plan) || "");
|
|
1294
1326
|
} catch {
|
|
1295
1327
|
return "";
|
|
@@ -105,6 +105,8 @@ async function boundRuntime(ilink, opts = {}) {
|
|
|
105
105
|
limitTable: opts.limitTable,
|
|
106
106
|
entitlements: opts.entitlements,
|
|
107
107
|
appUrl: opts.appUrl,
|
|
108
|
+
// 可控时钟:额度用量按自然月归零,「跨月再提醒」必须能确定性地测(不能真等一个月)
|
|
109
|
+
clock: opts.clock,
|
|
108
110
|
logger: quietLogger()
|
|
109
111
|
});
|
|
110
112
|
// 替换订阅器:本文件测编排,不测 DSH 协议(那是 dsh-events.test.mjs 的职责)
|
|
@@ -144,6 +146,26 @@ const lastText = (state) => {
|
|
|
144
146
|
: "";
|
|
145
147
|
};
|
|
146
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
|
+
|
|
147
169
|
// ── ① 词表翻译 ────────────────────────────────────────────────────────────
|
|
148
170
|
|
|
149
171
|
test("★ 词表覆盖:事件侧每个 kind 都必须有归宿(上游新增节点会当场暴露)", () => {
|
|
@@ -1105,3 +1127,353 @@ test("绑定态只有 bound/unbound 两态:未绑定时通道不启动、绑定
|
|
|
1105
1127
|
await ilink.close();
|
|
1106
1128
|
}
|
|
1107
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
|
+
});
|
|
@@ -21,7 +21,14 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
|
21
21
|
const BRIDGE_SRC = readFileSync(path.join(HERE, "..", "dsh-bridge.mjs"), "utf8");
|
|
22
22
|
const API_BASE = "https://api.example/relay-api";
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** 可控时钟:切片里的 `Date.now()` 是唯一的取时点,替掉它就能确定性验 5 分钟缓存。 */
|
|
25
|
+
function fakeDate(nowFn) {
|
|
26
|
+
return class FakeDate extends Date {
|
|
27
|
+
static now() { return nowFn(); }
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadTierResolver({ localKey = "", token = "tok-1", status = 200, body = { user: { plan: "pro" } }, throws = false, now = null } = {}) {
|
|
25
32
|
// 切片必须**带上 token 缓存那两个声明**(它们在函数之外,漏了就 ReferenceError →
|
|
26
33
|
// 被函数内的 try/catch 吞成空串,测试会假绿)。
|
|
27
34
|
const from = BRIDGE_SRC.indexOf("const WECHAT_TIER_TOKEN_TTL_MS");
|
|
@@ -31,16 +38,38 @@ function loadTierResolver({ localKey = "", token = "tok-1", status = 200, body =
|
|
|
31
38
|
"切片必须同时包含 token 缓存声明与 resolveWechatTier");
|
|
32
39
|
const calls = [];
|
|
33
40
|
let tokenCalls = 0;
|
|
41
|
+
// body 允许传函数:这样能在**同一个实例**里改变服务端响应,验"这次没给 user 时快照不被覆盖"。
|
|
42
|
+
const bodyFor = typeof body === "function" ? body : () => body;
|
|
34
43
|
const fakeFetch = async (url, init) => {
|
|
35
44
|
calls.push({ url, init });
|
|
36
45
|
if (throws) throw new Error("network down");
|
|
37
|
-
return { ok: status >= 200 && status < 300, status, json: async () =>
|
|
46
|
+
return { ok: status >= 200 && status < 300, status, json: async () => bodyFor() };
|
|
38
47
|
};
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
// 多出口两个"内部状态"的只读探针 —— 否则"token 缓存被清空""快照不因缺 user 被覆盖"
|
|
49
|
+
// 这两条只能用间接行为猜,抓不住回归(它们都在闭包里)。
|
|
50
|
+
const make = new Function("process", "resolveToken", "API_BASE", "fetch", "AbortSignal", "Date",
|
|
51
|
+
`${BRIDGE_SRC.slice(from, to)}
|
|
52
|
+
return {
|
|
53
|
+
fn: resolveWechatTier,
|
|
54
|
+
tokenCache: () => WECHAT_TIER_TOKEN,
|
|
55
|
+
snapshot: () => WECHAT_ACCOUNT_SNAPSHOT
|
|
56
|
+
};`);
|
|
41
57
|
const env = localKey ? { DSH_BRIDGE_LOCAL_KEY: localKey } : {};
|
|
42
|
-
const
|
|
43
|
-
|
|
58
|
+
const api = make(
|
|
59
|
+
{ env },
|
|
60
|
+
async () => { tokenCalls += 1; return token; },
|
|
61
|
+
API_BASE,
|
|
62
|
+
fakeFetch,
|
|
63
|
+
AbortSignal,
|
|
64
|
+
now ? fakeDate(now) : Date
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
fn: api.fn,
|
|
68
|
+
calls,
|
|
69
|
+
tokenCache: api.tokenCache,
|
|
70
|
+
snapshot: api.snapshot,
|
|
71
|
+
tokenCalls: () => tokenCalls
|
|
72
|
+
};
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
function loadAppUrl(apiBase) {
|
|
@@ -123,3 +152,136 @@ test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL
|
|
|
123
152
|
assert.equal(loadAppUrl("https://api.example/relay-api/")(), "https://api.example/app/",
|
|
124
153
|
"结尾斜杠也要正确吃掉");
|
|
125
154
|
});
|
|
155
|
+
|
|
156
|
+
// ── 权益包(服务端 /api/me 新增的 entitlements 字段,契约 §5/§6.1) ──────────
|
|
157
|
+
|
|
158
|
+
const FREE_ENT = Object.freeze({
|
|
159
|
+
plan: "free",
|
|
160
|
+
caps: ["notify", "approve", "status", "stop", "assign.continue"],
|
|
161
|
+
limits: { messages_per_month: 20 },
|
|
162
|
+
rev: "a1b2c3d4",
|
|
163
|
+
source: "configured"
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("★权益包:/api/me 带 entitlements 时返回对象,caps/limits/rev 原样透传", async () => {
|
|
167
|
+
// 为什么必须**原样**:服务端是权限的唯一真相源(契约 §0)。桥接侧任何"本地过滤/补齐"
|
|
168
|
+
// 都会让后台配置失效(红线 §8.1「话术与权限一致」直接建立在"下发什么就是什么"上)。
|
|
169
|
+
const { fn } = loadTierResolver({ body: { user: { plan: "free" }, entitlements: FREE_ENT } });
|
|
170
|
+
const got = await fn();
|
|
171
|
+
assert.equal(typeof got, "object", "有权益包就必须返回对象(runtime 才能以它为准判定)");
|
|
172
|
+
assert.equal(got.plan, "free");
|
|
173
|
+
assert.deepEqual(got.caps, FREE_ENT.caps);
|
|
174
|
+
assert.equal(got.caps, FREE_ENT.caps, "★caps 必须原样透传(连数组都不重建,杜绝二次加工)");
|
|
175
|
+
assert.deepEqual(got.limits, { messages_per_month: 20 });
|
|
176
|
+
assert.equal(got.limits, FREE_ENT.limits, "★limits 同样原样透传");
|
|
177
|
+
assert.equal(got.rev, "a1b2c3d4", "rev 要带上(客户端据此判断包变没变)");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("★权益包:没有 entitlements 时保持今天的字符串契约(新旧服务端都能跑)", async () => {
|
|
181
|
+
// 灰度期:服务端没发版 → 只给 {user}。这时绝不能返回对象(缺 caps 会让 runtime 判不出档位),
|
|
182
|
+
// 必须退回字符串,让 runtime 按内置表解析 —— 与改动前**逐字一致**的行为。
|
|
183
|
+
for (const plan of ["free", "pro", "pro_max"]) {
|
|
184
|
+
const { fn } = loadTierResolver({ body: { user: { plan } } });
|
|
185
|
+
const got = await fn();
|
|
186
|
+
assert.equal(typeof got, "string", `旧服务端必须走字符串契约(plan=${plan})`);
|
|
187
|
+
assert.equal(got, plan);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("★权益包:caps 为**数组**(含空数组)就走对象契约;不是数组/缺字段才退回字符串", async () => {
|
|
192
|
+
// 2026-09-23 修正:原先这里要求"caps 必须非空",把 `caps: []` 也挡在对象契约之外 ——
|
|
193
|
+
// 那是个 **fail-open**:后台「明确清空某档」(契约 §4.1)下发 `caps: []`,
|
|
194
|
+
// 桥接当"没给"退回字符串 → runtime 沿用上次那份包(或回退**内置表**)→
|
|
195
|
+
// 本该"什么都不能做"的档位拿回一整套能力,比后台意图**更多**权限。
|
|
196
|
+
// 正确语义:数组 = 权威(空数组就是"空");不是数组/缺字段 = 这次没拿到 → 沿用上次。
|
|
197
|
+
const bad = [
|
|
198
|
+
{ entitlements: { plan: "free", caps: null, limits: { messages_per_month: 20 }, rev: "r" } },
|
|
199
|
+
{ entitlements: { plan: "free", limits: { messages_per_month: 20 }, rev: "r" } },
|
|
200
|
+
{ entitlements: { plan: "free", caps: "assign.new", rev: "r" } } // 字符串不是数组
|
|
201
|
+
];
|
|
202
|
+
for (const body of bad) {
|
|
203
|
+
const { fn } = loadTierResolver({ body: { user: { plan: "free" }, ...body } });
|
|
204
|
+
assert.equal(await fn(), "free", `坏权益包必须退回字符串契约:${JSON.stringify(body.entitlements)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ★ 空数组 = 权威的"这一档什么都不能做",必须原样作为对象返回
|
|
208
|
+
const { fn } = loadTierResolver({
|
|
209
|
+
body: { user: { plan: "pro" }, entitlements: { plan: "pro", caps: [], limits: { messages_per_month: 0 }, rev: "r9" } }
|
|
210
|
+
});
|
|
211
|
+
const got = await fn();
|
|
212
|
+
assert.equal(typeof got, "object", "★caps: [] 必须是有效的权益包,不能退回字符串(否则清空会静默失效)");
|
|
213
|
+
assert.deepEqual([...got.caps], [], "空数组要原样透传,不能被换成别的");
|
|
214
|
+
assert.equal(got.plan, "pro");
|
|
215
|
+
assert.equal(got.rev, "r9");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("★权益包:limits 缺失时给 null 而不是 {}(补 {} 会被 runtime 读成「不限」=放权)", async () => {
|
|
219
|
+
// runtime 里 `limits` 只要是对象就生效,而 `{}` 的 messages_per_month 读成 0 = 不限。
|
|
220
|
+
// 于是"服务端这次没给 limits"会静默把免费用户变成无限额度 —— 缺字段必须 fail-closed。
|
|
221
|
+
const { fn } = loadTierResolver({
|
|
222
|
+
body: { user: { plan: "free" }, entitlements: { plan: "free", caps: ["notify"], rev: "r1" } }
|
|
223
|
+
});
|
|
224
|
+
const got = await fn();
|
|
225
|
+
assert.equal(typeof got, "object");
|
|
226
|
+
assert.equal(got.limits, null, "★缺 limits 必须是 null(runtime 据此沿用上次 / 回退内置表)");
|
|
227
|
+
assert.equal(got.rev, "r1");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("★权益包:401/403 丢弃 token 缓存、返回空串(沿用旧值,绝不降级)", async () => {
|
|
231
|
+
// 「空串 = 这次没取到」是整条不降级链路的起点:retrurn "free" 会把付过钱的用户降级,
|
|
232
|
+
// 而"缓存没清"会让下一次查询继续拿坏 token 打(用户永远恢复不了)。两条都要证明。
|
|
233
|
+
for (const status of [401, 403]) {
|
|
234
|
+
const r = loadTierResolver({ status, body: { user: { plan: "free" }, entitlements: FREE_ENT } });
|
|
235
|
+
const got = await r.fn();
|
|
236
|
+
assert.equal(got, "", `${status} 必须返回空串(上层沿用缓存,不降级)`);
|
|
237
|
+
assert.equal(r.tokenCache().value, "", `★${status} 之后 token 缓存必须被清空`);
|
|
238
|
+
assert.equal(r.tokenCache().at, 0, "缓存时间戳也要归零(否则被当成「刚取过」继续用)");
|
|
239
|
+
await r.fn();
|
|
240
|
+
assert.equal(r.tokenCalls(), 2, "★清空后必须重新登录,而不是一直拿坏 token 打");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("★权益包:token 的 5 分钟缓存原样保留(device-login 是按出口 IP 限流的)", () => {
|
|
245
|
+
// /api/device-login 限流是**按出口 IP**的(15 分钟 5 次),连累的不只是这个用户。
|
|
246
|
+
// 所以档位查询自己缓存 token 的逻辑一个字都不能动 —— 这里把 TTL 与行为都锁死。
|
|
247
|
+
assert.match(BRIDGE_SRC, /const WECHAT_TIER_TOKEN_TTL_MS = 5 \* 60_000;/,
|
|
248
|
+
"★TTL 必须还是 5 分钟(改动它就是改限流预算)");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("★权益包:TTL 内不重复登录,超过 TTL 才重新登录", async () => {
|
|
252
|
+
let t = 1_700_000_000_000;
|
|
253
|
+
const r = loadTierResolver({ body: { user: { plan: "free" }, entitlements: FREE_ENT }, now: () => t });
|
|
254
|
+
await r.fn();
|
|
255
|
+
assert.equal(r.tokenCalls(), 1);
|
|
256
|
+
|
|
257
|
+
t += 4 * 60_000; // TTL 内
|
|
258
|
+
await r.fn();
|
|
259
|
+
assert.equal(r.tokenCalls(), 1, "TTL 内不得重新登录(每 10 分钟一次的档位校准必须被缓存挡住)");
|
|
260
|
+
|
|
261
|
+
t += 2 * 60_000; // 累计 6 分钟 > 5 分钟
|
|
262
|
+
await r.fn();
|
|
263
|
+
assert.equal(r.tokenCalls(), 2, "超过 TTL 才重新登录");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("★权益包:账号快照只在真拿到 user 时覆盖(到期提醒的数据源)", async () => {
|
|
267
|
+
// 到期提醒复用这次 /api/me 的结果(不额外打网络)。所以"这次没给 user"时**必须保持上次** ——
|
|
268
|
+
// 否则一次抖动就会让到期提醒凭空消失(或拿 null 去算到期日)。
|
|
269
|
+
let body = { user: { plan: "pro", plan_ends_at: 1_800_000_000_000 }, entitlements: { ...FREE_ENT, plan: "pro" } };
|
|
270
|
+
const r = loadTierResolver({ body: () => body });
|
|
271
|
+
|
|
272
|
+
await r.fn();
|
|
273
|
+
assert.equal(r.snapshot().plan_ends_at, 1_800_000_000_000, "拿到 user 时要记快照");
|
|
274
|
+
|
|
275
|
+
// 服务端这次只发了权益包、没有 user → 快照必须保持上次的值,而权益包照常返回
|
|
276
|
+
body = { entitlements: FREE_ENT };
|
|
277
|
+
const got = await r.fn();
|
|
278
|
+
assert.equal(typeof got, "object", "没有 user 也要照常返回权益包(两者互不依赖)");
|
|
279
|
+
assert.equal(r.snapshot().plan_ends_at, 1_800_000_000_000,
|
|
280
|
+
"★没有 user 时不得覆盖/清空快照(一次抖动不该让到期提醒消失)");
|
|
281
|
+
|
|
282
|
+
// 从没拿到过 user → 快照就是 null,不许凭空造一个
|
|
283
|
+
const fresh = loadTierResolver({ body: { entitlements: FREE_ENT } });
|
|
284
|
+
await fresh.fn();
|
|
285
|
+
assert.equal(fresh.snapshot(), null, "没取到过 user 就该是 null(上层据此什么都不发)");
|
|
286
|
+
});
|
|
287
|
+
|
|
@@ -182,6 +182,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
|
|
|
182
182
|
NODE_KINDS.FAULT
|
|
183
183
|
]);
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* **提醒类**节点:它不是"等用户拍板",只是"告诉他一件事 + 顺带给个入口"。
|
|
187
|
+
*
|
|
188
|
+
* 为什么必须单独分一类(2026-09-23 定位到真机问题):
|
|
189
|
+
* 额度提醒与会员到期提醒在文案层是**可回执**的(带「邀请好友 / 知道了」选项,
|
|
190
|
+
* 见 wechat-channel.mjs 的 `optionsFor`),于是它们会被塞进审批队列 `pendingReplies`。
|
|
191
|
+
* 而数字回执按 **FIFO** 认领(微信里消息位置固定,用户从上往下读)——
|
|
192
|
+
* 真机后果:用户看到的是底部**最新那条审批**,回「1」,
|
|
193
|
+
* 回执却落到了**更早的那条额度提醒**上 → 审批没人答,得再回一次才轮到。
|
|
194
|
+
*
|
|
195
|
+
* 所以队列分两类:
|
|
196
|
+
* · **决策**(审批 / 提问 / 计划)= 需要用户拍板,数字回执优先给它们;
|
|
197
|
+
* · **提醒**(额度 / 会员到期)= 不占决策位;只有一条决策都没有时,数字才给它们。
|
|
198
|
+
*/
|
|
199
|
+
const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
|
|
200
|
+
|
|
185
201
|
/**
|
|
186
202
|
* 把事件节点翻译成文案节点。
|
|
187
203
|
* @returns {{node: object|null, formatterKind: string}}
|
|
@@ -446,7 +462,8 @@ export class WeChatRuntime {
|
|
|
446
462
|
last_push_ok_at: state.last_push_ok_at || 0,
|
|
447
463
|
last_error: state.last_error || "",
|
|
448
464
|
cooldown_ms: cooldownMs,
|
|
449
|
-
|
|
465
|
+
// 只数**待拍板**的(不含额度/会员到期这类提醒):面板上的数字要与用户真实要做的决定一致
|
|
466
|
+
pending_replies: this.#decisionCount(),
|
|
450
467
|
// ★ 面板要把「你现在能用什么」如实展示出来(业主:话术与权限必须一致,不多承诺)。
|
|
451
468
|
// 这里下发的是**通道实际在用**的那一份(服务端权益包优先,否则内置表按档位),
|
|
452
469
|
// 所以面板不需要自己猜档位 → 展示与判定不可能不一致。
|
|
@@ -687,13 +704,20 @@ export class WeChatRuntime {
|
|
|
687
704
|
* 用户看着第 1 条回「1」,系统却答了最后一条,第 1 条于是永远没人回应。
|
|
688
705
|
*/
|
|
689
706
|
#oldestPending() {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
return
|
|
707
|
+
// 先清掉已被消费/过期的,再看队列
|
|
708
|
+
this.pendingReplies = this.pendingReplies.filter((p) => this.channel.registry.get(p.eventId));
|
|
709
|
+
// ★ **决策优先**:数字回执是给"要用户拍板的事"用的。
|
|
710
|
+
// 提醒类(额度/会员到期)只是告知 + 顺带入个口,不能抢走审批的回执位 ——
|
|
711
|
+
// 否则用户看着底部那条审批回「1」,回执却落到更早的提醒上(真机复现过)。
|
|
712
|
+
const decisions = this.pendingReplies.filter((p) => !p.notice);
|
|
713
|
+
if (decisions.length) return decisions[0];
|
|
714
|
+
// 一条决策都没有 → 才轮到提醒(它的「邀请好友 / 知道了」这时才有意义)
|
|
715
|
+
return this.pendingReplies[0] || null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** 待**拍板**的条数(不含提醒类)。 */
|
|
719
|
+
#decisionCount() {
|
|
720
|
+
return this.pendingReplies.filter((p) => !p.notice).length;
|
|
697
721
|
}
|
|
698
722
|
|
|
699
723
|
/**
|
|
@@ -712,7 +736,7 @@ export class WeChatRuntime {
|
|
|
712
736
|
const opts = Array.isArray(entry && entry.options) ? entry.options : [];
|
|
713
737
|
if (!opts.length) return; // 不可回执的(如破坏性审批)不再提示
|
|
714
738
|
const task = String((pending.node && (pending.node.toolName || pending.node.title)) || "").trim();
|
|
715
|
-
const lines = [`还有 ${this
|
|
739
|
+
const lines = [`还有 ${this.#decisionCount()} 条待你拍板:`];
|
|
716
740
|
if (task) lines.push(`· ${task}`);
|
|
717
741
|
lines.push("");
|
|
718
742
|
opts.forEach((o, i) => lines.push(`回复 ${i + 1} = ${o.label}`));
|
|
@@ -752,8 +776,9 @@ export class WeChatRuntime {
|
|
|
752
776
|
// ⚠️ 陷阱:回执只认**纯数字**(见 classifyInbound)。免费档的核心价值恰恰是审批,
|
|
753
777
|
// 用户很可能打字「允许」而不是回「1」—— 若只回一句付费提示,他会以为免费版什么都干不了。
|
|
754
778
|
// 所以手上有待回执的消息时,先把"回数字就能拍板"说在前面。
|
|
755
|
-
const
|
|
756
|
-
|
|
779
|
+
const waiting = this.#decisionCount();
|
|
780
|
+
const head = waiting > 0
|
|
781
|
+
? `你还有 ${waiting} 条待你拍板的消息 —— 直接回一个数字(如 1)就能完成决定,不用打字。\n\n`
|
|
757
782
|
: "";
|
|
758
783
|
await this.reply(from, head + this.#upsellText());
|
|
759
784
|
return;
|
|
@@ -779,7 +804,11 @@ export class WeChatRuntime {
|
|
|
779
804
|
*/
|
|
780
805
|
#caps() {
|
|
781
806
|
const server = this.entitlements && Array.isArray(this.entitlements.caps) ? this.entitlements.caps : null;
|
|
782
|
-
|
|
807
|
+
// ⚠️ 判据是「**是不是数组**」,不是「数组非空」(2026-09-23 修):
|
|
808
|
+
// 服务端下发 `caps: []` 是**权威的"这一档什么都不能做"**(后台明确清空,契约 §4.1)。
|
|
809
|
+
// 写成 `server.length` 会把空数组当成"没给",于是回退**内置表** ——
|
|
810
|
+
// 本该"什么都不能做"的档位拿回内置的一整套能力(付费档尤其严重),方向正好是 fail-open。
|
|
811
|
+
if (server) return server;
|
|
783
812
|
return capabilitiesFor(this.#tier(), this.capabilityTable);
|
|
784
813
|
}
|
|
785
814
|
|
|
@@ -848,7 +877,11 @@ export class WeChatRuntime {
|
|
|
848
877
|
const isObj = Boolean(got) && typeof got === "object";
|
|
849
878
|
const plan = String((isObj ? got.plan : got) || "").trim();
|
|
850
879
|
if (!plan) return this.#tier(); // 空 = 这次没取到 → 沿用缓存(绝不降级)
|
|
851
|
-
|
|
880
|
+
// ★ 判据是「caps 是**数组**」,**空数组也算权威**(2026-09-23 修):
|
|
881
|
+
// 空数组 = 服务端明确表达"这一档什么都不能做"(后台"清空",契约 §4.1)。
|
|
882
|
+
// 若把空数组当成"没给",会沿用上次那份包(或回退内置表)——
|
|
883
|
+
// 于是"清空 pro"变成"pro 拿回内置的一整付费套能力",比意图**更多**权限(fail-open)。
|
|
884
|
+
const caps = isObj && Array.isArray(got.caps) ? got.caps : null;
|
|
852
885
|
const limits = isObj && got.limits && typeof got.limits === "object" ? got.limits : null;
|
|
853
886
|
const rev = isObj ? String(got.rev || "") : "";
|
|
854
887
|
const planChanged = plan !== this.cachedTier;
|
|
@@ -1067,6 +1100,10 @@ export class WeChatRuntime {
|
|
|
1067
1100
|
}
|
|
1068
1101
|
this.#msgQuotaBump();
|
|
1069
1102
|
await this.reply(from, `已开新任务 ${short} 并下发。跑完我会推结论给你;中途想补充直接回话即可。`);
|
|
1103
|
+
// ★ 额度消耗**之后**才可能提醒(必须排在 bump 之后:提醒要看到刚扣掉的这一条)。
|
|
1104
|
+
// 放在回执之后:提醒落在"已下发"下面,读起来是补充说明而不是打断。
|
|
1105
|
+
// fire-and-forget:提醒是锦上添花,绝不能因为它失败/变慢而影响派活这条主流程。
|
|
1106
|
+
this.#maybeRemindQuota().catch(() => {});
|
|
1070
1107
|
}
|
|
1071
1108
|
|
|
1072
1109
|
/** `/ls` —— 列出最近会话(带名称),供 /use 选择。 */
|
|
@@ -1125,7 +1162,7 @@ export class WeChatRuntime {
|
|
|
1125
1162
|
|
|
1126
1163
|
/** `/status` —— 绑定状态 + 当前会话。 */
|
|
1127
1164
|
async #cmdStatus(from) {
|
|
1128
|
-
const base = renderStatusText(this.channel.account, loadState(this.relayDir), { pending: this
|
|
1165
|
+
const base = renderStatusText(this.channel.account, loadState(this.relayDir), { pending: this.#decisionCount() });
|
|
1129
1166
|
const name = this.currentSessionTitle || "";
|
|
1130
1167
|
const short = this.currentSessionId ? String(this.currentSessionId).replace(/^session-/, "").slice(0, 8) : "";
|
|
1131
1168
|
const line = this.currentSessionId
|
|
@@ -1184,6 +1221,9 @@ export class WeChatRuntime {
|
|
|
1184
1221
|
if (r && r.ok) {
|
|
1185
1222
|
this.#msgQuotaBump(); // 只在真的派出去之后扣额度(发失败不该扣)
|
|
1186
1223
|
await this.reply(from, `已补充给「${this.currentSessionTitle || "当前任务"}」,跑完推结论给你。`);
|
|
1224
|
+
// ★ 额度消耗**之后**才可能提醒(顺序不能反:提醒要看的是扣完之后还剩几条);
|
|
1225
|
+
// fire-and-forget,提醒失败不影响"已下发"这条主流程的结果。
|
|
1226
|
+
this.#maybeRemindQuota().catch(() => {});
|
|
1187
1227
|
return;
|
|
1188
1228
|
}
|
|
1189
1229
|
await this.reply(from, `发送失败:${(r && r.message) || "未知错误"}。回 /ls 确认当前任务还在不在。`);
|
|
@@ -1211,6 +1251,26 @@ export class WeChatRuntime {
|
|
|
1211
1251
|
return;
|
|
1212
1252
|
}
|
|
1213
1253
|
|
|
1254
|
+
// ── 提醒类(额度 / 会员到期):它**没有**对应的 DSH 提问或审批,回执必须就地消化 ──
|
|
1255
|
+
// ⚠️ 这里顺手修掉一个「从来没生效过」的入口:此前提醒的「邀请好友 / 知道了」
|
|
1256
|
+
// 被当成提问派给 `subscriber.answerQuestion`,拿一个**不存在的 question id** 去答,
|
|
1257
|
+
// 必然失败 → 用户看到的是「没能替你完成这个选择」。选项文案承诺了、行为却做不到,
|
|
1258
|
+
// 正是业主红线里"话术与权限不一致"的那一类。
|
|
1259
|
+
if (pending.notice) {
|
|
1260
|
+
this.channel.registry.consume(pending.eventId);
|
|
1261
|
+
this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
|
|
1262
|
+
if (String(option.value) === "invite") {
|
|
1263
|
+
const lines = ["邀请好友:在 App 里的「邀请」入口取你的专属链接,发给他即可。"];
|
|
1264
|
+
if (this.appUrl) lines.push(this.appUrl);
|
|
1265
|
+
// 只说事实,不报具体奖励数字(那是 App/后台的口径,这里报错了就成了假承诺)
|
|
1266
|
+
lines.push("(只有邀请人得奖励,被邀请方没有奖励;奖励档位以 App 内展示为准。)");
|
|
1267
|
+
await this.reply(from, lines.join("\n"));
|
|
1268
|
+
} else {
|
|
1269
|
+
await this.reply(from, "好的。");
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1214
1274
|
// 用掉,避免同一条被回复两次
|
|
1215
1275
|
this.channel.registry.consume(pending.eventId);
|
|
1216
1276
|
this.pendingReplies = this.pendingReplies.filter((p) => p.eventId !== pending.eventId);
|
|
@@ -1411,7 +1471,9 @@ export class WeChatRuntime {
|
|
|
1411
1471
|
// 已经还有别的待拍板 → 明确说"不止这一条"。不说的话用户会以为答完就结束了,
|
|
1412
1472
|
// 后面几条就变成"没人回应"(业主报的那个 bug 的后半段现象)。
|
|
1413
1473
|
// ⚠️ 数字必须取**发送前**的快照:push 之后它会包含这一条自己。
|
|
1414
|
-
|
|
1474
|
+
// ⚠️ 只数**决策**:提醒类(额度/会员到期)不占拍板位,更不该在提醒里说"你还有 N 条待拍板"。
|
|
1475
|
+
const isNotice = NOTICE_FORMATTER_KINDS.includes(formatterKind);
|
|
1476
|
+
const othersWaiting = built.replyable && built.eventId && !isNotice ? this.#decisionCount() : 0;
|
|
1415
1477
|
const outgoing = othersWaiting > 0
|
|
1416
1478
|
? `${built.text}\n\n(你还有 ${othersWaiting + 1} 条待拍板,回完这条我会把下一条发到下面)`
|
|
1417
1479
|
: built.text;
|
|
@@ -1423,6 +1485,8 @@ export class WeChatRuntime {
|
|
|
1423
1485
|
kind: formatterKind,
|
|
1424
1486
|
answerShape: eventNode.answerShape || (formatterKind === "approval" ? "approval" : "question"),
|
|
1425
1487
|
node: eventNode,
|
|
1488
|
+
// ★ 提醒类不参与 FIFO 认领(只有一条决策都没有时才会接数字),见 NOTICE_FORMATTER_KINDS
|
|
1489
|
+
notice: isNotice,
|
|
1426
1490
|
at: this.clock()
|
|
1427
1491
|
});
|
|
1428
1492
|
// 队列上限:只保留最近若干条待答,避免无限增长
|
|
@@ -1496,6 +1560,63 @@ export class WeChatRuntime {
|
|
|
1496
1560
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1497
1561
|
}
|
|
1498
1562
|
|
|
1563
|
+
/**
|
|
1564
|
+
* 每月消息额度「快用完了」的提醒(这是**唯一的**触发点)。
|
|
1565
|
+
*
|
|
1566
|
+
* ⚠️ 这条**以前根本不存在**:`notifyQuotaLow()` 只有定义、全仓零调用方 ——
|
|
1567
|
+
* 于是用户永远是在额度**归零那一刻**才撞上冷冰冰的拒绝,事先毫无预告。
|
|
1568
|
+
* 与 `#maybeRemindExpiry()` 同一套模式(落盘去重 + 只有真发出去了才记账),
|
|
1569
|
+
* 调用点也照它那样 fire-and-forget(见 `#cmdNew` / `#sendToSession`)。
|
|
1570
|
+
*
|
|
1571
|
+
* 触发口径(两个条件取**较宽松**的那个,谁先到用谁 —— 别让用户到 0 才收到):
|
|
1572
|
+
* · 已用 ≥ 80% ;或 · 剩余 ≤ 2 条
|
|
1573
|
+
* 频控:**每个自然月最多一次**(`#monthKey()` 落盘去重)。每发一条就念一遍额度是骚扰,
|
|
1574
|
+
* 而且微信里挡住消息的正是额度本身 —— 提醒的价值在"提前预告",不是"复读"。
|
|
1575
|
+
*
|
|
1576
|
+
* ⚠️ `messages_per_month` 为 0/缺省 = **不限**(付费档就是 0):必须**直接返回**,
|
|
1577
|
+
* 否则付费用户会被按月念叨"你额度快用完了"(他没有额度这回事)。
|
|
1578
|
+
* ⚠️ 提醒本身**不消耗**消息额度:它不走 `#msgQuotaBump()`,也不经 `handleInbound`
|
|
1579
|
+
* (那是"派活"的入口)—— 发提醒绝不能让用户的余额更少。
|
|
1580
|
+
*/
|
|
1581
|
+
async #maybeRemindQuota() {
|
|
1582
|
+
if (this.stopping || !this.channel.account || !this.subscriber) return;
|
|
1583
|
+
const limit = Number((this.#limits() || {}).messages_per_month || 0);
|
|
1584
|
+
// 0 / 缺省 / 非法 = 不限 → 永不提醒(付费用户不该被念额度)
|
|
1585
|
+
if (!Number.isFinite(limit) || limit <= 0) return;
|
|
1586
|
+
const month = this.#monthKey();
|
|
1587
|
+
const used = this.msgUsage && this.msgUsage.month === month ? Number(this.msgUsage.count || 0) : 0;
|
|
1588
|
+
if (used <= 0) return; // 一条都还没发过:没有"快用完"这回事
|
|
1589
|
+
const remain = Math.max(0, limit - used);
|
|
1590
|
+
// 阈值 = min(80% 处, 剩余 2 条处)。`Math.max(1, …)` 保证不会在第 0 条就触发。
|
|
1591
|
+
const threshold = Math.min(Math.ceil(limit * 0.8), Math.max(1, limit - 2));
|
|
1592
|
+
if (used < threshold) return;
|
|
1593
|
+
const state = loadState(this.relayDir);
|
|
1594
|
+
if (state.last_quota_notice === month) return; // 这个自然月已经提醒过
|
|
1595
|
+
const r = await this.notifyQuotaLow({ state: "low", message: this.#quotaLowText(limit, used, remain) });
|
|
1596
|
+
// 只有真发出去了才记账(与简报/到期提醒同一个道理:没发出去的不能算已发)
|
|
1597
|
+
if (r && r.ok) this.channel.writeState({ last_quota_notice: month });
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* 额度提醒正文。**只讲事实 + 与该用户实际能力一致的引导**(契约 §8 红线 1:不多承诺)。
|
|
1602
|
+
*
|
|
1603
|
+
* ⚠️ 升级引导必须**看 caps**,不能背一句固定的"开通会员即可用全部功能":
|
|
1604
|
+
* 能力是后台按档位配的(契约 §2),写死的话术会承诺 `caps` 里根本没有的能力。
|
|
1605
|
+
* 这里的做法是:只有在用户**确实还不具备**开新任务能力(`assign`)时才提示"这是会员能力",
|
|
1606
|
+
* 其余情况只给 App 入口、不描述任何能力 —— 陈述少一句,总比多承诺一句强。
|
|
1607
|
+
*/
|
|
1608
|
+
#quotaLowText(limit, used, remain) {
|
|
1609
|
+
const lines = [
|
|
1610
|
+
`本月 ${limit} 条消息额度已用 ${used} 条,还剩 ${remain} 条。`,
|
|
1611
|
+
`额度按自然月计算,下个月 1 号自动归零(重新给满 ${limit} 条)。`
|
|
1612
|
+
];
|
|
1613
|
+
if (!this.#can("assign")) {
|
|
1614
|
+
lines.push("", "在微信里直接派活属于会员能力;开通后可继续使用(你能用的能力以 App 内展示为准)。");
|
|
1615
|
+
}
|
|
1616
|
+
if (this.appUrl) lines.push("", `👉 打开 App:${this.appUrl}`);
|
|
1617
|
+
return lines.join("\n");
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1499
1620
|
#bumpToday(field = "notified") {
|
|
1500
1621
|
const day = this.#today();
|
|
1501
1622
|
if (this.todayStats.day !== day) {
|
|
@@ -1553,6 +1674,15 @@ export class WeChatRuntime {
|
|
|
1553
1674
|
return this.#maybeRemindExpiry();
|
|
1554
1675
|
}
|
|
1555
1676
|
|
|
1677
|
+
/**
|
|
1678
|
+
* 供测试/运维手动触发一次"额度将尽"检查。生产路径是**派活成功之后**自动调用
|
|
1679
|
+
* (见 `#cmdNew` / `#sendToSession`);单测要确定性地验它,所以单独开一个口子 ——
|
|
1680
|
+
* 与 `runExpiryRemindNow` / `runDigestNow` 同一目的。
|
|
1681
|
+
*/
|
|
1682
|
+
async runQuotaRemindNow() {
|
|
1683
|
+
return this.#maybeRemindQuota();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1556
1686
|
/**
|
|
1557
1687
|
* 供测试/运维手动触发一次简报(绕过时刻判断)。
|
|
1558
1688
|
* 生产路径走 #maybeDigest 的定时器;单测不能等一天,所以单独开一个口子。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.10-beta.
|
|
3
|
+
"version": "0.6.10-beta.8",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -4475,7 +4475,10 @@ window.__ModuleLoader__.load({
|
|
|
4475
4475
|
// 已绑定态同样保留:它是常驻的用途说明(不是一次性引导气泡),不占用任何按钮。
|
|
4476
4476
|
var wxPlan = (wx && wx.plan) ? String(wx.plan) : "free";
|
|
4477
4477
|
var wxIsPaid = wxPlan !== "free";
|
|
4478
|
-
|
|
4478
|
+
// ★ 判据是「wx.caps 是不是数组」,**空数组也算权威**(与服务端/bridge 同一口径):
|
|
4479
|
+
// 通道下发 `caps: []` = 后台把这一档清空了(契约 §4.1)。若当成"没给"而回退本地面板镜像,
|
|
4480
|
+
// 面板就会展示出一个用户**实际没有**的能力清单 —— 展示与判定分叉,正是业主红线。
|
|
4481
|
+
var wxCaps = (wx && Array.isArray(wx.caps)) ? wx.caps : null;
|
|
4479
4482
|
var wxHas = function (cap) {
|
|
4480
4483
|
var list = wxCaps || (wxIsPaid ? WECHAT_PAID_CAPS : WECHAT_FREE_CAPS);
|
|
4481
4484
|
return list.some(function (c) { return c === cap || cap.indexOf(c + ".") === 0; });
|
|
@@ -4487,8 +4490,14 @@ window.__ModuleLoader__.load({
|
|
|
4487
4490
|
var text = row.free || row.paid;
|
|
4488
4491
|
if (!text) return;
|
|
4489
4492
|
var ok = wxHas(row.cap);
|
|
4493
|
+
// ★ 锁定的说法要看**当前档位**:
|
|
4494
|
+
// · 免费用户看到一个用不了的能力 → 「(会员)」是准确的(这正是升级理由)
|
|
4495
|
+
// · 付费用户看到一个用不了的能力 → 那是**后台把他的这项关了**,
|
|
4496
|
+
// 再写「(会员)」就是对着会员说"这是会员功能",纯属胡话。
|
|
4497
|
+
// 改成「(当前套餐不含)」,与后台配置保持一致(业主:话术与权限必须一致)。
|
|
4498
|
+
var lockNote = wxIsPaid ? "(当前套餐不含)" : "(会员)";
|
|
4490
4499
|
capRows.push(h("div", { className: "dru-wx-intro-row", key: "cap" + i },
|
|
4491
|
-
(ok ? "✅ " : "🔒 ") + text + (ok ? "" :
|
|
4500
|
+
(ok ? "✅ " : "🔒 ") + text + (ok ? "" : lockNote)));
|
|
4492
4501
|
});
|
|
4493
4502
|
} else {
|
|
4494
4503
|
WECHAT_CAP_LABELS.forEach(function (row, i) {
|
|
@@ -4496,10 +4505,24 @@ window.__ModuleLoader__.load({
|
|
|
4496
4505
|
if (text) capRows.push(h("div", { className: "dru-wx-intro-row", key: "cap" + i }, "· " + text));
|
|
4497
4506
|
});
|
|
4498
4507
|
}
|
|
4499
|
-
|
|
4508
|
+
// ★ 额度也必须是**通道实际在用的那一份**(`wx.limits`,来自服务端权益包):
|
|
4509
|
+
// 写死镜像的话,后台把免费额度从 20 改成 5,面板仍会说"20 条" ——
|
|
4510
|
+
// 用户按面板的承诺去发,第 6 条就被拦,只会认为产品坏了。
|
|
4511
|
+
// 拿不到 `wx.limits`(旧版 bridge / 状态没读到)才退回本地面板镜像。
|
|
4512
|
+
var wxLimitMsgs = (wx && wx.limits && typeof wx.limits.messages_per_month === "number")
|
|
4513
|
+
? Number(wx.limits.messages_per_month)
|
|
4514
|
+
: WECHAT_FREE_MSGS_PER_MONTH;
|
|
4515
|
+
// 不再按"是不是付费"决定显不显示:付费档也可能是有限额度(后台可配),
|
|
4516
|
+
// 一律「额度 > 0 就如实写出来,0 = 不限就不提」。
|
|
4517
|
+
// 用量同理:**没有这个字段就不说** —— 旧版 bridge 不上报用量,补一句
|
|
4518
|
+
// 「本月已用 0 条」等于替用户编了一个事实(他可能已经用掉 8 条)。
|
|
4519
|
+
var wxUsed = (wx && typeof wx.messages_used_this_month === "number")
|
|
4520
|
+
? Number(wx.messages_used_this_month)
|
|
4521
|
+
: null;
|
|
4522
|
+
var quotaRow = (wxLimitMsgs > 0)
|
|
4500
4523
|
? h("div", { className: "dru-wx-intro-row" },
|
|
4501
|
-
"·
|
|
4502
|
-
+ (
|
|
4524
|
+
"· 每月 " + wxLimitMsgs + " 条消息额度"
|
|
4525
|
+
+ (wxUsed === null ? "" : "(本月已用 " + wxUsed + " 条)"))
|
|
4503
4526
|
: null;
|
|
4504
4527
|
var intro = h("div", { className: "dru-wx-intro", role: "note", "aria-label": "微信机器人通道能做什么" },
|
|
4505
4528
|
h("div", { className: "dru-wx-intro-title" },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.10-beta.
|
|
3
|
+
"version": "0.6.10-beta.8",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -912,3 +912,91 @@ test("★★ 面板能力镜像必须与运行时判定表一致(否则面板
|
|
|
912
912
|
assert.equal(mirrorMsgs, Number(rt.WECHAT_TIER_LIMITS.free.messages_per_month),
|
|
913
913
|
"★面板展示的每月额度必须与运行时限制相同");
|
|
914
914
|
});
|
|
915
|
+
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
// 权益包(服务端下发)驱动面板文案
|
|
918
|
+
//
|
|
919
|
+
// 上面那条镜像一致性用例保证的是「冷启动兜底等于运行时内置表」;
|
|
920
|
+
// 下面这三条保证的是**服务端一旦下发了权益包,面板就以它为准** ——
|
|
921
|
+
// 这是业主「后台改权限无需发版」与「话术与权限必须一致、不多承诺」的交汇点:
|
|
922
|
+
// 后台把免费额度从 20 改成 5,面板必须当场说 5;说 20 就是让用户按 20 去发、
|
|
923
|
+
// 第 6 条被拦,然后认为产品坏了。
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
|
|
926
|
+
/** 取导览里所有能力行的文本(不含小标题),便于逐行断言。 */
|
|
927
|
+
function introRowTexts(tree) {
|
|
928
|
+
const intro = find(tree, (n) => n.props?.className === "dru-wx-intro");
|
|
929
|
+
assert.ok(intro, "微信 tab 顶部必须有导览块");
|
|
930
|
+
const rows = [];
|
|
931
|
+
walk(intro, (n) => { if (n.props?.className === "dru-wx-intro-row") rows.push(n); });
|
|
932
|
+
return { intro, texts: rows.map((r) => (r.children || []).join("")) };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** 用一份新的 /wechat/status 应答刷新面板(复用卡上的「刷新状态」按钮)。 */
|
|
936
|
+
async function withStatus(status) {
|
|
937
|
+
const plugin = loadPlugin();
|
|
938
|
+
const first = await plugin.settle();
|
|
939
|
+
plugin.setStatus(status);
|
|
940
|
+
const refresh = buttonWithText(first, "刷新状态");
|
|
941
|
+
assert.ok(refresh, "前提:未绑定卡上有「刷新状态」按钮");
|
|
942
|
+
refresh.props.onClick();
|
|
943
|
+
return plugin.settle();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
test("★ 额度以服务端权益包为准:后台改成 5,面板必须说 5(不是写死的镜像 20)", async () => {
|
|
947
|
+
const tree = await withStatus({
|
|
948
|
+
...NOT_BOUND, plan: "free",
|
|
949
|
+
caps: ["notify", "approve", "status", "stop", "assign.continue"],
|
|
950
|
+
limits: { messages_per_month: 5 },
|
|
951
|
+
messages_used_this_month: 2
|
|
952
|
+
});
|
|
953
|
+
const { intro, texts } = introRowTexts(tree);
|
|
954
|
+
assert.ok(textHas(intro, "5 条消息额度"), "面板必须显示服务端下发的 5 条");
|
|
955
|
+
assert.ok(!textHas(intro, "20 条"), "★不得再显示写死的镜像值 20(展示与判定不一致是最容易被投诉的)");
|
|
956
|
+
assert.ok(textHas(intro, "已用 2 条"), "已用条数来自同一份状态");
|
|
957
|
+
// 免费档缺的能力仍要如实标注为会员能力
|
|
958
|
+
assert.ok(texts.some((t) => t.indexOf("(会员)") > -1), "免费用户缺的能力必须标注(会员)");
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("旧版 bridge 不下发 limits:退回本地面板镜像,不能因为缺字段就不显示额度", async () => {
|
|
962
|
+
const tree = await withStatus({
|
|
963
|
+
...NOT_BOUND, plan: "free",
|
|
964
|
+
caps: ["notify", "approve", "status", "stop", "assign.continue"]
|
|
965
|
+
// 刻意不给 limits / messages_used_this_month(旧版 bridge 就是这样)
|
|
966
|
+
});
|
|
967
|
+
const { intro } = introRowTexts(tree);
|
|
968
|
+
assert.ok(textHas(intro, "20 条消息额度"), "缺 limits 时应退回镜像的 20(而不是不显示额度)");
|
|
969
|
+
assert.ok(!textHas(intro, "已用"), "没有用量字段时不该编一个「已用 0 条」出来");
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("★ 付费用户被后台关掉某项能力:要如实说「当前套餐不含」,不能对着会员说「这是会员功能」", async () => {
|
|
973
|
+
const tree = await withStatus({
|
|
974
|
+
...NOT_BOUND, plan: "pro",
|
|
975
|
+
caps: ["notify"], // 后台把 pro 的其他能力全关了
|
|
976
|
+
limits: { messages_per_month: 0 }, // 0 = 不限
|
|
977
|
+
messages_used_this_month: 0
|
|
978
|
+
});
|
|
979
|
+
const { texts } = introRowTexts(tree);
|
|
980
|
+
assert.ok(texts.some((t) => t.indexOf("当前套餐不含") > -1),
|
|
981
|
+
"付费用户的锁定项必须说明是「当前套餐不含」(后台关掉了)");
|
|
982
|
+
assert.ok(!texts.some((t) => t.indexOf("(会员)") > -1),
|
|
983
|
+
"★不得对着付费用户说「(会员)」—— 那是胡话,且与后台配置矛盾");
|
|
984
|
+
// 0 = 不限:不该提额度
|
|
985
|
+
assert.ok(!texts.some((t) => t.indexOf("条消息额度") > -1),
|
|
986
|
+
"额度 0 表示不限,不应显示成「0 条」也不该凭空提额度");
|
|
987
|
+
assert.ok(texts.some((t) => t.indexOf("✅") === 0), "仍然授权的能力要显示为可用");
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("★ 通道下发 caps: [] → 面板不得回退本地镜像(否则会展示用户实际没有的能力)", async () => {
|
|
991
|
+
// 空数组是**权威**的"这一档什么都不能做"(后台明确清空,契约 §4.1)。
|
|
992
|
+
// 判据若写成 `wx.caps.length`,这里就会回退到 WECHAT_PAID_CAPS 镜像 ——
|
|
993
|
+
// 面板给会员展示一整列 ✅,而他在微信里一条都发不出去(展示与判定分叉)。
|
|
994
|
+
const tree = await withStatus({
|
|
995
|
+
...NOT_BOUND, plan: "pro", caps: [], limits: { messages_per_month: 0 }, messages_used_this_month: 0
|
|
996
|
+
});
|
|
997
|
+
const { texts } = introRowTexts(tree);
|
|
998
|
+
assert.ok(texts.length > 0, "前提:导览有能力行");
|
|
999
|
+
assert.ok(!texts.some((t) => t.indexOf("✅") === 0),
|
|
1000
|
+
"★空 caps 时一个能力都不能显示为可用(回退镜像就会显示一排 ✅)");
|
|
1001
|
+
assert.ok(texts.some((t) => t.indexOf("🔒") === 0), "应显示为不可用");
|
|
1002
|
+
});
|