@minesa-org/mini-interaction 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/builders/ActionRowBuilder.d.ts +25 -0
  4. package/dist/builders/ActionRowBuilder.js +35 -0
  5. package/dist/builders/ButtonBuilder.d.ts +53 -0
  6. package/dist/builders/ButtonBuilder.js +124 -0
  7. package/dist/builders/ChannelSelectMenuBuilder.d.ts +58 -0
  8. package/dist/builders/ChannelSelectMenuBuilder.js +111 -0
  9. package/dist/builders/ModalBuilder.d.ts +40 -0
  10. package/dist/builders/ModalBuilder.js +65 -0
  11. package/dist/builders/RoleSelectMenuBuilder.d.ts +52 -0
  12. package/dist/builders/RoleSelectMenuBuilder.js +98 -0
  13. package/dist/builders/StringSelectMenuBuilder.d.ts +56 -0
  14. package/dist/builders/StringSelectMenuBuilder.js +100 -0
  15. package/dist/builders/index.d.ts +14 -0
  16. package/dist/builders/index.js +7 -0
  17. package/dist/builders/shared.d.ts +8 -0
  18. package/dist/builders/shared.js +11 -0
  19. package/dist/clients/MiniInteraction.d.ts +176 -0
  20. package/dist/clients/MiniInteraction.js +578 -0
  21. package/dist/commands/CommandBuilder.d.ts +278 -0
  22. package/dist/commands/CommandBuilder.js +687 -0
  23. package/dist/index.d.ts +16 -0
  24. package/dist/index.js +9 -0
  25. package/dist/types/ButtonStyle.d.ts +16 -0
  26. package/dist/types/ButtonStyle.js +17 -0
  27. package/dist/types/ChannelType.d.ts +2 -0
  28. package/dist/types/ChannelType.js +2 -0
  29. package/dist/types/Commands.d.ts +11 -0
  30. package/dist/types/Commands.js +1 -0
  31. package/dist/types/ComponentTypes.d.ts +5 -0
  32. package/dist/types/ComponentTypes.js +1 -0
  33. package/dist/types/InteractionFlags.d.ts +10 -0
  34. package/dist/types/InteractionFlags.js +12 -0
  35. package/dist/types/RoleConnectionMetadataTypes.d.ts +11 -0
  36. package/dist/types/RoleConnectionMetadataTypes.js +12 -0
  37. package/dist/utils/CommandInteractionOptions.d.ts +143 -0
  38. package/dist/utils/CommandInteractionOptions.js +376 -0
  39. package/dist/utils/MessageComponentInteraction.d.ts +19 -0
  40. package/dist/utils/MessageComponentInteraction.js +60 -0
  41. package/dist/utils/constants.d.ts +16 -0
  42. package/dist/utils/constants.js +16 -0
  43. package/dist/utils/interactionMessageHelpers.d.ts +30 -0
  44. package/dist/utils/interactionMessageHelpers.js +32 -0
  45. package/package.json +50 -0
@@ -0,0 +1,578 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { InteractionResponseType, InteractionType, } from "discord-api-types/v10";
5
+ import { verifyKey } from "discord-interactions";
6
+ import { DISCORD_BASE_URL } from "../utils/constants.js";
7
+ import { createCommandInteraction } from "../utils/CommandInteractionOptions.js";
8
+ import { createMessageComponentInteraction, } from "../utils/MessageComponentInteraction.js";
9
+ /** File extensions that are treated as command modules when auto-loading. */
10
+ const SUPPORTED_COMMAND_EXTENSIONS = new Set([
11
+ ".js",
12
+ ".mjs",
13
+ ".cjs",
14
+ ".ts",
15
+ ".mts",
16
+ ".cts",
17
+ ]);
18
+ /**
19
+ * Lightweight client for registering, loading, and handling Discord slash command interactions.
20
+ */
21
+ export class MiniInteraction {
22
+ applicationId;
23
+ publicKey;
24
+ baseUrl;
25
+ fetchImpl;
26
+ verifyKeyImpl;
27
+ commandsDirectory;
28
+ commands = new Map();
29
+ componentHandlers = new Map();
30
+ commandsLoaded = false;
31
+ loadCommandsPromise = null;
32
+ /**
33
+ * Creates a new MiniInteraction client with optional command auto-loading and custom runtime hooks.
34
+ */
35
+ constructor({ applicationId, publicKey, commandsDirectory, fetchImplementation, verifyKeyImplementation, }) {
36
+ if (!applicationId) {
37
+ throw new Error("[MiniInteraction] applicationId is required");
38
+ }
39
+ if (!publicKey) {
40
+ throw new Error("[MiniInteraction] publicKey is required");
41
+ }
42
+ const fetchImpl = fetchImplementation ?? globalThis.fetch;
43
+ if (typeof fetchImpl !== "function") {
44
+ throw new Error("[MiniInteraction] fetch is not available. Provide a global fetch implementation.");
45
+ }
46
+ this.applicationId = applicationId;
47
+ this.publicKey = publicKey;
48
+ this.baseUrl = DISCORD_BASE_URL;
49
+ this.fetchImpl = fetchImpl;
50
+ this.verifyKeyImpl = verifyKeyImplementation ?? verifyKey;
51
+ this.commandsDirectory =
52
+ commandsDirectory === false
53
+ ? null
54
+ : this.resolveCommandsDirectory(commandsDirectory);
55
+ }
56
+ /**
57
+ * Registers a single command handler with the client.
58
+ *
59
+ * @param command - The command definition to register.
60
+ */
61
+ useCommand(command) {
62
+ const commandName = command?.data?.name;
63
+ if (!commandName) {
64
+ throw new Error("[MiniInteraction] command.data.name is required");
65
+ }
66
+ if (this.commands.has(commandName)) {
67
+ console.warn(`[MiniInteraction] Command "${commandName}" already exists and will be overwritten.`);
68
+ }
69
+ this.commands.set(commandName, command);
70
+ return this;
71
+ }
72
+ /**
73
+ * Registers multiple command handlers with the client.
74
+ *
75
+ * @param commands - The command definitions to register.
76
+ */
77
+ useCommands(commands) {
78
+ for (const command of commands) {
79
+ this.useCommand(command);
80
+ }
81
+ return this;
82
+ }
83
+ /**
84
+ * Registers a single component handler mapped to a custom identifier.
85
+ *
86
+ * @param component - The component definition to register.
87
+ */
88
+ useComponent(component) {
89
+ const customId = component?.customId;
90
+ if (!customId) {
91
+ throw new Error("[MiniInteraction] component.customId is required");
92
+ }
93
+ if (typeof component.handler !== "function") {
94
+ throw new Error("[MiniInteraction] component.handler must be a function");
95
+ }
96
+ if (this.componentHandlers.has(customId)) {
97
+ console.warn(`[MiniInteraction] Component "${customId}" already exists and will be overwritten.`);
98
+ }
99
+ this.componentHandlers.set(customId, component.handler);
100
+ return this;
101
+ }
102
+ /**
103
+ * Registers multiple component handlers in a single call.
104
+ *
105
+ * @param components - The component definitions to register.
106
+ */
107
+ useComponents(components) {
108
+ for (const component of components) {
109
+ this.useComponent(component);
110
+ }
111
+ return this;
112
+ }
113
+ /**
114
+ * Recursively loads commands from the configured commands directory.
115
+ *
116
+ * @param directory - Optional directory override for command discovery.
117
+ */
118
+ async loadCommandsFromDirectory(directory) {
119
+ const targetDirectory = directory !== undefined
120
+ ? this.resolveCommandsDirectory(directory)
121
+ : this.commandsDirectory;
122
+ if (!targetDirectory) {
123
+ throw new Error("[MiniInteraction] Commands directory support disabled. Provide a directory path.");
124
+ }
125
+ const exists = await this.pathExists(targetDirectory);
126
+ if (!exists) {
127
+ this.commandsLoaded = true;
128
+ console.warn(`[MiniInteraction] Commands directory "${targetDirectory}" does not exist. Skipping command auto-load.`);
129
+ return this;
130
+ }
131
+ const files = await this.collectCommandFiles(targetDirectory);
132
+ if (files.length === 0) {
133
+ this.commandsLoaded = true;
134
+ console.warn(`[MiniInteraction] No command files found under "${targetDirectory}".`);
135
+ return this;
136
+ }
137
+ for (const file of files) {
138
+ const command = await this.importCommandModule(file);
139
+ if (!command) {
140
+ continue;
141
+ }
142
+ this.commands.set(command.data.name, command);
143
+ }
144
+ this.commandsLoaded = true;
145
+ return this;
146
+ }
147
+ /**
148
+ * Lists the raw command data payloads for registration with Discord.
149
+ */
150
+ listCommandData() {
151
+ return Array.from(this.commands.values(), (command) => command.data);
152
+ }
153
+ /**
154
+ * Registers slash commands with Discord's REST API.
155
+ *
156
+ * @param botToken - The bot token authorising the registration request.
157
+ * @param commands - Optional command list to register instead of auto-loaded commands.
158
+ */
159
+ async registerCommands(botToken, commands) {
160
+ if (!botToken) {
161
+ throw new Error("[MiniInteraction] botToken is required");
162
+ }
163
+ let resolvedCommands = commands;
164
+ if (!resolvedCommands || resolvedCommands.length === 0) {
165
+ await this.ensureCommandsLoaded();
166
+ resolvedCommands = this.listCommandData();
167
+ }
168
+ if (!Array.isArray(resolvedCommands) || resolvedCommands.length === 0) {
169
+ throw new Error("[MiniInteraction] commands must be a non-empty array payload");
170
+ }
171
+ const url = `${this.baseUrl}/applications/${this.applicationId}/commands`;
172
+ const response = await this.fetchImpl(url, {
173
+ method: "PUT",
174
+ headers: {
175
+ Authorization: `Bot ${botToken}`,
176
+ "Content-Type": "application/json",
177
+ },
178
+ body: JSON.stringify(resolvedCommands),
179
+ });
180
+ if (!response.ok) {
181
+ const errorBody = await response.text();
182
+ throw new Error(`[MiniInteraction] Failed to register commands: [${response.status}] ${errorBody}`);
183
+ }
184
+ return response.json();
185
+ }
186
+ /**
187
+ * Registers role connection metadata with Discord's REST API.
188
+ *
189
+ * @param botToken - The bot token authorising the request.
190
+ * @param metadata - The metadata collection to register.
191
+ */
192
+ async registerMetadata(botToken, metadata) {
193
+ if (!botToken) {
194
+ throw new Error("[MiniInteraction] botToken is required");
195
+ }
196
+ if (!Array.isArray(metadata) || metadata.length === 0) {
197
+ throw new Error("[MiniInteraction] metadata must be a non-empty array payload");
198
+ }
199
+ const url = `${this.baseUrl}/applications/${this.applicationId}/role-connections/metadata`;
200
+ const response = await this.fetchImpl(url, {
201
+ method: "PUT",
202
+ headers: {
203
+ Authorization: `Bot ${botToken}`,
204
+ "Content-Type": "application/json",
205
+ },
206
+ body: JSON.stringify(metadata),
207
+ });
208
+ if (!response.ok) {
209
+ const errorBody = await response.text();
210
+ throw new Error(`[MiniInteraction] Failed to register metadata: [${response.status}] ${errorBody}`);
211
+ }
212
+ return response.json();
213
+ }
214
+ /**
215
+ * Validates and handles a single Discord interaction request.
216
+ *
217
+ * @param request - The request payload containing headers and body data.
218
+ */
219
+ async handleRequest(request) {
220
+ const { body, signature, timestamp } = request;
221
+ if (!signature || !timestamp) {
222
+ return {
223
+ status: 401,
224
+ body: { error: "[MiniInteraction] Missing signature headers" },
225
+ };
226
+ }
227
+ const rawBody = this.normalizeBody(body);
228
+ const verified = await this.verifyKeyImpl(rawBody, signature, timestamp, this.publicKey);
229
+ if (!verified) {
230
+ return {
231
+ status: 401,
232
+ body: {
233
+ error: "[MiniInteraction] Signature verification failed",
234
+ },
235
+ };
236
+ }
237
+ let interaction;
238
+ try {
239
+ interaction = JSON.parse(rawBody);
240
+ }
241
+ catch {
242
+ return {
243
+ status: 400,
244
+ body: {
245
+ error: "[MiniInteraction] Invalid interaction payload",
246
+ },
247
+ };
248
+ }
249
+ if (interaction.type === InteractionType.Ping) {
250
+ return {
251
+ status: 200,
252
+ body: { type: InteractionResponseType.Pong },
253
+ };
254
+ }
255
+ if (interaction.type === InteractionType.ApplicationCommand) {
256
+ return this.handleApplicationCommand(interaction);
257
+ }
258
+ if (interaction.type === InteractionType.MessageComponent) {
259
+ return this.handleMessageComponent(interaction);
260
+ }
261
+ return {
262
+ status: 400,
263
+ body: {
264
+ error: `[MiniInteraction] Unsupported interaction type: ${interaction.type}`,
265
+ },
266
+ };
267
+ }
268
+ /**
269
+ * Creates a Node.js style request handler that validates and processes interactions.
270
+ */
271
+ createNodeHandler() {
272
+ return (request, response) => {
273
+ if (request.method !== "POST") {
274
+ response.statusCode = 405;
275
+ response.setHeader("content-type", "application/json");
276
+ response.end(JSON.stringify({
277
+ error: "[MiniInteraction] Only POST is supported",
278
+ }));
279
+ return;
280
+ }
281
+ const chunks = [];
282
+ request.on("data", (chunk) => {
283
+ chunks.push(chunk);
284
+ });
285
+ request.on("error", (error) => {
286
+ response.statusCode = 500;
287
+ response.setHeader("content-type", "application/json");
288
+ response.end(JSON.stringify({
289
+ error: `[MiniInteraction] Failed to read request: ${String(error)}`,
290
+ }));
291
+ });
292
+ request.on("end", async () => {
293
+ const rawBody = Buffer.concat(chunks);
294
+ const signatureHeader = request.headers["x-signature-ed25519"];
295
+ const timestampHeader = request.headers["x-signature-timestamp"];
296
+ const signature = Array.isArray(signatureHeader)
297
+ ? signatureHeader[0]
298
+ : signatureHeader;
299
+ const timestamp = Array.isArray(timestampHeader)
300
+ ? timestampHeader[0]
301
+ : timestampHeader;
302
+ try {
303
+ const result = await this.handleRequest({
304
+ body: rawBody,
305
+ signature,
306
+ timestamp,
307
+ });
308
+ response.statusCode = result.status;
309
+ response.setHeader("content-type", "application/json");
310
+ response.end(JSON.stringify(result.body));
311
+ }
312
+ catch (error) {
313
+ response.statusCode = 500;
314
+ response.setHeader("content-type", "application/json");
315
+ response.end(JSON.stringify({
316
+ error: `[MiniInteraction] Handler failed: ${String(error)}`,
317
+ }));
318
+ }
319
+ });
320
+ };
321
+ }
322
+ /**
323
+ * Alias for {@link createNodeHandler} for frameworks expecting a listener function.
324
+ */
325
+ createNodeListener() {
326
+ return this.createNodeHandler();
327
+ }
328
+ /**
329
+ * Convenience alias for {@link createNodeHandler} tailored to Vercel serverless functions.
330
+ */
331
+ createVercelHandler() {
332
+ return this.createNodeHandler();
333
+ }
334
+ /**
335
+ * Creates a Fetch API compatible handler for runtimes like Workers or Deno.
336
+ */
337
+ createFetchHandler() {
338
+ return async (request) => {
339
+ if (request.method !== "POST") {
340
+ return new Response(JSON.stringify({
341
+ error: "[MiniInteraction] Only POST is supported",
342
+ }), {
343
+ status: 405,
344
+ headers: { "content-type": "application/json" },
345
+ });
346
+ }
347
+ const signature = request.headers.get("x-signature-ed25519") ?? undefined;
348
+ const timestamp = request.headers.get("x-signature-timestamp") ?? undefined;
349
+ const bodyArrayBuffer = await request.arrayBuffer();
350
+ const body = new Uint8Array(bodyArrayBuffer);
351
+ try {
352
+ const result = await this.handleRequest({
353
+ body,
354
+ signature,
355
+ timestamp,
356
+ });
357
+ return new Response(JSON.stringify(result.body), {
358
+ status: result.status,
359
+ headers: { "content-type": "application/json" },
360
+ });
361
+ }
362
+ catch (error) {
363
+ return new Response(JSON.stringify({
364
+ error: `[MiniInteraction] Handler failed: ${String(error)}`,
365
+ }), {
366
+ status: 500,
367
+ headers: { "content-type": "application/json" },
368
+ });
369
+ }
370
+ };
371
+ }
372
+ /**
373
+ * Checks if the provided directory path exists on disk.
374
+ */
375
+ async pathExists(targetPath) {
376
+ try {
377
+ const stats = await stat(targetPath);
378
+ return stats.isDirectory();
379
+ }
380
+ catch {
381
+ return false;
382
+ }
383
+ }
384
+ /**
385
+ * Recursively collects all command module file paths from the target directory.
386
+ */
387
+ async collectCommandFiles(directory) {
388
+ const entries = await readdir(directory, { withFileTypes: true });
389
+ const files = [];
390
+ for (const entry of entries) {
391
+ if (entry.name.startsWith(".")) {
392
+ continue;
393
+ }
394
+ const fullPath = path.join(directory, entry.name);
395
+ if (entry.isDirectory()) {
396
+ const nestedFiles = await this.collectCommandFiles(fullPath);
397
+ files.push(...nestedFiles);
398
+ continue;
399
+ }
400
+ if (entry.isFile() && this.isSupportedCommandFile(fullPath)) {
401
+ files.push(fullPath);
402
+ }
403
+ }
404
+ return files;
405
+ }
406
+ /**
407
+ * Determines whether the provided file path matches a supported command file extension.
408
+ */
409
+ isSupportedCommandFile(filePath) {
410
+ return SUPPORTED_COMMAND_EXTENSIONS.has(path.extname(filePath).toLowerCase());
411
+ }
412
+ /**
413
+ * Dynamically imports and validates a command module from disk.
414
+ */
415
+ async importCommandModule(absolutePath) {
416
+ try {
417
+ const moduleUrl = pathToFileURL(absolutePath).href;
418
+ const imported = await import(moduleUrl);
419
+ const candidate = imported.default ??
420
+ imported.command ??
421
+ imported.commandDefinition ??
422
+ imported;
423
+ if (!candidate || typeof candidate !== "object") {
424
+ console.warn(`[MiniInteraction] Command module "${absolutePath}" does not export a command object. Skipping.`);
425
+ return null;
426
+ }
427
+ const { data, handler } = candidate;
428
+ if (!data || typeof data.name !== "string") {
429
+ console.warn(`[MiniInteraction] Command module "${absolutePath}" is missing "data.name". Skipping.`);
430
+ return null;
431
+ }
432
+ if (typeof handler !== "function") {
433
+ console.warn(`[MiniInteraction] Command module "${absolutePath}" is missing a "handler" function. Skipping.`);
434
+ return null;
435
+ }
436
+ return { data, handler };
437
+ }
438
+ catch (error) {
439
+ console.error(`[MiniInteraction] Failed to load command module "${absolutePath}":`, error);
440
+ return null;
441
+ }
442
+ }
443
+ /**
444
+ * Normalises the request body into a UTF-8 string for signature validation and parsing.
445
+ */
446
+ normalizeBody(body) {
447
+ if (typeof body === "string") {
448
+ return body;
449
+ }
450
+ return Buffer.from(body).toString("utf8");
451
+ }
452
+ /**
453
+ * Ensures commands have been loaded from disk once before being accessed.
454
+ */
455
+ async ensureCommandsLoaded() {
456
+ if (this.commandsLoaded || this.commandsDirectory === null) {
457
+ return;
458
+ }
459
+ if (!this.loadCommandsPromise) {
460
+ this.loadCommandsPromise = this.loadCommandsFromDirectory().then(() => {
461
+ this.loadCommandsPromise = null;
462
+ });
463
+ }
464
+ await this.loadCommandsPromise;
465
+ }
466
+ /**
467
+ * Resolves the absolute commands directory path from configuration.
468
+ */
469
+ resolveCommandsDirectory(commandsDirectory) {
470
+ if (!commandsDirectory) {
471
+ return path.resolve(process.cwd(), "src/commands");
472
+ }
473
+ return path.isAbsolute(commandsDirectory)
474
+ ? commandsDirectory
475
+ : path.resolve(process.cwd(), commandsDirectory);
476
+ }
477
+ /**
478
+ * Handles execution of a message component interaction.
479
+ */
480
+ async handleMessageComponent(interaction) {
481
+ const customId = interaction?.data?.custom_id;
482
+ if (!customId) {
483
+ return {
484
+ status: 400,
485
+ body: {
486
+ error: "[MiniInteraction] Message component interaction is missing a custom_id",
487
+ },
488
+ };
489
+ }
490
+ const handler = this.componentHandlers.get(customId);
491
+ if (!handler) {
492
+ return {
493
+ status: 404,
494
+ body: {
495
+ error: `[MiniInteraction] No handler registered for component "${customId}"`,
496
+ },
497
+ };
498
+ }
499
+ try {
500
+ const interactionWithHelpers = createMessageComponentInteraction(interaction);
501
+ const response = await handler(interactionWithHelpers);
502
+ const resolvedResponse = response ?? interactionWithHelpers.getResponse();
503
+ if (!resolvedResponse) {
504
+ return {
505
+ status: 500,
506
+ body: {
507
+ error: `[MiniInteraction] Component "${customId}" did not return a response. ` +
508
+ "Return an APIInteractionResponse to acknowledge the interaction.",
509
+ },
510
+ };
511
+ }
512
+ return {
513
+ status: 200,
514
+ body: resolvedResponse,
515
+ };
516
+ }
517
+ catch (error) {
518
+ return {
519
+ status: 500,
520
+ body: {
521
+ error: `[MiniInteraction] Component "${customId}" failed: ${String(error)}`,
522
+ },
523
+ };
524
+ }
525
+ }
526
+ /**
527
+ * Handles execution of an application command interaction.
528
+ */
529
+ async handleApplicationCommand(interaction) {
530
+ await this.ensureCommandsLoaded();
531
+ const commandInteraction = interaction;
532
+ if (!commandInteraction.data || !commandInteraction.data.name) {
533
+ return {
534
+ status: 400,
535
+ body: {
536
+ error: "[MiniInteraction] Invalid application command interaction",
537
+ },
538
+ };
539
+ }
540
+ const commandName = commandInteraction.data.name;
541
+ const command = this.commands.get(commandName);
542
+ if (!command) {
543
+ return {
544
+ status: 404,
545
+ body: {
546
+ error: `[MiniInteraction] No handler registered for "${commandName}"`,
547
+ },
548
+ };
549
+ }
550
+ const interactionWithHelpers = createCommandInteraction(commandInteraction);
551
+ try {
552
+ const response = await command.handler(interactionWithHelpers);
553
+ const resolvedResponse = response ?? interactionWithHelpers.getResponse();
554
+ if (!resolvedResponse) {
555
+ return {
556
+ status: 500,
557
+ body: {
558
+ error: `[MiniInteraction] Command "${commandName}" did not return a response. ` +
559
+ "Call interaction.reply(), interaction.deferReply(), interaction.showModal(), " +
560
+ "or return an APIInteractionResponse.",
561
+ },
562
+ };
563
+ }
564
+ return {
565
+ status: 200,
566
+ body: resolvedResponse,
567
+ };
568
+ }
569
+ catch (error) {
570
+ return {
571
+ status: 500,
572
+ body: {
573
+ error: `[MiniInteraction] Command "${commandName}" failed: ${String(error)}`,
574
+ },
575
+ };
576
+ }
577
+ }
578
+ }