@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-beta.2
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 +1318 -0
- package/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/clients/dsh-remote/dsh-bridge.mjs +398 -80
- package/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/clients/dsh-remote/im-format.mjs +372 -0
- package/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
- package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
- package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +550 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +839 -0
- package/clients/dsh-remote/test/im-format.test.mjs +229 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
- package/clients/dsh-remote/test/wecom-bot.test.mjs +281 -1
- package/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/dsh-setup.mjs +78 -7
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +703 -115
- package/packages/dsh-remote-web/lib/index.js +487 -31
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +94 -8
- package/packages/dsh-remote-web/test/im-bots.test.mjs +265 -2
- package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +4 -1
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -34
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -113
- package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +13 -5
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
|
@@ -0,0 +1,839 @@
|
|
|
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" } = {}) {
|
|
88
|
+
const calls = { ws: [], dispatchers: [], starts: [], closes: 0, ctors: 0 };
|
|
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 { constructor(opts) { this.opts = opts; } }
|
|
104
|
+
return { sdk: { WSClient, EventDispatcher, Client }, calls };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── 假 fetch ──────────────────────────────────────────────────────────────
|
|
108
|
+
function makeFetch({ tokenCode = 0, sendCode = 0, sendMsg = "", patchCode = 0 } = {}) {
|
|
109
|
+
const calls = [];
|
|
110
|
+
const fn = async (url, init = {}) => {
|
|
111
|
+
calls.push({ url, method: init.method || "GET", headers: init.headers || {}, body: init.body });
|
|
112
|
+
const isToken = url.includes("/auth/v3/tenant_access_token/internal");
|
|
113
|
+
const isPatch = (init.method || "GET") === "PATCH";
|
|
114
|
+
if (isToken) {
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
status: 200,
|
|
118
|
+
json: async () => (tokenCode === 0
|
|
119
|
+
? { code: 0, tenant_access_token: "t-token", expire: 7200 }
|
|
120
|
+
: { code: tokenCode, msg: "token failed" })
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (isPatch) return { ok: true, status: 200, json: async () => ({ code: patchCode, msg: "" }) };
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
status: 200,
|
|
127
|
+
json: async () => (sendCode === 0
|
|
128
|
+
? { code: 0, data: { message_id: "om_msg_1" } }
|
|
129
|
+
: { code: sendCode, msg: sendMsg || "send failed" })
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
return { fn, calls };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 假事件订阅器 ──────────────────────────────────────────────────────────
|
|
136
|
+
function makeSubscriber() {
|
|
137
|
+
const emitter = new EventEmitter();
|
|
138
|
+
const calls = { sessions: [], prompts: [], started: 0, stopped: 0, listenSessions: 0 };
|
|
139
|
+
return {
|
|
140
|
+
calls,
|
|
141
|
+
on: (evt, cb) => emitter.on(evt, cb),
|
|
142
|
+
emit: (evt, arg) => emitter.emit(evt, arg),
|
|
143
|
+
start() { calls.started += 1; return this; },
|
|
144
|
+
stop() { calls.stopped += 1; },
|
|
145
|
+
sessions() { return ["session-abc"]; },
|
|
146
|
+
async listSessions() { calls.listenSessions += 1; return [{ sessionId: "session-abc", title: "t" }]; },
|
|
147
|
+
async promptSession(req) { calls.prompts.push(req); return { ok: true }; }
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── 假消息/回调夹具(形状对齐官方事件) ──────────────────────────────────
|
|
152
|
+
const inboundText = (text = "你好", { senderType = "user", openId = "ou_user_1" } = {}) => ({
|
|
153
|
+
event: {
|
|
154
|
+
sender: { sender_id: { open_id: openId }, sender_type: senderType },
|
|
155
|
+
message: {
|
|
156
|
+
message_id: "om_in_1",
|
|
157
|
+
chat_id: "oc_chat_1",
|
|
158
|
+
chat_type: "p2p",
|
|
159
|
+
message_type: "text",
|
|
160
|
+
content: JSON.stringify({ text })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
const cardAction = ({ decision = "allow", requestId = "apr_1", nonce = "n1", expiresAt = NOW + 60_000, openId = "ou_user_1" } = {}) => ({
|
|
165
|
+
event_id: `ev_${requestId}_${decision}`,
|
|
166
|
+
event: {
|
|
167
|
+
operator: { operator_id: { open_id: openId } },
|
|
168
|
+
action: { value: { action: decision, requestId, nonce, expiresAt } },
|
|
169
|
+
context: { open_chat_id: "oc_chat_1", open_message_id: "om_card_1" }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ===========================================================================
|
|
174
|
+
// A. SDK 状态 / 懒加载
|
|
175
|
+
// ===========================================================================
|
|
176
|
+
|
|
177
|
+
test("SDK 状态:未下载 → installed:false(面板据此显示「下载依赖」)", () => {
|
|
178
|
+
const relayDir = mkRelayDir("sdk-missing");
|
|
179
|
+
const st = feishuSdkStatus({ relayDir, env: {} });
|
|
180
|
+
assert.equal(st.installed, false);
|
|
181
|
+
assert.match(st.depsDir, /im-deps$/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("SDK 懒加载:目录不存在 → ok:false + sdk_missing 人话(不抛)", async () => {
|
|
185
|
+
_resetFeishuSdkCache();
|
|
186
|
+
const relayDir = mkRelayDir("sdk-missing2");
|
|
187
|
+
const r = await loadFeishuSdk({ relayDir, env: {} });
|
|
188
|
+
assert.equal(r.ok, false);
|
|
189
|
+
assert.equal(r.error, SDK_MISSING_HINT);
|
|
190
|
+
assert.equal(r.status.installed, false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("SDK 状态:已下载 → installed/version/path 正确(入口从 package.json main 解析)", () => {
|
|
194
|
+
const relayDir = mkRelayDir("sdk-ok");
|
|
195
|
+
const sdkDir = fakeSdkDir(relayDir, "1.76.0");
|
|
196
|
+
const st = feishuSdkStatus({ relayDir, env: {} });
|
|
197
|
+
assert.equal(st.installed, true);
|
|
198
|
+
assert.equal(st.version, "1.76.0");
|
|
199
|
+
assert.equal(st.path, path.join(sdkDir, "index.js"));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("SDK 懒加载:import 成功并缓存(第二次不再 import)", async () => {
|
|
203
|
+
_resetFeishuSdkCache();
|
|
204
|
+
const relayDir = mkRelayDir("sdk-load");
|
|
205
|
+
fakeSdkDir(relayDir);
|
|
206
|
+
let imports = 0;
|
|
207
|
+
const importImpl = async () => {
|
|
208
|
+
imports += 1;
|
|
209
|
+
return { default: { WSClient: class {}, EventDispatcher: class {} } };
|
|
210
|
+
};
|
|
211
|
+
const a = await loadFeishuSdk({ relayDir, env: {}, importImpl });
|
|
212
|
+
assert.equal(a.ok, true);
|
|
213
|
+
assert.equal(imports, 1);
|
|
214
|
+
const b = await loadFeishuSdk({ relayDir, env: {}, importImpl });
|
|
215
|
+
assert.equal(b.ok, true);
|
|
216
|
+
assert.equal(imports, 1, "命中缓存");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("SDK 懒加载:sdkFactory 注入(测试用桩)→ installed:injected", async () => {
|
|
220
|
+
_resetFeishuSdkCache();
|
|
221
|
+
const fake = makeFakeSdk();
|
|
222
|
+
const st = feishuSdkStatus({ relayDir: "", env: {}, sdkFactory: () => fake.sdk });
|
|
223
|
+
assert.equal(st.installed, true);
|
|
224
|
+
assert.equal(st.injected, true);
|
|
225
|
+
const r = await loadFeishuSdk({ relayDir: "", env: {}, sdkFactory: () => fake.sdk });
|
|
226
|
+
assert.equal(r.ok, true);
|
|
227
|
+
assert.equal(r.sdk, fake.sdk);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("SDK 懒加载:import 抛错 → ok:false,lastError 进状态(不抛)", async () => {
|
|
231
|
+
_resetFeishuSdkCache();
|
|
232
|
+
const relayDir = mkRelayDir("sdk-broken");
|
|
233
|
+
fakeSdkDir(relayDir);
|
|
234
|
+
const r = await loadFeishuSdk({
|
|
235
|
+
relayDir,
|
|
236
|
+
env: {},
|
|
237
|
+
importImpl: async () => {
|
|
238
|
+
throw new Error("cannot find module proto");
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
assert.equal(r.ok, false);
|
|
242
|
+
assert.match(r.error, /加载失败/);
|
|
243
|
+
const st = feishuSdkStatus({ relayDir, env: {} });
|
|
244
|
+
assert.match(st.lastError, /cannot find module/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ===========================================================================
|
|
248
|
+
// B. 纯函数
|
|
249
|
+
// ===========================================================================
|
|
250
|
+
|
|
251
|
+
test("App ID 校验:严格/宽松/非法三档(SDK 静默拒绝的坑要提前暴露)", () => {
|
|
252
|
+
assert.equal(validateAppId("").ok, false);
|
|
253
|
+
assert.equal(validateAppId("https://open.feishu.cn").ok, false);
|
|
254
|
+
const strict = validateAppId(APP_ID);
|
|
255
|
+
assert.equal(strict.ok, true);
|
|
256
|
+
assert.equal(strict.strict, true);
|
|
257
|
+
assert.ok(APP_ID_STRICT_RE.test(APP_ID));
|
|
258
|
+
// 官方文档示例里出现过含 x 的 ID(过不了 SDK 严格正则)→ 我们放行但要 warning
|
|
259
|
+
const loose = validateAppId("cli_a5ca35a685b0x26e");
|
|
260
|
+
assert.equal(loose.ok, true);
|
|
261
|
+
assert.equal(loose.strict, false);
|
|
262
|
+
assert.match(loose.warning, /严格校验/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("配置归一:默认值 / domain 归一 / allowFrom 去重 / cardApproval 默认开", () => {
|
|
266
|
+
const cfg = normalizeFeishuConfig({
|
|
267
|
+
feishu: {
|
|
268
|
+
enabled: true, appId: " cli_x1 ", appSecret: " s ", domain: "Lark",
|
|
269
|
+
receiveId: " oc_1 ", receiveIdType: "CHAT_ID",
|
|
270
|
+
allowFrom: ["ou_a", "ou_a", ""], cardApproval: false
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
assert.equal(cfg.enabled, true);
|
|
274
|
+
assert.equal(cfg.appId, "cli_x1");
|
|
275
|
+
assert.equal(cfg.appSecret, " s ");
|
|
276
|
+
assert.equal(cfg.domain, "lark");
|
|
277
|
+
assert.equal(cfg.receiveId, "oc_1");
|
|
278
|
+
assert.equal(cfg.receiveIdType, "chat_id");
|
|
279
|
+
assert.deepEqual(cfg.allowFrom, ["ou_a"]);
|
|
280
|
+
assert.equal(cfg.cardApproval, false);
|
|
281
|
+
// 缺省
|
|
282
|
+
const def = normalizeFeishuConfig({});
|
|
283
|
+
assert.equal(def.domain, "feishu");
|
|
284
|
+
assert.equal(def.receiveIdType, "");
|
|
285
|
+
assert.equal(def.cardApproval, true);
|
|
286
|
+
assert.equal(def.sdkVersion, "");
|
|
287
|
+
assert.equal(def.verifyToken, undefined, "长连接模式不需要 verifyToken");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("读配置:文件不存在 → null;存在 → {config, raw}", () => {
|
|
291
|
+
const relayDir = mkRelayDir("cfg-read");
|
|
292
|
+
assert.equal(readFeishuConfig(relayDir), null);
|
|
293
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: "s" });
|
|
294
|
+
const r = readFeishuConfig(relayDir);
|
|
295
|
+
assert.equal(r.config.enabled, true);
|
|
296
|
+
assert.equal(r.config.appId, APP_ID);
|
|
297
|
+
assert.ok(r.raw.wecom, "原始对象保留(便于调试)");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("审批卡片:卡片 2.0 + 两个按钮 + behaviors callback 带 requestId/action/nonce/expiresAt", () => {
|
|
301
|
+
const card = buildApprovalCard({
|
|
302
|
+
requestId: "apr_1", toolName: "Bash", summary: "npm test",
|
|
303
|
+
expiresAt: NOW + 90_000, nonce: "n1"
|
|
304
|
+
});
|
|
305
|
+
assert.equal(card.schema, "2.0");
|
|
306
|
+
const actions = card.body.elements.find((e) => e.tag === "action");
|
|
307
|
+
assert.equal(actions.actions.length, 2);
|
|
308
|
+
const values = actions.actions.map((b) => b.behaviors[0].value);
|
|
309
|
+
assert.deepEqual(values.map((v) => v.action), ["allow", "deny"]);
|
|
310
|
+
assert.equal(values[0].requestId, "apr_1");
|
|
311
|
+
assert.equal(values[0].nonce, "n1");
|
|
312
|
+
assert.equal(values[0].expiresAt, NOW + 90_000);
|
|
313
|
+
assert.equal(JSON.stringify(card).includes("**"), false, "飞书文本无 markdown");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("决定后的卡片:标题/颜色随决策变", () => {
|
|
317
|
+
const ok = buildDecidedCard({ requestId: "apr_1", decision: "allow", operator: "ou_us***_1" });
|
|
318
|
+
assert.equal(ok.header.template, "green");
|
|
319
|
+
assert.match(ok.header.title.content, /已允许/);
|
|
320
|
+
const no = buildDecidedCard({ requestId: "apr_1", decision: "deny" });
|
|
321
|
+
assert.equal(no.header.template, "red");
|
|
322
|
+
assert.match(no.header.title.content, /已拒绝/);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("解析卡片回调:合法 allow/deny;缺 requestId / 未知动作 → ok:false", () => {
|
|
326
|
+
const allow = parseCardAction(cardAction({ decision: "allow", requestId: "apr_9" }));
|
|
327
|
+
assert.equal(allow.ok, true);
|
|
328
|
+
assert.equal(allow.requestId, "apr_9");
|
|
329
|
+
assert.equal(allow.decision, "allow");
|
|
330
|
+
assert.equal(allow.operatorOpenId, "ou_user_1");
|
|
331
|
+
assert.equal(allow.chatId, "oc_chat_1");
|
|
332
|
+
assert.equal(allow.messageId, "om_card_1");
|
|
333
|
+
|
|
334
|
+
const deny = parseCardAction(cardAction({ decision: "deny" }));
|
|
335
|
+
assert.equal(deny.decision, "deny");
|
|
336
|
+
|
|
337
|
+
assert.equal(parseCardAction(null).ok, false);
|
|
338
|
+
assert.equal(parseCardAction({ event: { action: { value: { action: "allow" } } } }).ok, false);
|
|
339
|
+
assert.equal(parseCardAction({ event: { action: { value: { action: "launch", requestId: "x" } } } }).ok, false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("入站消息归一:text / post / 未知类型;bot 自己可识别", () => {
|
|
343
|
+
assert.equal(extractFeishuMessageText("text", JSON.stringify({ text: " hi " })), "hi");
|
|
344
|
+
assert.equal(
|
|
345
|
+
extractFeishuMessageText("post", JSON.stringify({ title: "T", content: [[{ tag: "text", text: "正文" }]] })),
|
|
346
|
+
"T 正文"
|
|
347
|
+
);
|
|
348
|
+
assert.equal(extractFeishuMessageText("image", JSON.stringify({ image_key: "k" })), "");
|
|
349
|
+
assert.equal(extractFeishuMessageText("text", "{bad json"), "");
|
|
350
|
+
|
|
351
|
+
const ev = normalizeInboundEvent(inboundText("hi"));
|
|
352
|
+
assert.equal(ev.ok, true);
|
|
353
|
+
assert.equal(ev.chatId, "oc_chat_1");
|
|
354
|
+
assert.equal(ev.openId, "ou_user_1");
|
|
355
|
+
assert.equal(ev.senderType, "user");
|
|
356
|
+
assert.equal(ev.text, "hi");
|
|
357
|
+
|
|
358
|
+
const botEv = normalizeInboundEvent(inboundText("hi", { senderType: "bot" }));
|
|
359
|
+
assert.equal(botEv.senderType, "bot");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("格式化:飞书无 markdown 文本 → 纯文本;超长按字节截断", () => {
|
|
363
|
+
const plain = formatFeishuText("**加粗** 内容", { maxBytes: 1000 });
|
|
364
|
+
assert.equal(plain.includes("**"), false);
|
|
365
|
+
const long = formatFeishuText("中文".repeat(5000), { maxBytes: 300 });
|
|
366
|
+
assert.ok(Buffer.byteLength(long, "utf8") <= 300 + 32, "截断后应在上限附近(含截断提示)");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("错误码 → 人话(凭据/权限/机器人不在群/超频/可用范围/连接超限)", () => {
|
|
370
|
+
assert.match(feishuErrorHint(99991663), /凭证失效/);
|
|
371
|
+
assert.match(feishuErrorHint(230002), /机器人不在/);
|
|
372
|
+
assert.match(feishuErrorHint(230013), /可用范围/);
|
|
373
|
+
assert.match(feishuErrorHint(1000040350), /连接数超限/);
|
|
374
|
+
assert.match(feishuErrorHint(999999, "boom"), /999999/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("建连错误映射:凭据/权限/网络(代理提示)/ 未知", () => {
|
|
378
|
+
assert.match(mapConnectError(new Error("auth_failed: 514")), /凭据/);
|
|
379
|
+
assert.match(mapConnectError(new Error("forbidden 403")), /权限|企业自建/);
|
|
380
|
+
assert.match(mapConnectError(new Error("ETIMEDOUT")), /代理/);
|
|
381
|
+
assert.match(mapConnectError(new Error("exceed_conn_limit")), /连接数超限/);
|
|
382
|
+
assert.match(mapConnectError(new Error("weird")), /建连失败/);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ===========================================================================
|
|
386
|
+
// C. 发送器
|
|
387
|
+
// ===========================================================================
|
|
388
|
+
|
|
389
|
+
test("发送文本:先拿 token 再 POST /im/v1/messages?receive_id_type=…,body 正确", async () => {
|
|
390
|
+
const f = makeFetch();
|
|
391
|
+
const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
|
|
392
|
+
const r = await sender.sendText("ou_u1", "**hi**");
|
|
393
|
+
assert.equal(r.ok, true);
|
|
394
|
+
assert.equal(r.messageId, "om_msg_1");
|
|
395
|
+
const tokenCall = f.calls.find((c) => c.url.includes("/auth/v3/tenant_access_token/internal"));
|
|
396
|
+
assert.ok(tokenCall);
|
|
397
|
+
assert.deepEqual(JSON.parse(tokenCall.body), { app_id: APP_ID, app_secret: APP_SECRET });
|
|
398
|
+
const sendCall = f.calls.find((c) => c.url.includes("/open-apis/im/v1/messages"));
|
|
399
|
+
assert.match(sendCall.url, /receive_id_type=open_id/);
|
|
400
|
+
assert.equal(sendCall.headers.authorization, "Bearer t-token");
|
|
401
|
+
const body = JSON.parse(sendCall.body);
|
|
402
|
+
assert.equal(body.receive_id, "ou_u1");
|
|
403
|
+
assert.equal(body.msg_type, "text");
|
|
404
|
+
assert.equal(JSON.parse(body.content).text.includes("**"), false, "发送前按飞书通道格式化");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("token 缓存:两次发送只取一次 token", async () => {
|
|
408
|
+
const f = makeFetch();
|
|
409
|
+
const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
|
|
410
|
+
await sender.sendText("ou_u1", "a");
|
|
411
|
+
await sender.sendText("ou_u1", "b");
|
|
412
|
+
assert.equal(f.calls.filter((c) => c.url.includes("tenant_access_token")).length, 1);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("发送卡片:msg_type=interactive,content 是卡片 JSON 字符串", async () => {
|
|
416
|
+
const f = makeFetch();
|
|
417
|
+
const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
|
|
418
|
+
const card = buildApprovalCard({ requestId: "apr_1", toolName: "Bash" });
|
|
419
|
+
const r = await sender.sendCard("oc_c1", card, { receiveIdType: "chat_id" });
|
|
420
|
+
assert.equal(r.ok, true);
|
|
421
|
+
const call = f.calls.find((c) => c.url.includes("/open-apis/im/v1/messages"));
|
|
422
|
+
assert.match(call.url, /receive_id_type=chat_id/);
|
|
423
|
+
const body = JSON.parse(call.body);
|
|
424
|
+
assert.equal(body.msg_type, "interactive");
|
|
425
|
+
assert.equal(JSON.parse(body.content).schema, "2.0");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("发送失败:230002 → ok:false + 人话(机器人不在群)", async () => {
|
|
429
|
+
const f = makeFetch({ sendCode: 230002, sendMsg: "bot is not in chat" });
|
|
430
|
+
const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
|
|
431
|
+
const r = await sender.sendText("oc_c1", "hi");
|
|
432
|
+
assert.equal(r.ok, false);
|
|
433
|
+
assert.match(r.reason, /机器人不在/);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("token 失效(99991663):清缓存重取一次再重试发送", async () => {
|
|
437
|
+
let tokenCalls = 0;
|
|
438
|
+
let sendCalls = 0;
|
|
439
|
+
const fetchImpl = async (url) => {
|
|
440
|
+
if (url.includes("tenant_access_token")) {
|
|
441
|
+
tokenCalls += 1;
|
|
442
|
+
return { ok: true, status: 200, json: async () => ({ code: 0, tenant_access_token: `t-${tokenCalls}`, expire: 7200 }) };
|
|
443
|
+
}
|
|
444
|
+
sendCalls += 1;
|
|
445
|
+
if (sendCalls === 1) return { ok: true, status: 200, json: async () => ({ code: 99991663, msg: "token invalid" }) };
|
|
446
|
+
return { ok: true, status: 200, json: async () => ({ code: 0, data: { message_id: "om_2" } }) };
|
|
447
|
+
};
|
|
448
|
+
const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl, clock: at });
|
|
449
|
+
const r = await sender.sendText("ou_u1", "hi");
|
|
450
|
+
assert.equal(r.ok, true);
|
|
451
|
+
assert.equal(tokenCalls, 2);
|
|
452
|
+
assert.equal(sendCalls, 2);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("更新卡片:PATCH /im/v1/messages/:message_id", async () => {
|
|
456
|
+
const f = makeFetch();
|
|
457
|
+
const sender = createFeishuSender({ appId: APP_ID, appSecret: APP_SECRET, fetchImpl: f.fn, clock: at });
|
|
458
|
+
const r = await sender.patchCard("om_1", buildDecidedCard({ requestId: "x", decision: "allow" }));
|
|
459
|
+
assert.equal(r.ok, true);
|
|
460
|
+
const call = f.calls.find((c) => c.method === "PATCH");
|
|
461
|
+
assert.match(call.url, /\/im\/v1\/messages\/om_1$/);
|
|
462
|
+
assert.equal(JSON.parse(call.body).content.includes("\"2.0\""), true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ===========================================================================
|
|
466
|
+
// D. Bot:建连 / 入站 / 卡片回调
|
|
467
|
+
// ===========================================================================
|
|
468
|
+
|
|
469
|
+
async function startBot({ sdkOpts, fetchOpts, botOpts = {} } = {}) {
|
|
470
|
+
const fake = makeFakeSdk(sdkOpts);
|
|
471
|
+
const f = makeFetch(fetchOpts);
|
|
472
|
+
const logs = [];
|
|
473
|
+
const bot = createFeishuBot({
|
|
474
|
+
appId: APP_ID,
|
|
475
|
+
appSecret: APP_SECRET,
|
|
476
|
+
relayDir: mkRelayDir(`bot-${Math.random().toString(36).slice(2, 8)}`),
|
|
477
|
+
sdkFactory: () => fake.sdk,
|
|
478
|
+
fetchImpl: f.fn,
|
|
479
|
+
clock: at,
|
|
480
|
+
receiveIdType: "open_id",
|
|
481
|
+
logger: { info: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m), debug: () => {} },
|
|
482
|
+
...botOpts
|
|
483
|
+
});
|
|
484
|
+
const r = await bot.start();
|
|
485
|
+
return { bot, fake, fetchCalls: f.calls, logs, r };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
test("bot 建连:WSClient 参数是 appId/appSecret;注册事件+卡片两个 handler;状态 connected", async () => {
|
|
489
|
+
const { bot, fake, r, logs } = await startBot();
|
|
490
|
+
assert.equal(r.ok, true);
|
|
491
|
+
assert.equal(fake.calls.ctors, 1);
|
|
492
|
+
assert.deepEqual(fake.calls.ws[0], { appId: APP_ID, appSecret: APP_SECRET });
|
|
493
|
+
const handlers = fake.calls.dispatchers[0].handlers;
|
|
494
|
+
assert.ok(typeof handlers["im.message.receive_v1"] === "function");
|
|
495
|
+
assert.ok(typeof handlers["card.action.trigger"] === "function");
|
|
496
|
+
const st = bot.status();
|
|
497
|
+
assert.equal(st.state, "connected");
|
|
498
|
+
assert.equal(st.connected, true);
|
|
499
|
+
assert.equal(st.authenticated, true);
|
|
500
|
+
assert.equal(st.appId.startsWith("cli_"), true);
|
|
501
|
+
assert.equal(st.appId.includes(APP_ID), false, "状态里 appId 必须掩码");
|
|
502
|
+
assert.equal(JSON.stringify(logs).includes(APP_SECRET), false, "secret 不进日志");
|
|
503
|
+
await bot.stop();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("bot 建连:appId 非法 → invalid_app_id,不创建 WSClient(SDK 会静默拒绝的坑提前拦)", async () => {
|
|
507
|
+
const fake = makeFakeSdk();
|
|
508
|
+
const bot = createFeishuBot({ appId: "not-a-cli-id", appSecret: "s", sdkFactory: () => fake.sdk, clock: at, relayDir: mkRelayDir("bot-bad-id") });
|
|
509
|
+
const r = await bot.start();
|
|
510
|
+
assert.equal(r.ok, false);
|
|
511
|
+
assert.equal(r.reason, "invalid_app_id");
|
|
512
|
+
assert.equal(fake.calls.ctors, 0);
|
|
513
|
+
assert.equal(bot.status().state, "failed");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("bot 建连:SDK 缺失 → sdk_missing(不抛、状态可读)", async () => {
|
|
517
|
+
const relayDir = mkRelayDir("bot-no-sdk");
|
|
518
|
+
const bot = createFeishuBot({
|
|
519
|
+
appId: APP_ID, appSecret: "s", relayDir, clock: at,
|
|
520
|
+
loadSdk: async () => ({ ok: false, error: SDK_MISSING_HINT, status: { installed: false } })
|
|
521
|
+
});
|
|
522
|
+
const r = await bot.start();
|
|
523
|
+
assert.equal(r.ok, false);
|
|
524
|
+
assert.equal(r.reason, "sdk_missing");
|
|
525
|
+
assert.equal(bot.status().state, "sdk_missing");
|
|
526
|
+
assert.match(bot.status().lastError, /未下载/);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("bot 建连:SDK start 抛凭据错 → 人话(凭据校验失败),状态 failed 不崩", async () => {
|
|
530
|
+
const fake = makeFakeSdk({ connectError: Object.assign(new Error("auth_failed: 514"), { code: "auth_failed" }) });
|
|
531
|
+
const bot = createFeishuBot({
|
|
532
|
+
appId: APP_ID, appSecret: "s", sdkFactory: () => fake.sdk, clock: at, relayDir: mkRelayDir("bot-auth-fail")
|
|
533
|
+
});
|
|
534
|
+
const r = await bot.start();
|
|
535
|
+
assert.equal(r.ok, false);
|
|
536
|
+
assert.equal(r.reason, "connect_failed");
|
|
537
|
+
assert.match(bot.status().lastError, /凭据/);
|
|
538
|
+
assert.equal(bot.status().state, "failed");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("bot 入站:文本消息 → 'message' 事件(归一化)+ 绑定 chatId;bot 自己消息被过滤", async () => {
|
|
542
|
+
const { bot } = await startBot();
|
|
543
|
+
const got = [];
|
|
544
|
+
bot.on("message", (m) => got.push(m));
|
|
545
|
+
bot._dispatch("im.message.receive_v1", inboundText("你好"));
|
|
546
|
+
assert.equal(got.length, 1);
|
|
547
|
+
assert.equal(got[0].text, "你好");
|
|
548
|
+
assert.equal(got[0].chatId, "oc_chat_1");
|
|
549
|
+
assert.equal(bot.status().boundChatId, "oc_chat_1");
|
|
550
|
+
bot._dispatch("im.message.receive_v1", inboundText("自己", { senderType: "bot" }));
|
|
551
|
+
assert.equal(got.length, 1, "bot 自己的消息不能触发");
|
|
552
|
+
await bot.stop();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("bot 入站:白名单外的人被拒(回一句提示,不触发 message)", async () => {
|
|
556
|
+
const { bot, fetchCalls } = await startBot({ botOpts: { allowFrom: ["ou_allowed"] } });
|
|
557
|
+
const got = [];
|
|
558
|
+
bot.on("message", (m) => got.push(m));
|
|
559
|
+
bot._dispatch("im.message.receive_v1", inboundText("hi", { openId: "ou_stranger" }));
|
|
560
|
+
assert.equal(got.length, 0);
|
|
561
|
+
await new Promise((r) => setTimeout(r, 20)); // 拒绝提示是 fire-and-forget,等一拍再断言
|
|
562
|
+
const sent = fetchCalls.find((c) => c.url.includes("/im/v1/messages"));
|
|
563
|
+
assert.ok(sent, "应回复提示");
|
|
564
|
+
assert.match(JSON.parse(sent.body).content, /白名单/);
|
|
565
|
+
bot._dispatch("im.message.receive_v1", inboundText("hi", { openId: "ou_allowed" }));
|
|
566
|
+
assert.equal(got.length, 1);
|
|
567
|
+
await bot.stop();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("卡片回执:同步 ACK(toast+卡片)→ 决策异步;重复点击幂等;过期不回执", async () => {
|
|
571
|
+
let decisions = [];
|
|
572
|
+
const { bot } = await startBot({
|
|
573
|
+
botOpts: {
|
|
574
|
+
onApprovalDecision: (rid, decision) => {
|
|
575
|
+
decisions.push({ rid, decision });
|
|
576
|
+
return new Promise(() => {}); // 永不 resolve:证明 ACK 不被它阻塞
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
const t0 = Date.now();
|
|
581
|
+
const ack = bot._dispatch("card.action.trigger", cardAction({ decision: "allow", requestId: "apr_1" }));
|
|
582
|
+
const ackMs = Date.now() - t0;
|
|
583
|
+
assert.ok(ack.toast && ack.toast.type === "success");
|
|
584
|
+
assert.equal(ack.card.type, "raw");
|
|
585
|
+
assert.match(ack.card.data.header.title.content, /已允许/);
|
|
586
|
+
assert.ok(ackMs < 200, `ACK 必须同步/极快(实际 ${ackMs}ms,官方要求 3s 内)`);
|
|
587
|
+
await Promise.resolve();
|
|
588
|
+
await Promise.resolve();
|
|
589
|
+
assert.deepEqual(decisions, [{ rid: "apr_1", decision: "allow" }], "决策应由异步链路收到一次");
|
|
590
|
+
|
|
591
|
+
// 同一条回执重复点:只回 toast,不再交决策
|
|
592
|
+
const again = bot._dispatch("card.action.trigger", cardAction({ decision: "allow", requestId: "apr_1" }));
|
|
593
|
+
assert.equal(again.toast.type, "info");
|
|
594
|
+
await Promise.resolve();
|
|
595
|
+
assert.equal(decisions.length, 1);
|
|
596
|
+
|
|
597
|
+
// 已过期:不回执
|
|
598
|
+
const expired = bot._dispatch("card.action.trigger", cardAction({ decision: "deny", requestId: "apr_2", expiresAt: NOW - 1 }));
|
|
599
|
+
assert.equal(expired.toast.type, "info");
|
|
600
|
+
assert.match(expired.toast.content, /过期/);
|
|
601
|
+
await Promise.resolve();
|
|
602
|
+
assert.equal(decisions.length, 1);
|
|
603
|
+
|
|
604
|
+
// 无法解析:toast 警告,绝不乱放行
|
|
605
|
+
const bad = bot._dispatch("card.action.trigger", { event: { action: { value: {} } } });
|
|
606
|
+
assert.equal(bad.toast.type, "warning");
|
|
607
|
+
await bot.stop();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("卡片回执:拒绝也走同一条决策链路", async () => {
|
|
611
|
+
const decisions = [];
|
|
612
|
+
const { bot } = await startBot({
|
|
613
|
+
botOpts: { onApprovalDecision: async (rid, decision) => { decisions.push({ rid, decision }); return { ok: true }; } }
|
|
614
|
+
});
|
|
615
|
+
const ack = bot._dispatch("card.action.trigger", cardAction({ decision: "deny", requestId: "apr_x" }));
|
|
616
|
+
assert.equal(ack.toast.content.includes("拒绝"), true);
|
|
617
|
+
await Promise.resolve();
|
|
618
|
+
await Promise.resolve();
|
|
619
|
+
assert.deepEqual(decisions, [{ rid: "apr_x", decision: "deny" }]);
|
|
620
|
+
await bot.stop();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("bot.stop:关闭 WSClient 且幂等;状态回到 idle", async () => {
|
|
624
|
+
const { bot, fake } = await startBot();
|
|
625
|
+
await bot.stop();
|
|
626
|
+
assert.equal(fake.calls.closes, 1);
|
|
627
|
+
assert.equal(bot.status().state, "idle");
|
|
628
|
+
assert.equal(bot.status().enabled, false);
|
|
629
|
+
await bot.stop();
|
|
630
|
+
assert.equal(fake.calls.closes, 1, "幂等");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// ===========================================================================
|
|
634
|
+
// E. Controller:配置 / 状态文件 / 会员闸 / 通知
|
|
635
|
+
// ===========================================================================
|
|
636
|
+
|
|
637
|
+
function startController(relayDir, opts = {}) {
|
|
638
|
+
const fake = makeFakeSdk();
|
|
639
|
+
const f = makeFetch();
|
|
640
|
+
const logs = [];
|
|
641
|
+
const sub = opts.subscriber || makeSubscriber();
|
|
642
|
+
const controller = createFeishuBotController({
|
|
643
|
+
relayDir,
|
|
644
|
+
env: {},
|
|
645
|
+
clock: at,
|
|
646
|
+
sdkFactory: () => fake.sdk,
|
|
647
|
+
fetchImpl: f.fn,
|
|
648
|
+
subscriberFactory: () => sub,
|
|
649
|
+
log: (line) => logs.push(String(line)),
|
|
650
|
+
configPollMs: 10,
|
|
651
|
+
...opts,
|
|
652
|
+
// 允许覆盖
|
|
653
|
+
...(opts.subscriber ? { subscriberFactory: () => opts.subscriber } : {})
|
|
654
|
+
});
|
|
655
|
+
return { controller, fake, fetchCalls: f.calls, logs, sub };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
test("controller:无配置 → 不启动(零连接);状态写 enabled:false", async () => {
|
|
659
|
+
const relayDir = mkRelayDir("ctl-none");
|
|
660
|
+
const { controller, fake } = startController(relayDir, { subscriber: makeSubscriber() });
|
|
661
|
+
await controller.start();
|
|
662
|
+
assert.equal(fake.calls.ctors, 0);
|
|
663
|
+
const st = controller.status();
|
|
664
|
+
assert.equal(st.enabled, false);
|
|
665
|
+
assert.equal(st.configFound, false);
|
|
666
|
+
await controller.stop();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("controller:enabled 但 SDK 未下载 → sdk_missing + deps.feishu.installed=false,不崩", async () => {
|
|
670
|
+
const relayDir = mkRelayDir("ctl-nosdk");
|
|
671
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET });
|
|
672
|
+
const controller = createFeishuBotController({ relayDir, env: {}, clock: at, configPollMs: 10, log: () => {} });
|
|
673
|
+
await controller.start();
|
|
674
|
+
assert.equal(controller.status().reason, "sdk_missing");
|
|
675
|
+
const statusFile = path.join(relayDir, "im-bot-status.json");
|
|
676
|
+
const st = JSON.parse(fs.readFileSync(statusFile, "utf8"));
|
|
677
|
+
assert.equal(st.feishu.state, "sdk_missing");
|
|
678
|
+
assert.equal(st.deps.feishu.installed, false);
|
|
679
|
+
assert.match(st.feishu.lastError, /未下载/);
|
|
680
|
+
await controller.stop();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("controller:appId 非法 → invalid_app_id + 人话,不建连", async () => {
|
|
684
|
+
const relayDir = mkRelayDir("ctl-badid");
|
|
685
|
+
writeConfig(relayDir, { enabled: true, appId: "not-cli", appSecret: APP_SECRET });
|
|
686
|
+
const { controller, fake } = startController(relayDir, { subscriber: makeSubscriber() });
|
|
687
|
+
await controller.start();
|
|
688
|
+
assert.equal(controller.status().reason, "invalid_app_id");
|
|
689
|
+
assert.equal(fake.calls.ctors, 0);
|
|
690
|
+
await controller.stop();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("controller:正常启用 → 建连 + 状态文件 feishu 段完整且保留 wecom 段", async () => {
|
|
694
|
+
const relayDir = mkRelayDir("ctl-ok");
|
|
695
|
+
writeConfig(relayDir, {
|
|
696
|
+
enabled: true, appId: APP_ID, appSecret: APP_SECRET,
|
|
697
|
+
receiveId: "ou_owner", receiveIdType: "open_id", cardApproval: true
|
|
698
|
+
});
|
|
699
|
+
// 预置一份 wecom 状态:验证合并写不覆盖别的通道
|
|
700
|
+
writeJson(path.join(relayDir, "im-bot-status.json"), {
|
|
701
|
+
version: 1, updatedAt: 1, wecom: { enabled: true, connected: true, authenticated: true }
|
|
702
|
+
});
|
|
703
|
+
const { controller, fake } = startController(relayDir, { subscriber: makeSubscriber(), capsProvider: async () => "pro" });
|
|
704
|
+
await controller.start();
|
|
705
|
+
assert.equal(fake.calls.ctors, 1);
|
|
706
|
+
const st = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
|
|
707
|
+
assert.equal(st.wecom.connected, true, "wecom 段必须保留");
|
|
708
|
+
assert.equal(st.feishu.enabled, true);
|
|
709
|
+
assert.equal(st.feishu.state, "connected");
|
|
710
|
+
assert.equal(st.feishu.connected, true);
|
|
711
|
+
assert.equal(st.feishu.authenticated, true);
|
|
712
|
+
assert.equal(st.feishu.tier, "pro");
|
|
713
|
+
assert.ok(Array.isArray(st.feishu.caps) && st.feishu.caps.length > 0);
|
|
714
|
+
assert.ok(st.feishu.usage && typeof st.feishu.usage.messages === "number");
|
|
715
|
+
assert.equal(st.deps.feishu.installed, true);
|
|
716
|
+
assert.equal(st.deps.feishu.version, "injected");
|
|
717
|
+
await controller.stop();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("controller:入站派活 — 免费档第 21 条被拦(配额从状态文件恢复),不转发会话", async () => {
|
|
721
|
+
const relayDir = mkRelayDir("ctl-quota");
|
|
722
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
|
|
723
|
+
const month = new Date(NOW).toISOString().slice(0, 7);
|
|
724
|
+
// 预置:本月已用 20 条
|
|
725
|
+
writeJson(path.join(relayDir, "im-bot-status.json"), {
|
|
726
|
+
version: 1, feishu: { usage: { month, messages: 20 }, tier: "free" }
|
|
727
|
+
});
|
|
728
|
+
const sub = makeSubscriber();
|
|
729
|
+
const { controller, botFetch } = (() => {
|
|
730
|
+
const r = startController(relayDir, { subscriber: sub, capsProvider: async () => "free" });
|
|
731
|
+
return { controller: r.controller, botFetch: r.fetchCalls };
|
|
732
|
+
})();
|
|
733
|
+
await controller.start();
|
|
734
|
+
// 模拟飞书推来一条消息(走真实 bot 的入站 handler)
|
|
735
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("帮我跑个任务"));
|
|
736
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
737
|
+
assert.equal(sub.calls.prompts.length, 0, "第 21 条不能转发给 agent");
|
|
738
|
+
const reply = botFetch.find((c) => c.url.includes("/im/v1/messages"));
|
|
739
|
+
assert.ok(reply, "应回一条额度提示");
|
|
740
|
+
assert.match(JSON.parse(reply.body).content, /免费额度已用完/);
|
|
741
|
+
await controller.stop();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("controller:入站派活 — 付费档可转发,usage 计数 +1 并落状态", async () => {
|
|
745
|
+
const relayDir = mkRelayDir("ctl-pass");
|
|
746
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "oc_chat_1", receiveIdType: "chat_id" });
|
|
747
|
+
const sub = makeSubscriber();
|
|
748
|
+
const { controller, fake, fetchCalls } = startController(relayDir, { subscriber: sub, capsProvider: async () => "pro" });
|
|
749
|
+
await controller.start();
|
|
750
|
+
controller._bot()._dispatch("im.message.receive_v1", inboundText("继续做"));
|
|
751
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
752
|
+
assert.equal(sub.calls.prompts.length, 1);
|
|
753
|
+
assert.deepEqual(sub.calls.prompts[0], { sessionId: "session-abc", text: "继续做" });
|
|
754
|
+
const ack = fetchCalls.find((c) => c.url.includes("/im/v1/messages"));
|
|
755
|
+
assert.match(JSON.parse(ack.body).content, /已收到/);
|
|
756
|
+
const st = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
|
|
757
|
+
assert.equal(st.feishu.usage.messages, 1);
|
|
758
|
+
await controller.stop();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("controller:审批事件 → 发可点卡片(interactive);turn_end → 发文本通知", async () => {
|
|
762
|
+
const relayDir = mkRelayDir("ctl-notify");
|
|
763
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner", receiveIdType: "open_id" });
|
|
764
|
+
const sub = makeSubscriber();
|
|
765
|
+
const { controller, fetchCalls } = startController(relayDir, { subscriber: sub });
|
|
766
|
+
await controller.start();
|
|
767
|
+
sub.emit("event", { kind: "approval-request", eventId: "apr_notify_1", toolName: "Bash", sessionId: "s1" });
|
|
768
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
769
|
+
const card = fetchCalls.find((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "interactive");
|
|
770
|
+
assert.ok(card, "审批事件应发 interactive 卡片");
|
|
771
|
+
const content = JSON.parse(JSON.parse(card.body).content);
|
|
772
|
+
const action = content.body.elements.find((e) => e.tag === "action");
|
|
773
|
+
assert.equal(action.actions[0].behaviors[0].value.requestId, "apr_notify_1");
|
|
774
|
+
|
|
775
|
+
sub.emit("event", { kind: "turn-end", sessionId: "s1", turn: 1, seq: 2, reason: "completed" });
|
|
776
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
777
|
+
const texts = fetchCalls.filter((c) => c.url.includes("/im/v1/messages") && JSON.parse(c.body).msg_type === "text");
|
|
778
|
+
assert.ok(texts.length >= 1, "turn_end 应发文本通知");
|
|
779
|
+
assert.match(JSON.parse(texts[texts.length - 1].body).content, /完成/);
|
|
780
|
+
await controller.stop();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("controller:热更新(改 appId)→ 重建长连接", async () => {
|
|
784
|
+
const relayDir = mkRelayDir("ctl-reload");
|
|
785
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner" });
|
|
786
|
+
const fake = makeFakeSdk();
|
|
787
|
+
const controller = createFeishuBotController({
|
|
788
|
+
relayDir,
|
|
789
|
+
env: {},
|
|
790
|
+
clock: at,
|
|
791
|
+
sdkFactory: () => fake.sdk,
|
|
792
|
+
fetchImpl: makeFetch().fn,
|
|
793
|
+
configPollMs: 10,
|
|
794
|
+
log: () => {}
|
|
795
|
+
});
|
|
796
|
+
await controller.start();
|
|
797
|
+
assert.equal(fake.calls.ctors, 1);
|
|
798
|
+
writeConfig(relayDir, { enabled: true, appId: "cli_aaaaaaaaaaaaaaaa", appSecret: APP_SECRET, receiveId: "ou_owner" });
|
|
799
|
+
const deadline = Date.now() + 1500;
|
|
800
|
+
while (Date.now() < deadline && fake.calls.ctors < 2) await new Promise((r) => setTimeout(r, 25));
|
|
801
|
+
assert.equal(fake.calls.ctors, 2, "改凭据应重建连接");
|
|
802
|
+
await controller.stop();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("controller:stop 幂等,状态写 stopped", async () => {
|
|
806
|
+
const relayDir = mkRelayDir("ctl-stop");
|
|
807
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET });
|
|
808
|
+
const { controller, fake, sub } = startController(relayDir, { subscriber: makeSubscriber() });
|
|
809
|
+
await controller.start();
|
|
810
|
+
await controller.stop();
|
|
811
|
+
await controller.stop();
|
|
812
|
+
assert.equal(fake.calls.closes, 1);
|
|
813
|
+
assert.equal(sub.calls.stopped, 1);
|
|
814
|
+
const st = JSON.parse(fs.readFileSync(path.join(relayDir, "im-bot-status.json"), "utf8"));
|
|
815
|
+
assert.equal(st.feishu.enabled, false);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("controller:日志里绝不出现 appSecret", async () => {
|
|
819
|
+
const relayDir = mkRelayDir("ctl-secret");
|
|
820
|
+
writeConfig(relayDir, { enabled: true, appId: APP_ID, appSecret: APP_SECRET, receiveId: "ou_owner" });
|
|
821
|
+
const { controller, logs } = startController(relayDir, { subscriber: makeSubscriber() });
|
|
822
|
+
await controller.start();
|
|
823
|
+
await controller.stop();
|
|
824
|
+
assert.equal(logs.some((l) => l.includes(APP_SECRET)), false, "secret 绝不能进日志");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// ===========================================================================
|
|
828
|
+
// F. 配置页向导契约(供插件半渲染;不画 UI)
|
|
829
|
+
// ===========================================================================
|
|
830
|
+
|
|
831
|
+
test("8 步向导:官方链接齐全、步骤 8 条、含顺序坑与「一个应用一台电脑」", () => {
|
|
832
|
+
assert.match(FEISHU_SETUP_GUIDE.docs.registerApp, /open\.feishu\.cn\/app/);
|
|
833
|
+
assert.match(FEISHU_SETUP_GUIDE.docs.longConnectionEvent, /long-connection|event-subscription/);
|
|
834
|
+
assert.equal(FEISHU_SETUP_GUIDE.steps.length, 8);
|
|
835
|
+
assert.match(FEISHU_SETUP_GUIDE.steps[4], /长连接接收事件/);
|
|
836
|
+
assert.match(FEISHU_SETUP_GUIDE.steps[5], /card\.action\.trigger/);
|
|
837
|
+
assert.match(FEISHU_SETUP_GUIDE.orderWarning, /长连接在线/);
|
|
838
|
+
assert.match(FEISHU_SETUP_GUIDE.oneAppOnePc, /一台电脑/);
|
|
839
|
+
});
|