@openclaw/crabline 0.1.12 → 0.1.13

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 (53) hide show
  1. package/README.md +38 -11
  2. package/dist/src/cli/program.js +17 -2
  3. package/dist/src/cli/program.js.map +1 -1
  4. package/dist/src/index.d.ts +12 -10
  5. package/dist/src/index.js +6 -5
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/openclaw/artifact-generation.d.ts +5 -9
  8. package/dist/src/openclaw/artifact-generation.js +41 -69
  9. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  10. package/dist/src/openclaw/bridges/discord.d.ts +1 -0
  11. package/dist/src/openclaw/bridges/discord.js +149 -0
  12. package/dist/src/openclaw/bridges/discord.js.map +1 -0
  13. package/dist/src/openclaw/bridges/matrix.js +1 -1
  14. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  15. package/dist/src/openclaw/bridges/mattermost.js +1 -1
  16. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  17. package/dist/src/openclaw/bridges/signal.js +1 -1
  18. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  19. package/dist/src/openclaw/bridges/slack.js +1 -1
  20. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  21. package/dist/src/openclaw/bridges/telegram.js +1 -1
  22. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  23. package/dist/src/openclaw/bridges/whatsapp.js +1 -1
  24. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  25. package/dist/src/openclaw/bridges/zalo.js +1 -1
  26. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  27. package/dist/src/openclaw/outbound-contract.js +3 -0
  28. package/dist/src/openclaw/outbound-contract.js.map +1 -1
  29. package/dist/src/openclaw/{smoke-lock.d.ts → provider-readiness-lock.d.ts} +7 -7
  30. package/dist/src/openclaw/{smoke-lock.js → provider-readiness-lock.js} +122 -260
  31. package/dist/src/openclaw/provider-readiness-lock.js.map +1 -0
  32. package/dist/src/openclaw/shared.d.ts +9 -17
  33. package/dist/src/openclaw/shared.js +5 -6
  34. package/dist/src/openclaw/shared.js.map +1 -1
  35. package/dist/src/openclaw.d.ts +3 -5
  36. package/dist/src/openclaw.js +22 -20
  37. package/dist/src/openclaw.js.map +1 -1
  38. package/dist/src/servers/discord.d.ts +40 -0
  39. package/dist/src/servers/discord.js +842 -0
  40. package/dist/src/servers/discord.js.map +1 -0
  41. package/dist/src/servers/index.d.ts +10 -4
  42. package/dist/src/servers/index.js +4 -0
  43. package/dist/src/servers/index.js.map +1 -1
  44. package/dist/src/servers/slack.js +11 -3
  45. package/dist/src/servers/slack.js.map +1 -1
  46. package/dist/src/servers/telegram.js +51 -2
  47. package/dist/src/servers/telegram.js.map +1 -1
  48. package/dist/src/servers/whatsapp.d.ts +0 -1
  49. package/dist/src/servers/whatsapp.js +0 -1
  50. package/dist/src/servers/whatsapp.js.map +1 -1
  51. package/docs/channel-setup.md +66 -21
  52. package/package.json +1 -1
  53. package/dist/src/openclaw/smoke-lock.js.map +0 -1
@@ -0,0 +1,842 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { WebSocket, WebSocketServer } from "ws";
4
+ import { adminAuthError, constantTimeTokenEqual, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, parseUnknownRequestBody, queryRecord, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
5
+ import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
6
+ import { closeWebSocketServer } from "./websocket.js";
7
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 45_000;
8
+ const DEFAULT_IDENTIFY_TIMEOUT_MS = 10_000;
9
+ const DEFAULT_MAX_GATEWAY_PAYLOAD_BYTES = 4_096;
10
+ const DISCORD_EPOCH_MS = 1420070400000n;
11
+ const DISCORD_ID_PATTERN = /^\d{17,20}$/u;
12
+ function discordSnowflake(seed = 0) {
13
+ const timestamp = BigInt(Math.max(Date.now(), Number(DISCORD_EPOCH_MS))) - DISCORD_EPOCH_MS;
14
+ return ((timestamp << 22n) | BigInt(seed & 0x3f_ffff)).toString();
15
+ }
16
+ export function discordDirectChannelId(botUserId, recipientId) {
17
+ if (!DISCORD_ID_PATTERN.test(botUserId) || !DISCORD_ID_PATTERN.test(recipientId)) {
18
+ throw new Error("Discord direct channel participants must be Discord snowflakes.");
19
+ }
20
+ const digest = createHash("sha256")
21
+ .update([botUserId, recipientId].sort().join(":"))
22
+ .digest("hex");
23
+ return (100000000000000000n +
24
+ (BigInt(`0x${digest.slice(0, 16)}`) % 900000000000000000n)).toString();
25
+ }
26
+ function requireSnowflake(value, label) {
27
+ const id = readTrimmedString(value);
28
+ if (!id || !DISCORD_ID_PATTERN.test(id)) {
29
+ throw new DiscordRequestError(400, 50_035, `${label} must be a Discord snowflake.`);
30
+ }
31
+ return id;
32
+ }
33
+ function positiveInteger(value, fallback, label) {
34
+ const resolved = value ?? fallback;
35
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
36
+ throw new RangeError(`${label} must be a positive safe integer.`);
37
+ }
38
+ return resolved;
39
+ }
40
+ class DiscordRequestError extends Error {
41
+ status;
42
+ code;
43
+ constructor(status, code, message) {
44
+ super(message);
45
+ this.status = status;
46
+ this.code = code;
47
+ this.name = "DiscordRequestError";
48
+ }
49
+ }
50
+ function discordError(status, code, message) {
51
+ return discordJson({ code, message }, status);
52
+ }
53
+ function rateLimitHeaders(headers) {
54
+ return new Headers({
55
+ "x-ratelimit-bucket": "crabline-discord",
56
+ "x-ratelimit-limit": "1000",
57
+ "x-ratelimit-remaining": "999",
58
+ "x-ratelimit-reset-after": "0",
59
+ ...Object.fromEntries(new Headers(headers)),
60
+ });
61
+ }
62
+ function discordJson(value, status = 200) {
63
+ return new Response(JSON.stringify(value), {
64
+ headers: rateLimitHeaders({ "content-type": "application/json" }),
65
+ status,
66
+ });
67
+ }
68
+ function discordEmpty(status = 204) {
69
+ return new Response(null, { headers: rateLimitHeaders(), status });
70
+ }
71
+ function authorizationError(request, state) {
72
+ const authorization = request.headers.authorization?.trim();
73
+ if (!authorization?.startsWith("Bot ")) {
74
+ return new Response(JSON.stringify({ code: 0, message: "401: Unauthorized" }), {
75
+ headers: rateLimitHeaders({
76
+ "content-type": "application/json",
77
+ "www-authenticate": 'Bot realm="Discord"',
78
+ }),
79
+ status: 401,
80
+ });
81
+ }
82
+ const token = authorization.slice(4);
83
+ return constantTimeTokenEqual(token, state.botToken)
84
+ ? null
85
+ : new Response(JSON.stringify({ code: 0, message: "401: Unauthorized" }), {
86
+ headers: rateLimitHeaders({ "content-type": "application/json" }),
87
+ status: 401,
88
+ });
89
+ }
90
+ function discordUser(id, username, bot = false) {
91
+ return {
92
+ avatar: null,
93
+ ...(bot ? { bot: true } : {}),
94
+ discriminator: "0",
95
+ global_name: username,
96
+ id,
97
+ username,
98
+ };
99
+ }
100
+ function ensureGuild(state, guildId) {
101
+ const current = state.guilds.get(guildId);
102
+ if (current) {
103
+ return current;
104
+ }
105
+ const guild = {
106
+ afk_timeout: 300,
107
+ channels: [],
108
+ emojis: [],
109
+ features: [],
110
+ id: guildId,
111
+ joined_at: new Date().toISOString(),
112
+ large: false,
113
+ member_count: 1,
114
+ members: [
115
+ {
116
+ deaf: false,
117
+ joined_at: new Date().toISOString(),
118
+ mute: false,
119
+ pending: false,
120
+ roles: [],
121
+ user: state.botUser,
122
+ },
123
+ ],
124
+ name: "Crabline Discord Guild",
125
+ owner_id: state.botUser.id,
126
+ presences: [],
127
+ roles: [
128
+ {
129
+ color: 0,
130
+ hoist: false,
131
+ id: guildId,
132
+ managed: false,
133
+ mentionable: false,
134
+ name: "@everyone",
135
+ permissions: "0",
136
+ position: 0,
137
+ },
138
+ ],
139
+ unavailable: false,
140
+ voice_states: [],
141
+ };
142
+ state.guilds.set(guildId, guild);
143
+ return guild;
144
+ }
145
+ function ensureChannel(params) {
146
+ const current = params.state.channels.get(params.channelId);
147
+ if (current) {
148
+ return current;
149
+ }
150
+ const channel = params.guildId
151
+ ? {
152
+ guild_id: params.guildId,
153
+ id: params.channelId,
154
+ last_message_id: null,
155
+ name: params.parentId ? "crabline-thread" : "crabline-channel",
156
+ parent_id: params.parentId ?? null,
157
+ type: params.parentId ? 11 : 0,
158
+ }
159
+ : {
160
+ id: params.channelId,
161
+ last_message_id: null,
162
+ recipients: params.sender ? [params.sender] : [],
163
+ type: 1,
164
+ };
165
+ params.state.channels.set(params.channelId, channel);
166
+ if (params.guildId) {
167
+ const guild = ensureGuild(params.state, params.guildId);
168
+ const channels = Array.isArray(guild.channels) ? guild.channels : [];
169
+ guild.channels = [...channels, channel];
170
+ }
171
+ return channel;
172
+ }
173
+ function parseMentions(content, state) {
174
+ const ids = new Set();
175
+ for (const match of content.matchAll(/<@!?(\d{17,20})>/gu)) {
176
+ ids.add(match[1]);
177
+ }
178
+ return [...ids].map((id) => id === state.botUser.id ? state.botUser : discordUser(id, `user-${id.slice(-6)}`));
179
+ }
180
+ function resolveMessageReference(state, channelId, value) {
181
+ if (!isJsonObject(value)) {
182
+ return undefined;
183
+ }
184
+ const messageId = readTrimmedString(value.message_id);
185
+ if (!messageId) {
186
+ return undefined;
187
+ }
188
+ const referencedChannelId = readTrimmedString(value.channel_id) ?? channelId;
189
+ const channel = state.channels.get(referencedChannelId);
190
+ return {
191
+ channel_id: referencedChannelId,
192
+ ...(channel?.guild_id ? { guild_id: channel.guild_id } : {}),
193
+ message_id: messageId,
194
+ };
195
+ }
196
+ function findMessage(state, channelId, messageId) {
197
+ return state.messages.get(channelId)?.find((message) => message.id === messageId);
198
+ }
199
+ function createMessage(params) {
200
+ const content = typeof params.body.content === "string" ? params.body.content : "";
201
+ const embeds = Array.isArray(params.body.embeds) ? params.body.embeds : [];
202
+ const components = Array.isArray(params.body.components) ? params.body.components : [];
203
+ const attachments = Array.isArray(params.body.attachments) ? params.body.attachments : [];
204
+ if (!content && embeds.length === 0 && components.length === 0 && attachments.length === 0) {
205
+ throw new DiscordRequestError(400, 50_006, "Cannot send an empty message");
206
+ }
207
+ if (content.length > 2_000) {
208
+ throw new DiscordRequestError(400, 50_035, "Invalid Form Body");
209
+ }
210
+ const reference = resolveMessageReference(params.state, params.channel.id, params.body.message_reference);
211
+ const referencedMessage = reference
212
+ ? (findMessage(params.state, reference.channel_id, reference.message_id) ?? null)
213
+ : undefined;
214
+ const failIfNotExists = isJsonObject(params.body.message_reference)
215
+ ? params.body.message_reference.fail_if_not_exists
216
+ : undefined;
217
+ if (reference && referencedMessage === null && failIfNotExists !== false) {
218
+ throw new DiscordRequestError(404, 10_008, "Unknown Message");
219
+ }
220
+ const message = {
221
+ ...(params.body.allowed_mentions !== undefined
222
+ ? { allowed_mentions: params.body.allowed_mentions }
223
+ : {}),
224
+ attachments,
225
+ author: params.author,
226
+ channel_id: params.channel.id,
227
+ components,
228
+ content,
229
+ edited_timestamp: null,
230
+ embeds,
231
+ flags: typeof params.body.flags === "number" ? params.body.flags : 0,
232
+ ...(params.channel.guild_id ? { guild_id: params.channel.guild_id } : {}),
233
+ id: discordSnowflake(params.state.nextSequence),
234
+ mention_everyone: /@(everyone|here)\b/u.test(content),
235
+ mention_roles: [...content.matchAll(/<@&(\d{17,20})>/gu)].map((match) => match[1]),
236
+ mentions: parseMentions(content, params.state),
237
+ ...(reference
238
+ ? {
239
+ message_reference: reference,
240
+ ...(referencedMessage !== undefined ? { referenced_message: referencedMessage } : {}),
241
+ }
242
+ : {}),
243
+ ...(typeof params.body.nonce === "string" || typeof params.body.nonce === "number"
244
+ ? { nonce: params.body.nonce }
245
+ : {}),
246
+ pinned: false,
247
+ timestamp: new Date().toISOString(),
248
+ tts: params.body.tts === true,
249
+ type: reference ? 19 : 0,
250
+ };
251
+ params.state.messages.set(params.channel.id, [
252
+ ...(params.state.messages.get(params.channel.id) ?? []),
253
+ message,
254
+ ]);
255
+ params.channel.last_message_id = message.id;
256
+ return message;
257
+ }
258
+ function sendGatewayPayload(socket, payload) {
259
+ if (socket.readyState !== WebSocket.OPEN) {
260
+ return false;
261
+ }
262
+ socket.send(JSON.stringify(payload));
263
+ return true;
264
+ }
265
+ function dispatchGatewayEvent(state, type, data) {
266
+ const payload = { d: data, op: 0, s: state.nextSequence++, t: type };
267
+ for (const client of state.gatewayClients) {
268
+ if (client.identified) {
269
+ sendGatewayPayload(client.socket, payload);
270
+ if (client.sessionId) {
271
+ const session = state.sessions.get(client.sessionId);
272
+ if (session) {
273
+ session.lastSequence = payload.s;
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+ function recordGatewayEvent(state, body, accepted) {
280
+ const pending = recordCommittedServerEvent({
281
+ event: {
282
+ accepted,
283
+ at: new Date().toISOString(),
284
+ body,
285
+ method: "WS",
286
+ path: "/gateway",
287
+ query: {},
288
+ type: "api",
289
+ },
290
+ onEvent: state.onEvent,
291
+ recorderPath: state.recorderPath,
292
+ });
293
+ state.pendingRecorderEvents.add(pending);
294
+ void pending.then(() => state.pendingRecorderEvents.delete(pending), () => state.pendingRecorderEvents.delete(pending));
295
+ }
296
+ function readyPayload(state, sessionId) {
297
+ return {
298
+ application: { flags: 0, id: state.applicationId },
299
+ guilds: [...state.guilds.values()].map((guild) => ({ id: guild.id, unavailable: true })),
300
+ resume_gateway_url: state.gatewayUrl,
301
+ session_id: sessionId,
302
+ user: state.botUser,
303
+ v: 10,
304
+ };
305
+ }
306
+ function attachGatewayServer(params) {
307
+ const gatewayServer = new WebSocketServer({
308
+ maxPayload: params.state.maxGatewayPayloadBytes,
309
+ noServer: true,
310
+ });
311
+ const onUpgrade = (request, socket, head) => {
312
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
313
+ if (url.pathname !== "/gateway") {
314
+ socket.destroy();
315
+ return;
316
+ }
317
+ if ((url.searchParams.get("v") ?? "10") !== "10") {
318
+ socket.destroy();
319
+ return;
320
+ }
321
+ const encoding = url.searchParams.get("encoding") ?? "json";
322
+ if (encoding !== "json") {
323
+ socket.destroy();
324
+ return;
325
+ }
326
+ gatewayServer.handleUpgrade(request, socket, head, (client) => {
327
+ gatewayServer.emit("connection", client, request);
328
+ });
329
+ };
330
+ params.server.on("upgrade", onUpgrade);
331
+ gatewayServer.on("connection", (socket) => {
332
+ const client = { identified: false, socket };
333
+ params.state.gatewayClients.add(client);
334
+ sendGatewayPayload(socket, {
335
+ d: { heartbeat_interval: params.state.heartbeatIntervalMs },
336
+ op: 10,
337
+ });
338
+ const identifyTimeout = setTimeout(() => {
339
+ if (!client.identified) {
340
+ socket.close(4_003, "Not authenticated");
341
+ }
342
+ }, params.state.identifyTimeoutMs);
343
+ identifyTimeout.unref();
344
+ socket.on("message", (raw) => {
345
+ if (Buffer.byteLength(raw.toString()) > params.state.maxGatewayPayloadBytes) {
346
+ socket.close(4_002, "Decode error");
347
+ return;
348
+ }
349
+ let payload;
350
+ try {
351
+ payload = JSON.parse(raw.toString());
352
+ }
353
+ catch {
354
+ socket.close(4_002, "Decode error");
355
+ return;
356
+ }
357
+ if (!isJsonObject(payload) || typeof payload.op !== "number") {
358
+ socket.close(4_002, "Decode error");
359
+ return;
360
+ }
361
+ recordGatewayEvent(params.state, payload, true);
362
+ if (payload.op === 1) {
363
+ sendGatewayPayload(socket, { d: null, op: 11 });
364
+ return;
365
+ }
366
+ if (payload.op === 2) {
367
+ if (client.identified || !isJsonObject(payload.d)) {
368
+ socket.close(4_005, "Already authenticated");
369
+ return;
370
+ }
371
+ const token = readTrimmedString(payload.d.token);
372
+ if (!token || !constantTimeTokenEqual(token, params.state.botToken)) {
373
+ socket.close(4_004, "Authentication failed");
374
+ return;
375
+ }
376
+ clearTimeout(identifyTimeout);
377
+ client.identified = true;
378
+ client.sessionId = randomUUID();
379
+ params.state.sessions.set(client.sessionId, { lastSequence: params.state.nextSequence });
380
+ if (params.state.sessions.size > 128) {
381
+ const oldestSessionId = params.state.sessions.keys().next().value;
382
+ if (oldestSessionId) {
383
+ params.state.sessions.delete(oldestSessionId);
384
+ }
385
+ }
386
+ sendGatewayPayload(socket, {
387
+ d: readyPayload(params.state, client.sessionId),
388
+ op: 0,
389
+ s: params.state.nextSequence++,
390
+ t: "READY",
391
+ });
392
+ for (const guild of params.state.guilds.values()) {
393
+ dispatchGatewayEvent(params.state, "GUILD_CREATE", guild);
394
+ }
395
+ return;
396
+ }
397
+ if (payload.op === 6) {
398
+ if (!isJsonObject(payload.d)) {
399
+ sendGatewayPayload(socket, { d: false, op: 9 });
400
+ return;
401
+ }
402
+ const token = readTrimmedString(payload.d.token);
403
+ const sessionId = readTrimmedString(payload.d.session_id);
404
+ const session = sessionId ? params.state.sessions.get(sessionId) : undefined;
405
+ if (!token ||
406
+ !constantTimeTokenEqual(token, params.state.botToken) ||
407
+ !sessionId ||
408
+ !Number.isSafeInteger(payload.d.seq) ||
409
+ payload.d.seq < 0 ||
410
+ !session ||
411
+ payload.d.seq !== session.lastSequence) {
412
+ sendGatewayPayload(socket, { d: false, op: 9 });
413
+ return;
414
+ }
415
+ clearTimeout(identifyTimeout);
416
+ client.identified = true;
417
+ client.sessionId = sessionId;
418
+ params.state.sessions.delete(sessionId);
419
+ params.state.sessions.set(sessionId, session);
420
+ const resumedSequence = params.state.nextSequence++;
421
+ session.lastSequence = resumedSequence;
422
+ sendGatewayPayload(socket, {
423
+ d: {},
424
+ op: 0,
425
+ s: resumedSequence,
426
+ t: "RESUMED",
427
+ });
428
+ return;
429
+ }
430
+ if (payload.op === 3 || payload.op === 4 || payload.op === 8) {
431
+ return;
432
+ }
433
+ socket.close(4_001, "Unknown opcode");
434
+ });
435
+ socket.on("error", () => undefined);
436
+ socket.once("close", () => {
437
+ clearTimeout(identifyTimeout);
438
+ params.state.gatewayClients.delete(client);
439
+ });
440
+ });
441
+ return async () => {
442
+ params.server.off("upgrade", onUpgrade);
443
+ await closeWebSocketServer(gatewayServer);
444
+ };
445
+ }
446
+ function commandBody(state, body, existing, guildId) {
447
+ const name = readTrimmedString(body.name ?? existing?.name);
448
+ if (!name || name.length > 32 || !/^[\w-]+$/u.test(name)) {
449
+ throw new DiscordRequestError(400, 50_035, "Invalid Form Body");
450
+ }
451
+ return {
452
+ ...existing,
453
+ ...body,
454
+ application_id: state.applicationId,
455
+ description: readTrimmedString(body.description ?? existing?.description) ?? "",
456
+ ...(guildId ? { guild_id: guildId } : {}),
457
+ id: existing?.id ?? discordSnowflake(state.nextSequence++),
458
+ name,
459
+ type: typeof body.type === "number" ? body.type : (existing?.type ?? 1),
460
+ version: discordSnowflake(state.nextSequence++),
461
+ };
462
+ }
463
+ async function handleAdminInbound(params) {
464
+ const requestedChannelId = requireSnowflake(params.body.channelId, "channelId");
465
+ const guildId = params.body.guildId === undefined
466
+ ? undefined
467
+ : requireSnowflake(params.body.guildId, "guildId");
468
+ const senderId = requireSnowflake(params.body.senderId, "senderId");
469
+ const parentChannelId = params.body.parentChannelId === undefined
470
+ ? undefined
471
+ : requireSnowflake(params.body.parentChannelId, "parentChannelId");
472
+ if (parentChannelId && !guildId) {
473
+ throw new DiscordRequestError(400, 50_035, "Threads require guildId.");
474
+ }
475
+ const sender = discordUser(senderId, readTrimmedString(params.body.senderName) ?? `user-${senderId.slice(-6)}`);
476
+ const hadChannel = params.state.channels.has(requestedChannelId);
477
+ const hadGuild = guildId ? params.state.guilds.has(guildId) : true;
478
+ const channel = ensureChannel({
479
+ channelId: requestedChannelId,
480
+ ...(guildId ? { guildId } : {}),
481
+ ...(parentChannelId ? { parentId: parentChannelId } : {}),
482
+ sender,
483
+ state: params.state,
484
+ });
485
+ const message = createMessage({
486
+ author: sender,
487
+ body: params.body,
488
+ channel,
489
+ state: params.state,
490
+ });
491
+ if (guildId) {
492
+ const guild = params.state.guilds.get(guildId);
493
+ const members = Array.isArray(guild.members) ? guild.members : [];
494
+ if (!members.some((member) => isJsonObject(member) && isJsonObject(member.user) && member.user.id === sender.id)) {
495
+ members.push({
496
+ deaf: false,
497
+ joined_at: new Date().toISOString(),
498
+ mute: false,
499
+ pending: false,
500
+ roles: [],
501
+ user: sender,
502
+ });
503
+ guild.members = members;
504
+ guild.member_count = members.length;
505
+ }
506
+ message.member = {
507
+ deaf: false,
508
+ joined_at: new Date().toISOString(),
509
+ mute: false,
510
+ pending: false,
511
+ roles: [],
512
+ };
513
+ }
514
+ if (!hadGuild && guildId) {
515
+ dispatchGatewayEvent(params.state, "GUILD_CREATE", params.state.guilds.get(guildId));
516
+ }
517
+ if (!hadChannel) {
518
+ dispatchGatewayEvent(params.state, "CHANNEL_CREATE", channel);
519
+ }
520
+ dispatchGatewayEvent(params.state, "MESSAGE_CREATE", message);
521
+ return discordJson({ event: { d: message, op: 0, t: "MESSAGE_CREATE" }, message });
522
+ }
523
+ async function handleDiscordApi(params) {
524
+ const pathParts = params.pathname.split("/").filter(Boolean);
525
+ if (pathParts[0] !== "api" || pathParts[1] !== "v10") {
526
+ return discordError(404, 0, "404: Not Found");
527
+ }
528
+ const route = pathParts.slice(2);
529
+ if (params.method === "GET" && route.join("/") === "users/@me") {
530
+ return discordJson(params.state.botUser);
531
+ }
532
+ if (params.method === "GET" && route.join("/") === "oauth2/applications/@me") {
533
+ return discordJson({
534
+ bot: params.state.botUser,
535
+ flags: 0,
536
+ id: params.state.applicationId,
537
+ name: "Crabline Discord Application",
538
+ owner: params.state.botUser,
539
+ verify_key: "crabline",
540
+ });
541
+ }
542
+ if (params.method === "GET" && route.join("/") === "gateway") {
543
+ return discordJson({ url: params.state.gatewayUrl });
544
+ }
545
+ if (params.method === "GET" && route.join("/") === "gateway/bot") {
546
+ return discordJson({
547
+ session_start_limit: {
548
+ max_concurrency: 1,
549
+ remaining: 1_000,
550
+ reset_after: 0,
551
+ total: 1_000,
552
+ },
553
+ shards: 1,
554
+ url: params.state.gatewayUrl,
555
+ });
556
+ }
557
+ if (route[0] === "channels" && route.length >= 2) {
558
+ const channelId = requireSnowflake(route[1], "channel id");
559
+ const channel = params.state.channels.get(channelId);
560
+ if (route.length === 2 && params.method === "GET") {
561
+ return channel ? discordJson(channel) : discordError(404, 10_003, "Unknown Channel");
562
+ }
563
+ if (route[2] === "typing" && route.length === 3 && params.method === "POST") {
564
+ return channel ? discordEmpty() : discordError(404, 10_003, "Unknown Channel");
565
+ }
566
+ if (route[2] === "messages") {
567
+ if (!channel) {
568
+ return discordError(404, 10_003, "Unknown Channel");
569
+ }
570
+ if (route.length === 3 && params.method === "POST") {
571
+ if (!isJsonObject(params.body)) {
572
+ throw new InvalidJsonBodyError(undefined, "Request body must be a JSON object.");
573
+ }
574
+ const message = createMessage({
575
+ author: params.state.botUser,
576
+ body: params.body,
577
+ channel,
578
+ state: params.state,
579
+ });
580
+ dispatchGatewayEvent(params.state, "MESSAGE_CREATE", message);
581
+ return discordJson(message);
582
+ }
583
+ if (route.length === 3 && params.method === "GET") {
584
+ return discordJson([...(params.state.messages.get(channelId) ?? [])].reverse().slice(0, 50));
585
+ }
586
+ const messageId = route[3] ? requireSnowflake(route[3], "message id") : undefined;
587
+ if (messageId &&
588
+ route[4] === "reactions" &&
589
+ route[5] &&
590
+ route[6] === "@me" &&
591
+ (params.method === "PUT" || params.method === "DELETE")) {
592
+ return findMessage(params.state, channelId, messageId)
593
+ ? discordEmpty()
594
+ : discordError(404, 10_008, "Unknown Message");
595
+ }
596
+ if (messageId && params.method === "GET") {
597
+ const message = findMessage(params.state, channelId, messageId);
598
+ return message ? discordJson(message) : discordError(404, 10_008, "Unknown Message");
599
+ }
600
+ }
601
+ }
602
+ if (params.method === "POST" && route.join("/") === "users/@me/channels") {
603
+ if (!isJsonObject(params.body)) {
604
+ throw new InvalidJsonBodyError(undefined, "Request body must be a JSON object.");
605
+ }
606
+ const recipientId = requireSnowflake(params.body.recipient_id, "recipient_id");
607
+ const recipient = discordUser(recipientId, `user-${recipientId.slice(-6)}`);
608
+ const channelId = discordDirectChannelId(params.state.botUser.id, recipientId);
609
+ return discordJson(ensureChannel({ channelId, sender: recipient, state: params.state }));
610
+ }
611
+ if (route[0] === "guilds" && route[1]) {
612
+ const guildId = requireSnowflake(route[1], "guild id");
613
+ const guild = params.state.guilds.get(guildId);
614
+ if (!guild) {
615
+ return discordError(404, 10_004, "Unknown Guild");
616
+ }
617
+ if (route.length === 2 && params.method === "GET") {
618
+ return discordJson(guild);
619
+ }
620
+ if (route[2] === "channels" && params.method === "GET") {
621
+ return discordJson([...params.state.channels.values()].filter((channel) => channel.guild_id === guildId));
622
+ }
623
+ if (route[2] === "roles" && params.method === "GET") {
624
+ return discordJson(guild.roles ?? []);
625
+ }
626
+ if (route[2] === "members" && route[3] && params.method === "GET") {
627
+ const userId = requireSnowflake(route[3], "user id");
628
+ const user = userId === params.state.botUser.id
629
+ ? params.state.botUser
630
+ : discordUser(userId, `user-${userId.slice(-6)}`);
631
+ return discordJson({
632
+ deaf: false,
633
+ joined_at: new Date().toISOString(),
634
+ mute: false,
635
+ pending: false,
636
+ roles: [],
637
+ user,
638
+ });
639
+ }
640
+ }
641
+ if (route[0] === "applications" && route[1] === params.state.applicationId) {
642
+ let commandOffset = 2;
643
+ let guildId;
644
+ if (route[2] === "guilds" && route[3] && route[4] === "commands") {
645
+ guildId = requireSnowflake(route[3], "guild id");
646
+ commandOffset = 4;
647
+ }
648
+ if (route[commandOffset] === "commands") {
649
+ const commands = guildId
650
+ ? (params.state.guildCommands.get(guildId) ?? new Map())
651
+ : params.state.commands;
652
+ if (guildId && !params.state.guildCommands.has(guildId)) {
653
+ params.state.guildCommands.set(guildId, commands);
654
+ }
655
+ const commandId = route[commandOffset + 1];
656
+ if (!commandId && params.method === "GET") {
657
+ return discordJson([...commands.values()]);
658
+ }
659
+ if (!commandId && params.method === "PUT") {
660
+ if (!Array.isArray(params.body) || params.body.some((entry) => !isJsonObject(entry))) {
661
+ throw new DiscordRequestError(400, 50_035, "Invalid Form Body");
662
+ }
663
+ commands.clear();
664
+ for (const body of params.body) {
665
+ const command = commandBody(params.state, body, undefined, guildId);
666
+ commands.set(command.id, command);
667
+ }
668
+ return discordJson([...commands.values()]);
669
+ }
670
+ if (!commandId && params.method === "POST") {
671
+ if (!isJsonObject(params.body)) {
672
+ throw new DiscordRequestError(400, 50_035, "Invalid Form Body");
673
+ }
674
+ const name = readTrimmedString(params.body.name);
675
+ const type = typeof params.body.type === "number" ? params.body.type : 1;
676
+ const existing = [...commands.values()].find((command) => command.name === name && command.type === type);
677
+ const command = commandBody(params.state, params.body, existing, guildId);
678
+ commands.set(command.id, command);
679
+ return discordJson(command, existing ? 200 : 201);
680
+ }
681
+ if (commandId) {
682
+ const existing = commands.get(commandId);
683
+ if (!existing) {
684
+ return discordError(404, 10_063, "Unknown application command");
685
+ }
686
+ if (params.method === "PATCH") {
687
+ if (!isJsonObject(params.body)) {
688
+ throw new DiscordRequestError(400, 50_035, "Invalid Form Body");
689
+ }
690
+ const command = commandBody(params.state, params.body, existing, guildId);
691
+ commands.set(commandId, command);
692
+ return discordJson(command);
693
+ }
694
+ if (params.method === "DELETE") {
695
+ commands.delete(commandId);
696
+ return discordEmpty();
697
+ }
698
+ }
699
+ }
700
+ }
701
+ return discordError(404, 0, "404: Not Found");
702
+ }
703
+ async function handleRequest(params) {
704
+ const url = new URL(params.request.url ?? "/", "http://127.0.0.1");
705
+ const method = params.request.method ?? "GET";
706
+ if (url.pathname === "/crabline/discord/inbound") {
707
+ if (method !== "POST") {
708
+ return new Response("not found", { status: 404 });
709
+ }
710
+ if (!hasAdminToken(params.request, params.state.adminToken)) {
711
+ params.request.resume();
712
+ return adminAuthError();
713
+ }
714
+ const body = await parseUnknownRequestBody(params.request);
715
+ if (!isJsonObject(body)) {
716
+ throw new InvalidJsonBodyError(undefined, "Request body must be a JSON object.");
717
+ }
718
+ const event = {
719
+ at: new Date().toISOString(),
720
+ body,
721
+ method,
722
+ path: url.pathname,
723
+ query: queryRecord(url),
724
+ type: "admin",
725
+ };
726
+ const response = await handleAdminInbound({ body, state: params.state });
727
+ event.accepted = response.ok;
728
+ await recordCommittedServerEvent({
729
+ event,
730
+ onEvent: params.state.onEvent,
731
+ recorderPath: params.state.recorderPath,
732
+ });
733
+ return response;
734
+ }
735
+ const authError = method === "GET" && url.pathname === "/api/v10/gateway"
736
+ ? null
737
+ : authorizationError(params.request, params.state);
738
+ if (authError) {
739
+ params.request.resume();
740
+ return authError;
741
+ }
742
+ const body = method === "GET" || method === "DELETE" ? {} : await parseUnknownRequestBody(params.request);
743
+ const event = {
744
+ at: new Date().toISOString(),
745
+ ...(isJsonObject(body) && Object.keys(body).length > 0 ? { body } : {}),
746
+ method,
747
+ path: url.pathname,
748
+ query: queryRecord(url),
749
+ type: "api",
750
+ };
751
+ const response = await handleDiscordApi({
752
+ body,
753
+ method,
754
+ pathname: url.pathname,
755
+ state: params.state,
756
+ });
757
+ event.accepted = response.ok;
758
+ await (response.ok ? recordCommittedServerEvent : recordServerEvent)({
759
+ event,
760
+ onEvent: params.state.onEvent,
761
+ recorderPath: params.state.recorderPath,
762
+ });
763
+ return response;
764
+ }
765
+ export async function startDiscordServer(params = {}) {
766
+ const host = params.host ?? "127.0.0.1";
767
+ const applicationId = params.applicationId ?? "135000000000000001";
768
+ const botUserId = params.botUserId ?? applicationId;
769
+ requireSnowflake(applicationId, "applicationId");
770
+ requireSnowflake(botUserId, "botUserId");
771
+ const encodedApplicationId = Buffer.from(applicationId, "utf8").toString("base64url");
772
+ const externallyBound = !isLoopbackHost(host);
773
+ const state = {
774
+ adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
775
+ applicationId,
776
+ botToken: params.botToken ??
777
+ (externallyBound
778
+ ? `${encodedApplicationId}.${randomBytes(6).toString("base64url")}.${randomBytes(24).toString("base64url")}`
779
+ : `${encodedApplicationId}.crabline.discord`),
780
+ botUser: discordUser(botUserId, params.botUsername ?? "crabline", true),
781
+ channels: new Map(),
782
+ commands: new Map(),
783
+ gatewayClients: new Set(),
784
+ guildCommands: new Map(),
785
+ guilds: new Map(),
786
+ heartbeatIntervalMs: positiveInteger(params.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, "heartbeatIntervalMs"),
787
+ identifyTimeoutMs: positiveInteger(params.identifyTimeoutMs, DEFAULT_IDENTIFY_TIMEOUT_MS, "identifyTimeoutMs"),
788
+ maxGatewayPayloadBytes: positiveInteger(params.maxGatewayPayloadBytes, DEFAULT_MAX_GATEWAY_PAYLOAD_BYTES, "maxGatewayPayloadBytes"),
789
+ messages: new Map(),
790
+ nextSequence: 1,
791
+ onEvent: params.onEvent,
792
+ pendingRecorderEvents: new Set(),
793
+ recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "discord.jsonl"),
794
+ sessions: new Map(),
795
+ };
796
+ const httpServer = await startHttpJsonServer({
797
+ handle: (request) => handleRequest({ request, state }),
798
+ handleError: (error) => {
799
+ if (error instanceof DiscordRequestError) {
800
+ return discordError(error.status, error.code, error.message);
801
+ }
802
+ if (error instanceof InvalidJsonBodyError) {
803
+ return discordError(400, 50_035, "Invalid Form Body");
804
+ }
805
+ if (error instanceof RequestBodyTooLargeError) {
806
+ return discordError(413, 40_005, "Request entity too large");
807
+ }
808
+ return undefined;
809
+ },
810
+ host,
811
+ port: params.port ?? 0,
812
+ serverName: "Discord",
813
+ });
814
+ state.gatewayUrl = `${httpServer.baseUrl.replace(/^http/u, "ws")}/gateway`;
815
+ const closeGateway = attachGatewayServer({ server: httpServer.server, state });
816
+ return {
817
+ async close() {
818
+ await closeGateway();
819
+ await httpServer.close();
820
+ await Promise.all([...state.pendingRecorderEvents]);
821
+ state.sessions.clear();
822
+ },
823
+ manifest: {
824
+ adminToken: state.adminToken,
825
+ applicationId: state.applicationId,
826
+ baseUrl: httpServer.baseUrl,
827
+ botToken: state.botToken,
828
+ botUserId: state.botUser.id,
829
+ endpoints: {
830
+ adminInboundUrl: `${httpServer.baseUrl}/crabline/discord/inbound`,
831
+ apiRoot: `${httpServer.baseUrl}/api`,
832
+ gatewayBotUrl: `${httpServer.baseUrl}/api/v10/gateway/bot`,
833
+ gatewayUrl: state.gatewayUrl,
834
+ },
835
+ env: { DISCORD_BOT_TOKEN: state.botToken },
836
+ provider: "discord",
837
+ recorderPath: state.recorderPath,
838
+ version: 1,
839
+ },
840
+ };
841
+ }
842
+ //# sourceMappingURL=discord.js.map