@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,1089 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * feishu-bot 契约测试(M2 第 6 周 / task-27)。
4
+ *
5
+ * 全部用**注入的假 SDK / 假 fetch / 假事件订阅器**:不安装 41MB 官方 SDK、不联网、不建连。
6
+ *
7
+ * 覆盖(对应任务书七条):
8
+ * ① SDK 缺失 → sdk_missing 且不崩;懒加载成功 → 缓存;sdkFactory 注入
9
+ * ② 建连参数(appId/appSecret/事件与回调注册)
10
+ * ③ 收消息解析 + 回复报文;过滤 bot 自己 / 白名单
11
+ * ④ 卡片回调 → 3 秒内 ACK、幂等、过期、再异步决策(与企微同一路)
12
+ * ⑤ 免费档第 21 条拦截(入站派活计数;通知/拍板不计数)
13
+ * ⑥ secret 不进日志
14
+ * ⑦ 状态字段(feishu 段 + deps.feishu;保留 wecom 段)
15
+ *
16
+ * 用法: node --test clients/dsh-remote/test/feishu-bot.test.mjs
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import { EventEmitter } from "node:events";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import test from "node:test";
24
+
25
+ import {
26
+ APP_ID_STRICT_RE,
27
+ FEISHU_SDK_PACKAGE,
28
+ FEISHU_SETUP_GUIDE,
29
+ SDK_MISSING_HINT,
30
+ _resetFeishuSdkCache,
31
+ buildApprovalCard,
32
+ buildDecidedCard,
33
+ createFeishuBot,
34
+ createFeishuBotController,
35
+ createFeishuSender,
36
+ extractFeishuMessageText,
37
+ feishuErrorHint,
38
+ feishuSdkStatus,
39
+ formatFeishuText,
40
+ loadFeishuSdk,
41
+ mapConnectError,
42
+ normalizeFeishuConfig,
43
+ normalizeInboundEvent,
44
+ parseCardAction,
45
+ readFeishuConfig,
46
+ resolveFeishuSdkDir,
47
+ validateAppId
48
+ } from "../feishu-bot.mjs";
49
+
50
+ const APP_ID = "cli_9f427eec54ae901b"; // 16 hex:既能过我们的宽松校验,也能过 SDK 严格正则
51
+ const APP_SECRET = "super-secret-value-do-not-log";
52
+ const NOW = 1_700_000_000_000;
53
+ const at = () => NOW;
54
+
55
+ // ── 临时目录 ──────────────────────────────────────────────────────────────
56
+ const DIR = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-feishu-"));
57
+ process.on("exit", () => {
58
+ try { fs.rmSync(DIR, { recursive: true, force: true }); } catch { /* ignore */ }
59
+ });
60
+
61
+ function mkRelayDir(name) {
62
+ const d = path.join(DIR, name);
63
+ fs.mkdirSync(d, { recursive: true });
64
+ return d;
65
+ }
66
+ function writeJson(file, obj) {
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
69
+ }
70
+ function writeConfig(relayDir, feishu, extra = {}) {
71
+ writeJson(path.join(relayDir, "im-bots.json"), {
72
+ version: 1,
73
+ wecom: { enabled: true, botId: "bot-x", secret: "wecom-secret" },
74
+ feishu,
75
+ ...extra
76
+ });
77
+ }
78
+ /** 造一个「已下载 SDK」的目录(package.json + index.js 占位)。 */
79
+ function fakeSdkDir(relayDir, version = "1.76.0") {
80
+ const sdkDir = resolveFeishuSdkDir({ relayDir });
81
+ writeJson(path.join(sdkDir, "package.json"), { name: FEISHU_SDK_PACKAGE, version, main: "index.js" });
82
+ fs.writeFileSync(path.join(sdkDir, "index.js"), "module.exports = {};\n");
83
+ return sdkDir;
84
+ }
85
+
86
+ // ── 假 SDK ────────────────────────────────────────────────────────────────
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
+ class EventDispatcher {
90
+ constructor(opts) { this.opts = opts; this.handlers = {}; calls.dispatchers.push(this); }
91
+ register(handlers) { this.handlers = handlers; return this; }
92
+ }
93
+ class WSClient {
94
+ constructor(opts) { calls.ctors += 1; this.opts = opts; calls.ws.push(opts); }
95
+ start({ eventDispatcher }) {
96
+ calls.starts.push({ eventDispatcher });
97
+ if (connectError) throw connectError;
98
+ return Promise.resolve();
99
+ }
100
+ getConnectionStatus() { return { state: connectionState }; }
101
+ close() { calls.closes += 1; return Promise.resolve(); }
102
+ }
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
+ }
121
+ return { sdk: { WSClient, EventDispatcher, Client }, calls };
122
+ }
123
+
124
+ // ── 假 fetch ──────────────────────────────────────────────────────────────
125
+ function makeFetch({ tokenCode = 0, sendCode = 0, sendMsg = "", patchCode = 0 } = {}) {
126
+ const calls = [];
127
+ const fn = async (url, init = {}) => {
128
+ calls.push({ url, method: init.method || "GET", headers: init.headers || {}, body: init.body });
129
+ const isToken = url.includes("/auth/v3/tenant_access_token/internal");
130
+ const isPatch = (init.method || "GET") === "PATCH";
131
+ if (isToken) {
132
+ return {
133
+ ok: true,
134
+ status: 200,
135
+ json: async () => (tokenCode === 0
136
+ ? { code: 0, tenant_access_token: "t-token", expire: 7200 }
137
+ : { code: tokenCode, msg: "token failed" })
138
+ };
139
+ }
140
+ if (isPatch) return { ok: true, status: 200, json: async () => ({ code: patchCode, msg: "" }) };
141
+ return {
142
+ ok: true,
143
+ status: 200,
144
+ json: async () => (sendCode === 0
145
+ ? { code: 0, data: { message_id: "om_msg_1" } }
146
+ : { code: sendCode, msg: sendMsg || "send failed" })
147
+ };
148
+ };
149
+ return { fn, calls };
150
+ }
151
+
152
+ // ── 假事件订阅器 ──────────────────────────────────────────────────────────
153
+ function makeSubscriber() {
154
+ const emitter = new EventEmitter();
155
+ const calls = { sessions: [], prompts: [], started: 0, stopped: 0, listenSessions: 0 };
156
+ return {
157
+ calls,
158
+ on: (evt, cb) => emitter.on(evt, cb),
159
+ emit: (evt, arg) => emitter.emit(evt, arg),
160
+ start() { calls.started += 1; return this; },
161
+ stop() { calls.stopped += 1; },
162
+ sessions() { return ["session-abc"]; },
163
+ async listSessions() { calls.listenSessions += 1; return [{ sessionId: "session-abc", title: "t" }]; },
164
+ async promptSession(req) { calls.prompts.push(req); return { ok: true }; }
165
+ };
166
+ }
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
+
178
+ // ── 假消息/回调夹具(形状对齐官方事件) ──────────────────────────────────
179
+ const inboundText = (text = "你好", { senderType = "user", openId = "ou_user_1" } = {}) => ({
180
+ event: {
181
+ sender: { sender_id: { open_id: openId }, sender_type: senderType },
182
+ message: {
183
+ message_id: "om_in_1",
184
+ chat_id: "oc_chat_1",
185
+ chat_type: "p2p",
186
+ message_type: "text",
187
+ content: JSON.stringify({ text })
188
+ }
189
+ }
190
+ });
191
+ const cardAction = ({ decision = "allow", requestId = "apr_1", nonce = "n1", expiresAt = NOW + 60_000, openId = "ou_user_1" } = {}) => ({
192
+ event_id: `ev_${requestId}_${decision}`,
193
+ event: {
194
+ operator: { operator_id: { open_id: openId } },
195
+ action: { value: { action: decision, requestId, nonce, expiresAt } },
196
+ context: { open_chat_id: "oc_chat_1", open_message_id: "om_card_1" }
197
+ }
198
+ });
199
+
200
+ // ===========================================================================
201
+ // A. SDK 状态 / 懒加载
202
+ // ===========================================================================
203
+
204
+ test("SDK 状态:未下载 → installed:false(面板据此显示「下载依赖」)", () => {
205
+ const relayDir = mkRelayDir("sdk-missing");
206
+ const st = feishuSdkStatus({ relayDir, env: {} });
207
+ assert.equal(st.installed, false);
208
+ assert.match(st.depsDir, /im-deps$/);
209
+ });
210
+
211
+ test("SDK 懒加载:目录不存在 → ok:false + sdk_missing 人话(不抛)", async () => {
212
+ _resetFeishuSdkCache();
213
+ const relayDir = mkRelayDir("sdk-missing2");
214
+ const r = await loadFeishuSdk({ relayDir, env: {} });
215
+ assert.equal(r.ok, false);
216
+ assert.equal(r.error, SDK_MISSING_HINT);
217
+ assert.equal(r.status.installed, false);
218
+ });
219
+
220
+ test("SDK 状态:已下载 → installed/version/path 正确(入口从 package.json main 解析)", () => {
221
+ const relayDir = mkRelayDir("sdk-ok");
222
+ const sdkDir = fakeSdkDir(relayDir, "1.76.0");
223
+ const st = feishuSdkStatus({ relayDir, env: {} });
224
+ assert.equal(st.installed, true);
225
+ assert.equal(st.version, "1.76.0");
226
+ assert.equal(st.path, path.join(sdkDir, "index.js"));
227
+ });
228
+
229
+ test("SDK 懒加载:import 成功并缓存(第二次不再 import)", async () => {
230
+ _resetFeishuSdkCache();
231
+ const relayDir = mkRelayDir("sdk-load");
232
+ fakeSdkDir(relayDir);
233
+ let imports = 0;
234
+ const importImpl = async () => {
235
+ imports += 1;
236
+ return { default: { WSClient: class {}, EventDispatcher: class {} } };
237
+ };
238
+ const a = await loadFeishuSdk({ relayDir, env: {}, importImpl });
239
+ assert.equal(a.ok, true);
240
+ assert.equal(imports, 1);
241
+ const b = await loadFeishuSdk({ relayDir, env: {}, importImpl });
242
+ assert.equal(b.ok, true);
243
+ assert.equal(imports, 1, "命中缓存");
244
+ });
245
+
246
+ test("SDK 懒加载:sdkFactory 注入(测试用桩)→ installed:injected", async () => {
247
+ _resetFeishuSdkCache();
248
+ const fake = makeFakeSdk();
249
+ const st = feishuSdkStatus({ relayDir: "", env: {}, sdkFactory: () => fake.sdk });
250
+ assert.equal(st.installed, true);
251
+ assert.equal(st.injected, true);
252
+ const r = await loadFeishuSdk({ relayDir: "", env: {}, sdkFactory: () => fake.sdk });
253
+ assert.equal(r.ok, true);
254
+ assert.equal(r.sdk, fake.sdk);
255
+ });
256
+
257
+ test("SDK 懒加载:import 抛错 → ok:false,lastError 进状态(不抛)", async () => {
258
+ _resetFeishuSdkCache();
259
+ const relayDir = mkRelayDir("sdk-broken");
260
+ fakeSdkDir(relayDir);
261
+ const r = await loadFeishuSdk({
262
+ relayDir,
263
+ env: {},
264
+ importImpl: async () => {
265
+ throw new Error("cannot find module proto");
266
+ }
267
+ });
268
+ assert.equal(r.ok, false);
269
+ assert.match(r.error, /加载失败/);
270
+ const st = feishuSdkStatus({ relayDir, env: {} });
271
+ assert.match(st.lastError, /cannot find module/);
272
+ });
273
+
274
+ // ===========================================================================
275
+ // B. 纯函数
276
+ // ===========================================================================
277
+
278
+ test("App ID 校验:严格/宽松/非法三档(SDK 静默拒绝的坑要提前暴露)", () => {
279
+ assert.equal(validateAppId("").ok, false);
280
+ assert.equal(validateAppId("https://open.feishu.cn").ok, false);
281
+ const strict = validateAppId(APP_ID);
282
+ assert.equal(strict.ok, true);
283
+ assert.equal(strict.strict, true);
284
+ assert.ok(APP_ID_STRICT_RE.test(APP_ID));
285
+ // 官方文档示例里出现过含 x 的 ID(过不了 SDK 严格正则)→ 我们放行但要 warning
286
+ const loose = validateAppId("cli_a5ca35a685b0x26e");
287
+ assert.equal(loose.ok, true);
288
+ assert.equal(loose.strict, false);
289
+ assert.match(loose.warning, /严格校验/);
290
+ });
291
+
292
+ test("配置归一:默认值 / domain 归一 / allowFrom 去重 / cardApproval 默认开", () => {
293
+ const cfg = normalizeFeishuConfig({
294
+ feishu: {
295
+ enabled: true, appId: " cli_x1 ", appSecret: " s ", domain: "Lark",
296
+ receiveId: " oc_1 ", receiveIdType: "CHAT_ID",
297
+ allowFrom: ["ou_a", "ou_a", ""], cardApproval: false
298
+ }
299
+ });
300
+ assert.equal(cfg.enabled, true);
301
+ assert.equal(cfg.appId, "cli_x1");
302
+ assert.equal(cfg.appSecret, " s ");
303
+ assert.equal(cfg.domain, "lark");
304
+ assert.equal(cfg.receiveId, "oc_1");
305
+ assert.equal(cfg.receiveIdType, "chat_id");
306
+ assert.deepEqual(cfg.allowFrom, ["ou_a"]);
307
+ assert.equal(cfg.cardApproval, false);
308
+ // 缺省
309
+ const def = normalizeFeishuConfig({});
310
+ assert.equal(def.domain, "feishu");
311
+ assert.equal(def.receiveIdType, "");
312
+ assert.equal(def.cardApproval, true);
313
+ assert.equal(def.sdkVersion, "");
314
+ assert.equal(def.verifyToken, undefined, "长连接模式不需要 verifyToken");
315
+ });
316
+
317
+ test("读配置:文件不存在 → null;存在 → {config, raw}", () => {
318
+ const relayDir = mkRelayDir("cfg-read");
319
+ assert.equal(readFeishuConfig(relayDir), null);
320
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: "s" });
321
+ const r = readFeishuConfig(relayDir);
322
+ assert.equal(r.config.enabled, true);
323
+ assert.equal(r.config.appId, APP_ID);
324
+ assert.ok(r.raw.wecom, "原始对象保留(便于调试)");
325
+ });
326
+
327
+ test("审批卡片:卡片 2.0 + 两个按钮 + behaviors callback 带 requestId/action/nonce/expiresAt", () => {
328
+ const card = buildApprovalCard({
329
+ requestId: "apr_1", toolName: "Bash", summary: "npm test",
330
+ expiresAt: NOW + 90_000, nonce: "n1"
331
+ });
332
+ assert.equal(card.schema, "2.0");
333
+ const actions = card.body.elements.find((e) => e.tag === "action");
334
+ assert.equal(actions.actions.length, 2);
335
+ const values = actions.actions.map((b) => b.behaviors[0].value);
336
+ assert.deepEqual(values.map((v) => v.action), ["allow", "deny"]);
337
+ assert.equal(values[0].requestId, "apr_1");
338
+ assert.equal(values[0].nonce, "n1");
339
+ assert.equal(values[0].expiresAt, NOW + 90_000);
340
+ assert.equal(JSON.stringify(card).includes("**"), false, "飞书文本无 markdown");
341
+ });
342
+
343
+ test("决定后的卡片:标题/颜色随决策变", () => {
344
+ const ok = buildDecidedCard({ requestId: "apr_1", decision: "allow", operator: "ou_us***_1" });
345
+ assert.equal(ok.header.template, "green");
346
+ assert.match(ok.header.title.content, /已允许/);
347
+ const no = buildDecidedCard({ requestId: "apr_1", decision: "deny" });
348
+ assert.equal(no.header.template, "red");
349
+ assert.match(no.header.title.content, /已拒绝/);
350
+ });
351
+
352
+ test("解析卡片回调:合法 allow/deny;缺 requestId / 未知动作 → ok:false", () => {
353
+ const allow = parseCardAction(cardAction({ decision: "allow", requestId: "apr_9" }));
354
+ assert.equal(allow.ok, true);
355
+ assert.equal(allow.requestId, "apr_9");
356
+ assert.equal(allow.decision, "allow");
357
+ assert.equal(allow.operatorOpenId, "ou_user_1");
358
+ assert.equal(allow.chatId, "oc_chat_1");
359
+ assert.equal(allow.messageId, "om_card_1");
360
+
361
+ const deny = parseCardAction(cardAction({ decision: "deny" }));
362
+ assert.equal(deny.decision, "deny");
363
+
364
+ assert.equal(parseCardAction(null).ok, false);
365
+ assert.equal(parseCardAction({ event: { action: { value: { action: "allow" } } } }).ok, false);
366
+ assert.equal(parseCardAction({ event: { action: { value: { action: "launch", requestId: "x" } } } }).ok, false);
367
+ });
368
+
369
+ test("入站消息归一:text / post / 未知类型;bot 自己可识别", () => {
370
+ assert.equal(extractFeishuMessageText("text", JSON.stringify({ text: " hi " })), "hi");
371
+ assert.equal(
372
+ extractFeishuMessageText("post", JSON.stringify({ title: "T", content: [[{ tag: "text", text: "正文" }]] })),
373
+ "T 正文"
374
+ );
375
+ assert.equal(extractFeishuMessageText("image", JSON.stringify({ image_key: "k" })), "");
376
+ assert.equal(extractFeishuMessageText("text", "{bad json"), "");
377
+
378
+ const ev = normalizeInboundEvent(inboundText("hi"));
379
+ assert.equal(ev.ok, true);
380
+ assert.equal(ev.chatId, "oc_chat_1");
381
+ assert.equal(ev.openId, "ou_user_1");
382
+ assert.equal(ev.senderType, "user");
383
+ assert.equal(ev.text, "hi");
384
+
385
+ const botEv = normalizeInboundEvent(inboundText("hi", { senderType: "bot" }));
386
+ assert.equal(botEv.senderType, "bot");
387
+ });
388
+
389
+ test("格式化:飞书无 markdown 文本 → 纯文本;超长按字节截断", () => {
390
+ const plain = formatFeishuText("**加粗** 内容", { maxBytes: 1000 });
391
+ assert.equal(plain.includes("**"), false);
392
+ const long = formatFeishuText("中文".repeat(5000), { maxBytes: 300 });
393
+ assert.ok(Buffer.byteLength(long, "utf8") <= 300 + 32, "截断后应在上限附近(含截断提示)");
394
+ });
395
+
396
+ test("错误码 → 人话(凭据/权限/机器人不在群/超频/可用范围/连接超限)", () => {
397
+ assert.match(feishuErrorHint(99991663), /凭证失效/);
398
+ assert.match(feishuErrorHint(230002), /机器人不在/);
399
+ assert.match(feishuErrorHint(230013), /可用范围/);
400
+ assert.match(feishuErrorHint(1000040350), /连接数超限/);
401
+ assert.match(feishuErrorHint(999999, "boom"), /999999/);
402
+ });
403
+
404
+ test("建连错误映射:凭据/权限/网络(代理提示)/ 未知", () => {
405
+ assert.match(mapConnectError(new Error("auth_failed: 514")), /凭据/);
406
+ assert.match(mapConnectError(new Error("forbidden 403")), /权限|企业自建/);
407
+ assert.match(mapConnectError(new Error("ETIMEDOUT")), /代理/);
408
+ assert.match(mapConnectError(new Error("exceed_conn_limit")), /连接数超限/);
409
+ assert.match(mapConnectError(new Error("weird")), /建连失败/);
410
+ });
411
+
412
+ // ===========================================================================
413
+ // C. 发送器
414
+ // ===========================================================================
415
+
416
+ test("发送文本:先拿 token 再 POST /im/v1/messages?receive_id_type=…,body 正确", async () => {
417
+ const f = makeFetch();
418
+ const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
419
+ const r = await sender.sendText("ou_u1", "**hi**");
420
+ assert.equal(r.ok, true);
421
+ assert.equal(r.messageId, "om_msg_1");
422
+ const tokenCall = f.calls.find((c) => c.url.includes("/auth/v3/tenant_access_token/internal"));
423
+ assert.ok(tokenCall);
424
+ assert.deepEqual(JSON.parse(tokenCall.body), { app_id: APP_ID, app_secret: APP_SECRET });
425
+ const sendCall = f.calls.find((c) => c.url.includes("/open-apis/im/v1/messages"));
426
+ assert.match(sendCall.url, /receive_id_type=open_id/);
427
+ assert.equal(sendCall.headers.authorization, "Bearer t-token");
428
+ const body = JSON.parse(sendCall.body);
429
+ assert.equal(body.receive_id, "ou_u1");
430
+ assert.equal(body.msg_type, "text");
431
+ assert.equal(JSON.parse(body.content).text.includes("**"), false, "发送前按飞书通道格式化");
432
+ });
433
+
434
+ test("token 缓存:两次发送只取一次 token", async () => {
435
+ const f = makeFetch();
436
+ const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
437
+ await sender.sendText("ou_u1", "a");
438
+ await sender.sendText("ou_u1", "b");
439
+ assert.equal(f.calls.filter((c) => c.url.includes("tenant_access_token")).length, 1);
440
+ });
441
+
442
+ test("发送卡片:msg_type=interactive,content 是卡片 JSON 字符串", async () => {
443
+ const f = makeFetch();
444
+ const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
445
+ const card = buildApprovalCard({ requestId: "apr_1", toolName: "Bash" });
446
+ const r = await sender.sendCard("oc_c1", card, { receiveIdType: "chat_id" });
447
+ assert.equal(r.ok, true);
448
+ const call = f.calls.find((c) => c.url.includes("/open-apis/im/v1/messages"));
449
+ assert.match(call.url, /receive_id_type=chat_id/);
450
+ const body = JSON.parse(call.body);
451
+ assert.equal(body.msg_type, "interactive");
452
+ assert.equal(JSON.parse(body.content).schema, "2.0");
453
+ });
454
+
455
+ test("发送失败:230002 → ok:false + 人话(机器人不在群)", async () => {
456
+ const f = makeFetch({ sendCode: 230002, sendMsg: "bot is not in chat" });
457
+ const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
458
+ const r = await sender.sendText("oc_c1", "hi");
459
+ assert.equal(r.ok, false);
460
+ assert.match(r.reason, /机器人不在/);
461
+ });
462
+
463
+ test("token 失效(99991663):清缓存重取一次再重试发送", async () => {
464
+ let tokenCalls = 0;
465
+ let sendCalls = 0;
466
+ const fetchImpl = async (url) => {
467
+ if (url.includes("tenant_access_token")) {
468
+ tokenCalls += 1;
469
+ return { ok: true, status: 200, json: async () => ({ code: 0, tenant_access_token: `t-${tokenCalls}`, expire: 7200 }) };
470
+ }
471
+ sendCalls += 1;
472
+ if (sendCalls === 1) return { ok: true, status: 200, json: async () => ({ code: 99991663, msg: "token invalid" }) };
473
+ return { ok: true, status: 200, json: async () => ({ code: 0, data: { message_id: "om_2" } }) };
474
+ };
475
+ const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl, clock: at });
476
+ const r = await sender.sendText("ou_u1", "hi");
477
+ assert.equal(r.ok, true);
478
+ assert.equal(tokenCalls, 2);
479
+ assert.equal(sendCalls, 2);
480
+ });
481
+
482
+ test("更新卡片:PATCH /im/v1/messages/:message_id", async () => {
483
+ const f = makeFetch();
484
+ const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
485
+ const r = await sender.patchCard("om_1", buildDecidedCard({ requestId: "x", decision: "allow" }));
486
+ assert.equal(r.ok, true);
487
+ const call = f.calls.find((c) => c.method === "PATCH");
488
+ assert.match(call.url, /\/im\/v1\/messages\/om_1$/);
489
+ assert.equal(JSON.parse(call.body).content.includes("\"2.0\""), true);
490
+ });
491
+
492
+ // ===========================================================================
493
+ // D. Bot:建连 / 入站 / 卡片回调
494
+ // ===========================================================================
495
+
496
+ async function startBot({ sdkOpts, fetchOpts, botOpts = {} } = {}) {
497
+ const fake = makeFakeSdk(sdkOpts);
498
+ const f = makeFetch(fetchOpts);
499
+ const logs = [];
500
+ const bot = createFeishuBot({
501
+ appId: APP_ID,
502
+ appSecret: APP_SECRET,
503
+ relayDir: mkRelayDir(`bot-${Math.random().toString(36).slice(2, 8)}`),
504
+ sdkFactory: () => fake.sdk,
505
+ fetchImpl: f.fn,
506
+ clock: at,
507
+ logger: { info: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m), debug: () => {} },
508
+ ...botOpts
509
+ });
510
+ const r = await bot.start();
511
+ return { bot, fake, fetchCalls: f.calls, logs, r };
512
+ }
513
+
514
+ test("bot 建连:WSClient 参数是 appId/appSecret;注册事件+卡片两个 handler;状态 connected", async () => {
515
+ const { bot, fake, r, logs } = await startBot();
516
+ assert.equal(r.ok, true);
517
+ assert.equal(fake.calls.ctors, 1);
518
+ assert.deepEqual(fake.calls.ws[0], { appId: APP_ID, appSecret: APP_SECRET });
519
+ const handlers = fake.calls.dispatchers[0].handlers;
520
+ assert.ok(typeof handlers["im.message.receive_v1"] === "function");
521
+ assert.ok(typeof handlers["card.action.trigger"] === "function");
522
+ const st = bot.status();
523
+ assert.equal(st.state, "connected");
524
+ assert.equal(st.connected, true);
525
+ assert.equal(st.authenticated, true);
526
+ assert.equal(st.appId.startsWith("cli_"), true);
527
+ assert.equal(st.appId.includes(APP_ID), false, "状态里 appId 必须掩码");
528
+ assert.equal(JSON.stringify(logs).includes(APP_SECRET), false, "secret 不进日志");
529
+ await bot.stop();
530
+ });
531
+
532
+ test("bot 建连:appId 非法 → invalid_app_id,不创建 WSClient(SDK 会静默拒绝的坑提前拦)", async () => {
533
+ const fake = makeFakeSdk();
534
+ const bot = createFeishuBot({ appId: "not-a-cli-id", appSecret: "s", sdkFactory: () => fake.sdk, clock: at, relayDir: mkRelayDir("bot-bad-id") });
535
+ const r = await bot.start();
536
+ assert.equal(r.ok, false);
537
+ assert.equal(r.reason, "invalid_app_id");
538
+ assert.equal(fake.calls.ctors, 0);
539
+ assert.equal(bot.status().state, "failed");
540
+ });
541
+
542
+ test("bot 建连:SDK 缺失 → sdk_missing(不抛、状态可读)", async () => {
543
+ const relayDir = mkRelayDir("bot-no-sdk");
544
+ const bot = createFeishuBot({
545
+ appId: APP_ID, appSecret: "s", relayDir, clock: at,
546
+ loadSdk: async () => ({ ok: false, error: SDK_MISSING_HINT, status: { installed: false } })
547
+ });
548
+ const r = await bot.start();
549
+ assert.equal(r.ok, false);
550
+ assert.equal(r.reason, "sdk_missing");
551
+ assert.equal(bot.status().state, "sdk_missing");
552
+ assert.match(bot.status().lastError, /未下载/);
553
+ });
554
+
555
+ test("bot 建连:SDK start 抛凭据错 → 人话(凭据校验失败),状态 failed 不崩", async () => {
556
+ const fake = makeFakeSdk({ connectError: Object.assign(new Error("auth_failed: 514"), { code: "auth_failed" }) });
557
+ const bot = createFeishuBot({
558
+ appId: APP_ID, appSecret: "s", sdkFactory: () => fake.sdk, clock: at, relayDir: mkRelayDir("bot-auth-fail")
559
+ });
560
+ const r = await bot.start();
561
+ assert.equal(r.ok, false);
562
+ assert.equal(r.reason, "connect_failed");
563
+ assert.match(bot.status().lastError, /凭据/);
564
+ assert.equal(bot.status().state, "failed");
565
+ });
566
+
567
+ test("bot 入站:文本消息 → 'message' 事件;**首次说话自动绑定**(单聊记 open_id)", async () => {
568
+ const { bot } = await startBot();
569
+ const got = [];
570
+ bot.on("message", (m) => got.push(m));
571
+ // 启动时还没有接收人,也不需要配置
572
+ assert.equal(bot.status().boundChatId, "");
573
+ assert.equal(bot.status().receiveIdSource, "none");
574
+ bot._dispatch("im.message.receive_v1", inboundText("你好"));
575
+ assert.equal(got.length, 1);
576
+ assert.equal(got[0].text, "你好");
577
+ assert.equal(got[0].chatId, "oc_chat_1");
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");
582
+ bot._dispatch("im.message.receive_v1", inboundText("自己", { senderType: "bot" }));
583
+ assert.equal(got.length, 1, "bot 自己的消息不能触发");
584
+ await bot.stop();
585
+ });
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
+
650
+ test("bot 入站:白名单外的人被拒(回一句提示,不触发 message)", async () => {
651
+ const { bot, fetchCalls } = await startBot({ botOpts: { allowFrom: ["ou_allowed"] } });
652
+ const got = [];
653
+ bot.on("message", (m) => got.push(m));
654
+ bot._dispatch("im.message.receive_v1", inboundText("hi", { openId: "ou_stranger" }));
655
+ assert.equal(got.length, 0);
656
+ await new Promise((r) => setTimeout(r, 20)); // 拒绝提示是 fire-and-forget,等一拍再断言
657
+ const sent = fetchCalls.find((c) => c.url.includes("/im/v1/messages"));
658
+ assert.ok(sent, "应回复提示");
659
+ assert.match(JSON.parse(sent.body).content, /白名单/);
660
+ bot._dispatch("im.message.receive_v1", inboundText("hi", { openId: "ou_allowed" }));
661
+ assert.equal(got.length, 1);
662
+ await bot.stop();
663
+ });
664
+
665
+ test("卡片回执:同步 ACK(toast+卡片)→ 决策异步;重复点击幂等;过期不回执", async () => {
666
+ let decisions = [];
667
+ const { bot } = await startBot({
668
+ botOpts: {
669
+ onApprovalDecision: (rid, decision) => {
670
+ decisions.push({ rid, decision });
671
+ return new Promise(() => {}); // 永不 resolve:证明 ACK 不被它阻塞
672
+ }
673
+ }
674
+ });
675
+ const t0 = Date.now();
676
+ const ack = bot._dispatch("card.action.trigger", cardAction({ decision: "allow", requestId: "apr_1" }));
677
+ const ackMs = Date.now() - t0;
678
+ assert.ok(ack.toast && ack.toast.type === "success");
679
+ assert.equal(ack.card.type, "raw");
680
+ assert.match(ack.card.data.header.title.content, /已允许/);
681
+ assert.ok(ackMs < 200, `ACK 必须同步/极快(实际 ${ackMs}ms,官方要求 3s 内)`);
682
+ await Promise.resolve();
683
+ await Promise.resolve();
684
+ assert.deepEqual(decisions, [{ rid: "apr_1", decision: "allow" }], "决策应由异步链路收到一次");
685
+
686
+ // 同一条回执重复点:只回 toast,不再交决策
687
+ const again = bot._dispatch("card.action.trigger", cardAction({ decision: "allow", requestId: "apr_1" }));
688
+ assert.equal(again.toast.type, "info");
689
+ await Promise.resolve();
690
+ assert.equal(decisions.length, 1);
691
+
692
+ // 已过期:不回执
693
+ const expired = bot._dispatch("card.action.trigger", cardAction({ decision: "deny", requestId: "apr_2", expiresAt: NOW - 1 }));
694
+ assert.equal(expired.toast.type, "info");
695
+ assert.match(expired.toast.content, /过期/);
696
+ await Promise.resolve();
697
+ assert.equal(decisions.length, 1);
698
+
699
+ // 无法解析:toast 警告,绝不乱放行
700
+ const bad = bot._dispatch("card.action.trigger", { event: { action: { value: {} } } });
701
+ assert.equal(bad.toast.type, "warning");
702
+ await bot.stop();
703
+ });
704
+
705
+ test("卡片回执:拒绝也走同一条决策链路", async () => {
706
+ const decisions = [];
707
+ const { bot } = await startBot({
708
+ botOpts: { onApprovalDecision: async (rid, decision) => { decisions.push({ rid, decision }); return { ok: true }; } }
709
+ });
710
+ const ack = bot._dispatch("card.action.trigger", cardAction({ decision: "deny", requestId: "apr_x" }));
711
+ assert.equal(ack.toast.content.includes("拒绝"), true);
712
+ await Promise.resolve();
713
+ await Promise.resolve();
714
+ assert.deepEqual(decisions, [{ rid: "apr_x", decision: "deny" }]);
715
+ await bot.stop();
716
+ });
717
+
718
+ test("bot.stop:关闭 WSClient 且幂等;状态回到 idle", async () => {
719
+ const { bot, fake } = await startBot();
720
+ await bot.stop();
721
+ assert.equal(fake.calls.closes, 1);
722
+ assert.equal(bot.status().state, "idle");
723
+ assert.equal(bot.status().enabled, false);
724
+ await bot.stop();
725
+ assert.equal(fake.calls.closes, 1, "幂等");
726
+ });
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
+
834
+ // ===========================================================================
835
+ // E. Controller:配置 / 状态文件 / 会员闸 / 通知
836
+ // ===========================================================================
837
+
838
+ function startController(relayDir, opts = {}) {
839
+ const fake = makeFakeSdk();
840
+ const f = makeFetch();
841
+ const logs = [];
842
+ const sub = opts.subscriber || makeSubscriber();
843
+ const controller = createFeishuBotController({
844
+ relayDir,
845
+ env: {},
846
+ clock: at,
847
+ sdkFactory: () => fake.sdk,
848
+ fetchImpl: f.fn,
849
+ subscriberFactory: () => sub,
850
+ log: (level, line) => logs.push(String(line ?? level)),
851
+ configPollMs: 10,
852
+ ...opts,
853
+ // 允许覆盖
854
+ ...(opts.subscriber ? { subscriberFactory: () => opts.subscriber } : {})
855
+ });
856
+ return { controller, fake, fetchCalls: f.calls, logs, sub };
857
+ }
858
+
859
+ test("controller:无配置 → 不启动(零连接);状态写 enabled:false", async () => {
860
+ const relayDir = mkRelayDir("ctl-none");
861
+ const { controller, fake } = startController(relayDir, { subscriber: makeSubscriber() });
862
+ await controller.start();
863
+ assert.equal(fake.calls.ctors, 0);
864
+ const st = controller.status();
865
+ assert.equal(st.enabled, false);
866
+ assert.equal(st.configFound, false);
867
+ await controller.stop();
868
+ });
869
+
870
+ test("controller:enabled 但 SDK 未下载 → sdk_missing + deps.feishu.installed=false,不崩", async () => {
871
+ const relayDir = mkRelayDir("ctl-nosdk");
872
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET });
873
+ const controller = createFeishuBotController({ relayDir, env: {}, clock: at, configPollMs: 10, log: () => {} });
874
+ await controller.start();
875
+ assert.equal(controller.status().reason, "sdk_missing");
876
+ const statusFile = path.join(relayDir, "im-bot-status.json");
877
+ const st = JSON.parse(fs.readFileSync(statusFile, "utf8"));
878
+ assert.equal(st.feishu.state, "sdk_missing");
879
+ assert.equal(st.deps.feishu.installed, false);
880
+ assert.match(st.feishu.lastError, /未下载/);
881
+ await controller.stop();
882
+ });
883
+
884
+ test("controller:appId 非法 → invalid_app_id + 人话,不建连", async () => {
885
+ const relayDir = mkRelayDir("ctl-badid");
886
+ writeConfig(relayDir, { enabled: true, appId: "not-cli", appSecret: APP_SECRET });
887
+ const { controller, fake } = startController(relayDir, { subscriber: makeSubscriber() });
888
+ await controller.start();
889
+ assert.equal(controller.status().reason, "invalid_app_id");
890
+ assert.equal(fake.calls.ctors, 0);
891
+ await controller.stop();
892
+ });
893
+
894
+ test("controller:正常启用 → 建连 + 状态文件 feishu 段完整且保留 wecom 段", async () => {
895
+ const relayDir = mkRelayDir("ctl-ok");
896
+ writeConfig(relayDir, {
897
+ enabled: true, appId: APP_ID, appSecret: APP_SECRET,
898
+ receiveId: "ou_owner", receiveIdType: "open_id", cardApproval: true
899
+ });
900
+ // 预置一份 wecom 状态:验证合并写不覆盖别的通道
901
+ writeJson(path.join(relayDir, "im-bot-status.json"), {
902
+ version: 1, updatedAt: 1, wecom: { enabled: true, connected: true, authenticated: true }
903
+ });
904
+ const { controller, fake } = startController(relayDir, { subscriber: makeSubscriber(), capsProvider: async () => "pro" });
905
+ await controller.start();
906
+ assert.equal(fake.calls.ctors, 1);
907
+ const st = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
908
+ assert.equal(st.wecom.connected, true, "wecom 段必须保留");
909
+ assert.equal(st.feishu.enabled, true);
910
+ assert.equal(st.feishu.state, "connected");
911
+ assert.equal(st.feishu.connected, true);
912
+ assert.equal(st.feishu.authenticated, true);
913
+ assert.equal(st.feishu.tier, "pro");
914
+ assert.ok(Array.isArray(st.feishu.caps) && st.feishu.caps.length > 0);
915
+ assert.ok(st.feishu.usage && typeof st.feishu.usage.messages === "number");
916
+ assert.equal(st.deps.feishu.installed, true);
917
+ assert.equal(st.deps.feishu.version, "injected");
918
+ await controller.stop();
919
+ });
920
+
921
+ test("controller:入站派活 — 免费档第 21 条被拦(配额从状态文件恢复),不转发会话", async () => {
922
+ const relayDir = mkRelayDir("ctl-quota");
923
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
924
+ const month = new Date(NOW).toISOString().slice(0, 7);
925
+ // 预置:本月已用 20 条
926
+ writeJson(path.join(relayDir, "im-bot-status.json"), {
927
+ version: 1, feishu: { usage: { month, messages: 20 }, tier: "free" }
928
+ });
929
+ const sub = makeSubscriber();
930
+ const { controller, botFetch } = (() => {
931
+ const r = startController(relayDir, { subscriber: sub, capsProvider: async () => "free" });
932
+ return { controller: r.controller, botFetch: r.fetchCalls };
933
+ })();
934
+ await controller.start();
935
+ // 模拟飞书推来一条消息(走真实 bot 的入站 handler)
936
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("帮我跑个任务"));
937
+ await new Promise((r) => setTimeout(r, 30));
938
+ assert.equal(sub.calls.prompts.length, 0, "第 21 条不能转发给 agent");
939
+ const reply = botFetch.find((c) => c.url.includes("/im/v1/messages"));
940
+ assert.ok(reply, "应回一条额度提示");
941
+ assert.match(JSON.parse(reply.body).content, /免费额度已用完/);
942
+ await controller.stop();
943
+ });
944
+
945
+ test("controller:入站派活 — 付费档可转发,usage 计数 +1 并落状态", async () => {
946
+ const relayDir = mkRelayDir("ctl-pass");
947
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
948
+ const sub = makeSubscriber();
949
+ const { controller, fake, fetchCalls } = startController(relayDir, { subscriber: sub, capsProvider: async () => "pro" });
950
+ await controller.start();
951
+ controller._bot()._dispatch("im.message.receive_v1", inboundText("继续做"));
952
+ await new Promise((r) => setTimeout(r, 30));
953
+ assert.equal(sub.calls.prompts.length, 1);
954
+ assert.deepEqual(sub.calls.prompts[0], { sessionId: "session-abc", text: "继续做" });
955
+ const ack = fetchCalls.find((c) => c.url.includes("/im/v1/messages"));
956
+ assert.match(JSON.parse(ack.body).content, /已收到/);
957
+ const st = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
958
+ assert.equal(st.feishu.usage.messages, 1);
959
+ await controller.stop();
960
+ });
961
+
962
+ test("controller:审批事件 → 发可点卡片(interactive);turn_end → 发文本通知", async () => {
963
+ const relayDir = mkRelayDir("ctl-notify");
964
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner", receiveIdType: "open_id" });
965
+ const sub = makeSubscriber();
966
+ const { controller, fetchCalls } = startController(relayDir, { subscriber: sub });
967
+ await controller.start();
968
+ sub.emit("event", { kind: "approval-request", eventId: "apr_notify_1", toolName: "Bash", sessionId: "s1" });
969
+ await new Promise((r) => setTimeout(r, 20));
970
+ const card = fetchCalls.find((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "interactive");
971
+ assert.ok(card, "审批事件应发 interactive 卡片");
972
+ const content = JSON.parse(JSON.parse(card.body).content);
973
+ const action = content.body.elements.find((e) => e.tag === "action");
974
+ assert.equal(action.actions[0].behaviors[0].value.requestId, "apr_notify_1");
975
+
976
+ sub.emit("event", { kind: "turn-end", sessionId: "s1", turn: 1, seq: 2, reason: "completed" });
977
+ await new Promise((r) => setTimeout(r, 20));
978
+ const texts = fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
979
+ assert.ok(texts.length >= 1, "turn_end 应发文本通知");
980
+ assert.match(JSON.parse(texts[texts.length - 1].body).content, /完成/);
981
+ await controller.stop();
982
+ });
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
+
1021
+ test("controller:热更新(改 appId)→ 重建长连接", async () => {
1022
+ const relayDir = mkRelayDir("ctl-reload");
1023
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner" });
1024
+ const fake = makeFakeSdk();
1025
+ const controller = createFeishuBotController({
1026
+ relayDir,
1027
+ env: {},
1028
+ clock: at,
1029
+ sdkFactory: () => fake.sdk,
1030
+ fetchImpl: makeFetch().fn,
1031
+ configPollMs: 10,
1032
+ log: () => {}
1033
+ });
1034
+ await controller.start();
1035
+ assert.equal(fake.calls.ctors, 1);
1036
+ writeConfig(relayDir, { enabled: true, appId: "cli_aaaaaaaaaaaaaaaa", appSecret: APP_SECRET, receiveId: "ou_owner" });
1037
+ const deadline = Date.now() + 1500;
1038
+ while (Date.now() < deadline && fake.calls.ctors < 2) await new Promise((r) => setTimeout(r, 25));
1039
+ assert.equal(fake.calls.ctors, 2, "改凭据应重建连接");
1040
+ await controller.stop();
1041
+ });
1042
+
1043
+ test("controller:stop 幂等,状态写 stopped", async () => {
1044
+ const relayDir = mkRelayDir("ctl-stop");
1045
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET });
1046
+ const { controller, fake, sub } = startController(relayDir, { subscriber: makeSubscriber() });
1047
+ await controller.start();
1048
+ await controller.stop();
1049
+ await controller.stop();
1050
+ assert.equal(fake.calls.closes, 1);
1051
+ assert.equal(sub.calls.stopped, 1);
1052
+ const st = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
1053
+ assert.equal(st.feishu.enabled, false);
1054
+ });
1055
+
1056
+ test("controller:日志里绝不出现 appSecret", async () => {
1057
+ const relayDir = mkRelayDir("ctl-secret");
1058
+ writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner" });
1059
+ const { controller, logs } = startController(relayDir, { subscriber: makeSubscriber() });
1060
+ await controller.start();
1061
+ await controller.stop();
1062
+ assert.equal(logs.some((l) => l.includes(APP_SECRET)), false, "secret 绝不能进日志");
1063
+ });
1064
+
1065
+ // ===========================================================================
1066
+ // F. 配置页向导契约(供插件半渲染;不画 UI)
1067
+ // ===========================================================================
1068
+
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 项不能省)
1083
+ assert.equal(FEISHU_SETUP_GUIDE.steps.length, 8);
1084
+ assert.match(FEISHU_SETUP_GUIDE.steps[4], /长连接接收事件/);
1085
+ assert.match(FEISHU_SETUP_GUIDE.steps[5], /card\.action\.trigger/);
1086
+ assert.match(FEISHU_SETUP_GUIDE.orderWarning, /长连接在线/);
1087
+ assert.match(FEISHU_SETUP_GUIDE.oneAppOnePc, /一台电脑/);
1088
+ assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
1089
+ });