meocord 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHOR.md +2 -3
- package/README.md +224 -41
- 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 +3 -1
- package/dist/cjs/enum/index.cjs +17 -4
- package/dist/cjs/testing/index.cjs +88 -4
- 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/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/controller-generator.helper.js +24 -7
- package/dist/esm/core/meocord.app.js +247 -86
- package/dist/esm/decorator/controller.decorator.js +73 -10
- 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/interaction.util.js +174 -0
- package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
- package/dist/types/core/index.d.ts +73 -0
- package/dist/types/decorator/index.d.ts +60 -48
- package/dist/types/enum/index.d.ts +1 -1
- package/dist/types/interface/index.d.ts +87 -3
- package/dist/types/testing/index.d.ts +3 -21
- package/package.json +2 -2
- package/dist/cjs/_shared/controller.decorator-CC6BjHkS.cjs +0 -288
- package/dist/types/controller.enum-QA-IuReF.d.ts +0 -18
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { injectable } from 'inversify';
|
|
3
|
-
import { ButtonInteraction, StringSelectMenuInteraction, ChatInputCommandInteraction, ContextMenuCommandInteraction, ModalSubmitInteraction } from 'discord.js';
|
|
4
3
|
import { CommandType } from '../enum/controller.enum.js';
|
|
5
4
|
import { MetadataKey } from '../enum/metadata-key.enum.js';
|
|
5
|
+
import { matchesCommandType, isCustomIdRouted } from '../util/interaction.util.js';
|
|
6
6
|
|
|
7
7
|
const COMMAND_METADATA_KEY = Symbol('commands');
|
|
8
8
|
const MESSAGE_HANDLER_METADATA_KEY = Symbol('message_handlers');
|
|
9
9
|
const REACTION_HANDLER_METADATA_KEY = Symbol('reaction_handlers');
|
|
10
|
+
const AUTOCOMPLETE_METADATA_KEY = Symbol('autocomplete_handlers');
|
|
10
11
|
/**
|
|
11
12
|
* Decorator to register message handlers in the controller.
|
|
12
13
|
*
|
|
@@ -138,8 +139,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
|
138
139
|
/**
|
|
139
140
|
* Decorator to register command methods in a controller.
|
|
140
141
|
*
|
|
141
|
-
* @param commandName -
|
|
142
|
-
*
|
|
142
|
+
* @param commandName - What the command is addressed by. Commands registered with
|
|
143
|
+
* Discord use their name, and a subcommand its full path — `settings notify email`,
|
|
144
|
+
* parts separated by a space, the way Discord displays it. Components use a customId
|
|
145
|
+
* pattern, where `{name}` captures one `/`-separated segment.
|
|
146
|
+
* @param builderOrType - A command builder class, or a `CommandType` for a handler that
|
|
147
|
+
* registers nothing of its own: every component, and every subcommand of a command
|
|
148
|
+
* whose builder already describes it.
|
|
143
149
|
*
|
|
144
150
|
* @example
|
|
145
151
|
* ```typescript
|
|
@@ -148,9 +154,19 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
|
148
154
|
* await interaction.reply('This is the help command!')
|
|
149
155
|
* }
|
|
150
156
|
*
|
|
151
|
-
* @Command('
|
|
152
|
-
* public async
|
|
153
|
-
* await
|
|
157
|
+
* @Command('settings notify email', CommandType.SLASH)
|
|
158
|
+
* public async handleNotifyEmail(interaction: ChatInputCommandInteraction, { enabled }) {
|
|
159
|
+
* await interaction.reply(`Email notifications ${enabled ? 'on' : 'off'}`)
|
|
160
|
+
* }
|
|
161
|
+
*
|
|
162
|
+
* @Command('stats/{id}', CommandType.BUTTON)
|
|
163
|
+
* public async handleStats(interaction: ButtonInteraction, { id }) {
|
|
164
|
+
* await interaction.reply(`Fetching stats for ID: ${id}`);
|
|
165
|
+
* }
|
|
166
|
+
*
|
|
167
|
+
* @Command('assign/{taskId}', CommandType.USER_SELECT_MENU)
|
|
168
|
+
* public async handleAssign(interaction: UserSelectMenuInteraction, { taskId }) {
|
|
169
|
+
* await interaction.reply(`Assigned ${interaction.users.size} user(s) to ${taskId}`)
|
|
154
170
|
* }
|
|
155
171
|
* ```
|
|
156
172
|
*/ function Command(commandName, builderOrType) {
|
|
@@ -161,8 +177,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
|
161
177
|
}
|
|
162
178
|
// Wrap original method for interaction type validation
|
|
163
179
|
_descriptor.value = function(interaction, params) {
|
|
164
|
-
|
|
165
|
-
if (!expectedInteraction) {
|
|
180
|
+
if (!matchesCommandType(commandType, interaction)) {
|
|
166
181
|
throw new Error(`Invalid interaction type passed to @Command for method: ${propertyKey}`);
|
|
167
182
|
}
|
|
168
183
|
return originalMethod.apply(this, [
|
|
@@ -188,7 +203,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
|
188
203
|
} else {
|
|
189
204
|
commandType = builderOrType;
|
|
190
205
|
}
|
|
191
|
-
if (commandType
|
|
206
|
+
if (isCustomIdRouted(commandType)) {
|
|
192
207
|
const { regex: generatedRegex, params, specificity: patternSpecificity } = createRegexFromPattern(commandName);
|
|
193
208
|
regex = generatedRegex;
|
|
194
209
|
dynamicParams = params;
|
|
@@ -217,6 +232,54 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
|
217
232
|
*/ function getCommandMap(controller) {
|
|
218
233
|
return Reflect.getMetadata(COMMAND_METADATA_KEY, controller);
|
|
219
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Decorator to register an autocomplete handler for a chat input command's option.
|
|
237
|
+
*
|
|
238
|
+
* Autocomplete is a separate interaction from the command it belongs to, and Discord
|
|
239
|
+
* sends it while the user is still typing. It is not a `@Command`: nothing is
|
|
240
|
+
* registered for it — the option's own `setAutocomplete(true)` is what turns it on —
|
|
241
|
+
* and it is answered with `interaction.respond()` rather than a reply. Leaving it
|
|
242
|
+
* unhandled is not silent to the user: the client shows a loading state until the
|
|
243
|
+
* three-second window closes.
|
|
244
|
+
*
|
|
245
|
+
* @param commandPath - The command to complete, e.g. `settings` or `settings notify email`
|
|
246
|
+
* for a subcommand. Parts are separated by a single space, as Discord displays them.
|
|
247
|
+
* @param optionName - The option to complete. Omit to handle every option of the command,
|
|
248
|
+
* branching on `interaction.options.getFocused(true)`.
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```typescript
|
|
252
|
+
* @Autocomplete('search', 'query')
|
|
253
|
+
* async completeQuery(interaction: AutocompleteInteraction) {
|
|
254
|
+
* const { value } = interaction.options.getFocused(true)
|
|
255
|
+
* await interaction.respond(this.search(value).map(name => ({ name, value: name })))
|
|
256
|
+
* }
|
|
257
|
+
* ```
|
|
258
|
+
*/ function Autocomplete(commandPath, optionName) {
|
|
259
|
+
return function(target, propertyKey, _descriptor) {
|
|
260
|
+
const handlers = Reflect.getMetadata(AUTOCOMPLETE_METADATA_KEY, target) || [];
|
|
261
|
+
handlers.push({
|
|
262
|
+
commandPath,
|
|
263
|
+
optionName,
|
|
264
|
+
methodName: propertyKey.toString()
|
|
265
|
+
});
|
|
266
|
+
Reflect.defineMetadata(AUTOCOMPLETE_METADATA_KEY, handlers, target);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Retrieves autocomplete handler metadata from a given controller.
|
|
271
|
+
*
|
|
272
|
+
* Handlers naming an option come first, so a command-wide handler acts as the fallback
|
|
273
|
+
* for options no specific handler claimed rather than shadowing them by declaration order.
|
|
274
|
+
*
|
|
275
|
+
* @param controller - The controller class instance.
|
|
276
|
+
* @returns The registered autocomplete handlers, most specific first.
|
|
277
|
+
*/ function getAutocompleteHandlers(controller) {
|
|
278
|
+
const handlers = Reflect.getMetadata(AUTOCOMPLETE_METADATA_KEY, controller) || [];
|
|
279
|
+
return [
|
|
280
|
+
...handlers
|
|
281
|
+
].sort((a, b)=>Number(Boolean(b.optionName)) - Number(Boolean(a.optionName)));
|
|
282
|
+
}
|
|
220
283
|
/**
|
|
221
284
|
* Decorator to mark a class as a controller that can later be registered to the App class `(app.ts)` using the `@MeoCord` decorator.
|
|
222
285
|
*
|
|
@@ -275,4 +338,4 @@ const PLACEHOLDER_PATTERN = /\{(\w+)}/g;
|
|
|
275
338
|
return collisions;
|
|
276
339
|
}
|
|
277
340
|
|
|
278
|
-
export { Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getCommandMap, getMessageHandlers, getReactionHandlers };
|
|
341
|
+
export { Autocomplete, Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getAutocompleteHandlers, 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, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
|
|
3
|
+
export { Autocomplete, Command, Controller, MessageHandler, PARAM_SEPARATOR, ReactionHandler, findAmbiguousRoutes, getAutocompleteHandlers, getCommandMap, getMessageHandlers, getReactionHandlers } from './controller.decorator.js';
|
|
4
4
|
export { Guard, UseGuard } from './guard.decorator.js';
|
|
5
5
|
export { MeoCord } from './app.decorator.js';
|
|
@@ -6,17 +6,36 @@
|
|
|
6
6
|
ControllerType["BUTTON"] = "button";
|
|
7
7
|
ControllerType["MODAL_SUBMIT"] = "modal-submit";
|
|
8
8
|
ControllerType["SELECT_MENU"] = "select-menu";
|
|
9
|
+
ControllerType["USER_SELECT_MENU"] = "user-select-menu";
|
|
10
|
+
ControllerType["ROLE_SELECT_MENU"] = "role-select-menu";
|
|
11
|
+
ControllerType["MENTIONABLE_SELECT_MENU"] = "mentionable-select-menu";
|
|
12
|
+
ControllerType["CHANNEL_SELECT_MENU"] = "channel-select-menu";
|
|
9
13
|
ControllerType["REACTION"] = "reaction";
|
|
10
14
|
ControllerType["MESSAGE"] = "message";
|
|
11
15
|
ControllerType["SLASH"] = "slash";
|
|
16
|
+
ControllerType["AUTOCOMPLETE"] = "autocomplete";
|
|
12
17
|
ControllerType["CONTEXT_MENU"] = "context-menu";
|
|
18
|
+
ControllerType["PRIMARY_ENTRY_POINT"] = "primary-entry-point";
|
|
13
19
|
return ControllerType;
|
|
14
20
|
}({});
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The kinds of interaction a `@Command` method can be bound to.
|
|
23
|
+
*
|
|
24
|
+
* Each member names one Discord interaction shape rather than a family of them, so a
|
|
25
|
+
* handler's parameter type follows from its command type alone. That is why the four
|
|
26
|
+
* entity select menus are separate members instead of one `SELECT_MENU`: Discord sends
|
|
27
|
+
* them as distinct component types (5-8) carrying different resolved data, and
|
|
28
|
+
* collapsing them would leave the handler with a union it has to re-narrow by hand.
|
|
29
|
+
*/ var CommandType = /*#__PURE__*/ function(CommandType) {
|
|
30
|
+
/** Chat input command, or one subcommand of it. */ CommandType["SLASH"] = "SLASH";
|
|
31
|
+
/** User or message context menu command. */ CommandType["CONTEXT_MENU"] = "CONTEXT_MENU";
|
|
32
|
+
/** Activity launch command (`ApplicationCommandType.PrimaryEntryPoint`). */ CommandType["PRIMARY_ENTRY_POINT"] = "PRIMARY_ENTRY_POINT";
|
|
17
33
|
CommandType["BUTTON"] = "BUTTON";
|
|
18
|
-
CommandType["
|
|
19
|
-
CommandType["
|
|
34
|
+
/** String select menu — the one whose options the application defines itself. */ CommandType["SELECT_MENU"] = "SELECT_MENU";
|
|
35
|
+
CommandType["USER_SELECT_MENU"] = "USER_SELECT_MENU";
|
|
36
|
+
CommandType["ROLE_SELECT_MENU"] = "ROLE_SELECT_MENU";
|
|
37
|
+
CommandType["MENTIONABLE_SELECT_MENU"] = "MENTIONABLE_SELECT_MENU";
|
|
38
|
+
CommandType["CHANNEL_SELECT_MENU"] = "CHANNEL_SELECT_MENU";
|
|
20
39
|
CommandType["MODAL_SUBMIT"] = "MODAL_SUBMIT";
|
|
21
40
|
return CommandType;
|
|
22
41
|
}({});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { createMockFn } from './mock-fn.js';
|
|
3
|
-
import { InteractionType, ComponentType, ApplicationCommandType, CommandInteractionOptionResolver, GuildMessageManager, ThreadManager, DMMessageManager, MessageManager, ThreadMemberManager, Client, ApplicationCommandManager, UserManager, ChannelManager, GuildManager, ClientUser, Guild, GuildMemberManager, GuildChannelManager, RoleManager, GuildBanManager, Message, User, GuildMember, TextChannel, ThreadChannel, MessageMentions } from 'discord.js';
|
|
3
|
+
import { InteractionType, ComponentType, ApplicationCommandType, CommandInteractionOptionResolver, GuildMessageManager, ThreadManager, DMMessageManager, MessageManager, ThreadMemberManager, Client, ApplicationCommandManager, UserManager, ChannelManager, GuildManager, ClientUser, Guild, GuildMemberManager, GuildChannelManager, RoleManager, GuildBanManager, Message, User, GuildMember, TextChannel, ThreadChannel, MessageMentions, ApplicationCommandOptionType, Role, BaseChannel, Attachment } from 'discord.js';
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// stubDeep — Proxy that auto-creates a mock fn on any property access
|
|
@@ -140,7 +140,9 @@ const TYPE_GUARD_METHODS = [
|
|
|
140
140
|
'isMentionableSelectMenu',
|
|
141
141
|
'isChannelSelectMenu',
|
|
142
142
|
'isAnySelectMenu',
|
|
143
|
-
|
|
143
|
+
// `isSelectMenu` is intentionally absent: discord.js deprecated it in favour of
|
|
144
|
+
// `isStringSelectMenu`, and wiring it here would emit a deprecation warning on every
|
|
145
|
+
// mock that has it on its prototype.
|
|
144
146
|
'isModalSubmit',
|
|
145
147
|
'isAutocomplete',
|
|
146
148
|
'isRepliable'
|
|
@@ -210,9 +212,11 @@ function findPrototypeMethod(instance, name) {
|
|
|
210
212
|
instance.ephemeral = false;
|
|
211
213
|
const alreadyReplied = ()=>new Error('The reply to this interaction has already been sent or deferred.');
|
|
212
214
|
const notYetReplied = (method)=>new Error(`Cannot call ${method}() before replying or deferring.`);
|
|
215
|
+
// Only `flags` is read: the `ephemeral: true` reply option is deprecated in
|
|
216
|
+
// discord.js, and honouring it here would let a test pass against a call the
|
|
217
|
+
// library has stopped supporting.
|
|
213
218
|
const hasEphemeralFlag = (options)=>{
|
|
214
219
|
if (!options) return false;
|
|
215
|
-
if (options.ephemeral === true) return true;
|
|
216
220
|
const { flags } = options;
|
|
217
221
|
if (typeof flags === 'number') return (flags & 64) !== 0;
|
|
218
222
|
if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
|
|
@@ -253,6 +257,17 @@ function findPrototypeMethod(instance, name) {
|
|
|
253
257
|
}));
|
|
254
258
|
}
|
|
255
259
|
}
|
|
260
|
+
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
261
|
+
// one `respond()` per interaction and rejects the second. Without `responded` set
|
|
262
|
+
// here it would read as an auto-stubbed object -- truthy -- and any code that checks
|
|
263
|
+
// it before answering would decide the window was already closed.
|
|
264
|
+
if (instance.type === InteractionType.ApplicationCommandAutocomplete) {
|
|
265
|
+
instance.responded = false;
|
|
266
|
+
stubs.set('respond', createMockFn(async ()=>{
|
|
267
|
+
if (instance.responded) throw new Error('The reply to this interaction has already been sent or deferred.');
|
|
268
|
+
instance.responded = true;
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
256
271
|
// Applied last so an explicit prop wins over the type fields and the reply
|
|
257
272
|
// state machine. defineProperty rather than assignment for the same reason the
|
|
258
273
|
// Proxy uses it: several of these shadow a getter-only prototype accessor.
|
|
@@ -532,7 +547,64 @@ function createMockMessage() {
|
|
|
532
547
|
* interaction.options.getSubcommand() // → 'notes'
|
|
533
548
|
* interaction.options.getNumber('uid') // → 12345678
|
|
534
549
|
* ```
|
|
535
|
-
*/
|
|
550
|
+
*/ /** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
551
|
+
if (typeof value === 'boolean') return ApplicationCommandOptionType.Boolean;
|
|
552
|
+
if (typeof value === 'number') return ApplicationCommandOptionType.Number;
|
|
553
|
+
if (value instanceof User) return ApplicationCommandOptionType.User;
|
|
554
|
+
if (value instanceof GuildMember) return ApplicationCommandOptionType.User;
|
|
555
|
+
if (value instanceof Role) return ApplicationCommandOptionType.Role;
|
|
556
|
+
if (value instanceof BaseChannel) return ApplicationCommandOptionType.Channel;
|
|
557
|
+
if (value instanceof Attachment) return ApplicationCommandOptionType.Attachment;
|
|
558
|
+
if (typeof value === 'object' && value !== null) return ApplicationCommandOptionType.Mentionable;
|
|
559
|
+
return ApplicationCommandOptionType.String;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Shapes one supplied option the way the gateway sends it.
|
|
563
|
+
*
|
|
564
|
+
* An entity option arrives as a snowflake in `value` *and* as the resolved object on
|
|
565
|
+
* its own field, and code that reads only one of the two is exactly what this lets a
|
|
566
|
+
* test catch — so both are set.
|
|
567
|
+
*/ function toOptionData(name, value) {
|
|
568
|
+
const type = optionTypeOf(value);
|
|
569
|
+
const isEntity = typeof value === 'object' && value !== null;
|
|
570
|
+
const option = {
|
|
571
|
+
name,
|
|
572
|
+
type,
|
|
573
|
+
value: isEntity ? value.id : value
|
|
574
|
+
};
|
|
575
|
+
if (value instanceof User) option.user = value;
|
|
576
|
+
else if (value instanceof GuildMember) option.member = value;
|
|
577
|
+
else if (value instanceof Role) option.role = value;
|
|
578
|
+
else if (value instanceof BaseChannel) option.channel = value;
|
|
579
|
+
else if (value instanceof Attachment) option.attachment = value;
|
|
580
|
+
else if (isEntity) option.user = value;
|
|
581
|
+
return option;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Nests the supplied options under the subcommand path they were invoked through,
|
|
585
|
+
* matching the shape Discord sends rather than a flat list.
|
|
586
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values) {
|
|
587
|
+
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value));
|
|
588
|
+
if (subcommand === null) return leaves;
|
|
589
|
+
const sub = {
|
|
590
|
+
name: subcommand,
|
|
591
|
+
type: ApplicationCommandOptionType.Subcommand,
|
|
592
|
+
options: leaves
|
|
593
|
+
};
|
|
594
|
+
if (subcommandGroup === null) return [
|
|
595
|
+
sub
|
|
596
|
+
];
|
|
597
|
+
return [
|
|
598
|
+
{
|
|
599
|
+
name: subcommandGroup,
|
|
600
|
+
type: ApplicationCommandOptionType.SubcommandGroup,
|
|
601
|
+
options: [
|
|
602
|
+
sub
|
|
603
|
+
]
|
|
604
|
+
}
|
|
605
|
+
];
|
|
606
|
+
}
|
|
607
|
+
// `any` is the default rather than `CacheType` because TypeScript types a generic
|
|
536
608
|
// class's `prototype` with `any` for its parameters, and createMockInteraction infers
|
|
537
609
|
// T from exactly that — `createMockInteraction(ChatInputCommandInteraction)` produces
|
|
538
610
|
// an interaction whose `options` is `CommandInteractionOptionResolver<any>`. Defaulting
|
|
@@ -540,7 +612,7 @@ function createMockMessage() {
|
|
|
540
612
|
// the target rejects, and the resolver stops being assignable to the property it exists
|
|
541
613
|
// to fill. Pass Cached explicitly when the interaction under test is pinned.
|
|
542
614
|
function createChatInputOptions(opts = {}) {
|
|
543
|
-
const { subcommandGroup = null, subcommand = null, ...values } = opts;
|
|
615
|
+
const { subcommandGroup = null, subcommand = null, focused = null, ...values } = opts;
|
|
544
616
|
function resolveOrThrow(name, value, required) {
|
|
545
617
|
if (value === null) {
|
|
546
618
|
if (required === true) throw new Error(`Option "${name}" is required but was not provided.`);
|
|
@@ -571,6 +643,18 @@ function createChatInputOptions(opts = {}) {
|
|
|
571
643
|
base.getChannel = createMockFn(getObjectOption);
|
|
572
644
|
base.getMember = createMockFn(getObjectOption);
|
|
573
645
|
base.getMentionable = createMockFn(getObjectOption);
|
|
646
|
+
base.getFocused = createMockFn((getFull)=>{
|
|
647
|
+
if (focused === null) throw new Error('No focused option found.');
|
|
648
|
+
const option = toOptionData(focused, values[focused] ?? null);
|
|
649
|
+
return getFull === true ? {
|
|
650
|
+
...option,
|
|
651
|
+
focused: true
|
|
652
|
+
} : option.value;
|
|
653
|
+
});
|
|
654
|
+
// `data` is what the framework reads to build a handler's params, and it is the one
|
|
655
|
+
// part of the resolver that is not a method — so it has to be materialised here
|
|
656
|
+
// rather than auto-stubbed, or every params assertion would see an empty record.
|
|
657
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values);
|
|
574
658
|
return stubDeep(base);
|
|
575
659
|
}
|
|
576
660
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { ApplicationCommandOptionType, MessageComponentInteraction, ModalSubmitInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, RoleSelectMenuInteraction, UserSelectMenuInteraction, StringSelectMenuInteraction, ButtonInteraction, PrimaryEntryPointCommandInteraction, ContextMenuCommandInteraction, ChatInputCommandInteraction } from 'discord.js';
|
|
2
|
+
import { CommandType } from '../enum/controller.enum.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The discord.js class each command type is handled by.
|
|
6
|
+
*
|
|
7
|
+
* One table rather than a chain of `isButton() || isStringSelectMenu() || ...`: the
|
|
8
|
+
* registration guard in `@Command`, the dispatcher, and the type-level
|
|
9
|
+
* `CommandInteractionType` all have to agree on what a command type accepts, and a
|
|
10
|
+
* chain repeated in three files drifts the moment a fifth select menu appears. Adding
|
|
11
|
+
* a `CommandType` member without an entry here is a compile error, not a silent
|
|
12
|
+
* fall-through to "Command not found!".
|
|
13
|
+
*/ const INTERACTION_MATCHERS = {
|
|
14
|
+
[CommandType.SLASH]: (interaction)=>interaction instanceof ChatInputCommandInteraction,
|
|
15
|
+
[CommandType.CONTEXT_MENU]: (interaction)=>interaction instanceof ContextMenuCommandInteraction,
|
|
16
|
+
[CommandType.PRIMARY_ENTRY_POINT]: (interaction)=>interaction instanceof PrimaryEntryPointCommandInteraction,
|
|
17
|
+
[CommandType.BUTTON]: (interaction)=>interaction instanceof ButtonInteraction,
|
|
18
|
+
[CommandType.SELECT_MENU]: (interaction)=>interaction instanceof StringSelectMenuInteraction,
|
|
19
|
+
[CommandType.USER_SELECT_MENU]: (interaction)=>interaction instanceof UserSelectMenuInteraction,
|
|
20
|
+
[CommandType.ROLE_SELECT_MENU]: (interaction)=>interaction instanceof RoleSelectMenuInteraction,
|
|
21
|
+
[CommandType.MENTIONABLE_SELECT_MENU]: (interaction)=>interaction instanceof MentionableSelectMenuInteraction,
|
|
22
|
+
[CommandType.CHANNEL_SELECT_MENU]: (interaction)=>interaction instanceof ChannelSelectMenuInteraction,
|
|
23
|
+
[CommandType.MODAL_SUBMIT]: (interaction)=>interaction instanceof ModalSubmitInteraction
|
|
24
|
+
};
|
|
25
|
+
/** Command types Discord identifies by a registered name rather than by a customId. */ const NAME_ROUTED_TYPES = new Set([
|
|
26
|
+
CommandType.SLASH,
|
|
27
|
+
CommandType.CONTEXT_MENU,
|
|
28
|
+
CommandType.PRIMARY_ENTRY_POINT
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Whether an interaction is the kind the given command type handles.
|
|
32
|
+
*
|
|
33
|
+
* @param type - The command type declared on `@Command`.
|
|
34
|
+
* @param interaction - The interaction being dispatched.
|
|
35
|
+
*/ function matchesCommandType(type, interaction) {
|
|
36
|
+
const matches = INTERACTION_MATCHERS[type];
|
|
37
|
+
return matches !== undefined && matches(interaction);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether the command type is routed by matching a customId pattern.
|
|
41
|
+
*
|
|
42
|
+
* Components carry an application-defined customId and so are matched by pattern;
|
|
43
|
+
* commands carry a name Discord itself registered and are matched exactly.
|
|
44
|
+
*
|
|
45
|
+
* @param type - The command type declared on `@Command`.
|
|
46
|
+
*/ function isCustomIdRouted(type) {
|
|
47
|
+
return !NAME_ROUTED_TYPES.has(type);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether an interaction carries a customId, and so can be routed by pattern.
|
|
51
|
+
*
|
|
52
|
+
* @param interaction - The interaction being dispatched.
|
|
53
|
+
*/ function hasCustomId(interaction) {
|
|
54
|
+
return interaction instanceof MessageComponentInteraction || interaction instanceof ModalSubmitInteraction;
|
|
55
|
+
}
|
|
56
|
+
/** Separates a command from its subcommand group and subcommand in a route key. */ const COMMAND_PATH_SEPARATOR = ' ';
|
|
57
|
+
/**
|
|
58
|
+
* The route keys a chat input interaction can be handled by, most specific first.
|
|
59
|
+
*
|
|
60
|
+
* Discord sends `/settings notify email` as one interaction named `settings`, so
|
|
61
|
+
* routing on `commandName` alone gives every subcommand of a command the same handler
|
|
62
|
+
* — and the framework would run whichever one was declared first. The full path is
|
|
63
|
+
* tried before the bare name so a command can either split its subcommands across
|
|
64
|
+
* methods or keep handling them in one, but never both by accident.
|
|
65
|
+
*
|
|
66
|
+
* A group is never dropped on the way down: `settings notify email` does not fall back
|
|
67
|
+
* to `settings email`, because a second group could declare its own `email` and the
|
|
68
|
+
* two would be indistinguishable.
|
|
69
|
+
*
|
|
70
|
+
* @param interaction - The chat input or autocomplete interaction being dispatched.
|
|
71
|
+
* @returns The keys to look up, most specific first.
|
|
72
|
+
*/ function resolveCommandPaths(interaction) {
|
|
73
|
+
const { commandName } = interaction;
|
|
74
|
+
const options = interaction.options;
|
|
75
|
+
// Guarded rather than called directly: `options` is a stub on a hand-built test
|
|
76
|
+
// double, and an interaction whose command has no subcommands still has to route.
|
|
77
|
+
const group = typeof options?.getSubcommandGroup === 'function' ? options.getSubcommandGroup(false) : null;
|
|
78
|
+
const sub = typeof options?.getSubcommand === 'function' ? options.getSubcommand(false) : null;
|
|
79
|
+
const path = [
|
|
80
|
+
commandName,
|
|
81
|
+
group,
|
|
82
|
+
sub
|
|
83
|
+
].filter((part)=>Boolean(part));
|
|
84
|
+
const full = path.join(COMMAND_PATH_SEPARATOR);
|
|
85
|
+
return full === commandName ? [
|
|
86
|
+
commandName
|
|
87
|
+
] : [
|
|
88
|
+
full,
|
|
89
|
+
commandName
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
/** Options that only wrap other options; their values live one level down. */ const NESTING_OPTION_TYPES = new Set([
|
|
93
|
+
ApplicationCommandOptionType.Subcommand,
|
|
94
|
+
ApplicationCommandOptionType.SubcommandGroup
|
|
95
|
+
]);
|
|
96
|
+
/**
|
|
97
|
+
* The value a handler should receive for one option.
|
|
98
|
+
*
|
|
99
|
+
* Discord sends entity options as a snowflake plus a `resolved` payload, and discord.js
|
|
100
|
+
* puts that payload on the option as `user`/`role`/`channel`/`attachment`. Passing
|
|
101
|
+
* `value` alone would hand the handler a bare id string for `@user`, forcing every
|
|
102
|
+
* handler to re-fetch what the gateway already delivered.
|
|
103
|
+
*/ function resolveOptionValue(option) {
|
|
104
|
+
return option.attachment ?? option.channel ?? option.role ?? option.user ?? option.member ?? option.value;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Flattens a chat input interaction's options into the params record handlers receive.
|
|
108
|
+
*
|
|
109
|
+
* Subcommand and subcommand-group options are containers, not values — for
|
|
110
|
+
* `/settings notify email true` the top level holds only `notify`. Recursing past them
|
|
111
|
+
* means a subcommand handler sees `{ email: true }`, the same shape a flat command's
|
|
112
|
+
* handler sees.
|
|
113
|
+
*
|
|
114
|
+
* @param interaction - The chat input or autocomplete interaction being dispatched.
|
|
115
|
+
* @returns Each supplied option keyed by name, with entity options resolved.
|
|
116
|
+
*/ function resolveOptionParams(interaction) {
|
|
117
|
+
const data = interaction.options?.data;
|
|
118
|
+
if (!Array.isArray(data)) return {};
|
|
119
|
+
const params = {};
|
|
120
|
+
const walk = (options)=>{
|
|
121
|
+
for (const option of options){
|
|
122
|
+
if (NESTING_OPTION_TYPES.has(option.type)) {
|
|
123
|
+
walk(option.options ?? []);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
params[option.name] = resolveOptionValue(option);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
walk(data);
|
|
130
|
+
return params;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The name of the option the user is currently typing, or `undefined`.
|
|
134
|
+
*
|
|
135
|
+
* `getFocused` throws when nothing is focused rather than returning null, and it is
|
|
136
|
+
* absent altogether on a hand-built test double. Neither is worth failing a dispatch
|
|
137
|
+
* over — an autocomplete with no focused option simply matches no option-specific
|
|
138
|
+
* handler.
|
|
139
|
+
*
|
|
140
|
+
* @param interaction - The autocomplete interaction being dispatched.
|
|
141
|
+
*/ function focusedOptionName(interaction) {
|
|
142
|
+
if (typeof interaction.options?.getFocused !== 'function') return undefined;
|
|
143
|
+
try {
|
|
144
|
+
return interaction.options.getFocused(true)?.name;
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Identifies an interaction in a log line, by whichever field would have routed it.
|
|
151
|
+
*
|
|
152
|
+
* @param interaction - The interaction that matched no handler.
|
|
153
|
+
*/ function describeInteraction(interaction) {
|
|
154
|
+
// Captured before the narrowing below: the guards cover every member of the union,
|
|
155
|
+
// so by the fallback `interaction` is `never` and nothing can be read off it.
|
|
156
|
+
const { type } = interaction;
|
|
157
|
+
if (interaction.isAutocomplete()) {
|
|
158
|
+
const path = resolveCommandPaths(interaction)[0];
|
|
159
|
+
const focused = focusedOptionName(interaction);
|
|
160
|
+
return focused === undefined ? `autocomplete for "${path}"` : `autocomplete for "${path}" option "${focused}"`;
|
|
161
|
+
}
|
|
162
|
+
if (interaction.isChatInputCommand()) {
|
|
163
|
+
return `command "${resolveCommandPaths(interaction)[0]}"`;
|
|
164
|
+
}
|
|
165
|
+
if (interaction.isContextMenuCommand() || interaction.isPrimaryEntryPointCommand()) {
|
|
166
|
+
return `command "${interaction.commandName}"`;
|
|
167
|
+
}
|
|
168
|
+
if (hasCustomId(interaction)) {
|
|
169
|
+
return `customId "${interaction.customId}"`;
|
|
170
|
+
}
|
|
171
|
+
return `interaction type ${type}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { COMMAND_PATH_SEPARATOR, describeInteraction, focusedOptionName, hasCustomId, isCustomIdRouted, matchesCommandType, resolveCommandPaths, resolveOptionParams };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds of interaction a `@Command` method can be bound to.
|
|
3
|
+
*
|
|
4
|
+
* Each member names one Discord interaction shape rather than a family of them, so a
|
|
5
|
+
* handler's parameter type follows from its command type alone. That is why the four
|
|
6
|
+
* entity select menus are separate members instead of one `SELECT_MENU`: Discord sends
|
|
7
|
+
* them as distinct component types (5-8) carrying different resolved data, and
|
|
8
|
+
* collapsing them would leave the handler with a union it has to re-narrow by hand.
|
|
9
|
+
*/
|
|
10
|
+
declare enum CommandType {
|
|
11
|
+
/** Chat input command, or one subcommand of it. */
|
|
12
|
+
SLASH = "SLASH",
|
|
13
|
+
/** User or message context menu command. */
|
|
14
|
+
CONTEXT_MENU = "CONTEXT_MENU",
|
|
15
|
+
/** Activity launch command (`ApplicationCommandType.PrimaryEntryPoint`). */
|
|
16
|
+
PRIMARY_ENTRY_POINT = "PRIMARY_ENTRY_POINT",
|
|
17
|
+
BUTTON = "BUTTON",
|
|
18
|
+
/** String select menu — the one whose options the application defines itself. */
|
|
19
|
+
SELECT_MENU = "SELECT_MENU",
|
|
20
|
+
USER_SELECT_MENU = "USER_SELECT_MENU",
|
|
21
|
+
ROLE_SELECT_MENU = "ROLE_SELECT_MENU",
|
|
22
|
+
MENTIONABLE_SELECT_MENU = "MENTIONABLE_SELECT_MENU",
|
|
23
|
+
CHANNEL_SELECT_MENU = "CHANNEL_SELECT_MENU",
|
|
24
|
+
MODAL_SUBMIT = "MODAL_SUBMIT"
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Enum representing actions that can be performed on a message reaction.
|
|
28
|
+
*/
|
|
29
|
+
declare enum ReactionHandlerAction {
|
|
30
|
+
/** Reaction added to a message. */
|
|
31
|
+
ADD = "ADD",
|
|
32
|
+
/** Reaction removed from a message. */
|
|
33
|
+
REMOVE = "REMOVE"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { CommandType as C, ReactionHandlerAction as R };
|
|
@@ -19,6 +19,31 @@ declare class MeoCordApp {
|
|
|
19
19
|
private activityInterval;
|
|
20
20
|
private controllerInstancesCache;
|
|
21
21
|
constructor(controllerClasses: (new (...args: any[]) => any)[], container: Container, discordClient: Client, discordToken: string, activities?: ActivityOptions[] | undefined);
|
|
22
|
+
/**
|
|
23
|
+
* Runs an event handler so a failure inside it cannot take the process down.
|
|
24
|
+
*
|
|
25
|
+
* discord.js calls listeners without awaiting them, so a rejection escaping one has
|
|
26
|
+
* nothing left to settle it: Node reports an unhandled rejection, which terminates
|
|
27
|
+
* the process by default. Losing the whole bot because one reaction landed on a
|
|
28
|
+
* deleted message, or one controller could not be resolved, is a worse failure than
|
|
29
|
+
* the one that caused it -- every other user is served by the same process.
|
|
30
|
+
*
|
|
31
|
+
* Nothing is silenced. The error is logged against the event that produced it, so a
|
|
32
|
+
* genuine misconfiguration -- an unbound controller, a missing dependency -- shows
|
|
33
|
+
* up on the very first interaction rather than staying hidden.
|
|
34
|
+
*
|
|
35
|
+
* @param event - The gateway event being handled, named in the log.
|
|
36
|
+
* @param run - The handler to run.
|
|
37
|
+
*/
|
|
38
|
+
private runListener;
|
|
39
|
+
/**
|
|
40
|
+
* Rotates the bot's activity.
|
|
41
|
+
*
|
|
42
|
+
* Guarded separately from {@link runListener}: this runs on a timer rather than an
|
|
43
|
+
* event, and a throw from a timer callback is an uncaught exception no listener
|
|
44
|
+
* wrapper can reach.
|
|
45
|
+
*/
|
|
46
|
+
private updateActivity;
|
|
22
47
|
private getInstance;
|
|
23
48
|
start(): Promise<void>;
|
|
24
49
|
registerCommands(): Promise<void>;
|
|
@@ -38,12 +63,60 @@ declare class MeoCordApp {
|
|
|
38
63
|
* refusing to start would turn a latent mis-route into an outage on upgrade.
|
|
39
64
|
*/
|
|
40
65
|
private reportAmbiguousRoutes;
|
|
66
|
+
/**
|
|
67
|
+
* Every `@Autocomplete` handler, ordered so an option-specific handler is found
|
|
68
|
+
* before a command-wide one.
|
|
69
|
+
*
|
|
70
|
+
* Cached alongside the component table: autocomplete fires on every keystroke, and
|
|
71
|
+
* rebuilding the list per keystroke would put reflection on the hottest path the
|
|
72
|
+
* framework has.
|
|
73
|
+
*/
|
|
74
|
+
private autocompleteRoutes?;
|
|
75
|
+
private getAutocompleteRoutes;
|
|
76
|
+
/**
|
|
77
|
+
* Dispatches an interaction, and makes sure a failure anywhere in that still reaches
|
|
78
|
+
* the person who triggered it.
|
|
79
|
+
*
|
|
80
|
+
* {@link executeCommand} already reports what a handler throws, but everything
|
|
81
|
+
* *before* the handler can fail too — resolving a controller through the container
|
|
82
|
+
* is the common case — and a component that fails there would otherwise look dead
|
|
83
|
+
* with nothing said to the user and nothing in the log.
|
|
84
|
+
*/
|
|
41
85
|
private handleInteraction;
|
|
86
|
+
private dispatchInteraction;
|
|
87
|
+
/**
|
|
88
|
+
* The names a command interaction can be handled under, most specific first.
|
|
89
|
+
*
|
|
90
|
+
* Empty for anything that is not a registered command, which is how a component
|
|
91
|
+
* whose customId matched no pattern falls through to the unmatched warning instead
|
|
92
|
+
* of being looked up under a name it does not have.
|
|
93
|
+
*/
|
|
94
|
+
private resolveNameRoutes;
|
|
95
|
+
/**
|
|
96
|
+
* Answers an autocomplete interaction from the `@Autocomplete` handler that claims it.
|
|
97
|
+
*
|
|
98
|
+
* Discord closes the window after three seconds and shows a loading state until
|
|
99
|
+
* something arrives, so an unclaimed option is answered with an empty list rather
|
|
100
|
+
* than left to time out -- a visibly empty menu is a better failure than a stuck one,
|
|
101
|
+
* and the warning says which option is missing a handler.
|
|
102
|
+
*/
|
|
103
|
+
private handleAutocomplete;
|
|
104
|
+
/** Closes an autocomplete window that nothing else answered. */
|
|
105
|
+
private respondEmpty;
|
|
42
106
|
/**
|
|
43
107
|
* Runs a resolved command, shared by both dispatch paths so a pattern-matched
|
|
44
108
|
* component and a named slash command behave identically once the route is chosen.
|
|
45
109
|
*/
|
|
46
110
|
private executeCommand;
|
|
111
|
+
/**
|
|
112
|
+
* Tells the user something went wrong, if the interaction can still hear it.
|
|
113
|
+
*
|
|
114
|
+
* A handler that replies and *then* throws is the common shape of a failure, and
|
|
115
|
+
* replying twice throws in turn -- out of the catch block, where nothing is left to
|
|
116
|
+
* handle it. Whatever the interaction's state, reporting an error must not be able
|
|
117
|
+
* to become a second, worse one.
|
|
118
|
+
*/
|
|
119
|
+
private replyWithError;
|
|
47
120
|
private handleMessage;
|
|
48
121
|
private handleReaction;
|
|
49
122
|
private gracefulShutdown;
|