@mrrisega/dsh-remote 0.6.10-beta.2 → 0.6.10-beta.6
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 +67 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +223 -22
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +125 -0
- package/clients/dsh-remote/wechat-channel.mjs +15 -5
- package/clients/dsh-remote/wechat-runtime.mjs +203 -21
- package/dsh-setup.mjs +58 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +6 -3
- 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 +4 -0
|
@@ -1224,6 +1224,66 @@ function tagWeChat(m) {
|
|
|
1224
1224
|
return s.startsWith("[") ? s : `[wechat] ${s}`;
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* 微信通道的档位来源(免费/付费分层)。
|
|
1229
|
+
*
|
|
1230
|
+
* 取值 = 账号的**生效套餐**,服务端只有三种:free / pro / pro_max
|
|
1231
|
+
* (见企业端 auth.js 的 PLAN_PRIORITY;试用期服务端已折算成 pro,这里不必自己判断)。
|
|
1232
|
+
*
|
|
1233
|
+
* ⚠️ 取不到时返回**空串**,而不是 "free":空串让 runtime 沿用上次成功取到的档位。
|
|
1234
|
+
* 一次网络抖动就把付过钱的用户降级成免费、还提示他去升级,比多给几分钟权限糟糕得多。
|
|
1235
|
+
* ⚠️ 自建部署直接给最高档:用户跑的是自己的服务器,不存在"会员"这回事,
|
|
1236
|
+
* 不能拿 SaaS 的分层去卡自建用户。
|
|
1237
|
+
*/
|
|
1238
|
+
/**
|
|
1239
|
+
* 档位查询专用的 token 缓存(理由见 resolveWechatTier 里的注释)。
|
|
1240
|
+
* TTL 取 5 分钟:远小于 JWT 有效期,又足以把「每 10 分钟一次定时校准 + 被拒即重查」
|
|
1241
|
+
* 全部挡在网络之外 —— 不缓存的话,免费用户连着发几条消息就会触发一串 device-login。
|
|
1242
|
+
*/
|
|
1243
|
+
const WECHAT_TIER_TOKEN_TTL_MS = 5 * 60_000;
|
|
1244
|
+
let WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1245
|
+
|
|
1246
|
+
async function resolveWechatTier() {
|
|
1247
|
+
if (process.env.DSH_BRIDGE_LOCAL_KEY) return "pro_max";
|
|
1248
|
+
try {
|
|
1249
|
+
// ⚠️ `resolveToken()` **不带缓存**:账号模式下每调一次就重新 device-login 一次
|
|
1250
|
+
// (见它的第一行,只有 DSH_BRIDGE_TOKEN / 自建模式才短路)。
|
|
1251
|
+
// 而档位会被反复查询:每 10 分钟一次定时校准 + 免费用户**每次被拒**都重查(最快 5 秒一次)。
|
|
1252
|
+
// 拿它直接查 = 用户连着发几条消息就把自己打到登录限流里 —— 而 /api/device-login
|
|
1253
|
+
// 是**按出口 IP 限流**的(15 分钟 5 次),连累的不只是他自己。
|
|
1254
|
+
// 所以这里自己缓存 token。
|
|
1255
|
+
const now = Date.now();
|
|
1256
|
+
if (!WECHAT_TIER_TOKEN.value || now - WECHAT_TIER_TOKEN.at > WECHAT_TIER_TOKEN_TTL_MS) {
|
|
1257
|
+
const t = await resolveToken();
|
|
1258
|
+
if (!t) return "";
|
|
1259
|
+
WECHAT_TIER_TOKEN = { value: t, at: now };
|
|
1260
|
+
}
|
|
1261
|
+
const r = await fetch(`${API_BASE}/api/me`, {
|
|
1262
|
+
headers: { authorization: `Bearer ${WECHAT_TIER_TOKEN.value}` },
|
|
1263
|
+
signal: AbortSignal.timeout(6000)
|
|
1264
|
+
});
|
|
1265
|
+
// token 过期/被吊销 → 丢掉缓存,下一次查询重新登录(而不是一直拿坏 token 打)
|
|
1266
|
+
if (r.status === 401 || r.status === 403) {
|
|
1267
|
+
WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1268
|
+
return "";
|
|
1269
|
+
}
|
|
1270
|
+
if (!r.ok) return "";
|
|
1271
|
+
const j = await r.json().catch(() => null);
|
|
1272
|
+
return String((j && j.user && j.user.plan) || "");
|
|
1273
|
+
} catch {
|
|
1274
|
+
return "";
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* App 入口(免费用户越界时给的转化链接)。
|
|
1280
|
+
* 由账号 API 地址推导:去掉 `/relay-api` 再加 `/app/` —— 与插件半的 DEFAULT_APP_URL 同一口径
|
|
1281
|
+
* (https://host:port/relay-api → https://host:port/app/)。
|
|
1282
|
+
*/
|
|
1283
|
+
function wechatAppUrl() {
|
|
1284
|
+
return `${API_BASE.replace(/\/relay-api\/?$/, "")}/app/`;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1227
1287
|
let wechatRuntime = null;
|
|
1228
1288
|
function startWeChat() {
|
|
1229
1289
|
if (WECHAT_DISABLED) {
|
|
@@ -1238,6 +1298,9 @@ function startWeChat() {
|
|
|
1238
1298
|
upstream: UPSTREAM,
|
|
1239
1299
|
cookieOf: harnessCookieOf,
|
|
1240
1300
|
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1301
|
+
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
1302
|
+
tierProvider: resolveWechatTier,
|
|
1303
|
+
appUrl: wechatAppUrl(),
|
|
1241
1304
|
// 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
|
|
1242
1305
|
// (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
|
|
1243
1306
|
logger: {
|
|
@@ -1280,6 +1343,10 @@ async function runTunnel() {
|
|
|
1280
1343
|
console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
|
|
1281
1344
|
process.exit(1);
|
|
1282
1345
|
}
|
|
1346
|
+
// 把刚拿到的 JWT 喂给档位查询的缓存:否则 startWeChat 里的档位校准会**再登录一次**
|
|
1347
|
+
// (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
|
|
1348
|
+
// 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
|
|
1349
|
+
WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
|
|
1283
1350
|
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
|
|
1284
1351
|
persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
|
|
1285
1352
|
await initE2ee(token);
|
|
@@ -97,6 +97,12 @@ async function boundRuntime(ilink, opts = {}) {
|
|
|
97
97
|
upstream: "http://127.0.0.1:1",
|
|
98
98
|
secret: SECRET,
|
|
99
99
|
cooldownMs: opts.cooldownMs,
|
|
100
|
+
// 默认按**付费档**跑:本文件绝大多数用例测的是遥控能力(交代任务/选会话)。
|
|
101
|
+
// 免费档的门槛由「分层」那组用例专门守 —— 它们显式传 tier:"free"。
|
|
102
|
+
tier: opts.tier === undefined ? "pro" : opts.tier,
|
|
103
|
+
tierProvider: opts.tierProvider,
|
|
104
|
+
tierDenyThrottleMs: opts.tierDenyThrottleMs,
|
|
105
|
+
appUrl: opts.appUrl,
|
|
100
106
|
logger: quietLogger()
|
|
101
107
|
});
|
|
102
108
|
// 替换订阅器:本文件测编排,不测 DSH 协议(那是 dsh-events.test.mjs 的职责)
|
|
@@ -300,17 +306,24 @@ test("★ handleCommand 的字段是 replyText:回 /help 必须真的发出帮
|
|
|
300
306
|
}
|
|
301
307
|
});
|
|
302
308
|
|
|
303
|
-
test("★
|
|
309
|
+
test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按最低档", () => {
|
|
304
310
|
const { capabilitiesFor, WECHAT_CAPABILITY_TABLE } = rtmod;
|
|
305
|
-
// 免费档今天必须**包含全部**能力(业主:当前免费开放给所有人)
|
|
306
311
|
const free = capabilitiesFor("free");
|
|
307
|
-
|
|
308
|
-
|
|
312
|
+
// 免费档(业主口径):收得到通知、点得了审批、看得到状态、停得下任务 —— 但**遥控不了**。
|
|
313
|
+
for (const cap of ["notify", "approve", "status", "stop"]) {
|
|
314
|
+
assert.ok(free.includes(cap), `免费档必须有 ${cap}`);
|
|
309
315
|
}
|
|
310
|
-
|
|
316
|
+
for (const cap of ["assign", "sessions", "summary"]) {
|
|
317
|
+
assert.ok(!free.includes(cap), `免费档**不得**有 ${cap}(业主:那是付费能力)`);
|
|
318
|
+
}
|
|
319
|
+
// 付费档 = 免费档超集 + 遥控
|
|
311
320
|
const pro = capabilitiesFor("pro");
|
|
312
321
|
for (const cap of free) assert.ok(pro.includes(cap), `付费档必须是免费档超集,缺 ${cap}`);
|
|
322
|
+
for (const cap of ["assign", "sessions", "summary"]) assert.ok(pro.includes(cap), `付费档必须有 ${cap}`);
|
|
313
323
|
assert.ok(pro.length > free.length, "付费档应比免费档多能力(否则钩子没意义)");
|
|
324
|
+
// ⚠️ 服务端的最高档取值是 **pro_max** —— 漏了它 capabilitiesFor 会回退 free,
|
|
325
|
+
// 把花了最多钱的用户当成免费用户挡在门外。
|
|
326
|
+
assert.deepEqual(capabilitiesFor("pro_max"), pro, "pro_max 必须与 pro 同权(服务端 plan 取值就是 pro_max)");
|
|
314
327
|
// 未知/空档位按最低档处理 —— 宁可少给,不可误放
|
|
315
328
|
assert.deepEqual(capabilitiesFor("nonexistent"), free);
|
|
316
329
|
assert.deepEqual(capabilitiesFor(""), free);
|
|
@@ -319,30 +332,126 @@ test("★ 运营钩子:能力表是活代码 —— 每档能力可查,未知档
|
|
|
319
332
|
assert.ok(Object.isFrozen(WECHAT_CAPABILITY_TABLE));
|
|
320
333
|
});
|
|
321
334
|
|
|
322
|
-
test("★
|
|
323
|
-
//
|
|
324
|
-
//
|
|
335
|
+
test("★ 分层:免费档被挡且回复里带 App 链接;付费档放行;未知档位按免费", async () => {
|
|
336
|
+
// 这条守的是两件事:(1) 门槛是**活代码**,不是文档;(2) 免费用户越界时拿到的是
|
|
337
|
+
// 「解释 + 可点击的 App 链接」,而不是一句干巴巴的拒绝 —— 那是把用户引回主功能的入口。
|
|
338
|
+
const APP_URL = "https://app.example/x/";
|
|
339
|
+
const ilink = await fakeIlink();
|
|
340
|
+
try {
|
|
341
|
+
const send = (rt) => rt.handleInbound({
|
|
342
|
+
from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }]
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ① 免费档:必须拒绝,且回复里要有 App 链接
|
|
346
|
+
const free = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "free", appUrl: APP_URL });
|
|
347
|
+
await send(free.rt);
|
|
348
|
+
assert.equal(free.rt.subscriber.calls.filter((c) => c.m === "createSession").length, 0,
|
|
349
|
+
"免费档不得真的建会话");
|
|
350
|
+
const freeText = lastText(ilink.state);
|
|
351
|
+
assert.match(freeText, /会员功能/, `要说清为什么被拒,实际:${freeText}`);
|
|
352
|
+
assert.ok(freeText.includes(APP_URL),
|
|
353
|
+
`★回复必须带上可点击的 App 链接(业主:免费用户要去做别的,就给他访问 App 的链接),实际:${freeText}`);
|
|
354
|
+
assert.match(freeText, /\/help/, "还要告诉他现在能做什么,而不是死路一条");
|
|
355
|
+
|
|
356
|
+
// ② 付费档:同一句话必须放行
|
|
357
|
+
const pro = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro", appUrl: APP_URL });
|
|
358
|
+
await send(pro.rt);
|
|
359
|
+
assert.equal(pro.rt.subscriber.calls.filter((c) => c.m === "createSession").length, 1,
|
|
360
|
+
"付费档必须能交代任务");
|
|
361
|
+
|
|
362
|
+
// ③ 未知档位按最低档 —— 宁可少给,不可误放
|
|
363
|
+
const weird = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "谁", appUrl: APP_URL });
|
|
364
|
+
await send(weird.rt);
|
|
365
|
+
assert.equal(weird.rt.subscriber.calls.filter((c) => c.m === "createSession").length, 0,
|
|
366
|
+
"未知档位必须按免费档处理");
|
|
367
|
+
} finally {
|
|
368
|
+
await ilink.close();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("★ 分层:免费档仍能收通知、点审批、看状态、急停(不能把免费版做成废的)", async () => {
|
|
325
373
|
const ilink = await fakeIlink();
|
|
326
374
|
try {
|
|
327
375
|
const sub = makeFakeSubscriber();
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
376
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free" });
|
|
377
|
+
|
|
378
|
+
// ① 通知 + 审批一步回执 —— 免费档的核心价值,绝不能因为分层被误伤
|
|
379
|
+
await rt.notify({
|
|
380
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "evt-free", toolName: "Read",
|
|
381
|
+
reason: "读取文件", answerShape: "approval", at: 1
|
|
382
|
+
});
|
|
383
|
+
assert.ok(rt.pendingReplies.length > 0, "免费档必须收到可回执的审批通知");
|
|
384
|
+
assert.match(lastText(ilink.state), /回复 1/, "非破坏性审批必须给编号选项");
|
|
385
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
386
|
+
assert.equal(sub.calls.filter((c) => String(c.m).startsWith("answer")).length, 1,
|
|
387
|
+
"免费档必须能一步回执审批");
|
|
388
|
+
|
|
389
|
+
// ② /status 是信息类:人人可用(也是免费用户的转化入口)
|
|
390
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
391
|
+
assert.ok(lastText(ilink.state).length > 0, "/status 必须对免费档可用");
|
|
392
|
+
|
|
393
|
+
// ③ /stop 是急停阀:免费档也必须能停。
|
|
394
|
+
// 免费用户不能主动 /use 切会话,但指针会在他收到事件时自动落上(见 #rememberSession),
|
|
395
|
+
// 这里直接把它置上,模拟"本来就在跑某个任务"。
|
|
396
|
+
rt.currentSessionId = "session-running";
|
|
397
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/stop" } }] });
|
|
398
|
+
assert.equal(sub.calls.filter((c) => c.m === "cancelSession").length, 1,
|
|
399
|
+
"免费档必须能急停(一个只会通知、连停都停不了的通道会让人觉得被挟持)");
|
|
400
|
+
} finally {
|
|
401
|
+
await ilink.close();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("★ 分层:档位取不到时沿用上次已知档位(绝不因一次网络抖动把付费用户降级)", async () => {
|
|
406
|
+
const ilink = await fakeIlink();
|
|
407
|
+
try {
|
|
408
|
+
let answer = "pro";
|
|
409
|
+
const sub1 = makeFakeSubscriber();
|
|
410
|
+
const { rt } = await boundRuntime(ilink, {
|
|
411
|
+
subscriber: sub1, tier: "", tierProvider: async () => answer, tierDenyThrottleMs: 0
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// 首次判档位时缓存为空(按免费)→ 被拒路径触发刷新 → 取到 pro → **同一条指令立即放行**
|
|
415
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
416
|
+
assert.equal(sub1.calls.filter((c) => c.m === "listSessions").length, 1,
|
|
417
|
+
"首次取到 pro 后就该放行(不能因为'一开始不知道'就把付费用户挡掉)");
|
|
418
|
+
|
|
419
|
+
// 此后账号 API 一直取不到(返回空串)→ 必须**沿用 pro**。
|
|
420
|
+
// ⚠️ 必须**主动**触发一次校准:光靠派发测不到 —— 缓存已是 pro 时 `#can` 直接放行,
|
|
421
|
+
// 根本不会再走刷新那条路径(变异测试实测:不加这一步,"取不到就降级"的变异抓不到)。
|
|
422
|
+
answer = "";
|
|
423
|
+
assert.equal(await rt.refreshTierNow(), "pro",
|
|
424
|
+
"★取不到档位时必须沿用上次的 pro —— 付过钱的用户不该因一次网络失败被判成免费");
|
|
425
|
+
|
|
426
|
+
// 而且能力也真的还在(不只是内部字段好看)
|
|
427
|
+
const sub2 = makeFakeSubscriber();
|
|
428
|
+
rt.subscriber = sub2;
|
|
429
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
|
|
430
|
+
assert.equal(sub2.calls.filter((c) => c.m === "listSessions").length, 1,
|
|
431
|
+
"沿用 pro 期间,遥控能力必须仍然可用");
|
|
432
|
+
} finally {
|
|
433
|
+
await ilink.close();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("★ 分层:刚升级完立刻重试必须能进(不必重启 bridge)", async () => {
|
|
438
|
+
const ilink = await fakeIlink();
|
|
439
|
+
try {
|
|
440
|
+
// 一开始服务端说 free → 交代任务被拒;用户买完后服务端改口 pro → 同一条指令必须能进。
|
|
441
|
+
// tierDenyThrottleMs:0 把节流关掉,让这条路径确定性地可测(生产默认 5 秒,注释已写明)。
|
|
442
|
+
let plan = "free";
|
|
443
|
+
const sub = makeFakeSubscriber();
|
|
444
|
+
const { rt } = await boundRuntime(ilink, {
|
|
445
|
+
subscriber: sub, tier: "", tierProvider: async () => plan, tierDenyThrottleMs: 0
|
|
334
446
|
});
|
|
335
|
-
rt.subscriber = sub;
|
|
336
447
|
|
|
337
448
|
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
338
|
-
assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 0, "
|
|
339
|
-
assert.match(lastText(ilink.state), /当前档位/, `必须给出可照做的拒绝理由,实际:${lastText(ilink.state)}`);
|
|
449
|
+
assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 0, "免费时先拒");
|
|
340
450
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
assert.equal(rt2.subscriber.calls.filter((c) => c.m === "createSession").length, 1, "默认(免费档)今天必须能交代任务");
|
|
451
|
+
plan = "pro"; // 用户升级
|
|
452
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
|
|
453
|
+
assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 1,
|
|
454
|
+
"★升级后必须能进:否则用户付了钱还要重启 bridge 才能用");
|
|
346
455
|
} finally {
|
|
347
456
|
await ilink.close();
|
|
348
457
|
}
|
|
@@ -454,6 +563,7 @@ test("★ v2:当前会话指针在重启后恢复(否则用户下一条消息会
|
|
|
454
563
|
const sub2 = makeFakeSubscriber();
|
|
455
564
|
const rt2 = createWeChatRuntime({
|
|
456
565
|
relayDir, upstream: "http://127.0.0.1:1", secret: SECRET,
|
|
566
|
+
tier: "pro", // 本用例测的是"指针持久化",不是分层;按付费档跑才有"发消息"这条能力
|
|
457
567
|
logger: quietLogger()
|
|
458
568
|
});
|
|
459
569
|
rt2.subscriber = sub2;
|
|
@@ -484,6 +594,97 @@ test("★ v2:完成推送带会话名称 + 结论,而不只是「任务已停止
|
|
|
484
594
|
}
|
|
485
595
|
});
|
|
486
596
|
|
|
597
|
+
test("★ 分层:免费档的完成推送不能承诺「回复就能接着做」,要改成会员说明 + App 链接", async () => {
|
|
598
|
+
// 默认那句「回复这条消息就能接着这个会话往下做」对免费用户是**空头承诺**:
|
|
599
|
+
// 他回复纯文本只会拿到付费引导。文案必须跟着档位走,否则用户会以为产品坏了。
|
|
600
|
+
const APP_URL = "https://app.example/x/";
|
|
601
|
+
const ilink = await fakeIlink();
|
|
602
|
+
try {
|
|
603
|
+
const sub = makeFakeSubscriber();
|
|
604
|
+
sub.sessionsFixture = [{ sessionId: "session-s1", title: "跑个长任务", running: false, cwd: "/p" }];
|
|
605
|
+
sub.summaryFixture = "已完成。";
|
|
606
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free", appUrl: APP_URL });
|
|
607
|
+
rt.currentSessionId = "session-s1";
|
|
608
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
|
|
609
|
+
const text = lastText(ilink.state);
|
|
610
|
+
|
|
611
|
+
// 免费档照样要看得到"完成了 + 结论"(这是他的核心价值)
|
|
612
|
+
assert.match(text, /完成/, "免费档必须照样收到完成通知");
|
|
613
|
+
assert.match(text, /已完成/, "结论也要给");
|
|
614
|
+
assert.ok(!/就能接着这个会话/.test(text),
|
|
615
|
+
`★不得对免费用户承诺"回复就能接着做"(他做不到),实际:${text}`);
|
|
616
|
+
assert.ok(text.includes(APP_URL), `★收尾要给出 App 链接,实际:${text}`);
|
|
617
|
+
|
|
618
|
+
// 付费档:保留原话术(产品核心,别被这条改动误伤)
|
|
619
|
+
const ilink2 = await fakeIlink();
|
|
620
|
+
try {
|
|
621
|
+
const sub2 = makeFakeSubscriber();
|
|
622
|
+
sub2.sessionsFixture = [{ sessionId: "session-s1", title: "跑个长任务", running: false, cwd: "/p" }];
|
|
623
|
+
sub2.summaryFixture = "已完成。";
|
|
624
|
+
const { rt: rt2 } = await boundRuntime(ilink2, { subscriber: sub2, tier: "pro", appUrl: APP_URL });
|
|
625
|
+
rt2.currentSessionId = "session-s1";
|
|
626
|
+
await rt2.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
|
|
627
|
+
assert.match(lastText(ilink2.state), /就能接着这个会话/, "付费档必须保留「回复即续接」这句");
|
|
628
|
+
} finally {
|
|
629
|
+
await ilink2.close();
|
|
630
|
+
}
|
|
631
|
+
} finally {
|
|
632
|
+
await ilink.close();
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("★ 分层:/help 必须按档位如实分层(免费用户看到的清单里不能有他做不到的指令)", async () => {
|
|
637
|
+
const APP_URL = "https://app.example/x/";
|
|
638
|
+
const ilink = await fakeIlink();
|
|
639
|
+
try {
|
|
640
|
+
const free = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "free", appUrl: APP_URL });
|
|
641
|
+
await free.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
|
|
642
|
+
const t = lastText(ilink.state);
|
|
643
|
+
assert.match(t, /通知版/, "免费档的帮助要说明自己是通知版");
|
|
644
|
+
assert.ok(t.includes(APP_URL), "免费档的帮助里必须带开通入口(App 链接)");
|
|
645
|
+
assert.match(t, /会员功能/, "会员能力必须单独分组,不能混进「现在能用的」");
|
|
646
|
+
// /new 只能出现在「会员功能」那一段之后 —— 混在前面就是骗人
|
|
647
|
+
const memberAt = t.indexOf("会员功能");
|
|
648
|
+
assert.ok(memberAt > -1 && t.indexOf("/new") > memberAt, "/new 不得出现在免费能力段里");
|
|
649
|
+
|
|
650
|
+
const pro = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro", appUrl: APP_URL });
|
|
651
|
+
await pro.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
|
|
652
|
+
const pt = lastText(ilink.state);
|
|
653
|
+
assert.match(pt, /DSH 微信通道/, "付费档仍用完整帮助");
|
|
654
|
+
assert.ok(!/通知版/.test(pt), "付费档不该被标成通知版");
|
|
655
|
+
} finally {
|
|
656
|
+
await ilink.close();
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("★ 分层:免费用户打字「允许」(而不是回数字)时,要先把「回数字就能拍板」说在前面", async () => {
|
|
661
|
+
// 回执只认纯数字(classifyInbound),而免费档的核心价值就是审批 ——
|
|
662
|
+
// 用户很可能打字「允许」。这时只回一句付费提示,他会以为免费版什么都干不了。
|
|
663
|
+
const ilink = await fakeIlink();
|
|
664
|
+
try {
|
|
665
|
+
const sub = makeFakeSubscriber();
|
|
666
|
+
const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free", appUrl: "https://app.example/x/" });
|
|
667
|
+
await rt.notify({
|
|
668
|
+
kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "evt-1", toolName: "Read",
|
|
669
|
+
reason: "读取文件", answerShape: "approval", at: 1
|
|
670
|
+
});
|
|
671
|
+
assert.ok(rt.pendingReplies.length > 0, "先得有一条待回执的消息");
|
|
672
|
+
|
|
673
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "允许" } }] });
|
|
674
|
+
const text = lastText(ilink.state);
|
|
675
|
+
assert.match(text, /回一个数字/, `必须先告诉他怎么拍板,实际:${text}`);
|
|
676
|
+
assert.match(text, /待你拍板/, "要说明确实有东西在等他决定");
|
|
677
|
+
assert.ok(text.includes("https://app.example/x/"), "仍然要带上 App 链接");
|
|
678
|
+
|
|
679
|
+
// 而回数字本身必须真的能拍板(免费档的核心价值不能被这条改动带坏)
|
|
680
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
|
|
681
|
+
assert.equal(sub.calls.filter((c) => String(c.m).startsWith("answer")).length, 1,
|
|
682
|
+
"回数字必须照常完成审批");
|
|
683
|
+
} finally {
|
|
684
|
+
await ilink.close();
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
487
688
|
test("★ v2 安全:破坏性审批不给回执选项,回数字也不产生任何回执", async () => {
|
|
488
689
|
const ilink = await fakeIlink();
|
|
489
690
|
try {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信通道「档位来源」回归(分层功能的接线段)。
|
|
3
|
+
*
|
|
4
|
+
* 分层本身在 wechat-runtime 里可测;这里守的是**桥接侧喂给它的那个值对不对**:
|
|
5
|
+
* · 自建部署必须是最高档 —— 用户跑的是自己的服务器,不该被 SaaS 的会员分层卡住;
|
|
6
|
+
* · SaaS 走账号的**生效套餐**(free / pro / pro_max,试用期服务端已折算成 pro);
|
|
7
|
+
* · 取不到时必须返回**空串**(让 runtime 沿用上次的档位),绝不能返回 "free" ——
|
|
8
|
+
* 一次网络抖动就把付过钱的用户降级、还提示他去升级,比多给几分钟权限糟糕得多;
|
|
9
|
+
* · App 链接要从账号 API 地址推导出来(免费用户越界时的转化入口)。
|
|
10
|
+
*
|
|
11
|
+
* 这些分支在测试机上跑不到(要真账号 + 真网络),所以把函数从源码抠出来,
|
|
12
|
+
* 注入假 process / resolveToken / fetch,把每条路真的执行一遍。
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const BRIDGE_SRC = readFileSync(path.join(HERE, "..", "dsh-bridge.mjs"), "utf8");
|
|
22
|
+
const API_BASE = "https://api.example/relay-api";
|
|
23
|
+
|
|
24
|
+
function loadTierResolver({ localKey = "", token = "tok-1", status = 200, body = { user: { plan: "pro" } }, throws = false } = {}) {
|
|
25
|
+
// 切片必须**带上 token 缓存那两个声明**(它们在函数之外,漏了就 ReferenceError →
|
|
26
|
+
// 被函数内的 try/catch 吞成空串,测试会假绿)。
|
|
27
|
+
const from = BRIDGE_SRC.indexOf("const WECHAT_TIER_TOKEN_TTL_MS");
|
|
28
|
+
const to = BRIDGE_SRC.indexOf("function wechatAppUrl()");
|
|
29
|
+
assert.ok(from > -1 && to > from, "未能从 dsh-bridge.mjs 切出 resolveWechatTier(锚点已漂移)");
|
|
30
|
+
assert.ok(/async function resolveWechatTier\(\)/.test(BRIDGE_SRC.slice(from, to)),
|
|
31
|
+
"切片必须同时包含 token 缓存声明与 resolveWechatTier");
|
|
32
|
+
const calls = [];
|
|
33
|
+
let tokenCalls = 0;
|
|
34
|
+
const fakeFetch = async (url, init) => {
|
|
35
|
+
calls.push({ url, init });
|
|
36
|
+
if (throws) throw new Error("network down");
|
|
37
|
+
return { ok: status >= 200 && status < 300, status, json: async () => body };
|
|
38
|
+
};
|
|
39
|
+
const make = new Function("process", "resolveToken", "API_BASE", "fetch", "AbortSignal",
|
|
40
|
+
`${BRIDGE_SRC.slice(from, to)}\nreturn resolveWechatTier;`);
|
|
41
|
+
const env = localKey ? { DSH_BRIDGE_LOCAL_KEY: localKey } : {};
|
|
42
|
+
const fn = make({ env }, async () => { tokenCalls += 1; return token; }, API_BASE, fakeFetch, AbortSignal);
|
|
43
|
+
return { fn, calls, tokenCalls: () => tokenCalls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadAppUrl(apiBase) {
|
|
47
|
+
const from = BRIDGE_SRC.indexOf("function wechatAppUrl()");
|
|
48
|
+
const to = BRIDGE_SRC.indexOf("let wechatRuntime = null;");
|
|
49
|
+
assert.ok(from > -1 && to > from, "未能从 dsh-bridge.mjs 切出 wechatAppUrl(锚点已漂移)");
|
|
50
|
+
const make = new Function("API_BASE", `${BRIDGE_SRC.slice(from, to)}\nreturn wechatAppUrl;`);
|
|
51
|
+
return make(apiBase);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("档位来源:SaaS 用账号的生效套餐,并带上 Bearer token 打 /api/me", async () => {
|
|
55
|
+
for (const plan of ["free", "pro", "pro_max"]) {
|
|
56
|
+
const { fn, calls } = loadTierResolver({ body: { user: { plan } } });
|
|
57
|
+
assert.equal(await fn(), plan, `必须如实透传服务端的生效套餐 ${plan}`);
|
|
58
|
+
assert.equal(calls.length, 1, "只应请求一次");
|
|
59
|
+
assert.equal(calls[0].url, `${API_BASE}/api/me`, "应打账号 API 的 /api/me");
|
|
60
|
+
assert.equal(calls[0].init.headers.authorization, "Bearer tok-1", "必须带 token");
|
|
61
|
+
assert.ok(calls[0].init.signal, "必须有超时信号(不能把档位查询挂死)");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("★档位来源:自建部署直接给最高档,且**不去打账号 API**", async () => {
|
|
66
|
+
// 自建用户跑的是自己的服务器,不存在"会员"这回事。
|
|
67
|
+
// 而且必须**一次网络请求都不发** —— 自建环境常常连不上(也不该连)账号 API。
|
|
68
|
+
const { fn, calls } = loadTierResolver({ localKey: "local-key-abc" });
|
|
69
|
+
assert.equal(await fn(), "pro_max", "自建部署必须是最高档");
|
|
70
|
+
assert.equal(calls.length, 0, "★自建模式不得请求账号 API");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("★档位来源:取不到时返回空串(让上层沿用旧值),绝不返回 free 把付费用户降级", async () => {
|
|
74
|
+
// 空串 = "这次没取到",runtime 据此保留上次的档位;返回 "free" 则会把人降级。
|
|
75
|
+
const http = loadTierResolver({ status: 503, body: {} });
|
|
76
|
+
assert.equal(await http.fn(), "", "HTTP 失败 → 空串");
|
|
77
|
+
const boom = loadTierResolver({ throws: true });
|
|
78
|
+
assert.equal(await boom.fn(), "", "网络异常必须被吞掉并返回空串(档位不是关键路径,不能拖垮 bridge)");
|
|
79
|
+
const noToken = loadTierResolver({ token: "" });
|
|
80
|
+
assert.equal(await noToken.fn(), "", "拿不到 token → 空串");
|
|
81
|
+
assert.equal(noToken.calls.length, 0, "没 token 就不该白跑一次请求");
|
|
82
|
+
const weird = loadTierResolver({ body: { user: {} } });
|
|
83
|
+
assert.equal(await weird.fn(), "", "服务端没给 plan → 空串(不是猜一个 free)");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("★档位来源:token 必须自己缓存(否则免费用户连点几下就把自己打到登录限流)", async () => {
|
|
87
|
+
// `resolveToken()` 在账号模式下**每调一次就重新 device-login 一次**,而 /api/device-login
|
|
88
|
+
// 是按**出口 IP** 限流的(15 分钟 5 次)。档位会被反复查询(定时校准 + 免费用户每次被拒都重查),
|
|
89
|
+
// 不缓存就是拿用户的登录额度去换一个只读字段。
|
|
90
|
+
const { fn, calls, tokenCalls } = loadTierResolver({ body: { user: { plan: "free" } } });
|
|
91
|
+
await fn();
|
|
92
|
+
await fn();
|
|
93
|
+
await fn();
|
|
94
|
+
assert.equal(tokenCalls(), 1, "★多次查档位只应登录一次");
|
|
95
|
+
assert.equal(calls.length, 3, "但 /api/me 每次都要查(档位本身要新鲜)");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("★档位来源:token 失效(401/403)时丢掉缓存,下次重新登录而不是一直用坏 token", async () => {
|
|
99
|
+
const bad = loadTierResolver({ status: 401, body: {} });
|
|
100
|
+
assert.equal(await bad.fn(), "", "401 → 空串(沿用上层旧档位,不降级)");
|
|
101
|
+
assert.equal(await bad.fn(), "", "仍然空串");
|
|
102
|
+
assert.equal(bad.tokenCalls(), 2, "★401 之后必须重新登录一次(缓存已失效,不能一直拿坏 token 打)");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("★档位来源:启动时必须复用桥接刚拿到的 JWT(否则每次启动多花一份登录额度)", () => {
|
|
106
|
+
// 真机日志实测:同一个桥接启动里出现两条并发的 device-login —— 一条是隧道登录,
|
|
107
|
+
// 另一条是档位校准。而 device-login 按出口 IP 限流,每次启动多花一份额度没有道理。
|
|
108
|
+
const i = BRIDGE_SRC.indexOf("async function runTunnel()");
|
|
109
|
+
assert.ok(i > -1, "应能找到 runTunnel");
|
|
110
|
+
const body = BRIDGE_SRC.slice(i, BRIDGE_SRC.indexOf("async function main()"));
|
|
111
|
+
assert.ok(body.length > 0, "切片边界应正确");
|
|
112
|
+
const seedAt = body.indexOf("WECHAT_TIER_TOKEN = { value: token");
|
|
113
|
+
const startAt = body.indexOf("startWeChat()");
|
|
114
|
+
assert.ok(seedAt > -1, "runTunnel 必须把刚拿到的 token 喂给档位缓存");
|
|
115
|
+
assert.ok(startAt > -1, "runTunnel 应调用 startWeChat()");
|
|
116
|
+
assert.ok(seedAt < startAt, "★必须在 startWeChat() **之前**喂,否则档位校准仍会再登录一次");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL 同口径", () => {
|
|
120
|
+
assert.equal(loadAppUrl(API_BASE)(), "https://api.example/app/");
|
|
121
|
+
assert.equal(loadAppUrl("https://n.risegao.cn:13443/relay-api")(), "https://n.risegao.cn:13443/app/",
|
|
122
|
+
"生产地址必须推导出真实可点的 App 入口");
|
|
123
|
+
assert.equal(loadAppUrl("https://api.example/relay-api/")(), "https://api.example/app/",
|
|
124
|
+
"结尾斜杠也要正确吃掉");
|
|
125
|
+
});
|
|
@@ -1947,7 +1947,9 @@ const COMPLETION_REASON_NOTES = Object.freeze({
|
|
|
1947
1947
|
*
|
|
1948
1948
|
* @param {object} args { title(会话名), reason(completed|aborted|blocked|error|max-tokens|interrupted),
|
|
1949
1949
|
* summary(结论), sessionId, hanging(结论取不到时 true) }
|
|
1950
|
-
* @param {object} [opts] { summaryMaxLength=400, maxLength=900, ttlMinutes }
|
|
1950
|
+
* @param {object} [opts] { summaryMaxLength=400, maxLength=900, ttlMinutes, continuationHint }
|
|
1951
|
+
* `continuationHint` 非空 = 用**调用方给的**收尾话术替换默认的「回复就能接着做」,
|
|
1952
|
+
* 且不再在结论缺失时邀请用户回复(免费档做不到,不能对他下这种指令)。
|
|
1951
1953
|
* @returns {{kind:'completed', title:string, text:string, replyable:false, ttlMinutes:number}}
|
|
1952
1954
|
* title 是**展示标题**(和 formatNotification 的 title 同义,【】里那一行);会话名在 text 里。
|
|
1953
1955
|
*/
|
|
@@ -1977,16 +1979,24 @@ export function formatCompletion({ title, reason, summary, sessionId, hanging }
|
|
|
1977
1979
|
}
|
|
1978
1980
|
|
|
1979
1981
|
// ③ 结论
|
|
1982
|
+
// 只有「能回话」的档位才邀请用户回复 —— 否则那是对一个做不到的人下指令
|
|
1983
|
+
// (免费用户回复纯文本只会拿到付费引导,见 wechat-runtime 的 #upsellText)。
|
|
1984
|
+
const canContinue = opts.continuationHint === undefined;
|
|
1980
1985
|
if (body) {
|
|
1981
1986
|
lines.push(`结论:${shorten(body, summaryMax)}`);
|
|
1982
1987
|
} else if (hanging) {
|
|
1983
|
-
lines.push(
|
|
1988
|
+
lines.push(canContinue
|
|
1989
|
+
? "结论:暂时没取到(任务可能还在收尾)。回复 /summary 可以再要一次。"
|
|
1990
|
+
: "结论:暂时没取到(任务可能还在收尾)。");
|
|
1984
1991
|
} else {
|
|
1985
|
-
lines.push(
|
|
1992
|
+
lines.push(canContinue
|
|
1993
|
+
? "结论:这次没有产出结论。回复一句话就能追问。"
|
|
1994
|
+
: "结论:这次没有产出结论。");
|
|
1986
1995
|
}
|
|
1987
1996
|
|
|
1988
|
-
// ④ 收尾:回复即续接同一会话(
|
|
1989
|
-
|
|
1997
|
+
// ④ 收尾:回复即续接同一会话(付费档的产品核心,别删这句)。
|
|
1998
|
+
// 免费档由调用方传 `continuationHint` 换成"会员可用 + App 链接"的说法。
|
|
1999
|
+
lines.push(opts.continuationHint || "回复这条消息(直接说下一步)就能接着这个会话往下做,不用重新交代背景。");
|
|
1990
2000
|
|
|
1991
2001
|
// 纯文本 + 长度上限:微信不是富客户端,超长整条截断并显式标注
|
|
1992
2002
|
let text = lines.join("\n");
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
extractInboundText,
|
|
41
41
|
extractFromUserId,
|
|
42
42
|
handleCommand,
|
|
43
|
+
HELP_TEXT,
|
|
43
44
|
classifyInbound,
|
|
44
45
|
formatCompletion,
|
|
45
46
|
isDestructiveTool,
|
|
@@ -62,21 +63,29 @@ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
|
62
63
|
*/
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* v2 能力表(运营钩子)
|
|
66
|
+
* v2 能力表(运营钩子) —— 业主 2026-09-22 确认的分层:
|
|
66
67
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* · **免费档 = 只读 + 审批**:收得到任务通知(含完成小结、报错、额度、会员到期),
|
|
69
|
+
* 点得了审批回执(选择下一步执行)。但**遥控不了**。
|
|
70
|
+
* · **付费档 = 免费档 + 遥控**:在微信里直接交代任务、切换会话(以及预留的中途纠偏/附件/多会话)。
|
|
70
71
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
72
|
+
* 免费用户尝试付费能力时,回复里带上 App 链接 —— 这既是解释,也是转化入口
|
|
73
|
+
* (见 `#upsellText()`:业主口径是"如果免费用户要去做其他的东西,就在消息后面跟一个访问 App 的链接")。
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ `pro_max` **必须存在**:服务端的生效套餐取值就是 free / pro / pro_max(见企业端 auth.js 的
|
|
76
|
+
* PLAN_PRIORITY),而 `capabilitiesFor` 对**未知档位回退 free** —— 漏了 pro_max 就会把
|
|
77
|
+
* 最高的付费档用户当成免费用户挡在门外。
|
|
78
|
+
* ⚠️ 改这张表 = 改产品权限,别顺手加能力;`free` 那行是"用户没付钱时他能做什么"的唯一定义。
|
|
75
79
|
*/
|
|
80
|
+
const PAID_CAPABILITIES = Object.freeze([
|
|
81
|
+
"notify", "approve", "status", "stop",
|
|
82
|
+
"assign", "sessions", "summary",
|
|
83
|
+
"steer", "attach", "multi" // 预留:中途纠偏 / 附件 / 多会话并行
|
|
84
|
+
]);
|
|
76
85
|
export const WECHAT_CAPABILITY_TABLE = Object.freeze({
|
|
77
|
-
free: Object.freeze(["notify", "approve", "
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
free: Object.freeze(["notify", "approve", "status", "stop"]),
|
|
87
|
+
pro: PAID_CAPABILITIES,
|
|
88
|
+
pro_max: PAID_CAPABILITIES
|
|
80
89
|
});
|
|
81
90
|
|
|
82
91
|
/** 某档位具备哪些能力。未知档位按最低档(free)处理 —— 宁可少给,不可误放。 */
|
|
@@ -85,6 +94,22 @@ export function capabilitiesFor(tier, table = WECHAT_CAPABILITY_TABLE) {
|
|
|
85
94
|
return table[key];
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 指令 → 所需能力。**没列出的指令不设门槛**(`/help`、`/status`、`/unbind`、`/quiet` 人人可用)。
|
|
99
|
+
*
|
|
100
|
+
* 为什么 `/unbind` 绝不设门槛:免费用户也必须能解绑 —— 否则他被绑上了却退不掉,
|
|
101
|
+
* 这既是骚扰也是合规问题。
|
|
102
|
+
* 为什么 `/stop` 放进免费档:免费用户已经能通过审批「选择下一步执行」,再给一个急停阀是同一件事;
|
|
103
|
+
* 一个只会发通知、却连"停下"都做不到的通道,用户会觉得被挟持。
|
|
104
|
+
*/
|
|
105
|
+
const COMMAND_CAPABILITY = Object.freeze({
|
|
106
|
+
"/new": "assign",
|
|
107
|
+
"/ls": "sessions",
|
|
108
|
+
"/use": "sessions",
|
|
109
|
+
"/summary": "summary",
|
|
110
|
+
"/stop": "stop"
|
|
111
|
+
});
|
|
112
|
+
|
|
88
113
|
/**
|
|
89
114
|
* ★ 两个上游模块的**节点词表不一致**,这里是唯一的翻译点。
|
|
90
115
|
*
|
|
@@ -176,6 +201,11 @@ export const CONTROL_HEADER = "x-dsh-bridge-secret";
|
|
|
176
201
|
export const DEFAULT_BIND_TTL_MS = 5 * 60_000;
|
|
177
202
|
/** 默认每日简报时刻(本地时区小时,0-23)。 */
|
|
178
203
|
export const DEFAULT_DIGEST_HOUR = 9;
|
|
204
|
+
/**
|
|
205
|
+
* 档位校准间隔。10 分钟足够跟上升级/到期(派发被拒时还会立刻再确认一次),
|
|
206
|
+
* 又不至于把账号 API 当心跳打。
|
|
207
|
+
*/
|
|
208
|
+
export const WECHAT_TIER_REFRESH_MS = 10 * 60_000;
|
|
179
209
|
|
|
180
210
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
181
211
|
|
|
@@ -302,11 +332,31 @@ export class WeChatRuntime {
|
|
|
302
332
|
/** `/ls` 的结果缓存(1-based 序号供 /use 使用)。 */
|
|
303
333
|
this.sessionIndex = [];
|
|
304
334
|
/**
|
|
305
|
-
*
|
|
306
|
-
* `tierOverride` 空 =
|
|
335
|
+
* 运营钩子的可注入点(生产由 bridge 注入真实档位;测试可覆盖)。
|
|
336
|
+
* `tierOverride` 空 = 用缓存/默认;`capabilityTable` 空 = 用内置表。
|
|
307
337
|
*/
|
|
308
338
|
this.tierOverride = typeof opts.tier === "string" ? opts.tier : "";
|
|
309
339
|
this.capabilityTable = opts.capabilityTable || WECHAT_CAPABILITY_TABLE;
|
|
340
|
+
/**
|
|
341
|
+
* 真实档位来源(async→ "free"|"pro"|"pro_max";空串 = 这次没取到)。
|
|
342
|
+
* bridge 用账号 API 的生效套餐实现它;不注入时档位恒为缓存/默认(测试与自建)。
|
|
343
|
+
*/
|
|
344
|
+
this.tierProvider = typeof opts.tierProvider === "function" ? opts.tierProvider : null;
|
|
345
|
+
/** App 入口(免费用户越界时给的转化链接)。空则不附链接,不编一个假地址。 */
|
|
346
|
+
this.appUrl = String(opts.appUrl || "").trim();
|
|
347
|
+
/**
|
|
348
|
+
* ★ 档位**持久化缓存**:进程重启后不会因为一次网络失败就把付费用户降级成免费。
|
|
349
|
+
* 首次运行且从未取到过 = 空 → 按 free(宁可少给,不可误放)。
|
|
350
|
+
*/
|
|
351
|
+
this.cachedTier = String(persisted.tier || "").trim();
|
|
352
|
+
this.tierCheckedAt = 0;
|
|
353
|
+
this.tierTimer = null;
|
|
354
|
+
/**
|
|
355
|
+
* 被拒时重查档位的节流窗口。用户刚升级完重试要能进,但免费用户反复发消息
|
|
356
|
+
* 也不能变成"每条消息打一次账号 API",所以取 5 秒(最坏情况等 5 秒就能进)。
|
|
357
|
+
* 可注入,便于测试把这条路径跑成确定性的。
|
|
358
|
+
*/
|
|
359
|
+
this.tierDenyThrottleMs = Number.isFinite(opts.tierDenyThrottleMs) ? opts.tierDenyThrottleMs : 5_000;
|
|
310
360
|
}
|
|
311
361
|
|
|
312
362
|
// ── 状态 ────────────────────────────────────────────────────────────────
|
|
@@ -456,6 +506,13 @@ export class WeChatRuntime {
|
|
|
456
506
|
if (this.channelTask || !this.channel.account) return false;
|
|
457
507
|
this.stopping = false;
|
|
458
508
|
this.channel.writeState({ connected_at: this.clock(), last_error: "" });
|
|
509
|
+
// 档位:启动时取一次(不等它,别拖慢上线),之后每 10 分钟校准一次。
|
|
510
|
+
// 用户升级后不必重启 bridge —— 下一次派发时的 #canNow 还会立刻再确认一遍。
|
|
511
|
+
void this.#refreshTier(0);
|
|
512
|
+
if (!this.tierTimer) {
|
|
513
|
+
this.tierTimer = setInterval(() => { void this.#refreshTier(0); }, WECHAT_TIER_REFRESH_MS);
|
|
514
|
+
if (typeof this.tierTimer.unref === "function") this.tierTimer.unref();
|
|
515
|
+
}
|
|
459
516
|
this.#startSubscriber();
|
|
460
517
|
this.channelTask = this.#channelLoop().catch((e) => {
|
|
461
518
|
this.logger.warn(`[wechat] 长轮询退出:${redact(String(e && e.message ? e.message : e))}`);
|
|
@@ -466,6 +523,10 @@ export class WeChatRuntime {
|
|
|
466
523
|
|
|
467
524
|
async stopChannel() {
|
|
468
525
|
this.stopping = true;
|
|
526
|
+
if (this.tierTimer) {
|
|
527
|
+
clearInterval(this.tierTimer);
|
|
528
|
+
this.tierTimer = null;
|
|
529
|
+
}
|
|
469
530
|
if (this.subscriber) {
|
|
470
531
|
try { this.subscriber.close(); } catch { /* 忽略 */ }
|
|
471
532
|
this.subscriber = null;
|
|
@@ -572,17 +633,29 @@ export class WeChatRuntime {
|
|
|
572
633
|
return;
|
|
573
634
|
}
|
|
574
635
|
if (cls.kind === "message") {
|
|
636
|
+
// 纯文本 = 「在微信里交代任务」→ 付费能力。免费用户拿到解释 + App 链接(转化入口)。
|
|
637
|
+
if (!(await this.#canNow("assign"))) {
|
|
638
|
+
// ⚠️ 陷阱:回执只认**纯数字**(见 classifyInbound)。免费档的核心价值恰恰是审批,
|
|
639
|
+
// 用户很可能打字「允许」而不是回「1」—— 若只回一句付费提示,他会以为免费版什么都干不了。
|
|
640
|
+
// 所以手上有待回执的消息时,先把"回数字就能拍板"说在前面。
|
|
641
|
+
const head = this.pendingReplies.length > 0
|
|
642
|
+
? `你还有 ${this.pendingReplies.length} 条待你拍板的消息 —— 直接回一个数字(如 1)就能完成决定,不用打字。\n\n`
|
|
643
|
+
: "";
|
|
644
|
+
await this.reply(from, head + this.#upsellText());
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
575
647
|
await this.#sendToSession(from, cls.text);
|
|
576
648
|
return;
|
|
577
649
|
}
|
|
578
650
|
}
|
|
579
651
|
|
|
580
652
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
653
|
+
* 当前账号档位。可用 `opts.tier` 覆盖(测试注入);否则用**上次成功取到的档位**。
|
|
654
|
+
* 从没取到过 = 空 → 按 free(宁可少给,不可误放)。
|
|
583
655
|
*/
|
|
584
656
|
#tier() {
|
|
585
|
-
|
|
657
|
+
if (this.tierOverride) return this.tierOverride;
|
|
658
|
+
return this.cachedTier || "free";
|
|
586
659
|
}
|
|
587
660
|
|
|
588
661
|
/** 当前档位是否具备某能力(运营钩子的唯一判断入口)。 */
|
|
@@ -590,15 +663,120 @@ export class WeChatRuntime {
|
|
|
590
663
|
return capabilitiesFor(this.#tier(), this.capabilityTable).includes(cap);
|
|
591
664
|
}
|
|
592
665
|
|
|
666
|
+
/**
|
|
667
|
+
* 刷新档位(带节流)。**取不到时保留上次已知档位** —— 网络抖一下就把付费用户降级成免费,
|
|
668
|
+
* 他会看到"升级后即可使用"而自己明明付过钱,这比多给几分钟权限糟糕得多。
|
|
669
|
+
* @returns {Promise<string>} 刷新后的档位
|
|
670
|
+
*/
|
|
671
|
+
async #refreshTier(minIntervalMs = 30_000) {
|
|
672
|
+
if (!this.tierProvider) return this.#tier();
|
|
673
|
+
const now = Date.now();
|
|
674
|
+
if (now - this.tierCheckedAt < minIntervalMs) return this.#tier();
|
|
675
|
+
this.tierCheckedAt = now;
|
|
676
|
+
let next = "";
|
|
677
|
+
try {
|
|
678
|
+
next = String((await this.tierProvider()) || "").trim();
|
|
679
|
+
} catch (e) {
|
|
680
|
+
this.logger.warn(`[wechat] 读取账号档位失败(沿用上次):${redact(String(e && e.message ? e.message : e))}`);
|
|
681
|
+
return this.#tier();
|
|
682
|
+
}
|
|
683
|
+
if (!next) return this.#tier(); // 空串 = 这次没取到 → 沿用缓存
|
|
684
|
+
if (next !== this.cachedTier) {
|
|
685
|
+
this.cachedTier = next;
|
|
686
|
+
this.channel.writeState({ tier: next });
|
|
687
|
+
this.logger.info(`[wechat] 账号档位:${next}`);
|
|
688
|
+
}
|
|
689
|
+
return this.#tier();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* 判能力时先**确保档位是新鲜的**:用户刚买完重试必须能进(最坏等 `tierDenyThrottleMs`,
|
|
694
|
+
* 默认 5 秒),而不是等到下次重启 bridge 才发现"我付了钱还是不能用"。
|
|
695
|
+
*/
|
|
696
|
+
async #canNow(cap) {
|
|
697
|
+
if (this.#can(cap)) return true;
|
|
698
|
+
await this.#refreshTier(this.tierDenyThrottleMs);
|
|
699
|
+
return this.#can(cap);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 立刻校准一次档位(不走节流)。
|
|
704
|
+
*
|
|
705
|
+
* 存在的理由:这是唯一能**主动**触发校准的入口 —— 其余触发点都被"当前档位够用就不查"
|
|
706
|
+
* 拦在前面(`#canNow` 只在被拒时才查),于是一条关键不变量没法被测到:
|
|
707
|
+
* 「provider 返回空串时必须沿用上次档位,而不是降级」。付费用户的钱包就挂在这条上。
|
|
708
|
+
* 将来面板做"我刚买完,立刻校准"也走这里。
|
|
709
|
+
*/
|
|
710
|
+
async refreshTierNow() {
|
|
711
|
+
return this.#refreshTier(0);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* 帮助文案。**必须按档位如实分层**。
|
|
716
|
+
*
|
|
717
|
+
* 为什么不能直接用上游那个静态 HELP_TEXT:它把 `/new`、`/ls`、`/use` 与"直接发一句话"
|
|
718
|
+
* 都列成"你能用的",而 `#upsellText()` 正是让免费用户"回复 /help 看现在能做什么" ——
|
|
719
|
+
* 那等于把人骗到一个他做不到的清单上,然后每次尝试都吃一次拒绝。
|
|
720
|
+
*/
|
|
721
|
+
#helpText() {
|
|
722
|
+
if (this.#can("assign")) return HELP_TEXT; // 付费档:完整清单
|
|
723
|
+
const lines = [
|
|
724
|
+
"【DSH 微信通道 · 通知版】",
|
|
725
|
+
"现在能用的:",
|
|
726
|
+
"· 回数字(如 1)回执最近一条需要你拍板的消息",
|
|
727
|
+
"· /stop 中断当前会话正在跑的回合",
|
|
728
|
+
"· /status 查看绑定与推送状态",
|
|
729
|
+
"· /quiet 暂停推送(回复任意消息恢复)",
|
|
730
|
+
"· /unbind 解除微信绑定",
|
|
731
|
+
"",
|
|
732
|
+
"会员功能(开通后可用):",
|
|
733
|
+
"· 直接发一句话 = 给当前任务派活",
|
|
734
|
+
"· /new 开新任务、/ls 列出会话、/use <编号> 切换会话、/summary 重发最近结论"
|
|
735
|
+
];
|
|
736
|
+
if (this.appUrl) lines.push("", `👉 开通并查看设备列表:${this.appUrl}`);
|
|
737
|
+
return lines.join("\n");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* 免费用户越界时的回复:说清「通知版能做什么」+ 给出可点击的 App 链接。
|
|
742
|
+
*
|
|
743
|
+
* 业主口径:免费用户要去做别的事情时,就在消息后面跟一个访问 App 的链接,
|
|
744
|
+
* 点一下就能到我们的 App 界面 —— 这既是解释,也是把用户引回远程控制主功能的入口。
|
|
745
|
+
* ⚠️ 微信不渲染 Markdown,所以这里用「」和纯文本,不要写 `**`。
|
|
746
|
+
*/
|
|
747
|
+
#upsellText() {
|
|
748
|
+
const lines = [
|
|
749
|
+
"微信机器人当前是「通知版」:",
|
|
750
|
+
"· 能收到任务通知、完成小结,以及需要你审批的请求",
|
|
751
|
+
"· 在微信里直接交代任务、切换会话属于会员功能"
|
|
752
|
+
];
|
|
753
|
+
if (this.appUrl) {
|
|
754
|
+
lines.push("", `👉 打开 App 查看设备列表、远程控制你的电脑:${this.appUrl}`);
|
|
755
|
+
}
|
|
756
|
+
lines.push("", "回复 /help 可以看到现在能做什么。");
|
|
757
|
+
return lines.join("\n");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* 免费档的任务收尾话术。
|
|
762
|
+
* 默认那句「回复就能接着做」对免费用户是**空头承诺**(他回复只会拿到付费引导),
|
|
763
|
+
* 所以换成"会员可用 + App 链接",把用户引到主功能上去。
|
|
764
|
+
*/
|
|
765
|
+
#continuationHint() {
|
|
766
|
+
const tail = this.appUrl ? `打开 App 继续:${this.appUrl}` : "打开 App 即可继续。";
|
|
767
|
+
return `在微信里接着交代下一步属于会员功能。${tail}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
593
770
|
/** 指令分派。 */
|
|
594
771
|
async #runCommand(from, cls) {
|
|
595
772
|
const cmd = cls.command;
|
|
596
773
|
const args = cls.args || "";
|
|
597
774
|
|
|
598
775
|
// ★ 运营钩子:能力表在**每次派发**时真的被查 —— 它是活代码,不是文档。
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
776
|
+
// 先 `#canNow` 刷新档位再判:用户刚升级完立刻重试必须能进。
|
|
777
|
+
const need = COMMAND_CAPABILITY[cmd];
|
|
778
|
+
if (need && !(await this.#canNow(need))) {
|
|
779
|
+
await this.reply(from, this.#upsellText());
|
|
602
780
|
return;
|
|
603
781
|
}
|
|
604
782
|
|
|
@@ -636,7 +814,11 @@ export class WeChatRuntime {
|
|
|
636
814
|
await this.#cmdStatus(from);
|
|
637
815
|
return;
|
|
638
816
|
}
|
|
639
|
-
|
|
817
|
+
if (cmd === "/help") {
|
|
818
|
+
await this.reply(from, this.#helpText());
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
// 其余交给上游的通用处理 —— ⚠️ 字段是 `replyText`,不是 text。
|
|
640
822
|
const r = handleCommand(cmd, args);
|
|
641
823
|
await this.reply(from, (r && r.replyText) || "可用指令:/new /ls /use /stop /status /summary /help /quiet /unbind");
|
|
642
824
|
}
|
|
@@ -983,7 +1165,7 @@ export class WeChatRuntime {
|
|
|
983
1165
|
summary,
|
|
984
1166
|
sessionId: sid,
|
|
985
1167
|
hanging: !summary
|
|
986
|
-
});
|
|
1168
|
+
}, this.#can("assign") ? {} : { continuationHint: this.#continuationHint() });
|
|
987
1169
|
built = { text: c.text, replyable: false, eventId: "" };
|
|
988
1170
|
} else if (
|
|
989
1171
|
formatterKind === "approval" &&
|
package/dsh-setup.mjs
CHANGED
|
@@ -407,6 +407,36 @@ function readPidFile(name) {
|
|
|
407
407
|
} catch { return null; }
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* 这个 pid 看起来**真的**是 watcher 吗?
|
|
412
|
+
*
|
|
413
|
+
* 为什么必须查:pid 文件可能过期,而 pid 会被系统**回收给无关进程**。
|
|
414
|
+
* 去重逻辑信任 `pidAlive()` 只会导致"我多退一次"(安全方向);但监管者接管时要
|
|
415
|
+
* **主动结束**那个 pid —— 盲杀一个被回收的无关进程是事故。所以动它之前先认一下命令行。
|
|
416
|
+
*/
|
|
417
|
+
function looksLikeWatcher(pid) {
|
|
418
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
419
|
+
const r = sh(`ps -p ${pid} -o command=`);
|
|
420
|
+
return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 去重决策(纯函数 —— 这条判断的代价很高,必须能被直接测)。
|
|
425
|
+
*
|
|
426
|
+
* · "run" → 没有别的守护(或就是自己),正常跑
|
|
427
|
+
* · "takeover" → 有别的守护,但**本进程是监管者(launchd)拉起的**:请它让位,我们接管
|
|
428
|
+
* · "yield" → 有别的守护,本进程是手动/插件脱离进程拉起的:直接退出,避免两个 bridge
|
|
429
|
+
* 抢同一个设备登记
|
|
430
|
+
*
|
|
431
|
+
* 为什么"被监管者拉起"必须走 takeover 而不是 yield:
|
|
432
|
+
* KeepAlive 每次重拉都秒退 → 监管者判为崩溃并节流 → 插件的自愈认定"服务坏了"→
|
|
433
|
+
* bootout 摘掉作业(入口判定成立时连 plist 一起删)→ **自启动彻底消失**(真机实测)。
|
|
434
|
+
*/
|
|
435
|
+
function dedupDecision({ existingPid = null, selfPid = 0, supervisor = "" } = {}) {
|
|
436
|
+
if (!existingPid || existingPid === selfPid) return "run";
|
|
437
|
+
return supervisor ? "takeover" : "yield";
|
|
438
|
+
}
|
|
439
|
+
|
|
410
440
|
function pidAlive(pid) {
|
|
411
441
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
412
442
|
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
@@ -958,12 +988,37 @@ function loginRateLimitUntil() {
|
|
|
958
988
|
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
959
989
|
async function runBridge() {
|
|
960
990
|
// 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
|
|
961
|
-
// 两个 bridge 会抢同一个设备登记 → 以 pid
|
|
991
|
+
// 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准。
|
|
992
|
+
//
|
|
993
|
+
// ⚠️ 但**被监管者(launchd)拉起的那个实例绝不能再"直接退出"**。
|
|
994
|
+
// 真机实测的后果链:KeepAlive 每次重拉 → 本进程秒退 → 监管者判为崩溃并节流
|
|
995
|
+
// (`launchctl print` 显示 `state = spawn scheduled`)→ 插件的自愈据此认定"服务坏了"
|
|
996
|
+
// → `bootout` 摘掉作业(入口判定成立时连 plist 一起删)→
|
|
997
|
+
// **自启动彻底消失,连 `launchctl kickstart` 都报"域里找不到该服务"**,
|
|
998
|
+
// 只剩插件面板的「启动」能救回来。
|
|
999
|
+
// 被监管者拉起 = 我们才是那个该活下来的实例 → 请游离实例让位,然后接管。
|
|
962
1000
|
{
|
|
963
1001
|
const other = readPidFile(WATCHER_PID_FILE);
|
|
964
1002
|
if (other && other !== process.pid && pidAlive(other)) {
|
|
965
|
-
|
|
966
|
-
|
|
1003
|
+
const supervisor = String(process.env.XPC_SERVICE_NAME || "");
|
|
1004
|
+
const decision = dedupDecision({ existingPid: other, selfPid: process.pid, supervisor });
|
|
1005
|
+
if (decision === "takeover") {
|
|
1006
|
+
console.log(`[dsh-remote] 检测到游离守护(pid=${other})——本进程由 ${supervisor} 监管,请它让位后接管。`);
|
|
1007
|
+
// ⚠️ pid 可能已被系统回收给无关进程:必须确认它**真的**是 watcher 才敢动它,
|
|
1008
|
+
// 否则宁可当作过期 pid 直接接管(下面 writePidFile 会覆盖掉)。
|
|
1009
|
+
if (looksLikeWatcher(other)) {
|
|
1010
|
+
try { process.kill(other, "SIGTERM"); } catch { /* 已退出 */ }
|
|
1011
|
+
// 等它自己退(它的退出钩子会清理 pid 文件并停掉它的 bridge)。最多 5 秒 ——
|
|
1012
|
+
// 不能等太久,否则监管者又看到一次"迟迟没起来"。
|
|
1013
|
+
const deadline = Date.now() + 5000;
|
|
1014
|
+
while (Date.now() < deadline && pidAlive(other)) sleepSync(100);
|
|
1015
|
+
if (pidAlive(other)) { try { process.kill(other, "SIGKILL"); } catch { /* 已退出 */ } }
|
|
1016
|
+
}
|
|
1017
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1018
|
+
} else {
|
|
1019
|
+
console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
967
1022
|
}
|
|
968
1023
|
}
|
|
969
1024
|
let cfg = loadConfig();
|
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.6",
|
|
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",
|
|
@@ -4442,15 +4442,18 @@ window.__ModuleLoader__.load({
|
|
|
4442
4442
|
|
|
4443
4443
|
// ── 首屏「这功能是干什么用的」导览(业主口径:先讲清用途,它就是绑定的理由) ──────────
|
|
4444
4444
|
// 位置:内嵌内容体的**最上面**,在「连接微信机器人」按钮之前(DOM 顺序也一样)——
|
|
4445
|
-
//
|
|
4445
|
+
// 第一次进来的人先看懂用途,再决定要不要绑。紧凑小字,
|
|
4446
4446
|
// 不会把连接按钮挤出首屏(见 .dru-wx-intro 的样式注释)。
|
|
4447
|
-
//
|
|
4447
|
+
// 只讲**产品真的会做的事**,且必须**按档位如实分层**:
|
|
4448
|
+
// 免费档只能收通知 + 回数字拍板,「在微信里交代任务」是会员能力 ——
|
|
4449
|
+
// 在面板里含糊其辞,用户绑完去微信发句话拿到付费提示,只会觉得产品骗人。
|
|
4448
4450
|
// 已绑定态同样保留:它是常驻的用途说明(不是一次性引导气泡),不占用任何按钮。
|
|
4449
4451
|
var intro = h("div", { className: "dru-wx-intro", role: "note", "aria-label": "微信机器人通道能做什么" },
|
|
4450
4452
|
h("div", { className: "dru-wx-intro-title" }, "绑定后能做什么"),
|
|
4451
4453
|
h("div", { className: "dru-wx-intro-row" }, "· 任务完成 / 出错 / 停下时**推送**到微信 —— 不用守着电脑。"),
|
|
4452
4454
|
h("div", { className: "dru-wx-intro-row" }, "· 需要你拍板时,在微信里**回一个数字**就完成决定(放行 / 拒绝 / 选哪个)。"),
|
|
4453
|
-
h("div", { className: "dru-wx-intro-row" }, "·
|
|
4455
|
+
h("div", { className: "dru-wx-intro-row" }, "· 以上两项**免费用**。"),
|
|
4456
|
+
h("div", { className: "dru-wx-intro-row" }, "· **会员**:还能直接在微信里交代任务、切换会话,并对同一个任务继续追问。")
|
|
4454
4457
|
);
|
|
4455
4458
|
|
|
4456
4459
|
if (loading && !wx && !fail) {
|
|
@@ -90,6 +90,20 @@ const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
|
|
|
90
90
|
|| "https://n.risegao.cn:13443/relay-api";
|
|
91
91
|
const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* 自启动服务(launchd plist / systemd unit)与子进程使用的 PATH。
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ **必须含 `/usr/sbin` 与 `/sbin`**:它们是系统管理命令所在目录(`ioreg`、`sysctl`…),
|
|
97
|
+
* 而 bridge 会用这些命令探测本机信息。
|
|
98
|
+
* 真机实证(2026-09-22):本文件此前**硬编码**了不含 `/usr/sbin` 的 PATH,而插件每次
|
|
99
|
+
* 自愈/启动都会重写 plist —— 于是它覆盖掉了 dsh-setup.mjs 里那份**正确**的 SERVICE_PATH,
|
|
100
|
+
* 结果 bridge 日志里刷 `/bin/sh: ioreg: command not found`,机器指纹静默退化成
|
|
101
|
+
* hostname 哈希(用户改一次主机名就被当成新设备)。
|
|
102
|
+
* ⚠️ 与 `dsh-setup.mjs` 的 `SERVICE_PATH` **必须一致** —— 两处都会写同一个 plist,
|
|
103
|
+
* 谁写谁说了算;有测试锁死这条一致性(跨文件不变量)。
|
|
104
|
+
*/
|
|
105
|
+
const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
106
|
+
|
|
93
107
|
/**
|
|
94
108
|
* 把文件权限收紧到「只有本人可读写」。
|
|
95
109
|
*
|
|
@@ -226,7 +240,9 @@ function spawnEnv(extra) {
|
|
|
226
240
|
// PATH 分隔符必须按平台取(Windows 是 ";",POSIX 是 ":"):写死 ":" 会把整条 PATH
|
|
227
241
|
// 在 Windows 上拼成一个不存在的路径,node/npx 全部找不到。
|
|
228
242
|
// 追加的 POSIX 目录只在类 Unix 上有意义,Windows 上不追加。
|
|
229
|
-
|
|
243
|
+
// 含 /usr/sbin 与 /sbin:插件用 spawnEnv 拉起的是 watcher,它会再拉起 bridge,
|
|
244
|
+
// 而 bridge 要用 ioreg 之类系统命令(缺了它 → 机器指纹静默退化,见 SERVICE_PATH 注释)。
|
|
245
|
+
const posixDirs = isWindows() ? [] : ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
230
246
|
const PATH = [nodeDir, base, ...posixDirs].filter(Boolean).join(delimiter);
|
|
231
247
|
return { ...process.env, PATH, ...(extra || {}) };
|
|
232
248
|
}
|
|
@@ -1007,7 +1023,18 @@ function ensureRuntime(relayDir) {
|
|
|
1007
1023
|
}
|
|
1008
1024
|
}
|
|
1009
1025
|
|
|
1010
|
-
/**
|
|
1026
|
+
/**
|
|
1027
|
+
* 生成 plist(与 dsh-setup.mjs 的 writeAutostartFile **必须同构**),返回路径。
|
|
1028
|
+
*
|
|
1029
|
+
* ⚠️ 两份模板历史上漂移过、并因此各造成一个真机事故(插件每次自愈/启动都会重写 plist,
|
|
1030
|
+
* 所以**插件这份谁写谁说了算**,漂移等于把安装器那份正确的覆盖掉):
|
|
1031
|
+
* · 漏 `/usr/sbin`、`/sbin`(SERVICE_PATH)→ bridge 找不到 `ioreg` → 机器指纹静默退化成
|
|
1032
|
+
* hostname 哈希(改一次主机名就被当成新设备);
|
|
1033
|
+
* · 漏 `LimitLoadToSessionType` → `bootstrap user/<uid>` 失败(rc=5)→ 域阶梯回退到
|
|
1034
|
+
* gui/<uid>,而 macOS 26 上 gui 域是 on-demand-only、`RunAtLoad`/`KeepAlive` 失效 →
|
|
1035
|
+
* **服务被系统(BTM)回收后再也回不来**,只能手动点面板「启动」。
|
|
1036
|
+
* 两者都由 launchd-domain.test.mjs 的跨文件不变量锁死。
|
|
1037
|
+
*/
|
|
1011
1038
|
function writeAutostartFile(relayDir) {
|
|
1012
1039
|
const plistPath = launchAgentPath();
|
|
1013
1040
|
if (!plistPath) return null;
|
|
@@ -1021,9 +1048,10 @@ function writeAutostartFile(relayDir) {
|
|
|
1021
1048
|
<key>RunAtLoad</key><true/>
|
|
1022
1049
|
<key>KeepAlive</key><true/>
|
|
1023
1050
|
<key>ThrottleInterval</key><integer>10</integer>
|
|
1051
|
+
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
1024
1052
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1025
1053
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1026
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string
|
|
1054
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string><key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
|
|
1027
1055
|
</dict></plist>`;
|
|
1028
1056
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
1029
1057
|
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
@@ -2453,7 +2481,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
2453
2481
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
2454
2482
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
2455
2483
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
2456
|
-
const PLUGIN_VERSION = "0.6.10-beta.
|
|
2484
|
+
const PLUGIN_VERSION = "0.6.10-beta.6";
|
|
2457
2485
|
const UPDATE_LOG = ".dsh-update.log";
|
|
2458
2486
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
2459
2487
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.10-beta.
|
|
3
|
+
"version": "0.6.10-beta.6",
|
|
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",
|
|
@@ -24,6 +24,8 @@ import { dirname, join } from "node:path";
|
|
|
24
24
|
|
|
25
25
|
const SETUP = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dsh-setup.mjs");
|
|
26
26
|
const src = readFileSync(SETUP, "utf8");
|
|
27
|
+
/** 插件半边也会写同一份 plist —— 两份模板必须一致(谁写谁说了算)。 */
|
|
28
|
+
const INDEX_SRC = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "index.js"), "utf8");
|
|
27
29
|
|
|
28
30
|
test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的必要条件)", () => {
|
|
29
31
|
assert.match(
|
|
@@ -34,6 +36,16 @@ test("launchd 修复:plist 必须带 LimitLoadToSessionType(user domain 的
|
|
|
34
36
|
// RunAtLoad / KeepAlive 仍需保留:user domain 下它们才真正生效
|
|
35
37
|
assert.match(src, /<key>RunAtLoad<\/key><true\/>/, "RunAtLoad 不能删(user domain 下靠它开机自启)");
|
|
36
38
|
assert.match(src, /<key>KeepAlive<\/key><true\/>/, "KeepAlive 不能删(user domain 下靠它崩溃自愈)");
|
|
39
|
+
// ★ 插件那份模板**也**必须有:它每次自愈/启动都会重写 plist(谁写谁说了算)。
|
|
40
|
+
// 真机实证 2026-09-22:插件模板漏了它 → bootstrap user/<uid> 失败 → 回退 gui/<uid> →
|
|
41
|
+
// macOS 26 上该域 on-demand-only、KeepAlive 失效 → 服务被系统回收后再也回不来,
|
|
42
|
+
// 现场表现就是「自启动服务偶尔整个消失,连 kickstart 都报域里找不到该服务」。
|
|
43
|
+
assert.match(
|
|
44
|
+
INDEX_SRC,
|
|
45
|
+
/<key>LimitLoadToSessionType<\/key><array><string>Aqua<\/string><string>Background<\/string><\/array>/,
|
|
46
|
+
"★插件半边的 plist 模板也必须有 LimitLoadToSessionType(否则它会覆盖掉安装器那份正确的)"
|
|
47
|
+
);
|
|
48
|
+
assert.match(INDEX_SRC, /<key>KeepAlive<\/key><true\/>/, "插件模板也要保留 KeepAlive");
|
|
37
49
|
});
|
|
38
50
|
|
|
39
51
|
test("launchd 修复:启动走 user → gui 阶梯,且每次都 kickstart", () => {
|
|
@@ -76,3 +88,50 @@ test("launchd 修复:服务 PATH 必须含 /usr/sbin 与 /sbin(否则 ioreg
|
|
|
76
88
|
assert.ok(/\$\{SERVICE_PATH\}/.test(src), "plist/systemd 应使用 SERVICE_PATH 常量");
|
|
77
89
|
assert.ok(!/Environment=PATH=\/usr\/local\/bin:\/usr\/bin:\/bin/.test(src), "systemd unit 不应再写死旧 PATH");
|
|
78
90
|
});
|
|
91
|
+
|
|
92
|
+
test("★ 两份 plist 模板的 PATH 必须一致(插件那份曾漏掉 /usr/sbin,覆盖掉正确的那份)", () => {
|
|
93
|
+
// 真机实证(2026-09-22):插件半边在 plist 模板里**硬编码**了不含 /usr/sbin 的 PATH,
|
|
94
|
+
// 而它每次自愈/启动都会重写 plist → 覆盖掉 dsh-setup.mjs 里正确的那份 →
|
|
95
|
+
// bridge 日志刷 `/bin/sh: ioreg: command not found` → 机器指纹静默退化成 hostname 哈希。
|
|
96
|
+
const setupPath = /const SERVICE_PATH = "([^"]+)"/.exec(src);
|
|
97
|
+
const pluginPath = /const SERVICE_PATH = "([^"]+)"/.exec(INDEX_SRC);
|
|
98
|
+
assert.ok(setupPath, "dsh-setup.mjs 应定义 SERVICE_PATH");
|
|
99
|
+
assert.ok(pluginPath, "插件半边也必须定义 SERVICE_PATH(它也会写 plist)");
|
|
100
|
+
assert.equal(pluginPath[1], setupPath[1], "★两处 PATH 必须逐字一致:同一个 plist 谁写谁说了算");
|
|
101
|
+
|
|
102
|
+
// 插件写 plist 时必须用它,不能再硬编码
|
|
103
|
+
assert.ok(/\$\{SERVICE_PATH\}/.test(INDEX_SRC), "插件的 plist 模板必须使用 SERVICE_PATH");
|
|
104
|
+
assert.ok(!/<key>PATH<\/key><string>\/usr\/local\/bin:\/opt\/homebrew\/bin:\/usr\/bin:\/bin<\/string>/.test(INDEX_SRC),
|
|
105
|
+
"插件不得再硬编码不含 /usr/sbin 的 PATH");
|
|
106
|
+
// spawnEnv 拉起的是 watcher → 它会再拉起 bridge → 也必须能用到系统管理命令
|
|
107
|
+
for (const dir of ["/usr/sbin", "/sbin"]) {
|
|
108
|
+
assert.ok(INDEX_SRC.includes(`"${dir}"`), `spawnEnv 的 posixDirs 也应含 ${dir}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("★ 被监管者(launchd)拉起的实例不得因『已有游离守护』而秒退", () => {
|
|
113
|
+
// 真机事故链:KeepAlive 重拉 → 本进程发现 pid 文件里已有游离守护 → 秒退
|
|
114
|
+
// → 监管者判为崩溃并节流(`launchctl print` = `state = spawn scheduled`)
|
|
115
|
+
// → 插件自愈认定"服务坏了" → bootout 摘作业(入口判定成立时连 plist 一起删)
|
|
116
|
+
// → **自启动彻底消失,`launchctl kickstart` 报"域里找不到该服务"**。
|
|
117
|
+
const from = src.indexOf("function dedupDecision(");
|
|
118
|
+
assert.ok(from > -1, "应能找到 dedupDecision");
|
|
119
|
+
const body = src.slice(from, src.indexOf("function writeWindowsTask("));
|
|
120
|
+
assert.ok(body.length > 0, "切片边界应正确");
|
|
121
|
+
const decide = new Function(`${body}\nreturn dedupDecision;`)();
|
|
122
|
+
|
|
123
|
+
assert.equal(decide({ existingPid: 123, selfPid: 456, supervisor: "com.dshremote.bridge" }), "takeover",
|
|
124
|
+
"★被监管者拉起时必须接管,绝不能退出(否则自启动会被监管者与自愈联手摘掉)");
|
|
125
|
+
assert.equal(decide({ existingPid: 123, selfPid: 456, supervisor: "" }), "yield",
|
|
126
|
+
"手动/插件脱离进程拉起时仍必须让位(两个 bridge 会抢同一设备登记)");
|
|
127
|
+
assert.equal(decide({ existingPid: 0, selfPid: 456, supervisor: "" }), "run", "没有别的守护 → 正常跑");
|
|
128
|
+
assert.equal(decide({ existingPid: 456, selfPid: 456, supervisor: "" }), "run", "pid 就是自己 → 正常跑");
|
|
129
|
+
assert.equal(decide({ existingPid: null, selfPid: 456, supervisor: "" }), "run");
|
|
130
|
+
|
|
131
|
+
// 接管路径的接线:判据用监管者注入的 XPC_SERVICE_NAME;动那个 pid 之前必须先认命令行
|
|
132
|
+
const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
|
|
133
|
+
assert.ok(rb.length > 0, "应能切出 runBridge");
|
|
134
|
+
assert.match(rb, /XPC_SERVICE_NAME/, "必须用监管者注入的 XPC_SERVICE_NAME 作判据");
|
|
135
|
+
assert.match(rb, /looksLikeWatcher\(other\)/, "★接管前必须确认那个 pid 真的是 watcher(pid 会被回收给无关进程)");
|
|
136
|
+
assert.match(rb, /dedupDecision\(/, "决策必须走 dedupDecision(保证被判为活代码且可测)");
|
|
137
|
+
});
|
|
@@ -498,6 +498,10 @@ test("首屏导览:未绑定时就在最上面(DOM 顺序在「连接微信
|
|
|
498
498
|
assert.ok(textHas(intro, "拍板"), "② 要交代这是需要你拍板的时刻");
|
|
499
499
|
assert.ok(textHas(intro, "交代任务"), "③ 要明说可以**直接在微信里交代任务**");
|
|
500
500
|
assert.ok(textHas(intro, "追问"), "③ 要明说可以对同一个任务继续追问");
|
|
501
|
+
// ★ 分层必须如实写在面板上:「交代任务/追问」是会员能力,免费档只有推送与回数字。
|
|
502
|
+
// 面板里含糊其辞 → 用户绑完去微信发句话拿到付费提示 → 只会觉得产品骗人。
|
|
503
|
+
assert.ok(textHas(intro, "免费用"), "必须写明哪两项是免费的");
|
|
504
|
+
assert.ok(textHas(intro, "会员"), "必须写明「交代任务/切换会话」属于会员能力(不能含糊)");
|
|
501
505
|
|
|
502
506
|
// DOM 顺序:导览在「连接微信机器人」按钮之前(用户先看懂用途,再看到按钮)
|
|
503
507
|
const introAt = domIndex(tree, (n) => n.props?.className === "dru-wx-intro");
|