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
package/dist/cjs/core/index.cjs
CHANGED
|
@@ -4,7 +4,7 @@ require('reflect-metadata');
|
|
|
4
4
|
var inversify = require('inversify');
|
|
5
5
|
var discord_js = require('discord.js');
|
|
6
6
|
var theme = require('../_shared/theme-Bz-D4RbT.cjs');
|
|
7
|
-
var controller_decorator = require('../_shared/controller.decorator-
|
|
7
|
+
var controller_decorator = require('../_shared/controller.decorator-MUHA_A3z.cjs');
|
|
8
8
|
var lodashEs = require('lodash-es');
|
|
9
9
|
var enum_index = require('../enum/index.cjs');
|
|
10
10
|
var Table = require('cli-table3');
|
|
@@ -26,16 +26,59 @@ const createErrorEmbed = (description)=>{
|
|
|
26
26
|
return embed;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The name a builder registers under, which is what Discord deduplicates on.
|
|
31
|
+
*
|
|
32
|
+
* Read from the built payload rather than from the `@Command` argument: a builder is
|
|
33
|
+
* free to name the command something other than the string it was handed, and it is
|
|
34
|
+
* the payload Discord sees.
|
|
35
|
+
*/ function commandNameOf(builder) {
|
|
36
|
+
try {
|
|
37
|
+
const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
|
|
38
|
+
return typeof json?.name === 'string' ? json.name : undefined;
|
|
39
|
+
} catch {
|
|
40
|
+
// A builder missing a required field throws from toJSON. Surfacing that is the
|
|
41
|
+
// registration call's job, where it is reported against the command Discord
|
|
42
|
+
// rejected -- deduplication should not be what turns it into a startup crash.
|
|
43
|
+
return undefined;
|
|
35
44
|
}
|
|
36
|
-
return `interaction type ${interaction.type}`;
|
|
37
45
|
}
|
|
38
46
|
class MeoCordApp {
|
|
47
|
+
/**
|
|
48
|
+
* Runs an event handler so a failure inside it cannot take the process down.
|
|
49
|
+
*
|
|
50
|
+
* discord.js calls listeners without awaiting them, so a rejection escaping one has
|
|
51
|
+
* nothing left to settle it: Node reports an unhandled rejection, which terminates
|
|
52
|
+
* the process by default. Losing the whole bot because one reaction landed on a
|
|
53
|
+
* deleted message, or one controller could not be resolved, is a worse failure than
|
|
54
|
+
* the one that caused it -- every other user is served by the same process.
|
|
55
|
+
*
|
|
56
|
+
* Nothing is silenced. The error is logged against the event that produced it, so a
|
|
57
|
+
* genuine misconfiguration -- an unbound controller, a missing dependency -- shows
|
|
58
|
+
* up on the very first interaction rather than staying hidden.
|
|
59
|
+
*
|
|
60
|
+
* @param event - The gateway event being handled, named in the log.
|
|
61
|
+
* @param run - The handler to run.
|
|
62
|
+
*/ async runListener(event, run) {
|
|
63
|
+
try {
|
|
64
|
+
await run();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.logger.error(`Unhandled error while handling "${event}":`, error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Rotates the bot's activity.
|
|
71
|
+
*
|
|
72
|
+
* Guarded separately from {@link runListener}: this runs on a timer rather than an
|
|
73
|
+
* event, and a throw from a timer callback is an uncaught exception no listener
|
|
74
|
+
* wrapper can reach.
|
|
75
|
+
*/ updateActivity() {
|
|
76
|
+
try {
|
|
77
|
+
this.bot.user?.setActivity(lodashEs.sample(this.activities));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
this.logger.error('Could not update the bot activity:', error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
39
82
|
getInstance(controllerClass) {
|
|
40
83
|
if (!this.controllerInstancesCache.has(controllerClass)) {
|
|
41
84
|
this.controllerInstancesCache.set(controllerClass, this.container.get(controllerClass));
|
|
@@ -45,30 +88,20 @@ class MeoCordApp {
|
|
|
45
88
|
async start() {
|
|
46
89
|
try {
|
|
47
90
|
this.logger.log('Starting bot...');
|
|
48
|
-
this.bot.on('clientReady', async ()=>{
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.bot.on('
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
user,
|
|
63
|
-
action: enum_index.ReactionHandlerAction.ADD
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
this.bot.on('messageReactionRemove', async (reaction, user)=>{
|
|
67
|
-
await this.handleReaction(reaction, {
|
|
68
|
-
user,
|
|
69
|
-
action: enum_index.ReactionHandlerAction.REMOVE
|
|
70
|
-
});
|
|
71
|
-
});
|
|
91
|
+
this.bot.on('clientReady', ()=>this.runListener('clientReady', async ()=>{
|
|
92
|
+
this.activityInterval = setInterval(()=>this.updateActivity(), 10000);
|
|
93
|
+
await this.registerCommands();
|
|
94
|
+
}));
|
|
95
|
+
this.bot.on('interactionCreate', (interaction)=>this.runListener('interactionCreate', ()=>this.handleInteraction(interaction)));
|
|
96
|
+
this.bot.on('messageCreate', (message)=>this.runListener('messageCreate', ()=>this.handleMessage(message)));
|
|
97
|
+
this.bot.on('messageReactionAdd', (reaction, user)=>this.runListener('messageReactionAdd', ()=>this.handleReaction(reaction, {
|
|
98
|
+
user,
|
|
99
|
+
action: enum_index.ReactionHandlerAction.ADD
|
|
100
|
+
})));
|
|
101
|
+
this.bot.on('messageReactionRemove', (reaction, user)=>this.runListener('messageReactionRemove', ()=>this.handleReaction(reaction, {
|
|
102
|
+
user,
|
|
103
|
+
action: enum_index.ReactionHandlerAction.REMOVE
|
|
104
|
+
})));
|
|
72
105
|
await this.bot.login(this.discordToken);
|
|
73
106
|
this.logger.log('Bot is online!');
|
|
74
107
|
} catch (error) {
|
|
@@ -76,7 +109,11 @@ class MeoCordApp {
|
|
|
76
109
|
}
|
|
77
110
|
}
|
|
78
111
|
async registerCommands() {
|
|
79
|
-
|
|
112
|
+
// Keyed by registered name: a command whose subcommands live in separate methods
|
|
113
|
+
// declares the same name more than once, and sending its builder twice makes
|
|
114
|
+
// Discord reject the whole payload. The first builder wins, and a second one that
|
|
115
|
+
// is not the same object is reported rather than silently dropped.
|
|
116
|
+
const buildersByName = new Map();
|
|
80
117
|
for (const controllerClass of this.controllerClasses){
|
|
81
118
|
const instance = this.getInstance(controllerClass);
|
|
82
119
|
const commandMap = controller_decorator.getCommandMap(instance);
|
|
@@ -84,12 +121,20 @@ class MeoCordApp {
|
|
|
84
121
|
const commandMetadataArray = commandMap[commandName];
|
|
85
122
|
if (!Array.isArray(commandMetadataArray)) continue;
|
|
86
123
|
for (const { builder, type } of commandMetadataArray){
|
|
87
|
-
if (type in enum_index.CommandType
|
|
88
|
-
|
|
124
|
+
if (!(type in enum_index.CommandType) || !builder) continue;
|
|
125
|
+
const registeredName = commandNameOf(builder) ?? commandName;
|
|
126
|
+
const existing = buildersByName.get(registeredName);
|
|
127
|
+
if (existing === undefined) {
|
|
128
|
+
buildersByName.set(registeredName, builder);
|
|
129
|
+
} else if (existing !== builder) {
|
|
130
|
+
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.`);
|
|
89
131
|
}
|
|
90
132
|
}
|
|
91
133
|
}
|
|
92
134
|
}
|
|
135
|
+
const builders = [
|
|
136
|
+
...buildersByName.values()
|
|
137
|
+
];
|
|
93
138
|
try {
|
|
94
139
|
if (this.bot.application) {
|
|
95
140
|
await this.bot.application.commands.set(builders);
|
|
@@ -108,7 +153,7 @@ class MeoCordApp {
|
|
|
108
153
|
});
|
|
109
154
|
for (const builder of builders){
|
|
110
155
|
const json = typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
|
|
111
|
-
const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : builder instanceof discord_js.SlashCommandBuilder ? 'SlashCommand' : 'Command';
|
|
156
|
+
const typeName = json?.type === 1 ? 'SlashCommand' : json?.type === 2 ? 'UserContextMenu' : json?.type === 3 ? 'MessageContextMenu' : json?.type === 4 ? 'PrimaryEntryPoint' : builder instanceof discord_js.SlashCommandBuilder ? 'SlashCommand' : 'Command';
|
|
112
157
|
const name = json?.name || builder.name;
|
|
113
158
|
const subCommands = Array.isArray(json?.options) && json.options.length ? json.options.map((opt)=>opt.name).join(', ') : '';
|
|
114
159
|
table.push([
|
|
@@ -149,16 +194,74 @@ class MeoCordApp {
|
|
|
149
194
|
* Warns rather than throws: an app whose patterns overlap boots and works today, and
|
|
150
195
|
* refusing to start would turn a latent mis-route into an outage on upgrade.
|
|
151
196
|
*/ reportAmbiguousRoutes(routes) {
|
|
152
|
-
|
|
197
|
+
// Grouped by command type first: dispatch only considers routes whose component
|
|
198
|
+
// type matches the interaction, so a button and a select menu sharing a pattern
|
|
199
|
+
// are never in competition and reporting them would be a false alarm.
|
|
200
|
+
const byType = new Map();
|
|
201
|
+
for (const { meta, pattern } of routes){
|
|
202
|
+
const patterns = byType.get(meta.type) ?? [];
|
|
203
|
+
patterns.push(pattern);
|
|
204
|
+
byType.set(meta.type, patterns);
|
|
205
|
+
}
|
|
206
|
+
const collisions = [
|
|
207
|
+
...byType.values()
|
|
208
|
+
].flatMap((patterns)=>controller_decorator.findAmbiguousRoutes(patterns));
|
|
153
209
|
if (collisions.length === 0) return;
|
|
154
210
|
this.logger.warn(`${collisions.length} pattern pair(s) can match the same customId, so which one runs is decided by ` + `ranking rather than by the ids themselves:\n` + collisions.map(([left, right])=>` "${left}" vs "${right}"`).join('\n') + `\nA parameter stops at "${controller_decorator.PARAM_SEPARATOR}", so separating these segments with it makes them distinct.`);
|
|
155
211
|
}
|
|
156
|
-
|
|
212
|
+
getAutocompleteRoutes() {
|
|
213
|
+
if (this.autocompleteRoutes) return this.autocompleteRoutes;
|
|
214
|
+
const routes = [];
|
|
215
|
+
for (const controllerClass of this.controllerClasses){
|
|
216
|
+
for (const meta of controller_decorator.getAutocompleteHandlers(this.getInstance(controllerClass))){
|
|
217
|
+
routes.push({
|
|
218
|
+
controllerClass,
|
|
219
|
+
meta
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
routes.sort((a, b)=>Number(Boolean(b.meta.optionName)) - Number(Boolean(a.meta.optionName)));
|
|
224
|
+
this.autocompleteRoutes = routes;
|
|
225
|
+
return routes;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Dispatches an interaction, and makes sure a failure anywhere in that still reaches
|
|
229
|
+
* the person who triggered it.
|
|
230
|
+
*
|
|
231
|
+
* {@link executeCommand} already reports what a handler throws, but everything
|
|
232
|
+
* *before* the handler can fail too — resolving a controller through the container
|
|
233
|
+
* is the common case — and a component that fails there would otherwise look dead
|
|
234
|
+
* with nothing said to the user and nothing in the log.
|
|
235
|
+
*/ async handleInteraction(interaction) {
|
|
236
|
+
try {
|
|
237
|
+
await this.dispatchInteraction(interaction);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
this.logger.error(`Error dispatching ${controller_decorator.describeInteraction(interaction)}:`, error);
|
|
240
|
+
// Autocomplete has no reply to fall back on; closing its window is the only
|
|
241
|
+
// thing that stops the client showing a loading state until it times out.
|
|
242
|
+
if (interaction.isAutocomplete()) {
|
|
243
|
+
await this.respondEmpty(interaction);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await this.replyWithError(interaction, 'An error occurred while executing the command.');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async dispatchInteraction(interaction) {
|
|
250
|
+
// Autocomplete first, and on its own path: it is answered with `respond()` rather
|
|
251
|
+
// than a reply, it has no customId to route on, and the "Command not found!" reply
|
|
252
|
+
// the other paths end in cannot be sent to it at all.
|
|
253
|
+
if (interaction.isAutocomplete()) {
|
|
254
|
+
await this.handleAutocomplete(interaction);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
157
257
|
// Component interactions route on a pattern, so they go through the ranked table.
|
|
158
|
-
//
|
|
159
|
-
if (
|
|
258
|
+
// Commands match their registered name exactly and cannot overlap.
|
|
259
|
+
if (controller_decorator.hasCustomId(interaction)) {
|
|
160
260
|
const customId = interaction.customId;
|
|
161
261
|
for (const { controllerClass, meta } of this.getComponentRoutes()){
|
|
262
|
+
// A button and a select menu may legitimately share a customId shape, so the
|
|
263
|
+
// pattern alone does not identify the handler -- the component type does.
|
|
264
|
+
if (!controller_decorator.matchesCommandType(meta.type, interaction)) continue;
|
|
162
265
|
const match = meta.regex.exec(customId);
|
|
163
266
|
if (!match) continue;
|
|
164
267
|
interaction.dynamicParams = match.groups ?? {};
|
|
@@ -166,19 +269,15 @@ class MeoCordApp {
|
|
|
166
269
|
return;
|
|
167
270
|
}
|
|
168
271
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
if (commandMetadataArray && commandMetadataArray.length > 0) {
|
|
180
|
-
const commandMetadata = commandMetadataArray[0];
|
|
181
|
-
await this.executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier);
|
|
272
|
+
// Paths are walked outside the controller loop so the full subcommand path always
|
|
273
|
+
// beats the bare command name, whatever order the controllers were registered in.
|
|
274
|
+
for (const path of this.resolveNameRoutes(interaction)){
|
|
275
|
+
for (const controllerClass of this.controllerClasses){
|
|
276
|
+
const controllerInstance = this.getInstance(controllerClass);
|
|
277
|
+
const commandMap = controller_decorator.getCommandMap(controllerInstance);
|
|
278
|
+
const commandMetadata = commandMap?.[path]?.find((meta)=>controller_decorator.matchesCommandType(meta.type, interaction));
|
|
279
|
+
if (!commandMetadata) continue;
|
|
280
|
+
await this.executeCommand(controllerInstance, commandMetadata, interaction, path);
|
|
182
281
|
return;
|
|
183
282
|
}
|
|
184
283
|
}
|
|
@@ -186,15 +285,58 @@ class MeoCordApp {
|
|
|
186
285
|
// about which id was unroutable, so a control that is emitted but never routed --
|
|
187
286
|
// a customId whose value broke its pattern, or a handler nobody wrote -- stays
|
|
188
287
|
// invisible until somebody reports the dead button.
|
|
189
|
-
this.logger.warn(`No handler matched ${
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
288
|
+
this.logger.warn(`No handler matched ${controller_decorator.describeInteraction(interaction)}. Check that a @Command pattern is ` + `declared for it and that its controller is registered.`);
|
|
289
|
+
await this.replyWithError(interaction, 'Command not found!');
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The names a command interaction can be handled under, most specific first.
|
|
293
|
+
*
|
|
294
|
+
* Empty for anything that is not a registered command, which is how a component
|
|
295
|
+
* whose customId matched no pattern falls through to the unmatched warning instead
|
|
296
|
+
* of being looked up under a name it does not have.
|
|
297
|
+
*/ resolveNameRoutes(interaction) {
|
|
298
|
+
if (interaction.isChatInputCommand()) return controller_decorator.resolveCommandPaths(interaction);
|
|
299
|
+
if (interaction.isContextMenuCommand() || interaction.isPrimaryEntryPointCommand()) {
|
|
300
|
+
return [
|
|
301
|
+
interaction.commandName
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Answers an autocomplete interaction from the `@Autocomplete` handler that claims it.
|
|
308
|
+
*
|
|
309
|
+
* Discord closes the window after three seconds and shows a loading state until
|
|
310
|
+
* something arrives, so an unclaimed option is answered with an empty list rather
|
|
311
|
+
* than left to time out -- a visibly empty menu is a better failure than a stuck one,
|
|
312
|
+
* and the warning says which option is missing a handler.
|
|
313
|
+
*/ async handleAutocomplete(interaction) {
|
|
314
|
+
const focusedName = controller_decorator.focusedOptionName(interaction);
|
|
315
|
+
for (const path of controller_decorator.resolveCommandPaths(interaction)){
|
|
316
|
+
for (const { controllerClass, meta } of this.getAutocompleteRoutes()){
|
|
317
|
+
if (meta.commandPath !== path) continue;
|
|
318
|
+
if (meta.optionName !== undefined && meta.optionName !== focusedName) continue;
|
|
319
|
+
try {
|
|
320
|
+
const controllerInstance = this.getInstance(controllerClass);
|
|
321
|
+
this.logger.log('[AUTOCOMPLETE]', `[${path}]`, `[${meta.methodName}]`);
|
|
322
|
+
await controllerInstance[meta.methodName](interaction, controller_decorator.resolveOptionParams(interaction));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
this.logger.error(`Error handling ${controller_decorator.describeInteraction(interaction)}:`, error);
|
|
325
|
+
await this.respondEmpty(interaction);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
this.logger.warn(`No handler matched ${controller_decorator.describeInteraction(interaction)}. Declare an @Autocomplete handler for it, ` + `or drop setAutocomplete(true) from the option.`);
|
|
331
|
+
await this.respondEmpty(interaction);
|
|
332
|
+
}
|
|
333
|
+
/** Closes an autocomplete window that nothing else answered. */ async respondEmpty(interaction) {
|
|
334
|
+
if (interaction.responded) return;
|
|
335
|
+
try {
|
|
336
|
+
await interaction.respond([]);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// The three-second window may already have closed, which is not actionable.
|
|
339
|
+
this.logger.debug(`Could not close autocomplete window: ${String(error)}`);
|
|
198
340
|
}
|
|
199
341
|
}
|
|
200
342
|
/**
|
|
@@ -202,33 +344,43 @@ class MeoCordApp {
|
|
|
202
344
|
* component and a named slash command behave identically once the route is chosen.
|
|
203
345
|
*/ async executeCommand(controllerInstance, commandMetadata, interaction, commandIdentifier) {
|
|
204
346
|
const { methodName, type } = commandMetadata;
|
|
347
|
+
// No interaction-type check here: both callers pick the route with
|
|
348
|
+
// `matchesCommandType` before getting this far, and `@Command` re-checks the
|
|
349
|
+
// interaction on the way into the handler.
|
|
205
350
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return acc;
|
|
213
|
-
}, {});
|
|
214
|
-
} else if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
|
|
215
|
-
dynamicParams = interaction.dynamicParams ?? {};
|
|
216
|
-
}
|
|
217
|
-
await controllerInstance[methodName](interaction, dynamicParams);
|
|
218
|
-
return;
|
|
351
|
+
this.logger.log('[INTERACTION]', `[${type}]`, `[${methodName}]`);
|
|
352
|
+
let dynamicParams = {};
|
|
353
|
+
if (interaction.isChatInputCommand()) {
|
|
354
|
+
dynamicParams = controller_decorator.resolveOptionParams(interaction);
|
|
355
|
+
} else if (controller_decorator.hasCustomId(interaction)) {
|
|
356
|
+
dynamicParams = interaction.dynamicParams ?? {};
|
|
219
357
|
}
|
|
220
|
-
|
|
358
|
+
await controllerInstance[methodName](interaction, dynamicParams);
|
|
221
359
|
} catch (error) {
|
|
222
360
|
this.logger.error(`Error executing command "${commandIdentifier}":`, error);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
361
|
+
await this.replyWithError(interaction, 'An error occurred while executing the command.');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Tells the user something went wrong, if the interaction can still hear it.
|
|
366
|
+
*
|
|
367
|
+
* A handler that replies and *then* throws is the common shape of a failure, and
|
|
368
|
+
* replying twice throws in turn -- out of the catch block, where nothing is left to
|
|
369
|
+
* handle it. Whatever the interaction's state, reporting an error must not be able
|
|
370
|
+
* to become a second, worse one.
|
|
371
|
+
*/ async replyWithError(interaction, message) {
|
|
372
|
+
if (!interaction.isRepliable() || interaction.replied || interaction.deferred) return;
|
|
373
|
+
try {
|
|
374
|
+
const embed = createErrorEmbed(message);
|
|
375
|
+
await interaction.reply({
|
|
376
|
+
embeds: [
|
|
377
|
+
embed
|
|
378
|
+
],
|
|
379
|
+
flags: discord_js.MessageFlagsBitField.Flags.Ephemeral
|
|
380
|
+
});
|
|
381
|
+
} catch (error) {
|
|
382
|
+
// Unknown or already-acknowledged interaction; the user cannot be told anything.
|
|
383
|
+
this.logger.debug(`Could not deliver the error reply: ${String(error)}`);
|
|
232
384
|
}
|
|
233
385
|
}
|
|
234
386
|
async handleMessage(message) {
|
|
@@ -260,7 +412,15 @@ class MeoCordApp {
|
|
|
260
412
|
}
|
|
261
413
|
}
|
|
262
414
|
async handleReaction(reaction, { user, action }) {
|
|
263
|
-
|
|
415
|
+
// A reaction arrives for messages the bot may no longer be able to read -- deleted,
|
|
416
|
+
// or in a channel it lost access to -- and `fetch` rejects for all of them. That is
|
|
417
|
+
// an ordinary outcome rather than a fault, so the reaction is skipped quietly.
|
|
418
|
+
try {
|
|
419
|
+
await reaction.message.fetch();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
this.logger.debug(`Skipping a reaction whose message could not be fetched: ${String(error)}`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
264
424
|
const relevantControllers = this.controllerClasses.filter((controllerClass)=>{
|
|
265
425
|
const instance = this.getInstance(controllerClass);
|
|
266
426
|
const reactionHandlers = controller_decorator.getReactionHandlers(instance);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require('reflect-metadata');
|
|
4
4
|
var inversify = require('inversify');
|
|
5
5
|
var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs');
|
|
6
|
-
var controller_decorator = require('../_shared/controller.decorator-
|
|
6
|
+
var controller_decorator = require('../_shared/controller.decorator-MUHA_A3z.cjs');
|
|
7
7
|
var discord_js = require('discord.js');
|
|
8
8
|
require('../enum/index.cjs');
|
|
9
9
|
|
|
@@ -154,7 +154,7 @@ export class ButtonInteractionGuard implements GuardInterface {
|
|
|
154
154
|
* @example
|
|
155
155
|
* ```typescript
|
|
156
156
|
* // Method-level usage
|
|
157
|
-
* @Command('profile
|
|
157
|
+
* @Command('profile/{id}', CommandType.BUTTON)
|
|
158
158
|
* @UseGuard(
|
|
159
159
|
* { provide: RateLimiterGuard, params: { limit: 2, window: 3000 } },
|
|
160
160
|
* ButtonInteractionGuard
|
|
@@ -246,12 +246,14 @@ export class ButtonInteractionGuard implements GuardInterface {
|
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
exports.Autocomplete = controller_decorator.Autocomplete;
|
|
249
250
|
exports.Command = controller_decorator.Command;
|
|
250
251
|
exports.Controller = controller_decorator.Controller;
|
|
251
252
|
exports.MessageHandler = controller_decorator.MessageHandler;
|
|
252
253
|
exports.PARAM_SEPARATOR = controller_decorator.PARAM_SEPARATOR;
|
|
253
254
|
exports.ReactionHandler = controller_decorator.ReactionHandler;
|
|
254
255
|
exports.findAmbiguousRoutes = controller_decorator.findAmbiguousRoutes;
|
|
256
|
+
exports.getAutocompleteHandlers = controller_decorator.getAutocompleteHandlers;
|
|
255
257
|
exports.getCommandMap = controller_decorator.getCommandMap;
|
|
256
258
|
exports.getMessageHandlers = controller_decorator.getMessageHandlers;
|
|
257
259
|
exports.getReactionHandlers = controller_decorator.getReactionHandlers;
|
package/dist/cjs/enum/index.cjs
CHANGED
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
var metadataKey_enum = require('../_shared/metadata-key.enum-BzzvGUId.cjs');
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The kinds of interaction a `@Command` method can be bound to.
|
|
7
|
+
*
|
|
8
|
+
* Each member names one Discord interaction shape rather than a family of them, so a
|
|
9
|
+
* handler's parameter type follows from its command type alone. That is why the four
|
|
10
|
+
* entity select menus are separate members instead of one `SELECT_MENU`: Discord sends
|
|
11
|
+
* them as distinct component types (5-8) carrying different resolved data, and
|
|
12
|
+
* collapsing them would leave the handler with a union it has to re-narrow by hand.
|
|
13
|
+
*/ var CommandType = /*#__PURE__*/ function(CommandType) {
|
|
14
|
+
/** Chat input command, or one subcommand of it. */ CommandType["SLASH"] = "SLASH";
|
|
15
|
+
/** User or message context menu command. */ CommandType["CONTEXT_MENU"] = "CONTEXT_MENU";
|
|
16
|
+
/** Activity launch command (`ApplicationCommandType.PrimaryEntryPoint`). */ CommandType["PRIMARY_ENTRY_POINT"] = "PRIMARY_ENTRY_POINT";
|
|
7
17
|
CommandType["BUTTON"] = "BUTTON";
|
|
8
|
-
CommandType["
|
|
9
|
-
CommandType["
|
|
18
|
+
/** String select menu — the one whose options the application defines itself. */ CommandType["SELECT_MENU"] = "SELECT_MENU";
|
|
19
|
+
CommandType["USER_SELECT_MENU"] = "USER_SELECT_MENU";
|
|
20
|
+
CommandType["ROLE_SELECT_MENU"] = "ROLE_SELECT_MENU";
|
|
21
|
+
CommandType["MENTIONABLE_SELECT_MENU"] = "MENTIONABLE_SELECT_MENU";
|
|
22
|
+
CommandType["CHANNEL_SELECT_MENU"] = "CHANNEL_SELECT_MENU";
|
|
10
23
|
CommandType["MODAL_SUBMIT"] = "MODAL_SUBMIT";
|
|
11
24
|
return CommandType;
|
|
12
25
|
}({});
|
|
@@ -410,7 +410,9 @@ const TYPE_GUARD_METHODS = [
|
|
|
410
410
|
'isMentionableSelectMenu',
|
|
411
411
|
'isChannelSelectMenu',
|
|
412
412
|
'isAnySelectMenu',
|
|
413
|
-
|
|
413
|
+
// `isSelectMenu` is intentionally absent: discord.js deprecated it in favour of
|
|
414
|
+
// `isStringSelectMenu`, and wiring it here would emit a deprecation warning on every
|
|
415
|
+
// mock that has it on its prototype.
|
|
414
416
|
'isModalSubmit',
|
|
415
417
|
'isAutocomplete',
|
|
416
418
|
'isRepliable'
|
|
@@ -480,9 +482,11 @@ function findPrototypeMethod(instance, name) {
|
|
|
480
482
|
instance.ephemeral = false;
|
|
481
483
|
const alreadyReplied = ()=>new Error('The reply to this interaction has already been sent or deferred.');
|
|
482
484
|
const notYetReplied = (method)=>new Error(`Cannot call ${method}() before replying or deferring.`);
|
|
485
|
+
// Only `flags` is read: the `ephemeral: true` reply option is deprecated in
|
|
486
|
+
// discord.js, and honouring it here would let a test pass against a call the
|
|
487
|
+
// library has stopped supporting.
|
|
483
488
|
const hasEphemeralFlag = (options)=>{
|
|
484
489
|
if (!options) return false;
|
|
485
|
-
if (options.ephemeral === true) return true;
|
|
486
490
|
const { flags } = options;
|
|
487
491
|
if (typeof flags === 'number') return (flags & 64) !== 0;
|
|
488
492
|
if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
|
|
@@ -523,6 +527,17 @@ function findPrototypeMethod(instance, name) {
|
|
|
523
527
|
}));
|
|
524
528
|
}
|
|
525
529
|
}
|
|
530
|
+
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
531
|
+
// one `respond()` per interaction and rejects the second. Without `responded` set
|
|
532
|
+
// here it would read as an auto-stubbed object -- truthy -- and any code that checks
|
|
533
|
+
// it before answering would decide the window was already closed.
|
|
534
|
+
if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
|
|
535
|
+
instance.responded = false;
|
|
536
|
+
stubs.set('respond', createMockFn(async ()=>{
|
|
537
|
+
if (instance.responded) throw new Error('The reply to this interaction has already been sent or deferred.');
|
|
538
|
+
instance.responded = true;
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
526
541
|
// Applied last so an explicit prop wins over the type fields and the reply
|
|
527
542
|
// state machine. defineProperty rather than assignment for the same reason the
|
|
528
543
|
// Proxy uses it: several of these shadow a getter-only prototype accessor.
|
|
@@ -802,7 +817,64 @@ function createMockMessage() {
|
|
|
802
817
|
* interaction.options.getSubcommand() // → 'notes'
|
|
803
818
|
* interaction.options.getNumber('uid') // → 12345678
|
|
804
819
|
* ```
|
|
805
|
-
*/
|
|
820
|
+
*/ /** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
821
|
+
if (typeof value === 'boolean') return discord_js.ApplicationCommandOptionType.Boolean;
|
|
822
|
+
if (typeof value === 'number') return discord_js.ApplicationCommandOptionType.Number;
|
|
823
|
+
if (value instanceof discord_js.User) return discord_js.ApplicationCommandOptionType.User;
|
|
824
|
+
if (value instanceof discord_js.GuildMember) return discord_js.ApplicationCommandOptionType.User;
|
|
825
|
+
if (value instanceof discord_js.Role) return discord_js.ApplicationCommandOptionType.Role;
|
|
826
|
+
if (value instanceof discord_js.BaseChannel) return discord_js.ApplicationCommandOptionType.Channel;
|
|
827
|
+
if (value instanceof discord_js.Attachment) return discord_js.ApplicationCommandOptionType.Attachment;
|
|
828
|
+
if (typeof value === 'object' && value !== null) return discord_js.ApplicationCommandOptionType.Mentionable;
|
|
829
|
+
return discord_js.ApplicationCommandOptionType.String;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Shapes one supplied option the way the gateway sends it.
|
|
833
|
+
*
|
|
834
|
+
* An entity option arrives as a snowflake in `value` *and* as the resolved object on
|
|
835
|
+
* its own field, and code that reads only one of the two is exactly what this lets a
|
|
836
|
+
* test catch — so both are set.
|
|
837
|
+
*/ function toOptionData(name, value) {
|
|
838
|
+
const type = optionTypeOf(value);
|
|
839
|
+
const isEntity = typeof value === 'object' && value !== null;
|
|
840
|
+
const option = {
|
|
841
|
+
name,
|
|
842
|
+
type,
|
|
843
|
+
value: isEntity ? value.id : value
|
|
844
|
+
};
|
|
845
|
+
if (value instanceof discord_js.User) option.user = value;
|
|
846
|
+
else if (value instanceof discord_js.GuildMember) option.member = value;
|
|
847
|
+
else if (value instanceof discord_js.Role) option.role = value;
|
|
848
|
+
else if (value instanceof discord_js.BaseChannel) option.channel = value;
|
|
849
|
+
else if (value instanceof discord_js.Attachment) option.attachment = value;
|
|
850
|
+
else if (isEntity) option.user = value;
|
|
851
|
+
return option;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Nests the supplied options under the subcommand path they were invoked through,
|
|
855
|
+
* matching the shape Discord sends rather than a flat list.
|
|
856
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values) {
|
|
857
|
+
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value));
|
|
858
|
+
if (subcommand === null) return leaves;
|
|
859
|
+
const sub = {
|
|
860
|
+
name: subcommand,
|
|
861
|
+
type: discord_js.ApplicationCommandOptionType.Subcommand,
|
|
862
|
+
options: leaves
|
|
863
|
+
};
|
|
864
|
+
if (subcommandGroup === null) return [
|
|
865
|
+
sub
|
|
866
|
+
];
|
|
867
|
+
return [
|
|
868
|
+
{
|
|
869
|
+
name: subcommandGroup,
|
|
870
|
+
type: discord_js.ApplicationCommandOptionType.SubcommandGroup,
|
|
871
|
+
options: [
|
|
872
|
+
sub
|
|
873
|
+
]
|
|
874
|
+
}
|
|
875
|
+
];
|
|
876
|
+
}
|
|
877
|
+
// `any` is the default rather than `CacheType` because TypeScript types a generic
|
|
806
878
|
// class's `prototype` with `any` for its parameters, and createMockInteraction infers
|
|
807
879
|
// T from exactly that — `createMockInteraction(ChatInputCommandInteraction)` produces
|
|
808
880
|
// an interaction whose `options` is `CommandInteractionOptionResolver<any>`. Defaulting
|
|
@@ -810,7 +882,7 @@ function createMockMessage() {
|
|
|
810
882
|
// the target rejects, and the resolver stops being assignable to the property it exists
|
|
811
883
|
// to fill. Pass Cached explicitly when the interaction under test is pinned.
|
|
812
884
|
function createChatInputOptions(opts = {}) {
|
|
813
|
-
const { subcommandGroup = null, subcommand = null, ...values } = opts;
|
|
885
|
+
const { subcommandGroup = null, subcommand = null, focused = null, ...values } = opts;
|
|
814
886
|
function resolveOrThrow(name, value, required) {
|
|
815
887
|
if (value === null) {
|
|
816
888
|
if (required === true) throw new Error(`Option "${name}" is required but was not provided.`);
|
|
@@ -841,6 +913,18 @@ function createChatInputOptions(opts = {}) {
|
|
|
841
913
|
base.getChannel = createMockFn(getObjectOption);
|
|
842
914
|
base.getMember = createMockFn(getObjectOption);
|
|
843
915
|
base.getMentionable = createMockFn(getObjectOption);
|
|
916
|
+
base.getFocused = createMockFn((getFull)=>{
|
|
917
|
+
if (focused === null) throw new Error('No focused option found.');
|
|
918
|
+
const option = toOptionData(focused, values[focused] ?? null);
|
|
919
|
+
return getFull === true ? {
|
|
920
|
+
...option,
|
|
921
|
+
focused: true
|
|
922
|
+
} : option.value;
|
|
923
|
+
});
|
|
924
|
+
// `data` is what the framework reads to build a handler's params, and it is the one
|
|
925
|
+
// part of the resolver that is not a method — so it has to be materialised here
|
|
926
|
+
// rather than auto-stubbed, or every params assertion would see an empty record.
|
|
927
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values);
|
|
844
928
|
return stubDeep(base);
|
|
845
929
|
}
|
|
846
930
|
|