@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.
@@ -124,6 +124,15 @@ import { createAgentRuntime } from "./agent/index.mjs";
124
124
  import { createWecomBotController } from "./wecom-bot.mjs";
125
125
  // 钉钉 Stream 模式长连接(2026-11-08):与企微同构、零新增依赖;默认关闭,配置在 im-bots.json 的 dingtalk 段。
126
126
  import { createDingtalkBotController } from "./dingtalk-bot.mjs";
127
+ // 钉钉「扫码一键创建机器人」授权会话:**持久化 + 断点续轮询**(2026-11-08)。
128
+ // 根因:device_code 只存在插件进程内存 → 面板关/宿主重启就丢,而凭据只在 poll SUCCESS 那次返回。
129
+ // 现在授权会话落盘在 <relayDir>/im-dingtalk-auth.json,bridge 启动即续轮询,桥在跑就能接住。
130
+ import {
131
+ resumeDingtalkAuth,
132
+ startDingtalkAuthCommandWatcher,
133
+ stopDingtalkAuthCommandWatcher,
134
+ stopDingtalkAuthPolling
135
+ } from "./dingtalk-auth.mjs";
127
136
  // 飞书通道:官方 SDK 按需下载(41MB,不随包分发)、懒加载;未启用时零副作用
128
137
  import { createFeishuBotController } from "./feishu-bot.mjs";
129
138
 
@@ -3358,6 +3367,29 @@ function imBotLog(level, message) {
3358
3367
  }
3359
3368
 
3360
3369
  function startImBots() {
3370
+ // ⓪ 钉钉扫码授权断点续轮询(fire-and-forget,绝不阻塞隧道/连接主链路):
3371
+ // 上次没等到用户确认就重启/关面板也没关系 —— 会话在磁盘上,桥一跑就继续 poll,
3372
+ // 成功后自动写 im-bots.json(credentialSource:"scan")→ controller 热更新把机器人连上。
3373
+ try {
3374
+ void resumeDingtalkAuth({
3375
+ relayDir: RELAY_DIR,
3376
+ logger: imBotLog,
3377
+ onWarn: (m) => imBotLog("warn", m),
3378
+ onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
3379
+ }).catch((e) => {
3380
+ console.warn(`[bridge] 钉钉扫码授权续轮询失败(不影响远程访问): ${redactText(e)}`);
3381
+ });
3382
+ // 控制命令文件:插件写 <relayDir>/im-dingtalk-auth-cmd.json(begin/cancel),桥读并执行。
3383
+ // 插件不需要 device_code,也不需要 HTTP 端点 —— 只表达意图;桥在跑就能接住。
3384
+ startDingtalkAuthCommandWatcher({
3385
+ relayDir: RELAY_DIR,
3386
+ logger: imBotLog,
3387
+ onWarn: (m) => imBotLog("warn", m),
3388
+ onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
3389
+ });
3390
+ } catch (e) {
3391
+ console.warn(`[bridge] 钉钉扫码授权续轮询初始化失败(忽略): ${redactText(e)}`);
3392
+ }
3361
3393
  // ① 企业微信(现有)
3362
3394
  try {
3363
3395
  if (!imBots) {
@@ -3437,6 +3469,9 @@ function startImBots() {
3437
3469
 
3438
3470
  /** 优雅退出 IM 机器人通道(企微 + 钉钉,互不影响)。失败只记日志。 */
3439
3471
  async function stopImBots() {
3472
+ // 先停扫码授权轮询/命令监听(**保留** auth 文件:下次启动能续轮询;用户显式取消才删文件)
3473
+ try { stopDingtalkAuthCommandWatcher(); } catch { /* 忽略 */ }
3474
+ try { stopDingtalkAuthPolling(); } catch { /* 忽略 */ }
3440
3475
  const wecom = imBots;
3441
3476
  const dingtalk = imBotsDingtalk;
3442
3477
  const feishu = imBotsFeishu;
@@ -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", "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", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
153
153
 
154
154
  /**
155
155
  * 把"客户端脚本"补齐到配置目录。
@@ -632,190 +632,100 @@ test("POST /im-deps/feishu:安装失败 → phase=failed + 人话原因(netw
632
632
  });
633
633
  });
634
634
 
635
- // ─────────────────── 钉钉「扫码一键创建机器人」device-flow(2026-11-08) ───────────────────
636
-
637
- /** 假钉钉接口(注入 imAuthFetch;测试绝不真的打 oapi.dingtalk.com)。 */
638
- function makeAuthFetch(plan = {}) {
639
- const calls = [];
640
- const resp = (body, ok = true, status = 200) => ({ ok, status, json: async () => body });
641
- const fn = async (url, opts) => {
642
- const body = opts && opts.body ? JSON.parse(opts.body) : {};
643
- calls.push({ url: String(url), body });
644
- const u = String(url);
645
- if (u.endsWith("/init")) {
646
- if (plan.initThrows) throw new Error("ECONNRESET");
647
- return resp(plan.init || { nonce: "nonce-1" });
648
- }
649
- if (u.endsWith("/begin")) {
650
- if (plan.beginRejected) return resp({ errmsg: "rejected" }, false, 400);
651
- return resp(plan.begin || { device_code: "dc-secret", verification_uri_complete: "https://oapi.dingtalk.com/auth?x=1", interval: 2, expires_in: 7200 });
652
- }
653
- if (u.endsWith("/poll")) {
654
- if (plan.pollThrows) throw new Error("ETIMEDOUT");
655
- return resp(plan.polls && plan.polls.length ? plan.polls.shift() : { status: "WAITING" });
656
- }
657
- throw new Error("unexpected url " + u);
658
- };
659
- fn.calls = calls;
660
- return fn;
661
- }
635
+ // ─────────────────── 钉钉扫码授权:**委托桥侧持久化**(2026-11-08 架构修正) ───────────────────
636
+ //
637
+ // 契约(与桥约定):
638
+ // · 插件只写命令文件 <relayDir>/im-dingtalk-auth-cmd.json {action:"begin"|"cancel", at}
639
+ // · 桥持久化 device_code/断点续轮询,把进度写进 im-bot-status.json 的 dingtalk.auth
640
+ // · 插件 GET status 直接读状态文件;**自身不存内存会话、不调 oapi.dingtalk.com**
641
+
642
+ const readCmd = async (relayDir) => {
643
+ const raw = await readFile(path.join(relayDir, "im-dingtalk-auth-cmd.json"), "utf8");
644
+ return { data: JSON.parse(raw), mode: (await stat(path.join(relayDir, "im-dingtalk-auth-cmd.json"))).mode & 0o777 };
645
+ };
646
+ const writeAuthStatus = (relayDir, auth) => writeFile(path.join(relayDir, "im-bot-status.json"),
647
+ JSON.stringify({ updatedAt: Date.now(), dingtalk: { enabled: true, connected: false, auth } }));
662
648
 
663
- test("POST /im-dingtalk-auth/begin:返回 authId/url/interval/expiresIn;device_code 绝不落浏览器", async () => {
649
+ test("POST begin:只写命令文件 {action:'begin', at}(0600),**不返回 URL**(URL 在桥状态里)", async () => {
664
650
  const relayDir = await tmpRelay();
665
- const imAuthFetch = makeAuthFetch();
666
- await withHost(relayDir, { imAuthFetch }, async (base) => {
651
+ await withHost(relayDir, {}, async (base) => {
652
+ const before = Date.now();
667
653
  const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
668
654
  assert.equal(r.status, 200);
669
655
  assert.equal(r.body.ok, true);
670
- assert.ok(r.body.authId, "必须给浏览器一个 authId 句柄");
671
- assert.equal(r.body.url, "https://oapi.dingtalk.com/auth?x=1");
672
- assert.equal(r.body.interval, 2);
673
- assert.equal(r.body.expiresIn, 7200, "★ TTL 必须按钉钉 expires_in(7200s),不能再是写死的 2 分钟");
674
- const raw = JSON.stringify(r.body);
675
- assert.ok(!raw.includes("dc-secret"), "★ device_code 不能下发给浏览器");
676
- });
677
- });
678
-
679
- test("POST begin 限流:同一时间只允许一个授权,旧 authId 查询回 cancelled", async () => {
680
- const relayDir = await tmpRelay();
681
- const imAuthFetch = makeAuthFetch();
682
- await withHost(relayDir, { imAuthFetch, imAuthPollMs: 60000 }, async (base) => {
683
- const a = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
684
- const b = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
685
- assert.ok(a.body.authId && b.body.authId && a.body.authId !== b.body.authId);
686
- const old = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(a.body.authId)}`);
687
- assert.equal(old.body.status, "failed");
688
- assert.equal(old.body.code, "cancelled", "旧授权必须被作废(而不是继续有效)");
689
- const cur = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}`);
690
- assert.equal(cur.body.status, "waiting");
691
- });
692
- });
693
-
694
- test("GET status:waiting → success 时把凭据写入 im-bots.json(0600、credentialSource=scan),响应不回 secret", async () => {
695
- const relayDir = await tmpRelay();
696
- const imAuthFetch = makeAuthFetch({
697
- polls: [
698
- { status: "WAITING" },
699
- { status: "SUCCESS", client_id: "ding-client-8899", client_secret: "DING-SCAN-SECRET" },
700
- ],
701
- });
702
- await withHost(relayDir, { imAuthFetch, imAuthPollMs: 60000 }, async (base) => {
703
- const begin = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
704
- const id = encodeURIComponent(begin.body.authId);
705
- let st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}&check=1`);
706
- assert.equal(st.body.status, "waiting", "用户还没确认 → waiting");
707
- st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}&check=1`);
708
- assert.equal(st.body.status, "success");
709
- assert.equal(st.body.credentialSource, "scan");
710
- assert.equal(st.body.hasSecret, true);
711
- assert.equal(st.body.clientIdTail, "8899");
712
- assert.ok(!JSON.stringify(st.body).includes("DING-SCAN-SECRET"), "★ 扫码拿到的 secret 绝不能回给浏览器");
713
-
714
- const cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
715
- assert.equal(cfg.dingtalk.clientId, "ding-client-8899");
716
- assert.equal(cfg.dingtalk.clientSecret, "DING-SCAN-SECRET", "凭据要写入配置文件(桥读)");
717
- assert.equal(cfg.dingtalk.credentialSource, "scan");
718
- assert.equal(cfg.dingtalk.enabled, true, "扫码成功即可用");
719
- const mode = (await stat(path.join(relayDir, "im-bots.json"))).mode & 0o777;
720
- assert.equal(mode, 0o600, "配置文件必须 0600");
721
-
722
- // 面板 GET 里也只见 hasSecret,不见 secret
723
- const view = await json(`${base}/dsh-remote/im-bots`);
724
- assert.equal(view.body.dingtalk.hasSecret, true);
725
- assert.equal(view.body.dingtalk.credentialSource, "scan");
726
- assert.ok(!JSON.stringify(view.body).includes("DING-SCAN-SECRET"));
656
+ assert.equal(r.body.url, undefined, "★ begin 不得返回 URL(授权链接要等桥跑完写状态)");
657
+ assert.equal(r.body.authId, undefined, "★ 不再有插件内存会话 authId");
658
+ const { data, mode } = await readCmd(relayDir);
659
+ assert.equal(data.action, "begin");
660
+ assert.equal(typeof data.at, "number");
661
+ assert.ok(data.at >= before, "at 应是本次点击时间戳(桥据此判断是否新命令)");
662
+ assert.equal(mode, 0o600, "命令文件必须 0600");
727
663
  });
728
664
  });
729
665
 
730
- test("GET status:未知/过期 authId → expired;钉钉拒绝 → failed(dingtalk_api_rejected);网络不通 → failed(network_error)", async () => {
666
+ test("POST cancel:写 {action:'cancel'} 命令(桥停轮询 + 删持久化会话)", async () => {
731
667
  const relayDir = await tmpRelay();
732
- await withHost(relayDir, { imAuthFetch: makeAuthFetch() }, async (base) => {
733
- const none = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=nope`);
734
- assert.equal(none.body.status, "expired");
735
- assert.equal(none.body.code, "expired");
668
+ await withHost(relayDir, {}, async (base) => {
669
+ const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/cancel`);
670
+ assert.equal(r.body.ok, true);
671
+ const { data, mode } = await readCmd(relayDir);
672
+ assert.equal(data.action, "cancel");
673
+ assert.equal(mode, 0o600);
674
+ });
675
+ });
736
676
 
737
- // 钉钉拒绝 poll
738
- const rejDir = await tmpRelay();
739
- const rejFetch = makeAuthFetch({ polls: [{ status: "DENIED", message: "用户取消了创建" }] });
740
- await withHost(rejDir, { imAuthFetch: rejFetch, imAuthPollMs: 60000 }, async (base2) => {
741
- const b = await post(base2, `${base2}/dsh-remote/im-dingtalk-auth/begin`);
742
- const st = await json(`${base2}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}&check=1`);
743
- assert.equal(st.body.status, "failed");
744
- assert.equal(st.body.code, "dingtalk_api_rejected");
745
- assert.ok(st.body.message.includes("用户取消"), "钉钉原文原因要带给用户");
677
+ test("GET status:直接读 im-bot-status.json 的 dingtalk.auth(白名单 + 只透 https 链接 + 人话兜底)", async () => {
678
+ const relayDir = await tmpRelay();
679
+ await withHost(relayDir, {}, async (base) => {
680
+ // 1) idle(无 auth 段)
681
+ let st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
682
+ assert.equal(st.body.state, "idle");
683
+ assert.equal(st.body.url, "");
684
+ assert.equal(st.body.message, "", "idle 不该有错误文案");
685
+
686
+ // 2) waiting(带链接/剩余时间/未知字段)
687
+ await writeAuthStatus(relayDir, {
688
+ state: "waiting", url: "https://oapi.dingtalk.com/x?nonce=1", expiresAt: 123, remainingSec: 300,
689
+ deviceCode: "LEAK-DEVICE", clientSecret: "LEAK-SECRET",
746
690
  });
691
+ st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
692
+ assert.equal(st.body.state, "waiting");
693
+ assert.equal(st.body.url, "https://oapi.dingtalk.com/x?nonce=1");
694
+ assert.equal(st.body.remainingSec, 300);
695
+ assert.equal(st.body.expiresAt, 123);
696
+ assert.ok(!JSON.stringify(st.body).includes("LEAK"), "★ 状态文件里的 device_code/secret 不能透出");
747
697
 
748
- // 网络抖动的 poll:**不放失败**(后台继续重试直到 expires_in),状态仍 waiting
749
- const netDir = await tmpRelay();
750
- const netFetch = makeAuthFetch({ pollThrows: true });
751
- await withHost(netDir, { imAuthFetch: netFetch, imAuthPollMs: 60000 }, async (base3) => {
752
- const b = await post(base3, `${base3}/dsh-remote/im-dingtalk-auth/begin`);
753
- const st = await json(`${base3}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}&check=1`);
754
- assert.equal(st.body.status, "waiting", "网络抖动不该判死授权(用户还在扫码)");
755
- });
756
- });
757
- });
698
+ // 3) 非 https 链接必须过滤(防状态文件被塞 javascript:/data:)
699
+ await writeAuthStatus(relayDir, { state: "waiting", url: "javascript:alert(1)" });
700
+ st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
701
+ assert.equal(st.body.url, "", "只允许 https 链接");
758
702
 
759
- test("POST begin:钉钉接口不可用 → network_error / dingtalk_api_rejected(人话)", async () => {
760
- const dir1 = await tmpRelay();
761
- await withHost(dir1, { imAuthFetch: makeAuthFetch({ initThrows: true }) }, async (base) => {
762
- const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
763
- assert.equal(r.status, 502);
764
- assert.equal(r.body.ok, false);
765
- assert.equal(r.body.code, "network_error");
766
- assert.match(r.body.message, /连不上钉钉接口/, "错误 message 必须是中文人话");
767
- });
768
- const dir2 = await tmpRelay();
769
- await withHost(dir2, { imAuthFetch: makeAuthFetch({ beginRejected: true }) }, async (base) => {
770
- const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
771
- assert.equal(r.body.code, "dingtalk_api_rejected");
703
+ // 4) failed:桥给裸 code/ok 时也要翻成中文人话
704
+ await writeAuthStatus(relayDir, { state: "failed", message: "ok" });
705
+ st = await json(`${base}/dsh-remote/im-dingtalk-auth/status`);
706
+ assert.equal(st.body.state, "failed");
707
+ assert.match(st.body.message, /[\u4e00-\u9fa5]/, "message 必须中文人话");
708
+ assert.notEqual(st.body.message, "ok", "★ 绝不裸渲染 ok");
772
709
  });
773
710
  });
774
711
 
775
- test("钉钉授权:后台轮询在**无人查询**时也继续前进(关面板/刷新不影响),TTL 按 expires_in=7200s", async () => {
712
+ test("GET /im-bots:status.dingtalk.auth 也按同一白名单透出(面板打开时恢复等待态)", async () => {
776
713
  const relayDir = await tmpRelay();
777
- const imAuthFetch = makeAuthFetch({
778
- polls: [{ status: "WAITING" }, { status: "SUCCESS", client_id: "ding-bg-1", client_secret: "BG-SECRET" }],
779
- });
780
- // 后台轮询间隔压到 20ms(生产 3s),验证"不靠面板/HTTP 查询也会成功"
781
- await withHost(relayDir, { imAuthFetch, imAuthPollMs: 20 }, async (base) => {
782
- const begin = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
783
- assert.equal(begin.body.expiresIn, 7200, "★ TTL 必须是钉钉的 expires_in(7200s),不是 2 分钟");
784
- // 不调用任何 status 接口,等后台轮询把 SUCCESS 落盘
785
- let cfg = null;
786
- for (let i = 0; i < 100; i++) {
787
- try { cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8")); } catch { cfg = null; }
788
- if (cfg && cfg.dingtalk && cfg.dingtalk.clientId === "ding-bg-1") break;
789
- await new Promise((r) => setTimeout(r, 25));
790
- }
791
- assert.ok(cfg && cfg.dingtalk.clientId === "ding-bg-1", "★ 后台轮询必须在没有面板查询时也把凭据写盘");
792
- assert.equal(cfg.dingtalk.credentialSource, "scan");
793
- assert.equal(cfg.dingtalk.enabled, true, "扫码成功自动 enabled");
794
- // 之后查询直接拿到 success(脱敏)
795
- const st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(begin.body.authId)}`);
796
- assert.equal(st.body.status, "success");
797
- assert.ok(!JSON.stringify(st.body).includes("BG-SECRET"));
714
+ await withHost(relayDir, {}, async (base) => {
715
+ await writeAuthStatus(relayDir, { state: "waiting", url: "https://oapi.dingtalk.com/y", remainingSec: 120, clientIdTail: "8899" });
716
+ const r = await json(`${base}/dsh-remote/im-bots`);
717
+ const auth = r.body.status.dingtalk.auth;
718
+ assert.equal(auth.state, "waiting");
719
+ assert.equal(auth.url, "https://oapi.dingtalk.com/y");
720
+ assert.equal(auth.remainingSec, 120);
721
+ assert.ok(!JSON.stringify(r.body).includes("LEAK"));
798
722
  });
799
723
  });
800
724
 
801
- test("钉钉授权错误:message 永远是中文人话(绝不裸 code / ok)", async () => {
802
- const dir1 = await tmpRelay();
803
- await withHost(dir1, { imAuthFetch: makeAuthFetch({ initThrows: true }) }, async (base) => {
804
- const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
805
- assert.equal(r.body.code, "network_error");
806
- assert.notEqual(r.body.message, r.body.code, "message 不能等于裸 code");
807
- assert.match(r.body.message, /[\u4e00-\u9fa5]/, "message 必须含中文");
808
- });
809
- const dir2 = await tmpRelay();
810
- await withHost(dir2, { imAuthFetch: makeAuthFetch({ beginRejected: true }) }, async (base) => {
811
- const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
812
- assert.match(r.body.message, /[\u4e00-\u9fa5]/);
813
- assert.ok(!/^\s*(ok|error|https?:)/i.test(r.body.message), "不得裸渲染 ok/errcode/URL");
814
- });
815
- const dir3 = await tmpRelay();
816
- await withHost(dir3, { imAuthFetch: makeAuthFetch() }, async (base) => {
817
- const st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=nope`);
818
- assert.equal(st.body.code, "expired");
819
- assert.match(st.body.message, /授权已过期/, "过期也要中文人话");
820
- });
725
+ test("源码约束:插件侧**不再有内存授权会话、不再直连 oapi.dingtalk.com**(只委托桥)", async () => {
726
+ const src = await readFile(new URL("../lib/index.js", import.meta.url), "utf8");
727
+ assert.ok(src.includes("im-dingtalk-auth-cmd.json"), "必须有命令文件契约");
728
+ assert.ok(!src.includes("oapi.dingtalk.com"), "★ 插件不得再自己调钉钉 registration 接口(由桥负责)");
729
+ assert.ok(!src.includes("dingtalkAuthSessions"), "★ 不得再有插件内存授权会话 Map(进程重启就丢)");
730
+ assert.ok(!/setInterval\([^)]*dingtalkAuthPollOnce/.test(src), "★ 不得再有插件侧后台轮询 loop");
821
731
  });
@@ -196,8 +196,8 @@ function loadPlugin(opts = {}) {
196
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 } };
197
197
  let imBotsTestReply = { status: 200, body: { ok: true, connected: true, authenticated: true, lastError: "" } };
198
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 = [];
199
+ let dingAuthBeginReply = { status: 200, body: { ok: true } };
200
+ let dingAuthStatusReply = { ok: true, state: "idle", url: "", remainingSec: 0, message: "" };
201
201
  const nextPoll = () => (pollReplies.length > 1 ? pollReplies.shift() : pollReplies[0]);
202
202
 
203
203
  const makeEl = () => ({
@@ -265,10 +265,13 @@ function loadPlugin(opts = {}) {
265
265
  dingAuthCalls.push({ kind: "begin", method: options.method || "POST" });
266
266
  return response(dingAuthBeginReply.status, dingAuthBeginReply.body);
267
267
  }
268
+ if (path === "/dsh-remote/im-dingtalk-auth/cancel") {
269
+ dingAuthCalls.push({ kind: "cancel", method: options.method || "POST" });
270
+ return response(200, { ok: true });
271
+ }
268
272
  if (path.startsWith("/dsh-remote/im-dingtalk-auth/status")) {
269
273
  dingAuthCalls.push({ kind: "status", path });
270
- const next = dingAuthStatusReplies.length ? dingAuthStatusReplies.shift() : { status: "waiting" };
271
- return response(200, next);
274
+ return response(200, dingAuthStatusReply);
272
275
  }
273
276
  if (path === "/dsh-remote/im-deps") {
274
277
  depsCalls.push(Date.now());
@@ -347,7 +350,7 @@ function loadPlugin(opts = {}) {
347
350
  setImBotsTest(body, status = 200) { imBotsTestReply = { status, body }; },
348
351
  setImDeps(body) { imDepsReply = body; },
349
352
  setDingAuthBegin(body, status = 200) { dingAuthBeginReply = { status, body }; },
350
- setDingAuthStatus(list) { dingAuthStatusReplies = (list || []).slice(); },
353
+ setDingAuthStatus(body) { dingAuthStatusReply = body || { ok: true, state: "idle" }; },
351
354
  dingAuthCalls() { return dingAuthCalls.slice(); },
352
355
  depsCalls() { return depsCalls.slice(); },
353
356
  depsInstalls() { return depsInstalls.slice(); },
@@ -713,7 +716,7 @@ test("平台列表:企业微信 / 钉钉 / 飞书 / Webhook 都是真入口(
713
716
  assert.ok(!texts.some((t) => t.includes("飞书机器人") && t.includes("即将支持")), "飞书不再是即将支持");
714
717
  });
715
718
 
716
- test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成功)+ 顶部「不用去后台」说明;手动配置折叠", async () => {
719
+ test("钉钉主路径:点扫码→begin→轮询桥状态:先生成链接、再等待确认、成功自动切已连接", async () => {
717
720
  const plugin = loadPlugin();
718
721
  plugin.setImBotsGet({
719
722
  ok: true,
@@ -721,7 +724,8 @@ test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成
721
724
  dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
722
725
  status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false, phase: "idle" } } },
723
726
  });
724
- plugin.setDingAuthStatus([{ status: "waiting" }, { status: "success", clientIdTail: "8899", credentialSource: "scan", hasSecret: true }]);
727
+ // 桥还没跑完 begin:状态是 idle → 面板显示"正在生成授权链接…"
728
+ plugin.setDingAuthStatus({ ok: true, state: "idle", url: "", remainingSec: 0 });
725
729
  let tree = await plugin.settle();
726
730
  openEntry(tree, "channel");
727
731
  tree = await plugin.settle();
@@ -729,41 +733,43 @@ test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成
729
733
  tree = await plugin.settle();
730
734
  let dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
731
735
  assert.ok(dlg, "钉钉配置页应打开");
732
- // 主路径:一句说明 + 扫码按钮
733
- assert.ok(textOf(dlg).includes("不用去开发者后台创建应用") || textOf(dlg).includes("不需要去开发者后台创建应用"),
734
- "顶部要说明不需要去开发者后台创建应用");
736
+ assert.ok(textOf(dlg).includes("不用去开发者后台创建应用"), "顶部要说明不需要去开发者后台创建应用");
735
737
  const scanBtn = btnWith(dlg, "用钉钉扫码,一键创建机器人");
736
738
  assert.ok(scanBtn, "主路径必须是一个「扫码一键创建」按钮");
737
- // 手动配置默认折叠(不占主路径)
738
- assert.equal(find(dlg, (n) => n.props?.id === "dru-ding-client"), undefined, "手动字段默认不应出现(折叠在高级里)");
739
+ assert.equal(find(dlg, (n) => n.props?.id === "dru-ding-client"), undefined, "手动字段默认折叠");
739
740
 
740
- // 点扫码 → begin
741
+ // 点扫码 → begin;此时还没有 URL(桥正在创建)
741
742
  scanBtn.props.onClick();
742
743
  await flush(); await flush();
743
744
  tree = await plugin.settle(3);
744
- assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "begin"), "★ 点扫码必须 POST begin");
745
+ assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "begin"), "★ 点扫码必须 POST begin(写命令文件)");
746
+ dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
747
+ assert.ok(textOf(dlg).includes("正在生成授权链接"), "URL 未就绪时如实显示正在生成(不瞎编链接)");
748
+
749
+ // 桥写回 waiting + url/remainingSec → 面板轮询拿到并展示
750
+ plugin.setDingAuthStatus({ ok: true, state: "waiting", url: "https://oapi.dingtalk.com/x?nonce=1", remainingSec: 300, expiresAt: Date.now() + 300000 });
751
+ await plugin.advance(2100);
752
+ await flush(); await flush();
753
+ tree = await plugin.settle(3);
745
754
  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("用手机钉钉扫"), "没有二维码库时要提示用手机钉钉扫链接");
755
+ assert.ok(textOf(dlg).includes("等待你在钉钉里确认"), "有 URL 后显示等待确认");
756
+ assert.ok(textOf(dlg).includes("剩余约 5 分钟"), "要显示剩余分钟(remainingSec/60)");
748
757
  const link = find(dlg, (n) => n.type === "a" && String(n.props?.href || "").includes("oapi.dingtalk.com"));
749
758
  assert.ok(link, "要给出可点击的授权链接");
750
759
  assert.equal(link.props.target, "_blank");
751
760
  assert.equal(link.props.rel, "noopener");
752
761
  assert.ok(btnWith(dlg, "复制授权链接"), "要有复制授权链接按钮");
762
+ assert.ok(btnWith(dlg, "我已完成授权,检查一下"), "要有手动检查按钮");
763
+ assert.ok(btnWith(dlg, "取消授权"), "要有取消授权(写 cancel 命令)");
753
764
 
754
- // 轮询:waiting → success(fake timers 推进 2 轮)
755
- await plugin.advance(2100);
756
- await flush(); await flush();
757
- tree = await plugin.settle(3);
765
+ // 桥把进度推进到 success → 面板下一次轮询自动切已连接并收起授权块
766
+ plugin.setDingAuthStatus({ ok: true, state: "success", clientIdTail: "8899", message: "" });
758
767
  await plugin.advance(2100);
759
768
  await flush(); await flush();
760
769
  tree = await plugin.settle(4);
761
- assert.ok(plugin.dingAuthCalls().filter((c) => c.kind === "status").length >= 1, "要轮询授权状态");
762
770
  assert.ok(textOf(tree).includes("来源:扫码创建"), "成功后自动切到已连接状态(显示来源)");
763
771
  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("等待你在钉钉里确认"), "成功后不再显示等待态");
772
+ assert.equal(btnWith(tree, "我已完成授权,检查一下"), undefined, "成功后授权块自动收起");
767
773
  });
768
774
 
769
775
  test("钉钉手动模式:折叠展开后 7 步 + 三个坑 + 三字段;官方链接全部 target=_blank rel=noopener 且提交体字段锁死", async () => {
@@ -1291,33 +1297,56 @@ test("飞书依赖下载:点下载 → POST 安装 → 轮询 /im-deps,装
1291
1297
 
1292
1298
  // ─────────── ⑫ 钉钉授权修正(2026-11-08 生产踩坑:TTL/人话/未配置措辞) ───────────
1293
1299
 
1294
- test("钉钉等待中:「我已完成授权,检查一下」手动触发 status?check=1(不再 2 分钟判死)", async () => {
1300
+ test("钉钉等待中:「我已完成授权,检查一下」再读一次桥状态;成功即切已连接(关面板也不丢)", async () => {
1295
1301
  const plugin = loadPlugin();
1296
1302
  plugin.setImBotsGet({
1297
1303
  ok: true, wecom: {}, feishu: {}, dingtalk: {},
1298
1304
  status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false } } },
1299
1305
  });
1300
- // 用户点「检查」时服务端后台已经推进到 success(手动检查只是立刻取一次结论)
1301
- plugin.setDingAuthStatus([{ status: "success", clientIdTail: "0001", credentialSource: "scan", hasSecret: true }]);
1306
+ plugin.setDingAuthStatus({ ok: true, state: "idle", url: "", remainingSec: 0 });
1302
1307
  let tree = await plugin.settle();
1303
1308
  openEntry(tree, "channel");
1304
1309
  tree = await plugin.settle();
1305
1310
  clickChannelRow(tree, "钉钉机器人");
1306
1311
  tree = await plugin.settle();
1307
1312
  btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
1313
+ plugin.setDingAuthStatus({ ok: true, state: "waiting", url: "https://oapi.dingtalk.com/z", remainingSec: 120 });
1314
+ await flush(); await flush();
1315
+ tree = await plugin.settle(3);
1316
+ await plugin.advance(2100); // 让轮询读到 waiting(url 到位)
1308
1317
  await flush(); await flush();
1309
1318
  tree = await plugin.settle(3);
1310
1319
  const checkBtn = btnWith(tree, "我已完成授权,检查一下");
1311
1320
  assert.ok(checkBtn, "等待中要有「我已完成授权,检查一下」按钮");
1321
+ const before = plugin.dingAuthCalls().filter((c) => c.kind === "status").length;
1322
+ // 用户已在手机钉钉确认 → 桥把状态写成 success;手动检查只再读一次状态
1323
+ plugin.setDingAuthStatus({ ok: true, state: "success", clientIdTail: "0001", message: "" });
1312
1324
  checkBtn.props.onClick();
1313
1325
  await flush(); await flush();
1314
1326
  tree = await plugin.settle(3);
1315
- const calls = plugin.dingAuthCalls().filter((c) => c.kind === "status");
1316
- assert.ok(calls.some((c) => String(c.path).includes("check=1")), "★ 手动检查必须触发一次 status?check=1(让服务端立刻补轮)");
1327
+ const after = plugin.dingAuthCalls().filter((c) => c.kind === "status").length;
1328
+ assert.ok(after > before, "手动检查必须再读一次 status(桥持久化,不需要面板传 check 参数)");
1317
1329
  assert.ok(textOf(tree).includes("来源:扫码创建"), "手动检查成功后自动切已连接并收起授权块");
1318
1330
  assert.equal(btnWith(tree, "我已完成授权,检查一下"), undefined, "成功后授权块自动收起");
1319
1331
  });
1320
1332
 
1333
+ test("钉钉授权失败/过期:显示「授权失败:<中文人话>」+ 重试按钮(绝不裸渲染)", async () => {
1334
+ const plugin = loadPlugin();
1335
+ plugin.setImBotsGet({
1336
+ ok: true, wecom: {}, feishu: {}, dingtalk: {},
1337
+ status: { dingtalk: { connected: false, auth: { state: "failed", message: "ok" } }, feishu: {}, deps: { feishu: { installed: false } } },
1338
+ });
1339
+ let tree = await plugin.settle();
1340
+ openEntry(tree, "channel");
1341
+ tree = await plugin.settle();
1342
+ clickChannelRow(tree, "钉钉机器人");
1343
+ tree = await plugin.settle();
1344
+ const t = textOf(tree);
1345
+ assert.ok(t.includes("授权失败"), "桥报 failed 要显示授权失败");
1346
+ assert.ok(t.includes("操作失败,请稍后重试"), "裸 ok 要兜底成中文人话");
1347
+ assert.ok(!/授权失败:\s*ok\s*/m.test(t), "绝不裸渲染 ok");
1348
+ });
1349
+
1321
1350
  test("钉钉未配置措辞:enabled=false 显示「未配置」,不是「连接失败」", async () => {
1322
1351
  const plugin = loadPlugin();
1323
1352
  plugin.setImBotsGet({
@@ -1353,3 +1382,27 @@ test("钉钉连接失败:只有 state=failed 才显示「连接失败:<中
1353
1382
  assert.ok(t.includes("操作失败,请稍后重试"), "后端给了裸 ok 时要兜底成中文人话");
1354
1383
  assert.ok(!/连接失败:\s*ok\s*$/m.test(t), "绝不把裸 ok 渲染出来");
1355
1384
  });
1385
+
1386
+ test("钉钉等待中:点「取消授权」写 cancel 命令并收起授权块", async () => {
1387
+ const plugin = loadPlugin();
1388
+ plugin.setImBotsGet({
1389
+ ok: true, wecom: {}, feishu: {}, dingtalk: {},
1390
+ status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false } } },
1391
+ });
1392
+ plugin.setDingAuthStatus({ ok: true, state: "idle", url: "", remainingSec: 0 });
1393
+ let tree = await plugin.settle();
1394
+ openEntry(tree, "channel");
1395
+ tree = await plugin.settle();
1396
+ clickChannelRow(tree, "钉钉机器人");
1397
+ tree = await plugin.settle();
1398
+ btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
1399
+ await flush(); await flush();
1400
+ tree = await plugin.settle(3);
1401
+ const cancel = btnWith(tree, "取消授权");
1402
+ assert.ok(cancel, "等待中要有「取消授权」");
1403
+ cancel.props.onClick();
1404
+ await flush(); await flush();
1405
+ tree = await plugin.settle(3);
1406
+ assert.ok(plugin.dingAuthCalls().some((c) => c.kind === "cancel"), "取消授权必须写 cancel 命令(桥停轮询)");
1407
+ assert.equal(btnWith(tree, "取消授权"), undefined, "取消后授权块收起");
1408
+ });