@mrrisega/dsh-remote 0.7.12-beta.4 → 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.
@@ -49,6 +49,11 @@ const FAKE_CLIENT = "ding-fake-client-id";
49
49
 
50
50
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
51
51
 
52
+ /** 读 JSON 文件(不存在/坏 JSON → null)。 */
53
+ function readJson(file) {
54
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
55
+ }
56
+
52
57
  async function waitFor(fn, { label = "条件", timeout = 5000, interval = 20 } = {}) {
53
58
  const t0 = Date.now();
54
59
  for (;;) {
@@ -162,7 +167,7 @@ async function startWebhook(payload = { errcode: 0, errmsg: "ok" }, status = 200
162
167
  /** 最小 fake 订阅器(与 bridge 里 dsh-events 的对外接口同形)。 */
163
168
  function fakeSubscriber({ sessions = [{ sessionId: "session-live", running: true, title: "活跃会话" }], answer = "这是 agent 的最终回答" } = {}) {
164
169
  const emitter = new EventEmitter();
165
- const calls = { prompt: [], listSessions: 0, lastAssistant: 0 };
170
+ const calls = { prompt: [], listSessions: 0, lastAssistant: 0, cancel: [] };
166
171
  return {
167
172
  on(evt, fn) { emitter.on(evt, fn); return this; },
168
173
  once(evt, fn) { emitter.once(evt, fn); return this; },
@@ -172,6 +177,7 @@ function fakeSubscriber({ sessions = [{ sessionId: "session-live", running: true
172
177
  sessions() { return sessions.map((s) => s.sessionId); },
173
178
  async listSessions() { calls.listSessions += 1; return { ok: true, sessions }; },
174
179
  async promptSession({ sessionId, text }) { calls.prompt.push({ sessionId, text }); return { ok: true }; },
180
+ async cancelSession({ sessionId } = {}) { calls.cancel.push({ sessionId }); return { ok: true }; },
175
181
  async lastAssistantText() { calls.lastAssistant += 1; return { ok: true, text: answer }; },
176
182
  calls
177
183
  };
@@ -553,6 +559,185 @@ test("controller:DSH_IM_BOTS=0 全关(env 优先)", async () => {
553
559
 
554
560
  // ─────────────────────────── 常量/默认值 ───────────────────────────
555
561
 
562
+ // ─────────────────── 回复通道三层降级(2026-11-08 实测 bug:回答回传发不出) ───────────────────
563
+
564
+ /** 包一层 fetch:把 openapi(accessToken / oToMessages / groupMessages)拦成本地假响应并记录。 */
565
+ function makeOpenapiFetch() {
566
+ const calls = [];
567
+ const fetchImpl = (url, init) => {
568
+ const u = String(url);
569
+ if (u.includes("/oauth2/accessToken")) {
570
+ calls.push({ kind: "token", url: u, body: init && init.body });
571
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ accessToken: "tok-123", expireIn: 7200 }) });
572
+ }
573
+ if (u.includes("/robot/oToMessages/batchSend") || u.includes("/robot/groupMessages/send")) {
574
+ let body = null;
575
+ try { body = JSON.parse(init && init.body ? init.body : "{}"); } catch { body = null; }
576
+ calls.push({ kind: "send", url: u, body, headers: init && init.headers });
577
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ processQueryKey: "pq-1" }) });
578
+ }
579
+ return fetch(url, init);
580
+ };
581
+ return { fetchImpl, calls };
582
+ }
583
+
584
+ test("★ 回复通道①:入站缓存 sessionWebhook → 回答回传直接 POST 它(跨重启保持)", async () => {
585
+ const relayDir = tmpRelayDir();
586
+ const hook = await startWebhook();
587
+ const gateway = await startGateway();
588
+ const sub = fakeSubscriber({ answer: "重启后也要能回传答案" });
589
+ const botOptions = {
590
+ clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "ding-robot",
591
+ gatewayUrl: gateway.openUrl, relayDir, subscriber: sub,
592
+ logger: () => {}, reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
593
+ };
594
+ const bot1 = createDingtalkBot(botOptions);
595
+ try {
596
+ bot1.start();
597
+ await waitFor(() => bot1.status().connected, { label: "连接1" });
598
+ gateway.push({
599
+ type: "CALLBACK", headers: { messageId: "mid-c1", topic: TOPIC_ROBOT },
600
+ data: JSON.stringify({
601
+ conversationId: "cid-1", conversationType: "2", senderStaffId: "staff-9",
602
+ msgtype: "text", msgId: "m-c1", text: { content: "你好" },
603
+ sessionWebhook: hook.url, sessionWebhookExpiredTime: Date.now() + 5 * 60_000
604
+ })
605
+ });
606
+ await waitFor(() => hook.state.calls.length >= 1, { label: "被动回复" });
607
+ const st1 = bot1.status();
608
+ assert.equal(st1.replyChannel.kind, "sessionWebhook");
609
+ assert.equal(st1.replyChannel.hasWebhook, true);
610
+ await bot1.stop();
611
+ } finally {
612
+ await gateway.close();
613
+ }
614
+ // 重启(新进程语义):只靠磁盘缓存也要能回传
615
+ const gateway2 = await startGateway();
616
+ const bot2 = createDingtalkBot({ ...botOptions, gatewayUrl: gateway2.openUrl });
617
+ try {
618
+ bot2.start();
619
+ await waitFor(() => bot2.status().connected, { label: "连接2" });
620
+ assert.equal(bot2.status().replyChannel.kind, "sessionWebhook", "★ 重启后回复通道来自持久化缓存");
621
+ const before = hook.state.calls.length;
622
+ const r = await bot2.sendMarkdown("重启后的回答回传");
623
+ assert.equal(r.ok, true);
624
+ assert.equal(r.kind, "sessionWebhook");
625
+ await waitFor(() => hook.state.calls.length > before, { label: "重启后回传到 webhook" });
626
+ assert.match(hook.last().body.markdown.text, /重启后的回答回传/);
627
+ } finally {
628
+ await bot2.stop();
629
+ await gateway2.close();
630
+ await hook.close();
631
+ fs.rmSync(relayDir, { recursive: true, force: true });
632
+ }
633
+ });
634
+
635
+ test("★ 回复通道②:sessionWebhook 过期 → 降级 openapi(userIds=[senderStaffId])", async () => {
636
+ const relayDir = tmpRelayDir();
637
+ const gateway = await startGateway();
638
+ const hook = await startWebhook();
639
+ const api = makeOpenapiFetch();
640
+ const sub = fakeSubscriber({ answer: "x" });
641
+ const bot = createDingtalkBot({
642
+ clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "ding-robot",
643
+ gatewayUrl: gateway.openUrl, relayDir, subscriber: sub,
644
+ fetchImpl: api.fetchImpl, logger: () => {},
645
+ reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
646
+ });
647
+ try {
648
+ bot.start();
649
+ await waitFor(() => bot.status().connected, { label: "连接" });
650
+ gateway.push({
651
+ type: "CALLBACK", headers: { messageId: "mid-exp", topic: TOPIC_ROBOT },
652
+ data: JSON.stringify({
653
+ conversationId: "cid-exp", conversationType: "1", senderStaffId: "staff-exp",
654
+ msgtype: "text", msgId: "m-exp", text: { content: "hi" },
655
+ sessionWebhook: hook.url, sessionWebhookExpiredTime: Date.now() - 1000 // 已过期
656
+ })
657
+ });
658
+ await waitFor(() => bot.status().messages >= 1, { label: "入站" });
659
+ assert.equal(bot.status().replyChannel.kind, "openapi", "★ webhook 过期 → 显示 openapi 可用");
660
+ const hookCalls = hook.state.calls.length;
661
+ const r = await bot.sendMarkdown("降级 openapi 回答");
662
+ assert.equal(r.ok, true);
663
+ assert.equal(r.kind, "openapi");
664
+ const send = api.calls.filter((c) => c.kind === "send");
665
+ assert.equal(send.length, 1);
666
+ assert.deepEqual(send[0].body.userIds, ["staff-exp"], "★ 用缓存的 senderStaffId 发单聊");
667
+ assert.match(send[0].headers["x-acs-dingtalk-access-token"], /tok-123/);
668
+ assert.equal(hook.state.calls.length, hookCalls, "过期 webhook 不得再被调用");
669
+ } finally {
670
+ await bot.stop(); await gateway.close(); await hook.close();
671
+ fs.rmSync(relayDir, { recursive: true, force: true });
672
+ }
673
+ });
674
+
675
+ test("★ 回复通道③:webhook 与 openapi 都不可用 → 明确日志 no_reply_channel + 状态人话(不静默)", async () => {
676
+ const relayDir = tmpRelayDir();
677
+ const gateway = await startGateway();
678
+ const logs = [];
679
+ const bot = createDingtalkBot({
680
+ clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "", // 无 robotCode
681
+ gatewayUrl: gateway.openUrl, relayDir,
682
+ logger: (level, line) => logs.push(line),
683
+ reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
684
+ });
685
+ try {
686
+ bot.start();
687
+ await waitFor(() => bot.status().connected, { label: "连接" });
688
+ const r = await bot.sendMarkdown("发不出去的");
689
+ assert.equal(r.ok, false);
690
+ assert.match(String(r.reason), /no_reply_channel|no_robot_code|no_target_user|no_webhook/);
691
+ await waitFor(() => logs.join("\n").match(/no_reply_channel|no_robot_code/), { label: "明确日志" });
692
+ const st = bot.status();
693
+ assert.equal(st.replyChannel.kind, "none");
694
+ assert.ok(String(st.replyChannel.lastError || "").length > 0, "状态要有人话原因");
695
+ const stFile = readJson(path.join(relayDir, IM_BOT_STATUS_FILE));
696
+ assert.equal(stFile.dingtalk.replyChannel.kind, "none");
697
+ assert.match(String(stFile.dingtalk.replyChannel.lastError || ""), /回复通道|robotCode/, "面板可显示可操作原因(补 robotCode / 再发一条消息激活)");
698
+ } finally {
699
+ await bot.stop(); await gateway.close();
700
+ fs.rmSync(relayDir, { recursive: true, force: true });
701
+ }
702
+ });
703
+
704
+ test("★ 脱敏:日志/状态只出现 webhook host 与哈希,不含整条 webhook URL 或 secret", async () => {
705
+ const relayDir = tmpRelayDir();
706
+ const gateway = await startGateway();
707
+ const logs = [];
708
+ const sub = fakeSubscriber();
709
+ const bot = createDingtalkBot({
710
+ clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "ding-robot",
711
+ gatewayUrl: gateway.openUrl, relayDir, subscriber: sub,
712
+ logger: (level, line) => logs.push(line),
713
+ reconnectBaseMs: 40, reconnectMaxMs: 160, tierDenyThrottleMs: 0
714
+ });
715
+ try {
716
+ bot.start();
717
+ await waitFor(() => bot.status().connected, { label: "连接" });
718
+ const webhookUrl = "https://oapi.dingtalk.test/robot/sendBySession?session=TOKEN-SECRET-AAA";
719
+ gateway.push({
720
+ type: "CALLBACK", headers: { messageId: "mid-sec", topic: TOPIC_ROBOT },
721
+ data: JSON.stringify({
722
+ conversationId: "cid-sec", conversationType: "1", senderStaffId: "staff-sec",
723
+ msgtype: "text", msgId: "m-sec", text: { content: "hi" },
724
+ sessionWebhook: webhookUrl, sessionWebhookExpiredTime: Date.now() + 60_000
725
+ })
726
+ });
727
+ await waitFor(() => bot.status().messages >= 1, { label: "入站" });
728
+ await waitFor(() => logs.join("\n").includes("oapi.dingtalk.test"), { label: "host 日志" });
729
+ const joined = logs.join("\n");
730
+ assert.ok(!joined.includes("TOKEN-SECRET-AAA"), "★ 日志不得含 webhook 会话令牌");
731
+ assert.ok(!joined.includes(FAKE_SECRET), "★ 日志不得含 clientSecret");
732
+ const raw = fs.readFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), "utf8");
733
+ assert.ok(!raw.includes("TOKEN-SECRET-AAA"), "★ 状态文件不得含 webhook 原值");
734
+ assert.ok(!raw.includes(FAKE_SECRET));
735
+ } finally {
736
+ await bot.stop(); await gateway.close();
737
+ fs.rmSync(relayDir, { recursive: true, force: true });
738
+ }
739
+ });
740
+
556
741
  // ─────────────────── robotCode 可选(扫码一键创建机器人,2026-11-08) ───────────────────
557
742
 
558
743
  test("★ robotCode 可选:只给 clientId/clientSecret 也能启动、收/回消息;状态 capabilities 如实降级", async () => {
@@ -628,3 +813,35 @@ test("常量:默认 open 端点 / ticket 90s / 退避 1s→60s", () => {
628
813
  assert.equal(TICKET_TTL_MS, 90_000);
629
814
  assert.equal(TOPIC_ROBOT, "/v1.0/im/bot/messages/get");
630
815
  });
816
+
817
+ // ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
818
+
819
+ test("★ 命令层:/help 回复、免费档 /ls 被拦(会员文案)、/stop 免费急停;命令不进 promptSession", async () => {
820
+ const rt = await setupBot();
821
+ try {
822
+ await rt.start();
823
+ const pushCmd = async (msgId, text) => {
824
+ rt.gateway.push({
825
+ type: "CALLBACK", headers: { messageId: `mid-${msgId}`, topic: TOPIC_ROBOT },
826
+ data: JSON.stringify({
827
+ conversationId: "cid-cmd", conversationType: "2", senderStaffId: "staff-cmd",
828
+ msgtype: "text", msgId: `m-${msgId}`, text: { content: text },
829
+ sessionWebhook: rt.hook.url, sessionWebhookExpiredTime: Date.now() + 60_000
830
+ })
831
+ });
832
+ };
833
+ pushCmd("help", "/help");
834
+ await waitFor(() => rt.hook.state.calls.find((c) => /可用命令/.test(c.body.markdown.text)), { label: "/help 回复" });
835
+ assert.equal(rt.sub.calls.prompt.length, 0, "★ 命令不得进模型");
836
+ pushCmd("ls", "/ls");
837
+ await waitFor(() => rt.hook.state.calls.find((c) => /会员功能/.test(c.body.markdown.text)), { label: "/ls 会员拦截" });
838
+ assert.equal(rt.sub.calls.prompt.length, 0);
839
+ pushCmd("stop", "/stop");
840
+ await waitFor(() => rt.hook.state.calls.find((c) => /已急停|没有正在运行/.test(c.body.markdown.text)), { label: "/stop 回复" });
841
+ assert.equal(rt.sub.calls.cancel.length, 1, "★ /stop 调 cancelSession");
842
+ assert.equal(rt.sub.calls.prompt.length, 0);
843
+ // 普通文本照旧转发
844
+ await rt.inbound({ text: "帮我看看日志", msgId: "m-normal-cmd" });
845
+ await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "普通文本转发" });
846
+ } finally { await rt.close(); }
847
+ });
@@ -152,7 +152,7 @@ function makeFetch({ tokenCode = 0, sendCode = 0, sendMsg = "", patchCode = 0 }
152
152
  // ── 假事件订阅器 ──────────────────────────────────────────────────────────
153
153
  function makeSubscriber() {
154
154
  const emitter = new EventEmitter();
155
- const calls = { sessions: [], prompts: [], started: 0, stopped: 0, listenSessions: 0 };
155
+ const calls = { sessions: [], prompts: [], cancels: [], started: 0, stopped: 0, listenSessions: 0 };
156
156
  return {
157
157
  calls,
158
158
  on: (evt, cb) => emitter.on(evt, cb),
@@ -161,7 +161,8 @@ function makeSubscriber() {
161
161
  stop() { calls.stopped += 1; },
162
162
  sessions() { return ["session-abc"]; },
163
163
  async listSessions() { calls.listenSessions += 1; return [{ sessionId: "session-abc", title: "t" }]; },
164
- async promptSession(req) { calls.prompts.push(req); return { ok: true }; }
164
+ async promptSession(req) { calls.prompts.push(req); return { ok: true }; },
165
+ async cancelSession(req) { calls.cancels.push(req); return { ok: true }; }
165
166
  };
166
167
  }
167
168
 
@@ -1087,3 +1088,34 @@ test("向导:主路径只有两件事(填 App ID/Secret + 发一句话自动
1087
1088
  assert.match(FEISHU_SETUP_GUIDE.oneAppOnePc, /一台电脑/);
1088
1089
  assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
1089
1090
  });
1091
+
1092
+ // ─────────────────────── 命令层(2026-11-08:/ls 不再转给模型)───────────────────────
1093
+
1094
+ test("controller:命令层 — /help 直接回复、免费档 /ls 被拦、/stop 急停;命令绝不进模型", async () => {
1095
+ const relayDir = mkRelayDir("ctl-cmd");
1096
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
1097
+ const sub = makeSubscriber();
1098
+ const { controller, fetchCalls } = startController(relayDir, { subscriber: sub, capsProvider: async () => "free" });
1099
+ await controller.start();
1100
+ const lastText = () => {
1101
+ const rows = fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
1102
+ return rows.length ? JSON.parse(rows[rows.length - 1].body).content : "";
1103
+ };
1104
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("/help"));
1105
+ await waitFor(() => /可用命令/.test(lastText()));
1106
+ assert.match(lastText(), /可用命令/);
1107
+ assert.equal(sub.calls.prompts.length, 0, "★ 命令不得进模型");
1108
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("/ls"));
1109
+ await waitFor(() => /会员功能/.test(lastText()));
1110
+ assert.match(lastText(), /会员功能/, "免费档 /ls 必须被拦");
1111
+ assert.equal(sub.calls.prompts.length, 0);
1112
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("/stop"));
1113
+ await waitFor(() => /已急停|没有正在运行/.test(lastText()));
1114
+ assert.equal(sub.calls.cancels.length, 1, "★ /stop 调 cancelSession");
1115
+ assert.equal(sub.calls.prompts.length, 0, "★ 全部命令都不进模型");
1116
+ // 普通文本照旧转发
1117
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("继续跑"));
1118
+ await waitFor(() => sub.calls.prompts.length === 1);
1119
+ assert.equal(sub.calls.prompts.length, 1);
1120
+ await controller.stop();
1121
+ });
@@ -0,0 +1,8 @@
1
+ /** 测试共用的能力集夹具(与 wechat-runtime 的 WECHAT_CAPABILITY_TABLE 对齐,避免各测试各抄一份)。 */
2
+ export function freeCaps() {
3
+ return ["notify", "approve", "status", "stop", "assign.continue"];
4
+ }
5
+
6
+ export function paidCaps() {
7
+ return ["notify", "approve", "status", "stop", "assign", "sessions", "summary", "steer", "attach", "multi"];
8
+ }
@@ -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
+ });