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,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
|
|
|
@@ -47,8 +47,16 @@ import chalk from 'chalk';
|
|
|
47
47
|
await wait(100);
|
|
48
48
|
process.exit(1);
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
loadMeoCordConfig();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Ensures a Discord token is configured.
|
|
54
|
+
*
|
|
55
|
+
* Kept apart from {@link compileAndValidateConfig} because producing a bundle needs no
|
|
56
|
+
* credentials — only connecting to the gateway does. Requiring one to build meant a
|
|
57
|
+
* freshly created application could not be built until a token had been obtained.
|
|
58
|
+
*/ async function validateDiscordToken() {
|
|
59
|
+
if (!loadMeoCordConfig()?.discordToken) {
|
|
52
60
|
console.error(chalk.red('Discord token is missing!'));
|
|
53
61
|
await wait(100);
|
|
54
62
|
process.exit(1);
|
|
@@ -65,4 +73,4 @@ import chalk from 'chalk';
|
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
export { compileAndValidateConfig, findModulePackageDir, setEnvironment };
|
|
76
|
+
export { compileAndValidateConfig, findModulePackageDir, setEnvironment, validateDiscordToken };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { execFile } from 'child_process';
|
|
4
4
|
import { Logger } from '../common/logger.js';
|
|
5
5
|
import '../common/theme.js';
|
|
6
6
|
import { kebabCase, startCase, camelCase } from 'lodash-es';
|
|
@@ -67,12 +67,29 @@ const logger = new Logger('MeoCord');
|
|
|
67
67
|
*/ function generateFile(filePath, content) {
|
|
68
68
|
try {
|
|
69
69
|
fs.writeFileSync(filePath, content);
|
|
70
|
-
logger.log(`
|
|
71
|
-
|
|
70
|
+
logger.log(`Created ${path.relative(process.cwd(), filePath)}`);
|
|
71
|
+
formatWithLocalESLint(filePath);
|
|
72
72
|
} catch (error) {
|
|
73
|
-
logger.error(`Failed to create
|
|
73
|
+
logger.error(`Failed to create ${filePath}`, error);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Formats a generated file with the project's own ESLint, when it has one.
|
|
78
|
+
*
|
|
79
|
+
* Reaching for `npx` instead would start downloading ESLint into a project that
|
|
80
|
+
* deliberately does not have it, once per generated file, with no way to see it happen —
|
|
81
|
+
* the call is not awaited. A project with its own rules still gets them applied.
|
|
82
|
+
*/ function formatWithLocalESLint(filePath) {
|
|
83
|
+
const binary = path.resolve(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'eslint.cmd' : 'eslint');
|
|
84
|
+
if (!fs.existsSync(binary)) return;
|
|
85
|
+
execFile(binary, [
|
|
86
|
+
'--fix',
|
|
87
|
+
filePath
|
|
88
|
+
], ()=>{
|
|
89
|
+
// Formatting is a courtesy; a project whose rules reject the template should still
|
|
90
|
+
// end up with the file it asked for.
|
|
91
|
+
});
|
|
92
|
+
}
|
|
76
93
|
/**
|
|
77
94
|
* Builds and returns a template string for a given class name using a specific template file.
|
|
78
95
|
* @param className - The name of the class to insert into the template.
|
|
@@ -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 };
|
|
@@ -6,10 +6,17 @@ const ALL_PACKAGE_MANAGERS = [
|
|
|
6
6
|
'yarn',
|
|
7
7
|
'pnpm'
|
|
8
8
|
];
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The package managers available on this machine.
|
|
11
|
+
*
|
|
12
|
+
* Each candidate is asked for its version rather than looked up with `which`: `which` is
|
|
13
|
+
* a separate binary that a minimal image need not carry, and it is absent on Windows
|
|
14
|
+
* entirely. Running the tool also answers the question actually being asked — whether it
|
|
15
|
+
* works — rather than whether something with that name sits on the path.
|
|
16
|
+
*/ function detectInstalledPMs() {
|
|
10
17
|
return ALL_PACKAGE_MANAGERS.filter((pm)=>{
|
|
11
18
|
try {
|
|
12
|
-
execSync(
|
|
19
|
+
execSync(`${pm} --version`, {
|
|
13
20
|
stdio: 'ignore'
|
|
14
21
|
});
|
|
15
22
|
return true;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs__default from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The package this CLI ships in, which is where the walk below stops. */ const OWN_PACKAGE_NAME = 'meocord';
|
|
5
|
+
/**
|
|
6
|
+
* The version recorded in the package this CLI ships in.
|
|
7
|
+
*
|
|
8
|
+
* Read at run time rather than compiled in. The release bumps the manifest after the
|
|
9
|
+
* bundle has already been built, so a version baked into the bundle is always the one
|
|
10
|
+
* from before the release that published it.
|
|
11
|
+
*
|
|
12
|
+
* The walk stops at the first manifest naming this package: the repository root when
|
|
13
|
+
* running from source, the installed package root when running from `dist`.
|
|
14
|
+
*
|
|
15
|
+
* @param startDir - Directory to walk up from.
|
|
16
|
+
* @param fallback - Returned when no manifest for this package can be read.
|
|
17
|
+
*/ function resolveOwnVersion(startDir, fallback) {
|
|
18
|
+
let current = path.resolve(startDir);
|
|
19
|
+
while(true){
|
|
20
|
+
try {
|
|
21
|
+
const manifest = JSON.parse(fs__default.readFileSync(path.join(current, 'package.json'), 'utf8'));
|
|
22
|
+
if (manifest.name === OWN_PACKAGE_NAME && typeof manifest.version === 'string') return manifest.version;
|
|
23
|
+
} catch {
|
|
24
|
+
// Nothing readable here; the next directory up may still hold the manifest.
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(current);
|
|
27
|
+
if (parent === current) return fallback;
|
|
28
|
+
current = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { resolveOwnVersion };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeoCord Framework
|
|
3
|
+
* Copyright (c) 2025 Ukasyah Rahmatullah Zada
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/ /**
|
|
6
|
+
* Environment variable that pins the binary the application is spawned with.
|
|
7
|
+
*
|
|
8
|
+
* An escape hatch for pinning a specific install, or for running the application under a
|
|
9
|
+
* different runtime than the CLI to compare behaviour. Unset is the normal case.
|
|
10
|
+
*/ const RUNTIME_OVERRIDE_ENV = 'MEOCORD_RUNTIME';
|
|
11
|
+
/**
|
|
12
|
+
* Runners that are themselves JavaScript runtimes.
|
|
13
|
+
*
|
|
14
|
+
* `npm_execpath` names the binary that launched the script. npm, pnpm and yarn set it to
|
|
15
|
+
* a `.js` file, which cannot run the application, so only the runners listed here are
|
|
16
|
+
* read as a runtime. An entry belongs here only once it has been checked against that
|
|
17
|
+
* runner's real environment, since guessing wrong means spawning something that cannot
|
|
18
|
+
* execute the bundle.
|
|
19
|
+
*/ const RUNTIME_RUNNERS = new Set([
|
|
20
|
+
'bun'
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* The runtime that launched the CLI, when the CLI is not itself running on it.
|
|
24
|
+
*
|
|
25
|
+
* `bun run` honours the bin's `#!/usr/bin/env node` shebang, so the CLI lands on node
|
|
26
|
+
* even though the user asked for bun. What they chose is still recoverable: bun sets
|
|
27
|
+
* `npm_config_user_agent` to `bun/<version> …` and `npm_execpath` to its own binary.
|
|
28
|
+
*
|
|
29
|
+
* @param env - Environment the CLI was launched with.
|
|
30
|
+
* @returns The launcher's binary, or `undefined` when it cannot run the application.
|
|
31
|
+
*/ function launcherRuntime(env) {
|
|
32
|
+
const runner = env.npm_config_user_agent?.split('/')[0];
|
|
33
|
+
const launcher = env.npm_execpath?.trim();
|
|
34
|
+
if (runner === undefined || !launcher) return undefined;
|
|
35
|
+
return RUNTIME_RUNNERS.has(runner) ? launcher : undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The binary the application should be spawned with.
|
|
39
|
+
*
|
|
40
|
+
* Follows the runtime the user chose rather than naming one. Someone who typed `bun`
|
|
41
|
+
* gets a bun process, and an image built on bun alone stays that way — pinning `node`
|
|
42
|
+
* would oblige them to install a second runtime beside the one they picked, or to
|
|
43
|
+
* remember `--bun` on every command.
|
|
44
|
+
*
|
|
45
|
+
* Preference runs from the most explicit signal to the least: an override, then the
|
|
46
|
+
* runner that launched the CLI, then the binary executing it.
|
|
47
|
+
*
|
|
48
|
+
* @param env - Environment the CLI was launched with.
|
|
49
|
+
* @param execPath - Binary executing the CLI, i.e. `process.execPath`.
|
|
50
|
+
* @returns The binary to spawn.
|
|
51
|
+
*/ function resolveRuntime(env, execPath) {
|
|
52
|
+
const override = env[RUNTIME_OVERRIDE_ENV]?.trim();
|
|
53
|
+
// An override of only whitespace would spawn '' and fail with an ENOENT naming nothing,
|
|
54
|
+
// so it is treated as absent rather than passed through.
|
|
55
|
+
if (override) return override;
|
|
56
|
+
return launcherRuntime(env) ?? execPath;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Runs the built application directly.
|
|
60
|
+
*
|
|
61
|
+
* @param runtime - Binary to run the application with.
|
|
62
|
+
* @param mainJsPath - Absolute path to the built entry file.
|
|
63
|
+
*/ function buildAppCommand(runtime, mainJsPath) {
|
|
64
|
+
return {
|
|
65
|
+
command: runtime,
|
|
66
|
+
args: [
|
|
67
|
+
mainJsPath
|
|
68
|
+
]
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { RUNTIME_OVERRIDE_ENV, buildAppCommand, resolveRuntime };
|
|
@@ -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>;
|
|
@@ -27,8 +52,8 @@ declare class MeoCordApp {
|
|
|
27
52
|
*
|
|
28
53
|
* Built once and cached, so dispatch stays a single ordered walk with an early exit
|
|
29
54
|
* rather than paying to rank anything per interaction. Ordering here is what lets
|
|
30
|
-
* `gi-profile
|
|
31
|
-
* `gi-profile
|
|
55
|
+
* `gi-profile/summary/{ownerId}/{uid}` keep the ids it owns when
|
|
56
|
+
* `gi-profile/{uuid}/{uid}` would also match them — without it, the winner would be
|
|
32
57
|
* whichever controller happened to be registered first.
|
|
33
58
|
*/
|
|
34
59
|
private componentRoutes?;
|
|
@@ -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;
|