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 +65 -0
- package/dist/cjs/_shared/{controller.decorator-DX5lFlPZ.cjs → controller.decorator-CC6BjHkS.cjs} +86 -14
- package/dist/cjs/core/index.cjs +92 -49
- package/dist/cjs/decorator/index.cjs +3 -1
- package/dist/cjs/testing/index.cjs +19 -62
- package/dist/esm/core/meocord.app.js +92 -49
- package/dist/esm/decorator/controller.decorator.js +85 -15
- package/dist/esm/decorator/index.js +1 -1
- package/dist/esm/testing/mock-fn.js +19 -62
- package/dist/types/core/index.d.ts +21 -0
- package/dist/types/decorator/index.d.ts +22 -1
- package/package.json +1 -1
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.
|
package/dist/cjs/_shared/{controller.decorator-DX5lFlPZ.cjs → controller.decorator-CC6BjHkS.cjs}
RENAMED
|
@@ -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
|
|
100
|
+
* @returns The regex, the parameter names, and how specific the pattern is.
|
|
87
101
|
*/ function createRegexFromPattern(pattern) {
|
|
88
102
|
const params = [];
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
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;
|
package/dist/cjs/core/index.cjs
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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 (
|
|
183
|
-
value =
|
|
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
|
-
|
|
238
|
-
set: true,
|
|
239
|
-
value: v
|
|
240
|
-
};
|
|
218
|
+
currentImpl = ()=>v;
|
|
241
219
|
return mockFn;
|
|
242
220
|
};
|
|
243
221
|
mockFn.mockReturnValueOnce = (v)=>{
|
|
244
|
-
|
|
222
|
+
onceQueue.push(()=>v);
|
|
245
223
|
return mockFn;
|
|
246
224
|
};
|
|
247
225
|
mockFn.mockResolvedValue = (v)=>{
|
|
248
|
-
|
|
249
|
-
set: true,
|
|
250
|
-
value: v
|
|
251
|
-
};
|
|
226
|
+
currentImpl = ()=>Promise.resolve(v);
|
|
252
227
|
return mockFn;
|
|
253
228
|
};
|
|
254
229
|
mockFn.mockResolvedValueOnce = (v)=>{
|
|
255
|
-
|
|
230
|
+
onceQueue.push(()=>Promise.resolve(v));
|
|
256
231
|
return mockFn;
|
|
257
232
|
};
|
|
258
233
|
mockFn.mockRejectedValue = (v)=>{
|
|
259
|
-
|
|
260
|
-
set: true,
|
|
261
|
-
value: v
|
|
262
|
-
};
|
|
234
|
+
currentImpl = ()=>Promise.reject(v);
|
|
263
235
|
return mockFn;
|
|
264
236
|
};
|
|
265
237
|
mockFn.mockRejectedValueOnce = (v)=>{
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
+
* @returns The regex, the parameter names, and how specific the pattern is.
|
|
85
99
|
*/ function createRegexFromPattern(pattern) {
|
|
86
100
|
const params = [];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
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
|
|
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 (
|
|
61
|
-
value =
|
|
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
|
-
|
|
116
|
-
set: true,
|
|
117
|
-
value: v
|
|
118
|
-
};
|
|
96
|
+
currentImpl = ()=>v;
|
|
119
97
|
return mockFn;
|
|
120
98
|
};
|
|
121
99
|
mockFn.mockReturnValueOnce = (v)=>{
|
|
122
|
-
|
|
100
|
+
onceQueue.push(()=>v);
|
|
123
101
|
return mockFn;
|
|
124
102
|
};
|
|
125
103
|
mockFn.mockResolvedValue = (v)=>{
|
|
126
|
-
|
|
127
|
-
set: true,
|
|
128
|
-
value: v
|
|
129
|
-
};
|
|
104
|
+
currentImpl = ()=>Promise.resolve(v);
|
|
130
105
|
return mockFn;
|
|
131
106
|
};
|
|
132
107
|
mockFn.mockResolvedValueOnce = (v)=>{
|
|
133
|
-
|
|
108
|
+
onceQueue.push(()=>Promise.resolve(v));
|
|
134
109
|
return mockFn;
|
|
135
110
|
};
|
|
136
111
|
mockFn.mockRejectedValue = (v)=>{
|
|
137
|
-
|
|
138
|
-
set: true,
|
|
139
|
-
value: v
|
|
140
|
-
};
|
|
112
|
+
currentImpl = ()=>Promise.reject(v);
|
|
141
113
|
return mockFn;
|
|
142
114
|
};
|
|
143
115
|
mockFn.mockRejectedValueOnce = (v)=>{
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
4
|
+
"version": "3.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22"
|