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
|
@@ -1,22 +1,66 @@
|
|
|
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, findAmbiguousRoutes, PARAM_SEPARATOR, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
|
|
4
|
+
import { getCommandMap, findAmbiguousRoutes, PARAM_SEPARATOR, getAutocompleteHandlers, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
|
|
5
5
|
import { sample } from 'lodash-es';
|
|
6
6
|
import { createErrorEmbed } from '../util/embed.util.js';
|
|
7
|
+
import { describeInteraction, hasCustomId, matchesCommandType, resolveCommandPaths, focusedOptionName, resolveOptionParams } from '../util/interaction.util.js';
|
|
7
8
|
import { ReactionHandlerAction, CommandType } from '../enum/controller.enum.js';
|
|
8
9
|
import CliTable3 from 'cli-table3';
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The name a builder registers under, which is what Discord deduplicates on.
|
|
13
|
+
*
|
|
14
|
+
* Read from the built payload rather than from the `@Command` argument: a builder is
|
|
15
|
+
* free to name the command something other than the string it was handed, and it is
|
|
16
|
+
* the payload Discord sees.
|
|
17
|
+
*/ function commandNameOf(builder) {
|
|
18
|
+
try {
|
|
19
|
+
const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
|
|
20
|
+
return typeof json?.name === 'string' ? json.name : undefined;
|
|
21
|
+
} catch {
|
|
22
|
+
// A builder missing a required field throws from toJSON. Surfacing that is the
|
|
23
|
+
// registration call's job, where it is reported against the command Discord
|
|
24
|
+
// rejected -- deduplication should not be what turns it into a startup crash.
|
|
25
|
+
return undefined;
|
|
13
26
|
}
|
|
14
|
-
if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
|
15
|
-
return `customId "${interaction.customId}"`;
|
|
16
|
-
}
|
|
17
|
-
return `interaction type ${interaction.type}`;
|
|
18
27
|
}
|
|
19
28
|
class MeoCordApp {
|
|
29
|
+
/**
|
|
30
|
+
* Runs an event handler so a failure inside it cannot take the process down.
|
|
31
|
+
*
|
|
32
|
+
* discord.js calls listeners without awaiting them, so a rejection escaping one has
|
|
33
|
+
* nothing left to settle it: Node reports an unhandled rejection, which terminates
|
|
34
|
+
* the process by default. Losing the whole bot because one reaction landed on a
|
|
35
|
+
* deleted message, or one controller could not be resolved, is a worse failure than
|
|
36
|
+
* the one that caused it -- every other user is served by the same process.
|
|
37
|
+
*
|
|
38
|
+
* Nothing is silenced. The error is logged against the event that produced it, so a
|
|
39
|
+
* genuine misconfiguration -- an unbound controller, a missing dependency -- shows
|
|
40
|
+
* up on the very first interaction rather than staying hidden.
|
|
41
|
+
*
|
|
42
|
+
* @param event - The gateway event being handled, named in the log.
|
|
43
|
+
* @param run - The handler to run.
|
|
44
|
+
*/ async runListener(event, run) {
|
|
45
|
+
try {
|
|
46
|
+
await run();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
this.logger.error(`Unhandled error while handling "${event}":`, error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Rotates the bot's activity.
|
|
53
|
+
*
|
|
54
|
+
* Guarded separately from {@link runListener}: this runs on a timer rather than an
|
|
55
|
+
* event, and a throw from a timer callback is an uncaught exception no listener
|
|
56
|
+
* wrapper can reach.
|
|
57
|
+
*/ updateActivity() {
|
|
58
|
+
try {
|
|
59
|
+
this.bot.user?.setActivity(sample(this.activities));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.logger.error('Could not update the bot activity:', error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
20
64
|
getInstance(controllerClass) {
|
|
21
65
|
if (!this.controllerInstancesCache.has(controllerClass)) {
|
|
22
66
|
this.controllerInstancesCache.set(controllerClass, this.container.get(controllerClass));
|
|
@@ -26,30 +70,20 @@ class MeoCordApp {
|
|
|
26
70
|
async start() {
|
|
27
71
|
try {
|
|
28
72
|
this.logger.log('Starting bot...');
|
|
29
|
-
this.bot.on('clientReady', async ()=>{
|
|
30
|
-
|
|
31
|
-
this.
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.bot.on('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
user,
|
|
44
|
-
action: ReactionHandlerAction.ADD
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
this.bot.on('messageReactionRemove', async (reaction, user)=>{
|
|
48
|
-
await this.handleReaction(reaction, {
|
|
49
|
-
user,
|
|
50
|
-
action: ReactionHandlerAction.REMOVE
|
|
51
|
-
});
|
|
52
|
-
});
|
|
73
|
+
this.bot.on('clientReady', ()=>this.runListener('clientReady', async ()=>{
|
|
74
|
+
this.activityInterval = setInterval(()=>this.updateActivity(), 10000);
|
|
75
|
+
await this.registerCommands();
|
|
76
|
+
}));
|
|
77
|
+
this.bot.on('interactionCreate', (interaction)=>this.runListener('interactionCreate', ()=>this.handleInteraction(interaction)));
|
|
78
|
+
this.bot.on('messageCreate', (message)=>this.runListener('messageCreate', ()=>this.handleMessage(message)));
|
|
79
|
+
this.bot.on('messageReactionAdd', (reaction, user)=>this.runListener('messageReactionAdd', ()=>this.handleReaction(reaction, {
|
|
80
|
+
user,
|
|
81
|
+
action: ReactionHandlerAction.ADD
|
|
82
|
+
})));
|
|
83
|
+
this.bot.on('messageReactionRemove', (reaction, user)=>this.runListener('messageReactionRemove', ()=>this.handleReaction(reaction, {
|
|
84
|
+
user,
|
|
85
|
+
action: ReactionHandlerAction.REMOVE
|
|
86
|
+
})));
|
|
53
87
|
await this.bot.login(this.discordToken);
|
|
54
88
|
this.logger.log('Bot is online!');
|
|
55
89
|
} catch (error) {
|
|
@@ -57,7 +91,11 @@ class MeoCordApp {
|
|
|
57
91
|
}
|
|
58
92
|
}
|
|
59
93
|
async registerCommands() {
|
|
60
|
-
|
|
94
|
+
// Keyed by registered name: a command whose subcommands live in separate methods
|
|
95
|
+
// declares the same name more than once, and sending its builder twice makes
|
|
96
|
+
// Discord reject the whole payload. The first builder wins, and a second one that
|
|
97
|
+
// is not the same object is reported rather than silently dropped.
|
|
98
|
+
const buildersByName = new Map();
|
|
61
99
|
for (const controllerClass of this.controllerClasses){
|
|
62
100
|
const instance = this.getInstance(controllerClass);
|
|
63
101
|
const commandMap = getCommandMap(instance);
|
|
@@ -65,12 +103,20 @@ class MeoCordApp {
|
|
|
65
103
|
const commandMetadataArray = commandMap[commandName];
|
|
66
104
|
if (!Array.isArray(commandMetadataArray)) continue;
|
|
67
105
|
for (const { builder, type } of commandMetadataArray){
|
|
68
|
-
if (type in CommandType
|
|
69
|
-
|
|
106
|
+
if (!(type in CommandType) || !builder) continue;
|
|
107
|
+
const registeredName = commandNameOf(builder) ?? commandName;
|
|
108
|
+
const existing = buildersByName.get(registeredName);
|
|
109
|
+
if (existing === undefined) {
|
|
110
|
+
buildersByName.set(registeredName, builder);
|
|
111
|
+
} else if (existing !== builder) {
|
|
112
|
+
this.logger.warn(`Command "${registeredName}" is built more than once; only the first builder is registered. ` + `Declare the builder on one @Command and give the others the plain CommandType.`);
|
|
70
113
|
}
|
|
71
114
|
}
|
|
72
115
|
}
|
|
73
116
|
}
|
|
117
|
+
const builders = [
|
|
118
|
+
...buildersByName.values()
|
|
119
|
+
];
|
|
74
120
|
try {
|
|
75
121
|
if (this.bot.application) {
|
|
76
122
|
await this.bot.application.commands.set(builders);
|
|
@@ -89,7 +135,7 @@ class MeoCordApp {
|
|
|
89
135
|
});
|
|
90
136
|
for (const builder of builders){
|
|
91
137
|
const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
|
|
92
|
-
const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : builder instanceof SlashCommandBuilder ? 'SlashCommand' : 'Command';
|
|
138
|
+
const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : json?.type === 4 ? 'PrimaryEntryPoint' : builder instanceof SlashCommandBuilder ? 'SlashCommand' : 'Command';
|
|
93
139
|
const name = json?.name || builder.name;
|
|
94
140
|
const subCommands = Array.isArray(json?.options) && json.options.length ? json.options.map((opt)=>opt.name).join(', ') : '';
|
|
95
141
|
table.push([
|
|
@@ -130,16 +176,74 @@ class MeoCordApp {
|
|
|
130
176
|
* Warns rather than throws: an app whose patterns overlap boots and works today, and
|
|
131
177
|
* refusing to start would turn a latent mis-route into an outage on upgrade.
|
|
132
178
|
*/ reportAmbiguousRoutes(routes) {
|
|
133
|
-
|
|
179
|
+
// Grouped by command type first: dispatch only considers routes whose component
|
|
180
|
+
// type matches the interaction, so a button and a select menu sharing a pattern
|
|
181
|
+
// are never in competition and reporting them would be a false alarm.
|
|
182
|
+
const byType = new Map();
|
|
183
|
+
for (const { meta, pattern } of routes){
|
|
184
|
+
const patterns = byType.get(meta.type) ?? [];
|
|
185
|
+
patterns.push(pattern);
|
|
186
|
+
byType.set(meta.type, patterns);
|
|
187
|
+
}
|
|
188
|
+
const collisions = [
|
|
189
|
+
...byType.values()
|
|
190
|
+
].flatMap((patterns)=>findAmbiguousRoutes(patterns));
|
|
134
191
|
if (collisions.length === 0) return;
|
|
135
192
|
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
193
|
}
|
|
137
|
-
|
|
194
|
+
getAutocompleteRoutes() {
|
|
195
|
+
if (this.autocompleteRoutes) return this.autocompleteRoutes;
|
|
196
|
+
const routes = [];
|
|
197
|
+
for (const controllerClass of this.controllerClasses){
|
|
198
|
+
for (const meta of getAutocompleteHandlers(this.getInstance(controllerClass))){
|
|
199
|
+
routes.push({
|
|
200
|
+
controllerClass,
|
|
201
|
+
meta
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
routes.sort((a, b)=>Number(Boolean(b.meta.optionName)) - Number(Boolean(a.meta.optionName)));
|
|
206
|
+
this.autocompleteRoutes = routes;
|
|
207
|
+
return routes;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Dispatches an interaction, and makes sure a failure anywhere in that still reaches
|
|
211
|
+
* the person who triggered it.
|
|
212
|
+
*
|
|
213
|
+
* {@link executeCommand} already reports what a handler throws, but everything
|
|
214
|
+
* *before* the handler can fail too — resolving a controller through the container
|
|
215
|
+
* is the common case — and a component that fails there would otherwise look dead
|
|
216
|
+
* with nothing said to the user and nothing in the log.
|
|
217
|
+
*/ async handleInteraction(interaction) {
|
|
218
|
+
try {
|
|
219
|
+
await this.dispatchInteraction(interaction);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
this.logger.error(`Error dispatching ${describeInteraction(interaction)}:`, error);
|
|
222
|
+
// Autocomplete has no reply to fall back on; closing its window is the only
|
|
223
|
+
// thing that stops the client showing a loading state until it times out.
|
|
224
|
+
if (interaction.isAutocomplete()) {
|
|
225
|
+
await this.respondEmpty(interaction);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await this.replyWithError(interaction, 'An error occurred while executing the command.');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async dispatchInteraction(interaction) {
|
|
232
|
+
// Autocomplete first, and on its own path: it is answered with `respond()` rather
|
|
233
|
+
// than a reply, it has no customId to route on, and the "Command not found!" reply
|
|
234
|
+
// the other paths end in cannot be sent to it at all.
|
|
235
|
+
if (interaction.isAutocomplete()) {
|
|
236
|
+
await this.handleAutocomplete(interaction);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
138
239
|
// Component interactions route on a pattern, so they go through the ranked table.
|
|
139
|
-
//
|
|
140
|
-
if (
|
|
240
|
+
// Commands match their registered name exactly and cannot overlap.
|
|
241
|
+
if (hasCustomId(interaction)) {
|
|
141
242
|
const customId = interaction.customId;
|
|
142
243
|
for (const { controllerClass, meta } of this.getComponentRoutes()){
|
|
244
|
+
// A button and a select menu may legitimately share a customId shape, so the
|
|
245
|
+
// pattern alone does not identify the handler -- the component type does.
|
|
246
|
+
if (!matchesCommandType(meta.type, interaction)) continue;
|
|
143
247
|
const match = meta.regex.exec(customId);
|
|
144
248
|
if (!match) continue;
|
|
145
249
|
interaction.dynamicParams = match.groups ?? {};
|
|
@@ -147,19 +251,15 @@ class MeoCordApp {
|
|
|
147
251
|
return;
|
|
148
252
|
}
|
|
149
253
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
if (commandMetadataArray && commandMetadataArray.length > 0) {
|
|
161
|
-
const commandMetadata = commandMetadataArray[0];
|
|
162
|
-
await this.executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier);
|
|
254
|
+
// Paths are walked outside the controller loop so the full subcommand path always
|
|
255
|
+
// beats the bare command name, whatever order the controllers were registered in.
|
|
256
|
+
for (const path of this.resolveNameRoutes(interaction)){
|
|
257
|
+
for (const controllerClass of this.controllerClasses){
|
|
258
|
+
const controllerInstance = this.getInstance(controllerClass);
|
|
259
|
+
const commandMap = getCommandMap(controllerInstance);
|
|
260
|
+
const commandMetadata = commandMap?.[path]?.find((meta)=>matchesCommandType(meta.type, interaction));
|
|
261
|
+
if (!commandMetadata) continue;
|
|
262
|
+
await this.executeCommand(controllerInstance, commandMetadata, interaction, path);
|
|
163
263
|
return;
|
|
164
264
|
}
|
|
165
265
|
}
|
|
@@ -167,15 +267,58 @@ class MeoCordApp {
|
|
|
167
267
|
// about which id was unroutable, so a control that is emitted but never routed --
|
|
168
268
|
// a customId whose value broke its pattern, or a handler nobody wrote -- stays
|
|
169
269
|
// invisible until somebody reports the dead button.
|
|
170
|
-
this.logger.warn(`No handler matched ${
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
270
|
+
this.logger.warn(`No handler matched ${describeInteraction(interaction)}. Check that a @Command pattern is ` + `declared for it and that its controller is registered.`);
|
|
271
|
+
await this.replyWithError(interaction, 'Command not found!');
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The names a command interaction can be handled under, most specific first.
|
|
275
|
+
*
|
|
276
|
+
* Empty for anything that is not a registered command, which is how a component
|
|
277
|
+
* whose customId matched no pattern falls through to the unmatched warning instead
|
|
278
|
+
* of being looked up under a name it does not have.
|
|
279
|
+
*/ resolveNameRoutes(interaction) {
|
|
280
|
+
if (interaction.isChatInputCommand()) return resolveCommandPaths(interaction);
|
|
281
|
+
if (interaction.isContextMenuCommand() || interaction.isPrimaryEntryPointCommand()) {
|
|
282
|
+
return [
|
|
283
|
+
interaction.commandName
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Answers an autocomplete interaction from the `@Autocomplete` handler that claims it.
|
|
290
|
+
*
|
|
291
|
+
* Discord closes the window after three seconds and shows a loading state until
|
|
292
|
+
* something arrives, so an unclaimed option is answered with an empty list rather
|
|
293
|
+
* than left to time out -- a visibly empty menu is a better failure than a stuck one,
|
|
294
|
+
* and the warning says which option is missing a handler.
|
|
295
|
+
*/ async handleAutocomplete(interaction) {
|
|
296
|
+
const focusedName = focusedOptionName(interaction);
|
|
297
|
+
for (const path of resolveCommandPaths(interaction)){
|
|
298
|
+
for (const { controllerClass, meta } of this.getAutocompleteRoutes()){
|
|
299
|
+
if (meta.commandPath !== path) continue;
|
|
300
|
+
if (meta.optionName !== undefined && meta.optionName !== focusedName) continue;
|
|
301
|
+
try {
|
|
302
|
+
const controllerInstance = this.getInstance(controllerClass);
|
|
303
|
+
this.logger.log('[AUTOCOMPLETE]', `[${path}]`, `[${meta.methodName}]`);
|
|
304
|
+
await controllerInstance[meta.methodName](interaction, resolveOptionParams(interaction));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
this.logger.error(`Error handling ${describeInteraction(interaction)}:`, error);
|
|
307
|
+
await this.respondEmpty(interaction);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
this.logger.warn(`No handler matched ${describeInteraction(interaction)}. Declare an @Autocomplete handler for it, ` + `or drop setAutocomplete(true) from the option.`);
|
|
313
|
+
await this.respondEmpty(interaction);
|
|
314
|
+
}
|
|
315
|
+
/** Closes an autocomplete window that nothing else answered. */ async respondEmpty(interaction) {
|
|
316
|
+
if (interaction.responded) return;
|
|
317
|
+
try {
|
|
318
|
+
await interaction.respond([]);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// The three-second window may already have closed, which is not actionable.
|
|
321
|
+
this.logger.debug(`Could not close autocomplete window: ${String(error)}`);
|
|
179
322
|
}
|
|
180
323
|
}
|
|
181
324
|
/**
|
|
@@ -183,33 +326,43 @@ class MeoCordApp {
|
|
|
183
326
|
* component and a named slash command behave identically once the route is chosen.
|
|
184
327
|
*/ async executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier) {
|
|
185
328
|
const { methodName, type } = commandMetadata;
|
|
329
|
+
// No interaction-type check here: both callers pick the route with
|
|
330
|
+
// `matchesCommandType` before getting this far, and `@Command` re-checks the
|
|
331
|
+
// interaction on the way into the handler.
|
|
186
332
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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;
|
|
333
|
+
this.logger.log('[INTERACTION]', `[${type}]`, `[${methodName}]`);
|
|
334
|
+
let dynamicParams = {};
|
|
335
|
+
if (interaction.isChatInputCommand()) {
|
|
336
|
+
dynamicParams = resolveOptionParams(interaction);
|
|
337
|
+
} else if (hasCustomId(interaction)) {
|
|
338
|
+
dynamicParams = interaction.dynamicParams ?? {};
|
|
200
339
|
}
|
|
201
|
-
|
|
340
|
+
await controllerInstance[methodName](interaction, dynamicParams);
|
|
202
341
|
} catch (error) {
|
|
203
342
|
this.logger.error(`Error executing command "${commandIdentifier}":`, error);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
343
|
+
await this.replyWithError(interaction, 'An error occurred while executing the command.');
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Tells the user something went wrong, if the interaction can still hear it.
|
|
348
|
+
*
|
|
349
|
+
* A handler that replies and *then* throws is the common shape of a failure, and
|
|
350
|
+
* replying twice throws in turn -- out of the catch block, where nothing is left to
|
|
351
|
+
* handle it. Whatever the interaction's state, reporting an error must not be able
|
|
352
|
+
* to become a second, worse one.
|
|
353
|
+
*/ async replyWithError(interaction, message) {
|
|
354
|
+
if (!interaction.isRepliable() || interaction.replied || interaction.deferred) return;
|
|
355
|
+
try {
|
|
356
|
+
const embed = createErrorEmbed(message);
|
|
357
|
+
await interaction.reply({
|
|
358
|
+
embeds: [
|
|
359
|
+
embed
|
|
360
|
+
],
|
|
361
|
+
flags: MessageFlagsBitField.Flags.Ephemeral
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// Unknown or already-acknowledged interaction; the user cannot be told anything.
|
|
365
|
+
this.logger.debug(`Could not deliver the error reply: ${String(error)}`);
|
|
213
366
|
}
|
|
214
367
|
}
|
|
215
368
|
async handleMessage(message) {
|
|
@@ -241,7 +394,15 @@ class MeoCordApp {
|
|
|
241
394
|
}
|
|
242
395
|
}
|
|
243
396
|
async handleReaction(reaction, { user, action }) {
|
|
244
|
-
|
|
397
|
+
// A reaction arrives for messages the bot may no longer be able to read -- deleted,
|
|
398
|
+
// or in a channel it lost access to -- and `fetch` rejects for all of them. That is
|
|
399
|
+
// an ordinary outcome rather than a fault, so the reaction is skipped quietly.
|
|
400
|
+
try {
|
|
401
|
+
await reaction.message.fetch();
|
|
402
|
+
} catch (error) {
|
|
403
|
+
this.logger.debug(`Skipping a reaction whose message could not be fetched: ${String(error)}`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
245
406
|
const relevantControllers = this.controllerClasses.filter((controllerClass)=>{
|
|
246
407
|
const instance = this.getInstance(controllerClass);
|
|
247
408
|
const reactionHandlers = getReactionHandlers(instance);
|
|
@@ -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 };
|
|
@@ -99,7 +99,7 @@ export class ButtonInteractionGuard implements GuardInterface {
|
|
|
99
99
|
* @example
|
|
100
100
|
* ```typescript
|
|
101
101
|
* // Method-level usage
|
|
102
|
-
* @Command('profile
|
|
102
|
+
* @Command('profile/{id}', CommandType.BUTTON)
|
|
103
103
|
* @UseGuard(
|
|
104
104
|
* { provide: RateLimiterGuard, params: { limit: 2, window: 3000 } },
|
|
105
105
|
* ButtonInteractionGuard
|
|
@@ -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
|
}({});
|