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

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 +40 -2
  4. package/dist/containers.d.ts +102 -0
  5. package/dist/containers.js +314 -0
  6. package/dist/dual-replica.d.ts +142 -0
  7. package/dist/dual-replica.js +270 -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 +49 -0
  33. package/dist/fixtures.js +92 -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 +67 -3
@@ -0,0 +1,654 @@
1
+ import { createHmac, createSign, generateKeyPairSync, randomUUID, } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { WebSocketServer } from "ws";
4
+ const readBody = (req) => new Promise((resolve) => {
5
+ let body = "";
6
+ req.on("data", (c) => {
7
+ body += c.toString();
8
+ });
9
+ req.on("end", () => resolve(body));
10
+ });
11
+ const JSON_FIELDS = new Set(["blocks", "attachments", "metadata"]);
12
+ function parseBody(raw, contentType) {
13
+ if (raw === "")
14
+ return {};
15
+ if (contentType?.includes("application/json"))
16
+ return JSON.parse(raw);
17
+ const out = {};
18
+ for (const [k, v] of new URLSearchParams(raw)) {
19
+ if (JSON_FIELDS.has(k)) {
20
+ try {
21
+ out[k] = JSON.parse(v);
22
+ continue;
23
+ }
24
+ catch {
25
+ // 原样保留,交给方法自己报 invalid_blocks
26
+ }
27
+ }
28
+ out[k] = v;
29
+ }
30
+ return out;
31
+ }
32
+ /** 启动假 Slack(监听 127.0.0.1 随机端口)。 */
33
+ export async function startFakeSlack(opts = {}) {
34
+ const teamId = opts.teamId ?? "T0001";
35
+ const botUserId = opts.botUserId ?? "UBOT";
36
+ const botId = opts.botId ?? "B0001";
37
+ const botName = opts.botName ?? "capybara";
38
+ const botToken = opts.botToken ?? "xoxb-fake-bot-token";
39
+ const appToken = opts.appToken ?? "xapp-fake-app-token";
40
+ const clientId = opts.clientId ?? "cli-fake-slack";
41
+ const clientSecret = opts.clientSecret ?? "fake-slack-client-secret";
42
+ const signing = generateKeyPairSync("rsa", { modulusLength: 2048 });
43
+ const foreign = generateKeyPairSync("rsa", { modulusLength: 2048 });
44
+ const kid = "fake-slack-key-1";
45
+ /** 未兑换的授权码 → 登录信息。 */
46
+ const codes = new Map();
47
+ /** access token → user id。 */
48
+ const accessTokens = new Map();
49
+ let tamper;
50
+ const messages = [];
51
+ const updates = [];
52
+ const calls = [];
53
+ const acks = [];
54
+ const users = new Map();
55
+ const failures = [];
56
+ const sockets = [];
57
+ const peerPorts = new WeakMap();
58
+ /** Every app by app token; the default app first. */
59
+ const apps = new Map();
60
+ apps.set(appToken, { appToken, botToken, teamId, botUserId, botId });
61
+ for (const a of opts.apps ?? []) {
62
+ if (apps.has(a.appToken))
63
+ throw new Error(`fake slack: duplicate app token ${a.appToken}`);
64
+ apps.set(a.appToken, {
65
+ appToken: a.appToken,
66
+ botToken: a.botToken,
67
+ teamId: a.teamId ?? teamId,
68
+ botUserId: a.botUserId ?? botUserId,
69
+ botId: a.botId ?? botId,
70
+ });
71
+ }
72
+ const appOfBot = (token) => [...apps.values()].find((a) => a.botToken === token);
73
+ const redeliver = opts.redeliverUnacked ?? false;
74
+ /** Which app each socket belongs to; the ticket from `apps.connections.open` names the app. */
75
+ const socketApp = new WeakMap();
76
+ const ticketApp = new Map();
77
+ const openedBy = new Map();
78
+ const peakBy = new Map();
79
+ /** Pushed, not yet acked envelopes in push order; `ws` is set while delivered to an open socket. */
80
+ const pending = new Map();
81
+ const ackWaiters = new Map();
82
+ const delays = new Map();
83
+ let dropLeft = 0;
84
+ let opened = 0;
85
+ let peak = 0;
86
+ let seq = 0;
87
+ let envSeq = 0;
88
+ let base = "";
89
+ const reply = (res, body, status = 200, headers = {}) => {
90
+ res.writeHead(status, { "content-type": "application/json", ...headers });
91
+ res.end(JSON.stringify(body));
92
+ };
93
+ const nextTs = () => {
94
+ seq++;
95
+ return `${1_760_000_000 + seq}.${String(seq).padStart(6, "0")}`;
96
+ };
97
+ const str = (v) => (typeof v === "string" ? v : undefined);
98
+ const api = async (req, res, method) => {
99
+ const body = parseBody(await readBody(req), req.headers["content-type"]);
100
+ const auth = String(req.headers.authorization ?? "");
101
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : (str(body.token) ?? "");
102
+ calls.push({ method, token, body });
103
+ const wait = delays.get(method) ?? 0;
104
+ if (wait > 0)
105
+ await new Promise((r) => setTimeout(r, wait));
106
+ const f = failures.find((x) => x.method === method && x.left > 0);
107
+ if (f) {
108
+ f.left--;
109
+ const { status, error, retryAfter } = f.failure;
110
+ if (f.failure.body !== undefined)
111
+ return reply(res, f.failure.body);
112
+ if (status !== undefined && status !== 200) {
113
+ const headers = retryAfter !== undefined ? { "retry-after": String(retryAfter) } : {};
114
+ return reply(res, { ok: false, error: error ?? "ratelimited" }, status, headers);
115
+ }
116
+ return reply(res, { ok: false, error: error ?? "internal_error" });
117
+ }
118
+ if (method === "openid.connect.token")
119
+ return oidcToken(res, body);
120
+ if (method === "openid.connect.userInfo") {
121
+ const u = users.get(accessTokens.get(token) ?? "");
122
+ if (!u)
123
+ return reply(res, { ok: false, error: "invalid_auth" });
124
+ return reply(res, { ok: true, ...profileClaims(u) });
125
+ }
126
+ const app = method === "apps.connections.open" ? apps.get(token) : appOfBot(token);
127
+ if (!app)
128
+ return reply(res, { ok: false, error: "invalid_auth" });
129
+ switch (method) {
130
+ case "auth.test":
131
+ return reply(res, {
132
+ ok: true,
133
+ url: "https://fake.slack.com/",
134
+ team: "Fake Team",
135
+ user: botName,
136
+ team_id: app.teamId,
137
+ user_id: app.botUserId,
138
+ bot_id: app.botId,
139
+ });
140
+ case "apps.connections.open":
141
+ opened++;
142
+ ticketApp.set(opened, app.appToken);
143
+ openedBy.set(app.appToken, (openedBy.get(app.appToken) ?? 0) + 1);
144
+ return reply(res, {
145
+ ok: true,
146
+ url: `${base.replace(/^http/, "ws")}/link?ticket=${opened}`,
147
+ });
148
+ case "users.info": {
149
+ const u = users.get(str(body.user) ?? "");
150
+ if (!u)
151
+ return reply(res, { ok: false, error: "user_not_found" });
152
+ return reply(res, {
153
+ ok: true,
154
+ user: {
155
+ id: u.id,
156
+ team_id: u.teamId,
157
+ name: u.name,
158
+ real_name: u.realName ?? u.name,
159
+ is_bot: u.isBot ?? false,
160
+ profile: {
161
+ display_name: u.displayName ?? "",
162
+ real_name: u.realName ?? u.name,
163
+ ...(u.image72 !== undefined && { image_72: u.image72 }),
164
+ },
165
+ },
166
+ });
167
+ }
168
+ case "chat.postMessage": {
169
+ const channel = str(body.channel);
170
+ const text = str(body.text) ?? "";
171
+ if (!channel)
172
+ return reply(res, { ok: false, error: "channel_not_found" });
173
+ if (text === "" && body.blocks === undefined)
174
+ return reply(res, { ok: false, error: "no_text" });
175
+ if (text.length > 40_000)
176
+ return reply(res, { ok: false, error: "msg_too_long" });
177
+ if (body.blocks !== undefined && !Array.isArray(body.blocks))
178
+ return reply(res, { ok: false, error: "invalid_blocks_format" });
179
+ const threadTs = str(body.thread_ts);
180
+ const m = {
181
+ channel,
182
+ ts: nextTs(),
183
+ text,
184
+ ...(Array.isArray(body.blocks) && { blocks: body.blocks }),
185
+ ...(threadTs !== undefined && { threadTs }),
186
+ at: Date.now(),
187
+ };
188
+ messages.push(m);
189
+ return reply(res, { ok: true, channel, ts: m.ts, message: { text, ts: m.ts } });
190
+ }
191
+ case "chat.update": {
192
+ const channel = str(body.channel);
193
+ const ts = str(body.ts);
194
+ const text = str(body.text) ?? "";
195
+ const orig = messages.find((m) => m.channel === channel && m.ts === ts);
196
+ if (!orig || channel === undefined || ts === undefined)
197
+ return reply(res, { ok: false, error: "message_not_found" });
198
+ if (text.length > 4000)
199
+ return reply(res, { ok: false, error: "msg_too_long" });
200
+ updates.push({
201
+ channel,
202
+ ts,
203
+ text,
204
+ ...(Array.isArray(body.blocks) && { blocks: body.blocks }),
205
+ ...(orig.threadTs !== undefined && { threadTs: orig.threadTs }),
206
+ at: Date.now(),
207
+ });
208
+ return reply(res, { ok: true, channel, ts, text });
209
+ }
210
+ case "users.list": {
211
+ const limit = Math.max(1, Math.min(1000, Number(body.limit ?? 100) || 100));
212
+ const offset = str(body.cursor)
213
+ ? Number(Buffer.from(str(body.cursor), "base64url").toString())
214
+ : 0;
215
+ if (!Number.isInteger(offset) || offset < 0)
216
+ return reply(res, { ok: false, error: "invalid_cursor" });
217
+ const all = [
218
+ {
219
+ id: "USLACKBOT",
220
+ team_id: teamId,
221
+ name: "slackbot",
222
+ deleted: false,
223
+ is_bot: false,
224
+ is_app_user: false,
225
+ real_name: "Slackbot",
226
+ profile: { real_name: "Slackbot", display_name: "Slackbot" },
227
+ },
228
+ ...[...users.values()].map((u) => ({
229
+ id: u.id,
230
+ team_id: u.teamId,
231
+ name: u.name,
232
+ deleted: u.deleted ?? false,
233
+ is_bot: u.isBot ?? false,
234
+ is_app_user: false,
235
+ is_restricted: u.isRestricted ?? false,
236
+ is_ultra_restricted: u.isUltraRestricted ?? false,
237
+ real_name: u.realName ?? u.name,
238
+ profile: {
239
+ real_name: u.realName ?? u.name,
240
+ display_name: u.displayName ?? "",
241
+ ...(u.email !== undefined && { email: u.email }),
242
+ ...(u.image72 !== undefined && { image_72: u.image72 }),
243
+ },
244
+ })),
245
+ ];
246
+ const page = all.slice(offset, offset + limit);
247
+ const end = offset + limit;
248
+ const next = end < all.length ? Buffer.from(String(end)).toString("base64url") : "";
249
+ return reply(res, {
250
+ ok: true,
251
+ members: page,
252
+ cache_ts: Math.floor(Date.now() / 1000),
253
+ response_metadata: { next_cursor: next },
254
+ });
255
+ }
256
+ default:
257
+ return reply(res, { ok: false, error: "unknown_method" });
258
+ }
259
+ };
260
+ /** OIDC 的用户资料声明(userInfo 与 ID token 共用)。 */
261
+ const profileClaims = (u) => ({
262
+ sub: u.id,
263
+ "https://slack.com/user_id": u.id,
264
+ "https://slack.com/team_id": u.teamId,
265
+ name: u.realName ?? u.name,
266
+ given_name: (u.realName ?? u.name).split(" ")[0],
267
+ ...(u.email !== undefined && { email: u.email, email_verified: u.emailVerified ?? false }),
268
+ ...(u.image72 !== undefined && { picture: u.image72 }),
269
+ locale: "en-US",
270
+ });
271
+ const b64 = (v) => Buffer.from(JSON.stringify(v)).toString("base64url");
272
+ const signJwt = (claims, key, keyId = kid) => {
273
+ const input = `${b64({ alg: "RS256", typ: "JWT", kid: keyId })}.${b64(claims)}`;
274
+ const sig = createSign("RSA-SHA256").update(input).sign(key).toString("base64url");
275
+ return `${input}.${sig}`;
276
+ };
277
+ const oidcToken = (res, body) => {
278
+ if (str(body.client_id) !== clientId)
279
+ return reply(res, { ok: false, error: "invalid_client_id" });
280
+ if (str(body.client_secret) !== clientSecret)
281
+ return reply(res, { ok: false, error: "bad_client_secret" });
282
+ const code = str(body.code) ?? "";
283
+ const c = codes.get(code);
284
+ if (!c)
285
+ return reply(res, { ok: false, error: "invalid_code" });
286
+ codes.delete(code);
287
+ if (str(body.redirect_uri) !== c.redirectUri)
288
+ return reply(res, { ok: false, error: "bad_redirect_uri" });
289
+ const u = users.get(c.userId);
290
+ if (!u)
291
+ return reply(res, { ok: false, error: "invalid_code" });
292
+ const access = `xoxp-oidc-${randomUUID()}`;
293
+ accessTokens.set(access, u.id);
294
+ const now = Math.floor(Date.now() / 1000);
295
+ const t = tamper;
296
+ tamper = undefined;
297
+ const claims = {
298
+ iss: "https://slack.com",
299
+ aud: clientId,
300
+ exp: now + 300,
301
+ iat: now,
302
+ auth_time: now,
303
+ nonce: c.nonce,
304
+ ...profileClaims(u),
305
+ ...t?.claims,
306
+ };
307
+ const idToken = signJwt(claims, t?.foreignKey ? foreign.privateKey : signing.privateKey, t?.kid ?? kid);
308
+ return reply(res, { ok: true, access_token: access, token_type: "Bearer", id_token: idToken });
309
+ };
310
+ const authorize = (res, u) => {
311
+ const q = u.searchParams;
312
+ const redirectUri = q.get("redirect_uri");
313
+ if (!redirectUri)
314
+ return reply(res, { error: "missing redirect_uri" }, 400);
315
+ const back = new URL(redirectUri);
316
+ const state = q.get("state");
317
+ if (state !== null)
318
+ back.searchParams.set("state", state);
319
+ const user = users.get(q.get("as") ?? "");
320
+ if (q.get("client_id") !== clientId || q.get("response_type") !== "code" || !user) {
321
+ back.searchParams.set("error", "access_denied");
322
+ }
323
+ else {
324
+ const code = `code-${randomUUID()}`;
325
+ codes.set(code, { userId: user.id, nonce: q.get("nonce") ?? "", redirectUri });
326
+ back.searchParams.set("code", code);
327
+ }
328
+ res.writeHead(302, { location: back.toString() }).end();
329
+ };
330
+ const server = createServer((req, res) => {
331
+ void (async () => {
332
+ const u = new URL(req.url ?? "/", "http://x");
333
+ const m = /^\/api\/([A-Za-z.]+)$/.exec(u.pathname);
334
+ if (m && req.method === "POST")
335
+ return api(req, res, m[1]);
336
+ if (u.pathname === "/openid/connect/authorize" && req.method === "GET")
337
+ return authorize(res, u);
338
+ if (u.pathname === "/openid/connect/keys" && req.method === "GET") {
339
+ const jwk = signing.publicKey.export({ format: "jwk" });
340
+ return reply(res, { keys: [{ ...jwk, kid, alg: "RS256", use: "sig" }] });
341
+ }
342
+ res.writeHead(404).end();
343
+ })().catch(() => {
344
+ if (!res.headersSent)
345
+ res.writeHead(500);
346
+ res.end();
347
+ });
348
+ });
349
+ const wss = new WebSocketServer({ noServer: true });
350
+ server.on("upgrade", (req, socket, head) => {
351
+ const u = new URL(req.url ?? "/", "http://x");
352
+ if (u.pathname !== "/link") {
353
+ socket.destroy();
354
+ return;
355
+ }
356
+ const app = ticketApp.get(Number(u.searchParams.get("ticket"))) ?? appToken;
357
+ wss.handleUpgrade(req, socket, head, (ws) => {
358
+ sockets.push(ws);
359
+ socketApp.set(ws, app);
360
+ if (req.socket.remotePort !== undefined)
361
+ peerPorts.set(ws, req.socket.remotePort);
362
+ peak = Math.max(peak, sockets.length);
363
+ peakBy.set(app, Math.max(peakBy.get(app) ?? 0, socketsOf(app).length));
364
+ ws.on("close", () => {
365
+ const i = sockets.indexOf(ws);
366
+ if (i >= 0)
367
+ sockets.splice(i, 1);
368
+ // Envelopes this socket never acked: retried on the app's next socket, or dropped
369
+ for (const [id, p] of pending) {
370
+ if (p.ws !== ws)
371
+ continue;
372
+ if (!redeliver) {
373
+ pending.delete(id);
374
+ continue;
375
+ }
376
+ p.ws = undefined;
377
+ p.attempt++;
378
+ }
379
+ flush(app);
380
+ });
381
+ ws.on("message", (data) => {
382
+ let msg;
383
+ try {
384
+ msg = JSON.parse(String(data));
385
+ }
386
+ catch {
387
+ return;
388
+ }
389
+ if (typeof msg.envelope_id !== "string")
390
+ return;
391
+ const a = {
392
+ envelopeId: msg.envelope_id,
393
+ payload: msg.payload,
394
+ at: Date.now(),
395
+ };
396
+ acks.push(a);
397
+ pending.delete(a.envelopeId);
398
+ for (const w of ackWaiters.get(a.envelopeId) ?? [])
399
+ w(a);
400
+ ackWaiters.delete(a.envelopeId);
401
+ });
402
+ ws.send(JSON.stringify({
403
+ type: "hello",
404
+ num_connections: sockets.length,
405
+ debug_info: { host: "fake-slack" },
406
+ connection_info: { app_id: "A0001" },
407
+ }));
408
+ if (dropLeft > 0) {
409
+ dropLeft--;
410
+ ws.close(1011);
411
+ return;
412
+ }
413
+ flush(app);
414
+ });
415
+ });
416
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
417
+ base = `http://127.0.0.1:${server.address().port}`;
418
+ function socketsOf(app) {
419
+ return sockets.filter((ws) => socketApp.get(ws) === app);
420
+ }
421
+ /** Delivers the app's queued envelopes (push order) to its most recently opened open socket. */
422
+ function flush(app) {
423
+ const ws = socketsOf(app)
424
+ .filter((s) => s.readyState === s.OPEN)
425
+ .at(-1);
426
+ if (!ws)
427
+ return;
428
+ for (const [envelope_id, p] of pending) {
429
+ if (p.app !== app || p.ws !== undefined)
430
+ continue;
431
+ p.ws = ws;
432
+ ws.send(JSON.stringify({
433
+ ...p.envelope,
434
+ envelope_id,
435
+ accepts_response_payload: false,
436
+ ...(p.attempt > 0 && { retry_attempt: p.attempt, retry_reason: "timeout" }),
437
+ }));
438
+ }
439
+ }
440
+ const appTokenOf = (target) => {
441
+ const app = target?.appToken ?? appToken;
442
+ if (!apps.has(app))
443
+ throw new Error(`fake slack: unknown app token ${app}`);
444
+ return app;
445
+ };
446
+ const push = (envelope, target) => {
447
+ const app = appTokenOf(target);
448
+ const envelope_id = envelope.envelope_id ?? `env-${++envSeq}`;
449
+ const { envelope_id: _, ...rest } = envelope;
450
+ pending.set(envelope_id, { app, envelope: rest, attempt: 0 });
451
+ flush(app);
452
+ return envelope_id;
453
+ };
454
+ return {
455
+ url: base,
456
+ apiUrl: `${base}/api/`,
457
+ teamId,
458
+ botUserId,
459
+ botId,
460
+ botToken,
461
+ appToken,
462
+ clientId,
463
+ clientSecret,
464
+ messages,
465
+ updates,
466
+ calls,
467
+ acks,
468
+ users,
469
+ connectionsOpened: (app) => (app === undefined ? opened : (openedBy.get(app) ?? 0)),
470
+ openSockets: (app) => (app === undefined ? sockets : socketsOf(app)).length,
471
+ maxConcurrentSockets: (app) => (app === undefined ? peak : (peakBy.get(app) ?? 0)),
472
+ pendingEnvelopes: (app) => [...pending.values()].filter((p) => app === undefined || p.app === app).length,
473
+ socketPeerPorts: (app) => (app === undefined ? sockets : socketsOf(app)).flatMap((ws) => {
474
+ const port = peerPorts.get(ws);
475
+ return port === undefined ? [] : [port];
476
+ }),
477
+ failNext(method, failure, count = 1) {
478
+ failures.push({ method, failure, left: count });
479
+ },
480
+ delay(method, ms) {
481
+ delays.set(method, ms);
482
+ },
483
+ dropNextConnections(count) {
484
+ dropLeft = count;
485
+ },
486
+ push,
487
+ pushEvent(event, o = {}) {
488
+ const app = appTokenOf(o);
489
+ return push({
490
+ type: "events_api",
491
+ payload: slackEventCallback(event, {
492
+ teamId: o.teamId ?? apps.get(app)?.teamId ?? teamId,
493
+ eventId: o.eventId ?? `Ev${++envSeq}`,
494
+ }),
495
+ }, { appToken: app });
496
+ },
497
+ pushInteractive(payload, target) {
498
+ return push({ type: "interactive", payload }, { appToken: appTokenOf(target) });
499
+ },
500
+ waitForAck(envelopeId, timeoutMs = 5000) {
501
+ const done = acks.find((a) => a.envelopeId === envelopeId);
502
+ if (done)
503
+ return Promise.resolve(done);
504
+ return new Promise((resolve, reject) => {
505
+ const t = setTimeout(() => reject(new Error(`fake slack: envelope ${envelopeId} not acked`)), timeoutMs);
506
+ const list = ackWaiters.get(envelopeId) ?? [];
507
+ list.push((a) => {
508
+ clearTimeout(t);
509
+ resolve(a);
510
+ });
511
+ ackWaiters.set(envelopeId, list);
512
+ });
513
+ },
514
+ acked: (id) => acks.some((a) => a.envelopeId === id),
515
+ disconnectAll(reason = "refresh_requested") {
516
+ for (const ws of [...sockets]) {
517
+ ws.send(JSON.stringify({ type: "disconnect", reason }));
518
+ ws.close(1000);
519
+ }
520
+ },
521
+ tamperNextIdToken(claims, o = {}) {
522
+ tamper = {
523
+ claims,
524
+ foreignKey: o.foreignKey ?? false,
525
+ ...(o.kid !== undefined && { kid: o.kid }),
526
+ };
527
+ },
528
+ finalText(m) {
529
+ return updates.filter((u) => u.channel === m.channel && u.ts === m.ts).at(-1)?.text ?? m.text;
530
+ },
531
+ finalBlocks(m) {
532
+ const u = updates.filter((x) => x.channel === m.channel && x.ts === m.ts).at(-1);
533
+ return u ? u.blocks : m.blocks;
534
+ },
535
+ async stop() {
536
+ for (const ws of [...sockets])
537
+ ws.terminate();
538
+ wss.close();
539
+ server.closeAllConnections();
540
+ await new Promise((r) => server.close(() => r()));
541
+ },
542
+ };
543
+ }
544
+ /** 私聊消息事件(`message.im`;默认频道 `D<user>`)。 */
545
+ export function slackDmEvent(user, text, opts = {}) {
546
+ return {
547
+ type: "message",
548
+ channel_type: "im",
549
+ channel: opts.channel ?? `D${user}`,
550
+ user,
551
+ text,
552
+ ts: opts.ts ?? `${1_750_000_000 + Math.floor(Math.random() * 1e6)}.000100`,
553
+ ...(opts.team !== undefined && { team: opts.team }),
554
+ ...opts.extra,
555
+ };
556
+ }
557
+ /** 频道里 @ 机器人的事件(`app_mention`;`threadTs` 给了表示在话题里)。 */
558
+ export function slackMentionEvent(user, botUserId, text, opts = {}) {
559
+ return {
560
+ type: "app_mention",
561
+ channel: opts.channel ?? "C0001",
562
+ user,
563
+ text: `<@${botUserId}> ${text}`,
564
+ ts: opts.ts ?? `${1_750_000_000 + Math.floor(Math.random() * 1e6)}.000200`,
565
+ ...(opts.threadTs !== undefined && { thread_ts: opts.threadTs }),
566
+ ...(opts.team !== undefined && { team: opts.team }),
567
+ };
568
+ }
569
+ /**
570
+ * 用户点了某条消息上的按钮(`block_actions`)。`message` 为假 Slack 记下的那条消息,`actionId` / `value`
571
+ * 取自其 blocks 里的按钮;`stateValues` 为 `state.values`(多选提交时带上勾选项)。
572
+ */
573
+ export function slackBlockAction(user, message, action, opts = {}) {
574
+ return {
575
+ type: "block_actions",
576
+ user: { id: user.id, team_id: user.teamId, username: user.name ?? user.id, name: user.name },
577
+ team: { id: user.teamId, domain: "fake" },
578
+ api_app_id: "A0001",
579
+ trigger_id: opts.triggerId ?? `trig-${Math.random().toString(36).slice(2)}`,
580
+ container: {
581
+ type: "message",
582
+ message_ts: message.ts,
583
+ channel_id: message.channel,
584
+ is_ephemeral: false,
585
+ ...(message.threadTs !== undefined && { thread_ts: message.threadTs }),
586
+ },
587
+ channel: { id: message.channel, name: message.channel.startsWith("D") ? "directmessage" : "c" },
588
+ message: {
589
+ type: "message",
590
+ ts: message.ts,
591
+ text: message.text,
592
+ blocks: message.blocks,
593
+ ...(message.threadTs !== undefined && { thread_ts: message.threadTs }),
594
+ },
595
+ state: { values: opts.stateValues ?? {} },
596
+ actions: [
597
+ {
598
+ type: "button",
599
+ action_id: action.actionId,
600
+ block_id: action.blockId ?? "b",
601
+ ...(action.value !== undefined && { value: action.value }),
602
+ action_ts: opts.actionTs ?? `${Date.now() / 1000}`,
603
+ },
604
+ ],
605
+ };
606
+ }
607
+ /** 在消息的 blocks 里找按钮(按 action_id 前缀或谓词),返回 `{ actionId, value, blockId }`。 */
608
+ export function findSlackButton(blocks, match) {
609
+ for (const b of blocks ?? []) {
610
+ const block = b;
611
+ if (block.type !== "actions")
612
+ continue;
613
+ for (const e of block.elements ?? []) {
614
+ const el = e;
615
+ if (el.type === "button" && match(el)) {
616
+ return {
617
+ actionId: el.action_id,
618
+ ...(el.value !== undefined && { value: el.value }),
619
+ ...(block.block_id !== undefined && { blockId: block.block_id }),
620
+ ...(el.url !== undefined && { url: el.url }),
621
+ };
622
+ }
623
+ }
624
+ }
625
+ return undefined;
626
+ }
627
+ /**
628
+ * An Events API `event_callback` body: what Slack posts to a webhook Request URL, and the payload
629
+ * of a Socket Mode `events_api` envelope (the same object, so both inbound modes see one shape).
630
+ */
631
+ export function slackEventCallback(event, opts = {}) {
632
+ return {
633
+ token: "verification-token",
634
+ team_id: opts.teamId ?? "T0001",
635
+ api_app_id: "A0001",
636
+ type: "event_callback",
637
+ event_id: opts.eventId ?? `Ev${Math.random().toString(36).slice(2, 10)}`,
638
+ event_time: Math.floor(Date.now() / 1000),
639
+ event,
640
+ };
641
+ }
642
+ /**
643
+ * Signs a Slack webhook request the way Slack does
644
+ * (https://docs.slack.dev/authentication/verifying-requests-from-slack/): returns the
645
+ * `x-slack-request-timestamp` and `x-slack-signature` headers, where the signature is
646
+ * `v0=` + hex HMAC-SHA256(signingSecret, `v0:<ts>:<raw body>`). `ts` is in seconds.
647
+ */
648
+ export function signSlackWebhook(body, signingSecret, ts) {
649
+ const raw = typeof body === "string" ? Buffer.from(body) : Buffer.from(body);
650
+ const mac = createHmac("sha256", signingSecret).update(`v0:${ts}:`).update(raw).digest("hex");
651
+ return { "x-slack-request-timestamp": String(ts), "x-slack-signature": `v0=${mac}` };
652
+ }
653
+ /** The form body Slack posts for an interactive payload (`payload=<url-encoded JSON>`). */
654
+ export const slackInteractiveForm = (payload) => new URLSearchParams({ payload: JSON.stringify(payload) }).toString();