meocord 3.0.0 → 3.2.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/AUTHOR.md +2 -3
- package/README.md +361 -55
- package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
- package/dist/cjs/core/index.cjs +246 -86
- package/dist/cjs/decorator/index.cjs +4 -2
- package/dist/cjs/enum/index.cjs +17 -4
- package/dist/cjs/testing/index.cjs +88 -4
- package/dist/esm/bin/app-template/README.md.template +61 -0
- package/dist/esm/bin/app-template/_env.example.template +2 -0
- package/dist/esm/bin/app-template/_gitignore.template +22 -0
- package/dist/esm/bin/app-template/_prettierrc.mjs.template +10 -0
- package/dist/esm/bin/app-template/eslint.config.ts.template +25 -0
- package/dist/esm/bin/app-template/meocord.config.ts.template +25 -0
- package/dist/esm/bin/app-template/package.json.template +38 -0
- package/dist/esm/bin/app-template/src/app.ts.template +44 -0
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +19 -0
- package/dist/esm/bin/app-template/src/controllers/context-menu/builders/sample.builder.ts.template +10 -0
- package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.ts.template +13 -0
- package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +28 -0
- package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.ts.template +13 -0
- package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.ts.template +29 -0
- package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.ts.template +11 -0
- package/dist/esm/bin/app-template/src/controllers/slash/builders/sample.builder.ts.template +10 -0
- package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.ts.template +19 -0
- package/dist/esm/bin/app-template/src/guards/rate-limit.guard.spec.ts.template +13 -0
- package/dist/esm/bin/app-template/src/guards/rate-limit.guard.ts.template +52 -0
- package/dist/esm/bin/app-template/src/main.ts.template +14 -0
- package/dist/esm/bin/app-template/src/services/sample.service.spec.ts.template +17 -0
- package/dist/esm/bin/app-template/src/services/sample.service.ts.template +9 -0
- package/dist/esm/bin/app-template/tsconfig.eslint.json.template +5 -0
- package/dist/esm/bin/app-template/tsconfig.json.template +30 -0
- package/dist/esm/bin/app-template/tsconfig.test.json.template +8 -0
- package/dist/esm/bin/app-template/vitest.config.ts.template +35 -0
- package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
- package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
- package/dist/esm/bin/builder-template/controller/button.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
- package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
- package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
- package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
- package/dist/esm/bin/generator.js +4 -9
- package/dist/esm/bin/helper/app-generator.helper.js +78 -0
- package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
- package/dist/esm/bin/meocord.js +156 -75
- package/dist/esm/core/meocord.app.js +247 -86
- package/dist/esm/decorator/controller.decorator.js +73 -10
- package/dist/esm/decorator/guard.decorator.js +1 -1
- package/dist/esm/decorator/index.js +1 -1
- package/dist/esm/enum/controller.enum.js +23 -4
- package/dist/esm/testing/mock-interaction.js +89 -5
- package/dist/esm/util/common.util.js +11 -3
- package/dist/esm/util/generator-cli.util.js +21 -4
- package/dist/esm/util/interaction.util.js +174 -0
- package/dist/esm/util/package-manager.util.js +9 -2
- package/dist/esm/util/package-version.util.js +32 -0
- package/dist/esm/util/runtime.util.js +72 -0
- package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
- package/dist/types/core/index.d.ts +75 -2
- package/dist/types/decorator/index.d.ts +61 -49
- package/dist/types/enum/index.d.ts +1 -1
- package/dist/types/interface/index.d.ts +89 -3
- package/dist/types/testing/index.d.ts +3 -21
- package/package.json +11 -12
- package/dist/cjs/_shared/controller.decorator-CC6BjHkS.cjs +0 -288
- package/dist/types/controller.enum-QA-IuReF.d.ts +0 -18
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
require('reflect-metadata');
|
|
4
|
+
var inversify = require('inversify');
|
|
5
|
+
var enum_index = require('../enum/index.cjs');
|
|
6
|
+
var metadataKey_enum = require('./metadata-key.enum-BzzvGUId.cjs');
|
|
7
|
+
var discord_js = require('discord.js');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The discord.js class each command type is handled by.
|
|
11
|
+
*
|
|
12
|
+
* One table rather than a chain of `isButton() || isStringSelectMenu() || ...`: the
|
|
13
|
+
* registration guard in `@Command`, the dispatcher, and the type-level
|
|
14
|
+
* `CommandInteractionType` all have to agree on what a command type accepts, and a
|
|
15
|
+
* chain repeated in three files drifts the moment a fifth select menu appears. Adding
|
|
16
|
+
* a `CommandType` member without an entry here is a compile error, not a silent
|
|
17
|
+
* fall-through to "Command not found!".
|
|
18
|
+
*/ const INTERACTION_MATCHERS = {
|
|
19
|
+
[enum_index.CommandType.SLASH]: (interaction)=>interaction instanceof discord_js.ChatInputCommandInteraction,
|
|
20
|
+
[enum_index.CommandType.CONTEXT_MENU]: (interaction)=>interaction instanceof discord_js.ContextMenuCommandInteraction,
|
|
21
|
+
[enum_index.CommandType.PRIMARY_ENTRY_POINT]: (interaction)=>interaction instanceof discord_js.PrimaryEntryPointCommandInteraction,
|
|
22
|
+
[enum_index.CommandType.BUTTON]: (interaction)=>interaction instanceof discord_js.ButtonInteraction,
|
|
23
|
+
[enum_index.CommandType.SELECT_MENU]: (interaction)=>interaction instanceof discord_js.StringSelectMenuInteraction,
|
|
24
|
+
[enum_index.CommandType.USER_SELECT_MENU]: (interaction)=>interaction instanceof discord_js.UserSelectMenuInteraction,
|
|
25
|
+
[enum_index.CommandType.ROLE_SELECT_MENU]: (interaction)=>interaction instanceof discord_js.RoleSelectMenuInteraction,
|
|
26
|
+
[enum_index.CommandType.MENTIONABLE_SELECT_MENU]: (interaction)=>interaction instanceof discord_js.MentionableSelectMenuInteraction,
|
|
27
|
+
[enum_index.CommandType.CHANNEL_SELECT_MENU]: (interaction)=>interaction instanceof discord_js.ChannelSelectMenuInteraction,
|
|
28
|
+
[enum_index.CommandType.MODAL_SUBMIT]: (interaction)=>interaction instanceof discord_js.ModalSubmitInteraction
|
|
29
|
+
};
|
|
30
|
+
/** Command types Discord identifies by a registered name rather than by a customId. */ const NAME_ROUTED_TYPES = new Set([
|
|
31
|
+
enum_index.CommandType.SLASH,
|
|
32
|
+
enum_index.CommandType.CONTEXT_MENU,
|
|
33
|
+
enum_index.CommandType.PRIMARY_ENTRY_POINT
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Whether an interaction is the kind the given command type handles.
|
|
37
|
+
*
|
|
38
|
+
* @param type - The command type declared on `@Command`.
|
|
39
|
+
* @param interaction - The interaction being dispatched.
|
|
40
|
+
*/ function matchesCommandType(type, interaction) {
|
|
41
|
+
const matches = INTERACTION_MATCHERS[type];
|
|
42
|
+
return matches !== undefined && matches(interaction);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether the command type is routed by matching a customId pattern.
|
|
46
|
+
*
|
|
47
|
+
* Components carry an application-defined customId and so are matched by pattern;
|
|
48
|
+
* commands carry a name Discord itself registered and are matched exactly.
|
|
49
|
+
*
|
|
50
|
+
* @param type - The command type declared on `@Command`.
|
|
51
|
+
*/ function isCustomIdRouted(type) {
|
|
52
|
+
return !NAME_ROUTED_TYPES.has(type);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether an interaction carries a customId, and so can be routed by pattern.
|
|
56
|
+
*
|
|
57
|
+
* @param interaction - The interaction being dispatched.
|
|
58
|
+
*/ function hasCustomId(interaction) {
|
|
59
|
+
return interaction instanceof discord_js.MessageComponentInteraction || interaction instanceof discord_js.ModalSubmitInteraction;
|
|
60
|
+
}
|
|
61
|
+
/** Separates a command from its subcommand group and subcommand in a route key. */ const COMMAND_PATH_SEPARATOR = ' ';
|
|
62
|
+
/**
|
|
63
|
+
* The route keys a chat input interaction can be handled by, most specific first.
|
|
64
|
+
*
|
|
65
|
+
* Discord sends `/settings notify email` as one interaction named `settings`, so
|
|
66
|
+
* routing on `commandName` alone gives every subcommand of a command the same handler
|
|
67
|
+
* — and the framework would run whichever one was declared first. The full path is
|
|
68
|
+
* tried before the bare name so a command can either split its subcommands across
|
|
69
|
+
* methods or keep handling them in one, but never both by accident.
|
|
70
|
+
*
|
|
71
|
+
* A group is never dropped on the way down: `settings notify email` does not fall back
|
|
72
|
+
* to `settings email`, because a second group could declare its own `email` and the
|
|
73
|
+
* two would be indistinguishable.
|
|
74
|
+
*
|
|
75
|
+
* @param interaction - The chat input or autocomplete interaction being dispatched.
|
|
76
|
+
* @returns The keys to look up, most specific first.
|
|
77
|
+
*/ function resolveCommandPaths(interaction) {
|
|
78
|
+
const { commandName } = interaction;
|
|
79
|
+
const options = interaction.options;
|
|
80
|
+
// Guarded rather than called directly: `options` is a stub on a hand-built test
|
|
81
|
+
// double, and an interaction whose command has no subcommands still has to route.
|
|
82
|
+
const group = typeof options?.getSubcommandGroup === 'function' ? options.getSubcommandGroup(false) : null;
|
|
83
|
+
const sub = typeof options?.getSubcommand === 'function' ? options.getSubcommand(false) : null;
|
|
84
|
+
const path = [
|
|
85
|
+
commandName,
|
|
86
|
+
group,
|
|
87
|
+
sub
|
|
88
|
+
].filter((part)=>Boolean(part));
|
|
89
|
+
const full = path.join(COMMAND_PATH_SEPARATOR);
|
|
90
|
+
return full === commandName ? [
|
|
91
|
+
commandName
|
|
92
|
+
] : [
|
|
93
|
+
full,
|
|
94
|
+
commandName
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
/** Options that only wrap other options; their values live one level down. */ const NESTING_OPTION_TYPES = new Set([
|
|
98
|
+
discord_js.ApplicationCommandOptionType.Subcommand,
|
|
99
|
+
discord_js.ApplicationCommandOptionType.SubcommandGroup
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* The value a handler should receive for one option.
|
|
103
|
+
*
|
|
104
|
+
* Discord sends entity options as a snowflake plus a `resolved` payload, and discord.js
|
|
105
|
+
* puts that payload on the option as `user`/`role`/`channel`/`attachment`. Passing
|
|
106
|
+
* `value` alone would hand the handler a bare id string for `@user`, forcing every
|
|
107
|
+
* handler to re-fetch what the gateway already delivered.
|
|
108
|
+
*/ function resolveOptionValue(option) {
|
|
109
|
+
return option.attachment ?? option.channel ?? option.role ?? option.user ?? option.member ?? option.value;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Flattens a chat input interaction's options into the params record handlers receive.
|
|
113
|
+
*
|
|
114
|
+
* Subcommand and subcommand-group options are containers, not values — for
|
|
115
|
+
* `/settings notify email true` the top level holds only `notify`. Recursing past them
|
|
116
|
+
* means a subcommand handler sees `{ email: true }`, the same shape a flat command's
|
|
117
|
+
* handler sees.
|
|
118
|
+
*
|
|
119
|
+
* @param interaction - The chat input or autocomplete interaction being dispatched.
|
|
120
|
+
* @returns Each supplied option keyed by name, with entity options resolved.
|
|
121
|
+
*/ function resolveOptionParams(interaction) {
|
|
122
|
+
const data = interaction.options?.data;
|
|
123
|
+
if (!Array.isArray(data)) return {};
|
|
124
|
+
const params = {};
|
|
125
|
+
const walk = (options)=>{
|
|
126
|
+
for (const option of options){
|
|
127
|
+
if (NESTING_OPTION_TYPES.has(option.type)) {
|
|
128
|
+
walk(option.options ?? []);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
params[option.name] = resolveOptionValue(option);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
walk(data);
|
|
135
|
+
return params;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The name of the option the user is currently typing, or `undefined`.
|
|
139
|
+
*
|
|
140
|
+
* `getFocused` throws when nothing is focused rather than returning null, and it is
|
|
141
|
+
* absent altogether on a hand-built test double. Neither is worth failing a dispatch
|
|
142
|
+
* over — an autocomplete with no focused option simply matches no option-specific
|
|
143
|
+
* handler.
|
|
144
|
+
*
|
|
145
|
+
* @param interaction - The autocomplete interaction being dispatched.
|
|
146
|
+
*/ function focusedOptionName(interaction) {
|
|
147
|
+
if (typeof interaction.options?.getFocused !== 'function') return undefined;
|
|
148
|
+
try {
|
|
149
|
+
return interaction.options.getFocused(true)?.name;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Identifies an interaction in a log line, by whichever field would have routed it.
|
|
156
|
+
*
|
|
157
|
+
* @param interaction - The interaction that matched no handler.
|
|
158
|
+
*/ function describeInteraction(interaction) {
|
|
159
|
+
// Captured before the narrowing below: the guards cover every member of the union,
|
|
160
|
+
// so by the fallback `interaction` is `never` and nothing can be read off it.
|
|
161
|
+
const { type } = interaction;
|
|
162
|
+
if (interaction.isAutocomplete()) {
|
|
163
|
+
const path = resolveCommandPaths(interaction)[0];
|
|
164
|
+
const focused = focusedOptionName(interaction);
|
|
165
|
+
return focused === undefined ? `autocomplete for "${path}"` : `autocomplete for "${path}" option "${focused}"`;
|
|
166
|
+
}
|
|
167
|
+
if (interaction.isChatInputCommand()) {
|
|
168
|
+
return `command "${resolveCommandPaths(interaction)[0]}"`;
|
|
169
|
+
}
|
|
170
|
+
if (interaction.isContextMenuCommand() || interaction.isPrimaryEntryPointCommand()) {
|
|
171
|
+
return `command "${interaction.commandName}"`;
|
|
172
|
+
}
|
|
173
|
+
if (hasCustomId(interaction)) {
|
|
174
|
+
return `customId "${interaction.customId}"`;
|
|
175
|
+
}
|
|
176
|
+
return `interaction type ${type}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const COMMAND_METADATA_KEY = Symbol('commands');
|
|
180
|
+
const MESSAGE_HANDLER_METADATA_KEY = Symbol('message_handlers');
|
|
181
|
+
const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
|
|
182
|
+
const AUTOCOMPLETE_METADATA_KEY = Symbol('autocomplete_handlers');
|
|
183
|
+
/**
|
|
184
|
+
* Decorator to register message handlers in the controller.
|
|
185
|
+
*
|
|
186
|
+
* @param keyword - An optional keyword to filter messages this handler should respond to.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```typescript
|
|
190
|
+
* @MessageHandler('hello')
|
|
191
|
+
* async handleHelloMessage(message: Message) {
|
|
192
|
+
* await message.reply('Hello! How can I help you?');
|
|
193
|
+
* }
|
|
194
|
+
*
|
|
195
|
+
* @MessageHandler()
|
|
196
|
+
* async handleAnyMessage(message: Message) {
|
|
197
|
+
* console.log(`Received a message: ${message.content}`);
|
|
198
|
+
* }
|
|
199
|
+
* ```
|
|
200
|
+
*/ function MessageHandler(keyword) {
|
|
201
|
+
return function(target, propertyKey, _descriptor) {
|
|
202
|
+
const handlers = Reflect.getMetadata(MESSAGE_HANDLER_METADATA_KEY, target) || [];
|
|
203
|
+
handlers.push({
|
|
204
|
+
keyword,
|
|
205
|
+
method: propertyKey.toString()
|
|
206
|
+
});
|
|
207
|
+
Reflect.defineMetadata(MESSAGE_HANDLER_METADATA_KEY, handlers, target);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Decorator to register reaction handlers in the controller.
|
|
212
|
+
*
|
|
213
|
+
* @param emoji - Optional emoji name to filter reactions this handler should respond to.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```typescript
|
|
217
|
+
* @ReactionHandler('👍')
|
|
218
|
+
* async handleThumbsUpReaction(reaction: MessageReaction, { user }: ReactionHandlerOptions) {
|
|
219
|
+
* console.log(`User ${user.username} reacted with 👍`);
|
|
220
|
+
* }
|
|
221
|
+
*
|
|
222
|
+
* @ReactionHandler()
|
|
223
|
+
* async handleAnyReaction(reaction: MessageReaction, { user }: ReactionHandlerOptions) {
|
|
224
|
+
* console.log(`User ${user.username} reacted with ${reaction.emoji.name}`);
|
|
225
|
+
* }
|
|
226
|
+
* ```
|
|
227
|
+
*/ function ReactionHandler(emoji) {
|
|
228
|
+
return function(target, propertyKey, _descriptor) {
|
|
229
|
+
const handlers = Reflect.getMetadata(REACTION_HANDLER_METADATA_KEY, target) || [];
|
|
230
|
+
handlers.push({
|
|
231
|
+
emoji,
|
|
232
|
+
method: propertyKey.toString()
|
|
233
|
+
});
|
|
234
|
+
Reflect.defineMetadata(REACTION_HANDLER_METADATA_KEY, handlers, target);
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Retrieves reaction handlers metadata from a given controller.
|
|
239
|
+
*
|
|
240
|
+
* @param controller - The controller class instance.
|
|
241
|
+
* @returns An array of reaction handler metadata objects.
|
|
242
|
+
*/ function getReactionHandlers(controller) {
|
|
243
|
+
return Reflect.getMetadata(REACTION_HANDLER_METADATA_KEY, controller) || [];
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Retrieves message handlers metadata from a given controller.
|
|
247
|
+
*
|
|
248
|
+
* @param controller - The controller class instance.
|
|
249
|
+
* @returns An array of message handler method names.
|
|
250
|
+
*/ function getMessageHandlers(controller) {
|
|
251
|
+
return Reflect.getMetadata(MESSAGE_HANDLER_METADATA_KEY, controller) || [];
|
|
252
|
+
}
|
|
253
|
+
const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
254
|
+
/** The character a parameter will not cross, so one pattern segment maps to one value. */ const PARAM_SEPARATOR = '/';
|
|
255
|
+
/** Escapes a literal stretch of a pattern so only placeholders stay meaningful. */ const escapeLiteral = (literal)=>literal.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
256
|
+
/**
|
|
257
|
+
* Helper function to create regex and parameter mappings from a pattern string.
|
|
258
|
+
*
|
|
259
|
+
* A `{name}` matches anything up to the next `/`, the same rule Express and Rails use
|
|
260
|
+
* for a path segment. That is what lets a value the application does not control — a
|
|
261
|
+
* uuid, an opaque vendor id, a slug — be captured whole without the author annotating
|
|
262
|
+
* anything, since a hyphen inside it is data rather than structure.
|
|
263
|
+
*
|
|
264
|
+
* It also keeps neighbouring patterns apart: `profile/{uuid}` and `profile/{uuid}/{id}`
|
|
265
|
+
* cannot both match one id, because a parameter cannot swallow the separator between
|
|
266
|
+
* them. Patterns separated by `-` instead have no such boundary, so a pair like
|
|
267
|
+
* `profile-{uuid}` and `profile-{uuid}-{id}` is ambiguous — {@link findAmbiguousRoutes}
|
|
268
|
+
* reports those at registration.
|
|
269
|
+
*
|
|
270
|
+
* @param pattern - The pattern string to parse.
|
|
271
|
+
* @returns The regex, the parameter names, and how specific the pattern is.
|
|
272
|
+
*/ function createRegexFromPattern(pattern) {
|
|
273
|
+
const params = [];
|
|
274
|
+
let regexPattern = '';
|
|
275
|
+
let cursor = 0;
|
|
276
|
+
let literalLength = 0;
|
|
277
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
278
|
+
let match;
|
|
279
|
+
while((match = PLACEHOLDER_PATTERN.exec(pattern)) !== null){
|
|
280
|
+
const [placeholder, param] = match;
|
|
281
|
+
const literal = pattern.slice(cursor, match.index);
|
|
282
|
+
const after = pattern[match.index + placeholder.length];
|
|
283
|
+
// A parameter has to own its segment. Sharing one with a literal leaves no
|
|
284
|
+
// boundary a sibling pattern can be told apart by, and the resulting overlap has
|
|
285
|
+
// no correct reading -- `profile-{uuid}` and `profile-{uuid}-{id}` both take
|
|
286
|
+
// `profile-a-b`. Registration is the last point where that is still fixable.
|
|
287
|
+
if (literal !== '' && !literal.endsWith(PARAM_SEPARATOR) || after !== undefined && after !== PARAM_SEPARATOR) {
|
|
288
|
+
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}}".`);
|
|
289
|
+
}
|
|
290
|
+
literalLength += literal.length;
|
|
291
|
+
regexPattern += escapeLiteral(literal);
|
|
292
|
+
regexPattern += `(?<${param}>[^${PARAM_SEPARATOR}]+)`;
|
|
293
|
+
params.push(param);
|
|
294
|
+
cursor = match.index + placeholder.length;
|
|
295
|
+
}
|
|
296
|
+
const trailing = pattern.slice(cursor);
|
|
297
|
+
literalLength += trailing.length;
|
|
298
|
+
regexPattern += escapeLiteral(trailing);
|
|
299
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
300
|
+
// Literal text is the signal: a pattern spelling out more of the id describes it
|
|
301
|
+
// more exactly than one leaving it to a parameter. Fewer parameters breaks a tie
|
|
302
|
+
// between equal-length patterns, so the ranking is total and never falls back to
|
|
303
|
+
// declaration order.
|
|
304
|
+
const specificity = literalLength * 1000 - params.length;
|
|
305
|
+
return {
|
|
306
|
+
regex,
|
|
307
|
+
params,
|
|
308
|
+
specificity
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Decorator to register command methods in a controller.
|
|
313
|
+
*
|
|
314
|
+
* @param commandName - What the command is addressed by. Commands registered with
|
|
315
|
+
* Discord use their name, and a subcommand its full path — `settings notify email`,
|
|
316
|
+
* parts separated by a space, the way Discord displays it. Components use a customId
|
|
317
|
+
* pattern, where `{name}` captures one `/`-separated segment.
|
|
318
|
+
* @param builderOrType - A command builder class, or a `CommandType` for a handler that
|
|
319
|
+
* registers nothing of its own: every component, and every subcommand of a command
|
|
320
|
+
* whose builder already describes it.
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* @Command('help', CommandType.SLASH)
|
|
325
|
+
* public async handleHelp(interaction: ChatInputCommandInteraction) {
|
|
326
|
+
* await interaction.reply('This is the help command!')
|
|
327
|
+
* }
|
|
328
|
+
*
|
|
329
|
+
* @Command('settings notify email', CommandType.SLASH)
|
|
330
|
+
* public async handleNotifyEmail(interaction: ChatInputCommandInteraction, { enabled }) {
|
|
331
|
+
* await interaction.reply(`Email notifications ${enabled ? 'on' : 'off'}`)
|
|
332
|
+
* }
|
|
333
|
+
*
|
|
334
|
+
* @Command('stats/{id}', CommandType.BUTTON)
|
|
335
|
+
* public async handleStats(interaction: ButtonInteraction, { id }) {
|
|
336
|
+
* await interaction.reply(`Fetching stats for ID: ${id}`);
|
|
337
|
+
* }
|
|
338
|
+
*
|
|
339
|
+
* @Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
|
|
340
|
+
* public async handleAssign(interaction: UserSelectMenuInteraction, { taskId }) {
|
|
341
|
+
* await interaction.reply(`Assigned ${interaction.users.size} user(s) to ${taskId}`)
|
|
342
|
+
* }
|
|
343
|
+
* ```
|
|
344
|
+
*/ function Command(commandName, builderOrType) {
|
|
345
|
+
return function(target, propertyKey, _descriptor) {
|
|
346
|
+
const originalMethod = _descriptor.value;
|
|
347
|
+
if (!originalMethod) {
|
|
348
|
+
throw new Error(`Missing implementation for method ${propertyKey}`);
|
|
349
|
+
}
|
|
350
|
+
// Wrap original method for interaction type validation
|
|
351
|
+
_descriptor.value = function(interaction, params) {
|
|
352
|
+
if (!matchesCommandType(commandType, interaction)) {
|
|
353
|
+
throw new Error(`Invalid interaction type passed to @Command for method: ${propertyKey}`);
|
|
354
|
+
}
|
|
355
|
+
return originalMethod.apply(this, [
|
|
356
|
+
interaction,
|
|
357
|
+
params
|
|
358
|
+
]);
|
|
359
|
+
};
|
|
360
|
+
// Retrieve existing metadata or initialize it
|
|
361
|
+
const commands = Reflect.getMetadata(COMMAND_METADATA_KEY, target) || {};
|
|
362
|
+
let builderInstance;
|
|
363
|
+
let commandType;
|
|
364
|
+
let regex;
|
|
365
|
+
let dynamicParams = [];
|
|
366
|
+
let specificity;
|
|
367
|
+
// Determine command type and builder
|
|
368
|
+
if (typeof builderOrType === 'function') {
|
|
369
|
+
const builderObj = new builderOrType();
|
|
370
|
+
builderInstance = builderObj.build(commandName);
|
|
371
|
+
commandType = Reflect.getMetadata(metadataKey_enum.MetadataKey.CommandType, builderOrType);
|
|
372
|
+
if (!(commandType in enum_index.CommandType)) {
|
|
373
|
+
throw new Error(`Metadata for 'commandType' is missing on builder ${builderOrType.name}`);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
commandType = builderOrType;
|
|
377
|
+
}
|
|
378
|
+
if (isCustomIdRouted(commandType)) {
|
|
379
|
+
const { regex: generatedRegex, params, specificity: patternSpecificity } = createRegexFromPattern(commandName);
|
|
380
|
+
regex = generatedRegex;
|
|
381
|
+
dynamicParams = params;
|
|
382
|
+
specificity = patternSpecificity;
|
|
383
|
+
}
|
|
384
|
+
// Ensure commandName supports multiple entries
|
|
385
|
+
if (!commands[commandName]) {
|
|
386
|
+
commands[commandName] = [];
|
|
387
|
+
}
|
|
388
|
+
commands[commandName].push({
|
|
389
|
+
methodName: propertyKey,
|
|
390
|
+
builder: builderInstance,
|
|
391
|
+
type: commandType,
|
|
392
|
+
regex,
|
|
393
|
+
dynamicParams,
|
|
394
|
+
specificity
|
|
395
|
+
});
|
|
396
|
+
Reflect.defineMetadata(COMMAND_METADATA_KEY, commands, target);
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Retrieves the command map for a given controller.
|
|
401
|
+
*
|
|
402
|
+
* @param controller - The controller class instance.
|
|
403
|
+
* @returns A record containing command metadata indexed by command names.
|
|
404
|
+
*/ function getCommandMap(controller) {
|
|
405
|
+
return Reflect.getMetadata(COMMAND_METADATA_KEY, controller);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Decorator to register an autocomplete handler for a chat input command's option.
|
|
409
|
+
*
|
|
410
|
+
* Autocomplete is a separate interaction from the command it belongs to, and Discord
|
|
411
|
+
* sends it while the user is still typing. It is not a `@Command`: nothing is
|
|
412
|
+
* registered for it — the option's own `setAutocomplete(true)` is what turns it on —
|
|
413
|
+
* and it is answered with `interaction.respond()` rather than a reply. Leaving it
|
|
414
|
+
* unhandled is not silent to the user: the client shows a loading state until the
|
|
415
|
+
* three-second window closes.
|
|
416
|
+
*
|
|
417
|
+
* @param commandPath - The command to complete, e.g. `settings` or `settings notify email`
|
|
418
|
+
* for a subcommand. Parts are separated by a single space, as Discord displays them.
|
|
419
|
+
* @param optionName - The option to complete. Omit to handle every option of the command,
|
|
420
|
+
* branching on `interaction.options.getFocused(true)`.
|
|
421
|
+
*
|
|
422
|
+
* @example
|
|
423
|
+
* ```typescript
|
|
424
|
+
* @Autocomplete('search', 'query')
|
|
425
|
+
* async completeQuery(interaction: AutocompleteInteraction) {
|
|
426
|
+
* const { value } = interaction.options.getFocused(true)
|
|
427
|
+
* await interaction.respond(this.search(value).map(name => ({ name, value: name })))
|
|
428
|
+
* }
|
|
429
|
+
* ```
|
|
430
|
+
*/ function Autocomplete(commandPath, optionName) {
|
|
431
|
+
return function(target, propertyKey, _descriptor) {
|
|
432
|
+
const handlers = Reflect.getMetadata(AUTOCOMPLETE_METADATA_KEY, target) || [];
|
|
433
|
+
handlers.push({
|
|
434
|
+
commandPath,
|
|
435
|
+
optionName,
|
|
436
|
+
methodName: propertyKey.toString()
|
|
437
|
+
});
|
|
438
|
+
Reflect.defineMetadata(AUTOCOMPLETE_METADATA_KEY, handlers, target);
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Retrieves autocomplete handler metadata from a given controller.
|
|
443
|
+
*
|
|
444
|
+
* Handlers naming an option come first, so a command-wide handler acts as the fallback
|
|
445
|
+
* for options no specific handler claimed rather than shadowing them by declaration order.
|
|
446
|
+
*
|
|
447
|
+
* @param controller - The controller class instance.
|
|
448
|
+
* @returns The registered autocomplete handlers, most specific first.
|
|
449
|
+
*/ function getAutocompleteHandlers(controller) {
|
|
450
|
+
const handlers = Reflect.getMetadata(AUTOCOMPLETE_METADATA_KEY, controller) || [];
|
|
451
|
+
return [
|
|
452
|
+
...handlers
|
|
453
|
+
].sort((a, b)=>Number(Boolean(b.optionName)) - Number(Boolean(a.optionName)));
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Decorator to mark a class as a controller that can later be registered to the App class `(app.ts)` using the `@MeoCord` decorator.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* ```typescript
|
|
460
|
+
* @Controller()
|
|
461
|
+
* export class PingSlashController {
|
|
462
|
+
* constructor(private pingService: PingService) {}
|
|
463
|
+
*
|
|
464
|
+
* @Command('ping', PingCommandBuilder)
|
|
465
|
+
* async ping(interaction: ChatInputCommandInteraction) {
|
|
466
|
+
* const response = await this.pingService.handlePing()
|
|
467
|
+
* await interaction.reply(response)
|
|
468
|
+
* }
|
|
469
|
+
* }
|
|
470
|
+
* ```
|
|
471
|
+
*/ function Controller() {
|
|
472
|
+
return function(target) {
|
|
473
|
+
if (!Reflect.hasMetadata(metadataKey_enum.MetadataKey.Injectable, target)) {
|
|
474
|
+
inversify.injectable()(target);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Finds pattern pairs that can both match one customId.
|
|
480
|
+
*
|
|
481
|
+
* Patterns of different segment counts are disjoint, because a parameter cannot cross
|
|
482
|
+
* `/`. Within the same count, two patterns overlap unless some position holds literals
|
|
483
|
+
* that differ: `a/{x}/c` and `a/b/{y}` both take `a/b/c`, and neither is more literal
|
|
484
|
+
* than the other, so ranking cannot settle it either.
|
|
485
|
+
*
|
|
486
|
+
* @param patterns - The registered patterns.
|
|
487
|
+
* @returns Each ambiguous pair, once, in the order the patterns were given.
|
|
488
|
+
*/ function findAmbiguousRoutes(patterns) {
|
|
489
|
+
const isParam = (segment)=>PLACEHOLDER_PATTERN.test(segment);
|
|
490
|
+
const segmentsOf = (pattern)=>pattern.split(PARAM_SEPARATOR);
|
|
491
|
+
const collisions = [];
|
|
492
|
+
for(let i = 0; i < patterns.length; i++){
|
|
493
|
+
for(let j = i + 1; j < patterns.length; j++){
|
|
494
|
+
const left = segmentsOf(patterns[i]);
|
|
495
|
+
const right = segmentsOf(patterns[j]);
|
|
496
|
+
if (left.length !== right.length) continue;
|
|
497
|
+
const disjoint = left.some((segment, index)=>{
|
|
498
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
499
|
+
const leftIsParam = isParam(segment);
|
|
500
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
501
|
+
const rightIsParam = isParam(right[index]);
|
|
502
|
+
return !leftIsParam && !rightIsParam && segment !== right[index];
|
|
503
|
+
});
|
|
504
|
+
if (!disjoint) collisions.push([
|
|
505
|
+
patterns[i],
|
|
506
|
+
patterns[j]
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return collisions;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
exports.Autocomplete = Autocomplete;
|
|
514
|
+
exports.Command = Command;
|
|
515
|
+
exports.Controller = Controller;
|
|
516
|
+
exports.MessageHandler = MessageHandler;
|
|
517
|
+
exports.PARAM_SEPARATOR = PARAM_SEPARATOR;
|
|
518
|
+
exports.ReactionHandler = ReactionHandler;
|
|
519
|
+
exports.describeInteraction = describeInteraction;
|
|
520
|
+
exports.findAmbiguousRoutes = findAmbiguousRoutes;
|
|
521
|
+
exports.focusedOptionName = focusedOptionName;
|
|
522
|
+
exports.getAutocompleteHandlers = getAutocompleteHandlers;
|
|
523
|
+
exports.getCommandMap = getCommandMap;
|
|
524
|
+
exports.getMessageHandlers = getMessageHandlers;
|
|
525
|
+
exports.getReactionHandlers = getReactionHandlers;
|
|
526
|
+
exports.hasCustomId = hasCustomId;
|
|
527
|
+
exports.matchesCommandType = matchesCommandType;
|
|
528
|
+
exports.resolveCommandPaths = resolveCommandPaths;
|
|
529
|
+
exports.resolveOptionParams = resolveOptionParams;
|