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.
Files changed (76) hide show
  1. package/AUTHOR.md +2 -3
  2. package/README.md +361 -55
  3. package/dist/cjs/_shared/controller.decorator-MUHA_A3z.cjs +529 -0
  4. package/dist/cjs/core/index.cjs +246 -86
  5. package/dist/cjs/decorator/index.cjs +4 -2
  6. package/dist/cjs/enum/index.cjs +17 -4
  7. package/dist/cjs/testing/index.cjs +88 -4
  8. package/dist/esm/bin/app-template/README.md.template +61 -0
  9. package/dist/esm/bin/app-template/_env.example.template +2 -0
  10. package/dist/esm/bin/app-template/_gitignore.template +22 -0
  11. package/dist/esm/bin/app-template/_prettierrc.mjs.template +10 -0
  12. package/dist/esm/bin/app-template/eslint.config.ts.template +25 -0
  13. package/dist/esm/bin/app-template/meocord.config.ts.template +25 -0
  14. package/dist/esm/bin/app-template/package.json.template +38 -0
  15. package/dist/esm/bin/app-template/src/app.ts.template +44 -0
  16. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +17 -0
  17. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +19 -0
  18. package/dist/esm/bin/app-template/src/controllers/context-menu/builders/sample.builder.ts.template +10 -0
  19. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.spec.ts.template +17 -0
  20. package/dist/esm/bin/app-template/src/controllers/context-menu/sample.context-menu.controller.ts.template +13 -0
  21. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.spec.ts.template +17 -0
  22. package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +28 -0
  23. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.spec.ts.template +17 -0
  24. package/dist/esm/bin/app-template/src/controllers/modal-submit/sample.modal-submit.controller.ts.template +13 -0
  25. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.spec.ts.template +17 -0
  26. package/dist/esm/bin/app-template/src/controllers/reaction/sample.reaction.controller.ts.template +29 -0
  27. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.spec.ts.template +17 -0
  28. package/dist/esm/bin/app-template/src/controllers/select-menu/sample.select-menu.controller.ts.template +11 -0
  29. package/dist/esm/bin/app-template/src/controllers/slash/builders/sample.builder.ts.template +10 -0
  30. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.spec.ts.template +17 -0
  31. package/dist/esm/bin/app-template/src/controllers/slash/sample.slash.controller.ts.template +19 -0
  32. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.spec.ts.template +13 -0
  33. package/dist/esm/bin/app-template/src/guards/rate-limit.guard.ts.template +52 -0
  34. package/dist/esm/bin/app-template/src/main.ts.template +14 -0
  35. package/dist/esm/bin/app-template/src/services/sample.service.spec.ts.template +17 -0
  36. package/dist/esm/bin/app-template/src/services/sample.service.ts.template +9 -0
  37. package/dist/esm/bin/app-template/tsconfig.eslint.json.template +5 -0
  38. package/dist/esm/bin/app-template/tsconfig.json.template +30 -0
  39. package/dist/esm/bin/app-template/tsconfig.test.json.template +8 -0
  40. package/dist/esm/bin/app-template/vitest.config.ts.template +35 -0
  41. package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +26 -0
  42. package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +16 -0
  43. package/dist/esm/bin/builder-template/controller/button.controller.template +1 -1
  44. package/dist/esm/bin/builder-template/controller/channel-select-menu.controller.template +12 -0
  45. package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
  46. package/dist/esm/bin/builder-template/controller/mentionable-select-menu.controller.template +12 -0
  47. package/dist/esm/bin/builder-template/controller/modal-submit.controller.template +1 -1
  48. package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +12 -0
  49. package/dist/esm/bin/builder-template/controller/role-select-menu.controller.template +12 -0
  50. package/dist/esm/bin/builder-template/controller/slash.controller.template +1 -1
  51. package/dist/esm/bin/builder-template/controller/user-select-menu.controller.template +12 -0
  52. package/dist/esm/bin/generator.js +4 -9
  53. package/dist/esm/bin/helper/app-generator.helper.js +78 -0
  54. package/dist/esm/bin/helper/controller-generator.helper.js +24 -7
  55. package/dist/esm/bin/meocord.js +156 -75
  56. package/dist/esm/core/meocord.app.js +247 -86
  57. package/dist/esm/decorator/controller.decorator.js +73 -10
  58. package/dist/esm/decorator/guard.decorator.js +1 -1
  59. package/dist/esm/decorator/index.js +1 -1
  60. package/dist/esm/enum/controller.enum.js +23 -4
  61. package/dist/esm/testing/mock-interaction.js +89 -5
  62. package/dist/esm/util/common.util.js +11 -3
  63. package/dist/esm/util/generator-cli.util.js +21 -4
  64. package/dist/esm/util/interaction.util.js +174 -0
  65. package/dist/esm/util/package-manager.util.js +9 -2
  66. package/dist/esm/util/package-version.util.js +32 -0
  67. package/dist/esm/util/runtime.util.js +72 -0
  68. package/dist/types/controller.enum-DYfhYaat.d.ts +36 -0
  69. package/dist/types/core/index.d.ts +75 -2
  70. package/dist/types/decorator/index.d.ts +61 -49
  71. package/dist/types/enum/index.d.ts +1 -1
  72. package/dist/types/interface/index.d.ts +89 -3
  73. package/dist/types/testing/index.d.ts +3 -21
  74. package/package.json +11 -12
  75. package/dist/cjs/_shared/controller.decorator-CC6BjHkS.cjs +0 -288
  76. 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
- 'isSelectMenu',
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
- */ // `any` is the default rather than `CacheType` because TypeScript types a generic
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
- const meocordConfig = loadMeoCordConfig();
51
- if (!meocordConfig?.discordToken) {
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 { exec } from 'child_process';
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(`Guard file created at: ${path.relative(process.cwd(), filePath)}`);
71
- exec(`npx eslint --fix ${filePath}`);
70
+ logger.log(`Created ${path.relative(process.cwd(), filePath)}`);
71
+ formatWithLocalESLint(filePath);
72
72
  } catch (error) {
73
- logger.error(`Failed to create guard file at ${filePath}`, error);
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
- function detectInstalledPMs() {
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(`which ${pm}`, {
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-summary-{ownerId}-{uid}` keep the ids it owns when
31
- * `gi-profile-{uuid}-{uid}` would also match them — without it, the winner would be
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;