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