@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.
@@ -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", "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", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
153
153
 
154
154
  /**
155
155
  * 把"客户端脚本"补齐到配置目录。
@@ -259,12 +259,25 @@ test("mirror_load 事件形状:ms / at / outcome 三个字段,且越界由 e
259
259
  // 缺 at → 保留默认时刻(绝不发 0,0 会被读成 1970 年)
260
260
  const noAt = TELEMETRY.eventOf("mirror_load", { ms: 10 });
261
261
  assert.ok(noAt.at > 0, "没给 at 时必须退回事件构造时刻,绝不发 0");
262
- assert.equal(noAt.outcome, "ok", "outcome 只认 slow,其余一律 ok");
262
+ assert.equal(noAt.outcome, "", "2026-11-08:缺省 outcome 发 ''(未知),不再谎报 ok");
263
263
  // 越界 / 非法 ms:不写入该字段(路由层已先挡掉,这是第二道防线)
264
264
  const bad = TELEMETRY.eventOf("mirror_load", { ms: "abc" });
265
265
  assert.equal("ms" in bad, false, "非法 ms 不得进 payload");
266
266
  const huge = TELEMETRY.eventOf("mirror_load", { ms: 10 ** 9 });
267
267
  assert.equal(huge.ms, 600000, "越界 ms 钳到契约上限");
268
+ // 2026-11-08 新口径:四枚举透传 + 活跃时长三字段 + active<=ms
269
+ const sus = TELEMETRY.eventOf("mirror_load", { ms: 541664, outcome: "suspended", active_ms: 23664, hidden_ms: 518000, frozen_ms: 0 });
270
+ assert.equal(sus.outcome, "suspended");
271
+ assert.equal(sus.active_ms, 23664);
272
+ assert.equal(sus.hidden_ms, 518000);
273
+ assert.equal(sus.frozen_ms, 0);
274
+ const weird = TELEMETRY.eventOf("mirror_load", { ms: 10, outcome: "wat", active_ms: 999 });
275
+ assert.equal(weird.outcome, "", "白名单外 outcome → ''");
276
+ assert.equal(weird.active_ms, 10, "active_ms 不得超过墙钟 ms");
277
+ const clamp = TELEMETRY.eventOf("mirror_load", { ms: 100, active_ms: 10 ** 9 });
278
+ assert.equal(clamp.active_ms, 100, "active_ms 先钳到契约上限,再被墙钟 ms 压回");
279
+ const cap = TELEMETRY.eventOf("mirror_load", { ms: 600000, active_ms: 10 ** 9 });
280
+ assert.equal(cap.active_ms, 600000, "墙钟也到上限时 active_ms 钳到 600000");
268
281
  });
269
282
 
270
283
  test("POST /dsh-remote/telemetry/mirror-load:合法/非法入参都回 200 {ok:true},非法**静默丢弃**", async () => {
@@ -463,15 +463,20 @@ test("PUT:保存钉钉配置(0600 原子写 + 保留未知字段 + 空 secre
463
463
  test("PUT:保存飞书配置(字段锁死 + receiveIdType 校验 + 空 secret 不覆盖)", async () => {
464
464
  const relayDir = await tmpRelay();
465
465
  await withHost(relayDir, {}, async (base) => {
466
- let r = await putPlatform(base, "feishu", { appId: "cli_a", appSecret: "fs_a", receiveId: "ou_a", receiveIdType: "chat_id" });
467
- assert.equal(r.status, 200);
466
+ // ★ 2026-11-08 业主:接收人不需要用户填(桥侧自动发现)→ 不传 receiveId 也必须能保存
467
+ let r = await putPlatform(base, "feishu", { appId: "cli_a", appSecret: "fs_a" });
468
+ assert.equal(r.status, 200, "只有 App ID + Secret 就必须能保存");
468
469
  let cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
469
- assert.equal(cfg.feishu.receiveIdType, "chat_id");
470
+ assert.equal(cfg.feishu.appId, "cli_a");
471
+ assert.equal(cfg.feishu.receiveId, "", "接收人留空 = 交给桥自动发现");
472
+ assert.equal(cfg.feishu.receiveIdType, "open_id", "默认 open_id(桥可能在自动发现后回写)");
473
+ r = await putPlatform(base, "feishu", { receiveIdType: "chat_id" });
474
+ assert.equal(r.status, 200);
470
475
  assert.equal(cfg.feishu.domain, "https://open.feishu.cn", "domain 默认官方");
471
476
  r = await putPlatform(base, "feishu", { appId: "cli_b", appSecret: "", receiveId: "ou_b" });
472
477
  cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
473
478
  assert.equal(cfg.feishu.appSecret, "fs_a", "空 secret 不覆盖");
474
- assert.equal(cfg.feishu.receiveIdType, "chat_id", "未传的字段 = 不修改(保留上次的 chat_id)");
479
+ assert.equal(cfg.feishu.receiveIdType, "chat_id", "显式传 receiveIdType 时可改(高级/兼容用)");
475
480
  // 非法 receiveIdType
476
481
  r = await putPlatform(base, "feishu", { appId: "x", appSecret: "y", receiveId: "z", receiveIdType: "userid" });
477
482
  assert.equal(r.status, 400);
@@ -512,11 +517,12 @@ test("GET 状态白名单:status.dingtalk / status.feishu 按契约透出(
512
517
  enabled: true, state: "connected", connected: true, authenticated: true, lastError: "", boundChatId: "chat-9",
513
518
  tier: "pro", caps: ["dingtalk"], capsCount: 1, bindingGated: false, messagesPerMonth: 0, remainingMessages: null,
514
519
  usage: { month: "2026-11", messages: 5 }, clientSecret: "LEAK-D", token: "LEAK-T",
520
+ credentialSource: "scan", robotCodeConfigured: true, capabilities: { openapi: true, card: false }, internal: "LEAK-C",
515
521
  },
516
522
  feishu: {
517
523
  enabled: true, state: "failed", connected: false, authenticated: false, lastError: "鉴权失败", boundChatId: "",
518
524
  tier: "free", caps: [], capsCount: 0, bindingGated: true, messagesPerMonth: 20, remainingMessages: 19,
519
- usage: { month: "2026-11", messages: 1 }, appSecret: "LEAK-F",
525
+ usage: { month: "2026-11", messages: 1 }, boundChatType: "p2p", receiveIdSource: "auto", appSecret: "LEAK-F",
520
526
  },
521
527
  });
522
528
  const r = await json(`${base}/dsh-remote/im-bots`);
@@ -526,11 +532,16 @@ test("GET 状态白名单:status.dingtalk / status.feishu 按契约透出(
526
532
  assert.deepEqual(d.caps, ["dingtalk"]);
527
533
  assert.equal(d.remainingMessages, null, "null 必须保持 null(≠0)");
528
534
  assert.deepEqual(d.usage, { month: "2026-11", messages: 5 });
535
+ assert.equal(d.credentialSource, "scan", "凭据来源要透出(扫码/手动)");
536
+ assert.equal(d.robotCodeConfigured, true, "Robot Code 是否配置要透出(面板据此置灰能力)");
537
+ assert.deepEqual(d.capabilities, { openapi: true, card: false }, "能力开关只透出 openapi/card 两个布尔");
529
538
  const f = r.body.status.feishu;
530
539
  assert.equal(f.state, "failed");
531
540
  assert.equal(f.tier, "free");
532
541
  assert.equal(f.bindingGated, true);
533
542
  assert.equal(f.remainingMessages, 19);
543
+ assert.equal(f.boundChatType, "p2p", "飞书自动绑定类型要透出(面板显示「已自动绑定:私聊」)");
544
+ assert.equal(f.receiveIdSource, "auto", "自动发现来源要透出");
534
545
  assert.ok(!JSON.stringify(r.body).includes("LEAK"), "★ 桥写状态里的敏感字段必须被白名单挡掉");
535
546
  // wecom 老字段仍在(向后兼容)
536
547
  assert.equal(typeof r.body.status.connected, "boolean");
@@ -620,3 +631,144 @@ test("POST /im-deps/feishu:安装失败 → phase=failed + 人话原因(netw
620
631
  assert.equal(r2.body.started, true, "失败后允许重试");
621
632
  });
622
633
  });
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: 120 });
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
+ }
662
+
663
+ test("POST /im-dingtalk-auth/begin:返回 authId/url/interval/expiresIn;device_code 绝不落浏览器", async () => {
664
+ const relayDir = await tmpRelay();
665
+ const imAuthFetch = makeAuthFetch();
666
+ await withHost(relayDir, { imAuthFetch }, async (base) => {
667
+ const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
668
+ assert.equal(r.status, 200);
669
+ 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, 120);
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 }, 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 }, 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}`);
706
+ assert.equal(st.body.status, "waiting", "用户还没确认 → waiting");
707
+ st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}`);
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"));
727
+ });
728
+ });
729
+
730
+ test("GET status:未知/过期 authId → expired;钉钉拒绝 → failed(dingtalk_api_rejected);网络不通 → failed(network_error)", async () => {
731
+ 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");
736
+
737
+ // 钉钉拒绝 poll
738
+ const rejDir = await tmpRelay();
739
+ const rejFetch = makeAuthFetch({ polls: [{ status: "DENIED", message: "用户取消了创建" }] });
740
+ await withHost(rejDir, { imAuthFetch: rejFetch }, 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)}`);
743
+ assert.equal(st.body.status, "failed");
744
+ assert.equal(st.body.code, "dingtalk_api_rejected");
745
+ assert.ok(st.body.message.includes("用户取消"), "钉钉原文原因要带给用户");
746
+ });
747
+
748
+ // 网络错误(poll 抛错)
749
+ const netDir = await tmpRelay();
750
+ const netFetch = makeAuthFetch({ pollThrows: true });
751
+ await withHost(netDir, { imAuthFetch: netFetch }, 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)}`);
754
+ assert.equal(st.body.status, "failed");
755
+ assert.equal(st.body.code, "network_error");
756
+ });
757
+ });
758
+ });
759
+
760
+ test("POST begin:钉钉接口不可用 → network_error / dingtalk_api_rejected(人话)", async () => {
761
+ const dir1 = await tmpRelay();
762
+ await withHost(dir1, { imAuthFetch: makeAuthFetch({ initThrows: true }) }, async (base) => {
763
+ const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
764
+ assert.equal(r.status, 502);
765
+ assert.equal(r.body.ok, false);
766
+ assert.equal(r.body.code, "network_error");
767
+ assert.match(r.body.message, /网络不通/);
768
+ });
769
+ const dir2 = await tmpRelay();
770
+ await withHost(dir2, { imAuthFetch: makeAuthFetch({ beginRejected: true }) }, async (base) => {
771
+ const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
772
+ assert.equal(r.body.code, "dingtalk_api_rejected");
773
+ });
774
+ });