@mrrisega/dsh-remote 0.7.12-beta.5 → 0.7.12-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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * im-commands — 三家 IM 机器人共用命令层单测(2026-11-08)。
3
+ *
4
+ * 纯函数 + 注入式执行器,覆盖:解析(大小写/参数/未知)、/help 全命令、未知命令不转发、
5
+ * 免费档 /ls /use 被拦 + 升级文案、/stop 免费立即生效、/use 越界/缺参、/status 与快照同源、
6
+ * /unbind 免费、命令回复不含裸表格、写配置 sessionId(保留其它段)。
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+
14
+ import {
15
+ IM_COMMANDS,
16
+ commandAllowed,
17
+ executeImCommand,
18
+ helpText,
19
+ lsText,
20
+ parseImCommand,
21
+ writeImBotsSession
22
+ } from "../im-commands.mjs";
23
+ import { freeCaps, paidCaps } from "./im-caps-fixture.mjs";
24
+ import { fileMode } from "../wecom-bot.mjs";
25
+
26
+ function tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-imcmd-")); }
27
+
28
+ function sessionsFixture() {
29
+ const rows = [
30
+ { sessionId: "session-alpha", title: "重构支付回调链路(很长的标题会被截断到二十四字以内)", updatedAt: Date.UTC(2026, 10, 8, 1, 30), running: true },
31
+ { sessionId: "session-beta", title: "修端口探测", updatedAt: Date.UTC(2026, 10, 7, 22, 0) },
32
+ { sessionId: "session-gamma", title: "写测试", updatedAt: 0 }
33
+ ];
34
+ return async () => rows;
35
+ }
36
+
37
+ /** 一个记录调用的 ctx(各用例按需覆盖)。 */
38
+ function makeCtx(over = {}) {
39
+ const calls = { switch: [], cancel: [], unbind: 0 };
40
+ const ctx = {
41
+ caps: freeCaps(),
42
+ tier: "free",
43
+ appUrl: "https://app.test/",
44
+ channelLabel: "钉钉",
45
+ sessions: sessionsFixture(),
46
+ currentSessionId: () => "session-alpha",
47
+ currentSessionTitle: "重构支付回调链路",
48
+ switchSession: async (arg) => { calls.switch.push(arg); return { ok: true }; },
49
+ cancelSession: async (arg) => { calls.cancel.push(arg); return { ok: true }; },
50
+ unbind: async () => { calls.unbind += 1; return { ok: true }; },
51
+ statusSnapshot: async () => ({
52
+ connected: true, authenticated: true, state: "connected", lastError: "",
53
+ tier: "free", usage: { month: "2026-11", messages: 3 }, messagesPerMonth: 20, remainingMessages: 17,
54
+ currentSessionId: "session-alpha", currentSessionTitle: "重构支付回调链路"
55
+ }),
56
+ ...over
57
+ };
58
+ return { ctx, calls };
59
+ }
60
+
61
+ const run = (text, ctx) => executeImCommand({ text, ctx });
62
+ const assertNoRawTable = (text) => {
63
+ assert.ok(!/\|\s*-{2,}/.test(text), `不得含表格分隔行:${text}`);
64
+ assert.ok(!/^\s*\|.*\|\s*$/m.test(text), `不得含裸表格行:${text}`);
65
+ };
66
+
67
+ // ─────────────────────────── 解析 ───────────────────────────
68
+
69
+ test("parseImCommand:命令/大小写/参数/未知;普通文本返回 null", () => {
70
+ assert.deepEqual(parseImCommand("/help"), { name: "/help", arg: "", rest: "", unknown: false, raw: "/help" });
71
+ assert.equal(parseImCommand("/HELP").name, "/help", "大小写不敏感");
72
+ assert.equal(parseImCommand(" /Status ").name, "/status");
73
+ assert.equal(parseImCommand("/use 3").arg, "3");
74
+ assert.equal(parseImCommand("/use").arg, "");
75
+ assert.equal(parseImCommand("/use abc").arg, "abc");
76
+ assert.equal(parseImCommand("/ls 额外 参数").arg, "额外");
77
+ assert.equal(parseImCommand("/").unknown, true, "光一个 / 算未知命令");
78
+ assert.equal(parseImCommand("/wat").unknown, false);
79
+ assert.equal(parseImCommand("hello"), null, "普通文本不是命令");
80
+ assert.equal(parseImCommand("ls"), null);
81
+ assert.equal(parseImCommand(" /help 看下 ").arg, "看下");
82
+ });
83
+
84
+ // ─────────────────────────── /help / 未知 ───────────────────────────
85
+
86
+ test("★ /help:列出全部命令(免费/会员标注),免费档看到会员命令标注", async () => {
87
+ const { ctx } = makeCtx();
88
+ const r = await run("/help", ctx);
89
+ assert.equal(r.handled, true);
90
+ assert.equal(r.ok, true);
91
+ for (const c of IM_COMMANDS) assert.ok(r.text.includes(c.usage), `/help 必须包含 ${c.usage}`);
92
+ assert.match(r.text, /命令不会发给模型/);
93
+ assert.match(r.text, /\/stop/);
94
+ assert.match(r.text, /\/unbind/);
95
+ assert.match(r.text, /会员/, "免费档要标注会员命令");
96
+ assertNoRawTable(r.text);
97
+ });
98
+
99
+ test("★ 未知命令:不转发给模型(handled=true),回「未知命令 + 帮助」", async () => {
100
+ let promptCalls = 0;
101
+ const { ctx } = makeCtx();
102
+ const r = await run("/deploy now", ctx);
103
+ assert.equal(r.handled, true, "★ 未知 /xxx 必须被命令层拦下");
104
+ assert.ok(promptCalls === 0);
105
+ assert.match(r.text, /未知命令:\/deploy/);
106
+ assert.match(r.text, /\/help/);
107
+ assertNoRawTable(r.text);
108
+ // 非命令文本仍返回 handled:false(照旧进模型)
109
+ const normal = await run("帮我改一下代码", ctx);
110
+ assert.deepEqual(normal, { handled: false });
111
+ });
112
+
113
+ // ─────────────────────────── 能力门禁 ───────────────────────────
114
+
115
+ test("★ 免费档 /ls、/use 被拦:回会员文案 + /app/promo;命令不进模型", async () => {
116
+ const { ctx } = makeCtx();
117
+ const ls = await run("/ls", ctx);
118
+ assert.equal(ls.handled, true);
119
+ assert.equal(ls.gated, true);
120
+ assert.match(ls.text, /会员功能/);
121
+ assert.match(ls.text, /https:\/\/app\.test\/promo/);
122
+ assert.match(ls.text, /免费可用/);
123
+ assertNoRawTable(ls.text);
124
+ const use = await run("/use 1", ctx);
125
+ assert.equal(use.gated, true);
126
+ assert.match(use.text, /\/use 是会员功能/);
127
+ // 免费命令不受影响
128
+ for (const cmd of ["/help", "/status", "/stop", "/unbind"]) {
129
+ const r = await run(cmd, ctx);
130
+ assert.equal(r.gated, undefined, `${cmd} 必须免费可用`);
131
+ assert.equal(r.handled, true);
132
+ }
133
+ });
134
+
135
+ // ─────────────────────────── /ls ───────────────────────────
136
+
137
+ test("★ /ls(会员):≤10 条、标题截断 24、索引 + 当前标记 + 更新时间;不含裸表格", async () => {
138
+ const many = Array.from({ length: 15 }, (_, i) => ({ sessionId: `session-${i}`, title: `会话 ${i} 号很长的标题需要被截断处理一下`, updatedAt: Date.UTC(2026, 10, 8, 10, i % 60) }));
139
+ const { ctx } = makeCtx({ caps: paidCaps(), sessions: async () => many, currentSessionId: () => "session-3" });
140
+ const r = await run("/ls", ctx);
141
+ assert.equal(r.ok, true);
142
+ const lines = r.text.split("\n").filter((l) => /^\d+\. /.test(l));
143
+ assert.equal(lines.length, 10, "最多 10 条");
144
+ assert.match(r.text, /1\. 会话 0 号/);
145
+ assert.match(r.text, /4\. 会话 3 号[^\n]*(当前)/, "当前会话要有标记");
146
+ assert.match(r.text, /· \d{2}-\d{2} \d{2}:\d{2}/, "带更新时间");
147
+ for (const line of lines) {
148
+ const titlePart = line.replace(/^\d+\. /, "").split(" ")[0];
149
+ assert.ok(Array.from(titlePart.replace(/…$/, "")).length <= 24, `标题超过 24 字:${line}`);
150
+ }
151
+ assert.match(r.text, /\/use <序号>/);
152
+ assertNoRawTable(r.text);
153
+ });
154
+
155
+ test("/ls:没有会话时给可操作提示", async () => {
156
+ const { ctx } = makeCtx({ caps: paidCaps(), sessions: async () => [] });
157
+ const r = await run("/ls", ctx);
158
+ assert.equal(r.ok, true);
159
+ assert.match(r.text, /最近没有可列出的会话/);
160
+ assert.match(r.text, /打开\/运行一个会话/);
161
+ });
162
+
163
+ // ─────────────────────────── /use ───────────────────────────
164
+
165
+ test("★ /use <N>:合法序号切换(写配置由 ctx.switchSession 完成)+ 回执", async () => {
166
+ const { ctx, calls } = makeCtx({ caps: paidCaps() });
167
+ const r = await run("/use 2", ctx);
168
+ assert.equal(r.ok, true);
169
+ assert.deepEqual(calls.switch, [{ sessionId: "session-beta", title: "修端口探测" }]);
170
+ assert.match(r.text, /已切换到 #2 修端口探测/);
171
+ });
172
+
173
+ test("★ /use 越界 / 缺参 / 无会话:给可操作提示,不崩", async () => {
174
+ const { ctx, calls } = makeCtx({ caps: paidCaps() });
175
+ const out = await run("/use 99", ctx);
176
+ assert.equal(out.ok, true, "越界是提示不是崩溃");
177
+ assert.match(out.text, /序号超出范围:当前列表是 1-3/);
178
+ assert.match(out.text, /\/ls/);
179
+ assert.equal(calls.switch.length, 0);
180
+ const missing = await run("/use", ctx);
181
+ assert.match(missing.text, /用法:\/use <序号>/);
182
+ const none = await run("/use 1", makeCtx({ caps: paidCaps(), sessions: async () => [] }).ctx);
183
+ assert.match(none.text, /没有可切换的会话/);
184
+ const failing = await run("/use 1", makeCtx({ caps: paidCaps(), switchSession: async () => ({ ok: false, reason: "写配置失败" }) }).ctx);
185
+ assert.match(failing.text, /切换失败:写配置失败/);
186
+ });
187
+
188
+ // ─────────────────────────── /stop / /status / /unbind ───────────────────────────
189
+
190
+ test("★ /stop:免费可用、立即调 cancelSession、回执明确(没会话给说明)", async () => {
191
+ const { ctx, calls } = makeCtx();
192
+ const r = await run("/stop", ctx);
193
+ assert.equal(r.ok, true);
194
+ assert.deepEqual(calls.cancel, [{ sessionId: "session-alpha" }]);
195
+ assert.match(r.text, /已急停:重构支付回调链路/);
196
+ // 无会话
197
+ const none = await run("/stop", makeCtx({ currentSessionId: () => "" }).ctx);
198
+ assert.match(none.text, /没有正在运行的会话/);
199
+ // 取消失败要给"没收到确认"
200
+ const fail = await run("/stop", makeCtx({ cancelSession: async () => ({ ok: false, message: "通道断开" }) }).ctx);
201
+ assert.match(fail.text, /没收到确认:通道断开/);
202
+ });
203
+
204
+ test("★ /status:连接状态 + 当前会话 + 本月剩余(与状态快照同源;不限量显示不限)", async () => {
205
+ const { ctx } = makeCtx();
206
+ const r = await run("/status", ctx);
207
+ assert.equal(r.ok, true);
208
+ assert.match(r.text, /连接状态/);
209
+ assert.match(r.text, /✅ 已连接/);
210
+ assert.match(r.text, /当前会话:重构支付回调链路/);
211
+ assert.match(r.text, /本月指令:已用 3\/20/);
212
+ assert.match(r.text, /剩余:17 条/);
213
+ assertNoRawTable(r.text);
214
+ // 付费不限量
215
+ const pro = await run("/status", makeCtx({
216
+ caps: paidCaps(), tier: "pro",
217
+ statusSnapshot: async () => ({ connected: false, state: "reconnecting", tier: "pro", usage: { messages: 0 }, messagesPerMonth: 0, currentSessionId: "" })
218
+ }).ctx);
219
+ assert.match(pro.text, /未连接(reconnecting/);
220
+ assert.match(pro.text, /本月指令:不限(pro)/);
221
+ assert.match(pro.text, /当前会话:未选择/);
222
+ });
223
+
224
+ test("★ /unbind:免费可用、任何人可退订(调 unbind + 明确回执)", async () => {
225
+ const { ctx, calls } = makeCtx();
226
+ const r = await run("/unbind", ctx);
227
+ assert.equal(r.ok, true);
228
+ assert.equal(calls.unbind, 1);
229
+ assert.match(r.text, /已解绑/);
230
+ assert.match(r.text, /重新绑定/);
231
+ });
232
+
233
+ // ─────────────────────────── 执行器健壮性 / 配置写入 ───────────────────────────
234
+
235
+ test("执行器:ctx 抛错也返回 handled:true(命令层绝不把异常漏给模型)", async () => {
236
+ const r = await run("/ls", makeCtx({ caps: paidCaps(), sessions: async () => { throw new Error("订阅器挂了"); } }).ctx);
237
+ assert.equal(r.handled, true);
238
+ assert.match(r.text, /命令执行失败:订阅器挂了/);
239
+ });
240
+
241
+ test("★ writeImBotsSession:写对应通道的 sessionId/sessionTitle(保留其它段,0600)", () => {
242
+ const dir = tmpDir();
243
+ try {
244
+ fs.writeFileSync(path.join(dir, "im-bots.json"), JSON.stringify({
245
+ version: 1,
246
+ wecom: { enabled: true, botId: "b" },
247
+ dingtalk: { enabled: true, clientId: "c" },
248
+ feishu: { enabled: false }
249
+ }), { mode: 0o600 });
250
+ const r = writeImBotsSession(dir, "dingtalk", { sessionId: "session-beta", sessionTitle: "修端口探测" });
251
+ assert.equal(r.ok, true);
252
+ const disk = JSON.parse(fs.readFileSync(path.join(dir, "im-bots.json"), "utf8"));
253
+ assert.equal(disk.dingtalk.sessionId, "session-beta");
254
+ assert.equal(disk.dingtalk.sessionTitle, "修端口探测");
255
+ assert.equal(disk.dingtalk.clientId, "c", "其它字段保留");
256
+ assert.equal(disk.wecom.botId, "b", "其它通道段保留");
257
+ if (process.platform !== "win32") assert.equal(fileMode(path.join(dir, "im-bots.json")) & 0o777, 0o600);
258
+ // 非法参数
259
+ assert.equal(writeImBotsSession(dir, "", {}).ok, false);
260
+ assert.equal(writeImBotsSession(dir, "dingtalk", { sessionId: "" }).ok, false);
261
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
262
+ });
263
+
264
+ test("helpText/commandAllowed:能力表判断与 /help 文案一致(单一真相源)", () => {
265
+ assert.equal(commandAllowed(IM_COMMANDS.find((c) => c.name === "/stop"), []), true, "急停不需要能力");
266
+ assert.equal(commandAllowed(IM_COMMANDS.find((c) => c.name === "/ls"), freeCaps()), false);
267
+ assert.equal(commandAllowed(IM_COMMANDS.find((c) => c.name === "/ls"), paidCaps()), true);
268
+ const paidHelp = helpText({ caps: paidCaps(), appUrl: "https://a/" });
269
+ assert.match(paidHelp, /你的档位已包含会话管理/);
270
+ const freeHelp = helpText({ caps: freeCaps() });
271
+ assert.ok(!/已包含会话管理/.test(freeHelp));
272
+ });
273
+
274
+ test("三家接线(源级契约):wecom/dingtalk/feishu 都在进模型前调用命令层", () => {
275
+ for (const file of ["wecom-bot.mjs", "dingtalk-bot.mjs", "feishu-bot.mjs"]) {
276
+ const src = fs.readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
277
+ assert.ok(/from "\.\/im-commands\.mjs"/.test(src), `${file} 必须 import im-commands.mjs`);
278
+ assert.ok(/executeImCommand\(/.test(src), `${file} 必须调用 executeImCommand`);
279
+ const cmdAt = src.indexOf("executeImCommand(");
280
+ const promptAt = src.indexOf("promptSession(");
281
+ assert.ok(cmdAt > -1 && (promptAt === -1 || cmdAt < promptAt), `${file} 命令层必须出现在 promptSession 之前(cmdAt=${cmdAt}, promptAt=${promptAt})`);
282
+ }
283
+ });
@@ -152,6 +152,8 @@ function fakeSubscriber(over = {}) {
152
152
  sub.started = false;
153
153
  sub.closed = false;
154
154
  sub.promptCalls = [];
155
+ sub.cancelCalls = [];
156
+ sub.cancelSession = async ({ sessionId } = {}) => { sub.cancelCalls.push({ sessionId }); return { ok: true }; };
155
157
  sub.sessionDetails = Array.isArray(over.sessions) ? over.sessions.slice() : [];
156
158
  sub.start = () => { sub.started = true; };
157
159
  sub.close = () => { sub.closed = true; };
@@ -1716,3 +1718,38 @@ test("atomicWriteJson:覆盖写、目录自动创建、0600、失败不留临
1716
1718
  fs.rmSync(dir, { recursive: true, force: true });
1717
1719
  }
1718
1720
  });
1721
+
1722
+ // ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
1723
+
1724
+ test("★ 命令层:/help /status 直接回复、/ls 免费档被拦、/stop 免费急停;命中命令**不进 promptSession**", async () => {
1725
+ const rt = await setupRelayBot();
1726
+ try {
1727
+ rt.bot.start();
1728
+ await waitFor(() => rt.bot.status().authenticated, { label: "认证" });
1729
+ const replies = () => rt.server.state.frames.filter((f) => f.cmd === "aibot_respond_msg");
1730
+ const sendCmd = async (msgid, content) => {
1731
+ const before = replies().length;
1732
+ rt.server.push({ cmd: "aibot_msg_callback", headers: { req_id: `cmd-${msgid}` }, body: { msgid: `m-${msgid}`, msgtype: "text", text: { content }, from: { userid: "user-a" } } });
1733
+ await waitFor(() => replies().length > before, { label: `命令 ${msgid} 回复` });
1734
+ return String(replies()[before].body.stream.content || "");
1735
+ };
1736
+ const help = await sendCmd("help", "/help");
1737
+ assert.match(help, /可用命令/);
1738
+ assert.match(help, /\/ls/);
1739
+ assert.match(help, /\/unbind/);
1740
+ assert.equal(rt.sub.promptCalls.length, 0, "★ 命令不得进模型");
1741
+ const status = await sendCmd("status", "/status");
1742
+ assert.match(status, /连接状态/);
1743
+ assert.match(status, /当前会话/);
1744
+ const ls = await sendCmd("ls", "/ls");
1745
+ assert.match(ls, /会员功能/, "免费档 /ls 必须被拦");
1746
+ assert.equal(rt.sub.promptCalls.length, 0);
1747
+ const stop = await sendCmd("stop", "/stop");
1748
+ assert.match(stop, /已急停|没有正在运行/);
1749
+ assert.deepEqual(rt.sub.cancelCalls, [{ sessionId: "session-abc" }], "★ /stop 调 cancelSession");
1750
+ assert.equal(rt.sub.promptCalls.length, 0, "★ 全部命令都不进模型");
1751
+ // 普通文本照旧进模型
1752
+ await rt.inbound("normal-1", "帮我改一下代码");
1753
+ assert.equal(rt.sub.promptCalls.length, 1);
1754
+ } finally { await rt.close(); }
1755
+ });
@@ -53,6 +53,9 @@ import WebSocket from "ws";
53
53
  import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
54
54
  // 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
55
55
  import { formatForChannel } from "./im-format.mjs";
56
+ // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在**进模型前**拦下执行;
57
+ // 解析/文案/能力门禁三家共用(与会员升级文案同口径)。
58
+ import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
56
59
  // 会员能力表 = **单一真相源**:直接复用微信通道那套(WECHAT_CAPABILITY_TABLE / limitsFor / grantsCap),
57
60
  // 不另造一套。企微/飞书/钉钉将来都套同一套判定(见 research/.../im-channel-membership-design.md)。
58
61
  import {
@@ -716,8 +719,8 @@ export function createWecomBot(options = {}) {
716
719
  const approvalCardTtlMs = positiveNumber(options.approvalCardTtlMs, APPROVAL_CARD_TTL_MS);
717
720
  const onApprovalDecision = typeof options.onApprovalDecision === "function" ? options.onApprovalDecision : null;
718
721
  // 会话可选绑定(task-24):配置固定的会话(空 = 跟随活跃会话)
719
- const configuredSessionId = String(options.configuredSessionId || "").trim();
720
- const configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
722
+ let configuredSessionId = String(options.configuredSessionId || "").trim();
723
+ let configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
721
724
  // ── 会员判定(2026-11-07 业主拍板:通知免费、审批免费、互动半阉割)──
722
725
  // capsProvider 由 bridge 注入(复用微信通道的 resolveWechatTier:字符串档位或 {plan,caps,limits,rev});
723
726
  // 拿不到 / 未注入 → **按 free fail-closed**。
@@ -1367,8 +1370,87 @@ export function createWecomBot(options = {}) {
1367
1370
  }
1368
1371
 
1369
1372
  /** 把入站文本转给 DSH 会话(尽力,失败给用户一句人话)。 */
1373
+ /**
1374
+ * 命令层(三家共用 `im-commands.mjs`):命中返回 true(已回复,调用方直接 return)。
1375
+ * 回复走被动回复/主动推送(replyText),不经过模型、不计额度。
1376
+ */
1377
+ async function handleCommand(frame, info) {
1378
+ const text = String((info && info.text) || "");
1379
+ if (!/^\s*\//.test(text)) return false; // 快路径:非命令不构造 ctx
1380
+ const cmd = await executeImCommand({
1381
+ text,
1382
+ ctx: {
1383
+ caps: capsNow(),
1384
+ tier: tierNow(),
1385
+ appUrl,
1386
+ channelLabel: "企业微信",
1387
+ sessions: async () => {
1388
+ try { await refreshSessions({ force: true }); } catch { /* 忽略 */ }
1389
+ return sessionList.map((x) => ({ sessionId: x.id, title: x.title, updatedAt: x.updatedAt, running: false }));
1390
+ },
1391
+ currentSessionId: () => {
1392
+ const known = boundSessionId || activeSession.id || "";
1393
+ if (known) return known;
1394
+ if (configuredSessionId && canCap("sessions")) return configuredSessionId;
1395
+ if (sessionList.length) return sessionList[0].id;
1396
+ const sub = subscriber;
1397
+ try {
1398
+ if (sub && typeof sub.sessions === "function") {
1399
+ const ids = sub.sessions();
1400
+ if (Array.isArray(ids) && ids.length) return String(ids[0]);
1401
+ }
1402
+ } catch { /* 忽略 */ }
1403
+ return "";
1404
+ },
1405
+ currentSessionTitle: () => titleOfSession(boundSessionId || activeSession.id, activeSession.title || ""),
1406
+ cancelSession: async ({ sessionId }) => {
1407
+ const sub = subscriber;
1408
+ if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
1409
+ try {
1410
+ const r = await sub.cancelSession({ sessionId });
1411
+ return r && typeof r === "object" ? r : { ok: Boolean(r) };
1412
+ } catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
1413
+ },
1414
+ switchSession: async ({ sessionId, title }) => {
1415
+ const r = writeImBotsSession(relayDir, "wecom", { sessionId, sessionTitle: title });
1416
+ if (r.ok) {
1417
+ configuredSessionId = sessionId;
1418
+ configuredSessionTitle = truncateText(title || "", 60);
1419
+ writeStatus();
1420
+ }
1421
+ return r;
1422
+ },
1423
+ unbind: async () => {
1424
+ state.boundChatId = "";
1425
+ writeStatus();
1426
+ return { ok: true };
1427
+ },
1428
+ statusSnapshot: async () => ({
1429
+ connected: state.connected === true,
1430
+ authenticated: state.authenticated === true,
1431
+ state: state.connected ? "connected" : "idle",
1432
+ lastError: state.lastError || "",
1433
+ tier: tierNow(),
1434
+ usage: { month: monthKey(), messages: usageThisMonth() },
1435
+ messagesPerMonth: messageLimitNow(),
1436
+ remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
1437
+ currentSessionId: boundSessionId || activeSession.id || "",
1438
+ currentSessionTitle: titleOfSession(boundSessionId || activeSession.id, activeSession.title || "")
1439
+ })
1440
+ }
1441
+ });
1442
+ if (!cmd || !cmd.handled) return false;
1443
+ replyText(frame, cmd.text);
1444
+ emitter.emit("command", { ...info, command: cmd.command, ok: cmd.ok === true, gated: cmd.gated === true });
1445
+ log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
1446
+ return true;
1447
+ }
1448
+
1370
1449
  async function dispatchInbound(frame, info) {
1371
1450
  const sub = subscriber;
1451
+ // ⓪ 命令层:命中即执行并回复,**绝不进模型**,也不计入额度(/stop、/unbind 永远免费)
1452
+ const cmd = await handleCommand(frame, info);
1453
+ if (cmd) return;
1372
1454
  // ① 会员闸(通知/审批免费;派活受能力与月度额度约束,fail-closed)
1373
1455
  const gated = await membershipGate(frame, info);
1374
1456
  if (gated) return;
package/dsh-setup.mjs CHANGED
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
149
149
  * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
150
150
  * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
151
151
  */
152
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
152
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
153
153
 
154
154
  /**
155
155
  * 把"客户端脚本"补齐到配置目录。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.12-beta.5",
3
+ "version": "0.7.12-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",
@@ -56,7 +56,7 @@
56
56
  "test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
57
  "_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
58
58
  "test:bridge": "DSH_PROFILE_DIR= DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
59
- "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/wecom-bot.mjs && node --check clients/dsh-remote/im-format.mjs && node --check clients/dsh-remote/dingtalk-bot.mjs && node --check clients/dsh-remote/feishu-bot.mjs && node --check clients/dsh-remote/dingtalk-auth.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
59
+ "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/wecom-bot.mjs && node --check clients/dsh-remote/im-format.mjs && node --check clients/dsh-remote/dingtalk-bot.mjs && node --check clients/dsh-remote/feishu-bot.mjs && node --check clients/dsh-remote/dingtalk-auth.mjs && node --check clients/dsh-remote/im-commands.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
60
60
  "release:tarballs": "bash scripts/release-tarballs.sh",
61
61
  "sync:local": "node scripts/sync-local.mjs"
62
62
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.12-beta.5",
3
+ "version": "0.7.12-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",