@mrrisega/dsh-remote 0.7.12-beta.1 → 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.
Files changed (31) hide show
  1. package/clients/dsh-remote/dingtalk-bot.mjs +1361 -0
  2. package/clients/dsh-remote/docs/feishu-bot-contract.md +217 -0
  3. package/clients/dsh-remote/dsh-bridge.mjs +404 -90
  4. package/clients/dsh-remote/feishu-bot.mjs +1852 -0
  5. package/clients/dsh-remote/mirror-diag-script.js +53 -14
  6. package/clients/dsh-remote/mobile-adapter.mjs +355 -40
  7. package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
  8. package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
  9. package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
  10. package/clients/dsh-remote/test/dingtalk-bot.test.mjs +630 -0
  11. package/clients/dsh-remote/test/feishu-bot.test.mjs +1089 -0
  12. package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
  13. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
  14. package/dsh-setup.mjs +1 -1
  15. package/package.json +2 -2
  16. package/packages/dsh-remote-web/lib/client.js +500 -4
  17. package/packages/dsh-remote-web/lib/index.js +581 -27
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +404 -84
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
  28. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +78 -7
  29. package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +14 -1
  30. package/packages/dsh-remote-web/test/im-bots.test.mjs +376 -2
  31. package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -103
@@ -99,6 +99,9 @@ function loadPlugin(opts = {}) {
99
99
  const injects = new Map();
100
100
  const requests = [];
101
101
  const clipboard = [];
102
+ const depsCalls = [];
103
+ const dingAuthCalls = [];
104
+ const depsInstalls = [];
102
105
  let unmounted = false;
103
106
 
104
107
  // ── 迷你 React:真实 useState/useEffect + 递归展开**函数子组件** ──
@@ -192,6 +195,9 @@ function loadPlugin(opts = {}) {
192
195
  let imBotsGetReply = { ok: true, wecom: { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 };
193
196
  let imBotsPutReply = { status: 200, body: { ok: true, wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "" }, status: { connected: false, authenticated: false, lastError: "" }, updatedAt: 0 } };
194
197
  let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
198
+ let imDepsReply = { installed: false, phase: "idle", version: "", lastError: "" };
199
+ let dingAuthBeginReply = { status: 200, body: { ok: true, authId: "auth-1", url: "https://oapi.dingtalk.com/x?nonce=1", interval: 2, expiresIn: 120 } };
200
+ let dingAuthStatusReplies = [];
195
201
  const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
196
202
 
197
203
  const makeEl = () => ({
@@ -255,6 +261,24 @@ function loadPlugin(opts = {}) {
255
261
  imBotsCalls.push({ method: "POST", body: options.body ? JSON.parse(options.body) : null });
256
262
  return response(imBotsTestReply.status, imBotsTestReply.body);
257
263
  }
264
+ if (path === "/dsh-remote/im-dingtalk-auth/begin") {
265
+ dingAuthCalls.push({ kind: "begin", method: options.method || "POST" });
266
+ return response(dingAuthBeginReply.status, dingAuthBeginReply.body);
267
+ }
268
+ if (path.startsWith("/dsh-remote/im-dingtalk-auth/status")) {
269
+ dingAuthCalls.push({ kind: "status", path });
270
+ const next = dingAuthStatusReplies.length ? dingAuthStatusReplies.shift() : { status: "waiting" };
271
+ return response(200, next);
272
+ }
273
+ if (path === "/dsh-remote/im-deps") {
274
+ depsCalls.push(Date.now());
275
+ return response(200, { ok: true, feishu: imDepsReply });
276
+ }
277
+ if (path === "/dsh-remote/im-deps/feishu") {
278
+ depsInstalls.push(Date.now());
279
+ imDepsReply = { ...imDepsReply, phase: "downloading", installed: false };
280
+ return response(200, { ok: true, started: true, deps: { feishu: imDepsReply } });
281
+ }
258
282
  return response(200, { ok: true });
259
283
  },
260
284
  Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console
@@ -321,6 +345,12 @@ function loadPlugin(opts = {}) {
321
345
  setImBotsGet(body) { imBotsGetReply = body; },
322
346
  setImBotsPut(body, status = 200) { imBotsPutReply = { status, body }; },
323
347
  setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
348
+ setImDeps(body) { imDepsReply = body; },
349
+ setDingAuthBegin(body, status = 200) { dingAuthBeginReply = { status, body }; },
350
+ setDingAuthStatus(list) { dingAuthStatusReplies = (list || []).slice(); },
351
+ dingAuthCalls() { return dingAuthCalls.slice(); },
352
+ depsCalls() { return depsCalls.slice(); },
353
+ depsInstalls() { return depsInstalls.slice(); },
324
354
  imBotsCalls() { return imBotsCalls.slice(); },
325
355
  /** 向 document 派发键盘事件(测试 Esc / Tab 的弹层契约)。 */
326
356
  fireKey(key, init = {}) {
@@ -667,141 +697,376 @@ test("会保存:选中某会话 → PUT 带 sessionId + sessionTitle;选回
667
697
  assert.equal(sent.body.wecom.sessionTitle, "", "★ 清空时标题也必须清空(否则 UI 会显示错目标)");
668
698
  });
669
699
 
670
- // ───────────────────── ⑥ 飞书 / 钉钉:诚实的「即将支持」 ─────────────────────
700
+ // ───────────────────── ⑥ 钉钉 / 飞书通道(2026-11-08 本轮支持) ─────────────────────
671
701
 
672
- test("平台列表:飞书 / 钉钉都标注「即将支持」,不放进假表单", async () => {
702
+ test("平台列表:企业微信 / 钉钉 / 飞书 / Webhook 都是真入口(不再有「即将支持」),Webhook 注明免费", async () => {
673
703
  const plugin = loadPlugin();
674
704
  let tree = await plugin.settle();
675
705
  openEntry(tree, "channel");
676
706
  tree = await plugin.settle();
677
- const rows = byClass(tree, "dru-ia-row");
678
- const texts = rows.map((r) => textOf(r));
679
- assert.ok(texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书行必须标注即将支持");
680
- assert.ok(texts.some((t) => t.includes("钉钉机器人") && t.includes("即将支持")), "钉钉行必须标注即将支持");
681
- assert.ok(texts.some((t) => t.includes("企业微信智能机器人") && !t.includes("即将支持")), "企微行是已支持(主推)");
707
+ const texts = byClass(tree, "dru-ia-row").map((r) => textOf(r));
708
+ assert.ok(texts.some((t) => t.includes("企业微信智能机器人")), "企业微信在列表里(已支持)");
709
+ assert.ok(texts.some((t) => t.includes("钉钉机器人")), "钉钉机器人必须在列表里(本轮支持)");
710
+ assert.ok(texts.some((t) => t.includes("飞书机器人")), "飞书机器人必须在列表里(本轮支持,按需下载依赖)");
711
+ assert.ok(texts.some((t) => t.includes("Webhook") && t.includes("免费")), "高级 Webhook 保留并注明免费");
712
+ assert.ok(!texts.some((t) => t.includes("钉钉机器人") && t.includes("即将支持")), "钉钉不再是即将支持");
713
+ assert.ok(!texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书不再是即将支持");
682
714
  });
683
715
 
684
- test("飞书/钉钉:点进去只有说明 + 「告诉我,优先支持」;**没有输入框、没有提交**,点优先支持走现有反馈", async () => {
716
+ test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成功)+ 顶部「不用去后台」说明;手动配置折叠", async () => {
685
717
  const plugin = loadPlugin();
686
- for (const [row, name] of [["飞书机器人", "飞书机器人"], ["钉钉机器人", "钉钉机器人"]]) {
687
- const p = loadPlugin();
688
- let tree = await p.settle();
689
- openEntry(tree, "channel");
690
- tree = await p.settle();
691
- assert.ok(clickChannelRow(tree, row), "应能点开 " + row);
692
- tree = await p.settle();
693
- const dlg = dialogOf(tree);
694
- assert.ok(dlg, row + " 必须打开弹层(不是假入口)");
695
- const text = textOf(dlg);
696
- assert.ok(text.includes("即将支持"), row + " 必须标注「即将支持」");
697
- assert.ok(text.includes("企业管理员"), row + " 说明要写清为什么现在还接不了(企业管理员配置应用)");
698
- assert.equal(find(dlg, (n) => n.type === "input" || n.type === "select"), undefined,
699
- "★ 不许放任何输入框/下拉(假表单)");
700
- const tell = btnWith(dlg, "告诉我,优先支持");
701
- assert.ok(tell, "必须有「告诉我,优先支持」按钮(复用反馈通道)");
702
- const before = p.imBotsCalls().length;
703
- tell.props.onClick();
704
- tree = await p.settle();
705
- assert.equal(dialogOf(tree), undefined, "点「优先支持」后关闭弹层");
706
- assert.equal(p.imBotsCalls().length, before, "★ 不得调用 im-bots 写接口(不新增后端)");
707
- const dbgText = textOf(tree);
708
- assert.ok(dbgText.includes("想要 " + name), "反馈视图应带一句引导(告知用户写什么);实际视图文本片段=" + dbgText.slice(0, 200));
709
- void plugin;
710
- }
718
+ plugin.setImBotsGet({
719
+ ok: true,
720
+ wecom: {}, feishu: {},
721
+ dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
722
+ status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
723
+ });
724
+ plugin.setDingAuthStatus([{ status: "waiting" }, { status: "success", clientIdTail: "8899", credentialSource: "scan", hasSecret: true }]);
725
+ let tree = await plugin.settle();
726
+ openEntry(tree, "channel");
727
+ tree = await plugin.settle();
728
+ assert.ok(clickChannelRow(tree, "钉钉机器人"), "应能进钉钉配置页");
729
+ tree = await plugin.settle();
730
+ let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
731
+ assert.ok(dlg, "钉钉配置页应打开");
732
+ // 主路径:一句说明 + 扫码按钮
733
+ assert.ok(textOf(dlg).includes("不用去开发者后台创建应用") || textOf(dlg).includes("不需要去开发者后台创建应用"),
734
+ "顶部要说明不需要去开发者后台创建应用");
735
+ const scanBtn = btnWith(dlg, "用钉钉扫码,一键创建机器人");
736
+ assert.ok(scanBtn, "主路径必须是一个「扫码一键创建」按钮");
737
+ // 手动配置默认折叠(不占主路径)
738
+ assert.equal(find(dlg, (n) => n.props?.id === "dru-ding-client"), undefined, "手动字段默认不应出现(折叠在高级里)");
739
+
740
+ // 点扫码 → begin
741
+ scanBtn.props.onClick();
742
+ await flush(); await flush();
743
+ tree = await plugin.settle(3);
744
+ assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "begin"), "★ 点扫码必须 POST begin");
745
+ dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
746
+ assert.ok(textOf(dlg).includes("等待你在钉钉里确认"), "要显示等待确认中");
747
+ assert.ok(textOf(dlg).includes("用手机钉钉扫"), "没有二维码库时要提示用手机钉钉扫链接");
748
+ const link = find(dlg, (n) => n.type === "a" && String(n.props?.href || "").includes("oapi.dingtalk.com"));
749
+ assert.ok(link, "要给出可点击的授权链接");
750
+ assert.equal(link.props.target, "_blank");
751
+ assert.equal(link.props.rel, "noopener");
752
+ assert.ok(btnWith(dlg, "复制授权链接"), "要有复制授权链接按钮");
753
+
754
+ // 轮询:waiting → success(fake timers 推进 2 轮)
755
+ await plugin.advance(2100);
756
+ await flush(); await flush();
757
+ tree = await plugin.settle(3);
758
+ await plugin.advance(2100);
759
+ await flush(); await flush();
760
+ tree = await plugin.settle(4);
761
+ assert.ok(plugin.dingAuthCalls().filter((c) => c.kind === "status").length >= 1, "要轮询授权状态");
762
+ assert.ok(textOf(tree).includes("已创建机器人并建立连接"), "成功后要自动切到已连接状态");
763
+ assert.ok(textOf(tree).includes("8899"), "成功提示要带 Client ID 尾号");
764
+ dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
765
+ assert.ok(dlg, "成功后弹层仍开着(用户能直接看到结果/去钉钉试)");
766
+ assert.ok(!textOf(dlg).includes("等待你在钉钉里确认"), "成功后不再显示等待态");
711
767
  });
712
768
 
713
- // ───────────────────── ⑦ 配置页收口:说明折叠 + 统一字段结构 ─────────────────────
714
-
715
- test("配置页收口:说明默认折叠(「为什么要填这些」),四个字段统一 dru-ia-field 结构", async () => {
769
+ test("钉钉手动模式:折叠展开后 7 步 + 三个坑 + 三字段;官方链接全部 target=_blank rel=noopener 且提交体字段锁死", async () => {
716
770
  const plugin = loadPlugin();
717
- const tree = await openWecom(plugin);
718
- // 折叠:默认看不到服务地址字段
719
- assert.equal(find(tree, (n) => n.props?.id === "dru-wecom-url"), undefined, "高级项默认折叠(不在主表单里)");
720
- const disclose = find(tree, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("为什么要填这些")));
721
- assert.ok(disclose, "必须有「为什么要填这些」折叠按钮");
722
- assert.equal(disclose.props["aria-expanded"], "false");
723
- disclose.props.onClick();
724
- const tree2 = await plugin.settle();
725
- assert.ok(find(tree2, (n) => n.props?.id === "dru-wecom-url"), "展开后才出现高级字段");
726
- // 四个字段(会话 / Bot ID / Secret / 接收会话 ID)都在 .dru-ia-field 里,label 可见
727
- const fields = byClass(tree2, "dru-ia-field");
728
- assert.ok(fields.length >= 4, "字段必须用统一 dru-ia-field 结构(实际 " + fields.length + ")");
729
- for (const id of ["dru-wecom-session", "dru-wecom-bot", "dru-wecom-secret", "dru-wecom-chat"]) {
730
- const ctl = find(tree2, (n) => (n.type === "input" || n.type === "select") && n.props?.id === id);
731
- assert.ok(ctl, "缺字段控件: " + id);
732
- const label = find(tree2, (n) => n.type === "label" && n.props?.htmlFor === id);
733
- assert.ok(label, "每个字段必须有可见 label: " + id);
771
+ plugin.setImBotsGet({
772
+ ok: true, wecom: {}, feishu: {},
773
+ dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
774
+ status: { dingtalk: { connected: false, robotCodeConfigured: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
775
+ });
776
+ let tree = await plugin.settle();
777
+ openEntry(tree, "channel");
778
+ tree = await plugin.settle();
779
+ clickChannelRow(tree, "钉钉机器人");
780
+ tree = await plugin.settle();
781
+ let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
782
+ const toggle = find(dlg, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("高级:去开发者后台手动创建")));
783
+ assert.ok(toggle, "折叠标题应为「高级:去开发者后台手动创建(或手动填凭据)」");
784
+ assert.equal(toggle.props["aria-expanded"], "false", "手动配置默认收起");
785
+ toggle.props.onClick();
786
+ tree = await plugin.settle();
787
+ dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
788
+
789
+ // 7 步(第一步在「步骤」列表里)+ 三个坑
790
+ const steps = findAll(dlg, (n) => n.type === "li");
791
+ assert.ok(steps.length >= 7, "手动创建要有 7 步(列表项 " + steps.length + ")");
792
+ const allText = textOf(dlg);
793
+ for (const bit of ["登录开发者后台", "创建应用", "凭证与基础信息", "机器人配置", "Stream 模式", "Robot Code", "版本管理与发布"]) {
794
+ assert.ok(allText.includes(bit), "7 步缺内容: " + bit);
795
+ }
796
+ for (const pit of ["建错机器人", "只保存没发布", "模式选错/凭证脏"]) {
797
+ assert.ok(allText.includes(pit), "三个高频坑缺: " + pit);
798
+ }
799
+ // 官方链接:全部新窗口 + noopener
800
+ const links = findAll(dlg, (n) => n.type === "a");
801
+ const hrefs = links.map((a) => a.props.href);
802
+ for (const u of ["https://open.dingtalk.com/document/orgapp/create-an-application",
803
+ "https://open.dingtalk.com/document/orgapp/add-application-capabilities",
804
+ "https://open.dingtalk.com/document/orgapp/configure-the-robot-application",
805
+ "https://open.dingtalk.com/document/orgapp/faq-robot",
806
+ "https://open.dingtalk.com/document/orgapp/robot-receive-message"]) {
807
+ assert.ok(hrefs.includes(u), "缺官方链接: " + u);
808
+ }
809
+ for (const a of links) {
810
+ assert.equal(a.props.target, "_blank", "官方链接必须新窗口打开");
811
+ assert.equal(a.props.rel, "noopener");
734
812
  }
735
- });
736
813
 
737
- // ───────────────────── ⑧ 发送预览卡片(task-25 尾,桥侧已就绪) ─────────────────────
814
+ // 三字段仍在(兜底手填)
815
+ for (const id of ["dru-ding-client", "dru-ding-secret", "dru-ding-robot"]) {
816
+ assert.ok(find(dlg, (n) => n.type === "input" && n.props?.id === id), "展开后缺字段: " + id);
817
+ }
818
+ find(dlg, (n) => n.props?.id === "dru-ding-client").props.onChange({ target: { value: "ding-client-1" } });
819
+ find(dlg, (n) => n.props?.id === "dru-ding-secret").props.onChange({ target: { value: "ding-secret-1" } });
820
+ find(dlg, (n) => n.props?.id === "dru-ding-robot").props.onChange({ target: { value: "ding-robot-1" } });
821
+ tree = await plugin.settle();
822
+ btnWith(tree, "仅保存").props.onClick();
823
+ await flush(); await flush();
824
+ const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
825
+ assert.ok(put, "应 PUT /dsh-remote/im-bots");
826
+ assert.equal(put.body.platform, "dingtalk");
827
+ assert.deepEqual(put.body.config, { clientId: "ding-client-1", clientSecret: "ding-secret-1", robotCode: "ding-robot-1" },
828
+ "★ 手动提交体字段锁死为 clientId/clientSecret/robotCode");
829
+ });
738
830
 
739
- test("发送预览卡片:次要按钮在「测试」旁边;点击 PUT 带 previewCardAt 且不丢 botId/sessionId/targetChatId", async () => {
831
+ test("钉钉顶部判断口诀 + Robot Code 缺失时「主动推送/卡片」置灰提示", async () => {
740
832
  const plugin = loadPlugin();
741
833
  plugin.setImBotsGet({
742
- ok: true,
743
- wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "chat-9", sessionId: "", sessionTitle: "" },
744
- status: {
745
- connected: true, authenticated: true, lastError: "", boundChatId: "chat-9",
746
- sessions: [{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 1 }],
747
- activeSessionId: "sess-1-abcdef123456", activeSessionTitle: "修复登录问题", boundSessionId: "",
748
- },
834
+ ok: true, wecom: {}, feishu: {},
835
+ dingtalk: { enabled: true, clientId: "ding-client-8899", hasSecret: true, robotCode: "", credentialSource: "scan" },
836
+ status: { dingtalk: { connected: true, robotCodeConfigured: false, capabilities: { openapi: true, card: true } }, feishu: {}, deps: { feishu: { installed: false } } },
749
837
  });
750
- let tree = await openWecom(plugin);
751
- const previewBtn = btnWith(tree, "发送预览卡片");
752
- assert.ok(previewBtn, "配置页必须有次要按钮「发送预览卡片」");
753
- // 次要按钮:不是 primary(不抢主操作)、与「测试」同组摆在底部
754
- assert.ok(!String(previewBtn.props.className || "").includes("is-primary"), "「发送预览卡片」不能是主按钮");
755
- assert.ok(btnWith(tree, "测试"), "「测试」按钮仍在(两者并列)");
838
+ let tree = await plugin.settle();
839
+ openEntry(tree, "channel");
840
+ tree = await plugin.settle();
841
+ clickChannelRow(tree, "钉钉机器人");
842
+ tree = await plugin.settle();
843
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
844
+ const t = textOf(dlg);
845
+ // 判断口诀(醒目)
846
+ assert.ok(t.includes("消息接收地址"), "顶部要有判断口诀");
847
+ assert.ok(t.includes("群 webhook 机器人"), "口诀要指出群 webhook 机器人不能长连接");
848
+ // 缺 Robot Code:两个能力置灰 + 提示
849
+ assert.ok(t.includes("补 Robot Code 可开启"), "缺 Robot Code 要提示「补 Robot Code 可开启」");
850
+ const disabled = findAll(dlg, (n) => String(n.props?.className || "").includes("is-disabled"));
851
+ assert.ok(disabled.length >= 2, "主动推送 / 卡片都应为置灰态,实际 " + disabled.length);
852
+ for (const row of disabled) assert.equal(row.props["aria-disabled"], "true", "置灰能力要 aria-disabled=true");
853
+ });
756
854
 
757
- // 先选一个会话,验证预览请求会带上会话绑定
758
- const sel = find(tree, (n) => n.type === "select" && n.props?.id === "dru-wecom-session");
759
- sel.props.onChange({ target: { value: "sess-1-abcdef123456" } });
855
+ test("钉钉能力:Robot Code 已配置 → 能力可用(不再置灰/提示)", async () => {
856
+ const plugin = loadPlugin();
857
+ plugin.setImBotsGet({
858
+ ok: true, wecom: {}, feishu: {},
859
+ dingtalk: { enabled: true, clientId: "ding-client-8899", hasSecret: true, robotCode: "rc-1", credentialSource: "manual" },
860
+ status: { dingtalk: { connected: true, robotCodeConfigured: true, capabilities: { openapi: true, card: true } }, feishu: {}, deps: { feishu: { installed: false } } },
861
+ });
862
+ let tree = await plugin.settle();
863
+ openEntry(tree, "channel");
864
+ tree = await plugin.settle();
865
+ clickChannelRow(tree, "钉钉机器人");
760
866
  tree = await plugin.settle();
867
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
868
+ assert.ok(!textOf(dlg).includes("补 Robot Code 可开启"), "已配置 Robot Code 就不该再提示补齐");
869
+ assert.equal(findAll(dlg, (n) => String(n.props?.className || "").includes("is-disabled")).length, 0, "能力不应置灰");
870
+ assert.ok(textOf(dlg).includes("来源:手动填写"), "来源显示手动填写");
871
+ });
761
872
 
762
- const before = Date.now();
763
- btnWith(tree, "发送预览卡片").props.onClick();
873
+ test("钉钉扫码:begin 失败 → 人话 + 重试;已连接显示 clientId 尾号与来源(扫码/手动)", async () => {
874
+ // begin 失败
875
+ const p1 = loadPlugin();
876
+ p1.setImBotsGet({ ok: true, wecom: {}, feishu: {}, dingtalk: {}, status: { dingtalk: {}, feishu: {}, deps: { feishu: { installed: false } } } });
877
+ p1.setDingAuthBegin({ ok: false, code: "network_error", message: "网络不通:无法连接钉钉接口" }, 502);
878
+ let tree = await p1.settle();
879
+ openEntry(tree, "channel");
880
+ tree = await p1.settle();
881
+ clickChannelRow(tree, "钉钉机器人");
882
+ tree = await p1.settle();
883
+ btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
764
884
  await flush(); await flush();
765
- tree = await plugin.settle();
885
+ tree = await p1.settle(3);
886
+ assert.ok(textOf(tree).includes("发起授权失败"), "失败要给人话");
887
+ assert.ok(textOf(tree).includes("网络不通"), "保留后端原因");
888
+ assert.ok(btnWith(tree, "用钉钉扫码,一键创建机器人"), "失败后按钮仍可重试");
889
+
890
+ // 已连接:尾号 + 来源
891
+ const p2 = loadPlugin();
892
+ p2.setImBotsGet({
893
+ ok: true, wecom: {}, feishu: {},
894
+ dingtalk: { enabled: true, clientId: "ding-client-8899", hasSecret: true, robotCode: "rc", credentialSource: "scan" },
895
+ status: { dingtalk: { connected: true }, feishu: {}, deps: { feishu: { installed: false } } },
896
+ });
897
+ tree = await p2.settle();
898
+ openEntry(tree, "channel");
899
+ tree = await p2.settle();
900
+ clickChannelRow(tree, "钉钉机器人");
901
+ tree = await p2.settle();
902
+ const t = textOf(tree);
903
+ assert.ok(t.includes("已连接"), "已连接状态要显示");
904
+ assert.ok(t.includes("8899"), "要显示 Client ID 尾号");
905
+ assert.ok(t.includes("来源:扫码创建"), "要显示凭据来源(扫码)");
906
+ });
907
+
908
+ test("飞书:依赖未装 → 41MB 下载入口;已装 → 只有 App ID/Secret 两个字段(不再让用户填接收人)", async () => {
909
+ // 未装依赖
910
+ const p1 = loadPlugin();
911
+ p1.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
912
+ p1.setImBotsGet({
913
+ ok: true, wecom: {}, dingtalk: {}, feishu: {},
914
+ status: { deps: { feishu: { installed: false, phase: "idle", version: "", lastError: "" } } },
915
+ });
916
+ let tree = await p1.settle();
917
+ openEntry(tree, "channel");
918
+ tree = await p1.settle();
919
+ assert.ok(clickChannelRow(tree, "飞书机器人"), "应能进飞书页");
920
+ tree = await p1.settle();
921
+ assert.ok(textOf(tree).includes("41MB"), "未装依赖要说明约 41MB、仅装一次");
922
+ const dlBtn = btnWith(tree, "下载依赖");
923
+ assert.ok(dlBtn, "要有「下载依赖」按钮");
924
+ assert.ok(textOf(tree).includes("不下载也能用其它通道"), "要说明不下载也能用其它通道");
925
+ assert.equal(find(tree, (n) => n.props?.id === "dru-fs-appid"), undefined, "未装依赖时不放配置表单");
926
+ dlBtn.props.onClick();
927
+ await flush(); await flush();
928
+ assert.ok(p1.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"),
929
+ "点下载必须 POST /dsh-remote/im-deps/feishu");
930
+
931
+ // 已装依赖:只有两个字段
932
+ const p2 = loadPlugin();
933
+ p2.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
934
+ p2.setImBotsGet({
935
+ ok: true, wecom: {}, dingtalk: {},
936
+ feishu: { enabled: false, appId: "", hasSecret: false },
937
+ status: { feishu: { connected: false, boundChatId: "" }, deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
938
+ });
939
+ tree = await p2.settle();
940
+ openEntry(tree, "channel");
941
+ tree = await p2.settle();
942
+ clickChannelRow(tree, "飞书机器人");
943
+ tree = await p2.settle();
944
+ let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
945
+ assert.ok(dlg, "已装依赖应打开飞书配置页");
946
+ assert.ok(textOf(dlg).includes("1.40.0"), "应显示依赖版本");
947
+ assert.ok(find(dlg, (n) => n.props?.id === "dru-fs-appid"), "要有 App ID");
948
+ assert.ok(find(dlg, (n) => n.props?.id === "dru-fs-secret"), "要有 App Secret");
949
+ // ★ 业主重点:接收人/接收类型不再出现(用户不知道怎么获取,默认自动发现)
950
+ assert.equal(find(dlg, (n) => n.props?.id === "dru-fs-recv"), undefined, "不得再出现「接收人」输入框");
951
+ assert.equal(find(dlg, (n) => n.props?.id === "dru-fs-rtype"), undefined, "不得再出现「接收类型」选择");
952
+ const inputs = findAll(dlg, (n) => n.type === "input");
953
+ assert.equal(inputs.length, 2, "飞书配置页只能有两个输入框(App ID / App Secret),实际 " + inputs.length);
954
+ assert.ok(textOf(dlg).includes("不需要操作") || textOf(dlg).includes("自动绑定"),
955
+ "未绑定时要说明「连接后自动绑定,不需要操作」");
956
+
957
+ // 两个官方链接(业主给的地址),target=_blank rel=noopener
958
+ const links = findAll(dlg, (n) => n.type === "a");
959
+ assert.equal(links.length, 2, "配置页顶部要有两个官方外链,实际 " + links.length);
960
+ const hrefs = links.map((a) => a.props.href);
961
+ assert.ok(hrefs.includes("https://open.feishu.cn/app?lang=zh-CN"), "必须有「创建企业自建应用」链接");
962
+ assert.ok(hrefs.includes("https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot"),
963
+ "必须有卡片交互机器人文档链接");
964
+ for (const a of links) {
965
+ assert.equal(a.props.target, "_blank");
966
+ assert.equal(a.props.rel, "noopener");
967
+ }
968
+
969
+ // 3 步主路径 + 细步骤折叠(不再一上来就 8 步)
970
+ const mainSteps = findAll(dlg, (n) => n.type === "li");
971
+ assert.equal(mainSteps.length, 3, "主路径只有 3 步,实际 " + mainSteps.length);
972
+ const toggle = find(dlg, (n) => n.type === "button" && (n.children || []).some((c) => typeof c === "string" && c.includes("细步骤")));
973
+ assert.ok(toggle, "要有「细步骤」折叠入口");
974
+ assert.equal(toggle.props["aria-expanded"], "false", "细步骤默认收起");
975
+ toggle.props.onClick();
976
+ tree = await p2.settle();
977
+ dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
978
+ const allSteps = findAll(dlg, (n) => n.type === "li");
979
+ assert.equal(allSteps.length, 3 + 5, "展开后 3 条主路径 + 5 条细步骤,实际 " + allSteps.length);
980
+ assert.ok(textOf(dlg).includes("长连接必须已在线"), "顺序坑必须保留");
981
+ assert.ok(textOf(dlg).includes("先在上面填凭据"), "必须写明正确顺序:先填凭据连上,再去后台保存事件配置");
982
+ });
766
983
 
767
- const sent = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
768
- assert.ok(sent, "★ 点击必须 PUT /dsh-remote/im-bots(桥监听 previewCardAt)");
769
- assert.equal(typeof sent.body.wecom.previewCardAt, "number", "previewCardAt 必须是数字(ms 时间戳)");
770
- assert.ok(sent.body.wecom.previewCardAt >= before, "previewCardAt 应是本次点击的时间戳(值变大才会触发)");
771
- assert.equal(sent.body.wecom.botId, "aibdemo1234", "★ 不丢 Bot ID(覆盖写不许冲掉)");
772
- assert.equal(sent.body.wecom.targetChatId, "chat-9", "★ 不丢接收会话 ID");
773
- assert.equal(sent.body.wecom.sessionId, "sess-1-abcdef123456", "★ 不丢会话绑定");
774
- assert.equal(sent.body.wecom.sessionTitle, "修复登录问题");
775
- assert.ok(!("secret" in sent.body.wecom), "空 Secret 不提交(= 保留已保存的)");
776
- // 成功状态行
777
- assert.ok(textOf(tree).includes("已发送预览卡片,去企业微信看看"), "发送成功要有状态行");
984
+ test("飞书:自动绑定状态行两态(未绑定提示自动发现 / 已绑定显示群名或私聊)", async () => {
985
+ // 已绑定私聊
986
+ const p1 = loadPlugin();
987
+ p1.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
988
+ p1.setImBotsGet({
989
+ ok: true, wecom: {}, dingtalk: {},
990
+ feishu: { enabled: true, appId: "cli_1", hasSecret: true },
991
+ status: {
992
+ feishu: { connected: true, boundChatId: "ou_user_1", boundChatType: "p2p", receiveIdSource: "auto" },
993
+ deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } },
994
+ },
995
+ });
996
+ let tree = await p1.settle();
997
+ openEntry(tree, "channel");
998
+ tree = await p1.settle();
999
+ clickChannelRow(tree, "飞书机器人");
1000
+ tree = await p1.settle();
1001
+ assert.ok(textOf(tree).includes("已自动绑定:私聊"), "已绑定私聊要显示「已自动绑定:私聊」");
1002
+
1003
+ // 已绑定群
1004
+ const p2 = loadPlugin();
1005
+ p2.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
1006
+ p2.setImBotsGet({
1007
+ ok: true, wecom: {}, dingtalk: {},
1008
+ feishu: { enabled: true, appId: "cli_1", hasSecret: true },
1009
+ status: {
1010
+ feishu: { connected: true, boundChatId: "oc_group_abcdef", boundChatType: "group", receiveIdSource: "auto" },
1011
+ deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } },
1012
+ },
1013
+ });
1014
+ tree = await p2.settle();
1015
+ openEntry(tree, "channel");
1016
+ tree = await p2.settle();
1017
+ clickChannelRow(tree, "飞书机器人");
1018
+ tree = await p2.settle();
1019
+ const t = textOf(tree);
1020
+ assert.ok(t.includes("已自动绑定:群"), "已绑定群要显示「已自动绑定:群…」");
1021
+ assert.ok(t.includes("无需配置"), "自动绑定要说明是自动发现、无需配置");
778
1022
  });
779
1023
 
780
- test("发送预览卡片:没有接收方(boundChatId 与 targetChatId 都空)→ 行内提示,但仍允许点击", async () => {
1024
+ test("飞书依赖:失败状态可重试,失败原因人话展示", async () => {
781
1025
  const plugin = loadPlugin();
1026
+ plugin.setImDeps({ installed: false, phase: "failed", version: "", lastError: "网络不通:检查能否访问 npm registry" });
782
1027
  plugin.setImBotsGet({
783
- ok: true,
784
- wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
785
- status: { connected: true, authenticated: true, lastError: "", boundChatId: "", sessions: [], activeSessionId: "", boundSessionId: "" },
1028
+ ok: true, wecom: {}, dingtalk: {}, feishu: {},
1029
+ status: { deps: { feishu: { installed: false, phase: "failed", version: "", lastError: "网络不通:检查能否访问 npm registry" } } },
786
1030
  });
787
- const tree = await openWecom(plugin);
788
- assert.ok(textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "未绑定给机器人时要有行内提示");
789
- const btn = btnWith(tree, "发送预览卡片");
790
- assert.ok(btn, "按钮必须存在");
791
- assert.notEqual(btn.props.disabled, true, "★ 仍允许点击(桥会记日志跳过,不拦用户)");
1031
+ let tree = await plugin.settle();
1032
+ openEntry(tree, "channel");
1033
+ tree = await plugin.settle();
1034
+ clickChannelRow(tree, "飞书机器人");
1035
+ tree = await plugin.settle();
1036
+ assert.ok(textOf(tree).includes("网络不通"), "失败原因要人话展示");
1037
+ assert.ok(btnWith(tree, "重试下载"), "失败后按钮变为「重试下载」");
1038
+ btnWith(tree, "重试下载").props.onClick();
1039
+ await flush(); await flush();
1040
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/im-deps/feishu" && r.method === "POST"), "重试必须再次触发安装");
792
1041
  });
793
1042
 
794
- test("发送预览卡片:已绑定(boundChatId 非空)→ 不显示未绑定提示;有 lastPreviewCardAt → 显示「上次预览:HH:MM」", async () => {
1043
+ test("飞书表单提交体:只提交 appId/appSecret(不含接收人);secret 不回显", async () => {
795
1044
  const plugin = loadPlugin();
796
- const ts = new Date(2026, 0, 2, 9, 7, 0).getTime();
1045
+ plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
797
1046
  plugin.setImBotsGet({
798
- ok: true,
799
- wecom: { enabled: true, botId: "aibdemo1234", hasSecret: true, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
800
- status: { connected: true, authenticated: true, lastError: "", boundChatId: "chat-9", lastPreviewCardAt: ts, sessions: [], activeSessionId: "", boundSessionId: "" },
1047
+ ok: true, wecom: {}, dingtalk: {},
1048
+ feishu: { enabled: true, appId: "cli_old", hasSecret: true },
1049
+ status: { feishu: { connected: true, boundChatId: "oc_g" }, deps: { feishu: { installed: true, phase: "done", version: "1.40.0", lastError: "" } } },
801
1050
  });
802
- const tree = await openWecom(plugin);
803
- assert.ok(!textOf(tree).includes("先在企微里给机器人发一句话完成绑定"), "已绑定就不该再提示未绑定");
804
- assert.ok(textOf(tree).includes("上次预览:09:07"), "应显示上次预览时间(HH:MM)");
1051
+ let tree = await plugin.settle();
1052
+ openEntry(tree, "channel");
1053
+ tree = await plugin.settle();
1054
+ clickChannelRow(tree, "飞书机器人");
1055
+ tree = await plugin.settle();
1056
+ const dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
1057
+ // 已保存的 secret 不回显:password 输入框初始为空 + 占位提示
1058
+ const sec = find(dlg, (n) => n.props?.id === "dru-fs-secret");
1059
+ assert.equal(sec.props.value, "", "★ 已保存的 Secret 绝不能回显到输入框");
1060
+ assert.ok(String(sec.props.placeholder || "").includes("已保存"), "要提示「已保存(留空表示不修改)」");
1061
+ find(dlg, (n) => n.props?.id === "dru-fs-appid").props.onChange({ target: { value: "cli_new" } });
1062
+ tree = await plugin.settle();
1063
+ btnWith(tree, "仅保存").props.onClick();
1064
+ await flush(); await flush();
1065
+ const put = plugin.imBotsCalls().filter((c) => c.method === "PUT").pop();
1066
+ assert.ok(put, "应 PUT /dsh-remote/im-bots");
1067
+ assert.equal(put.body.platform, "feishu");
1068
+ assert.deepEqual(put.body.config, { appId: "cli_new", appSecret: "" },
1069
+ "★ 飞书提交体只有两个凭据字段(接收人由桥自动发现;空 secret = 不修改)");
805
1070
  });
806
1071
 
807
1072
  // ───────────── ⑨ 未登录守卫 + 后台服务横排 + 卸载挽留(2026-11-07 业主逐条)─────────────
@@ -994,3 +1259,32 @@ test("版本与更新:有一行可复制的安装/更新命令 npx @mrrisega/d
994
1259
  tree = await plugin.settle();
995
1260
  assert.ok(textOf(tree).includes("✅ 已复制"), "复制成功要有与其它复制按钮一致的反馈");
996
1261
  });
1262
+
1263
+ test("飞书依赖下载:点下载 → POST 安装 → 轮询 /im-deps,装好后页面自动切到配置表单(无需重开弹层)", async () => {
1264
+ const plugin = loadPlugin();
1265
+ plugin.setImDeps({ installed: false, phase: "idle", version: "", lastError: "" });
1266
+ plugin.setImBotsGet({
1267
+ ok: true, wecom: {}, dingtalk: {}, feishu: {},
1268
+ status: { deps: { feishu: { installed: false, phase: "idle", version: "", lastError: "" } } },
1269
+ });
1270
+ let tree = await plugin.settle();
1271
+ openEntry(tree, "channel");
1272
+ tree = await plugin.settle();
1273
+ clickChannelRow(tree, "飞书机器人");
1274
+ tree = await plugin.settle();
1275
+ assert.ok(btnWith(tree, "下载依赖"), "未装依赖先给下载入口");
1276
+
1277
+ btnWith(tree, "下载依赖").props.onClick();
1278
+ await flush(); await flush();
1279
+ assert.equal(plugin.depsInstalls().length, 1, "★ 点下载必须触发一次后台安装");
1280
+
1281
+ // 先渲染一次让"下载中"的轮询 effect 挂上定时器,再模拟安装完成
1282
+ tree = await plugin.settle(2);
1283
+ plugin.setImDeps({ installed: true, phase: "done", version: "1.40.0", lastError: "" });
1284
+ await plugin.advance(1000); // 触发轮询定时器(真实环境 900ms 一次)
1285
+ await flush(); await flush();
1286
+ tree = await plugin.settle(4);
1287
+ assert.ok(plugin.depsCalls().length >= 1, "应轮询 /dsh-remote/im-deps 拿进度");
1288
+ assert.ok(textOf(tree).includes("1.40.0"), "装好后面板直接显示版本(用户不需要重开弹层)");
1289
+ assert.ok(find(tree, (n) => n.props?.id === "dru-fs-appid"), "装好后应出现飞书配置表单");
1290
+ });