@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
@@ -0,0 +1,630 @@
1
+ /**
2
+ * dingtalk-bot — 钉钉 Stream 长连接通道单测(2026-11-08)。
3
+ *
4
+ * 全部零真实网络:本机起一个假 gateway(HTTP /open + WebSocketServer),本机起一个假 sessionWebhook
5
+ * 服务;凭据用假值。覆盖:
6
+ * 建连报文(open body/clientId/订阅 topic/ua)、每帧 ACK(SYSTEM ping / 机器人消息 / EVENT)、
7
+ * 入站解析 + sessionWebhook 回复(格式化后无裸表格)、ticket 一次性(每次重连重新 open)、
8
+ * SYSTEM disconnect 重连、未知 SYSTEM 忽略不崩、凭据错(4xx)不重连、会员闸(免费 20 条/月)、
9
+ * 状态文件字段、secret 不进日志、controller 启停与热更新。
10
+ *
11
+ * 说明:ACK 的 `data` 我们按官方 SDK 行为发 **JSON 字符串**(task 里提过的 base64 变体标 UNVERIFIED,
12
+ * 真机核验后只改 buildAck 一处);断言里解析后比较,不依赖序列化细节。
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import fs from "node:fs";
16
+ import http from "node:http";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import test from "node:test";
20
+ import { EventEmitter } from "node:events";
21
+
22
+ import { WebSocketServer } from "ws";
23
+
24
+ import {
25
+ BOT_CALLBACK_ACK,
26
+ DINGTALK_OPEN_URL,
27
+ TICKET_TTL_MS,
28
+ TOPIC_ROBOT,
29
+ buildAck,
30
+ buildOpenBody,
31
+ buildPingAck,
32
+ buildSessionWebhookBody,
33
+ createDingtalkBot,
34
+ createDingtalkBotController,
35
+ normalizeBotMessage,
36
+ normalizeDingtalkConfig,
37
+ parseFrame,
38
+ readDingtalkConfig
39
+ } from "../dingtalk-bot.mjs";
40
+ import {
41
+ IM_BOTS_FILE,
42
+ IM_BOT_STATUS_FILE
43
+ } from "../wecom-bot.mjs";
44
+
45
+ const FAKE_SECRET = "DingSecret-Should-Never-Leak-123";
46
+ const FAKE_CLIENT = "ding-fake-client-id";
47
+
48
+ // ─────────────────────────── 测试工具 ───────────────────────────
49
+
50
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
51
+
52
+ async function waitFor(fn, { label = "条件", timeout = 5000, interval = 20 } = {}) {
53
+ const t0 = Date.now();
54
+ for (;;) {
55
+ let value;
56
+ try { value = await fn(); } catch { value = undefined; }
57
+ if (value) return value;
58
+ if (Date.now() - t0 > timeout) throw new Error(`等待超时:${label}`);
59
+ await sleep(interval);
60
+ }
61
+ }
62
+
63
+ function tmpRelayDir() {
64
+ return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-dingtalk-"));
65
+ }
66
+
67
+ /**
68
+ * 假 gateway:HTTP POST /open 返回 {endpoint, ticket};WebSocketServer 收 ACK。
69
+ * `openResponder` 可返回 {status, body} 覆盖 open 响应(测凭据错/异常)。
70
+ */
71
+ async function startGateway({ openResponder } = {}) {
72
+ const state = { opens: [], acks: [], tickets: [], clients: [], closeCount: 0, lastUpgradeUrl: "" };
73
+ const server = http.createServer((req, res) => {
74
+ if (req.method === "POST" && String(req.url || "").startsWith("/open")) {
75
+ let body = "";
76
+ req.on("data", (c) => { body += c; });
77
+ req.on("end", () => {
78
+ let json = null;
79
+ try { json = JSON.parse(body); } catch { json = null; }
80
+ state.opens.push({ url: req.url, body: json, headers: req.headers, raw: body });
81
+ const custom = typeof openResponder === "function" ? openResponder(json, state) : null;
82
+ if (custom) {
83
+ res.writeHead(custom.status || 200, { "content-type": "application/json" });
84
+ res.end(JSON.stringify(custom.body === undefined ? {} : custom.body));
85
+ return;
86
+ }
87
+ state.tickets.push(`ticket-${state.tickets.length + 1}`);
88
+ res.writeHead(200, { "content-type": "application/json", accept: "application/json" });
89
+ res.end(JSON.stringify({ endpoint: `ws://127.0.0.1:${server.address().port}`, ticket: state.tickets[state.tickets.length - 1] }));
90
+ });
91
+ return;
92
+ }
93
+ res.writeHead(404, { "content-type": "text/plain" });
94
+ res.end("not found");
95
+ });
96
+ const wss = new WebSocketServer({ noServer: true });
97
+ wss.on("connection", (ws) => {
98
+ state.clients.push(ws);
99
+ ws.on("message", (raw) => {
100
+ try { state.acks.push(JSON.parse(raw.toString("utf8"))); } catch { /* 忽略坏帧 */ }
101
+ });
102
+ ws.on("close", () => { state.closeCount += 1; });
103
+ });
104
+ server.on("upgrade", (req, socket, head) => {
105
+ state.lastUpgradeUrl = String(req.url || "");
106
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
107
+ });
108
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
109
+ const port = server.address().port;
110
+ return {
111
+ state, port,
112
+ openUrl: `http://127.0.0.1:${port}/open`,
113
+ wsUrl: `ws://127.0.0.1:${port}`,
114
+ /** 给最新一条连接推一帧(服务端 → 客户端)。 */
115
+ push(frame) {
116
+ const ws = state.clients[state.clients.length - 1];
117
+ assert.ok(ws, "还没有 WebSocket 连接");
118
+ ws.send(typeof frame === "string" ? frame : JSON.stringify(frame));
119
+ },
120
+ killClients(code = 1006) {
121
+ for (const ws of state.clients) { try { ws.terminate(); } catch { /* 忽略 */ } }
122
+ },
123
+ /** 取到的 ACK 里 data 解析成对象(兼容 string/object 两种实现)。 */
124
+ ackData(ack) {
125
+ if (!ack) return null;
126
+ if (ack.data && typeof ack.data === "object") return ack.data;
127
+ try { return JSON.parse(String(ack.data || "")); } catch { return null; }
128
+ },
129
+ async close() {
130
+ for (const ws of state.clients) { try { ws.terminate(); } catch { /* 忽略 */ } }
131
+ await new Promise((r) => wss.close(() => r()));
132
+ await new Promise((r) => server.close(() => r()));
133
+ }
134
+ };
135
+ }
136
+
137
+ /** 假 sessionWebhook(钉钉回复走 HTTP POST)。 */
138
+ async function startWebhook(payload = { errcode: 0, errmsg: "ok" }, status = 200) {
139
+ const state = { calls: [] };
140
+ const server = http.createServer((req, res) => {
141
+ if (req.method !== "POST") { res.writeHead(404); res.end(); return; }
142
+ let body = "";
143
+ req.on("data", (c) => { body += c; });
144
+ req.on("end", () => {
145
+ let json = null;
146
+ try { json = JSON.parse(body); } catch { json = null; }
147
+ state.calls.push({ url: req.url, body: json, headers: req.headers });
148
+ res.writeHead(status, { "content-type": "application/json" });
149
+ res.end(JSON.stringify(payload));
150
+ });
151
+ });
152
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
153
+ const port = server.address().port;
154
+ return {
155
+ state,
156
+ url: `http://127.0.0.1:${port}/hook`,
157
+ last() { return state.calls[state.calls.length - 1]; },
158
+ async close() { await new Promise((r) => server.close(() => r())); }
159
+ };
160
+ }
161
+
162
+ /** 最小 fake 订阅器(与 bridge 里 dsh-events 的对外接口同形)。 */
163
+ function fakeSubscriber({ sessions = [{ sessionId: "session-live", running: true, title: "活跃会话" }], answer = "这是 agent 的最终回答" } = {}) {
164
+ const emitter = new EventEmitter();
165
+ const calls = { prompt: [], listSessions: 0, lastAssistant: 0 };
166
+ return {
167
+ on(evt, fn) { emitter.on(evt, fn); return this; },
168
+ once(evt, fn) { emitter.once(evt, fn); return this; },
169
+ emit(evt, ...args) { emitter.emit(evt, ...args); },
170
+ start() { /* noop */ },
171
+ close() { /* noop */ },
172
+ sessions() { return sessions.map((s) => s.sessionId); },
173
+ async listSessions() { calls.listSessions += 1; return { ok: true, sessions }; },
174
+ async promptSession({ sessionId, text }) { calls.prompt.push({ sessionId, text }); return { ok: true }; },
175
+ async lastAssistantText() { calls.lastAssistant += 1; return { ok: true, text: answer }; },
176
+ calls
177
+ };
178
+ }
179
+
180
+ /** 建一个 bot(可选注入 capsProvider)。 */
181
+ async function setupBot(over = {}) {
182
+ const gateway = await startGateway(over.gateway);
183
+ const hook = await startWebhook();
184
+ const relayDir = over.relayDir === undefined ? tmpRelayDir() : over.relayDir;
185
+ const sub = over.sub || fakeSubscriber(over.subOptions);
186
+ const logs = [];
187
+ const bot = createDingtalkBot({
188
+ clientId: FAKE_CLIENT,
189
+ clientSecret: FAKE_SECRET,
190
+ robotCode: "ding-robot-code",
191
+ gatewayUrl: gateway.openUrl,
192
+ relayDir,
193
+ subscriber: sub,
194
+ appUrl: "https://app.test/",
195
+ logger: (level, line) => logs.push(line),
196
+ reconnectBaseMs: 40,
197
+ reconnectMaxMs: 160,
198
+ tierDenyThrottleMs: 0,
199
+ ...(over.bot || {})
200
+ });
201
+ return {
202
+ bot, gateway, hook, relayDir, sub, logs,
203
+ async start() {
204
+ bot.start();
205
+ await waitFor(() => bot.status().connected === true, { label: "连接建立" });
206
+ return bot.status();
207
+ },
208
+ /** 推一条机器人文本消息(模拟钉钉下发)。 */
209
+ async inbound({ text = "继续", msgId = `msg-${Date.now()}-${Math.random()}`, conversationId = "cid-group-1", conversationType = "2", senderStaffId = "staff-1", webhook } = {}) {
210
+ const data = {
211
+ conversationId, conversationType, senderStaffId, senderNick: "小钉",
212
+ msgtype: "text", msgId, text: { content: text },
213
+ sessionWebhook: webhook === undefined ? hook.url : webhook,
214
+ sessionWebhookExpiredTime: Date.now() + 10 * 60 * 1000,
215
+ robotCode: "ding-robot-code", createAt: Date.now()
216
+ };
217
+ gateway.push({
218
+ type: "CALLBACK",
219
+ headers: { messageId: `mid-${msgId}`, topic: TOPIC_ROBOT, contentType: "application/json", time: String(Date.now()) },
220
+ data: JSON.stringify(data)
221
+ });
222
+ return data;
223
+ },
224
+ async close() {
225
+ await bot.stop();
226
+ await gateway.close();
227
+ await hook.close();
228
+ fs.rmSync(relayDir, { recursive: true, force: true });
229
+ }
230
+ };
231
+ }
232
+
233
+ // ─────────────────────────── 纯协议函数 ───────────────────────────
234
+
235
+ test("协议纯函数:open 请求体 / ACK(JSON 字符串 data)/ ping ACK 原样回传 opaque / 帧解析 / 配置规范化", () => {
236
+ const body = buildOpenBody({ clientId: "id", clientSecret: "sec", ua: "dsh-remote-bridge/0.7.11" });
237
+ assert.deepEqual(body.subscriptions, [{ type: "CALLBACK", topic: TOPIC_ROBOT }], "默认只订机器人消息 topic");
238
+ assert.equal(body.clientId, "id");
239
+ assert.equal(body.clientSecret, "sec");
240
+ assert.equal(body.ua, "dsh-remote-bridge/0.7.11");
241
+ assert.ok(!("localIp" in body), "未提供 localIp 时不带该字段");
242
+
243
+ const ack = buildAck("m-1", BOT_CALLBACK_ACK);
244
+ assert.equal(ack.code, 200);
245
+ assert.equal(ack.message, "OK");
246
+ assert.equal(ack.headers.messageId, "m-1");
247
+ assert.equal(ack.headers.contentType, "application/json");
248
+ assert.equal(typeof ack.data, "string", "★ ACK data 是 JSON 字符串(官方 SDK 行为;base64 变体标 UNVERIFIED)");
249
+ assert.deepEqual(JSON.parse(ack.data), { response: null });
250
+
251
+ const ping = buildPingAck("m-2", "opaque-xyz");
252
+ assert.equal(ping.headers.messageId, "m-2");
253
+ assert.deepEqual(JSON.parse(ping.data), { opaque: "opaque-xyz" }, "ping 必须原样回传 data.opaque");
254
+
255
+ const frame = parseFrame(JSON.stringify({ type: "CALLBACK", headers: { messageId: "m", topic: TOPIC_ROBOT }, data: JSON.stringify({ msgId: "x", text: { content: "hi" } }) }));
256
+ assert.equal(frame.type, "CALLBACK");
257
+ assert.equal(frame.headers.messageId, "m");
258
+ assert.deepEqual(frame.data, { msgId: "x", text: { content: "hi" } }, "data 字符串要二次 parse");
259
+ assert.equal(parseFrame("not-json"), null);
260
+
261
+ const msg = normalizeBotMessage({ conversationId: "c1", conversationType: "2", senderStaffId: "s1", msgtype: "text", text: { content: " hi " }, sessionWebhook: "u", msgId: "m" });
262
+ assert.equal(msg.text, "hi");
263
+ assert.equal(msg.isGroup, true);
264
+ assert.equal(msg.chatId, "c1", "群聊目标 = conversationId");
265
+
266
+ const cfg = normalizeDingtalkConfig({ enabled: true, clientId: " a ", clientSecret: "s", replyMode: "OPENAPI", allowFrom: ["u1", "u2"] });
267
+ assert.equal(cfg.clientId, "a");
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");
273
+ assert.deepEqual(cfg.allowFrom, ["u1", "u2"]);
274
+ // 缺省/非法值不抛
275
+ assert.equal(normalizeDingtalkConfig(null).enabled, false);
276
+ assert.equal(normalizeDingtalkConfig({ replyMode: "weird" }).replyMode, "sessionWebhook");
277
+ });
278
+
279
+ test("buildSessionWebhookBody:markdown 形状(标题兜底、正文原样由 im-format 适配)", () => {
280
+ const body = buildSessionWebhookBody({ title: "", text: "| a | b |\n|---|---|\n| 1 | 2 |" });
281
+ assert.equal(body.msgtype, "markdown");
282
+ assert.equal(body.markdown.title, "DeepSeek Harness");
283
+ assert.ok(body.markdown.text.includes("|---|---|"), "这里只构造报文;表格适配由 replySessionWebhook 里的 im-format 负责");
284
+ });
285
+
286
+ // ─────────────────────────── 建连 + ACK ───────────────────────────
287
+
288
+ test("★ 建连:open 请求体(clientId/clientSecret/订阅 topic/ua)→ WS 连上 → 状态 connected", async () => {
289
+ const rt = await setupBot();
290
+ try {
291
+ await rt.start();
292
+ const open = rt.gateway.state.opens[0];
293
+ assert.ok(open, "必须调用 connections/open");
294
+ assert.equal(open.body.clientId, FAKE_CLIENT);
295
+ assert.equal(open.body.clientSecret, FAKE_SECRET);
296
+ assert.deepEqual(open.body.subscriptions, [{ type: "CALLBACK", topic: TOPIC_ROBOT }]);
297
+ assert.match(rt.gateway.state.lastUpgradeUrl, /\?ticket=ticket-1$/, "WS 必须带 ticket 参数");
298
+ const st = rt.bot.status();
299
+ assert.equal(st.connected, true);
300
+ assert.equal(st.authenticated, true, "ticket 校验即鉴权");
301
+ assert.equal(st.state, "connected");
302
+ assert.ok(st.ticketExpiresAt > Date.now(), "状态里只留 ticket 过期时间(不存 ticket)");
303
+ } finally { await rt.close(); }
304
+ });
305
+
306
+ test("★ 每帧 ACK:SYSTEM ping 回显 opaque、机器人消息 {response:null}、EVENT {status:SUCCESS};disconnect/未知 SYSTEM 不 ACK", async () => {
307
+ const rt = await setupBot();
308
+ try {
309
+ await rt.start();
310
+ // ① SYSTEM ping
311
+ rt.gateway.push({ type: "SYSTEM", headers: { messageId: "ping-1", topic: "ping" }, data: JSON.stringify({ opaque: "op-1" }) });
312
+ const pingAck = await waitFor(() => rt.gateway.state.acks.find((a) => a.headers && a.headers.messageId === "ping-1"), { label: "ping ACK" });
313
+ assert.equal(pingAck.code, 200);
314
+ assert.deepEqual(rt.gateway.ackData(pingAck), { opaque: "op-1" });
315
+ // ② 机器人消息 CALLBACK(ACK 先于业务处理)
316
+ rt.gateway.push({
317
+ type: "CALLBACK",
318
+ headers: { messageId: "cb-1", topic: TOPIC_ROBOT },
319
+ data: JSON.stringify({ msgId: "cb-msg-1", msgtype: "text", text: { content: "hi" }, conversationId: "cid-1", conversationType: "1", senderStaffId: "staff-1", sessionWebhook: rt.hook.url })
320
+ });
321
+ const cbAck = await waitFor(() => rt.gateway.state.acks.find((a) => a.headers && a.headers.messageId === "cb-1"), { label: "callback ACK" });
322
+ assert.deepEqual(rt.gateway.ackData(cbAck), { response: null });
323
+ // ③ EVENT 订阅
324
+ rt.gateway.push({ type: "EVENT", headers: { messageId: "ev-1", topic: "*" }, data: JSON.stringify({ eventType: "x" }) });
325
+ const evAck = await waitFor(() => rt.gateway.state.acks.find((a) => a.headers && a.headers.messageId === "ev-1"), { label: "event ACK" });
326
+ assert.deepEqual(rt.gateway.ackData(evAck), { status: "SUCCESS" });
327
+ // ④ 未知 SYSTEM 忽略、不崩、不 ACK
328
+ const before = rt.gateway.state.acks.length;
329
+ for (const topic of ["CONNECTED", "REGISTERED", "KEEPALIVE", "something-new"]) {
330
+ rt.gateway.push({ type: "SYSTEM", headers: { messageId: `sys-${topic}`, topic }, data: "{}" });
331
+ }
332
+ await sleep(80);
333
+ assert.equal(rt.gateway.state.acks.length, before, "★ 未知 SYSTEM topic 不得 ACK(官方协议未定义,只记日志)");
334
+ // 收到 SYSTEM disconnect:不 ACK + 换 ticket 重连
335
+ const opensBefore = rt.gateway.state.opens.length;
336
+ rt.gateway.push({ type: "SYSTEM", headers: { messageId: "disc-1", topic: "disconnect" }, data: "{}" });
337
+ const reconnect = await waitFor(() => rt.gateway.state.opens.length > opensBefore, { label: "disconnect 后重新 open" });
338
+ assert.ok(reconnect);
339
+ await waitFor(() => rt.bot.status().connected === true, { label: "重连成功" });
340
+ assert.ok(!rt.gateway.state.acks.some((a) => a.headers && a.headers.messageId === "disc-1"), "disconnect 不 ACK");
341
+ } finally { await rt.close(); }
342
+ });
343
+
344
+ test("★ ticket 一次性 + 断线重连:每次重连都重新 open(新 ticket),不会复用旧 ticket", async () => {
345
+ const rt = await setupBot();
346
+ try {
347
+ await rt.start();
348
+ assert.equal(rt.gateway.state.opens.length, 1);
349
+ rt.gateway.killClients();
350
+ await waitFor(() => rt.gateway.state.opens.length >= 2, { label: "第二次 open" });
351
+ assert.notEqual(rt.gateway.state.tickets[0], rt.gateway.state.tickets[1], "★ 每次重连必须是新 ticket");
352
+ await waitFor(() => rt.bot.status().connected === true, { label: "重连后 connected" });
353
+ assert.equal(rt.bot.status().state, "connected");
354
+ assert.ok(rt.bot.status().openCount >= 2);
355
+ } finally { await rt.close(); }
356
+ });
357
+
358
+ test("★ 凭据错(open 返回 4xx):状态 failed、不重连、错误人话里点出 Client ID/Secret", async () => {
359
+ const rt = await setupBot({
360
+ gateway: { openResponder: () => ({ status: 401, body: { code: "InvalidAuthentication", message: "invalid clientId or clientSecret" } }) }
361
+ });
362
+ try {
363
+ rt.bot.start();
364
+ await waitFor(() => rt.bot.status().state === "failed", { label: "failed 状态" });
365
+ const st = rt.bot.status();
366
+ assert.equal(st.connected, false);
367
+ assert.match(st.lastError, /HTTP 401/);
368
+ assert.match(st.lastError, /Client ID/);
369
+ await sleep(400); // 远大于测试退避(40/80/160ms):凭据错必须**不重连**
370
+ assert.equal(rt.gateway.state.opens.length, 1, "★ 凭据错不得重连");
371
+ } finally { await rt.close(); }
372
+ });
373
+
374
+ // ─────────────────────────── 入站 + 回复 ───────────────────────────
375
+
376
+ test("★ 入站消息解析 → 转给 DSH 会话 → sessionWebhook 回复(markdown、表格已被格式化)", async () => {
377
+ const rt = await setupBot();
378
+ try {
379
+ await rt.start();
380
+ await rt.inbound({ text: "继续排查", msgId: "in-1", conversationId: "cid-group-9" });
381
+ await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "promptSession 调用" });
382
+ assert.deepEqual(rt.sub.calls.prompt[0], { sessionId: "session-live", text: "继续排查" });
383
+ const call = await waitFor(() => rt.hook.state.calls[0], { label: "sessionWebhook 回复" });
384
+ assert.equal(call.body.msgtype, "markdown");
385
+ assert.match(call.body.markdown.text, /已转给桌面端会话/);
386
+ const st = rt.bot.status();
387
+ assert.equal(st.boundChatId, "cid-group-9");
388
+ assert.equal(st.messages, 1);
389
+ assert.equal(st.usage.messages, 1, "派活成功记 1 条额度");
390
+ // 状态文件同步
391
+ const disk = JSON.parse(fs.readFileSync(path.join(rt.relayDir, IM_BOT_STATUS_FILE), "utf8"));
392
+ assert.equal(disk.dingtalk.boundChatId, "cid-group-9");
393
+ assert.equal(disk.dingtalk.usage.messages, 1);
394
+ } finally { await rt.close(); }
395
+ });
396
+
397
+ test("★ 回答回传:turn_end 拿到 agent 最终回答 → 发回 sessionWebhook;Markdown 表格转成可读列表(无裸表格)", async () => {
398
+ const answer = [
399
+ "修复完成:",
400
+ "",
401
+ "| 文件 | 改动 |",
402
+ "|---|---|",
403
+ "| a.mjs | 修端口 |",
404
+ "| b.mjs | 加缓存 |"
405
+ ].join("\n");
406
+ const rt = await setupBot({ subOptions: { answer } });
407
+ try {
408
+ await rt.start();
409
+ await rt.inbound({ text: "给我看改动", msgId: "in-2" });
410
+ await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站转发" });
411
+ rt.sub.emit("event", { kind: "turn-end", sessionId: "session-live", turn: 1, seq: 7, at: Date.now() });
412
+ const call = await waitFor(() => rt.hook.state.calls.find((c) => /修复完成/.test(c.body.markdown.text)), { label: "回答回传" });
413
+ const text = call.body.markdown.text;
414
+ assert.ok(!/\|\s*-{2,}/.test(text), "★ 不得出现 |--- 分隔行");
415
+ assert.ok(!/^\s*\|.*\|\s*$/m.test(text), "★ 不得出现裸表格");
416
+ assert.match(text, /• 文件:a\.mjs · 改动:修端口/);
417
+ } finally { await rt.close(); }
418
+ });
419
+
420
+ test("★ 会员闸:免费档本月用满 20 条 → 第 21 条不派活、回升级文案(含 /app/promo)、通知不受影响", async () => {
421
+ const relayDir = tmpRelayDir();
422
+ const d = new Date();
423
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
424
+ fs.writeFileSync(path.join(relayDir, IM_BOT_STATUS_FILE), JSON.stringify({
425
+ version: 1, dingtalk: { usage: { month, messages: 20 } }
426
+ }), { mode: 0o600 });
427
+ const capsProvider = async () => ({ plan: "free", caps: ["notify", "approve", "status", "stop", "assign.continue"], limits: { messages_per_month: 20 }, rev: "r-free" });
428
+ const rt = await setupBot({ relayDir, bot: { capsProvider } });
429
+ try {
430
+ await rt.start();
431
+ await waitFor(() => rt.bot.status().tier === "free", { label: "档位 free" });
432
+ await rt.inbound({ text: "第 21 条", msgId: "gate-1" });
433
+ await waitFor(() => rt.hook.state.calls.length >= 1, { label: "拦截回复" });
434
+ assert.equal(rt.sub.calls.prompt.length, 0, "★ 超额不得调 promptSession");
435
+ const text = rt.hook.last().body.markdown.text;
436
+ assert.match(text, /本月免费指令已用完/);
437
+ assert.match(text, /升级 Pro/);
438
+ assert.match(text, /https:\/\/app\.test\/promo/, "要带升级链接 /app/promo");
439
+ assert.equal(rt.bot.status().usage.messages, 20, "被拦的消息不计数");
440
+ // 通知照常(免费能力,不能静默停掉)
441
+ rt.sub.emit("event", { kind: "turn-end", sessionId: "session-other", turn: 2, seq: 1, at: Date.now(), reason: "completed" });
442
+ await waitFor(() => rt.hook.state.calls.some((c) => /任务完成|完成|turn/i.test(c.body.markdown.text)), { label: "通知" }).catch(() => null);
443
+ assert.ok(rt.hook.state.calls.length >= 1, "通知/回复仍可用");
444
+ } finally { await rt.close(); }
445
+ });
446
+
447
+ // ─────────────────────────── 状态 / 脱敏 ───────────────────────────
448
+
449
+ test("★ 状态文件字段齐全(与企微对齐 + subscriptions/ticketExpiresAt),且**不含 secret/ticket/token**", async () => {
450
+ const rt = await setupBot();
451
+ try {
452
+ await rt.start();
453
+ await rt.inbound({ text: "hi", msgId: "st-1" });
454
+ await waitFor(() => rt.sub.calls.prompt.length === 1, { label: "入站" });
455
+ const raw = fs.readFileSync(path.join(rt.relayDir, IM_BOT_STATUS_FILE), "utf8");
456
+ const disk = JSON.parse(raw);
457
+ const d = disk.dingtalk;
458
+ for (const key of ["enabled", "state", "connected", "authenticated", "lastError", "lastConnectedAt", "lastMessageAt", "boundChatId", "tier", "caps", "capsCount", "bindingGated", "messagesPerMonth", "remainingMessages", "usage"]) {
459
+ assert.ok(key in d, `状态文件缺少字段 ${key}`);
460
+ }
461
+ assert.ok(Array.isArray(d.subscriptions) && d.subscriptions.includes(TOPIC_ROBOT), "subscriptions 排障字段");
462
+ assert.ok(Number(d.ticketExpiresAt) > 0, "ticketExpiresAt 时间戳");
463
+ assert.ok(!raw.includes(FAKE_SECRET), "★ 状态文件不得含 clientSecret");
464
+ assert.ok(!raw.includes("ticket-"), "★ 状态文件不得含 ticket 原值");
465
+ assert.ok(!/accessToken/i.test(raw), "★ 状态文件不得含 accessToken");
466
+ assert.deepEqual(d.usage.month.length, 7);
467
+ assert.equal(d.usage.messages, 1);
468
+ } finally { await rt.close(); }
469
+ });
470
+
471
+ test("★ 脱敏:日志里不出现 clientSecret(open 失败/正常连接都不泄漏)", async () => {
472
+ const rt = await setupBot({
473
+ gateway: { openResponder: () => ({ status: 500, body: { message: `boom ${FAKE_SECRET}` } }) }
474
+ });
475
+ try {
476
+ rt.bot.start();
477
+ await waitFor(() => rt.bot.status().state === "reconnecting" || rt.bot.status().state === "failed", { label: "错误状态" });
478
+ await sleep(50);
479
+ const joined = rt.logs.join("\n");
480
+ assert.ok(joined.length > 0, "应有日志");
481
+ assert.ok(!joined.includes(FAKE_SECRET), "★ 日志不得出现 clientSecret(含服务端回显)");
482
+ assert.ok(!/ticket-[0-9]/.test(joined), "日志不得打印 ticket 原值");
483
+ } finally { await rt.close(); }
484
+ });
485
+
486
+ // ─────────────────────────── 配置 / 控制器 ───────────────────────────
487
+
488
+ test("readDingtalkConfig:只读 dingtalk 段;文件缺失/损坏返回 null,绝不抛", () => {
489
+ const dir = tmpRelayDir();
490
+ try {
491
+ assert.equal(readDingtalkConfig(dir), null, "文件不存在 → null");
492
+ fs.writeFileSync(path.join(dir, IM_BOTS_FILE), "{bad json", { mode: 0o600 });
493
+ assert.equal(readDingtalkConfig(dir), null, "坏 JSON → null");
494
+ fs.writeFileSync(path.join(dir, IM_BOTS_FILE), JSON.stringify({ version: 1, wecom: { enabled: true } }), { mode: 0o600 });
495
+ const cfg = readDingtalkConfig(dir);
496
+ assert.ok(cfg && cfg.enabled === false, "没有 dingtalk 段 → 默认关闭");
497
+ fs.writeFileSync(path.join(dir, IM_BOTS_FILE), JSON.stringify({
498
+ version: 1,
499
+ dingtalk: { enabled: true, clientId: "id", clientSecret: "sec", robotCode: "rk", replyMode: "sessionWebhook" }
500
+ }), { mode: 0o600 });
501
+ const cfg2 = readDingtalkConfig(dir);
502
+ assert.equal(cfg2.enabled, true);
503
+ assert.equal(cfg2.robotCode, "rk");
504
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
505
+ });
506
+
507
+ test("controller:未启用不建连;启用后按配置起 bot(注入 subscriber),stop 后断开", async () => {
508
+ const gateway = await startGateway();
509
+ const relayDir = tmpRelayDir();
510
+ const sub = fakeSubscriber();
511
+ try {
512
+ fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({ version: 1, dingtalk: { enabled: false } }), { mode: 0o600 });
513
+ const controller = createDingtalkBotController({
514
+ relayDir,
515
+ log: () => {},
516
+ botOptions: { subscriber: sub },
517
+ configPollMs: 50
518
+ });
519
+ await controller.start();
520
+ assert.equal(controller.status().enabled, false);
521
+ assert.equal(controller.status().reason, "disabled");
522
+ assert.equal(gateway.state.opens.length, 0, "未启用不得建连");
523
+ // 改成启用 + 本地网关(streamUrl 覆盖)→ 热更新后连上
524
+ fs.writeFileSync(path.join(relayDir, IM_BOTS_FILE), JSON.stringify({
525
+ version: 1,
526
+ dingtalk: {
527
+ enabled: true, clientId: FAKE_CLIENT, clientSecret: FAKE_SECRET,
528
+ robotCode: "rk", cardTemplateId: "card-tpl-9", credentialSource: "scan", streamUrl: gateway.openUrl
529
+ }
530
+ }), { mode: 0o600 });
531
+ await waitFor(() => controller.status().enabled === true, { label: "控制器启用(热更新)", timeout: 4000 });
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");
536
+ await controller.stop();
537
+ assert.equal(controller.status().enabled, false);
538
+ assert.equal(controller.status().reason, "stopped");
539
+ } finally {
540
+ await gateway.close();
541
+ fs.rmSync(relayDir, { recursive: true, force: true });
542
+ }
543
+ });
544
+
545
+ test("controller:DSH_IM_BOTS=0 全关(env 优先)", async () => {
546
+ const controller = createDingtalkBotController({ relayDir: tmpRelayDir(), env: { DSH_IM_BOTS: "0" }, log: () => {}, configPollMs: 50 });
547
+ try {
548
+ await controller.start();
549
+ assert.equal(controller.status().enabled, false);
550
+ assert.equal(controller.status().reason, "disabled_by_env");
551
+ } finally { await controller.stop(); }
552
+ });
553
+
554
+ // ─────────────────────────── 常量/默认值 ───────────────────────────
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
+
626
+ test("常量:默认 open 端点 / ticket 90s / 退避 1s→60s", () => {
627
+ assert.equal(DINGTALK_OPEN_URL, "https://api.dingtalk.com/v1.0/gateway/connections/open");
628
+ assert.equal(TICKET_TTL_MS, 90_000);
629
+ assert.equal(TOPIC_ROBOT, "/v1.0/im/bot/messages/get");
630
+ });