@mrrisega/dsh-remote 0.7.12-beta.2 → 0.7.12-beta.3

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.
@@ -266,6 +266,10 @@ test("协议纯函数:open 请求体 / ACK(JSON 字符串 data)/ ping ACK
266
266
  const cfg = normalizeDingtalkConfig({ enabled: true, clientId: " a ", clientSecret: "s", replyMode: "OPENAPI", allowFrom: ["u1", "u2"] });
267
267
  assert.equal(cfg.clientId, "a");
268
268
  assert.equal(cfg.replyMode, "openapi");
269
+ assert.equal(cfg.credentialSource, "manual", "默认凭据来源 = manual");
270
+ assert.equal(normalizeDingtalkConfig({ credentialSource: "scan" }).credentialSource, "scan", "扫码授权写入 scan");
271
+ assert.equal(normalizeDingtalkConfig({ credentialSource: "SCAN" }).credentialSource, "scan");
272
+ assert.equal(normalizeDingtalkConfig({ credentialSource: "weird" }).credentialSource, "manual", "非法值回 manual");
269
273
  assert.deepEqual(cfg.allowFrom, ["u1", "u2"]);
270
274
  // 缺省/非法值不抛
271
275
  assert.equal(normalizeDingtalkConfig(null).enabled, false);
@@ -519,10 +523,16 @@ test("controller:未启用不建连;启用后按配置起 bot(注入 subsc
519
523
  // 改成启用 + 本地网关(streamUrl 覆盖)→ 热更新后连上
520
524
  fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
521
525
  version: 1,
522
- dingtalk: { enabled: true, clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET, robotCode: "rk", streamUrl: gateway.openUrl }
526
+ dingtalk: {
527
+ enabled: true, clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET,
528
+ robotCode: "rk", cardTemplateId: "card-tpl-9", credentialSource: "scan", streamUrl: gateway.openUrl
529
+ }
523
530
  }), { mode: 0o600 });
524
531
  await waitFor(() => controller.status().enabled === true, { label: "控制器启用(热更新)", timeout: 4000 });
525
532
  await waitFor(() => gateway.state.opens.length >= 1, { label: "按配置建连", timeout: 4000 });
533
+ const botStatus = controller.status().bot;
534
+ assert.equal(botStatus.credentialSource, "scan", "controller 要把 credentialSource 透传给 bot");
535
+ assert.deepEqual(botStatus.capabilities, { openapi: true, card: true }, "controller 要把 cardTemplateId 透传给 bot");
526
536
  await controller.stop();
527
537
  assert.equal(controller.status().enabled, false);
528
538
  assert.equal(controller.status().reason, "stopped");
@@ -543,6 +553,76 @@ test("controller:DSH_IM_BOTS=0 全关(env 优先)", async () => {
543
553
 
544
554
  // ─────────────────────────── 常量/默认值 ───────────────────────────
545
555
 
556
+ // ─────────────────── robotCode 可选(扫码一键创建机器人,2026-11-08) ───────────────────
557
+
558
+ test("★ robotCode 可选:只给 clientId/clientSecret 也能启动、收/回消息;状态 capabilities 如实降级", async () => {
559
+ const rt = await setupBot({ bot: { robotCode: "" } });
560
+ try {
561
+ await rt.start();
562
+ const st = rt.bot.status();
563
+ assert.equal(st.connected, true, "★ 没有 robotCode 也必须能连上");
564
+ assert.equal(st.robotCodeConfigured, false);
565
+ assert.deepEqual(st.capabilities, { openapi: false, card: false }, "★ robotCode 缺省 → openapi/card 降级");
566
+ // 收消息 + sessionWebhook 回复(主链路完全不依赖 robotCode)
567
+ await rt.inbound({ text: "扫码创建的机器人也能用", msgId: "norobot-1" });
568
+ await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站转发" });
569
+ const call = await waitFor(() => rt.hook.state.calls[0], { label: "sessionWebhook 回复" });
570
+ assert.match(call.body.markdown.text, /已转给桌面端会话/);
571
+ // 状态文件也标记降级 + 凭据来源
572
+ const d = JSON.parse(fs.readFileSync(path.join(rt.relayDir, IM_BOT_STATUS_FILE), "utf8")).dingtalk;
573
+ assert.deepEqual(d.capabilities, { openapi: false, card: false });
574
+ assert.equal(d.robotCodeConfigured, false);
575
+ assert.equal(d.credentialSource, "manual");
576
+ } finally { await rt.close(); }
577
+ });
578
+
579
+ test("★ openapi 模式缺 robotCode:给明确错误、不崩、不白打 token 请求;接收消息照常", async () => {
580
+ const fetchUrls = [];
581
+ const trackedFetch = (url, init) => { fetchUrls.push(String(url)); return fetch(url, init); };
582
+ const rt = await setupBot({ bot: { robotCode: "", replyMode: "openapi", fetchImpl: trackedFetch } });
583
+ try {
584
+ await rt.start();
585
+ // 主动发送:明确拒绝,带 detail 与 capabilities(不是抛异常/静默失败)
586
+ const r = await rt.bot.sendMarkdown("主动推送试试");
587
+ assert.equal(r.ok, false);
588
+ assert.equal(r.reason, "no_robot_code");
589
+ assert.match(String(r.detail || ""), /robotCode/);
590
+ assert.match(String(r.detail || ""), /接收消息|sessionWebhook/, "要说清主链路不受影响");
591
+ assert.deepEqual(r.capabilities, { openapi: false, card: false });
592
+ assert.ok(!fetchUrls.some((u) => u.includes("/oauth2/accessToken")), "★ 缺 robotCode 时不得白打一次 token 请求");
593
+ // 接收消息照常:openapi 回复失败但入站照常转给 DSH(不崩)
594
+ await rt.inbound({ text: "openapi 缺 robotCode 时的入站", msgId: "norobot-2" });
595
+ await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站转发" });
596
+ assert.equal(rt.bot.status().capabilities.openapi, false);
597
+ assert.equal(rt.bot.status().connected, true, "连接不受能力降级影响");
598
+ } finally { await rt.close(); }
599
+ });
600
+
601
+ test("★ capabilities 组合:robotCode 决定 openapi;card 还需 cardTemplateId;credentialSource 透出", async () => {
602
+ // ① 有 robotCode、无卡片模板 → openapi 可用、card 不可用
603
+ {
604
+ const rt = await setupBot({ bot: { robotCode: "ding-robot", credentialSource: "scan" } });
605
+ try {
606
+ await rt.start();
607
+ assert.deepEqual(rt.bot.status().capabilities, { openapi: true, card: false });
608
+ assert.equal(rt.bot.status().credentialSource, "scan", "扫码授权的来源要透出");
609
+ const d = JSON.parse(fs.readFileSync(path.join(rt.relayDir, IM_BOT_STATUS_FILE), "utf8")).dingtalk;
610
+ assert.deepEqual(d.capabilities, { openapi: true, card: false });
611
+ assert.equal(d.credentialSource, "scan");
612
+ } finally { await rt.close(); }
613
+ }
614
+ // ② robotCode + cardTemplateId → 两项都可用
615
+ {
616
+ const rt = await setupBot({ bot: { robotCode: "ding-robot", cardTemplateId: "card-tpl-1" } });
617
+ try {
618
+ await rt.start();
619
+ assert.deepEqual(rt.bot.status().capabilities, { openapi: true, card: true });
620
+ const d = JSON.parse(fs.readFileSync(path.join(rt.relayDir, IM_BOT_STATUS_FILE), "utf8")).dingtalk;
621
+ assert.deepEqual(d.capabilities, { openapi: true, card: true });
622
+ } finally { await rt.close(); }
623
+ }
624
+ });
625
+
546
626
  test("常量:默认 open 端点 / ticket 90s / 退避 1s→60s", () => {
547
627
  assert.equal(DINGTALK_OPEN_URL, "https://api.dingtalk.com/v1.0/gateway/connections/open");
548
628
  assert.equal(TICKET_TTL_MS, 90_000);
@@ -84,8 +84,8 @@ function fakeSdkDir(relayDir, version = "1.76.0") {
84
84
  }
85
85
 
86
86
  // ── 假 SDK ────────────────────────────────────────────────────────────────
87
- function makeFakeSdk({ connectError = null, connectionState = "connected" } = {}) {
88
- const calls = { ws: [], dispatchers: [], starts: [], closes: 0, ctors: 0 };
87
+ function makeFakeSdk({ connectError = null, connectionState = "connected", chats = [], chatListError = null, chatListCode = 0 } = {}) {
88
+ const calls = { ws: [], dispatchers: [], starts: [], closes: 0, ctors: 0, clientCtors: 0, chatListArgs: [] };
89
89
  class EventDispatcher {
90
90
  constructor(opts) { this.opts = opts; this.handlers = {}; calls.dispatchers.push(this); }
91
91
  register(handlers) { this.handlers = handlers; return this; }
@@ -100,7 +100,24 @@ function makeFakeSdk({ connectError = null, connectionState = "connected" } = {}
100
100
  getConnectionStatus() { return { state: connectionState }; }
101
101
  close() { calls.closes += 1; return Promise.resolve(); }
102
102
  }
103
- class Client { constructor(opts) { this.opts = opts; } }
103
+ class Client {
104
+ constructor(opts) {
105
+ calls.clientCtors += 1;
106
+ this.opts = opts;
107
+ // 对齐官方 1.76:client.im.v1.chat.list({ params });无权限/失败用 chatListError 模拟
108
+ this.im = {
109
+ v1: {
110
+ chat: {
111
+ list: async (args) => {
112
+ calls.chatListArgs.push(args);
113
+ if (chatListError) throw chatListError;
114
+ return { code: chatListCode, msg: chatListCode ? "list failed" : "", data: { items: chats } };
115
+ }
116
+ }
117
+ }
118
+ };
119
+ }
120
+ }
104
121
  return { sdk: { WSClient, EventDispatcher, Client }, calls };
105
122
  }
106
123
 
@@ -148,6 +165,16 @@ function makeSubscriber() {
148
165
  };
149
166
  }
150
167
 
168
+ /** 轮询等待(自动绑定等异步动作;不依赖真实 timers 精度)。 */
169
+ async function waitFor(fn, ms = 1500) {
170
+ const end = Date.now() + ms;
171
+ while (Date.now() < end) {
172
+ try { if (fn()) return true; } catch { /* 继续等 */ }
173
+ await new Promise((r) => setTimeout(r, 10));
174
+ }
175
+ return false;
176
+ }
177
+
151
178
  // ── 假消息/回调夹具(形状对齐官方事件) ──────────────────────────────────
152
179
  const inboundText = (text = "你好", { senderType = "user", openId = "ou_user_1" } = {}) => ({
153
180
  event: {
@@ -477,7 +504,6 @@ async function startBot({ sdkOpts, fetchOpts, botOpts = {} } = {}) {
477
504
  sdkFactory: () => fake.sdk,
478
505
  fetchImpl: f.fn,
479
506
  clock: at,
480
- receiveIdType: "open_id",
481
507
  logger: { info: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m), debug: () => {} },
482
508
  ...botOpts
483
509
  });
@@ -538,20 +564,89 @@ test("bot 建连:SDK start 抛凭据错 → 人话(凭据校验失败),
538
564
  assert.equal(bot.status().state, "failed");
539
565
  });
540
566
 
541
- test("bot 入站:文本消息 → 'message' 事件(归一化)+ 绑定 chatId;bot 自己消息被过滤", async () => {
567
+ test("bot 入站:文本消息 → 'message' 事件;**首次说话自动绑定**(单聊记 open_id)", async () => {
542
568
  const { bot } = await startBot();
543
569
  const got = [];
544
570
  bot.on("message", (m) => got.push(m));
571
+ // 启动时还没有接收人,也不需要配置
572
+ assert.equal(bot.status().boundChatId, "");
573
+ assert.equal(bot.status().receiveIdSource, "none");
545
574
  bot._dispatch("im.message.receive_v1", inboundText("你好"));
546
575
  assert.equal(got.length, 1);
547
576
  assert.equal(got[0].text, "你好");
548
577
  assert.equal(got[0].chatId, "oc_chat_1");
549
- assert.equal(bot.status().boundChatId, "oc_chat_1");
578
+ const st = bot.status();
579
+ assert.equal(st.boundChatId, "ou_user_1", "单聊绑定发送者 open_id(不用用户填)");
580
+ assert.equal(st.boundChatType, "open_id");
581
+ assert.equal(st.receiveIdSource, "auto");
550
582
  bot._dispatch("im.message.receive_v1", inboundText("自己", { senderType: "bot" }));
551
583
  assert.equal(got.length, 1, "bot 自己的消息不能触发");
552
584
  await bot.stop();
553
585
  });
554
586
 
587
+ /** 群聊入站夹具:chat_type=group。 */
588
+ const inboundGroup = (text = "群里 @ 它") => ({
589
+ event: {
590
+ sender: { sender_id: { open_id: "ou_user_1" }, sender_type: "user" },
591
+ message: {
592
+ message_id: "om_in_g1",
593
+ chat_id: "oc_group_9",
594
+ chat_type: "group",
595
+ message_type: "text",
596
+ content: JSON.stringify({ text })
597
+ }
598
+ }
599
+ });
600
+
601
+ test("bot 入站:群聊自动绑定 chat_id;单聊/群聊的推送类型自动判断", async () => {
602
+ const { bot, fetchCalls } = await startBot();
603
+ bot._dispatch("im.message.receive_v1", inboundText("私聊"));
604
+ assert.equal(bot.status().boundChatId, "ou_user_1");
605
+ assert.equal(bot.status().boundChatType, "open_id");
606
+ await bot.sendText(undefined, "给单聊用户");
607
+ const p2pCall = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
608
+ assert.match(p2pCall.url, /receive_id_type=open_id/);
609
+ assert.equal(JSON.parse(p2pCall.body).receive_id, "ou_user_1");
610
+
611
+ bot._dispatch("im.message.receive_v1", inboundGroup("群里"));
612
+ assert.equal(bot.status().boundChatId, "oc_group_9");
613
+ assert.equal(bot.status().boundChatType, "chat_id");
614
+ await bot.sendText(undefined, "给群");
615
+ const groupCall = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
616
+ assert.match(groupCall.url, /receive_id_type=chat_id/);
617
+ assert.equal(JSON.parse(groupCall.body).receive_id, "oc_group_9");
618
+ await bot.stop();
619
+ });
620
+
621
+ test("bot 发送:配置 receiveId/receiveIdType 优先于自动绑定(高级场景)", async () => {
622
+ const { bot, fetchCalls } = await startBot({
623
+ botOpts: { receiveId: "oc_configured", receiveIdType: "chat_id" }
624
+ });
625
+ assert.equal(bot.status().receiveIdSource, "config");
626
+ assert.equal(bot.status().receiveIdConfigured, true);
627
+ bot._dispatch("im.message.receive_v1", inboundText("私聊")); // 自动绑定仍记,但不影响配置优先
628
+ assert.equal(bot.status().receiveIdSource, "config");
629
+ await bot.sendText(undefined, "配置优先");
630
+ const call = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
631
+ assert.equal(JSON.parse(call.body).receive_id, "oc_configured");
632
+ assert.match(call.url, /receive_id_type=chat_id/);
633
+ // 显式 opts.receiveIdType 覆盖配置
634
+ await bot.sendText(undefined, "覆盖", { receiveIdType: "open_id" });
635
+ const override = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
636
+ assert.match(override.url, /receive_id_type=open_id/);
637
+ await bot.stop();
638
+ });
639
+
640
+ test("bot 发送:未绑定也无配置 → 返回明确 reason,不发请求(不报错)", async () => {
641
+ const { bot, fetchCalls } = await startBot();
642
+ const before = fetchCalls.length;
643
+ const r = await bot.sendText(undefined, "还没人跟我说话");
644
+ assert.equal(r.ok, false);
645
+ assert.match(r.reason, /还没绑定接收人/);
646
+ assert.equal(fetchCalls.length, before, "未绑定不该发任何 HTTP");
647
+ await bot.stop();
648
+ });
649
+
555
650
  test("bot 入站:白名单外的人被拒(回一句提示,不触发 message)", async () => {
556
651
  const { bot, fetchCalls } = await startBot({ botOpts: { allowFrom: ["ou_allowed"] } });
557
652
  const got = [];
@@ -630,6 +725,112 @@ test("bot.stop:关闭 WSClient 且幂等;状态回到 idle", async () => {
630
725
  assert.equal(fake.calls.closes, 1, "幂等");
631
726
  });
632
727
 
728
+ test("chat.list 自动选群:只有 1 个群 → 连上就绑定(不用用户先说话)", async () => {
729
+ const { bot } = await startBot({ sdkOpts: { chats: [{ chat_id: "oc_auto_1", name: "项目群" }] } });
730
+ assert.ok(await waitFor(() => bot.status().boundChatId === "oc_auto_1"), "应自动绑定唯一群");
731
+ const st = bot.status();
732
+ assert.equal(st.boundChatType, "chat_id");
733
+ assert.equal(st.boundChatName, "项目群");
734
+ assert.equal(st.receiveIdSource, "auto_group");
735
+ assert.equal(st.chatCount, 1);
736
+ assert.equal(st.receiveIdConfigured, false);
737
+ await bot.stop();
738
+ });
739
+
740
+ test("chat.list 多群 → 取第一个并记录群数;用户发消息即改绑", async () => {
741
+ const { bot, fetchCalls } = await startBot({
742
+ sdkOpts: { chats: [{ chat_id: "oc_g1", name: "群1" }, { chat_id: "oc_g2", name: "群2" }] }
743
+ });
744
+ assert.ok(await waitFor(() => bot.status().boundChatId === "oc_g1"));
745
+ assert.equal(bot.status().chatCount, 2);
746
+ assert.equal(bot.status().receiveIdSource, "auto_group");
747
+ await bot.sendText(undefined, "发到第一个群");
748
+ const call = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
749
+ assert.match(call.url, /receive_id_type=chat_id/);
750
+ assert.equal(JSON.parse(call.body).receive_id, "oc_g1");
751
+ // 用户私聊 → 改绑到该用户
752
+ bot._dispatch("im.message.receive_v1", inboundText("私聊改绑"));
753
+ assert.equal(bot.status().boundChatId, "ou_user_1");
754
+ assert.equal(bot.status().boundChatType, "open_id");
755
+ assert.equal(bot.status().receiveIdSource, "auto");
756
+ await bot.stop();
757
+ });
758
+
759
+ test("chat.list 无权限/失败 → 只记日志,不影响连接与「发消息绑定」", async () => {
760
+ const { bot, logs } = await startBot({
761
+ sdkOpts: { chatListError: Object.assign(new Error("permission denied: im:chat:readonly"), { code: 99991672 }) }
762
+ });
763
+ await new Promise((r) => setTimeout(r, 30));
764
+ assert.equal(bot.status().connected, true, "连接不受影响");
765
+ assert.equal(bot.status().boundChatId, "");
766
+ assert.equal(bot.status().receiveIdSource, "none");
767
+ assert.ok(logs.some((l) => /chat\.list 失败/.test(l)), "应有可读日志");
768
+ bot._dispatch("im.message.receive_v1", inboundText("先说话"));
769
+ assert.equal(bot.status().boundChatId, "ou_user_1");
770
+ assert.equal(bot.status().receiveIdSource, "auto");
771
+ await bot.stop();
772
+ });
773
+
774
+ test("chat.list 空列表 → 不绑定,等首条私聊消息(open_id)", async () => {
775
+ const { bot } = await startBot({ sdkOpts: { chats: [] } });
776
+ assert.ok(await waitFor(() => bot.status().chatCount === 0));
777
+ assert.equal(bot.status().boundChatId, "");
778
+ assert.equal(bot.status().receiveIdSource, "none");
779
+ bot._dispatch("im.message.receive_v1", inboundText("你好"));
780
+ assert.equal(bot.status().boundChatId, "ou_user_1");
781
+ assert.equal(bot.status().boundChatType, "open_id");
782
+ await bot.stop();
783
+ });
784
+
785
+ test("配置优先:填了 receiveId 时不被 chat.list 覆盖,但群数仍记录,发送走配置", async () => {
786
+ const { bot, fetchCalls } = await startBot({
787
+ botOpts: { receiveId: "oc_cfg", receiveIdType: "chat_id" },
788
+ sdkOpts: { chats: [{ chat_id: "oc_auto", name: "群" }] }
789
+ });
790
+ assert.ok(await waitFor(() => bot.status().chatCount === 1));
791
+ assert.equal(bot.status().receiveIdSource, "config");
792
+ assert.equal(bot.status().boundChatId, "", "配置优先时不写自动绑定");
793
+ assert.equal(bot.status().receiveIdConfigured, true);
794
+ await bot.sendText(undefined, "发配置目标");
795
+ const call = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
796
+ assert.equal(JSON.parse(call.body).receive_id, "oc_cfg");
797
+ assert.match(call.url, /receive_id_type=chat_id/);
798
+ await bot.stop();
799
+ });
800
+
801
+ test("真实配置形态:receiveId 空但插件写了默认 receiveIdType=open_id → 群绑定仍按 chat_id 发送", async () => {
802
+ // 复刻 ~/.dsh-remote/im-bots.json 的实际形态:receiveId=""、receiveIdType="open_id"、boundChatId=oc_…
803
+ const { bot, fetchCalls } = await startBot({
804
+ botOpts: { receiveId: "", receiveIdType: "open_id", boundChatId: "oc_real" },
805
+ sdkOpts: { chats: [{ chat_id: "oc_real", name: "真实群" }] }
806
+ });
807
+ assert.ok(await waitFor(() => bot.status().chatCount === 1));
808
+ assert.equal(bot.status().receiveIdSource, "auto");
809
+ assert.equal(bot.status().boundChatType, "chat_id");
810
+ assert.equal(bot.status().boundChatName, "真实群", "预填目标也要能从 chat.list 补群名");
811
+ await bot.sendText(undefined, "发到群");
812
+ const call = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
813
+ assert.match(call.url, /receive_id_type=chat_id/, "receiveIdType=open_id 不能套到群 chat_id 上");
814
+ assert.equal(JSON.parse(call.body).receive_id, "oc_real");
815
+ await bot.stop();
816
+ });
817
+
818
+ test("配置预填 boundChatId 优先于 chat.list(不被第一个群覆盖);仍记录群数", async () => {
819
+ const { bot, fetchCalls } = await startBot({
820
+ botOpts: { boundChatId: "oc_seeded" },
821
+ sdkOpts: { chats: [{ chat_id: "oc_other", name: "别的群" }, { chat_id: "oc_g2", name: "群2" }] }
822
+ });
823
+ assert.ok(await waitFor(() => bot.status().chatCount === 2));
824
+ assert.equal(bot.status().boundChatId, "oc_seeded", "预填目标不能被覆盖");
825
+ assert.equal(bot.status().boundChatType, "chat_id");
826
+ assert.equal(bot.status().receiveIdSource, "auto");
827
+ await bot.sendText(undefined, "发到预填群");
828
+ const call = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
829
+ assert.equal(JSON.parse(call.body).receive_id, "oc_seeded");
830
+ assert.match(call.url, /receive_id_type=chat_id/);
831
+ await bot.stop();
832
+ });
833
+
633
834
  // ===========================================================================
634
835
  // E. Controller:配置 / 状态文件 / 会员闸 / 通知
635
836
  // ===========================================================================
@@ -646,7 +847,7 @@ function startController(relayDir, opts = {}) {
646
847
  sdkFactory: () => fake.sdk,
647
848
  fetchImpl: f.fn,
648
849
  subscriberFactory: () => sub,
649
- log: (line) => logs.push(String(line)),
850
+ log: (level, line) => logs.push(String(line ?? level)),
650
851
  configPollMs: 10,
651
852
  ...opts,
652
853
  // 允许覆盖
@@ -780,6 +981,43 @@ test("controller:审批事件 → 发可点卡片(interactive);turn_end
780
981
  await controller.stop();
781
982
  });
782
983
 
984
+ test("controller:接收人自动绑定 —— 未绑定时通知跳过(不报错),首次说话后发到自动绑定目标", async () => {
985
+ const relayDir = mkRelayDir("ctl-autobind");
986
+ // 关键:**不填 receiveId**(业主口径:用户不知道也不用填)
987
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET });
988
+ const sub = makeSubscriber();
989
+ const { controller, fetchCalls, logs } = startController(relayDir, { subscriber: sub });
990
+ await controller.start();
991
+ assert.equal(controller._bot().status().receiveIdSource, "none");
992
+
993
+ // 未绑定:通知跳过(只记日志,不报错、不发 HTTP)
994
+ sub.emit("event", { kind: "approval-request", eventId: "apr_auto_1", toolName: "Bash", sessionId: "s1" });
995
+ await new Promise((r) => setTimeout(r, 20));
996
+ assert.equal(fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).length, 0, "未绑定时不该发");
997
+ assert.ok(logs.some((l) => /还没绑定接收方/.test(l)), "应记一条可读日志");
998
+ const st0 = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
999
+ assert.equal(st0.feishu.boundChatId, "");
1000
+ assert.equal(st0.feishu.receiveIdSource, "none");
1001
+
1002
+ // 首次说话 → 自动绑定(单聊记 open_id)
1003
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("你好"));
1004
+ await new Promise((r) => setTimeout(r, 20));
1005
+ const st1 = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
1006
+ assert.equal(st1.feishu.boundChatId, "ou_user_1");
1007
+ assert.equal(st1.feishu.boundChatType, "open_id");
1008
+ assert.equal(st1.feishu.receiveIdSource, "auto");
1009
+
1010
+ // 绑定后:审批卡片发到自动绑定的 open_id
1011
+ sub.emit("event", { kind: "approval-request", eventId: "apr_auto_2", toolName: "Bash", sessionId: "s1" });
1012
+ await new Promise((r) => setTimeout(r, 20));
1013
+ const card = fetchCalls.filter((c) => c.url.includes("/im/v1/messages")).pop();
1014
+ assert.ok(card, "绑定后应发卡片");
1015
+ assert.match(card.url, /receive_id_type=open_id/);
1016
+ assert.equal(JSON.parse(card.body).receive_id, "ou_user_1");
1017
+ assert.equal(JSON.parse(card.body).msg_type, "interactive");
1018
+ await controller.stop();
1019
+ });
1020
+
783
1021
  test("controller:热更新(改 appId)→ 重建长连接", async () => {
784
1022
  const relayDir = mkRelayDir("ctl-reload");
785
1023
  writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner" });
@@ -828,12 +1066,24 @@ test("controller:日志里绝不出现 appSecret", async () => {
828
1066
  // F. 配置页向导契约(供插件半渲染;不画 UI)
829
1067
  // ===========================================================================
830
1068
 
831
- test("8 步向导:官方链接齐全、步骤 8 条、含顺序坑与「一个应用一台电脑」", () => {
832
- assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
833
- assert.match(FEISHU_SETUP_GUIDE.docs.longConnectionEvent, /long-connection|event-subscription/);
1069
+ test("向导:主路径只有两件事(填 App ID/Secret + 发一句话自动绑定)+ 业主给的官方链接", () => {
1070
+ // 业主 2026-11-08 给的链接(一字不改)
1071
+ assert.equal(FEISHU_SETUP_GUIDE.docs.createApp, "https://open.feishu.cn/app?lang=zh-CN");
1072
+ assert.equal(
1073
+ FEISHU_SETUP_GUIDE.docs.cardInteractiveBot,
1074
+ "https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot"
1075
+ );
1076
+ assert.equal(FEISHU_SETUP_GUIDE.quickPath.length, 2, "主路径只留两件事");
1077
+ assert.match(FEISHU_SETUP_GUIDE.quickPath[0], /App ID \/ App Secret/);
1078
+ assert.match(FEISHU_SETUP_GUIDE.quickPath[0], /接收人不用填/);
1079
+ assert.match(FEISHU_SETUP_GUIDE.quickPath[1], /发一句话/);
1080
+ assert.match(FEISHU_SETUP_GUIDE.bindingHint, /自动记住这个会话/);
1081
+ assert.equal(FEISHU_SETUP_GUIDE.detailsLabel, "需要更细的步骤", "其余步骤折叠");
1082
+ // 折叠的详细步骤仍在(后台那 7 项不能省)
834
1083
  assert.equal(FEISHU_SETUP_GUIDE.steps.length, 8);
835
1084
  assert.match(FEISHU_SETUP_GUIDE.steps[4], /长连接接收事件/);
836
1085
  assert.match(FEISHU_SETUP_GUIDE.steps[5], /card\.action\.trigger/);
837
1086
  assert.match(FEISHU_SETUP_GUIDE.orderWarning, /长连接在线/);
838
1087
  assert.match(FEISHU_SETUP_GUIDE.oneAppOnePc, /一台电脑/);
1088
+ assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
839
1089
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.12-beta.2",
3
+ "version": "0.7.12-beta.3",
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",