@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.0

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 (89) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +34 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +121 -0
  7. package/dist/dual-replica.js +247 -0
  8. package/dist/extension-config.d.ts +9 -0
  9. package/dist/extension-config.js +16 -0
  10. package/dist/fake-channel.d.ts +162 -0
  11. package/dist/fake-channel.js +325 -0
  12. package/dist/fake-e2b.d.ts +104 -0
  13. package/dist/fake-e2b.js +500 -0
  14. package/dist/fake-git-server.d.ts +57 -0
  15. package/dist/fake-git-server.js +323 -0
  16. package/dist/fake-lark.d.ts +234 -0
  17. package/dist/fake-lark.js +837 -0
  18. package/dist/fake-marketplace.d.ts +25 -0
  19. package/dist/fake-marketplace.js +58 -0
  20. package/dist/fake-mcp-server.d.ts +134 -0
  21. package/dist/fake-mcp-server.js +308 -0
  22. package/dist/fake-model-server.d.ts +28 -0
  23. package/dist/fake-model-server.js +67 -0
  24. package/dist/fake-npm-registry.d.ts +59 -0
  25. package/dist/fake-npm-registry.js +174 -0
  26. package/dist/fake-oauth-server.d.ts +63 -0
  27. package/dist/fake-oauth-server.js +284 -0
  28. package/dist/fake-openapi-server.d.ts +50 -0
  29. package/dist/fake-openapi-server.js +278 -0
  30. package/dist/fake-slack.d.ts +258 -0
  31. package/dist/fake-slack.js +654 -0
  32. package/dist/fixtures.d.ts +47 -0
  33. package/dist/fixtures.js +90 -0
  34. package/dist/global-setup.d.ts +15 -0
  35. package/dist/global-setup.js +58 -0
  36. package/dist/index.d.ts +25 -0
  37. package/dist/index.js +25 -0
  38. package/dist/kernel-helpers.d.ts +125 -0
  39. package/dist/kernel-helpers.js +336 -0
  40. package/dist/media-samples.d.ts +10 -0
  41. package/dist/media-samples.js +76 -0
  42. package/dist/model-conformance.d.ts +61 -0
  43. package/dist/model-conformance.js +287 -0
  44. package/dist/replica-runner.d.ts +1 -0
  45. package/dist/replica-runner.js +59 -0
  46. package/dist/sse.d.ts +20 -0
  47. package/dist/sse.js +87 -0
  48. package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
  49. package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
  50. package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
  51. package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
  52. package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
  53. package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
  54. package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
  55. package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
  56. package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
  57. package/fixtures/extensions/fake-connector/package.json +13 -0
  58. package/fixtures/extensions/fake-connector/src/index.ts +477 -0
  59. package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
  60. package/fixtures/extensions/fake-dep-provider/package.json +12 -0
  61. package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
  62. package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
  63. package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
  64. package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
  65. package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
  66. package/fixtures/extensions/fake-embed/package.json +12 -0
  67. package/fixtures/extensions/fake-embed/src/index.ts +207 -0
  68. package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
  69. package/fixtures/extensions/fake-im/package.json +12 -0
  70. package/fixtures/extensions/fake-im/src/channel.ts +224 -0
  71. package/fixtures/extensions/fake-im/src/directory.ts +25 -0
  72. package/fixtures/extensions/fake-im/src/identity.ts +66 -0
  73. package/fixtures/extensions/fake-im/src/index.ts +91 -0
  74. package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
  75. package/fixtures/extensions/fake-object-store/package.json +15 -0
  76. package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
  77. package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
  78. package/fixtures/extensions/fake-openapi/package.json +12 -0
  79. package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
  80. package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
  81. package/fixtures/extensions/fake-storage-user/package.json +12 -0
  82. package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
  83. package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
  84. package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
  85. package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
  86. package/fixtures/extensions/fake-tools-ui/package.json +12 -0
  87. package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
  88. package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
  89. package/package.json +66 -3
@@ -0,0 +1,837 @@
1
+ import { createCipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import protobuf from "protobufjs";
4
+ import { WebSocketServer } from "ws";
5
+ // SDK 内置的帧定义(@larksuiteoapi/node-sdk 的 pbbp2.Frame / pbbp2.Header)
6
+ const root = protobuf.Root.fromJSON({
7
+ nested: {
8
+ pbbp2: {
9
+ nested: {
10
+ Header: {
11
+ fields: {
12
+ key: { rule: "required", type: "string", id: 1 },
13
+ value: { rule: "required", type: "string", id: 2 },
14
+ },
15
+ },
16
+ Frame: {
17
+ fields: {
18
+ SeqID: { rule: "required", type: "uint64", id: 1 },
19
+ LogID: { rule: "required", type: "uint64", id: 2 },
20
+ service: { rule: "required", type: "int32", id: 3 },
21
+ method: { rule: "required", type: "int32", id: 4 },
22
+ headers: { rule: "repeated", type: "Header", id: 5 },
23
+ payloadEncoding: { type: "string", id: 6 },
24
+ payloadType: { type: "string", id: 7 },
25
+ payload: { type: "bytes", id: 8 },
26
+ LogIDNew: { type: "string", id: 9 },
27
+ },
28
+ },
29
+ },
30
+ },
31
+ },
32
+ });
33
+ const Frame = root.lookupType("pbbp2.Frame");
34
+ const encodeFrame = (f) => Buffer.from(Frame.encode(Frame.fromObject(f)).finish());
35
+ const decodeFrame = (b) => Frame.toObject(Frame.decode(b), { longs: Number, bytes: Buffer });
36
+ const readBody = (req) => new Promise((resolve) => {
37
+ let body = "";
38
+ req.on("data", (c) => {
39
+ body += c.toString();
40
+ });
41
+ req.on("end", () => resolve(body));
42
+ });
43
+ const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
44
+ ? v
45
+ : undefined;
46
+ const str = (v) => (typeof v === "string" ? v : undefined);
47
+ const hex = (n) => randomBytes(n).toString("hex");
48
+ /** 卡片里的全部组件(展开 column_set / column / form)。 */
49
+ function walk(card) {
50
+ const out = [];
51
+ const visit = (els) => {
52
+ if (!Array.isArray(els))
53
+ return;
54
+ for (const e of els) {
55
+ const o = obj(e);
56
+ if (!o)
57
+ continue;
58
+ out.push(o);
59
+ visit(o.columns);
60
+ visit(o.elements);
61
+ }
62
+ };
63
+ visit(obj(obj(card)?.body)?.elements);
64
+ return out;
65
+ }
66
+ /** 启动假 Lark(监听 127.0.0.1 随机端口)。 */
67
+ export async function startFakeLark(opts = {}) {
68
+ const appId = opts.appId ?? "cli_a1b2c3d4e5f60718";
69
+ const appSecret = opts.appSecret ?? "fake-lark-app-secret";
70
+ const tenantKey = opts.tenantKey ?? "tenant_fake";
71
+ const botOpenId = opts.botOpenId ?? "ou_bot";
72
+ const botName = opts.botName ?? "Capybara";
73
+ const messages = [];
74
+ const updates = [];
75
+ const calls = [];
76
+ const acks = [];
77
+ const users = new Map();
78
+ const departments = [];
79
+ const codes = new Map();
80
+ const userTokens = new Map();
81
+ let userIssued = 0;
82
+ let statusHidden = false;
83
+ const known = new Map();
84
+ const byUuid = new Map();
85
+ const tokens = new Set();
86
+ const failures = [];
87
+ let rootRejected = false;
88
+ const delays = new Map();
89
+ const sockets = [];
90
+ const peerPorts = new WeakMap();
91
+ const ackWaiters = new Map();
92
+ let issued = 0;
93
+ let endpoint = 0;
94
+ let opened = 0;
95
+ let peak = 0;
96
+ let dropLeft = 0;
97
+ let seq = 0;
98
+ let base = "";
99
+ const reply = (res, body, status = 200, headers = {}) => {
100
+ res.writeHead(status, { "content-type": "application/json", ...headers });
101
+ res.end(JSON.stringify(body));
102
+ };
103
+ const fail = (res, code, msg = "error", status = 400) => reply(res, { code, msg, data: {} }, status);
104
+ /** 发送 / 回复 / 更新的内容校验;通过返回解析后的卡片。 */
105
+ const checkContent = (res, raw, body) => {
106
+ if (Buffer.byteLength(raw) > 30_000) {
107
+ fail(res, 230025, "The length of the message content reaches its limit.");
108
+ return undefined;
109
+ }
110
+ const msgType = str(body.msg_type) ?? "interactive";
111
+ let card;
112
+ try {
113
+ card = JSON.parse(str(body.content) ?? "");
114
+ }
115
+ catch {
116
+ fail(res, 230001, "invalid content");
117
+ return undefined;
118
+ }
119
+ if (msgType === "interactive") {
120
+ const c = obj(card);
121
+ if (c?.schema !== "2.0" || obj(c.config)?.update_multi === false || !obj(c.body)) {
122
+ fail(res, 230099, "Failed to create card content.");
123
+ return undefined;
124
+ }
125
+ }
126
+ return { msgType, card };
127
+ };
128
+ const api = async (req, res, u) => {
129
+ const raw = await readBody(req);
130
+ let body = {};
131
+ try {
132
+ body = raw === "" ? {} : JSON.parse(raw);
133
+ }
134
+ catch {
135
+ return fail(res, 9499, "bad request");
136
+ }
137
+ const path = u.pathname;
138
+ const method = req.method ?? "GET";
139
+ const reply$ = /^\/open-apis\/im\/v1\/messages\/([^/]+)\/reply$/.exec(path);
140
+ const msg$ = /^\/open-apis\/im\/v1\/messages\/([^/]+)$/.exec(path);
141
+ const user$ = path === "/open-apis/contact/v3/users/find_by_department"
142
+ ? null
143
+ : /^\/open-apis\/contact\/v3\/users\/([^/]+)$/.exec(path);
144
+ const dept$ = /^\/open-apis\/contact\/v3\/departments\/([^/]+)\/children$/.exec(path);
145
+ let name;
146
+ if (path === "/open-apis/auth/v3/tenant_access_token/internal" && method === "POST")
147
+ name = "token";
148
+ else if (path === "/open-apis/bot/v3/info" && method === "GET")
149
+ name = "bot";
150
+ else if (path === "/open-apis/tenant/v2/tenant/query" && method === "GET")
151
+ name = "tenant";
152
+ else if (path === "/open-apis/im/v1/messages" && method === "POST")
153
+ name = "create";
154
+ else if (reply$ && method === "POST")
155
+ name = "reply";
156
+ else if (msg$ && method === "PATCH")
157
+ name = "patch";
158
+ else if (user$ && method === "GET")
159
+ name = "user";
160
+ else if (path === "/callback/ws/endpoint" && method === "POST")
161
+ name = "endpoint";
162
+ else if (path === "/open-apis/authen/v2/oauth/token" && method === "POST")
163
+ name = "oauth_token";
164
+ else if (path === "/open-apis/authen/v1/user_info" && method === "GET")
165
+ name = "user_info";
166
+ else if (dept$ && method === "GET")
167
+ name = "departments";
168
+ else if (path === "/open-apis/contact/v3/users/find_by_department" && method === "GET")
169
+ name = "find_by_department";
170
+ if (name === undefined)
171
+ return fail(res, 404, "not found", 404);
172
+ const auth = String(req.headers.authorization ?? "");
173
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
174
+ calls.push({ api: name, token, query: Object.fromEntries(u.searchParams), body });
175
+ if (name === "endpoint")
176
+ endpoint++;
177
+ const wait = delays.get(name) ?? 0;
178
+ if (wait > 0)
179
+ await new Promise((r) => setTimeout(r, wait));
180
+ let f = failures.find((x) => x.api === name && x.left > 0);
181
+ if (f && f.skip > 0) {
182
+ f.skip--;
183
+ f = undefined;
184
+ }
185
+ if (f) {
186
+ f.left--;
187
+ const { status = name === "endpoint" ? 200 : 400, code, msg, retryAfter } = f.failure;
188
+ const headers = retryAfter !== undefined ? { "x-ogw-ratelimit-reset": String(retryAfter) } : {};
189
+ if (code === undefined)
190
+ return reply(res, {}, status, headers);
191
+ return reply(res, { code, msg: msg ?? "error", data: name === "endpoint" ? { URL: "" } : {} }, status, headers);
192
+ }
193
+ if (name === "token") {
194
+ const app = [{ appId, appSecret }, ...(opts.extraApps ?? [])].find((a) => a.appId === str(body.app_id));
195
+ if (!app)
196
+ return reply(res, { code: 10014, msg: "app id not exists" });
197
+ if (str(body.app_secret) !== app.appSecret)
198
+ return reply(res, { code: 10015, msg: "wrong app secret" });
199
+ const t = `t-fake-${++issued}-${hex(4)}`;
200
+ tokens.add(t);
201
+ return reply(res, { code: 0, msg: "ok", tenant_access_token: t, expire: 7200 });
202
+ }
203
+ if (name === "oauth_token")
204
+ return oauthToken(res, body);
205
+ if (name === "user_info") {
206
+ const openId = userTokens.get(token);
207
+ const usr = openId !== undefined ? users.get(openId) : undefined;
208
+ if (!usr)
209
+ return fail(res, 20005, "The user access token passed is invalid", 401);
210
+ return reply(res, {
211
+ code: 0,
212
+ msg: "success",
213
+ data: {
214
+ name: usr.name,
215
+ ...(usr.enName !== undefined && { en_name: usr.enName }),
216
+ ...(usr.avatar72 !== undefined && {
217
+ avatar_url: usr.avatar72,
218
+ avatar_thumb: usr.avatar72,
219
+ }),
220
+ open_id: usr.openId,
221
+ union_id: `on_${usr.openId}`,
222
+ ...(usr.email !== undefined && { email: usr.email }),
223
+ ...(usr.enterpriseEmail !== undefined && { enterprise_email: usr.enterpriseEmail }),
224
+ tenant_key: usr.tenantKey ?? tenantKey,
225
+ },
226
+ });
227
+ }
228
+ if (name === "endpoint") {
229
+ if (str(body.AppID) !== appId || str(body.AppSecret) !== appSecret) {
230
+ return reply(res, { code: 1000040343, msg: "internal error", data: { URL: "" } });
231
+ }
232
+ const wsUrl = `${base.replace(/^http/, "ws")}/ws?fake=1&device_id=dev_1&service_id=1&ticket=${hex(4)}`;
233
+ return reply(res, {
234
+ code: 0,
235
+ msg: "ok",
236
+ data: {
237
+ URL: wsUrl,
238
+ ClientConfig: {
239
+ PingInterval: 120,
240
+ ReconnectCount: -1,
241
+ ReconnectInterval: 120,
242
+ ReconnectNonce: 30,
243
+ },
244
+ },
245
+ });
246
+ }
247
+ if (!tokens.has(token))
248
+ return fail(res, 99991663, "tenant token invalid");
249
+ switch (name) {
250
+ case "bot":
251
+ return reply(res, {
252
+ code: 0,
253
+ msg: "ok",
254
+ bot: {
255
+ activate_status: 2,
256
+ app_name: botName,
257
+ avatar_url: "",
258
+ ip_white_list: [],
259
+ open_id: botOpenId,
260
+ },
261
+ });
262
+ case "tenant":
263
+ return reply(res, {
264
+ code: 0,
265
+ msg: "ok",
266
+ data: {
267
+ tenant: {
268
+ name: "Fake Tenant",
269
+ display_id: "F001",
270
+ tenant_tag: 0,
271
+ tenant_key: tenantKey,
272
+ avatar: {},
273
+ },
274
+ },
275
+ });
276
+ case "user": {
277
+ const id = decodeURIComponent(user$?.[1] ?? "");
278
+ const usr = users.get(id);
279
+ if (!usr)
280
+ return fail(res, 41050, "no user authority");
281
+ return reply(res, {
282
+ code: 0,
283
+ msg: "ok",
284
+ data: {
285
+ user: {
286
+ open_id: usr.openId,
287
+ name: usr.name,
288
+ ...(usr.avatar72 !== undefined && { avatar: { avatar_72: usr.avatar72 } }),
289
+ },
290
+ },
291
+ });
292
+ }
293
+ case "create":
294
+ case "reply": {
295
+ const uuid = str(body.uuid);
296
+ if (uuid !== undefined && uuid.length > 50)
297
+ return fail(res, 230001, "uuid too long");
298
+ const dupId = uuid !== undefined ? byUuid.get(uuid) : undefined;
299
+ if (dupId !== undefined) {
300
+ const m = messages.find((x) => x.messageId === dupId);
301
+ return reply(res, {
302
+ code: 0,
303
+ msg: "ok",
304
+ data: { message_id: dupId, chat_id: m?.chatId },
305
+ });
306
+ }
307
+ const content = checkContent(res, raw, body);
308
+ if (!content)
309
+ return;
310
+ let chatId;
311
+ let rootId;
312
+ let parentId;
313
+ if (name === "create") {
314
+ if (u.searchParams.get("receive_id_type") !== "chat_id")
315
+ return fail(res, 230001, "receive_id_type");
316
+ const rid = str(body.receive_id);
317
+ if (!rid)
318
+ return fail(res, 230001, "receive_id");
319
+ chatId = rid;
320
+ }
321
+ else {
322
+ parentId = decodeURIComponent(reply$?.[1] ?? "");
323
+ const parent = known.get(parentId);
324
+ if (!parent)
325
+ return fail(res, 230001, "message not found");
326
+ chatId = parent.chatId;
327
+ rootId = parent.rootId ?? parentId;
328
+ }
329
+ const m = {
330
+ messageId: `om_${hex(12)}`,
331
+ chatId,
332
+ ...(rootId !== undefined && { rootId }),
333
+ ...(parentId !== undefined && { parentId }),
334
+ replyInThread: name === "reply" && body.reply_in_thread === true,
335
+ msgType: content.msgType,
336
+ card: content.card,
337
+ ...(uuid !== undefined && { uuid }),
338
+ at: Date.now(),
339
+ };
340
+ messages.push(m);
341
+ known.set(m.messageId, { chatId, ...(rootId !== undefined && { rootId }) });
342
+ if (uuid !== undefined)
343
+ byUuid.set(uuid, m.messageId);
344
+ return reply(res, {
345
+ code: 0,
346
+ msg: "ok",
347
+ data: {
348
+ message_id: m.messageId,
349
+ chat_id: chatId,
350
+ ...(rootId !== undefined && { root_id: rootId }),
351
+ },
352
+ });
353
+ }
354
+ case "patch": {
355
+ const id = decodeURIComponent(msg$?.[1] ?? "");
356
+ const orig = messages.find((x) => x.messageId === id);
357
+ if (!orig)
358
+ return fail(res, 230001, "message not found");
359
+ if (orig.msgType !== "interactive")
360
+ return fail(res, 230001, "only interactive messages can be patched");
361
+ const content = checkContent(res, raw, { ...body, msg_type: "interactive" });
362
+ if (!content)
363
+ return;
364
+ updates.push({ ...orig, card: content.card, at: Date.now() });
365
+ return reply(res, { code: 0, msg: "ok", data: {} });
366
+ }
367
+ case "departments": {
368
+ const id = decodeURIComponent(dept$?.[1] ?? "");
369
+ if (rootRejected && id === "0")
370
+ return fail(res, 40004, "no dept authority");
371
+ const deep = u.searchParams.get("fetch_child") === "true";
372
+ const under = (p) => departments
373
+ .filter((d) => d.parent === p)
374
+ .flatMap((d) => (deep ? [d, ...under(d.openDepartmentId)] : [d]));
375
+ return page(res, u, under(id), (d) => ({
376
+ name: d.name,
377
+ parent_department_id: d.parent,
378
+ open_department_id: d.openDepartmentId,
379
+ }));
380
+ }
381
+ case "find_by_department": {
382
+ const id = u.searchParams.get("department_id");
383
+ if (id === null || id === "")
384
+ return fail(res, 99992402, "department_id is required");
385
+ if (rootRejected && id === "0")
386
+ return fail(res, 40004, "no dept authority");
387
+ const all = [...users.values()].filter((x) => x.departments?.includes(id));
388
+ return page(res, u, all, (x) => ({
389
+ open_id: x.openId,
390
+ union_id: `on_${x.openId}`,
391
+ name: x.name,
392
+ ...(x.enName !== undefined && { en_name: x.enName }),
393
+ ...(x.email !== undefined && { email: x.email }),
394
+ ...(x.enterpriseEmail !== undefined && { enterprise_email: x.enterpriseEmail }),
395
+ ...(!statusHidden && {
396
+ status: {
397
+ is_frozen: x.status?.isFrozen === true,
398
+ is_resigned: x.status?.isResigned === true,
399
+ is_activated: x.status?.isActivated !== false,
400
+ is_exited: x.status?.isExited === true,
401
+ is_unjoin: x.status?.isUnjoin === true,
402
+ },
403
+ }),
404
+ department_ids: x.departments ?? [],
405
+ }));
406
+ }
407
+ default:
408
+ return fail(res, 404, "not found", 404);
409
+ }
410
+ };
411
+ /** 通讯录分页:`page_size`(默认 10,最大 50)+ 不透明的 `page_token`(偏移量的 base64url)。 */
412
+ function page(res, u, all, map) {
413
+ const size = Number(u.searchParams.get("page_size") ?? "10");
414
+ if (!Number.isInteger(size) || size < 1 || size > 50)
415
+ return fail(res, 99992402, "page_size");
416
+ const tok = u.searchParams.get("page_token");
417
+ const offset = tok ? Number(Buffer.from(tok, "base64url").toString()) : 0;
418
+ if (!Number.isInteger(offset) || offset < 0)
419
+ return fail(res, 99992402, "page_token");
420
+ const more = offset + size < all.length;
421
+ return reply(res, {
422
+ code: 0,
423
+ msg: "success",
424
+ data: {
425
+ has_more: more,
426
+ ...(more && { page_token: Buffer.from(String(offset + size)).toString("base64url") }),
427
+ items: all.slice(offset, offset + size).map(map),
428
+ },
429
+ });
430
+ }
431
+ /** OAuth 错误:HTTP 400 + `{code, error, error_description}`(与 Lark v2 token 接口一致)。 */
432
+ const oauthFail = (res, code, error, description) => reply(res, { code, error, error_description: description }, 400);
433
+ function oauthToken(res, body) {
434
+ if (body.grant_type !== "authorization_code")
435
+ return oauthFail(res, 20036, "unsupported_grant_type", "grant_type");
436
+ if (str(body.client_id) !== appId || str(body.client_secret) !== appSecret)
437
+ return oauthFail(res, 20002, "invalid_client", "The client secret is invalid.");
438
+ const rec = codes.get(str(body.code) ?? "");
439
+ if (!rec)
440
+ return oauthFail(res, 20003, "invalid_grant", "The authorization code is not found.");
441
+ if (rec.used)
442
+ return oauthFail(res, 20065, "invalid_grant", "The authorization code has been used.");
443
+ rec.used = true;
444
+ if (str(body.redirect_uri) !== rec.redirectUri)
445
+ return oauthFail(res, 20071, "invalid_grant", "The provided redirect URI does not match.");
446
+ if (rec.challenge !== undefined) {
447
+ const verifier = str(body.code_verifier) ?? "";
448
+ const expected = rec.method === "S256"
449
+ ? createHash("sha256").update(verifier).digest("base64url")
450
+ : verifier;
451
+ if (verifier === "" || expected !== rec.challenge)
452
+ return oauthFail(res, 20049, "invalid_grant", "PKCE code challenge failed.");
453
+ }
454
+ const t = `u-fake-${++userIssued}-${hex(6)}`;
455
+ userTokens.set(t, rec.openId);
456
+ return reply(res, {
457
+ code: 0,
458
+ access_token: t,
459
+ expires_in: 7200,
460
+ token_type: "Bearer",
461
+ scope: "",
462
+ });
463
+ }
464
+ /** 授权页:`&as=<open_id>` 表示该用户同意授权。 */
465
+ function authorize(res, u) {
466
+ const redirect = u.searchParams.get("redirect_uri");
467
+ if (!redirect)
468
+ return fail(res, 20001, "redirect_uri is required");
469
+ const back = new URL(redirect);
470
+ const state = u.searchParams.get("state");
471
+ if (state !== null)
472
+ back.searchParams.set("state", state);
473
+ const as = u.searchParams.get("as") ?? "";
474
+ if (u.searchParams.get("client_id") !== appId || !users.has(as)) {
475
+ back.searchParams.set("error", "access_denied");
476
+ }
477
+ else {
478
+ const code = hex(24);
479
+ const challenge = u.searchParams.get("code_challenge");
480
+ codes.set(code, {
481
+ openId: as,
482
+ redirectUri: redirect,
483
+ ...(challenge !== null && { challenge }),
484
+ method: u.searchParams.get("code_challenge_method") ?? "plain",
485
+ used: false,
486
+ });
487
+ back.searchParams.set("code", code);
488
+ }
489
+ res.writeHead(302, { location: back.toString() });
490
+ res.end();
491
+ }
492
+ const server = createServer((req, res) => {
493
+ void (async () => {
494
+ const u = new URL(req.url ?? "/", "http://x");
495
+ if (u.pathname === "/open-apis/authen/v1/authorize" && req.method === "GET")
496
+ return authorize(res, u);
497
+ return api(req, res, u);
498
+ })().catch(() => {
499
+ if (!res.headersSent)
500
+ res.writeHead(500);
501
+ res.end();
502
+ });
503
+ });
504
+ const recordAck = (f) => {
505
+ const id = f.headers.find((h) => h.key === "message_id")?.value;
506
+ if (id === undefined || !f.payload)
507
+ return;
508
+ let resp;
509
+ try {
510
+ resp = JSON.parse(Buffer.from(f.payload).toString("utf8"));
511
+ }
512
+ catch {
513
+ return;
514
+ }
515
+ let data;
516
+ if (typeof resp.data === "string") {
517
+ try {
518
+ data = JSON.parse(Buffer.from(resp.data, "base64").toString("utf8"));
519
+ }
520
+ catch {
521
+ data = resp.data;
522
+ }
523
+ }
524
+ const a = {
525
+ id,
526
+ code: typeof resp.code === "number" ? resp.code : 0,
527
+ ...(data !== undefined && { data }),
528
+ at: Date.now(),
529
+ };
530
+ acks.push(a);
531
+ for (const w of ackWaiters.get(id) ?? [])
532
+ w(a);
533
+ ackWaiters.delete(id);
534
+ };
535
+ /** 已收到第一个 ping 的连接(客户端已挂上 message 监听);之前推的帧暂存在 `held`。 */
536
+ const listening = new WeakSet();
537
+ const held = new Map();
538
+ const wss = new WebSocketServer({ noServer: true });
539
+ server.on("upgrade", (req, socket, head) => {
540
+ const u = new URL(req.url ?? "/", "http://x");
541
+ if (u.pathname !== "/ws") {
542
+ socket.destroy();
543
+ return;
544
+ }
545
+ wss.handleUpgrade(req, socket, head, (ws) => {
546
+ opened++;
547
+ sockets.push(ws);
548
+ if (req.socket.remotePort !== undefined)
549
+ peerPorts.set(ws, req.socket.remotePort);
550
+ peak = Math.max(peak, sockets.length);
551
+ ws.on("close", () => {
552
+ const i = sockets.indexOf(ws);
553
+ if (i >= 0)
554
+ sockets.splice(i, 1);
555
+ held.delete(ws);
556
+ });
557
+ ws.on("message", (data) => {
558
+ let f;
559
+ try {
560
+ f = decodeFrame(Buffer.isBuffer(data) ? data : Buffer.from(data));
561
+ }
562
+ catch {
563
+ return;
564
+ }
565
+ const type = f.headers.find((h) => h.key === "type")?.value;
566
+ if (f.method === 0 && type === "ping") {
567
+ if (!listening.has(ws)) {
568
+ listening.add(ws);
569
+ for (const b of held.get(ws) ?? [])
570
+ ws.send(b);
571
+ held.delete(ws);
572
+ }
573
+ ws.send(encodeFrame({
574
+ SeqID: 0,
575
+ LogID: 0,
576
+ service: f.service,
577
+ method: 0,
578
+ headers: [{ key: "type", value: "pong" }],
579
+ payload: Buffer.from(JSON.stringify({
580
+ PingInterval: 120,
581
+ ReconnectCount: -1,
582
+ ReconnectInterval: 120,
583
+ ReconnectNonce: 30,
584
+ })),
585
+ }));
586
+ return;
587
+ }
588
+ if (f.method === 1)
589
+ recordAck(f);
590
+ });
591
+ if (dropLeft > 0) {
592
+ dropLeft--;
593
+ setImmediate(() => ws.close(1011));
594
+ }
595
+ });
596
+ });
597
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
598
+ base = `http://127.0.0.1:${server.address().port}`;
599
+ const pushEvent = (payload) => {
600
+ const ws = sockets.at(-1);
601
+ if (!ws)
602
+ throw new Error("fake lark: no open long connection");
603
+ const header = { ...obj(payload.header) };
604
+ header.tenant_key ??= tenantKey;
605
+ header.app_id ??= appId;
606
+ const event = { ...obj(payload.event) };
607
+ const sender = obj(event.sender);
608
+ if (sender)
609
+ event.sender = { ...sender, tenant_key: sender.tenant_key ?? tenantKey };
610
+ const message = obj(event.message);
611
+ const mid = str(message?.message_id);
612
+ const cid = str(message?.chat_id);
613
+ if (mid && cid) {
614
+ const rid = str(message?.root_id);
615
+ known.set(mid, { chatId: cid, ...(rid !== undefined && { rootId: rid }) });
616
+ }
617
+ const full = { ...payload, header, event };
618
+ const id = `frame_${++seq}_${hex(3)}`;
619
+ const frame = encodeFrame({
620
+ SeqID: seq,
621
+ LogID: seq,
622
+ service: 1,
623
+ method: 1,
624
+ headers: [
625
+ { key: "type", value: "event" },
626
+ { key: "message_id", value: id },
627
+ { key: "sum", value: "1" },
628
+ { key: "seq", value: "0" },
629
+ { key: "trace_id", value: `trace_${seq}` },
630
+ ],
631
+ payload: Buffer.from(JSON.stringify(full)),
632
+ });
633
+ if (listening.has(ws))
634
+ ws.send(frame);
635
+ else
636
+ held.set(ws, [...(held.get(ws) ?? []), frame]);
637
+ return id;
638
+ };
639
+ return {
640
+ url: base,
641
+ appId,
642
+ appSecret,
643
+ tenantKey,
644
+ botOpenId,
645
+ botName,
646
+ messages,
647
+ updates,
648
+ calls,
649
+ acks,
650
+ users,
651
+ departments,
652
+ userTokensIssued: () => userIssued,
653
+ hideUserStatus(hide) {
654
+ statusHidden = hide;
655
+ },
656
+ tokensIssued: () => issued,
657
+ revokeTokens: () => tokens.clear(),
658
+ endpointCalls: () => endpoint,
659
+ connectionsOpened: () => opened,
660
+ openSockets: () => sockets.length,
661
+ maxConcurrentSockets: () => peak,
662
+ socketPeerPorts: () => sockets.flatMap((ws) => {
663
+ const port = peerPorts.get(ws);
664
+ return port === undefined ? [] : [port];
665
+ }),
666
+ failNext(api, failure, count = 1, skip = 0) {
667
+ failures.push({ api, failure, left: count, skip });
668
+ },
669
+ rejectRootDepartment(reject) {
670
+ rootRejected = reject;
671
+ },
672
+ delay(api, ms) {
673
+ delays.set(api, ms);
674
+ },
675
+ dropNextConnections(count) {
676
+ dropLeft = count;
677
+ },
678
+ registerMessage(messageId, chatId) {
679
+ known.set(messageId, { chatId });
680
+ },
681
+ pushEvent,
682
+ waitForAck(id, timeoutMs = 5000) {
683
+ const done = acks.find((a) => a.id === id);
684
+ if (done)
685
+ return Promise.resolve(done);
686
+ return new Promise((resolve, reject) => {
687
+ const t = setTimeout(() => reject(new Error(`fake lark: frame ${id} not acknowledged`)), timeoutMs);
688
+ const list = ackWaiters.get(id) ?? [];
689
+ list.push((a) => {
690
+ clearTimeout(t);
691
+ resolve(a);
692
+ });
693
+ ackWaiters.set(id, list);
694
+ });
695
+ },
696
+ acked: (id) => acks.some((a) => a.id === id),
697
+ disconnectAll() {
698
+ for (const ws of [...sockets])
699
+ ws.close(1000);
700
+ },
701
+ finalCard(m) {
702
+ return updates.filter((x) => x.messageId === m.messageId).at(-1)?.card ?? m.card;
703
+ },
704
+ cardText(card) {
705
+ return walk(card)
706
+ .filter((e) => e.tag === "markdown")
707
+ .map((e) => String(e.content))
708
+ .join("\n");
709
+ },
710
+ async stop() {
711
+ for (const ws of [...sockets])
712
+ ws.terminate();
713
+ wss.close();
714
+ server.closeAllConnections();
715
+ await new Promise((r) => server.close(() => r()));
716
+ },
717
+ };
718
+ }
719
+ /** 一条 `im.message.receive_v1` 事件(schema 2.0)。`text` 给的是 text 消息的原文(@ 用 `@_user_N` 占位)。 */
720
+ export function larkMessageEvent(o) {
721
+ return {
722
+ schema: "2.0",
723
+ header: {
724
+ event_id: `ev_${hex(8)}`,
725
+ event_type: "im.message.receive_v1",
726
+ create_time: String(Date.now()),
727
+ token: "verification-token",
728
+ ...(o.tenantKey !== undefined && { tenant_key: o.tenantKey }),
729
+ },
730
+ event: {
731
+ sender: {
732
+ sender_id: { open_id: o.openId, union_id: `on_${o.openId}` },
733
+ sender_type: "user",
734
+ ...(o.tenantKey !== undefined && { tenant_key: o.tenantKey }),
735
+ },
736
+ message: {
737
+ message_id: o.messageId ?? `om_${hex(12)}`,
738
+ ...(o.rootId !== undefined && { root_id: o.rootId }),
739
+ ...((o.parentId ?? o.rootId) !== undefined && { parent_id: o.parentId ?? o.rootId }),
740
+ ...(o.threadId !== undefined && { thread_id: o.threadId }),
741
+ create_time: String(Date.now()),
742
+ chat_id: o.chatId,
743
+ chat_type: o.chatType,
744
+ message_type: o.messageType ?? "text",
745
+ content: o.content ?? JSON.stringify({ text: o.text ?? "" }),
746
+ ...(o.mentions !== undefined && {
747
+ mentions: o.mentions.map((m) => ({
748
+ key: m.key,
749
+ id: { open_id: m.openId, union_id: `on_${m.openId}` },
750
+ name: m.name,
751
+ ...(o.tenantKey !== undefined && { tenant_key: o.tenantKey }),
752
+ })),
753
+ }),
754
+ },
755
+ },
756
+ };
757
+ }
758
+ /** 在卡片里找按钮(展开 column_set / form)。 */
759
+ export function findLarkButton(card, match) {
760
+ for (const e of walk(card)) {
761
+ if (e.tag !== "button")
762
+ continue;
763
+ const behaviors = Array.isArray(e.behaviors) ? e.behaviors : [];
764
+ const cb = behaviors.find((b) => b.type === "callback");
765
+ const open = behaviors.find((b) => b.type === "open_url");
766
+ const b = {
767
+ text: String(obj(e.text)?.content ?? ""),
768
+ ...(typeof e.type === "string" && { type: e.type }),
769
+ ...(typeof e.name === "string" && { name: e.name }),
770
+ ...(cb !== undefined && { value: cb.value }),
771
+ ...(typeof open?.default_url === "string" && { url: open.default_url }),
772
+ };
773
+ if (match(b))
774
+ return b;
775
+ }
776
+ return undefined;
777
+ }
778
+ /** 用户点了某条消息卡片上的按钮(`card.action.trigger`,schema 2.0)。`formValue` 为表单提交的数据。 */
779
+ export function larkCardAction(user, message, button, opts = {}) {
780
+ return {
781
+ schema: "2.0",
782
+ header: {
783
+ event_id: opts.eventId ?? `evt_${hex(8)}`,
784
+ token: "verification-token",
785
+ create_time: String(Date.now() * 1000),
786
+ event_type: "card.action.trigger",
787
+ ...(user.tenantKey !== undefined && { tenant_key: user.tenantKey }),
788
+ },
789
+ event: {
790
+ operator: {
791
+ open_id: user.openId,
792
+ union_id: `on_${user.openId}`,
793
+ ...(user.tenantKey !== undefined && { tenant_key: user.tenantKey }),
794
+ },
795
+ token: `c-${hex(8)}`,
796
+ action: {
797
+ tag: "button",
798
+ ...(button.value !== undefined && { value: button.value }),
799
+ ...(button.name !== undefined && { name: button.name }),
800
+ ...(opts.formValue !== undefined && { form_value: opts.formValue }),
801
+ },
802
+ host: "im_message",
803
+ context: { open_message_id: message.messageId, open_chat_id: message.chatId },
804
+ },
805
+ };
806
+ }
807
+ /**
808
+ * Encrypts a Lark webhook payload the way Lark does when an Encrypt Key is set
809
+ * (https://open.larksuite.com/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/event-subscription-configure-/encrypt-key-encryption-configuration-case):
810
+ * AES-256-CBC with key = SHA-256(encryptKey), a random 16-byte IV prepended to the ciphertext,
811
+ * base64. Returns the request body `{"encrypt":"<base64>"}`.
812
+ */
813
+ export function encryptLarkWebhook(payload, encryptKey) {
814
+ const key = createHash("sha256").update(encryptKey).digest();
815
+ const iv = randomBytes(16);
816
+ const cipher = createCipheriv("aes-256-cbc", key, iv);
817
+ const enc = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
818
+ return JSON.stringify({ encrypt: Buffer.concat([iv, enc]).toString("base64") });
819
+ }
820
+ /**
821
+ * Signs a Lark webhook request: returns the `x-lark-request-timestamp` (seconds),
822
+ * `x-lark-request-nonce` and `x-lark-signature` headers, where the signature is
823
+ * hex SHA-256(timestamp + nonce + encryptKey + raw body).
824
+ */
825
+ export function signLarkWebhook(body, encryptKey, opts = {}) {
826
+ const ts = String(opts.timestamp ?? Math.floor(Date.now() / 1000));
827
+ const nonce = opts.nonce ?? hex(8);
828
+ const sig = createHash("sha256")
829
+ .update(ts + nonce + encryptKey)
830
+ .update(typeof body === "string" ? Buffer.from(body) : Buffer.from(body))
831
+ .digest("hex");
832
+ return {
833
+ "x-lark-request-timestamp": ts,
834
+ "x-lark-request-nonce": nonce,
835
+ "x-lark-signature": sig,
836
+ };
837
+ }