meocord 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/AUTHOR.md +2 -3
  2. package/README.md +224 -41
  3. package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
  4. package/dist/cjs/core/index.cjs +246 -86
  5. package/dist/cjs/decorator/index.cjs +3 -1
  6. package/dist/cjs/enum/index.cjs +17 -4
  7. package/dist/cjs/testing/index.cjs +88 -4
  8. package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
  9. package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
  10. package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
  11. package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
  12. package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
  13. package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
  14. package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
  15. package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
  16. package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
  17. package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
  18. package/dist/esm/bin/generator.js +4 -9
  19. package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
  20. package/dist/esm/core/meocord.app.js +247 -86
  21. package/dist/esm/decorator/controller.decorator.js +73 -10
  22. package/dist/esm/decorator/index.js +1 -1
  23. package/dist/esm/enum/controller.enum.js +23 -4
  24. package/dist/esm/testing/mock-interaction.js +89 -5
  25. package/dist/esm/util/interaction.util.js +174 -0
  26. package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
  27. package/dist/types/core/index.d.ts +73 -0
  28. package/dist/types/decorator/index.d.ts +60 -48
  29. package/dist/types/enum/index.d.ts +1 -1
  30. package/dist/types/interface/index.d.ts +87 -3
  31. package/dist/types/testing/index.d.ts +3 -21
  32. package/package.json +2 -2
  33. package/dist/cjs/_shared/controller.decorator-CC6BjHkS.cjs +0 -288
  34. package/dist/types/controller.enum-QA-IuReF.d.ts +0 -18
@@ -1,12 +1,13 @@
1
1
  import 'reflect-metadata';
2
2
  import { injectable } from 'inversify';
3
- import { ButtonInteraction, StringSelectMenuInteraction, ChatInputCommandInteraction, ContextMenuCommandInteraction, ModalSubmitInteraction } from 'discord.js';
4
3
  import { CommandType } from '../enum/controller.enum.js';
5
4
  import { MetadataKey } from '../enum/metadata-key.enum.js';
5
+ import { matchesCommandType, isCustomIdRouted } from '../util/interaction.util.js';
6
6
 
7
7
  const COMMAND_METADATA_KEY = Symbol('commands');
8
8
  const MESSAGE_HANDLER_METADATA_KEY = Symbol('message_handlers');
9
9
  const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
10
+ const AUTOCOMPLETE_METADATA_KEY = Symbol('autocomplete_handlers');
10
11
  /**
11
12
  * Decorator to register message handlers in the controller.
12
13
  *
@@ -138,8 +139,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
138
139
  /**
139
140
  * Decorator to register command methods in a controller.
140
141
  *
141
- * @param commandName - The name or pattern of the command.
142
- * @param builderOrType - A command builder class or a command type from `CommandType`.
142
+ * @param commandName - What the command is addressed by. Commands registered with
143
+ * Discord use their name, and a subcommand its full path — `settings notify email`,
144
+ * parts separated by a space, the way Discord displays it. Components use a customId
145
+ * pattern, where `{name}` captures one `/`-separated segment.
146
+ * @param builderOrType - A command builder class, or a `CommandType` for a handler that
147
+ * registers nothing of its own: every component, and every subcommand of a command
148
+ * whose builder already describes it.
143
149
  *
144
150
  * @example
145
151
  * ```typescript
@@ -148,9 +154,19 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
148
154
  * await interaction.reply('This is the help command!')
149
155
  * }
150
156
  *
151
- * @Command('stats-{id}', CommandType.BUTTON)
152
- * public async handleStats(message: ButtonInteraction, { id }) {
153
- * await message.reply(`Fetching stats for ID: ${id}`);
157
+ * @Command('settings notify email', CommandType.SLASH)
158
+ * public async handleNotifyEmail(interaction: ChatInputCommandInteraction, { enabled }) {
159
+ * await interaction.reply(`Email notifications ${enabled ? 'on' : 'off'}`)
160
+ * }
161
+ *
162
+ * @Command('stats/{id}', CommandType.BUTTON)
163
+ * public async handleStats(interaction: ButtonInteraction, { id }) {
164
+ * await interaction.reply(`Fetching stats for ID: ${id}`);
165
+ * }
166
+ *
167
+ * @Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
168
+ * public async handleAssign(interaction: UserSelectMenuInteraction, { taskId }) {
169
+ * await interaction.reply(`Assigned ${interaction.users.size} user(s) to ${taskId}`)
154
170
  * }
155
171
  * ```
156
172
  */ function Command(commandName, builderOrType) {
@@ -161,8 +177,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
161
177
  }
162
178
  // Wrap original method for interaction type validation
163
179
  _descriptor.value = function(interaction, params) {
164
- const expectedInteraction = commandType === CommandType.BUTTON && interaction instanceof ButtonInteraction || commandType === CommandType.SELECT_MENU && interaction instanceof StringSelectMenuInteraction || commandType === CommandType.SLASH && interaction instanceof ChatInputCommandInteraction || commandType === CommandType.CONTEXT_MENU && interaction instanceof ContextMenuCommandInteraction || commandType === CommandType.MODAL_SUBMIT && interaction instanceof ModalSubmitInteraction;
165
- if (!expectedInteraction) {
180
+ if (!matchesCommandType(commandType, interaction)) {
166
181
  throw new Error(`Invalid interaction type passed to @Command for method: ${propertyKey}`);
167
182
  }
168
183
  return originalMethod.apply(this, [
@@ -188,7 +203,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
188
203
  } else {
189
204
  commandType = builderOrType;
190
205
  }
191
- if (commandType !== CommandType.SLASH && commandType !== CommandType.CONTEXT_MENU) {
206
+ if (isCustomIdRouted(commandType)) {
192
207
  const { regex: generatedRegex, params, specificity: patternSpecificity } = createRegexFromPattern(commandName);
193
208
  regex = generatedRegex;
194
209
  dynamicParams = params;
@@ -217,6 +232,54 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
217
232
  */ function getCommandMap(controller) {
218
233
  return Reflect.getMetadata(COMMAND_METADATA_KEY, controller);
219
234
  }
235
+ /**
236
+ * Decorator to register an autocomplete handler for a chat input command's option.
237
+ *
238
+ * Autocomplete is a separate interaction from the command it belongs to, and Discord
239
+ * sends it while the user is still typing. It is not a `@Command`: nothing is
240
+ * registered for it — the option's own `setAutocomplete(true)` is what turns it on —
241
+ * and it is answered with `interaction.respond()` rather than a reply. Leaving it
242
+ * unhandled is not silent to the user: the client shows a loading state until the
243
+ * three-second window closes.
244
+ *
245
+ * @param commandPath - The command to complete, e.g. `settings` or `settings notify email`
246
+ * for a subcommand. Parts are separated by a single space, as Discord displays them.
247
+ * @param optionName - The option to complete. Omit to handle every option of the command,
248
+ * branching on `interaction.options.getFocused(true)`.
249
+ *
250
+ * @example
251
+ * ```typescript
252
+ * @Autocomplete('search', 'query')
253
+ * async completeQuery(interaction: AutocompleteInteraction) {
254
+ * const { value } = interaction.options.getFocused(true)
255
+ * await interaction.respond(this.search(value).map(name => ({ name, value: name })))
256
+ * }
257
+ * ```
258
+ */ function Autocomplete(commandPath, optionName) {
259
+ return function(target, propertyKey, _descriptor) {
260
+ const handlers = Reflect.getMetadata(AUTOCOMPLETE_METADATA_KEY, target) || [];
261
+ handlers.push({
262
+ commandPath,
263
+ optionName,
264
+ methodName: propertyKey.toString()
265
+ });
266
+ Reflect.defineMetadata(AUTOCOMPLETE_METADATA_KEY, handlers, target);
267
+ };
268
+ }
269
+ /**
270
+ * Retrieves autocomplete handler metadata from a given controller.
271
+ *
272
+ * Handlers naming an option come first, so a command-wide handler acts as the fallback
273
+ * for options no specific handler claimed rather than shadowing them by declaration order.
274
+ *
275
+ * @param controller - The controller class instance.
276
+ * @returns The registered autocomplete handlers, most specific first.
277
+ */ function getAutocompleteHandlers(controller) {
278
+ const handlers = Reflect.getMetadata(AUTOCOMPLETE_METADATA_KEY, controller) || [];
279
+ return [
280
+ ...handlers
281
+ ].sort((a, b)=>Number(Boolean(b.optionName)) - Number(Boolean(a.optionName)));
282
+ }
220
283
  /**
221
284
  * Decorator to mark a class as a controller that can later be registered to the App class `(app.ts)` using the `@MeoCord` decorator.
222
285
  *
@@ -275,4 +338,4 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
275
338
  return collisions;
276
339
  }
277
340
 
278
- export { Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getCommandMap, getMessageHandlers, getReactionHandlers };
341
+ export { Autocomplete, Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getAutocompleteHandlers, getCommandMap, getMessageHandlers, getReactionHandlers };
@@ -1,5 +1,5 @@
1
1
  export { Service } from './service.decorator.js';
2
2
  export { CommandBuilder } from './command-builder.decorator.js';
3
- export { Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
3
+ export { Autocomplete, Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getAutocompleteHandlers, getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
4
4
  export { Guard, UseGuard } from './guard.decorator.js';
5
5
  export { MeoCord } from './app.decorator.js';
@@ -6,17 +6,36 @@
6
6
  ControllerType["BUTTON"] = "button";
7
7
  ControllerType["MODAL_SUBMIT"] = "modal-submit";
8
8
  ControllerType["SELECT_MENU"] = "select-menu";
9
+ ControllerType["USER_SELECT_MENU"] = "user-select-menu";
10
+ ControllerType["ROLE_SELECT_MENU"] = "role-select-menu";
11
+ ControllerType["MENTIONABLE_SELECT_MENU"] = "mentionable-select-menu";
12
+ ControllerType["CHANNEL_SELECT_MENU"] = "channel-select-menu";
9
13
  ControllerType["REACTION"] = "reaction";
10
14
  ControllerType["MESSAGE"] = "message";
11
15
  ControllerType["SLASH"] = "slash";
16
+ ControllerType["AUTOCOMPLETE"] = "autocomplete";
12
17
  ControllerType["CONTEXT_MENU"] = "context-menu";
18
+ ControllerType["PRIMARY_ENTRY_POINT"] = "primary-entry-point";
13
19
  return ControllerType;
14
20
  }({});
15
- var CommandType = /*#__PURE__*/ function(CommandType) {
16
- CommandType["SLASH"] = "SLASH";
21
+ /**
22
+ * The kinds of interaction a `@Command` method can be bound to.
23
+ *
24
+ * Each member names one Discord interaction shape rather than a family of them, so a
25
+ * handler's parameter type follows from its command type alone. That is why the four
26
+ * entity select menus are separate members instead of one `SELECT_MENU`: Discord sends
27
+ * them as distinct component types (5-8) carrying different resolved data, and
28
+ * collapsing them would leave the handler with a union it has to re-narrow by hand.
29
+ */ var CommandType = /*#__PURE__*/ function(CommandType) {
30
+ /** Chat input command, or one subcommand of it. */ CommandType["SLASH"] = "SLASH";
31
+ /** User or message context menu command. */ CommandType["CONTEXT_MENU"] = "CONTEXT_MENU";
32
+ /** Activity launch command (`ApplicationCommandType.PrimaryEntryPoint`). */ CommandType["PRIMARY_ENTRY_POINT"] = "PRIMARY_ENTRY_POINT";
17
33
  CommandType["BUTTON"] = "BUTTON";
18
- CommandType["CONTEXT_MENU"] = "CONTEXT_MENU";
19
- CommandType["SELECT_MENU"] = "SELECT_MENU";
34
+ /** String select menu — the one whose options the application defines itself. */ CommandType["SELECT_MENU"] = "SELECT_MENU";
35
+ CommandType["USER_SELECT_MENU"] = "USER_SELECT_MENU";
36
+ CommandType["ROLE_SELECT_MENU"] = "ROLE_SELECT_MENU";
37
+ CommandType["MENTIONABLE_SELECT_MENU"] = "MENTIONABLE_SELECT_MENU";
38
+ CommandType["CHANNEL_SELECT_MENU"] = "CHANNEL_SELECT_MENU";
20
39
  CommandType["MODAL_SUBMIT"] = "MODAL_SUBMIT";
21
40
  return CommandType;
22
41
  }({});
@@ -1,6 +1,6 @@
1
1
  import 'reflect-metadata';
2
2
  import { createMockFn } from './mock-fn.js';
3
- import { InteractionType, ComponentType, ApplicationCommandType, CommandInteractionOptionResolver, GuildMessageManager, ThreadManager, DMMessageManager, MessageManager, ThreadMemberManager, Client, ApplicationCommandManager, UserManager, ChannelManager, GuildManager, ClientUser, Guild, GuildMemberManager, GuildChannelManager, RoleManager, GuildBanManager, Message, User, GuildMember, TextChannel, ThreadChannel, MessageMentions } from 'discord.js';
3
+ import { InteractionType, ComponentType, ApplicationCommandType, CommandInteractionOptionResolver, GuildMessageManager, ThreadManager, DMMessageManager, MessageManager, ThreadMemberManager, Client, ApplicationCommandManager, UserManager, ChannelManager, GuildManager, ClientUser, Guild, GuildMemberManager, GuildChannelManager, RoleManager, GuildBanManager, Message, User, GuildMember, TextChannel, ThreadChannel, MessageMentions, ApplicationCommandOptionType, Role, BaseChannel, Attachment } from 'discord.js';
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // stubDeep — Proxy that auto-creates a mock fn on any property access
@@ -140,7 +140,9 @@ const TYPE_GUARD_METHODS = [
140
140
  'isMentionableSelectMenu',
141
141
  'isChannelSelectMenu',
142
142
  'isAnySelectMenu',
143
- 'isSelectMenu',
143
+ // `isSelectMenu` is intentionally absent: discord.js deprecated it in favour of
144
+ // `isStringSelectMenu`, and wiring it here would emit a deprecation warning on every
145
+ // mock that has it on its prototype.
144
146
  'isModalSubmit',
145
147
  'isAutocomplete',
146
148
  'isRepliable'
@@ -210,9 +212,11 @@ function findPrototypeMethod(instance, name) {
210
212
  instance.ephemeral = false;
211
213
  const alreadyReplied = ()=>new Error('The reply to this interaction has already been sent or deferred.');
212
214
  const notYetReplied = (method)=>new Error(`Cannot call ${method}() before replying or deferring.`);
215
+ // Only `flags` is read: the `ephemeral: true` reply option is deprecated in
216
+ // discord.js, and honouring it here would let a test pass against a call the
217
+ // library has stopped supporting.
213
218
  const hasEphemeralFlag = (options)=>{
214
219
  if (!options) return false;
215
- if (options.ephemeral === true) return true;
216
220
  const { flags } = options;
217
221
  if (typeof flags === 'number') return (flags & 64) !== 0;
218
222
  if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
@@ -253,6 +257,17 @@ function findPrototypeMethod(instance, name) {
253
257
  }));
254
258
  }
255
259
  }
260
+ // Autocomplete is not repliable, but it has a response of its own: Discord accepts
261
+ // one `respond()` per interaction and rejects the second. Without `responded` set
262
+ // here it would read as an auto-stubbed object -- truthy -- and any code that checks
263
+ // it before answering would decide the window was already closed.
264
+ if (instance.type === InteractionType.ApplicationCommandAutocomplete) {
265
+ instance.responded = false;
266
+ stubs.set('respond', createMockFn(async ()=>{
267
+ if (instance.responded) throw new Error('The reply to this interaction has already been sent or deferred.');
268
+ instance.responded = true;
269
+ }));
270
+ }
256
271
  // Applied last so an explicit prop wins over the type fields and the reply
257
272
  // state machine. defineProperty rather than assignment for the same reason the
258
273
  // Proxy uses it: several of these shadow a getter-only prototype accessor.
@@ -532,7 +547,64 @@ function createMockMessage() {
532
547
  * interaction.options.getSubcommand() // → 'notes'
533
548
  * interaction.options.getNumber('uid') // → 12345678
534
549
  * ```
535
- */ // `any` is the default rather than `CacheType` because TypeScript types a generic
550
+ */ /** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
551
+ if (typeof value === 'boolean') return ApplicationCommandOptionType.Boolean;
552
+ if (typeof value === 'number') return ApplicationCommandOptionType.Number;
553
+ if (value instanceof User) return ApplicationCommandOptionType.User;
554
+ if (value instanceof GuildMember) return ApplicationCommandOptionType.User;
555
+ if (value instanceof Role) return ApplicationCommandOptionType.Role;
556
+ if (value instanceof BaseChannel) return ApplicationCommandOptionType.Channel;
557
+ if (value instanceof Attachment) return ApplicationCommandOptionType.Attachment;
558
+ if (typeof value === 'object' && value !== null) return ApplicationCommandOptionType.Mentionable;
559
+ return ApplicationCommandOptionType.String;
560
+ }
561
+ /**
562
+ * Shapes one supplied option the way the gateway sends it.
563
+ *
564
+ * An entity option arrives as a snowflake in `value` *and* as the resolved object on
565
+ * its own field, and code that reads only one of the two is exactly what this lets a
566
+ * test catch — so both are set.
567
+ */ function toOptionData(name, value) {
568
+ const type = optionTypeOf(value);
569
+ const isEntity = typeof value === 'object' && value !== null;
570
+ const option = {
571
+ name,
572
+ type,
573
+ value: isEntity ? value.id : value
574
+ };
575
+ if (value instanceof User) option.user = value;
576
+ else if (value instanceof GuildMember) option.member = value;
577
+ else if (value instanceof Role) option.role = value;
578
+ else if (value instanceof BaseChannel) option.channel = value;
579
+ else if (value instanceof Attachment) option.attachment = value;
580
+ else if (isEntity) option.user = value;
581
+ return option;
582
+ }
583
+ /**
584
+ * Nests the supplied options under the subcommand path they were invoked through,
585
+ * matching the shape Discord sends rather than a flat list.
586
+ */ function buildOptionData(subcommandGroup, subcommand, values) {
587
+ const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value));
588
+ if (subcommand === null) return leaves;
589
+ const sub = {
590
+ name: subcommand,
591
+ type: ApplicationCommandOptionType.Subcommand,
592
+ options: leaves
593
+ };
594
+ if (subcommandGroup === null) return [
595
+ sub
596
+ ];
597
+ return [
598
+ {
599
+ name: subcommandGroup,
600
+ type: ApplicationCommandOptionType.SubcommandGroup,
601
+ options: [
602
+ sub
603
+ ]
604
+ }
605
+ ];
606
+ }
607
+ // `any` is the default rather than `CacheType` because TypeScript types a generic
536
608
  // class's `prototype` with `any` for its parameters, and createMockInteraction infers
537
609
  // T from exactly that — `createMockInteraction(ChatInputCommandInteraction)` produces
538
610
  // an interaction whose `options` is `CommandInteractionOptionResolver<any>`. Defaulting
@@ -540,7 +612,7 @@ function createMockMessage() {
540
612
  // the target rejects, and the resolver stops being assignable to the property it exists
541
613
  // to fill. Pass Cached explicitly when the interaction under test is pinned.
542
614
  function createChatInputOptions(opts = {}) {
543
- const { subcommandGroup = null, subcommand = null, ...values } = opts;
615
+ const { subcommandGroup = null, subcommand = null, focused = null, ...values } = opts;
544
616
  function resolveOrThrow(name, value, required) {
545
617
  if (value === null) {
546
618
  if (required === true) throw new Error(`Option "${name}" is required but was not provided.`);
@@ -571,6 +643,18 @@ function createChatInputOptions(opts = {}) {
571
643
  base.getChannel = createMockFn(getObjectOption);
572
644
  base.getMember = createMockFn(getObjectOption);
573
645
  base.getMentionable = createMockFn(getObjectOption);
646
+ base.getFocused = createMockFn((getFull)=>{
647
+ if (focused === null) throw new Error('No focused option found.');
648
+ const option = toOptionData(focused, values[focused] ?? null);
649
+ return getFull === true ? {
650
+ ...option,
651
+ focused: true
652
+ } : option.value;
653
+ });
654
+ // `data` is what the framework reads to build a handler's params, and it is the one
655
+ // part of the resolver that is not a method — so it has to be materialised here
656
+ // rather than auto-stubbed, or every params assertion would see an empty record.
657
+ base.data = buildOptionData(subcommandGroup, subcommand, values);
574
658
  return stubDeep(base);
575
659
  }
576
660
 
@@ -0,0 +1,174 @@
1
+ import { ApplicationCommandOptionType, MessageComponentInteraction, ModalSubmitInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, RoleSelectMenuInteraction, UserSelectMenuInteraction, StringSelectMenuInteraction, ButtonInteraction, PrimaryEntryPointCommandInteraction, ContextMenuCommandInteraction, ChatInputCommandInteraction } from 'discord.js';
2
+ import { CommandType } from '../enum/controller.enum.js';
3
+
4
+ /**
5
+ * The discord.js class each command type is handled by.
6
+ *
7
+ * One table rather than a chain of `isButton() || isStringSelectMenu() || ...`: the
8
+ * registration guard in `@Command`, the dispatcher, and the type-level
9
+ * `CommandInteractionType` all have to agree on what a command type accepts, and a
10
+ * chain repeated in three files drifts the moment a fifth select menu appears. Adding
11
+ * a `CommandType` member without an entry here is a compile error, not a silent
12
+ * fall-through to "Command not found!".
13
+ */ const INTERACTION_MATCHERS = {
14
+ [CommandType.SLASH]: (interaction)=>interaction instanceof ChatInputCommandInteraction,
15
+ [CommandType.CONTEXT_MENU]: (interaction)=>interaction instanceof ContextMenuCommandInteraction,
16
+ [CommandType.PRIMARY_ENTRY_POINT]: (interaction)=>interaction instanceof PrimaryEntryPointCommandInteraction,
17
+ [CommandType.BUTTON]: (interaction)=>interaction instanceof ButtonInteraction,
18
+ [CommandType.SELECT_MENU]: (interaction)=>interaction instanceof StringSelectMenuInteraction,
19
+ [CommandType.USER_SELECT_MENU]: (interaction)=>interaction instanceof UserSelectMenuInteraction,
20
+ [CommandType.ROLE_SELECT_MENU]: (interaction)=>interaction instanceof RoleSelectMenuInteraction,
21
+ [CommandType.MENTIONABLE_SELECT_MENU]: (interaction)=>interaction instanceof MentionableSelectMenuInteraction,
22
+ [CommandType.CHANNEL_SELECT_MENU]: (interaction)=>interaction instanceof ChannelSelectMenuInteraction,
23
+ [CommandType.MODAL_SUBMIT]: (interaction)=>interaction instanceof ModalSubmitInteraction
24
+ };
25
+ /** Command types Discord identifies by a registered name rather than by a customId. */ const NAME_ROUTED_TYPES = new Set([
26
+ CommandType.SLASH,
27
+ CommandType.CONTEXT_MENU,
28
+ CommandType.PRIMARY_ENTRY_POINT
29
+ ]);
30
+ /**
31
+ * Whether an interaction is the kind the given command type handles.
32
+ *
33
+ * @param type - The command type declared on `@Command`.
34
+ * @param interaction - The interaction being dispatched.
35
+ */ function matchesCommandType(type, interaction) {
36
+ const matches = INTERACTION_MATCHERS[type];
37
+ return matches !== undefined && matches(interaction);
38
+ }
39
+ /**
40
+ * Whether the command type is routed by matching a customId pattern.
41
+ *
42
+ * Components carry an application-defined customId and so are matched by pattern;
43
+ * commands carry a name Discord itself registered and are matched exactly.
44
+ *
45
+ * @param type - The command type declared on `@Command`.
46
+ */ function isCustomIdRouted(type) {
47
+ return !NAME_ROUTED_TYPES.has(type);
48
+ }
49
+ /**
50
+ * Whether an interaction carries a customId, and so can be routed by pattern.
51
+ *
52
+ * @param interaction - The interaction being dispatched.
53
+ */ function hasCustomId(interaction) {
54
+ return interaction instanceof MessageComponentInteraction || interaction instanceof ModalSubmitInteraction;
55
+ }
56
+ /** Separates a command from its subcommand group and subcommand in a route key. */ const COMMAND_PATH_SEPARATOR = ' ';
57
+ /**
58
+ * The route keys a chat input interaction can be handled by, most specific first.
59
+ *
60
+ * Discord sends `/settings notify email` as one interaction named `settings`, so
61
+ * routing on `commandName` alone gives every subcommand of a command the same handler
62
+ * — and the framework would run whichever one was declared first. The full path is
63
+ * tried before the bare name so a command can either split its subcommands across
64
+ * methods or keep handling them in one, but never both by accident.
65
+ *
66
+ * A group is never dropped on the way down: `settings notify email` does not fall back
67
+ * to `settings email`, because a second group could declare its own `email` and the
68
+ * two would be indistinguishable.
69
+ *
70
+ * @param interaction - The chat input or autocomplete interaction being dispatched.
71
+ * @returns The keys to look up, most specific first.
72
+ */ function resolveCommandPaths(interaction) {
73
+ const { commandName } = interaction;
74
+ const options = interaction.options;
75
+ // Guarded rather than called directly: `options` is a stub on a hand-built test
76
+ // double, and an interaction whose command has no subcommands still has to route.
77
+ const group = typeof options?.getSubcommandGroup === 'function' ? options.getSubcommandGroup(false) : null;
78
+ const sub = typeof options?.getSubcommand === 'function' ? options.getSubcommand(false) : null;
79
+ const path = [
80
+ commandName,
81
+ group,
82
+ sub
83
+ ].filter((part)=>Boolean(part));
84
+ const full = path.join(COMMAND_PATH_SEPARATOR);
85
+ return full === commandName ? [
86
+ commandName
87
+ ] : [
88
+ full,
89
+ commandName
90
+ ];
91
+ }
92
+ /** Options that only wrap other options; their values live one level down. */ const NESTING_OPTION_TYPES = new Set([
93
+ ApplicationCommandOptionType.Subcommand,
94
+ ApplicationCommandOptionType.SubcommandGroup
95
+ ]);
96
+ /**
97
+ * The value a handler should receive for one option.
98
+ *
99
+ * Discord sends entity options as a snowflake plus a `resolved` payload, and discord.js
100
+ * puts that payload on the option as `user`/`role`/`channel`/`attachment`. Passing
101
+ * `value` alone would hand the handler a bare id string for `@user`, forcing every
102
+ * handler to re-fetch what the gateway already delivered.
103
+ */ function resolveOptionValue(option) {
104
+ return option.attachment ?? option.channel ?? option.role ?? option.user ?? option.member ?? option.value;
105
+ }
106
+ /**
107
+ * Flattens a chat input interaction's options into the params record handlers receive.
108
+ *
109
+ * Subcommand and subcommand-group options are containers, not values — for
110
+ * `/settings notify email true` the top level holds only `notify`. Recursing past them
111
+ * means a subcommand handler sees `{ email: true }`, the same shape a flat command's
112
+ * handler sees.
113
+ *
114
+ * @param interaction - The chat input or autocomplete interaction being dispatched.
115
+ * @returns Each supplied option keyed by name, with entity options resolved.
116
+ */ function resolveOptionParams(interaction) {
117
+ const data = interaction.options?.data;
118
+ if (!Array.isArray(data)) return {};
119
+ const params = {};
120
+ const walk = (options)=>{
121
+ for (const option of options){
122
+ if (NESTING_OPTION_TYPES.has(option.type)) {
123
+ walk(option.options ?? []);
124
+ continue;
125
+ }
126
+ params[option.name] = resolveOptionValue(option);
127
+ }
128
+ };
129
+ walk(data);
130
+ return params;
131
+ }
132
+ /**
133
+ * The name of the option the user is currently typing, or `undefined`.
134
+ *
135
+ * `getFocused` throws when nothing is focused rather than returning null, and it is
136
+ * absent altogether on a hand-built test double. Neither is worth failing a dispatch
137
+ * over — an autocomplete with no focused option simply matches no option-specific
138
+ * handler.
139
+ *
140
+ * @param interaction - The autocomplete interaction being dispatched.
141
+ */ function focusedOptionName(interaction) {
142
+ if (typeof interaction.options?.getFocused !== 'function') return undefined;
143
+ try {
144
+ return interaction.options.getFocused(true)?.name;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+ /**
150
+ * Identifies an interaction in a log line, by whichever field would have routed it.
151
+ *
152
+ * @param interaction - The interaction that matched no handler.
153
+ */ function describeInteraction(interaction) {
154
+ // Captured before the narrowing below: the guards cover every member of the union,
155
+ // so by the fallback `interaction` is `never` and nothing can be read off it.
156
+ const { type } = interaction;
157
+ if (interaction.isAutocomplete()) {
158
+ const path = resolveCommandPaths(interaction)[0];
159
+ const focused = focusedOptionName(interaction);
160
+ return focused === undefined ? `autocomplete for "${path}"` : `autocomplete for "${path}" option "${focused}"`;
161
+ }
162
+ if (interaction.isChatInputCommand()) {
163
+ return `command "${resolveCommandPaths(interaction)[0]}"`;
164
+ }
165
+ if (interaction.isContextMenuCommand() || interaction.isPrimaryEntryPointCommand()) {
166
+ return `command "${interaction.commandName}"`;
167
+ }
168
+ if (hasCustomId(interaction)) {
169
+ return `customId "${interaction.customId}"`;
170
+ }
171
+ return `interaction type ${type}`;
172
+ }
173
+
174
+ export { COMMAND_PATH_SEPARATOR, describeInteraction, focusedOptionName, hasCustomId, isCustomIdRouted, matchesCommandType, resolveCommandPaths, resolveOptionParams };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The kinds of interaction a `@Command` method can be bound to.
3
+ *
4
+ * Each member names one Discord interaction shape rather than a family of them, so a
5
+ * handler's parameter type follows from its command type alone. That is why the four
6
+ * entity select menus are separate members instead of one `SELECT_MENU`: Discord sends
7
+ * them as distinct component types (5-8) carrying different resolved data, and
8
+ * collapsing them would leave the handler with a union it has to re-narrow by hand.
9
+ */
10
+ declare enum CommandType {
11
+ /** Chat input command, or one subcommand of it. */
12
+ SLASH = "SLASH",
13
+ /** User or message context menu command. */
14
+ CONTEXT_MENU = "CONTEXT_MENU",
15
+ /** Activity launch command (`ApplicationCommandType.PrimaryEntryPoint`). */
16
+ PRIMARY_ENTRY_POINT = "PRIMARY_ENTRY_POINT",
17
+ BUTTON = "BUTTON",
18
+ /** String select menu — the one whose options the application defines itself. */
19
+ SELECT_MENU = "SELECT_MENU",
20
+ USER_SELECT_MENU = "USER_SELECT_MENU",
21
+ ROLE_SELECT_MENU = "ROLE_SELECT_MENU",
22
+ MENTIONABLE_SELECT_MENU = "MENTIONABLE_SELECT_MENU",
23
+ CHANNEL_SELECT_MENU = "CHANNEL_SELECT_MENU",
24
+ MODAL_SUBMIT = "MODAL_SUBMIT"
25
+ }
26
+ /**
27
+ * Enum representing actions that can be performed on a message reaction.
28
+ */
29
+ declare enum ReactionHandlerAction {
30
+ /** Reaction added to a message. */
31
+ ADD = "ADD",
32
+ /** Reaction removed from a message. */
33
+ REMOVE = "REMOVE"
34
+ }
35
+
36
+ export { CommandType as C, ReactionHandlerAction as R };
@@ -19,6 +19,31 @@ declare class MeoCordApp {
19
19
  private activityInterval;
20
20
  private controllerInstancesCache;
21
21
  constructor(controllerClasses: (new (...args: any[]) => any)[], container: Container, discordClient: Client, discordToken: string, activities?: ActivityOptions[] | undefined);
22
+ /**
23
+ * Runs an event handler so a failure inside it cannot take the process down.
24
+ *
25
+ * discord.js calls listeners without awaiting them, so a rejection escaping one has
26
+ * nothing left to settle it: Node reports an unhandled rejection, which terminates
27
+ * the process by default. Losing the whole bot because one reaction landed on a
28
+ * deleted message, or one controller could not be resolved, is a worse failure than
29
+ * the one that caused it -- every other user is served by the same process.
30
+ *
31
+ * Nothing is silenced. The error is logged against the event that produced it, so a
32
+ * genuine misconfiguration -- an unbound controller, a missing dependency -- shows
33
+ * up on the very first interaction rather than staying hidden.
34
+ *
35
+ * @param event - The gateway event being handled, named in the log.
36
+ * @param run - The handler to run.
37
+ */
38
+ private runListener;
39
+ /**
40
+ * Rotates the bot's activity.
41
+ *
42
+ * Guarded separately from {@link runListener}: this runs on a timer rather than an
43
+ * event, and a throw from a timer callback is an uncaught exception no listener
44
+ * wrapper can reach.
45
+ */
46
+ private updateActivity;
22
47
  private getInstance;
23
48
  start(): Promise<void>;
24
49
  registerCommands(): Promise<void>;
@@ -38,12 +63,60 @@ declare class MeoCordApp {
38
63
  * refusing to start would turn a latent mis-route into an outage on upgrade.
39
64
  */
40
65
  private reportAmbiguousRoutes;
66
+ /**
67
+ * Every `@Autocomplete` handler, ordered so an option-specific handler is found
68
+ * before a command-wide one.
69
+ *
70
+ * Cached alongside the component table: autocomplete fires on every keystroke, and
71
+ * rebuilding the list per keystroke would put reflection on the hottest path the
72
+ * framework has.
73
+ */
74
+ private autocompleteRoutes?;
75
+ private getAutocompleteRoutes;
76
+ /**
77
+ * Dispatches an interaction, and makes sure a failure anywhere in that still reaches
78
+ * the person who triggered it.
79
+ *
80
+ * {@link executeCommand} already reports what a handler throws, but everything
81
+ * *before* the handler can fail too — resolving a controller through the container
82
+ * is the common case — and a component that fails there would otherwise look dead
83
+ * with nothing said to the user and nothing in the log.
84
+ */
41
85
  private handleInteraction;
86
+ private dispatchInteraction;
87
+ /**
88
+ * The names a command interaction can be handled under, most specific first.
89
+ *
90
+ * Empty for anything that is not a registered command, which is how a component
91
+ * whose customId matched no pattern falls through to the unmatched warning instead
92
+ * of being looked up under a name it does not have.
93
+ */
94
+ private resolveNameRoutes;
95
+ /**
96
+ * Answers an autocomplete interaction from the `@Autocomplete` handler that claims it.
97
+ *
98
+ * Discord closes the window after three seconds and shows a loading state until
99
+ * something arrives, so an unclaimed option is answered with an empty list rather
100
+ * than left to time out -- a visibly empty menu is a better failure than a stuck one,
101
+ * and the warning says which option is missing a handler.
102
+ */
103
+ private handleAutocomplete;
104
+ /** Closes an autocomplete window that nothing else answered. */
105
+ private respondEmpty;
42
106
  /**
43
107
  * Runs a resolved command, shared by both dispatch paths so a pattern-matched
44
108
  * component and a named slash command behave identically once the route is chosen.
45
109
  */
46
110
  private executeCommand;
111
+ /**
112
+ * Tells the user something went wrong, if the interaction can still hear it.
113
+ *
114
+ * A handler that replies and *then* throws is the common shape of a failure, and
115
+ * replying twice throws in turn -- out of the catch block, where nothing is left to
116
+ * handle it. Whatever the interaction's state, reporting an error must not be able
117
+ * to become a second, worse one.
118
+ */
119
+ private replyWithError;
47
120
  private handleMessage;
48
121
  private handleReaction;
49
122
  private gracefulShutdown;