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
@@ -0,0 +1,16 @@
1
+ import { AutocompleteInteraction } from 'discord.js'
2
+ import { Controller, Autocomplete } from 'meocord/decorator'
3
+
4
+ @Controller()
5
+ export class {{className}}AutocompleteController {
6
+
7
+ // The option this completes must be declared with `.setAutocomplete(true)` on the
8
+ // slash command builder, otherwise Discord never sends the interaction.
9
+ @Autocomplete('sample-slash', 'query')
10
+ async completeQuery(interaction: AutocompleteInteraction) {
11
+ const { value } = interaction.options.getFocused(true)
12
+ const choices = ['alpha', 'beta', 'gamma'].filter(choice => choice.startsWith(value))
13
+
14
+ await interaction.respond(choices.map(choice => ({ name: choice, value: choice })))
15
+ }
16
+ }
@@ -0,0 +1,12 @@
1
+ import { ChannelSelectMenuInteraction } from 'discord.js'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { CommandType } from 'meocord/enum'
4
+
5
+ @Controller()
6
+ export class {{className}}ChannelSelectMenuController {
7
+
8
+ @Command('channel-select-menu', CommandType.CHANNEL_SELECT_MENU)
9
+ async handleChannelSelectMenu(interaction: ChannelSelectMenuInteraction) {
10
+ await interaction.reply(`Selected: ${interaction.channels.map(channel => channel.toString()).join(', ')}`)
11
+ }
12
+ }
@@ -1,6 +1,6 @@
1
1
  import { UserContextMenuCommandInteraction, MessageContextMenuCommandInteraction } from 'discord.js'
2
- import { Controller, Command, CommandType } from 'meocord/decorator'
3
- import { SampleCommandBuilder } from '@src/controllers/context-menu/builders/sample.builder'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { SampleCommandBuilder } from '{{builderImportPath}}'
4
4
 
5
5
  @Controller()
6
6
  export class {{className}}ContextMenuController {
@@ -0,0 +1,12 @@
1
+ import { MentionableSelectMenuInteraction } from 'discord.js'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { CommandType } from 'meocord/enum'
4
+
5
+ @Controller()
6
+ export class {{className}}MentionableSelectMenuController {
7
+
8
+ @Command('mentionable-select-menu', CommandType.MENTIONABLE_SELECT_MENU)
9
+ async handleMentionableSelectMenu(interaction: MentionableSelectMenuInteraction) {
10
+ await interaction.reply(`Selected ${interaction.users.size} user(s) and ${interaction.roles.size} role(s).`)
11
+ }
12
+ }
@@ -3,7 +3,7 @@ import { Controller, Command } from 'meocord/decorator'
3
3
  import { CommandType } from 'meocord/enum'
4
4
 
5
5
  @Controller()
6
- export class {{className}}ModalController {
6
+ export class {{className}}ModalSubmitController {
7
7
 
8
8
  @Command('submit-modal', CommandType.MODAL_SUBMIT)
9
9
  async handleModal(interaction: ModalSubmitInteraction) {
@@ -0,0 +1,12 @@
1
+ import { PrimaryEntryPointCommandInteraction } from 'discord.js'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { SampleCommandBuilder } from '{{builderImportPath}}'
4
+
5
+ @Controller()
6
+ export class {{className}}PrimaryEntryPointController {
7
+
8
+ @Command('sample-entry-point', SampleCommandBuilder)
9
+ async handleEntryPoint(interaction: PrimaryEntryPointCommandInteraction) {
10
+ await interaction.reply('Launching the activity.')
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ import { RoleSelectMenuInteraction } from 'discord.js'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { CommandType } from 'meocord/enum'
4
+
5
+ @Controller()
6
+ export class {{className}}RoleSelectMenuController {
7
+
8
+ @Command('role-select-menu', CommandType.ROLE_SELECT_MENU)
9
+ async handleRoleSelectMenu(interaction: RoleSelectMenuInteraction) {
10
+ await interaction.reply(`Selected: ${interaction.roles.map(role => role.name).join(', ')}`)
11
+ }
12
+ }
@@ -1,6 +1,6 @@
1
1
  import { ChatInputCommandInteraction } from 'discord.js'
2
2
  import { Controller, Command } from 'meocord/decorator'
3
- import { SampleCommandBuilder } from '@src/controllers/slash/builders/sample.builder'
3
+ import { SampleCommandBuilder } from '{{builderImportPath}}'
4
4
 
5
5
  @Controller()
6
6
  export class {{className}}SlashController {
@@ -0,0 +1,12 @@
1
+ import { UserSelectMenuInteraction } from 'discord.js'
2
+ import { Controller, Command } from 'meocord/decorator'
3
+ import { CommandType } from 'meocord/enum'
4
+
5
+ @Controller()
6
+ export class {{className}}UserSelectMenuController {
7
+
8
+ @Command('user-select-menu', CommandType.USER_SELECT_MENU)
9
+ async handleUserSelectMenu(interaction: UserSelectMenuInteraction) {
10
+ await interaction.reply(`Selected: ${interaction.users.map(user => user.username).join(', ')}`)
11
+ }
12
+ }
@@ -1,4 +1,5 @@
1
1
  import { Argument } from 'commander';
2
+ import { ControllerType } from '../enum/controller.enum.js';
2
3
  import { ControllerGeneratorHelper } from './helper/controller-generator.helper.js';
3
4
  import { Logger } from '../common/logger.js';
4
5
  import '../common/theme.js';
@@ -9,15 +10,9 @@ import wait from '../util/wait.util.js';
9
10
  class GeneratorCLI {
10
11
  register(program) {
11
12
  const generatorCommand = program.command('generate').alias('g').description('Generate components');
12
- generatorCommand.command('controller').alias('co').description('Generate a controller component').addArgument(new Argument('<type>', 'Type of the controller (e.g., button, context-menu, etc.)').choices([
13
- 'button',
14
- 'context-menu',
15
- 'message',
16
- 'modal-submit',
17
- 'reaction',
18
- 'select-menu',
19
- 'slash'
20
- ])).addArgument(new Argument('<name>', 'Name of the controller')).action(async (type, name)=>{
13
+ generatorCommand.command('controller').alias('co').description('Generate a controller component').addArgument(// Derived from the enum rather than listed by hand, so a new controller kind
14
+ // cannot be added to the framework and stay unreachable from the CLI.
15
+ new Argument('<type>', 'Type of the controller (e.g., button, context-menu, etc.)').choices(Object.values(ControllerType))).addArgument(new Argument('<name>', 'Name of the controller')).action(async (type, name)=>{
21
16
  await this.handleGenerateComponent({
22
17
  component: 'controller',
23
18
  type: type,
@@ -14,7 +14,7 @@ class ControllerGeneratorHelper {
14
14
  */ generateController(args, type) {
15
15
  const { parts, kebabCaseName, className } = validateAndFormatName(args.controllerName);
16
16
  const controllerDir = path.join(process.cwd(), 'src', 'controllers', type, ...parts);
17
- const template = this.buildControllerTemplate(className, type);
17
+ const template = this.buildControllerTemplate(className, type, parts);
18
18
  this.generateControllerStructure(controllerDir, kebabCaseName, className, type, template);
19
19
  }
20
20
  /**
@@ -23,8 +23,8 @@ class ControllerGeneratorHelper {
23
23
  * @param type - The type of the controller, defined in the `ControllerType` enum.
24
24
  * @returns The populated template string for the controller.
25
25
  * @throws Will throw an error if the controller type is unsupported.
26
- */ buildControllerTemplate(className, type) {
27
- const templateConfig = this.getTemplateConfig(type, className);
26
+ */ buildControllerTemplate(className, type, parts = []) {
27
+ const templateConfig = this.getTemplateConfig(type, className, parts);
28
28
  if (!templateConfig) {
29
29
  throw new Error(`Unsupported controller type: ${type}`);
30
30
  }
@@ -35,22 +35,38 @@ class ControllerGeneratorHelper {
35
35
  * @param type - The type of the controller, defined in the `ControllerType` enum.
36
36
  * @param className - The name of the controller class.
37
37
  * @returns An object containing the template path and variables, or `undefined` if not found.
38
- */ getTemplateConfig(type, className) {
38
+ */ getTemplateConfig(type, className, parts = []) {
39
39
  const baseDir = path.resolve(__dirname$1, '..', 'builder-template', 'controller');
40
40
  const templates = {
41
41
  [ControllerType.BUTTON]: 'button.controller.template',
42
42
  [ControllerType.MODAL_SUBMIT]: 'modal-submit.controller.template',
43
43
  [ControllerType.SELECT_MENU]: 'select-menu.controller.template',
44
+ [ControllerType.USER_SELECT_MENU]: 'user-select-menu.controller.template',
45
+ [ControllerType.ROLE_SELECT_MENU]: 'role-select-menu.controller.template',
46
+ [ControllerType.MENTIONABLE_SELECT_MENU]: 'mentionable-select-menu.controller.template',
47
+ [ControllerType.CHANNEL_SELECT_MENU]: 'channel-select-menu.controller.template',
44
48
  [ControllerType.REACTION]: 'reaction.controller.template',
45
49
  [ControllerType.MESSAGE]: 'message.controller.template',
46
50
  [ControllerType.CONTEXT_MENU]: 'context-menu.controller.template',
47
- [ControllerType.SLASH]: 'slash.controller.template'
51
+ [ControllerType.SLASH]: 'slash.controller.template',
52
+ [ControllerType.AUTOCOMPLETE]: 'autocomplete.controller.template',
53
+ [ControllerType.PRIMARY_ENTRY_POINT]: 'primary-entry-point.controller.template'
48
54
  };
49
55
  const template = templates[type] ? path.resolve(baseDir, templates[type]) : undefined;
56
+ // The builder is written beside the controller, so a nested controller name moves
57
+ // it too. Hard-coding `@src/controllers/<type>/builders/...` only ever pointed at
58
+ // the top-level one, which for a nested name does not exist.
59
+ const builderImportPath = [
60
+ '@src/controllers',
61
+ type,
62
+ ...parts,
63
+ 'builders/sample.builder'
64
+ ].join('/');
50
65
  return template ? {
51
66
  template,
52
67
  variables: {
53
- className
68
+ className,
69
+ builderImportPath
54
70
  }
55
71
  } : undefined;
56
72
  }
@@ -99,7 +115,8 @@ class ControllerGeneratorHelper {
99
115
  const baseDir = path.resolve(__dirname$1, '..', 'builder-template', 'builder');
100
116
  const templates = {
101
117
  [ControllerType.CONTEXT_MENU]: 'context-menu.builder.template',
102
- [ControllerType.SLASH]: 'slash.builder.template'
118
+ [ControllerType.SLASH]: 'slash.builder.template',
119
+ [ControllerType.PRIMARY_ENTRY_POINT]: 'primary-entry-point.builder.template'
103
120
  };
104
121
  const template = templates[type] ? path.resolve(baseDir, templates[type]) : undefined;
105
122
  return template ? {
@@ -1,22 +1,66 @@
1
1
  import { SlashCommandBuilder, MessageFlagsBitField } from 'discord.js';
2
2
  import { Logger } from '../common/logger.js';
3
3
  import '../common/theme.js';
4
- import { getCommandMap, findAmbiguousRoutes, PARAM_SEPARATOR, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
4
+ import { getCommandMap, findAmbiguousRoutes, PARAM_SEPARATOR, getAutocompleteHandlers, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
5
5
  import { sample } from 'lodash-es';
6
6
  import { createErrorEmbed } from '../util/embed.util.js';
7
+ import { describeInteraction, hasCustomId, matchesCommandType, resolveCommandPaths, focusedOptionName, resolveOptionParams } from '../util/interaction.util.js';
7
8
  import { ReactionHandlerAction, CommandType } from '../enum/controller.enum.js';
8
9
  import CliTable3 from 'cli-table3';
9
10
 
10
- /** Identifies an unmatched interaction for the log, by whichever field would have routed it. */ function describeUnmatched(interaction) {
11
- if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) {
12
- return `command "${interaction.commandName}"`;
11
+ /**
12
+ * The name a builder registers under, which is what Discord deduplicates on.
13
+ *
14
+ * Read from the built payload rather than from the `@Command` argument: a builder is
15
+ * free to name the command something other than the string it was handed, and it is
16
+ * the payload Discord sees.
17
+ */ function commandNameOf(builder) {
18
+ try {
19
+ const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
20
+ return typeof json?.name === 'string' ? json.name : undefined;
21
+ } catch {
22
+ // A builder missing a required field throws from toJSON. Surfacing that is the
23
+ // registration call's job, where it is reported against the command Discord
24
+ // rejected -- deduplication should not be what turns it into a startup crash.
25
+ return undefined;
13
26
  }
14
- if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
15
- return `customId "${interaction.customId}"`;
16
- }
17
- return `interaction type ${interaction.type}`;
18
27
  }
19
28
  class MeoCordApp {
29
+ /**
30
+ * Runs an event handler so a failure inside it cannot take the process down.
31
+ *
32
+ * discord.js calls listeners without awaiting them, so a rejection escaping one has
33
+ * nothing left to settle it: Node reports an unhandled rejection, which terminates
34
+ * the process by default. Losing the whole bot because one reaction landed on a
35
+ * deleted message, or one controller could not be resolved, is a worse failure than
36
+ * the one that caused it -- every other user is served by the same process.
37
+ *
38
+ * Nothing is silenced. The error is logged against the event that produced it, so a
39
+ * genuine misconfiguration -- an unbound controller, a missing dependency -- shows
40
+ * up on the very first interaction rather than staying hidden.
41
+ *
42
+ * @param event - The gateway event being handled, named in the log.
43
+ * @param run - The handler to run.
44
+ */ async runListener(event, run) {
45
+ try {
46
+ await run();
47
+ } catch (error) {
48
+ this.logger.error(`Unhandled error while handling "${event}":`, error);
49
+ }
50
+ }
51
+ /**
52
+ * Rotates the bot's activity.
53
+ *
54
+ * Guarded separately from {@link runListener}: this runs on a timer rather than an
55
+ * event, and a throw from a timer callback is an uncaught exception no listener
56
+ * wrapper can reach.
57
+ */ updateActivity() {
58
+ try {
59
+ this.bot.user?.setActivity(sample(this.activities));
60
+ } catch (error) {
61
+ this.logger.error('Could not update the bot activity:', error);
62
+ }
63
+ }
20
64
  getInstance(controllerClass) {
21
65
  if (!this.controllerInstancesCache.has(controllerClass)) {
22
66
  this.controllerInstancesCache.set(controllerClass, this.container.get(controllerClass));
@@ -26,30 +70,20 @@ class MeoCordApp {
26
70
  async start() {
27
71
  try {
28
72
  this.logger.log('Starting bot...');
29
- this.bot.on('clientReady', async ()=>{
30
- this.activityInterval = setInterval(()=>{
31
- this.bot.user?.setActivity(sample(this.activities));
32
- }, 10000);
33
- await this.registerCommands();
34
- });
35
- this.bot.on('interactionCreate', async (interaction)=>{
36
- await this.handleInteraction(interaction);
37
- });
38
- this.bot.on('messageCreate', async (message)=>{
39
- await this.handleMessage(message);
40
- });
41
- this.bot.on('messageReactionAdd', async (reaction, user)=>{
42
- await this.handleReaction(reaction, {
43
- user,
44
- action: ReactionHandlerAction.ADD
45
- });
46
- });
47
- this.bot.on('messageReactionRemove', async (reaction, user)=>{
48
- await this.handleReaction(reaction, {
49
- user,
50
- action: ReactionHandlerAction.REMOVE
51
- });
52
- });
73
+ this.bot.on('clientReady', ()=>this.runListener('clientReady', async ()=>{
74
+ this.activityInterval = setInterval(()=>this.updateActivity(), 10000);
75
+ await this.registerCommands();
76
+ }));
77
+ this.bot.on('interactionCreate', (interaction)=>this.runListener('interactionCreate', ()=>this.handleInteraction(interaction)));
78
+ this.bot.on('messageCreate', (message)=>this.runListener('messageCreate', ()=>this.handleMessage(message)));
79
+ this.bot.on('messageReactionAdd', (reaction, user)=>this.runListener('messageReactionAdd', ()=>this.handleReaction(reaction, {
80
+ user,
81
+ action: ReactionHandlerAction.ADD
82
+ })));
83
+ this.bot.on('messageReactionRemove', (reaction, user)=>this.runListener('messageReactionRemove', ()=>this.handleReaction(reaction, {
84
+ user,
85
+ action: ReactionHandlerAction.REMOVE
86
+ })));
53
87
  await this.bot.login(this.discordToken);
54
88
  this.logger.log('Bot is online!');
55
89
  } catch (error) {
@@ -57,7 +91,11 @@ class MeoCordApp {
57
91
  }
58
92
  }
59
93
  async registerCommands() {
60
- const builders = [];
94
+ // Keyed by registered name: a command whose subcommands live in separate methods
95
+ // declares the same name more than once, and sending its builder twice makes
96
+ // Discord reject the whole payload. The first builder wins, and a second one that
97
+ // is not the same object is reported rather than silently dropped.
98
+ const buildersByName = new Map();
61
99
  for (const controllerClass of this.controllerClasses){
62
100
  const instance = this.getInstance(controllerClass);
63
101
  const commandMap = getCommandMap(instance);
@@ -65,12 +103,20 @@ class MeoCordApp {
65
103
  const commandMetadataArray = commandMap[commandName];
66
104
  if (!Array.isArray(commandMetadataArray)) continue;
67
105
  for (const { builder, type } of commandMetadataArray){
68
- if (type in CommandType && builder) {
69
- builders.push(builder);
106
+ if (!(type in CommandType) || !builder) continue;
107
+ const registeredName = commandNameOf(builder) ?? commandName;
108
+ const existing = buildersByName.get(registeredName);
109
+ if (existing === undefined) {
110
+ buildersByName.set(registeredName, builder);
111
+ } else if (existing !== builder) {
112
+ this.logger.warn(`Command "${registeredName}" is built more than once; only the first builder is registered. ` + `Declare the builder on one @Command and give the others the plain CommandType.`);
70
113
  }
71
114
  }
72
115
  }
73
116
  }
117
+ const builders = [
118
+ ...buildersByName.values()
119
+ ];
74
120
  try {
75
121
  if (this.bot.application) {
76
122
  await this.bot.application.commands.set(builders);
@@ -89,7 +135,7 @@ class MeoCordApp {
89
135
  });
90
136
  for (const builder of builders){
91
137
  const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
92
- const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : builder instanceof SlashCommandBuilder ? 'SlashCommand' : 'Command';
138
+ const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : json?.type === 4 ? 'PrimaryEntryPoint' : builder instanceof SlashCommandBuilder ? 'SlashCommand' : 'Command';
93
139
  const name = json?.name || builder.name;
94
140
  const subCommands = Array.isArray(json?.options) && json.options.length ? json.options.map((opt)=>opt.name).join(', ') : '';
95
141
  table.push([
@@ -130,16 +176,74 @@ class MeoCordApp {
130
176
  * Warns rather than throws: an app whose patterns overlap boots and works today, and
131
177
  * refusing to start would turn a latent mis-route into an outage on upgrade.
132
178
  */ reportAmbiguousRoutes(routes) {
133
- const collisions = findAmbiguousRoutes(routes.map(({ pattern })=>pattern));
179
+ // Grouped by command type first: dispatch only considers routes whose component
180
+ // type matches the interaction, so a button and a select menu sharing a pattern
181
+ // are never in competition and reporting them would be a false alarm.
182
+ const byType = new Map();
183
+ for (const { meta, pattern } of routes){
184
+ const patterns = byType.get(meta.type) ?? [];
185
+ patterns.push(pattern);
186
+ byType.set(meta.type, patterns);
187
+ }
188
+ const collisions = [
189
+ ...byType.values()
190
+ ].flatMap((patterns)=>findAmbiguousRoutes(patterns));
134
191
  if (collisions.length === 0) return;
135
192
  this.logger.warn(`${collisions.length} pattern pair(s) can match the same customId, so which one runs is decided by ` + `ranking rather than by the ids themselves:\n` + collisions.map(([left, right])=>` "${left}" vs "${right}"`).join('\n') + `\nA parameter stops at "${PARAM_SEPARATOR}", so separating these segments with it makes them distinct.`);
136
193
  }
137
- async handleInteraction(interaction) {
194
+ getAutocompleteRoutes() {
195
+ if (this.autocompleteRoutes) return this.autocompleteRoutes;
196
+ const routes = [];
197
+ for (const controllerClass of this.controllerClasses){
198
+ for (const meta of getAutocompleteHandlers(this.getInstance(controllerClass))){
199
+ routes.push({
200
+ controllerClass,
201
+ meta
202
+ });
203
+ }
204
+ }
205
+ routes.sort((a, b)=>Number(Boolean(b.meta.optionName)) - Number(Boolean(a.meta.optionName)));
206
+ this.autocompleteRoutes = routes;
207
+ return routes;
208
+ }
209
+ /**
210
+ * Dispatches an interaction, and makes sure a failure anywhere in that still reaches
211
+ * the person who triggered it.
212
+ *
213
+ * {@link executeCommand} already reports what a handler throws, but everything
214
+ * *before* the handler can fail too — resolving a controller through the container
215
+ * is the common case — and a component that fails there would otherwise look dead
216
+ * with nothing said to the user and nothing in the log.
217
+ */ async handleInteraction(interaction) {
218
+ try {
219
+ await this.dispatchInteraction(interaction);
220
+ } catch (error) {
221
+ this.logger.error(`Error dispatching ${describeInteraction(interaction)}:`, error);
222
+ // Autocomplete has no reply to fall back on; closing its window is the only
223
+ // thing that stops the client showing a loading state until it times out.
224
+ if (interaction.isAutocomplete()) {
225
+ await this.respondEmpty(interaction);
226
+ return;
227
+ }
228
+ await this.replyWithError(interaction, 'An error occurred while executing the command.');
229
+ }
230
+ }
231
+ async dispatchInteraction(interaction) {
232
+ // Autocomplete first, and on its own path: it is answered with `respond()` rather
233
+ // than a reply, it has no customId to route on, and the "Command not found!" reply
234
+ // the other paths end in cannot be sent to it at all.
235
+ if (interaction.isAutocomplete()) {
236
+ await this.handleAutocomplete(interaction);
237
+ return;
238
+ }
138
239
  // Component interactions route on a pattern, so they go through the ranked table.
139
- // Slash and context-menu commands match their name exactly and cannot overlap.
140
- if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
240
+ // Commands match their registered name exactly and cannot overlap.
241
+ if (hasCustomId(interaction)) {
141
242
  const customId = interaction.customId;
142
243
  for (const { controllerClass, meta } of this.getComponentRoutes()){
244
+ // A button and a select menu may legitimately share a customId shape, so the
245
+ // pattern alone does not identify the handler -- the component type does.
246
+ if (!matchesCommandType(meta.type, interaction)) continue;
143
247
  const match = meta.regex.exec(customId);
144
248
  if (!match) continue;
145
249
  interaction.dynamicParams = match.groups ?? {};
@@ -147,19 +251,15 @@ class MeoCordApp {
147
251
  return;
148
252
  }
149
253
  }
150
- for (const controllerClass of this.controllerClasses){
151
- const controllerInstance = this.getInstance(controllerClass);
152
- const commandMap = getCommandMap(controllerInstance);
153
- if (!commandMap) continue;
154
- let commandMetadataArray = undefined;
155
- let commandIdentifier = undefined;
156
- if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) {
157
- commandIdentifier = interaction.commandName;
158
- commandMetadataArray = commandMap[commandIdentifier];
159
- }
160
- if (commandMetadataArray && commandMetadataArray.length > 0) {
161
- const commandMetadata = commandMetadataArray[0];
162
- await this.executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier);
254
+ // Paths are walked outside the controller loop so the full subcommand path always
255
+ // beats the bare command name, whatever order the controllers were registered in.
256
+ for (const path of this.resolveNameRoutes(interaction)){
257
+ for (const controllerClass of this.controllerClasses){
258
+ const controllerInstance = this.getInstance(controllerClass);
259
+ const commandMap = getCommandMap(controllerInstance);
260
+ const commandMetadata = commandMap?.[path]?.find((meta)=>matchesCommandType(meta.type, interaction));
261
+ if (!commandMetadata) continue;
262
+ await this.executeCommand(controllerInstance, commandMetadata, interaction, path);
163
263
  return;
164
264
  }
165
265
  }
@@ -167,15 +267,58 @@ class MeoCordApp {
167
267
  // about which id was unroutable, so a control that is emitted but never routed --
168
268
  // a customId whose value broke its pattern, or a handler nobody wrote -- stays
169
269
  // invisible until somebody reports the dead button.
170
- this.logger.warn(`No handler matched ${describeUnmatched(interaction)}. Check that a @Command pattern is ` + `declared for it and that its controller is registered.`);
171
- if (interaction.isRepliable()) {
172
- const embed = createErrorEmbed('Command not found!');
173
- await interaction.reply({
174
- embeds: [
175
- embed
176
- ],
177
- flags: MessageFlagsBitField.Flags.Ephemeral
178
- });
270
+ this.logger.warn(`No handler matched ${describeInteraction(interaction)}. Check that a @Command pattern is ` + `declared for it and that its controller is registered.`);
271
+ await this.replyWithError(interaction, 'Command not found!');
272
+ }
273
+ /**
274
+ * The names a command interaction can be handled under, most specific first.
275
+ *
276
+ * Empty for anything that is not a registered command, which is how a component
277
+ * whose customId matched no pattern falls through to the unmatched warning instead
278
+ * of being looked up under a name it does not have.
279
+ */ resolveNameRoutes(interaction) {
280
+ if (interaction.isChatInputCommand()) return resolveCommandPaths(interaction);
281
+ if (interaction.isContextMenuCommand() || interaction.isPrimaryEntryPointCommand()) {
282
+ return [
283
+ interaction.commandName
284
+ ];
285
+ }
286
+ return [];
287
+ }
288
+ /**
289
+ * Answers an autocomplete interaction from the `@Autocomplete` handler that claims it.
290
+ *
291
+ * Discord closes the window after three seconds and shows a loading state until
292
+ * something arrives, so an unclaimed option is answered with an empty list rather
293
+ * than left to time out -- a visibly empty menu is a better failure than a stuck one,
294
+ * and the warning says which option is missing a handler.
295
+ */ async handleAutocomplete(interaction) {
296
+ const focusedName = focusedOptionName(interaction);
297
+ for (const path of resolveCommandPaths(interaction)){
298
+ for (const { controllerClass, meta } of this.getAutocompleteRoutes()){
299
+ if (meta.commandPath !== path) continue;
300
+ if (meta.optionName !== undefined && meta.optionName !== focusedName) continue;
301
+ try {
302
+ const controllerInstance = this.getInstance(controllerClass);
303
+ this.logger.log('[AUTOCOMPLETE]', `[${path}]`, `[${meta.methodName}]`);
304
+ await controllerInstance[meta.methodName](interaction, resolveOptionParams(interaction));
305
+ } catch (error) {
306
+ this.logger.error(`Error handling ${describeInteraction(interaction)}:`, error);
307
+ await this.respondEmpty(interaction);
308
+ }
309
+ return;
310
+ }
311
+ }
312
+ this.logger.warn(`No handler matched ${describeInteraction(interaction)}. Declare an @Autocomplete handler for it, ` + `or drop setAutocomplete(true) from the option.`);
313
+ await this.respondEmpty(interaction);
314
+ }
315
+ /** Closes an autocomplete window that nothing else answered. */ async respondEmpty(interaction) {
316
+ if (interaction.responded) return;
317
+ try {
318
+ await interaction.respond([]);
319
+ } catch (error) {
320
+ // The three-second window may already have closed, which is not actionable.
321
+ this.logger.debug(`Could not close autocomplete window: ${String(error)}`);
179
322
  }
180
323
  }
181
324
  /**
@@ -183,33 +326,43 @@ class MeoCordApp {
183
326
  * component and a named slash command behave identically once the route is chosen.
184
327
  */ async executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier) {
185
328
  const { methodName, type } = commandMetadata;
329
+ // No interaction-type check here: both callers pick the route with
330
+ // `matchesCommandType` before getting this far, and `@Command` re-checks the
331
+ // interaction on the way into the handler.
186
332
  try {
187
- if (type === CommandType.SLASH && interaction.isChatInputCommand() || type === CommandType.BUTTON && interaction.isButton() || type === CommandType.SELECT_MENU && interaction.isStringSelectMenu() || type === CommandType.CONTEXT_MENU && interaction.isUserContextMenuCommand() || type === CommandType.CONTEXT_MENU && interaction.isMessageContextMenuCommand() || type === CommandType.MODAL_SUBMIT && interaction.isModalSubmit()) {
188
- this.logger.log('[INTERACTION]', `[${CommandType[type]}]`, `[${methodName}]`);
189
- let dynamicParams = {};
190
- if (interaction.isChatInputCommand() && interaction.options) {
191
- dynamicParams = interaction.options.data.reduce((acc, opt)=>{
192
- acc[opt.name] = opt.value;
193
- return acc;
194
- }, {});
195
- } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
196
- dynamicParams = interaction.dynamicParams ?? {};
197
- }
198
- await controllerInstance[methodName](interaction, dynamicParams);
199
- return;
333
+ this.logger.log('[INTERACTION]', `[${type}]`, `[${methodName}]`);
334
+ let dynamicParams = {};
335
+ if (interaction.isChatInputCommand()) {
336
+ dynamicParams = resolveOptionParams(interaction);
337
+ } else if (hasCustomId(interaction)) {
338
+ dynamicParams = interaction.dynamicParams ?? {};
200
339
  }
201
- this.logger.warn(`Interaction type mismatch for command "${commandIdentifier}". Interaction type: ${interaction.type}.`);
340
+ await controllerInstance[methodName](interaction, dynamicParams);
202
341
  } catch (error) {
203
342
  this.logger.error(`Error executing command "${commandIdentifier}":`, error);
204
- if (interaction.isRepliable()) {
205
- const embed = createErrorEmbed('An error occurred while executing the command.');
206
- await interaction.reply({
207
- embeds: [
208
- embed
209
- ],
210
- flags: MessageFlagsBitField.Flags.Ephemeral
211
- });
212
- }
343
+ await this.replyWithError(interaction, 'An error occurred while executing the command.');
344
+ }
345
+ }
346
+ /**
347
+ * Tells the user something went wrong, if the interaction can still hear it.
348
+ *
349
+ * A handler that replies and *then* throws is the common shape of a failure, and
350
+ * replying twice throws in turn -- out of the catch block, where nothing is left to
351
+ * handle it. Whatever the interaction's state, reporting an error must not be able
352
+ * to become a second, worse one.
353
+ */ async replyWithError(interaction, message) {
354
+ if (!interaction.isRepliable() || interaction.replied || interaction.deferred) return;
355
+ try {
356
+ const embed = createErrorEmbed(message);
357
+ await interaction.reply({
358
+ embeds: [
359
+ embed
360
+ ],
361
+ flags: MessageFlagsBitField.Flags.Ephemeral
362
+ });
363
+ } catch (error) {
364
+ // Unknown or already-acknowledged interaction; the user cannot be told anything.
365
+ this.logger.debug(`Could not deliver the error reply: ${String(error)}`);
213
366
  }
214
367
  }
215
368
  async handleMessage(message) {
@@ -241,7 +394,15 @@ class MeoCordApp {
241
394
  }
242
395
  }
243
396
  async handleReaction(reaction, { user, action }) {
244
- await reaction.message.fetch();
397
+ // A reaction arrives for messages the bot may no longer be able to read -- deleted,
398
+ // or in a channel it lost access to -- and `fetch` rejects for all of them. That is
399
+ // an ordinary outcome rather than a fault, so the reaction is skipped quietly.
400
+ try {
401
+ await reaction.message.fetch();
402
+ } catch (error) {
403
+ this.logger.debug(`Skipping a reaction whose message could not be fetched: ${String(error)}`);
404
+ return;
405
+ }
245
406
  const relevantControllers = this.controllerClasses.filter((controllerClass)=>{
246
407
  const instance = this.getInstance(controllerClass);
247
408
  const reactionHandlers = getReactionHandlers(instance);