meocord 2.1.0 → 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;
@@ -152,23 +152,16 @@ function isValueProvider(p) {
152
152
  * fn(2) // → 99
153
153
  * fn.mock.calls // → [[2], [2]]
154
154
  */ function createMockFn(impl) {
155
+ // One persistent implementation and one queue of single-use ones, which is how
156
+ // jest and vitest model this. mockReturnValue, mockResolvedValue,
157
+ // mockRejectedValue and mockImplementation all write the same slot, so the last
158
+ // call wins; the four `*Once` variants all push onto the same queue, so they are
159
+ // consumed in the order they were declared regardless of which kind they are.
160
+ // Keeping a slot per kind instead gave a fixed precedence, where a stored
161
+ // resolved value beat a later mockRejectedValue and the override was silently
162
+ // dropped.
155
163
  let currentImpl = impl;
156
- let returnOnce = [];
157
- let returnValue = {
158
- set: false,
159
- value: undefined
160
- };
161
- let resolvedOnce = [];
162
- let resolvedValue = {
163
- set: false,
164
- value: undefined
165
- };
166
- let rejectedOnce = [];
167
- let rejectedValue = {
168
- set: false,
169
- value: undefined
170
- };
171
- const implOnce = [];
164
+ let onceQueue = [];
172
165
  let name = 'vi.fn';
173
166
  const calls = [];
174
167
  const results = [];
@@ -179,20 +172,8 @@ function isValueProvider(p) {
179
172
  let type = 'return';
180
173
  let value;
181
174
  try {
182
- if (implOnce.length > 0) {
183
- value = implOnce.shift().apply(this, args);
184
- } else if (returnOnce.length > 0) {
185
- value = returnOnce.shift();
186
- } else if (returnValue.set) {
187
- value = returnValue.value;
188
- } else if (resolvedOnce.length > 0) {
189
- value = Promise.resolve(resolvedOnce.shift());
190
- } else if (resolvedValue.set) {
191
- value = Promise.resolve(resolvedValue.value);
192
- } else if (rejectedOnce.length > 0) {
193
- value = Promise.reject(rejectedOnce.shift());
194
- } else if (rejectedValue.set) {
195
- value = Promise.reject(rejectedValue.value);
175
+ if (onceQueue.length > 0) {
176
+ value = onceQueue.shift().apply(this, args);
196
177
  } else if (currentImpl !== undefined) {
197
178
  value = currentImpl.apply(this, args);
198
179
  } else {
@@ -234,36 +215,27 @@ function isValueProvider(p) {
234
215
  enumerable: false
235
216
  });
236
217
  mockFn.mockReturnValue = (v)=>{
237
- returnValue = {
238
- set: true,
239
- value: v
240
- };
218
+ currentImpl = ()=>v;
241
219
  return mockFn;
242
220
  };
243
221
  mockFn.mockReturnValueOnce = (v)=>{
244
- returnOnce.push(v);
222
+ onceQueue.push(()=>v);
245
223
  return mockFn;
246
224
  };
247
225
  mockFn.mockResolvedValue = (v)=>{
248
- resolvedValue = {
249
- set: true,
250
- value: v
251
- };
226
+ currentImpl = ()=>Promise.resolve(v);
252
227
  return mockFn;
253
228
  };
254
229
  mockFn.mockResolvedValueOnce = (v)=>{
255
- resolvedOnce.push(v);
230
+ onceQueue.push(()=>Promise.resolve(v));
256
231
  return mockFn;
257
232
  };
258
233
  mockFn.mockRejectedValue = (v)=>{
259
- rejectedValue = {
260
- set: true,
261
- value: v
262
- };
234
+ currentImpl = ()=>Promise.reject(v);
263
235
  return mockFn;
264
236
  };
265
237
  mockFn.mockRejectedValueOnce = (v)=>{
266
- rejectedOnce.push(v);
238
+ onceQueue.push(()=>Promise.reject(v));
267
239
  return mockFn;
268
240
  };
269
241
  mockFn.mockImplementation = (fn)=>{
@@ -271,7 +243,7 @@ function isValueProvider(p) {
271
243
  return mockFn;
272
244
  };
273
245
  mockFn.mockImplementationOnce = (fn)=>{
274
- implOnce.push(fn);
246
+ onceQueue.push(fn);
275
247
  return mockFn;
276
248
  };
277
249
  mockFn.mockClear = ()=>{
@@ -284,22 +256,7 @@ function isValueProvider(p) {
284
256
  calls.length = 0;
285
257
  results.length = 0;
286
258
  instances.length = 0;
287
- returnOnce = [];
288
- returnValue = {
289
- set: false,
290
- value: undefined
291
- };
292
- resolvedOnce = [];
293
- resolvedValue = {
294
- set: false,
295
- value: undefined
296
- };
297
- rejectedOnce = [];
298
- rejectedValue = {
299
- set: false,
300
- value: undefined
301
- };
302
- implOnce.length = 0;
259
+ onceQueue = [];
303
260
  currentImpl = impl;
304
261
  return mockFn;
305
262
  };
@@ -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';
@@ -30,23 +30,16 @@
30
30
  * fn(2) // → 99
31
31
  * fn.mock.calls // → [[2], [2]]
32
32
  */ function createMockFn(impl) {
33
+ // One persistent implementation and one queue of single-use ones, which is how
34
+ // jest and vitest model this. mockReturnValue, mockResolvedValue,
35
+ // mockRejectedValue and mockImplementation all write the same slot, so the last
36
+ // call wins; the four `*Once` variants all push onto the same queue, so they are
37
+ // consumed in the order they were declared regardless of which kind they are.
38
+ // Keeping a slot per kind instead gave a fixed precedence, where a stored
39
+ // resolved value beat a later mockRejectedValue and the override was silently
40
+ // dropped.
33
41
  let currentImpl = impl;
34
- let returnOnce = [];
35
- let returnValue = {
36
- set: false,
37
- value: undefined
38
- };
39
- let resolvedOnce = [];
40
- let resolvedValue = {
41
- set: false,
42
- value: undefined
43
- };
44
- let rejectedOnce = [];
45
- let rejectedValue = {
46
- set: false,
47
- value: undefined
48
- };
49
- const implOnce = [];
42
+ let onceQueue = [];
50
43
  let name = 'vi.fn';
51
44
  const calls = [];
52
45
  const results = [];
@@ -57,20 +50,8 @@
57
50
  let type = 'return';
58
51
  let value;
59
52
  try {
60
- if (implOnce.length > 0) {
61
- value = implOnce.shift().apply(this, args);
62
- } else if (returnOnce.length > 0) {
63
- value = returnOnce.shift();
64
- } else if (returnValue.set) {
65
- value = returnValue.value;
66
- } else if (resolvedOnce.length > 0) {
67
- value = Promise.resolve(resolvedOnce.shift());
68
- } else if (resolvedValue.set) {
69
- value = Promise.resolve(resolvedValue.value);
70
- } else if (rejectedOnce.length > 0) {
71
- value = Promise.reject(rejectedOnce.shift());
72
- } else if (rejectedValue.set) {
73
- value = Promise.reject(rejectedValue.value);
53
+ if (onceQueue.length > 0) {
54
+ value = onceQueue.shift().apply(this, args);
74
55
  } else if (currentImpl !== undefined) {
75
56
  value = currentImpl.apply(this, args);
76
57
  } else {
@@ -112,36 +93,27 @@
112
93
  enumerable: false
113
94
  });
114
95
  mockFn.mockReturnValue = (v)=>{
115
- returnValue = {
116
- set: true,
117
- value: v
118
- };
96
+ currentImpl = ()=>v;
119
97
  return mockFn;
120
98
  };
121
99
  mockFn.mockReturnValueOnce = (v)=>{
122
- returnOnce.push(v);
100
+ onceQueue.push(()=>v);
123
101
  return mockFn;
124
102
  };
125
103
  mockFn.mockResolvedValue = (v)=>{
126
- resolvedValue = {
127
- set: true,
128
- value: v
129
- };
104
+ currentImpl = ()=>Promise.resolve(v);
130
105
  return mockFn;
131
106
  };
132
107
  mockFn.mockResolvedValueOnce = (v)=>{
133
- resolvedOnce.push(v);
108
+ onceQueue.push(()=>Promise.resolve(v));
134
109
  return mockFn;
135
110
  };
136
111
  mockFn.mockRejectedValue = (v)=>{
137
- rejectedValue = {
138
- set: true,
139
- value: v
140
- };
112
+ currentImpl = ()=>Promise.reject(v);
141
113
  return mockFn;
142
114
  };
143
115
  mockFn.mockRejectedValueOnce = (v)=>{
144
- rejectedOnce.push(v);
116
+ onceQueue.push(()=>Promise.reject(v));
145
117
  return mockFn;
146
118
  };
147
119
  mockFn.mockImplementation = (fn)=>{
@@ -149,7 +121,7 @@
149
121
  return mockFn;
150
122
  };
151
123
  mockFn.mockImplementationOnce = (fn)=>{
152
- implOnce.push(fn);
124
+ onceQueue.push(fn);
153
125
  return mockFn;
154
126
  };
155
127
  mockFn.mockClear = ()=>{
@@ -162,22 +134,7 @@
162
134
  calls.length = 0;
163
135
  results.length = 0;
164
136
  instances.length = 0;
165
- returnOnce = [];
166
- returnValue = {
167
- set: false,
168
- value: undefined
169
- };
170
- resolvedOnce = [];
171
- resolvedValue = {
172
- set: false,
173
- value: undefined
174
- };
175
- rejectedOnce = [];
176
- rejectedValue = {
177
- set: false,
178
- value: undefined
179
- };
180
- implOnce.length = 0;
137
+ onceQueue = [];
181
138
  currentImpl = impl;
182
139
  return mockFn;
183
140
  };
@@ -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.0",
4
+ "version": "3.0.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22"