meocord 2.1.1 → 3.0.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.
package/README.md CHANGED
@@ -16,6 +16,7 @@
16
16
  - [meocord.config.ts](#meocordconfigts)
17
17
  - [ESLint](#eslint)
18
18
  - [CLI Reference](#cli-reference)
19
+ - [Command Parameters](#command-parameters)
19
20
  - [Guards](#guards)
20
21
  - [Custom Decorators](#custom-decorators)
21
22
  - [Testing](#testing)
@@ -240,6 +241,70 @@ npx meocord g --help # list all generator sub-commands
240
241
 
241
242
  ---
242
243
 
244
+ ## Command Parameters
245
+
246
+ Buttons, select menus and modals route on their `customId`, and a pattern can capture parts of it. Captured values arrive as the handler's second argument.
247
+
248
+ ```typescript
249
+ @Command('profile/{ownerId}/{uid}', CommandType.BUTTON)
250
+ async showProfile(interaction: ButtonInteraction, { ownerId, uid }) {
251
+ // customId `profile/123/800000001` gives ownerId '123', uid '800000001'
252
+ }
253
+ ```
254
+
255
+ `/` separates segments, and **a parameter must occupy a whole segment** — the same rule Express and Rails use for a path. A pattern that breaks it is rejected when the command is registered:
256
+
257
+ ```typescript
258
+ @Command('profile/{uuid}', CommandType.BUTTON) // fine
259
+ @Command('gi-profile/{ownerId}', CommandType.BUTTON) // fine — the hyphen is inside a literal segment
260
+ @Command('profile-{uuid}', CommandType.BUTTON) // throws
261
+ ```
262
+
263
+ ```
264
+ Invalid pattern "profile-{uuid}": {uuid} must occupy a whole segment, so it has to be
265
+ preceded and followed by "/" or by the ends of the pattern. Write "a/{uuid}" rather
266
+ than "a-{uuid}".
267
+ ```
268
+
269
+ ### Why the rule exists
270
+
271
+ A parameter matches anything up to the next `/`, so an identifier you do not control is captured whole — a hyphen inside a uuid is data, not structure:
272
+
273
+ ```typescript
274
+ @Command('profile/{uuid}', CommandType.BUTTON)
275
+ // `profile/8400e29b-41d4-a716` -> uuid '8400e29b-41d4-a716'
276
+ ```
277
+
278
+ That only works because the separator cannot appear inside a value. Let a parameter share a segment with a literal and the boundary disappears: `profile-{uuid}` and `profile-{uuid}-{id}` both match `profile-a-b-c`, and neither reading is more correct than the other. No rule can settle that afterwards, so the shape is refused up front.
279
+
280
+ Segment counts then keep neighbours apart on their own:
281
+
282
+ ```typescript
283
+ @Command('profile/{uuid}', CommandType.BUTTON) // profile/8400e29b-41d4-a716
284
+ @Command('profile/{uuid}/{id}', CommandType.BUTTON) // profile/8400e29b-41d4-a716/99
285
+ ```
286
+
287
+ Each id matches exactly one of them.
288
+
289
+ ### Overlapping patterns
290
+
291
+ Two patterns with the same segment count can still both match. The one spelling out more literal text wins, so declaration order and file layout never decide it:
292
+
293
+ ```typescript
294
+ @Command('profile/summary/{ownerId}/{uid}', CommandType.BUTTON) // wins profile/summary/123/456
295
+ @Command('profile/{uuid}/{other}/{uid}', CommandType.BUTTON) // wins everything else
296
+ ```
297
+
298
+ Ties between equally literal patterns go to the one with fewer parameters. The ranking is computed once when commands are registered, so dispatch stays a single ordered lookup.
299
+
300
+ Where two patterns trade a literal for a parameter in opposite positions — `a/{x}/c` and `a/b/{y}` both take `a/b/c` — neither is more literal, and MeoCord logs a warning at startup naming the pair.
301
+
302
+ ### When nothing matches
303
+
304
+ An unroutable interaction replies "Command not found!" to the user and logs a warning naming the `customId` or command that failed to match. If a control appears dead, that log line is the first place to look.
305
+
306
+ ---
307
+
243
308
  ## Guards
244
309
 
245
310
  Guards run before the handler method. Each guard implements `canActivate` — return `true` to allow, `false` to block.
@@ -79,29 +79,62 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
79
79
  */ function getMessageHandlers(controller) {
80
80
  return Reflect.getMetadata(MESSAGE_HANDLER_METADATA_KEY, controller) || [];
81
81
  }
82
+ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
83
+ /** The character a parameter will not cross, so one pattern segment maps to one value. */ const PARAM_SEPARATOR = '/';
84
+ /** Escapes a literal stretch of a pattern so only placeholders stay meaningful. */ const escapeLiteral = (literal)=>literal.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
82
85
  /**
83
86
  * Helper function to create regex and parameter mappings from a pattern string.
84
87
  *
88
+ * A `{name}` matches anything up to the next `/`, the same rule Express and Rails use
89
+ * for a path segment. That is what lets a value the application does not control — a
90
+ * uuid, an opaque vendor id, a slug — be captured whole without the author annotating
91
+ * anything, since a hyphen inside it is data rather than structure.
92
+ *
93
+ * It also keeps neighbouring patterns apart: `profile/{uuid}` and `profile/{uuid}/{id}`
94
+ * cannot both match one id, because a parameter cannot swallow the separator between
95
+ * them. Patterns separated by `-` instead have no such boundary, so a pair like
96
+ * `profile-{uuid}` and `profile-{uuid}-{id}` is ambiguous — {@link findAmbiguousRoutes}
97
+ * reports those at registration.
98
+ *
85
99
  * @param pattern - The pattern string to parse.
86
- * @returns An object containing the generated regex and parameter names.
100
+ * @returns The regex, the parameter names, and how specific the pattern is.
87
101
  */ function createRegexFromPattern(pattern) {
88
102
  const params = [];
89
- // Escape special characters except for {} and -
90
- const escapedPattern = pattern.replace(/[/\\^$*+?.()|[\]]/g, '\\$&') // Removed hyphen `-` from this list
91
- ;
92
- // Replace placeholders with named capturing groups
93
- const regexPattern = escapedPattern.replace(/\{(\w+)}/g, (_, param)=>{
94
- if (!/^\w+$/.test(param)) {
95
- throw new Error(`Invalid parameter name: ${param}. Parameter names must be alphanumeric.`);
103
+ let regexPattern = '';
104
+ let cursor = 0;
105
+ let literalLength = 0;
106
+ PLACEHOLDER_PATTERN.lastIndex = 0;
107
+ let match;
108
+ while((match = PLACEHOLDER_PATTERN.exec(pattern)) !== null){
109
+ const [placeholder, param] = match;
110
+ const literal = pattern.slice(cursor, match.index);
111
+ const after = pattern[match.index + placeholder.length];
112
+ // A parameter has to own its segment. Sharing one with a literal leaves no
113
+ // boundary a sibling pattern can be told apart by, and the resulting overlap has
114
+ // no correct reading -- `profile-{uuid}` and `profile-{uuid}-{id}` both take
115
+ // `profile-a-b`. Registration is the last point where that is still fixable.
116
+ if (literal !== '' && !literal.endsWith(PARAM_SEPARATOR) || after !== undefined && after !== PARAM_SEPARATOR) {
117
+ throw new Error(`Invalid pattern "${pattern}": {${param}} must occupy a whole segment, so it has to be ` + `preceded and followed by "${PARAM_SEPARATOR}" or by the ends of the pattern. ` + `Write "a${PARAM_SEPARATOR}{${param}}" rather than "a-{${param}}".`);
96
118
  }
119
+ literalLength += literal.length;
120
+ regexPattern += escapeLiteral(literal);
121
+ regexPattern += `(?<${param}>[^${PARAM_SEPARATOR}]+)`;
97
122
  params.push(param);
98
- return `(?<${param}>[a-zA-Z0-9]+)`;
99
- });
100
- // Construct the final regex
123
+ cursor = match.index + placeholder.length;
124
+ }
125
+ const trailing = pattern.slice(cursor);
126
+ literalLength += trailing.length;
127
+ regexPattern += escapeLiteral(trailing);
101
128
  const regex = new RegExp(`^${regexPattern}$`);
129
+ // Literal text is the signal: a pattern spelling out more of the id describes it
130
+ // more exactly than one leaving it to a parameter. Fewer parameters breaks a tie
131
+ // between equal-length patterns, so the ranking is total and never falls back to
132
+ // declaration order.
133
+ const specificity = literalLength * 1000 - params.length;
102
134
  return {
103
135
  regex,
104
- params
136
+ params,
137
+ specificity
105
138
  };
106
139
  }
107
140
  /**
@@ -145,6 +178,7 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
145
178
  let commandType;
146
179
  let regex;
147
180
  let dynamicParams = [];
181
+ let specificity;
148
182
  // Determine command type and builder
149
183
  if (typeof builderOrType === 'function') {
150
184
  const builderObj = new builderOrType();
@@ -157,9 +191,10 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
157
191
  commandType = builderOrType;
158
192
  }
159
193
  if (commandType !== enum_index.CommandType.SLASH && commandType !== enum_index.CommandType.CONTEXT_MENU) {
160
- const { regex: generatedRegex, params } = createRegexFromPattern(commandName);
194
+ const { regex: generatedRegex, params, specificity: patternSpecificity } = createRegexFromPattern(commandName);
161
195
  regex = generatedRegex;
162
196
  dynamicParams = params;
197
+ specificity = patternSpecificity;
163
198
  }
164
199
  // Ensure commandName supports multiple entries
165
200
  if (!commands[commandName]) {
@@ -170,7 +205,8 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
170
205
  builder: builderInstance,
171
206
  type: commandType,
172
207
  regex,
173
- dynamicParams
208
+ dynamicParams,
209
+ specificity
174
210
  });
175
211
  Reflect.defineMetadata(COMMAND_METADATA_KEY, commands, target);
176
212
  };
@@ -206,11 +242,47 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
206
242
  }
207
243
  };
208
244
  }
245
+ /**
246
+ * Finds pattern pairs that can both match one customId.
247
+ *
248
+ * Patterns of different segment counts are disjoint, because a parameter cannot cross
249
+ * `/`. Within the same count, two patterns overlap unless some position holds literals
250
+ * that differ: `a/{x}/c` and `a/b/{y}` both take `a/b/c`, and neither is more literal
251
+ * than the other, so ranking cannot settle it either.
252
+ *
253
+ * @param patterns - The registered patterns.
254
+ * @returns Each ambiguous pair, once, in the order the patterns were given.
255
+ */ function findAmbiguousRoutes(patterns) {
256
+ const isParam = (segment)=>PLACEHOLDER_PATTERN.test(segment);
257
+ const segmentsOf = (pattern)=>pattern.split(PARAM_SEPARATOR);
258
+ const collisions = [];
259
+ for(let i = 0; i < patterns.length; i++){
260
+ for(let j = i + 1; j < patterns.length; j++){
261
+ const left = segmentsOf(patterns[i]);
262
+ const right = segmentsOf(patterns[j]);
263
+ if (left.length !== right.length) continue;
264
+ const disjoint = left.some((segment, index)=>{
265
+ PLACEHOLDER_PATTERN.lastIndex = 0;
266
+ const leftIsParam = isParam(segment);
267
+ PLACEHOLDER_PATTERN.lastIndex = 0;
268
+ const rightIsParam = isParam(right[index]);
269
+ return !leftIsParam && !rightIsParam && segment !== right[index];
270
+ });
271
+ if (!disjoint) collisions.push([
272
+ patterns[i],
273
+ patterns[j]
274
+ ]);
275
+ }
276
+ }
277
+ return collisions;
278
+ }
209
279
 
210
280
  exports.Command = Command;
211
281
  exports.Controller = Controller;
212
282
  exports.MessageHandler = MessageHandler;
283
+ exports.PARAM_SEPARATOR = PARAM_SEPARATOR;
213
284
  exports.ReactionHandler = ReactionHandler;
285
+ exports.findAmbiguousRoutes = findAmbiguousRoutes;
214
286
  exports.getCommandMap = getCommandMap;
215
287
  exports.getMessageHandlers = getMessageHandlers;
216
288
  exports.getReactionHandlers = getReactionHandlers;
@@ -4,7 +4,7 @@ require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
5
  var discord_js = require('discord.js');
6
6
  var theme = require('../_shared/theme-Bz-D4RbT.cjs');
7
- var controller_decorator = require('../_shared/controller.decorator-DX5lFlPZ.cjs');
7
+ var controller_decorator = require('../_shared/controller.decorator-CC6BjHkS.cjs');
8
8
  var lodashEs = require('lodash-es');
9
9
  var enum_index = require('../enum/index.cjs');
10
10
  var Table = require('cli-table3');
@@ -26,6 +26,15 @@ const createErrorEmbed = (description)=>{
26
26
  return embed;
27
27
  };
28
28
 
29
+ /** Identifies an unmatched interaction for the log, by whichever field would have routed it. */ function describeUnmatched(interaction) {
30
+ if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) {
31
+ return `command "${interaction.commandName}"`;
32
+ }
33
+ if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
34
+ return `customId "${interaction.customId}"`;
35
+ }
36
+ return `interaction type ${interaction.type}`;
37
+ }
29
38
  class MeoCordApp {
30
39
  getInstance(controllerClass) {
31
40
  if (!this.controllerInstancesCache.has(controllerClass)) {
@@ -114,7 +123,49 @@ class MeoCordApp {
114
123
  this.logger.error('Error during command registration:', error);
115
124
  }
116
125
  }
126
+ getComponentRoutes() {
127
+ if (this.componentRoutes) return this.componentRoutes;
128
+ const routes = [];
129
+ for (const controllerClass of this.controllerClasses){
130
+ const commandMap = controller_decorator.getCommandMap(this.getInstance(controllerClass));
131
+ if (!commandMap) continue;
132
+ for (const [pattern, metaArray] of Object.entries(commandMap)){
133
+ if (!Array.isArray(metaArray)) continue;
134
+ for (const meta of metaArray){
135
+ if (meta.regex) routes.push({
136
+ controllerClass,
137
+ meta,
138
+ pattern
139
+ });
140
+ }
141
+ }
142
+ }
143
+ routes.sort((a, b)=>(b.meta.specificity ?? 0) - (a.meta.specificity ?? 0));
144
+ this.reportAmbiguousRoutes(routes);
145
+ this.componentRoutes = routes;
146
+ return routes;
147
+ }
148
+ /**
149
+ * Warns rather than throws: an app whose patterns overlap boots and works today, and
150
+ * refusing to start would turn a latent mis-route into an outage on upgrade.
151
+ */ reportAmbiguousRoutes(routes) {
152
+ const collisions = controller_decorator.findAmbiguousRoutes(routes.map(({ pattern })=>pattern));
153
+ if (collisions.length === 0) return;
154
+ 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 "${controller_decorator.PARAM_SEPARATOR}", so separating these segments with it makes them distinct.`);
155
+ }
117
156
  async handleInteraction(interaction) {
157
+ // Component interactions route on a pattern, so they go through the ranked table.
158
+ // Slash and context-menu commands match their name exactly and cannot overlap.
159
+ if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
160
+ const customId = interaction.customId;
161
+ for (const { controllerClass, meta } of this.getComponentRoutes()){
162
+ const match = meta.regex.exec(customId);
163
+ if (!match) continue;
164
+ interaction.dynamicParams = match.groups ?? {};
165
+ await this.executeCommand(this.getInstance(controllerClass), meta, interaction, customId);
166
+ return;
167
+ }
168
+ }
118
169
  for (const controllerClass of this.controllerClasses){
119
170
  const controllerInstance = this.getInstance(controllerClass);
120
171
  const commandMap = controller_decorator.getCommandMap(controllerInstance);
@@ -124,60 +175,18 @@ class MeoCordApp {
124
175
  if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) {
125
176
  commandIdentifier = interaction.commandName;
126
177
  commandMetadataArray = commandMap[commandIdentifier];
127
- } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
128
- commandIdentifier = interaction.customId;
129
- const foundEntry = Object.entries(commandMap).find(([commandName, metaArray])=>{
130
- if (!Array.isArray(metaArray)) return false;
131
- return metaArray.some((meta)=>{
132
- if (!meta.regex || !commandIdentifier) return false;
133
- const match = meta.regex.exec(commandIdentifier);
134
- if (match?.groups) {
135
- interaction.dynamicParams = match.groups;
136
- return true;
137
- }
138
- return commandIdentifier === commandName;
139
- });
140
- });
141
- if (foundEntry) {
142
- commandMetadataArray = foundEntry[1];
143
- }
144
178
  }
145
179
  if (commandMetadataArray && commandMetadataArray.length > 0) {
146
180
  const commandMetadata = commandMetadataArray[0];
147
- const { methodName, type } = commandMetadata;
148
- try {
149
- if (type === enum_index.CommandType.SLASH && interaction.isChatInputCommand() || type === enum_index.CommandType.BUTTON && interaction.isButton() || type === enum_index.CommandType.SELECT_MENU && interaction.isStringSelectMenu() || type === enum_index.CommandType.CONTEXT_MENU && interaction.isUserContextMenuCommand() || type === enum_index.CommandType.CONTEXT_MENU && interaction.isMessageContextMenuCommand() || type === enum_index.CommandType.MODAL_SUBMIT && interaction.isModalSubmit()) {
150
- this.logger.log('[INTERACTION]', `[${enum_index.CommandType[type]}]`, `[${methodName}]`);
151
- let dynamicParams = {};
152
- if (interaction.isChatInputCommand() && interaction.options) {
153
- dynamicParams = interaction.options.data.reduce((acc, opt)=>{
154
- acc[opt.name] = opt.value;
155
- return acc;
156
- }, {});
157
- } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
158
- dynamicParams = interaction.dynamicParams || {};
159
- }
160
- await controllerInstance[methodName](interaction, dynamicParams);
161
- return;
162
- } else {
163
- this.logger.debug(type, methodName, enum_index.CommandType.BUTTON, interaction.isButton());
164
- this.logger.warn(`Interaction type mismatch for command "${commandIdentifier}". Interaction type: ${interaction.type}.`);
165
- }
166
- } catch (error) {
167
- this.logger.error(`Error executing command "${commandIdentifier}":`, error);
168
- if (interaction.isRepliable()) {
169
- const embed = createErrorEmbed('An error occurred while executing the command.');
170
- await interaction.reply({
171
- embeds: [
172
- embed
173
- ],
174
- flags: discord_js.MessageFlagsBitField.Flags.Ephemeral
175
- });
176
- }
177
- }
181
+ await this.executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier);
178
182
  return;
179
183
  }
180
184
  }
185
+ // Log what actually failed to match. The user's "Command not found!" says nothing
186
+ // about which id was unroutable, so a control that is emitted but never routed --
187
+ // a customId whose value broke its pattern, or a handler nobody wrote -- stays
188
+ // invisible until somebody reports the dead button.
189
+ this.logger.warn(`No handler matched ${describeUnmatched(interaction)}. Check that a @Command pattern is ` + `declared for it and that its controller is registered.`);
181
190
  if (interaction.isRepliable()) {
182
191
  const embed = createErrorEmbed('Command not found!');
183
192
  await interaction.reply({
@@ -188,6 +197,40 @@ class MeoCordApp {
188
197
  });
189
198
  }
190
199
  }
200
+ /**
201
+ * Runs a resolved command, shared by both dispatch paths so a pattern-matched
202
+ * component and a named slash command behave identically once the route is chosen.
203
+ */ async executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier) {
204
+ const { methodName, type } = commandMetadata;
205
+ try {
206
+ if (type === enum_index.CommandType.SLASH && interaction.isChatInputCommand() || type === enum_index.CommandType.BUTTON && interaction.isButton() || type === enum_index.CommandType.SELECT_MENU && interaction.isStringSelectMenu() || type === enum_index.CommandType.CONTEXT_MENU && interaction.isUserContextMenuCommand() || type === enum_index.CommandType.CONTEXT_MENU && interaction.isMessageContextMenuCommand() || type === enum_index.CommandType.MODAL_SUBMIT && interaction.isModalSubmit()) {
207
+ this.logger.log('[INTERACTION]', `[${enum_index.CommandType[type]}]`, `[${methodName}]`);
208
+ let dynamicParams = {};
209
+ if (interaction.isChatInputCommand() && interaction.options) {
210
+ dynamicParams = interaction.options.data.reduce((acc, opt)=>{
211
+ acc[opt.name] = opt.value;
212
+ return acc;
213
+ }, {});
214
+ } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
215
+ dynamicParams = interaction.dynamicParams ?? {};
216
+ }
217
+ await controllerInstance[methodName](interaction, dynamicParams);
218
+ return;
219
+ }
220
+ this.logger.warn(`Interaction type mismatch for command "${commandIdentifier}". Interaction type: ${interaction.type}.`);
221
+ } catch (error) {
222
+ this.logger.error(`Error executing command "${commandIdentifier}":`, error);
223
+ if (interaction.isRepliable()) {
224
+ const embed = createErrorEmbed('An error occurred while executing the command.');
225
+ await interaction.reply({
226
+ embeds: [
227
+ embed
228
+ ],
229
+ flags: discord_js.MessageFlagsBitField.Flags.Ephemeral
230
+ });
231
+ }
232
+ }
233
+ }
191
234
  async handleMessage(message) {
192
235
  if (message.author.bot || !message.content?.trim()) return;
193
236
  const messageContent = message.content.trim();
@@ -3,7 +3,7 @@
3
3
  require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
5
  var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs');
6
- var controller_decorator = require('../_shared/controller.decorator-DX5lFlPZ.cjs');
6
+ var controller_decorator = require('../_shared/controller.decorator-CC6BjHkS.cjs');
7
7
  var discord_js = require('discord.js');
8
8
  require('../enum/index.cjs');
9
9
 
@@ -249,7 +249,9 @@ export class ButtonInteractionGuard implements GuardInterface {
249
249
  exports.Command = controller_decorator.Command;
250
250
  exports.Controller = controller_decorator.Controller;
251
251
  exports.MessageHandler = controller_decorator.MessageHandler;
252
+ exports.PARAM_SEPARATOR = controller_decorator.PARAM_SEPARATOR;
252
253
  exports.ReactionHandler = controller_decorator.ReactionHandler;
254
+ exports.findAmbiguousRoutes = controller_decorator.findAmbiguousRoutes;
253
255
  exports.getCommandMap = controller_decorator.getCommandMap;
254
256
  exports.getMessageHandlers = controller_decorator.getMessageHandlers;
255
257
  exports.getReactionHandlers = controller_decorator.getReactionHandlers;
@@ -1,12 +1,21 @@
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, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
4
+ import { getCommandMap, findAmbiguousRoutes, PARAM_SEPARATOR, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
5
5
  import { sample } from 'lodash-es';
6
6
  import { createErrorEmbed } from '../util/embed.util.js';
7
7
  import { ReactionHandlerAction, CommandType } from '../enum/controller.enum.js';
8
8
  import CliTable3 from 'cli-table3';
9
9
 
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}"`;
13
+ }
14
+ if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
15
+ return `customId "${interaction.customId}"`;
16
+ }
17
+ return `interaction type ${interaction.type}`;
18
+ }
10
19
  class MeoCordApp {
11
20
  getInstance(controllerClass) {
12
21
  if (!this.controllerInstancesCache.has(controllerClass)) {
@@ -95,7 +104,49 @@ class MeoCordApp {
95
104
  this.logger.error('Error during command registration:', error);
96
105
  }
97
106
  }
107
+ getComponentRoutes() {
108
+ if (this.componentRoutes) return this.componentRoutes;
109
+ const routes = [];
110
+ for (const controllerClass of this.controllerClasses){
111
+ const commandMap = getCommandMap(this.getInstance(controllerClass));
112
+ if (!commandMap) continue;
113
+ for (const [pattern, metaArray] of Object.entries(commandMap)){
114
+ if (!Array.isArray(metaArray)) continue;
115
+ for (const meta of metaArray){
116
+ if (meta.regex) routes.push({
117
+ controllerClass,
118
+ meta,
119
+ pattern
120
+ });
121
+ }
122
+ }
123
+ }
124
+ routes.sort((a, b)=>(b.meta.specificity ?? 0) - (a.meta.specificity ?? 0));
125
+ this.reportAmbiguousRoutes(routes);
126
+ this.componentRoutes = routes;
127
+ return routes;
128
+ }
129
+ /**
130
+ * Warns rather than throws: an app whose patterns overlap boots and works today, and
131
+ * refusing to start would turn a latent mis-route into an outage on upgrade.
132
+ */ reportAmbiguousRoutes(routes) {
133
+ const collisions = findAmbiguousRoutes(routes.map(({ pattern })=>pattern));
134
+ if (collisions.length === 0) return;
135
+ 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
+ }
98
137
  async handleInteraction(interaction) {
138
+ // 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()) {
141
+ const customId = interaction.customId;
142
+ for (const { controllerClass, meta } of this.getComponentRoutes()){
143
+ const match = meta.regex.exec(customId);
144
+ if (!match) continue;
145
+ interaction.dynamicParams = match.groups ?? {};
146
+ await this.executeCommand(this.getInstance(controllerClass), meta, interaction, customId);
147
+ return;
148
+ }
149
+ }
99
150
  for (const controllerClass of this.controllerClasses){
100
151
  const controllerInstance = this.getInstance(controllerClass);
101
152
  const commandMap = getCommandMap(controllerInstance);
@@ -105,60 +156,18 @@ class MeoCordApp {
105
156
  if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) {
106
157
  commandIdentifier = interaction.commandName;
107
158
  commandMetadataArray = commandMap[commandIdentifier];
108
- } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
109
- commandIdentifier = interaction.customId;
110
- const foundEntry = Object.entries(commandMap).find(([commandName, metaArray])=>{
111
- if (!Array.isArray(metaArray)) return false;
112
- return metaArray.some((meta)=>{
113
- if (!meta.regex || !commandIdentifier) return false;
114
- const match = meta.regex.exec(commandIdentifier);
115
- if (match?.groups) {
116
- interaction.dynamicParams = match.groups;
117
- return true;
118
- }
119
- return commandIdentifier === commandName;
120
- });
121
- });
122
- if (foundEntry) {
123
- commandMetadataArray = foundEntry[1];
124
- }
125
159
  }
126
160
  if (commandMetadataArray && commandMetadataArray.length > 0) {
127
161
  const commandMetadata = commandMetadataArray[0];
128
- const { methodName, type } = commandMetadata;
129
- try {
130
- 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()) {
131
- this.logger.log('[INTERACTION]', `[${CommandType[type]}]`, `[${methodName}]`);
132
- let dynamicParams = {};
133
- if (interaction.isChatInputCommand() && interaction.options) {
134
- dynamicParams = interaction.options.data.reduce((acc, opt)=>{
135
- acc[opt.name] = opt.value;
136
- return acc;
137
- }, {});
138
- } else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
139
- dynamicParams = interaction.dynamicParams || {};
140
- }
141
- await controllerInstance[methodName](interaction, dynamicParams);
142
- return;
143
- } else {
144
- this.logger.debug(type, methodName, CommandType.BUTTON, interaction.isButton());
145
- this.logger.warn(`Interaction type mismatch for command "${commandIdentifier}". Interaction type: ${interaction.type}.`);
146
- }
147
- } catch (error) {
148
- this.logger.error(`Error executing command "${commandIdentifier}":`, error);
149
- if (interaction.isRepliable()) {
150
- const embed = createErrorEmbed('An error occurred while executing the command.');
151
- await interaction.reply({
152
- embeds: [
153
- embed
154
- ],
155
- flags: MessageFlagsBitField.Flags.Ephemeral
156
- });
157
- }
158
- }
162
+ await this.executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier);
159
163
  return;
160
164
  }
161
165
  }
166
+ // Log what actually failed to match. The user's "Command not found!" says nothing
167
+ // about which id was unroutable, so a control that is emitted but never routed --
168
+ // a customId whose value broke its pattern, or a handler nobody wrote -- stays
169
+ // 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.`);
162
171
  if (interaction.isRepliable()) {
163
172
  const embed = createErrorEmbed('Command not found!');
164
173
  await interaction.reply({
@@ -169,6 +178,40 @@ class MeoCordApp {
169
178
  });
170
179
  }
171
180
  }
181
+ /**
182
+ * Runs a resolved command, shared by both dispatch paths so a pattern-matched
183
+ * component and a named slash command behave identically once the route is chosen.
184
+ */ async executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier) {
185
+ const { methodName, type } = commandMetadata;
186
+ 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;
200
+ }
201
+ this.logger.warn(`Interaction type mismatch for command "${commandIdentifier}". Interaction type: ${interaction.type}.`);
202
+ } catch (error) {
203
+ 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
+ }
213
+ }
214
+ }
172
215
  async handleMessage(message) {
173
216
  if (message.author.bot || !message.content?.trim()) return;
174
217
  const messageContent = message.content.trim();
@@ -77,29 +77,62 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
77
77
  */ function getMessageHandlers(controller) {
78
78
  return Reflect.getMetadata(MESSAGE_HANDLER_METADATA_KEY, controller) || [];
79
79
  }
80
+ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
81
+ /** The character a parameter will not cross, so one pattern segment maps to one value. */ const PARAM_SEPARATOR = '/';
82
+ /** Escapes a literal stretch of a pattern so only placeholders stay meaningful. */ const escapeLiteral = (literal)=>literal.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
80
83
  /**
81
84
  * Helper function to create regex and parameter mappings from a pattern string.
82
85
  *
86
+ * A `{name}` matches anything up to the next `/`, the same rule Express and Rails use
87
+ * for a path segment. That is what lets a value the application does not control — a
88
+ * uuid, an opaque vendor id, a slug — be captured whole without the author annotating
89
+ * anything, since a hyphen inside it is data rather than structure.
90
+ *
91
+ * It also keeps neighbouring patterns apart: `profile/{uuid}` and `profile/{uuid}/{id}`
92
+ * cannot both match one id, because a parameter cannot swallow the separator between
93
+ * them. Patterns separated by `-` instead have no such boundary, so a pair like
94
+ * `profile-{uuid}` and `profile-{uuid}-{id}` is ambiguous — {@link findAmbiguousRoutes}
95
+ * reports those at registration.
96
+ *
83
97
  * @param pattern - The pattern string to parse.
84
- * @returns An object containing the generated regex and parameter names.
98
+ * @returns The regex, the parameter names, and how specific the pattern is.
85
99
  */ function createRegexFromPattern(pattern) {
86
100
  const params = [];
87
- // Escape special characters except for {} and -
88
- const escapedPattern = pattern.replace(/[/\\^$*+?.()|[\]]/g, '\\$&') // Removed hyphen `-` from this list
89
- ;
90
- // Replace placeholders with named capturing groups
91
- const regexPattern = escapedPattern.replace(/\{(\w+)}/g, (_, param)=>{
92
- if (!/^\w+$/.test(param)) {
93
- throw new Error(`Invalid parameter name: ${param}. Parameter names must be alphanumeric.`);
101
+ let regexPattern = '';
102
+ let cursor = 0;
103
+ let literalLength = 0;
104
+ PLACEHOLDER_PATTERN.lastIndex = 0;
105
+ let match;
106
+ while((match = PLACEHOLDER_PATTERN.exec(pattern)) !== null){
107
+ const [placeholder, param] = match;
108
+ const literal = pattern.slice(cursor, match.index);
109
+ const after = pattern[match.index + placeholder.length];
110
+ // A parameter has to own its segment. Sharing one with a literal leaves no
111
+ // boundary a sibling pattern can be told apart by, and the resulting overlap has
112
+ // no correct reading -- `profile-{uuid}` and `profile-{uuid}-{id}` both take
113
+ // `profile-a-b`. Registration is the last point where that is still fixable.
114
+ if (literal !== '' && !literal.endsWith(PARAM_SEPARATOR) || after !== undefined && after !== PARAM_SEPARATOR) {
115
+ throw new Error(`Invalid pattern "${pattern}": {${param}} must occupy a whole segment, so it has to be ` + `preceded and followed by "${PARAM_SEPARATOR}" or by the ends of the pattern. ` + `Write "a${PARAM_SEPARATOR}{${param}}" rather than "a-{${param}}".`);
94
116
  }
117
+ literalLength += literal.length;
118
+ regexPattern += escapeLiteral(literal);
119
+ regexPattern += `(?<${param}>[^${PARAM_SEPARATOR}]+)`;
95
120
  params.push(param);
96
- return `(?<${param}>[a-zA-Z0-9]+)`;
97
- });
98
- // Construct the final regex
121
+ cursor = match.index + placeholder.length;
122
+ }
123
+ const trailing = pattern.slice(cursor);
124
+ literalLength += trailing.length;
125
+ regexPattern += escapeLiteral(trailing);
99
126
  const regex = new RegExp(`^${regexPattern}$`);
127
+ // Literal text is the signal: a pattern spelling out more of the id describes it
128
+ // more exactly than one leaving it to a parameter. Fewer parameters breaks a tie
129
+ // between equal-length patterns, so the ranking is total and never falls back to
130
+ // declaration order.
131
+ const specificity = literalLength * 1000 - params.length;
100
132
  return {
101
133
  regex,
102
- params
134
+ params,
135
+ specificity
103
136
  };
104
137
  }
105
138
  /**
@@ -143,6 +176,7 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
143
176
  let commandType;
144
177
  let regex;
145
178
  let dynamicParams = [];
179
+ let specificity;
146
180
  // Determine command type and builder
147
181
  if (typeof builderOrType === 'function') {
148
182
  const builderObj = new builderOrType();
@@ -155,9 +189,10 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
155
189
  commandType = builderOrType;
156
190
  }
157
191
  if (commandType !== CommandType.SLASH && commandType !== CommandType.CONTEXT_MENU) {
158
- const { regex: generatedRegex, params } = createRegexFromPattern(commandName);
192
+ const { regex: generatedRegex, params, specificity: patternSpecificity } = createRegexFromPattern(commandName);
159
193
  regex = generatedRegex;
160
194
  dynamicParams = params;
195
+ specificity = patternSpecificity;
161
196
  }
162
197
  // Ensure commandName supports multiple entries
163
198
  if (!commands[commandName]) {
@@ -168,7 +203,8 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
168
203
  builder: builderInstance,
169
204
  type: commandType,
170
205
  regex,
171
- dynamicParams
206
+ dynamicParams,
207
+ specificity
172
208
  });
173
209
  Reflect.defineMetadata(COMMAND_METADATA_KEY, commands, target);
174
210
  };
@@ -204,5 +240,39 @@ const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
204
240
  }
205
241
  };
206
242
  }
243
+ /**
244
+ * Finds pattern pairs that can both match one customId.
245
+ *
246
+ * Patterns of different segment counts are disjoint, because a parameter cannot cross
247
+ * `/`. Within the same count, two patterns overlap unless some position holds literals
248
+ * that differ: `a/{x}/c` and `a/b/{y}` both take `a/b/c`, and neither is more literal
249
+ * than the other, so ranking cannot settle it either.
250
+ *
251
+ * @param patterns - The registered patterns.
252
+ * @returns Each ambiguous pair, once, in the order the patterns were given.
253
+ */ function findAmbiguousRoutes(patterns) {
254
+ const isParam = (segment)=>PLACEHOLDER_PATTERN.test(segment);
255
+ const segmentsOf = (pattern)=>pattern.split(PARAM_SEPARATOR);
256
+ const collisions = [];
257
+ for(let i = 0; i < patterns.length; i++){
258
+ for(let j = i + 1; j < patterns.length; j++){
259
+ const left = segmentsOf(patterns[i]);
260
+ const right = segmentsOf(patterns[j]);
261
+ if (left.length !== right.length) continue;
262
+ const disjoint = left.some((segment, index)=>{
263
+ PLACEHOLDER_PATTERN.lastIndex = 0;
264
+ const leftIsParam = isParam(segment);
265
+ PLACEHOLDER_PATTERN.lastIndex = 0;
266
+ const rightIsParam = isParam(right[index]);
267
+ return !leftIsParam && !rightIsParam && segment !== right[index];
268
+ });
269
+ if (!disjoint) collisions.push([
270
+ patterns[i],
271
+ patterns[j]
272
+ ]);
273
+ }
274
+ }
275
+ return collisions;
276
+ }
207
277
 
208
- export { Command, Controller, MessageHandler, ReactionHandler, getCommandMap, getMessageHandlers, getReactionHandlers };
278
+ export { Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, 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, ReactionHandler, getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
3
+ export { Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
4
4
  export { Guard, UseGuard } from './guard.decorator.js';
5
5
  export { MeoCord } from './app.decorator.js';
@@ -22,7 +22,28 @@ declare class MeoCordApp {
22
22
  private getInstance;
23
23
  start(): Promise<void>;
24
24
  registerCommands(): Promise<void>;
25
+ /**
26
+ * Every pattern-matched route, ordered most specific first.
27
+ *
28
+ * Built once and cached, so dispatch stays a single ordered walk with an early exit
29
+ * rather than paying to rank anything per interaction. Ordering here is what lets
30
+ * `gi-profile-summary-{ownerId}-{uid}` keep the ids it owns when
31
+ * `gi-profile-{uuid}-{uid}` would also match them — without it, the winner would be
32
+ * whichever controller happened to be registered first.
33
+ */
34
+ private componentRoutes?;
35
+ private getComponentRoutes;
36
+ /**
37
+ * Warns rather than throws: an app whose patterns overlap boots and works today, and
38
+ * refusing to start would turn a latent mis-route into an outage on upgrade.
39
+ */
40
+ private reportAmbiguousRoutes;
25
41
  private handleInteraction;
42
+ /**
43
+ * Runs a resolved command, shared by both dispatch paths so a pattern-matched
44
+ * component and a named slash command behave identically once the route is chosen.
45
+ */
46
+ private executeCommand;
26
47
  private handleMessage;
27
48
  private handleReaction;
28
49
  private gracefulShutdown;
@@ -57,6 +57,13 @@ interface CommandMetadata<T extends string = string> {
57
57
  type: CommandType;
58
58
  regex?: RegExp;
59
59
  dynamicParams?: T[];
60
+ /**
61
+ * How specific this pattern is; higher wins when more than one route matches the
62
+ * same customId. A greedy parameter can overlap a more literal sibling —
63
+ * `gi-profile-{uuid:*}-{uid}` also matches an id meant for
64
+ * `gi-profile-summary-{ownerId}-{uid}` — so dispatch cannot rely on declaration order.
65
+ */
66
+ specificity?: number;
60
67
  }
61
68
  type CommandInteractionType<CBC extends CommandType.SLASH | CommandType.CONTEXT_MENU, T extends CommandBuilderConstructor<CBC> | CommandType> = T extends CommandType.BUTTON ? ButtonInteraction : T extends CommandType.SELECT_MENU ? StringSelectMenuInteraction : T extends CommandBuilderConstructor<CommandType.SLASH> ? ChatInputCommandInteraction : T extends CommandBuilderConstructor<CommandType.CONTEXT_MENU> ? UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction : T extends CommandType.MODAL_SUBMIT ? ModalSubmitInteraction : never;
62
69
 
@@ -150,6 +157,8 @@ declare function getMessageHandlers(controller: any): {
150
157
  keyword: string | undefined;
151
158
  method: string;
152
159
  }[];
160
+ /** The character a parameter will not cross, so one pattern segment maps to one value. */
161
+ declare const PARAM_SEPARATOR = "/";
153
162
  /**
154
163
  * Decorator to register command methods in a controller.
155
164
  *
@@ -195,6 +204,18 @@ declare function getCommandMap<T extends string>(controller: any): Record<string
195
204
  * ```
196
205
  */
197
206
  declare function Controller(): (target: any) => void;
207
+ /**
208
+ * Finds pattern pairs that can both match one customId.
209
+ *
210
+ * Patterns of different segment counts are disjoint, because a parameter cannot cross
211
+ * `/`. Within the same count, two patterns overlap unless some position holds literals
212
+ * that differ: `a/{x}/c` and `a/b/{y}` both take `a/b/c`, and neither is more literal
213
+ * than the other, so ranking cannot settle it either.
214
+ *
215
+ * @param patterns - The registered patterns.
216
+ * @returns Each ambiguous pair, once, in the order the patterns were given.
217
+ */
218
+ declare function findAmbiguousRoutes(patterns: string[]): [string, string][];
198
219
 
199
220
  /**
200
221
  * MeoCord Framework
@@ -334,4 +355,4 @@ declare function MeoCord(options: {
334
355
  services?: ServiceIdentifier[];
335
356
  }): (target: any) => void;
336
357
 
337
- export { Command, CommandBuilder, Controller, Guard, MeoCord, MessageHandler, ReactionHandler, Service, UseGuard, getCommandMap, getMessageHandlers, getReactionHandlers };
358
+ export { Command, CommandBuilder, Controller, Guard, MeoCord, MessageHandler, PARAM_SEPARATOR, ReactionHandler, Service, UseGuard, findAmbiguousRoutes, getCommandMap, getMessageHandlers, getReactionHandlers };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "meocord",
3
3
  "description": "Decorator-based Discord bot framework built on discord.js. Brings NestJS-style controllers, dependency injection, guards, and testing utilities to bot development — with a full CLI and TypeScript-first design.",
4
- "version": "2.1.1",
4
+ "version": "3.0.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22"