@minesa-org/mini-interaction 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1731 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { readdir, stat } from "node:fs/promises";
3
- import path from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- import { ApplicationCommandType, InteractionResponseType, InteractionType, } from "discord-api-types/v10";
6
- import { verifyKey } from "discord-interactions";
7
- import { resolveJSONEncodable } from "../builders/shared.js";
8
- import { DISCORD_BASE_URL } from "../utils/constants.js";
9
- import { createCommandInteraction } from "../utils/CommandInteractionOptions.js";
10
- import { createMessageComponentInteraction, } from "../utils/MessageComponentInteraction.js";
11
- import { createModalSubmitInteraction, } from "../utils/ModalSubmitInteraction.js";
12
- import { createUserContextMenuInteraction, createMessageContextMenuInteraction, createAppCommandInteraction, } from "../utils/ContextMenuInteraction.js";
13
- import { generateOAuthUrl, getOAuthTokens, getDiscordUser, } from "../oauth/DiscordOAuth.js";
14
- /** File extensions that are treated as loadable modules when auto-loading. */
15
- const SUPPORTED_MODULE_EXTENSIONS = new Set([
16
- ".js",
17
- ".mjs",
18
- ".cjs",
19
- ".ts",
20
- ".mts",
21
- ".cts",
22
- ]);
23
- /**
24
- * Lightweight client for registering, loading, and handling Discord slash command interactions.
25
- */
26
- export class MiniInteraction {
27
- applicationId;
28
- publicKey;
29
- fetchImpl;
30
- verifyKeyImpl;
31
- commandsDirectory;
32
- componentsDirectory;
33
- utilsDirectory;
34
- debug;
35
- timeoutConfig;
36
- commands = new Map();
37
- componentHandlers = new Map();
38
- modalHandlers = new Map();
39
- htmlTemplateCache = new Map();
40
- interactionStates = new Map();
41
- commandsLoaded = false;
42
- loadCommandsPromise = null;
43
- componentsLoaded = false;
44
- loadComponentsPromise = null;
45
- registerCommandsPromise = null;
46
- registerCommandsSignature = null;
47
- vercelWaitUntil = null;
48
- searchedForVercel = false;
49
- /**
50
- * Creates a new MiniInteraction client with optional command auto-loading and custom runtime hooks.
51
- */
52
- constructor(options = {}) {
53
- // Attempt to load .env if dotenv is available (non-blocking)
54
- if (typeof process !== "undefined" && !process.env.DISCORD_APPLICATION_ID) {
55
- // @ts-ignore - Optional dependency, may not have types available during build
56
- import("dotenv/config").catch(() => { });
57
- }
58
- const { applicationId, publicKey, commandsDirectory, componentsDirectory, utilsDirectory, debug, fetchImplementation, verifyKeyImplementation, timeoutConfig, } = options;
59
- this.debug = debug ?? (typeof process !== "undefined" ? process.env.DEBUG === "true" || process.env.MINI_DEBUG === "true" : false);
60
- const resolvedAppId = applicationId ?? (typeof process !== "undefined" ? process.env.DISCORD_APPLICATION_ID : undefined);
61
- const resolvedPublicKey = publicKey ?? (typeof process !== "undefined" ? (process.env.DISCORD_PUBLIC_KEY ?? process.env.DISCORD_APP_PUBLIC_KEY) : undefined);
62
- const fetchImpl = fetchImplementation ?? globalThis.fetch;
63
- if (typeof fetchImpl !== "function") {
64
- throw new Error("[MiniInteraction] fetch is not available. Provide a global fetch implementation.");
65
- }
66
- this.applicationId = resolvedAppId;
67
- this.publicKey = resolvedPublicKey;
68
- this.fetchImpl = fetchImpl;
69
- this.verifyKeyImpl = verifyKeyImplementation ?? verifyKey;
70
- this.commandsDirectory =
71
- commandsDirectory === false
72
- ? null
73
- : this.resolveCommandsDirectory(commandsDirectory);
74
- this.componentsDirectory =
75
- componentsDirectory === false
76
- ? null
77
- : this.resolveComponentsDirectory(componentsDirectory);
78
- this.utilsDirectory =
79
- utilsDirectory === false
80
- ? null
81
- : this.resolveUtilsDirectory(utilsDirectory);
82
- this.timeoutConfig = {
83
- initialResponseTimeout: 2800, // Leave 200ms buffer before Discord's 3s limit
84
- enableTimeoutWarnings: true,
85
- autoDeferSlowOperations: true,
86
- enableResponseDebugLogging: false,
87
- ...timeoutConfig,
88
- };
89
- }
90
- log(message, ...args) {
91
- if (this.debug) {
92
- console.log(`[MiniInteraction] ${message}`, ...args);
93
- }
94
- }
95
- trackInteractionState(interactionId, token, state) {
96
- const now = Date.now();
97
- this.interactionStates.set(interactionId, {
98
- state,
99
- timestamp: now,
100
- token,
101
- });
102
- }
103
- /**
104
- * Checks if an interaction can still respond (not expired and not already responded).
105
- */
106
- canRespond(interactionId) {
107
- const state = this.getInteractionState(interactionId);
108
- if (!state)
109
- return true; // New interaction
110
- // Check if expired (15 minutes)
111
- if (Date.now() - state.timestamp > 900000) {
112
- this.trackInteractionState(interactionId, state.token, 'expired');
113
- return false;
114
- }
115
- // Initial response only allowed once if not deferred
116
- if (state.state === 'responded' && !state.token) {
117
- return false;
118
- }
119
- return true;
120
- }
121
- /**
122
- * Gets the current state of an interaction.
123
- */
124
- getInteractionState(interactionId) {
125
- return this.interactionStates.get(interactionId);
126
- }
127
- /**
128
- * Clears expired interaction states to prevent memory leaks.
129
- * Call this periodically to clean up old interaction data.
130
- */
131
- cleanupExpiredInteractions() {
132
- const now = Date.now();
133
- let cleaned = 0;
134
- for (const [id, state] of this.interactionStates.entries()) {
135
- // Remove interactions older than 15 minutes
136
- if (now - state.timestamp > 900000) {
137
- this.interactionStates.delete(id);
138
- cleaned++;
139
- }
140
- }
141
- return cleaned;
142
- }
143
- /**
144
- * Attempt to find a waitUntil implementation in the environment (e.g. Vercel or Cloudflare).
145
- */
146
- async getAutoWaitUntil() {
147
- if (this.searchedForVercel)
148
- return this.vercelWaitUntil;
149
- this.searchedForVercel = true;
150
- // Try Vercel's @vercel/functions
151
- try {
152
- // @ts-ignore - Dynamic import to avoid hard dependency or build errors in non-vercel environments
153
- const { waitUntil } = await import("@vercel/functions");
154
- if (typeof waitUntil === "function") {
155
- this.vercelWaitUntil = waitUntil;
156
- return waitUntil;
157
- }
158
- }
159
- catch {
160
- // Ignore if not found
161
- }
162
- // Try globalThis.waitUntil (some edge runtimes)
163
- // @ts-ignore
164
- if (typeof globalThis.waitUntil === "function") {
165
- // @ts-ignore
166
- return globalThis.waitUntil;
167
- }
168
- return null;
169
- }
170
- normalizeCommandData(data) {
171
- if (typeof data === "object" && data !== null) {
172
- return resolveJSONEncodable(data);
173
- }
174
- return data;
175
- }
176
- registerCommand(command) {
177
- const normalizedData = this.normalizeCommandData(command.data);
178
- const commandName = normalizedData?.name;
179
- if (!commandName) {
180
- throw new Error("[MiniInteraction] command.data.name is required");
181
- }
182
- if (this.commands.has(commandName)) {
183
- console.warn(`[MiniInteraction] Command "${commandName}" already exists and will be overwritten.`);
184
- }
185
- const normalizedCommand = {
186
- ...command,
187
- data: normalizedData,
188
- };
189
- this.commands.set(commandName, normalizedCommand);
190
- }
191
- /**
192
- * Registers a single command handler with the client.
193
- *
194
- * @param command - The command definition to register.
195
- */
196
- useCommand(command) {
197
- this.registerCommand(command);
198
- return this;
199
- }
200
- /**
201
- * Registers multiple command handlers with the client.
202
- *
203
- * @param commands - The command definitions to register.
204
- */
205
- useCommands(commands) {
206
- for (const command of commands) {
207
- this.useCommand(command);
208
- }
209
- return this;
210
- }
211
- /**
212
- * Registers a single component handler mapped to a custom identifier.
213
- *
214
- * @param component - The component definition to register.
215
- */
216
- useComponent(component) {
217
- const customId = component?.customId;
218
- if (!customId) {
219
- throw new Error("[MiniInteraction] component.customId is required");
220
- }
221
- if (typeof component.handler !== "function") {
222
- throw new Error("[MiniInteraction] component.handler must be a function");
223
- }
224
- if (this.componentHandlers.has(customId)) {
225
- console.warn(`[MiniInteraction] Component "${customId}" already exists and will be overwritten.`);
226
- }
227
- this.componentHandlers.set(customId, component.handler);
228
- return this;
229
- }
230
- /**
231
- * Registers multiple component handlers in a single call.
232
- *
233
- * @param components - The component definitions to register.
234
- */
235
- useComponents(components) {
236
- for (const component of components) {
237
- this.useComponent(component);
238
- }
239
- return this;
240
- }
241
- /**
242
- * Registers a single modal handler mapped to a custom identifier.
243
- *
244
- * @param modal - The modal definition to register.
245
- */
246
- useModal(modal) {
247
- const customId = modal?.customId;
248
- if (!customId) {
249
- throw new Error("[MiniInteraction] modal.customId is required");
250
- }
251
- if (typeof modal.handler !== "function") {
252
- throw new Error("[MiniInteraction] modal.handler must be a function");
253
- }
254
- if (this.modalHandlers.has(customId)) {
255
- console.warn(`[MiniInteraction] Modal "${customId}" already exists and will be overwritten.`);
256
- }
257
- this.modalHandlers.set(customId, modal.handler);
258
- return this;
259
- }
260
- /**
261
- * Registers multiple modal handlers in a single call.
262
- *
263
- * @param modals - The modal definitions to register.
264
- */
265
- useModals(modals) {
266
- for (const modal of modals) {
267
- this.useModal(modal);
268
- }
269
- return this;
270
- }
271
- /**
272
- * Recursively loads components from the configured components directory.
273
- *
274
- * @param directory - Optional directory override for component discovery.
275
- */
276
- async loadComponentsFromDirectory(directory) {
277
- const targetDirectory = directory !== undefined
278
- ? this.resolveComponentsDirectory(directory)
279
- : this.componentsDirectory;
280
- if (!targetDirectory) {
281
- throw new Error("[MiniInteraction] Components directory support disabled. Provide a directory path.");
282
- }
283
- const exists = await this.pathExists(targetDirectory);
284
- if (!exists) {
285
- this.componentsLoaded = true;
286
- console.warn(`[MiniInteraction] Components directory "${targetDirectory}" does not exist. Skipping component auto-load.`);
287
- return this;
288
- }
289
- const files = await this.collectModuleFiles(targetDirectory);
290
- if (files.length === 0) {
291
- this.componentsLoaded = true;
292
- console.warn(`[MiniInteraction] No component files found under "${targetDirectory}".`);
293
- return this;
294
- }
295
- for (const file of files) {
296
- const components = await this.importComponentModule(file);
297
- if (components.length === 0) {
298
- continue;
299
- }
300
- for (const component of components) {
301
- this.useComponent(component);
302
- }
303
- }
304
- this.componentsLoaded = true;
305
- return this;
306
- }
307
- /**
308
- * Recursively loads commands from the configured commands directory.
309
- *
310
- * @param directory - Optional directory override for command discovery.
311
- */
312
- async loadCommandsFromDirectory(directory) {
313
- const targetDirectory = directory !== undefined
314
- ? this.resolveCommandsDirectory(directory)
315
- : this.commandsDirectory;
316
- if (!targetDirectory) {
317
- throw new Error("[MiniInteraction] Commands directory support disabled. Provide a directory path.");
318
- }
319
- const exists = await this.pathExists(targetDirectory);
320
- if (!exists) {
321
- this.commandsLoaded = true;
322
- console.warn(`[MiniInteraction] Commands directory "${targetDirectory}" does not exist. Skipping command auto-load.`);
323
- return this;
324
- }
325
- const files = await this.collectModuleFiles(targetDirectory);
326
- if (files.length === 0) {
327
- this.commandsLoaded = true;
328
- console.warn(`[MiniInteraction] No command files found under "${targetDirectory}".`);
329
- return this;
330
- }
331
- for (const file of files) {
332
- const command = await this.importCommandModule(file);
333
- if (!command) {
334
- continue;
335
- }
336
- this.registerCommand(command);
337
- }
338
- this.commandsLoaded = true;
339
- return this;
340
- }
341
- /**
342
- * Lists the raw command data payloads for registration with Discord.
343
- */
344
- listCommandData() {
345
- return Array.from(this.commands.values(), (command) => command.data);
346
- }
347
- /**
348
- * Registers commands with Discord's REST API.
349
- *
350
- * @param botToken - The bot token authorising the registration request.
351
- * @param commands - Optional command list to register instead of auto-loaded commands.
352
- */
353
- async registerCommands(botToken, commands) {
354
- if (!botToken) {
355
- throw new Error("[MiniInteraction] botToken is required");
356
- }
357
- let resolvedCommands = commands;
358
- if (!resolvedCommands || resolvedCommands.length === 0) {
359
- await this.ensureCommandsLoaded();
360
- resolvedCommands = this.listCommandData();
361
- }
362
- if (!Array.isArray(resolvedCommands) || resolvedCommands.length === 0) {
363
- throw new Error("[MiniInteraction] commands must be a non-empty array payload");
364
- }
365
- const signature = JSON.stringify(resolvedCommands);
366
- if (this.registerCommandsPromise) {
367
- if (this.registerCommandsSignature === signature) {
368
- console.warn("[MiniInteraction] Command registration already in progress. Reusing the in-flight request.");
369
- return this.registerCommandsPromise;
370
- }
371
- console.warn("[MiniInteraction] Command registration already in progress. Waiting for it to finish before continuing.");
372
- await this.registerCommandsPromise.catch(() => undefined);
373
- }
374
- const url = `${DISCORD_BASE_URL}/applications/${this.applicationId}/commands`;
375
- const requestPromise = (async () => {
376
- try {
377
- const response = await this.fetchImpl(url, {
378
- method: "PUT",
379
- headers: {
380
- Authorization: `Bot ${botToken}`,
381
- "Content-Type": "application/json",
382
- },
383
- body: JSON.stringify(resolvedCommands),
384
- });
385
- if (!response.ok) {
386
- const errorBody = await response.text();
387
- throw new Error(`[MiniInteraction] Failed to register commands: [${response.status}] ${errorBody}`);
388
- }
389
- return response.json();
390
- }
391
- catch (error) {
392
- const message = error instanceof Error ? error.message : String(error);
393
- if (message.startsWith("[MiniInteraction]")) {
394
- throw error;
395
- }
396
- throw new Error(`[MiniInteraction] Failed to register commands: ${message}`);
397
- }
398
- })();
399
- this.registerCommandsSignature = signature;
400
- this.registerCommandsPromise = requestPromise.finally(() => {
401
- this.registerCommandsPromise = null;
402
- this.registerCommandsSignature = null;
403
- });
404
- return this.registerCommandsPromise;
405
- }
406
- /**
407
- * Registers role connection metadata with Discord's REST API.
408
- *
409
- * @param botToken - The bot token authorising the request.
410
- * @param metadata - The metadata collection to register.
411
- */
412
- async registerMetadata(botToken, metadata) {
413
- if (!botToken) {
414
- throw new Error("[MiniInteraction] botToken is required");
415
- }
416
- if (!Array.isArray(metadata) || metadata.length === 0) {
417
- throw new Error("[MiniInteraction] metadata must be a non-empty array payload");
418
- }
419
- const url = `${DISCORD_BASE_URL}/applications/${this.applicationId}/role-connections/metadata`;
420
- const response = await this.fetchImpl(url, {
421
- method: "PUT",
422
- headers: {
423
- Authorization: `Bot ${botToken}`,
424
- "Content-Type": "application/json",
425
- },
426
- body: JSON.stringify(metadata),
427
- });
428
- if (!response.ok) {
429
- const errorBody = await response.text();
430
- throw new Error(`[MiniInteraction] Failed to register metadata: [${response.status}] ${errorBody}`);
431
- }
432
- return response.json();
433
- }
434
- /**
435
- * Validates and handles a single Discord interaction request.
436
- *
437
- * @param request - The request payload containing headers and body data.
438
- */
439
- async handleRequest(request) {
440
- const requestStartTime = Date.now();
441
- const { body, signature, timestamp } = request;
442
- if (!signature || !timestamp) {
443
- return {
444
- status: 401,
445
- body: { error: "[MiniInteraction] Missing signature headers" },
446
- };
447
- }
448
- const rawBody = this.normalizeBody(body);
449
- const verified = await this.verifyKeyImpl(rawBody, signature, timestamp, this.publicKey);
450
- if (!verified) {
451
- return {
452
- status: 401,
453
- body: {
454
- error: "[MiniInteraction] Signature verification failed",
455
- },
456
- };
457
- }
458
- let interaction;
459
- try {
460
- interaction = JSON.parse(rawBody);
461
- }
462
- catch {
463
- return {
464
- status: 400,
465
- body: {
466
- error: "[MiniInteraction] Invalid interaction payload",
467
- },
468
- };
469
- }
470
- if (interaction.type === InteractionType.Ping) {
471
- return {
472
- status: 200,
473
- body: { type: InteractionResponseType.Pong },
474
- };
475
- }
476
- if (interaction.type === InteractionType.ApplicationCommand) {
477
- // Track interaction start
478
- this.trackInteractionState(interaction.id, interaction.token, 'pending');
479
- return this.handleApplicationCommand(interaction);
480
- }
481
- if (interaction.type === InteractionType.MessageComponent) {
482
- // Track interaction start
483
- this.trackInteractionState(interaction.id, interaction.token, 'pending');
484
- return this.handleMessageComponent(interaction);
485
- }
486
- if (interaction.type === InteractionType.ModalSubmit) {
487
- // Track interaction start
488
- this.trackInteractionState(interaction.id, interaction.token, 'pending');
489
- return this.handleModalSubmit(interaction);
490
- }
491
- // Check total processing time and log potential timeout issues
492
- const totalProcessingTime = Date.now() - requestStartTime;
493
- if (this.timeoutConfig.enableTimeoutWarnings &&
494
- totalProcessingTime >
495
- this.timeoutConfig.initialResponseTimeout * 0.9) {
496
- console.warn(`[MiniInteraction] CRITICAL: Interaction processing took ${totalProcessingTime}ms ` +
497
- `(${Math.round((totalProcessingTime / 3000) * 100)}% of Discord's 3-second limit). ` +
498
- `This may cause "didn't respond in time" errors. ` +
499
- `Consider optimizing or using deferReply() for slow operations.`);
500
- }
501
- return {
502
- status: 400,
503
- body: {
504
- error: `[MiniInteraction] Unsupported interaction type: ${interaction.type}`,
505
- },
506
- };
507
- }
508
- /**
509
- * Creates a Node.js style request handler compatible with Express, Next.js API routes,
510
- * Vercel serverless functions, and any runtime that expects a `(req, res)` listener.
511
- */
512
- createNodeHandler(options) {
513
- const { waitUntil } = options ?? {};
514
- return (request, response) => {
515
- if (request.method !== "POST") {
516
- response.statusCode = 405;
517
- response.setHeader("content-type", "application/json");
518
- response.end(JSON.stringify({
519
- error: "[MiniInteraction] Only POST is supported",
520
- }));
521
- return;
522
- }
523
- const chunks = [];
524
- request.on("data", (chunk) => {
525
- chunks.push(chunk);
526
- });
527
- request.on("error", (error) => {
528
- response.statusCode = 500;
529
- response.setHeader("content-type", "application/json");
530
- response.end(JSON.stringify({
531
- error: `[MiniInteraction] Failed to read request: ${String(error)}`,
532
- }));
533
- });
534
- request.on("end", async () => {
535
- const rawBody = Buffer.concat(chunks);
536
- const signatureHeader = request.headers["x-signature-ed25519"];
537
- const timestampHeader = request.headers["x-signature-timestamp"];
538
- const signature = Array.isArray(signatureHeader)
539
- ? signatureHeader[0]
540
- : signatureHeader;
541
- const timestamp = Array.isArray(timestampHeader)
542
- ? timestampHeader[0]
543
- : timestampHeader;
544
- try {
545
- const result = await this.handleRequest({
546
- body: rawBody,
547
- signature,
548
- timestamp,
549
- });
550
- if (result.backgroundWork) {
551
- const resolvedWaitUntil = waitUntil ?? await this.getAutoWaitUntil();
552
- if (resolvedWaitUntil) {
553
- resolvedWaitUntil(result.backgroundWork);
554
- }
555
- }
556
- response.statusCode = result.status;
557
- response.setHeader("content-type", "application/json");
558
- response.end(JSON.stringify(result.body));
559
- }
560
- catch (error) {
561
- response.statusCode = 500;
562
- response.setHeader("content-type", "application/json");
563
- response.end(JSON.stringify({
564
- error: `[MiniInteraction] Handler failed: ${String(error)}`,
565
- }));
566
- }
567
- });
568
- };
569
- }
570
- /**
571
- * Generates a lightweight verification handler that serves an HTML page with an embedded OAuth link.
572
- *
573
- * This is primarily used when Discord asks for a verification URL while setting up Linked Roles.
574
- * Provide an HTML file that contains the `{{OAUTH_URL}}` placeholder (or a custom placeholder defined in options)
575
- * and this helper will replace the token with a freshly generated OAuth link on every request.
576
- *
577
- * Available placeholders:
578
- * - `{{OAUTH_URL}}` - HTML-escaped OAuth URL for links/buttons.
579
- * - `{{OAUTH_URL_RAW}}` - raw OAuth URL, ideal for `<script>` usage.
580
- * - `{{OAUTH_STATE}}` - HTML-escaped OAuth state value.
581
- * - Custom placeholder name (from {@link DiscordOAuthVerificationPageOptions.placeholder}) and its `_RAW` variant.
582
- */
583
- discordOAuthVerificationPage(options = {}) {
584
- const scopes = options.scopes ?? ["identify", "role_connections.write"];
585
- const htmlFile = options.htmlFile ?? "index.html";
586
- const placeholderKey = this.normalizeTemplateKey(options.placeholder ?? "OAUTH_URL");
587
- const template = this.loadHtmlTemplate(htmlFile);
588
- const oauthConfig = resolveOAuthConfig(options.oauth);
589
- return (_request, response) => {
590
- try {
591
- const { url, state } = generateOAuthUrl(oauthConfig, scopes);
592
- const values = {
593
- OAUTH_URL: url,
594
- OAUTH_URL_RAW: url,
595
- OAUTH_STATE: state,
596
- };
597
- const rawKeys = new Set(["OAUTH_URL_RAW"]);
598
- if (placeholderKey !== "OAUTH_URL") {
599
- values[placeholderKey] = url;
600
- const rawVariant = `${placeholderKey}_RAW`;
601
- values[rawVariant] = url;
602
- rawKeys.add(rawVariant);
603
- }
604
- const html = this.renderHtmlTemplate(template, values, {
605
- rawKeys,
606
- });
607
- sendHtml(response, html);
608
- }
609
- catch (error) {
610
- console.error("[MiniInteraction] Failed to render OAuth verification page:", error);
611
- sendHtml(response, DEFAULT_VERIFICATION_ERROR_HTML, 500);
612
- }
613
- };
614
- }
615
- /**
616
- * Loads an HTML file and returns a success template that replaces useful placeholders.
617
- *
618
- * The following placeholders are available in the HTML file:
619
- * - `{{username}}`, `{{discriminator}}`, `{{user_id}}`, `{{user_tag}}`
620
- * - `{{access_token}}`, `{{refresh_token}}`, `{{token_type}}`, `{{scope}}`, `{{expires_at}}`
621
- * - `{{state}}`
622
- */
623
- connectedOAuthPage(filePath) {
624
- const template = this.loadHtmlTemplate(filePath);
625
- return ({ user, tokens, state }) => {
626
- const discriminator = user.discriminator;
627
- const userTag = discriminator && discriminator !== "0"
628
- ? `${user.username}#${discriminator}`
629
- : user.username;
630
- return this.renderHtmlTemplate(template, {
631
- username: user.username,
632
- discriminator,
633
- user_id: user.id,
634
- user_tag: userTag,
635
- access_token: tokens.access_token,
636
- refresh_token: tokens.refresh_token,
637
- token_type: tokens.token_type,
638
- scope: tokens.scope,
639
- expires_at: tokens.expires_at.toString(),
640
- state,
641
- });
642
- };
643
- }
644
- /**
645
- * Loads an HTML file and returns an error template that can be reused for all failure cases.
646
- *
647
- * The following placeholders are available in the HTML file:
648
- * - `{{error}}`
649
- * - `{{state}}`
650
- */
651
- failedOAuthPage(filePath) {
652
- const template = this.loadHtmlTemplate(filePath);
653
- const renderer = (context) => this.renderHtmlTemplate(template, {
654
- error: context.error,
655
- state: context.state,
656
- });
657
- return renderer;
658
- }
659
- /**
660
- * Resolves an HTML template from disk and caches the result for reuse.
661
- */
662
- loadHtmlTemplate(filePath) {
663
- const resolvedPath = path.isAbsolute(filePath)
664
- ? filePath
665
- : path.join(process.cwd(), filePath);
666
- const cached = this.htmlTemplateCache.get(resolvedPath);
667
- if (cached) {
668
- return cached;
669
- }
670
- if (!existsSync(resolvedPath)) {
671
- throw new Error(`[MiniInteraction] HTML template not found: ${resolvedPath}`);
672
- }
673
- const fileContents = readFileSync(resolvedPath, "utf8");
674
- this.htmlTemplateCache.set(resolvedPath, fileContents);
675
- return fileContents;
676
- }
677
- /**
678
- * Replaces placeholder tokens in a template with escaped HTML values.
679
- */
680
- renderHtmlTemplate(template, values, options) {
681
- const rawKeys = options?.rawKeys instanceof Set
682
- ? options.rawKeys
683
- : new Set(options?.rawKeys ?? []);
684
- return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
685
- const value = values[key];
686
- if (value === undefined || value === null) {
687
- return "";
688
- }
689
- const stringValue = String(value);
690
- return rawKeys.has(key) ? stringValue : escapeHtml(stringValue);
691
- });
692
- }
693
- /**
694
- * Normalizes placeholder tokens to the bare key the HTML renderer expects.
695
- */
696
- normalizeTemplateKey(token) {
697
- if (!token) {
698
- return "OAUTH_URL";
699
- }
700
- const trimmed = token.trim();
701
- const match = trimmed.match(/^\{\{\s*(\w+)\s*\}\}$/);
702
- if (match) {
703
- return match[1];
704
- }
705
- return trimmed || "OAUTH_URL";
706
- }
707
- /**
708
- * Creates a minimal Discord OAuth callback handler that renders helpful HTML responses.
709
- *
710
- * This helper keeps the user-side implementation tiny while still exposing hooks for
711
- * storing metadata or validating the OAuth state value.
712
- */
713
- discordOAuthCallback(options) {
714
- const templates = {
715
- ...DEFAULT_DISCORD_OAUTH_TEMPLATES,
716
- ...options.templates,
717
- };
718
- const oauthConfig = resolveOAuthConfig(options.oauth);
719
- return async (request, response) => {
720
- if (request.method !== "GET") {
721
- response.statusCode = 405;
722
- response.setHeader("content-type", "application/json");
723
- response.end(JSON.stringify({
724
- error: "[MiniInteraction] Only GET is supported",
725
- }));
726
- return;
727
- }
728
- let state = null;
729
- try {
730
- const host = request.headers.host ?? "localhost";
731
- const url = new URL(request.url ?? "", `http://${host}`);
732
- const code = url.searchParams.get("code");
733
- state = url.searchParams.get("state");
734
- const error = url.searchParams.get("error");
735
- if (error) {
736
- sendHtml(response, templates.oauthError({
737
- error,
738
- state,
739
- }), 400);
740
- return;
741
- }
742
- if (!code) {
743
- sendHtml(response, templates.missingCode({
744
- state,
745
- }), 400);
746
- return;
747
- }
748
- if (options.validateState) {
749
- const isValid = await options.validateState(state, request);
750
- if (!isValid) {
751
- sendHtml(response, templates.invalidState({
752
- state,
753
- }), 400);
754
- return;
755
- }
756
- }
757
- const tokens = await getOAuthTokens(code, oauthConfig);
758
- const user = await getDiscordUser(tokens.access_token);
759
- const authorizeContext = {
760
- tokens,
761
- user,
762
- state,
763
- request,
764
- response,
765
- };
766
- if (options.onAuthorize) {
767
- await options.onAuthorize(authorizeContext);
768
- if (response.writableEnded || response.headersSent) {
769
- return;
770
- }
771
- }
772
- if (options.successRedirect) {
773
- const location = typeof options.successRedirect === "function"
774
- ? options.successRedirect(authorizeContext)
775
- : options.successRedirect;
776
- if (location &&
777
- !response.headersSent &&
778
- !response.writableEnded) {
779
- response.statusCode = 302;
780
- response.setHeader("location", location);
781
- response.end();
782
- return;
783
- }
784
- }
785
- sendHtml(response, templates.success({
786
- user,
787
- tokens,
788
- state,
789
- }));
790
- }
791
- catch (error) {
792
- console.error("[MiniInteraction] Discord OAuth callback failed:", error);
793
- if (!response.headersSent && !response.writableEnded) {
794
- sendHtml(response, templates.serverError({
795
- state,
796
- }), 500);
797
- }
798
- }
799
- };
800
- }
801
- /**
802
- * Creates a Fetch API compatible handler for runtimes like Workers or Deno.
803
- */
804
- /**
805
- * Generates a Fetch-standard request handler compatible with Cloudflare Workers,
806
- * Bun, Deno, and Next.js Edge Runtime.
807
- */
808
- createFetchHandler(options) {
809
- const { waitUntil } = options ?? {};
810
- return async (request) => {
811
- if (request.method !== "POST") {
812
- return new Response(JSON.stringify({
813
- error: "[MiniInteraction] Only POST is supported",
814
- }), {
815
- status: 405,
816
- headers: { "content-type": "application/json" },
817
- });
818
- }
819
- const signature = request.headers.get("x-signature-ed25519") ?? undefined;
820
- const timestamp = request.headers.get("x-signature-timestamp") ?? undefined;
821
- const bodyArrayBuffer = await request.arrayBuffer();
822
- const body = new Uint8Array(bodyArrayBuffer);
823
- try {
824
- const result = await this.handleRequest({
825
- body,
826
- signature,
827
- timestamp,
828
- });
829
- if (result.backgroundWork) {
830
- const resolvedWaitUntil = waitUntil ?? await this.getAutoWaitUntil();
831
- if (resolvedWaitUntil) {
832
- resolvedWaitUntil(result.backgroundWork);
833
- }
834
- }
835
- return new Response(JSON.stringify(result.body), {
836
- status: result.status,
837
- headers: { "content-type": "application/json" },
838
- });
839
- }
840
- catch (error) {
841
- return new Response(JSON.stringify({
842
- error: `[MiniInteraction] Handler failed: ${String(error)}`,
843
- }), {
844
- status: 500,
845
- headers: { "content-type": "application/json" },
846
- });
847
- }
848
- };
849
- }
850
- /**
851
- * Checks if the provided directory path exists on disk.
852
- */
853
- async pathExists(targetPath) {
854
- try {
855
- const stats = await stat(targetPath);
856
- return stats.isDirectory();
857
- }
858
- catch {
859
- return false;
860
- }
861
- }
862
- /**
863
- * Recursively collects all command module file paths from the target directory.
864
- */
865
- async collectModuleFiles(directory) {
866
- if (this.debug) {
867
- this.log(`Collecting module files from: ${directory}`);
868
- }
869
- const entries = await readdir(directory, { withFileTypes: true });
870
- const files = [];
871
- for (const entry of entries) {
872
- if (entry.name.startsWith(".")) {
873
- continue;
874
- }
875
- const fullPath = path.join(directory, entry.name);
876
- if (entry.isDirectory()) {
877
- const nestedFiles = await this.collectModuleFiles(fullPath);
878
- files.push(...nestedFiles);
879
- continue;
880
- }
881
- if (entry.isFile() && this.isSupportedModuleFile(fullPath)) {
882
- files.push(fullPath);
883
- }
884
- }
885
- return files;
886
- }
887
- /**
888
- * Determines whether the provided file path matches a supported command file extension.
889
- */
890
- isSupportedModuleFile(filePath) {
891
- return SUPPORTED_MODULE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
892
- }
893
- /**
894
- * Dynamically imports and validates a command module from disk.
895
- * Supports multiple export patterns:
896
- * - export default { data, handler }
897
- * - export const command = { data, handler }
898
- * - export const ping_command = { data, handler }
899
- * - export const data = ...; export const handler = ...;
900
- */
901
- async importCommandModule(absolutePath) {
902
- try {
903
- const moduleUrl = pathToFileURL(absolutePath).href;
904
- this.log(`Dynamically importing command: ${moduleUrl}`);
905
- const imported = await import(moduleUrl);
906
- // Try to find a command object from various export patterns
907
- let candidate = imported.default ??
908
- imported.command ??
909
- imported.commandDefinition;
910
- // If not found, look for named exports ending with "_command"
911
- if (!candidate) {
912
- for (const [key, value] of Object.entries(imported)) {
913
- if (key.endsWith("_command") &&
914
- typeof value === "object" &&
915
- value !== null) {
916
- candidate = value;
917
- break;
918
- }
919
- }
920
- }
921
- // If still not found, try to construct from separate data/handler exports
922
- if (!candidate) {
923
- if (imported.data && imported.handler) {
924
- candidate = {
925
- data: imported.data,
926
- handler: imported.handler,
927
- };
928
- }
929
- else {
930
- // Last resort: use the entire module
931
- candidate = imported;
932
- }
933
- }
934
- if (!candidate || typeof candidate !== "object") {
935
- console.warn(`[MiniInteraction] Command module "${absolutePath}" does not export a command object. Skipping.`);
936
- return null;
937
- }
938
- const { data, handler } = candidate;
939
- const normalizedData = this.normalizeCommandData(data);
940
- if (!normalizedData || typeof normalizedData.name !== "string") {
941
- console.warn(`[MiniInteraction] Command module "${absolutePath}" is missing "data.name". Skipping.`);
942
- return null;
943
- }
944
- if (typeof handler !== "function") {
945
- console.warn(`[MiniInteraction] Command module "${absolutePath}" is missing a "handler" function. Skipping.`);
946
- return null;
947
- }
948
- return { data: normalizedData, handler };
949
- }
950
- catch (error) {
951
- console.error(`[MiniInteraction] Failed to load command module "${absolutePath}":`, error);
952
- return null;
953
- }
954
- }
955
- /**
956
- * Dynamically imports and validates a component module from disk.
957
- * Also handles modal components if they're in a "modals" subdirectory.
958
- * Supports multiple export patterns:
959
- * - export default { customId, handler }
960
- * - export const component = { customId, handler }
961
- * - export const ping_button = { customId, handler }
962
- * - export const customId = "..."; export const handler = ...;
963
- * - export const components = [{ customId, handler }, ...]
964
- */
965
- async importComponentModule(absolutePath) {
966
- try {
967
- const moduleUrl = pathToFileURL(absolutePath).href;
968
- this.log(`Dynamically importing component: ${moduleUrl}`);
969
- const imported = await import(moduleUrl);
970
- // Collect all potential component candidates
971
- const candidates = [];
972
- // Try standard exports first
973
- const standardExport = imported.default ??
974
- imported.component ??
975
- imported.components ??
976
- imported.componentDefinition ??
977
- imported.modal ??
978
- imported.modals;
979
- if (standardExport) {
980
- if (Array.isArray(standardExport)) {
981
- candidates.push(...standardExport);
982
- }
983
- else {
984
- candidates.push(standardExport);
985
- }
986
- }
987
- // Look for named exports ending with "_button", "_select", "_modal", etc.
988
- for (const [key, value] of Object.entries(imported)) {
989
- if ((key.endsWith("_button") ||
990
- key.endsWith("_select") ||
991
- key.endsWith("_modal") ||
992
- key.endsWith("_component")) &&
993
- typeof value === "object" &&
994
- value !== null &&
995
- !candidates.includes(value)) {
996
- candidates.push(value);
997
- }
998
- }
999
- // If no candidates found, try to construct from separate customId/handler exports
1000
- if (candidates.length === 0) {
1001
- if (imported.customId && imported.handler) {
1002
- candidates.push({
1003
- customId: imported.customId,
1004
- handler: imported.handler,
1005
- });
1006
- }
1007
- }
1008
- const components = [];
1009
- // Check if this file is in a "modals" subdirectory
1010
- const isModalFile = absolutePath.includes(path.sep + "modals" + path.sep) ||
1011
- absolutePath.includes("/modals/");
1012
- for (const item of candidates) {
1013
- if (!item || typeof item !== "object") {
1014
- continue;
1015
- }
1016
- const { customId, handler } = item;
1017
- if (typeof customId !== "string") {
1018
- console.warn(`[MiniInteraction] Component module "${absolutePath}" is missing "customId". Skipping.`);
1019
- continue;
1020
- }
1021
- if (typeof handler !== "function") {
1022
- console.warn(`[MiniInteraction] Component module "${absolutePath}" is missing a "handler" function. Skipping.`);
1023
- continue;
1024
- }
1025
- // If it's in a modals directory, register it as a modal
1026
- if (isModalFile) {
1027
- this.useModal({
1028
- customId,
1029
- handler: handler,
1030
- });
1031
- }
1032
- else {
1033
- components.push({ customId, handler });
1034
- }
1035
- }
1036
- if (components.length === 0 && !isModalFile) {
1037
- console.warn(`[MiniInteraction] Component module "${absolutePath}" did not export any valid components. Skipping.`);
1038
- }
1039
- return components;
1040
- }
1041
- catch (error) {
1042
- console.error(`[MiniInteraction] Failed to load component module "${absolutePath}":`, error);
1043
- return [];
1044
- }
1045
- }
1046
- /**
1047
- * Normalises the request body into a UTF-8 string for signature validation and parsing.
1048
- */
1049
- normalizeBody(body) {
1050
- if (typeof body === "string") {
1051
- return body;
1052
- }
1053
- return Buffer.from(body).toString("utf8");
1054
- }
1055
- /**
1056
- * Ensures components have been loaded from disk once before being accessed.
1057
- */
1058
- async ensureComponentsLoaded() {
1059
- if (this.componentsLoaded || this.componentsDirectory === null) {
1060
- return;
1061
- }
1062
- if (!this.loadComponentsPromise) {
1063
- this.loadComponentsPromise =
1064
- this.loadComponentsFromDirectory().then(() => {
1065
- this.loadComponentsPromise = null;
1066
- });
1067
- }
1068
- await this.loadComponentsPromise;
1069
- }
1070
- /**
1071
- * Ensures commands have been loaded from disk once before being accessed.
1072
- */
1073
- async ensureCommandsLoaded() {
1074
- if (this.commandsLoaded || this.commandsDirectory === null) {
1075
- return;
1076
- }
1077
- if (!this.loadCommandsPromise) {
1078
- this.loadCommandsPromise = this.loadCommandsFromDirectory().then(() => {
1079
- this.loadCommandsPromise = null;
1080
- });
1081
- }
1082
- await this.loadCommandsPromise;
1083
- }
1084
- /**
1085
- * Resolves the absolute commands directory path from configuration.
1086
- */
1087
- resolveCommandsDirectory(commandsDirectory) {
1088
- return this.resolveDirectory("commands", commandsDirectory);
1089
- }
1090
- /**
1091
- * Resolves the absolute components directory path from configuration.
1092
- */
1093
- resolveComponentsDirectory(componentsDirectory) {
1094
- return this.resolveDirectory("components", componentsDirectory);
1095
- }
1096
- /**
1097
- * Resolves the absolute utilities directory path from configuration.
1098
- */
1099
- resolveUtilsDirectory(utilsDirectory) {
1100
- return this.resolveDirectory("utils", utilsDirectory);
1101
- }
1102
- /**
1103
- * Resolves a directory relative to the project "src" or "dist" folders with optional overrides.
1104
- */
1105
- resolveDirectory(defaultFolder, overrideDirectory) {
1106
- const projectRoot = process.cwd();
1107
- const allowedRoots = ["src", "dist"].map((folder) => path.resolve(projectRoot, folder));
1108
- const candidates = [];
1109
- const isWithin = (parent, child) => {
1110
- const relative = path.relative(parent, child);
1111
- return (relative === "" ||
1112
- (!relative.startsWith("..") && !path.isAbsolute(relative)));
1113
- };
1114
- const pushCandidate = (candidate) => {
1115
- if (!candidates.includes(candidate)) {
1116
- candidates.push(candidate);
1117
- }
1118
- };
1119
- const ensureWithinAllowedRoots = (absolutePath) => {
1120
- if (!allowedRoots.some((root) => isWithin(root, absolutePath))) {
1121
- throw new Error(`[MiniInteraction] Directory overrides must be located within "${path.join(projectRoot, "src")}" or "${path.join(projectRoot, "dist")}". Received: ${absolutePath}`);
1122
- }
1123
- pushCandidate(absolutePath);
1124
- };
1125
- const addOverrideCandidates = (overridePath) => {
1126
- const trimmed = overridePath.trim();
1127
- if (!trimmed) {
1128
- return;
1129
- }
1130
- if (path.isAbsolute(trimmed)) {
1131
- ensureWithinAllowedRoots(trimmed);
1132
- return;
1133
- }
1134
- const normalised = trimmed.replace(/^[./\\]+/, "");
1135
- if (!normalised) {
1136
- return;
1137
- }
1138
- if (normalised.startsWith("src") || normalised.startsWith("dist")) {
1139
- const absolutePath = path.resolve(projectRoot, normalised);
1140
- ensureWithinAllowedRoots(absolutePath);
1141
- return;
1142
- }
1143
- for (const root of allowedRoots) {
1144
- ensureWithinAllowedRoots(path.resolve(root, normalised));
1145
- }
1146
- };
1147
- if (overrideDirectory) {
1148
- addOverrideCandidates(overrideDirectory);
1149
- }
1150
- for (const root of allowedRoots) {
1151
- pushCandidate(path.resolve(root, defaultFolder));
1152
- }
1153
- for (const candidate of candidates) {
1154
- const exists = existsSync(candidate);
1155
- if (this.debug) {
1156
- this.log(`Checking directory: ${candidate} (exists: ${exists})`);
1157
- }
1158
- if (exists) {
1159
- return candidate;
1160
- }
1161
- }
1162
- if (this.debug) {
1163
- this.log(`No existing directory found for ${defaultFolder}, using default candidate: ${candidates[0]}`);
1164
- }
1165
- return candidates[0];
1166
- }
1167
- /**
1168
- * Handles execution of a message component interaction.
1169
- */
1170
- async handleMessageComponent(interaction) {
1171
- const customId = interaction?.data?.custom_id;
1172
- if (!customId) {
1173
- return {
1174
- status: 400,
1175
- body: {
1176
- error: "[MiniInteraction] Message component interaction is missing a custom_id",
1177
- },
1178
- };
1179
- }
1180
- await this.ensureComponentsLoaded();
1181
- const handler = this.componentHandlers.get(customId);
1182
- if (!handler) {
1183
- return {
1184
- status: 404,
1185
- body: {
1186
- error: `[MiniInteraction] No handler registered for component "${customId}"`,
1187
- },
1188
- };
1189
- }
1190
- try {
1191
- // Create an acknowledgment promise that resolves when the handler calls reply() or deferReply()
1192
- let ackResolver = null;
1193
- const ackPromise = new Promise((resolve) => {
1194
- ackResolver = resolve;
1195
- });
1196
- // Helper to send follow-up responses via webhooks
1197
- const sendFollowUp = (token, data, messageId = '@original') => this.sendFollowUp(token, data, messageId);
1198
- const interactionWithHelpers = createMessageComponentInteraction(interaction, {
1199
- onAck: (response) => ackResolver?.(response),
1200
- sendFollowUp,
1201
- trackResponse: (id, token, state) => this.trackInteractionState(id, token, state),
1202
- canRespond: (id) => this.canRespond(id),
1203
- });
1204
- // Wrap component handler with timeout and acknowledgment
1205
- const timeoutWrapper = createTimeoutWrapper(async () => {
1206
- const response = await handler(interactionWithHelpers);
1207
- const resolvedResponse = response ?? interactionWithHelpers.getResponse();
1208
- if (this.timeoutConfig.enableResponseDebugLogging) {
1209
- console.log(`[MiniInteraction] Component handler finished: "${customId}"`);
1210
- }
1211
- return resolvedResponse;
1212
- }, this.timeoutConfig.initialResponseTimeout, `Component "${customId}"`, this.timeoutConfig.enableTimeoutWarnings, ackPromise);
1213
- const { response: resolvedResponse, backgroundWork } = await timeoutWrapper();
1214
- return {
1215
- status: 200,
1216
- body: resolvedResponse,
1217
- backgroundWork,
1218
- };
1219
- }
1220
- catch (error) {
1221
- const errorMessage = error instanceof Error ? error.message : String(error);
1222
- if (errorMessage.includes("Handler timeout")) {
1223
- console.error(`[MiniInteraction] CRITICAL: Component "${customId}" timed out. ` +
1224
- `This will result in "didn't respond in time" errors for users.`);
1225
- }
1226
- return {
1227
- status: 500,
1228
- body: {
1229
- error: `[MiniInteraction] Component "${customId}" failed: ${errorMessage}`,
1230
- },
1231
- };
1232
- }
1233
- }
1234
- /**
1235
- * Handles execution of a modal submit interaction.
1236
- */
1237
- async handleModalSubmit(interaction) {
1238
- const customId = interaction?.data?.custom_id;
1239
- if (!customId) {
1240
- return {
1241
- status: 400,
1242
- body: {
1243
- error: "[MiniInteraction] Modal submit interaction is missing a custom_id",
1244
- },
1245
- };
1246
- }
1247
- await this.ensureComponentsLoaded();
1248
- const handler = this.modalHandlers.get(customId);
1249
- if (!handler) {
1250
- return {
1251
- status: 404,
1252
- body: {
1253
- error: `[MiniInteraction] No handler registered for modal "${customId}"`,
1254
- },
1255
- };
1256
- }
1257
- try {
1258
- // Create an acknowledgment promise for modals
1259
- let ackResolver = null;
1260
- const ackPromise = new Promise((resolve) => {
1261
- ackResolver = resolve;
1262
- });
1263
- // Helper to send follow-up responses via webhooks
1264
- const sendFollowUp = (token, data, messageId = '@original') => this.sendFollowUp(token, data, messageId);
1265
- const interactionWithHelpers = createModalSubmitInteraction(interaction, {
1266
- onAck: (response) => ackResolver?.(response),
1267
- sendFollowUp,
1268
- trackResponse: (id, token, state) => this.trackInteractionState(id, token, state),
1269
- canRespond: (id) => this.canRespond(id),
1270
- });
1271
- // Wrap modal handler with timeout and acknowledgment
1272
- const timeoutWrapper = createTimeoutWrapper(async () => {
1273
- const response = await handler(interactionWithHelpers);
1274
- const resolvedResponse = response ?? interactionWithHelpers.getResponse();
1275
- if (this.timeoutConfig.enableResponseDebugLogging) {
1276
- console.log(`[MiniInteraction] Modal handler finished: "${customId}"`);
1277
- }
1278
- return resolvedResponse;
1279
- }, this.timeoutConfig.initialResponseTimeout, `Modal "${customId}"`, this.timeoutConfig.enableTimeoutWarnings, ackPromise);
1280
- const { response: resolvedResponse, backgroundWork } = await timeoutWrapper();
1281
- return {
1282
- status: 200,
1283
- body: resolvedResponse,
1284
- backgroundWork,
1285
- };
1286
- }
1287
- catch (error) {
1288
- const errorMessage = error instanceof Error ? error.message : String(error);
1289
- if (errorMessage.includes("Handler timeout")) {
1290
- console.error(`[MiniInteraction] CRITICAL: Modal "${customId}" timed out. ` +
1291
- `This will result in "didn't respond in time" errors for users.`);
1292
- }
1293
- return {
1294
- status: 500,
1295
- body: {
1296
- error: `[MiniInteraction] Modal "${customId}" failed: ${errorMessage}`,
1297
- },
1298
- };
1299
- }
1300
- }
1301
- /**
1302
- * Handles execution of an application command interaction.
1303
- */
1304
- async handleApplicationCommand(interaction) {
1305
- await this.ensureCommandsLoaded();
1306
- const commandInteraction = interaction;
1307
- if (!commandInteraction.data || !commandInteraction.data.name) {
1308
- return {
1309
- status: 400,
1310
- body: {
1311
- error: "[MiniInteraction] Invalid application command interaction",
1312
- },
1313
- };
1314
- }
1315
- const commandName = commandInteraction.data.name;
1316
- const command = this.commands.get(commandName);
1317
- if (!command) {
1318
- return {
1319
- status: 404,
1320
- body: {
1321
- error: `[MiniInteraction] No handler registered for "${commandName}"`,
1322
- },
1323
- };
1324
- }
1325
- try {
1326
- let response;
1327
- let resolvedResponse = null;
1328
- // Create an acknowledgment promise for application commands
1329
- let ackResolver = null;
1330
- const ackPromise = new Promise((resolve) => {
1331
- ackResolver = resolve;
1332
- });
1333
- // Helper to send follow-up responses via webhooks
1334
- const sendFollowUp = (token, data, messageId = '@original') => this.sendFollowUp(token, data, messageId);
1335
- // Create a timeout wrapper for the command handler
1336
- const timeoutWrapper = createTimeoutWrapper(async () => {
1337
- // Check if it's a chat input (slash) command
1338
- if (commandInteraction.data.type ===
1339
- ApplicationCommandType.ChatInput) {
1340
- const interactionWithHelpers = createCommandInteraction(commandInteraction, {
1341
- canRespond: (id) => this.canRespond(id),
1342
- trackResponse: (id, token, state) => this.trackInteractionState(id, token, state),
1343
- onAck: (response) => ackResolver?.(response),
1344
- sendFollowUp,
1345
- });
1346
- response = await command.handler(interactionWithHelpers);
1347
- resolvedResponse =
1348
- response ?? interactionWithHelpers.getResponse();
1349
- }
1350
- else if (commandInteraction.data.type ===
1351
- ApplicationCommandType.User) {
1352
- const interactionWithHelpers = createUserContextMenuInteraction(commandInteraction, {
1353
- onAck: (response) => ackResolver?.(response),
1354
- sendFollowUp,
1355
- canRespond: (id) => this.canRespond(id),
1356
- trackResponse: (id, token, state) => this.trackInteractionState(id, token, state),
1357
- });
1358
- response = await command.handler(interactionWithHelpers);
1359
- resolvedResponse =
1360
- response ?? interactionWithHelpers.getResponse();
1361
- }
1362
- else if (commandInteraction.data.type ===
1363
- ApplicationCommandType.PrimaryEntryPoint) {
1364
- const interactionWithHelpers = createAppCommandInteraction(commandInteraction, {
1365
- onAck: (response) => ackResolver?.(response),
1366
- sendFollowUp,
1367
- canRespond: (id) => this.canRespond(id),
1368
- trackResponse: (id, token, state) => this.trackInteractionState(id, token, state),
1369
- });
1370
- response = await command.handler(interactionWithHelpers);
1371
- resolvedResponse =
1372
- response ?? interactionWithHelpers.getResponse();
1373
- }
1374
- else if (commandInteraction.data.type ===
1375
- ApplicationCommandType.Message) {
1376
- // Message context menu command
1377
- const interactionWithHelpers = createMessageContextMenuInteraction(commandInteraction, {
1378
- onAck: (response) => ackResolver?.(response),
1379
- sendFollowUp,
1380
- canRespond: (id) => this.canRespond(id),
1381
- trackResponse: (id, token, state) => this.trackInteractionState(id, token, state),
1382
- });
1383
- response = await command.handler(interactionWithHelpers);
1384
- resolvedResponse =
1385
- response ?? interactionWithHelpers.getResponse();
1386
- }
1387
- else {
1388
- // Unknown command type
1389
- response = await command.handler(commandInteraction);
1390
- resolvedResponse = response ?? null;
1391
- }
1392
- if (this.timeoutConfig.enableResponseDebugLogging) {
1393
- console.log(`[MiniInteraction] Command handler finished: "${commandName}"`);
1394
- }
1395
- return resolvedResponse;
1396
- }, this.timeoutConfig.initialResponseTimeout, `Command "${commandName}"`, this.timeoutConfig.enableTimeoutWarnings, ackPromise);
1397
- const { response: finalResponse, backgroundWork } = await timeoutWrapper();
1398
- if (this.timeoutConfig.enableResponseDebugLogging) {
1399
- console.log(`[MiniInteraction] handleApplicationCommand: initial response determined (type=${finalResponse?.type})`);
1400
- }
1401
- if (!finalResponse) {
1402
- console.error(`[MiniInteraction] Command "${commandName}" did not return a response. ` +
1403
- "This indicates the handler completed but no response was generated. " +
1404
- "Check that deferReply(), reply(), showModal(), or a direct response is returned.");
1405
- return {
1406
- status: 500,
1407
- body: {
1408
- error: `[MiniInteraction] Command "${commandName}" did not return a response. ` +
1409
- "Call interaction.reply(), interaction.deferReply(), interaction.showModal(), " +
1410
- "or return an APIInteractionResponse.",
1411
- },
1412
- };
1413
- }
1414
- return {
1415
- status: 200,
1416
- body: finalResponse,
1417
- backgroundWork,
1418
- };
1419
- }
1420
- catch (error) {
1421
- const errorMessage = error instanceof Error ? error.message : String(error);
1422
- // Check if this was a timeout error
1423
- if (errorMessage.includes("Handler timeout")) {
1424
- console.error(`[MiniInteraction] CRITICAL: Command "${commandName}" timed out before responding to Discord. ` +
1425
- `This will result in "didn't respond in time" errors for users. ` +
1426
- `Handler took longer than ${this.timeoutConfig.initialResponseTimeout}ms to complete. ` +
1427
- `Consider using deferReply() for operations that take more than 3 seconds.`);
1428
- }
1429
- return {
1430
- status: 500,
1431
- body: {
1432
- error: `[MiniInteraction] Command "${commandName}" failed: ${errorMessage}`,
1433
- },
1434
- };
1435
- }
1436
- }
1437
- /**
1438
- * Sends a follow-up response or edits an existing response via Discord's interaction webhooks.
1439
- * This is used for interactions that have already been acknowledged (e.g., via deferReply).
1440
- *
1441
- * Includes retry logic to handle race conditions where the ACK hasn't reached Discord yet.
1442
- */
1443
- async sendFollowUp(token, response, messageId = "@original", retryCount = 0) {
1444
- const MAX_RETRIES = 5;
1445
- const BASE_DELAY_MS = 200; // Start with 200ms delay
1446
- const INITIAL_DELAY_MS = 100; // Wait for ACK to propagate to Discord
1447
- // On first attempt, add a small delay to allow ACK to propagate
1448
- if (retryCount === 0) {
1449
- await new Promise(resolve => setTimeout(resolve, INITIAL_DELAY_MS));
1450
- }
1451
- const isEdit = messageId !== "";
1452
- const url = isEdit
1453
- ? `${DISCORD_BASE_URL}/webhooks/${this.applicationId}/${token}/messages/${messageId}`
1454
- : `${DISCORD_BASE_URL}/webhooks/${this.applicationId}/${token}`;
1455
- if (this.timeoutConfig.enableResponseDebugLogging) {
1456
- console.log(`[MiniInteraction] sendFollowUp: id=${messageId || 'new'}, edit=${isEdit}, retry=${retryCount}, url=${url}`);
1457
- }
1458
- // Only send follow-up if there is data to send
1459
- if (!('data' in response) || !response.data) {
1460
- if (this.timeoutConfig.enableResponseDebugLogging) {
1461
- console.warn(`[MiniInteraction] sendFollowUp cancelled: no data in response object`);
1462
- }
1463
- return;
1464
- }
1465
- try {
1466
- const fetchResponse = await this.fetchImpl(url, {
1467
- method: isEdit ? "PATCH" : "POST",
1468
- headers: {
1469
- "Content-Type": "application/json",
1470
- },
1471
- body: JSON.stringify(response.data),
1472
- });
1473
- if (this.timeoutConfig.enableResponseDebugLogging) {
1474
- console.log(`[MiniInteraction] sendFollowUp response: [${fetchResponse.status}] ${fetchResponse.statusText}`);
1475
- }
1476
- if (!fetchResponse.ok) {
1477
- const errorBody = await fetchResponse.text();
1478
- // Check for "Unknown Webhook" error (10015) - this means ACK hasn't reached Discord yet
1479
- if (fetchResponse.status === 404 && errorBody.includes("10015") && retryCount < MAX_RETRIES) {
1480
- const delayMs = BASE_DELAY_MS * Math.pow(2, retryCount); // Exponential backoff: 200, 400, 800, 1600, 3200ms
1481
- console.warn(`[MiniInteraction] Webhook not ready yet, retrying in ${delayMs}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`);
1482
- await new Promise(resolve => setTimeout(resolve, delayMs));
1483
- return this.sendFollowUp(token, response, messageId, retryCount + 1);
1484
- }
1485
- console.error(`[MiniInteraction] Failed to send follow-up response (id=${messageId || 'new'}): [${fetchResponse.status}] ${errorBody}`);
1486
- if (fetchResponse.status === 404) {
1487
- console.error("[MiniInteraction] Hint: Interaction token might have expired or the message was deleted.");
1488
- }
1489
- }
1490
- }
1491
- catch (error) {
1492
- console.error(`[MiniInteraction] Error sending follow-up response: ${error instanceof Error ? error.message : String(error)}`);
1493
- }
1494
- }
1495
- }
1496
- const DEFAULT_DISCORD_OAUTH_TEMPLATES = {
1497
- success: ({ user }) => {
1498
- const username = escapeHtml(user.username ?? "Discord User");
1499
- const discriminator = user.discriminator && user.discriminator !== "0"
1500
- ? `#${escapeHtml(user.discriminator)}`
1501
- : "";
1502
- return `<!DOCTYPE html>
1503
- <html>
1504
- <head>
1505
- <meta charset="utf-8" />
1506
- <title>Linked Role Connected</title>
1507
- <style>
1508
- body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
1509
- .success { color: #2e7d32; background: #e8f5e9; padding: 20px; border-radius: 10px; margin: 20px 0; }
1510
- .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin: 20px 0; }
1511
- h1 { color: #5865f2; }
1512
- .user-info { margin: 10px 0; font-size: 18px; }
1513
- </style>
1514
- </head>
1515
- <body>
1516
- <h1>✅ Successfully Connected!</h1>
1517
- <div class="success">
1518
- <p><strong>Your Discord account has been linked!</strong></p>
1519
- <div class="user-info">
1520
- <p>👤 ${username}${discriminator}</p>
1521
- </div>
1522
- </div>
1523
- <div class="info">
1524
- <p>Your linked role metadata has been updated.</p>
1525
- <p>You can now close this window and return to Discord.</p>
1526
- </div>
1527
- </body>
1528
- </html>`;
1529
- },
1530
- missingCode: () => `<!DOCTYPE html>
1531
- <html>
1532
- <head>
1533
- <meta charset="utf-8" />
1534
- <title>Missing Authorization Code</title>
1535
- <style>
1536
- body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
1537
- .error { color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 5px; }
1538
- </style>
1539
- </head>
1540
- <body>
1541
- <h1>Missing Authorization Code</h1>
1542
- <div class="error">
1543
- <p>No authorization code was provided.</p>
1544
- </div>
1545
- </body>
1546
- </html>`,
1547
- oauthError: ({ error }) => `<!DOCTYPE html>
1548
- <html>
1549
- <head>
1550
- <meta charset="utf-8" />
1551
- <title>OAuth Error</title>
1552
- <style>
1553
- body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
1554
- .error { color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 5px; }
1555
- </style>
1556
- </head>
1557
- <body>
1558
- <h1>OAuth Error</h1>
1559
- <div class="error">
1560
- <p>Authorization failed: ${escapeHtml(error)}</p>
1561
- <p>Please try again.</p>
1562
- </div>
1563
- </body>
1564
- </html>`,
1565
- invalidState: () => `<!DOCTYPE html>
1566
- <html>
1567
- <head>
1568
- <meta charset="utf-8" />
1569
- <title>Invalid Session</title>
1570
- <style>
1571
- body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
1572
- .error { color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 5px; }
1573
- </style>
1574
- </head>
1575
- <body>
1576
- <h1>Invalid Session</h1>
1577
- <div class="error">
1578
- <p>The provided state value did not match an active session.</p>
1579
- <p>Please restart the linking process.</p>
1580
- </div>
1581
- </body>
1582
- </html>`,
1583
- serverError: () => `<!DOCTYPE html>
1584
- <html>
1585
- <head>
1586
- <meta charset="utf-8" />
1587
- <title>Server Error</title>
1588
- <style>
1589
- body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
1590
- .error { color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 5px; }
1591
- </style>
1592
- </head>
1593
- <body>
1594
- <h1>Server Error</h1>
1595
- <div class="error">
1596
- <p>An error occurred while processing your request.</p>
1597
- <p>Please try again later.</p>
1598
- </div>
1599
- </body>
1600
- </html>`,
1601
- };
1602
- const DEFAULT_VERIFICATION_ERROR_HTML = `<!DOCTYPE html>
1603
- <html>
1604
- <head>
1605
- <meta charset="utf-8" />
1606
- <title>Server Error</title>
1607
- <style>
1608
- body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; }
1609
- .error { color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 5px; display: inline-block; }
1610
- </style>
1611
- </head>
1612
- <body>
1613
- <div class="error">
1614
- <p>We were unable to load the Discord verification page. Please try again later.</p>
1615
- </div>
1616
- </body>
1617
- </html>`;
1618
- function sendHtml(response, body, statusCode = 200) {
1619
- if (response.headersSent || response.writableEnded) {
1620
- return;
1621
- }
1622
- response.statusCode = statusCode;
1623
- response.setHeader("content-type", "text/html; charset=utf-8");
1624
- response.end(body);
1625
- }
1626
- function escapeHtml(value) {
1627
- return value.replace(/[&<>"']/g, (character) => {
1628
- switch (character) {
1629
- case "&":
1630
- return "&amp;";
1631
- case "<":
1632
- return "&lt;";
1633
- case ">":
1634
- return "&gt;";
1635
- case '"':
1636
- return "&quot;";
1637
- case "'":
1638
- return "&#39;";
1639
- default:
1640
- return character;
1641
- }
1642
- });
1643
- }
1644
- function resolveOAuthConfig(provided) {
1645
- if (provided) {
1646
- return provided;
1647
- }
1648
- const appId = process.env.DISCORD_APPLICATION_ID ?? process.env.DISCORD_CLIENT_ID;
1649
- const appSecret = process.env.DISCORD_CLIENT_SECRET;
1650
- const redirectUri = process.env.DISCORD_REDIRECT_URI;
1651
- if (!appId || !appSecret || !redirectUri) {
1652
- throw new Error("[MiniInteraction] Missing OAuth configuration. Provide options.oauth or set DISCORD_APPLICATION_ID, DISCORD_CLIENT_SECRET, and DISCORD_REDIRECT_URI environment variables.");
1653
- }
1654
- return {
1655
- appId,
1656
- appSecret,
1657
- redirectUri,
1658
- };
1659
- }
1660
- /**
1661
- * Wraps a handler function with timeout detection and error handling.
1662
- *
1663
- * CRITICAL FOR HTTP INTERACTIONS:
1664
- * When deferReply() is called, we MUST return the ACK to Discord immediately.
1665
- * The handler continues executing and sends follow-up via webhook.
1666
- * The ACK must reach Discord before any webhook PATCH requests can succeed.
1667
- */
1668
- function createTimeoutWrapper(handler, timeoutMs, handlerName, enableWarnings = true, ackPromise) {
1669
- return async (...args) => {
1670
- const startTime = Date.now();
1671
- let timeoutId;
1672
- const timeoutPromise = new Promise((_, reject) => {
1673
- timeoutId = setTimeout(() => {
1674
- const elapsed = Date.now() - startTime;
1675
- console.error(`[MiniInteraction] ${handlerName} timed out after ${elapsed}ms (limit: ${timeoutMs}ms)`);
1676
- reject(new Error(`Handler timeout: ${handlerName} exceeded ${timeoutMs}ms limit`));
1677
- }, timeoutMs);
1678
- });
1679
- // Start handler execution immediately (don't await yet)
1680
- const handlerPromise = Promise.resolve(handler(...args));
1681
- // Attach a default error handler to prevent unhandled rejections
1682
- const backgroundWork = handlerPromise.catch((error) => {
1683
- console.error(`[MiniInteraction] ${handlerName} background execution failed:`, error instanceof Error ? error.message : String(error));
1684
- }).then(() => {
1685
- // Ensure it always resolves to void
1686
- });
1687
- // If we have an ackPromise, race between ACK and timeout
1688
- if (ackPromise) {
1689
- try {
1690
- const response = await Promise.race([
1691
- ackPromise,
1692
- timeoutPromise,
1693
- ]);
1694
- // ACK received! Clear timeout and return immediately
1695
- if (timeoutId) {
1696
- clearTimeout(timeoutId);
1697
- }
1698
- return { response, backgroundWork };
1699
- }
1700
- catch (error) {
1701
- // Timeout occurred before ACK - fall through to check handler
1702
- if (timeoutId) {
1703
- clearTimeout(timeoutId);
1704
- }
1705
- throw error;
1706
- }
1707
- }
1708
- // No ACK promise - wait for handler with timeout
1709
- try {
1710
- const response = await Promise.race([
1711
- handlerPromise,
1712
- timeoutPromise,
1713
- ]);
1714
- if (timeoutId) {
1715
- clearTimeout(timeoutId);
1716
- }
1717
- const elapsed = Date.now() - startTime;
1718
- if (enableWarnings && elapsed > timeoutMs * 0.8) {
1719
- console.warn(`[MiniInteraction] ${handlerName} completed in ${elapsed}ms (${Math.round((elapsed / timeoutMs) * 100)}% of timeout limit)`);
1720
- }
1721
- return { response, backgroundWork };
1722
- }
1723
- catch (error) {
1724
- if (timeoutId) {
1725
- clearTimeout(timeoutId);
1726
- }
1727
- console.error(`[MiniInteraction] ${handlerName} failed:`, error);
1728
- throw error;
1729
- }
1730
- };
1731
- }