@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.
- package/clients/dsh-remote/dingtalk-bot.mjs +49 -6
- package/clients/dsh-remote/docs/feishu-bot-contract.md +56 -16
- package/clients/dsh-remote/dsh-bridge.mjs +6 -4
- package/clients/dsh-remote/feishu-bot.mjs +197 -13
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +81 -1
- package/clients/dsh-remote/test/feishu-bot.test.mjs +260 -10
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +237 -69
- package/packages/dsh-remote-web/lib/index.js +215 -7
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +404 -90
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +14 -1
- package/packages/dsh-remote-web/test/im-bots.test.mjs +157 -5
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +261 -132
|
@@ -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, "
|
|
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
|
-
|
|
467
|
-
|
|
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.
|
|
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", "
|
|
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
|
+
});
|