meocord 4.1.0-beta.6 → 4.1.0-beta.7
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/CHANGELOG.md +31 -0
- package/dist/cjs/_shared/{explained-error-CtgL_q_6.cjs → explained-error-BRrfk1lT.cjs} +1 -1
- package/dist/cjs/_shared/{providers-BXnrmm9p.cjs → providers-CIA3ykpw.cjs} +2 -2
- package/dist/cjs/_shared/{shard-context-BmA9OoaF.cjs → shard-context-fX-JKqOi.cjs} +441 -5
- package/dist/cjs/_shared/{theme-resolvers-DhoRhqJV.cjs → theme-resolvers-B4BV5Ikl.cjs} +43 -17
- package/dist/cjs/_shared/{translate-error-CNY8ic6A.cjs → translate-error-DtdMVhf7.cjs} +1 -1
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +15 -373
- package/dist/cjs/decorator/index.cjs +2 -2
- package/dist/cjs/enum/index.cjs +1 -1
- package/dist/cjs/testing/index.cjs +314 -15
- package/dist/cli.json +590 -0
- package/dist/esm/bin/meocord.js +7 -1
- package/dist/esm/common/response/response-state.js +10 -5
- package/dist/esm/core/command-conflicts.js +75 -0
- package/dist/esm/core/command-registration.js +5 -4
- package/dist/esm/core/meocord-factory.js +6 -2
- package/dist/esm/core/observer-runner.js +2 -2
- package/dist/esm/decorator/controller.decorator.js +25 -3
- package/dist/esm/enum/controller.enum.js +1 -1
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/discord-shape.js +123 -0
- package/dist/esm/testing/meocord-testing-module.js +123 -11
- package/dist/esm/testing/mock-interaction.js +70 -2
- package/dist/types/common/index.d.cts +5 -5
- package/dist/types/common/index.d.ts +5 -5
- package/dist/types/{controller.enum-HOQhQPQ4.d.cts → controller.enum-yd-fOr9m.d.cts} +1 -1
- package/dist/types/{controller.enum-HOQhQPQ4.d.ts → controller.enum-yd-fOr9m.d.ts} +1 -1
- package/dist/types/core/index.d.cts +4 -3
- package/dist/types/core/index.d.ts +4 -3
- package/dist/types/decorator/index.d.cts +8 -5
- package/dist/types/decorator/index.d.ts +8 -5
- package/dist/types/enum/index.d.cts +1 -1
- package/dist/types/enum/index.d.ts +1 -1
- package/dist/types/{index-Bi_mYodF.d.cts → index-BmSDfetA.d.ts} +35 -4
- package/dist/types/{index-oSZz7ale.d.ts → index-CrcfxIXr.d.cts} +35 -4
- package/dist/types/interface/index.d.cts +2 -2
- package/dist/types/interface/index.d.ts +2 -2
- package/dist/types/testing/index.d.cts +93 -8
- package/dist/types/testing/index.d.ts +93 -8
- package/dist/types/{theme-resolvers-EMIDH5y5.d.ts → theme-resolvers-CJq6aGG8.d.cts} +2 -0
- package/dist/types/{theme-resolvers-EMIDH5y5.d.cts → theme-resolvers-CWU3_PhU.d.ts} +2 -0
- package/dist/types/{translator-CN8tHTRV.d.cts → translator-B2F8HOge.d.cts} +1 -1
- package/dist/types/{translator-geIgJfsS.d.ts → translator-CAOse4oQ.d.ts} +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# meocord
|
|
2
2
|
|
|
3
|
+
## 4.1.0-beta.7
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#257](https://github.com/meocord/meocord/pull/257) [`afa139d`](https://github.com/meocord/meocord/commit/afa139debcc8e4accfe3f5c2a8997dbe9d0fb4d3) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `MeoCordTestingModule.fromApp(App, options?)` builds a testing module from a whole `@MeoCord` app, wired as the bot wires it: its controllers, services, providers and cooldown store, with its stages, translator, presenter, message options, theme and observers. A test no longer lists the app's controllers and providers again. `options.providers` replaces the app's by token, before anything is made, so a database factory the test replaces never runs; `options.controllers` and `options.observers` add a test's own; the `override*()` methods still apply. The app's services are made at `init()`, as the bot makes them before it logs in.
|
|
8
|
+
|
|
9
|
+
A class that injects the Discord `Client` in a testing module that was given none is now refused with what to do, `{ provide: Client, useValue: createMockClient() }`, where it read `No bindings found for service: "Client"`.
|
|
10
|
+
|
|
11
|
+
- [#258](https://github.com/meocord/meocord/pull/258) [`f9189b9`](https://github.com/meocord/meocord/commit/f9189b9b1008a9fee97ede546466b302afe71e92) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A context menu handler is typed with the kind its builder's `setType()` names. With `.setType(ApplicationCommandType.User)`, `@Command('Report user', ReportUserBuilder)` gives the handler a `UserContextMenuCommandInteraction`, and a handler declaring `MessageContextMenuCommandInteraction` no longer compiles. It is caught however the interaction is imported, an `import { type … }` included, where the check as the bot starts needed it imported as a value. Such a handler was broken anyway: dispatch sends a builder's command only to a handler of its kind, so its first click already failed. Declare the kind the builder's `setType()` names.
|
|
12
|
+
|
|
13
|
+
Nothing new is needed: MeoCord adds the kind to discord.js's `ContextMenuCommandBuilder.setType` for the compiler alone, and nothing changes at runtime. A builder whose kind the compiler can't tell, one that never calls `setType()` or picks the kind at runtime, gives either kind as before, and is still checked as the bot starts.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [#260](https://github.com/meocord/meocord/pull/260) [`18334ec`](https://github.com/meocord/meocord/commit/18334ecb55c1301586606e4c8d7a9a6ddf4173a1) Thanks [@l7aromeo](https://github.com/l7aromeo)! - The package includes `dist/cli.json`, which describes the `meocord` CLI: every command and subcommand, with its aliases, arguments, options, defaults, choices and descriptions, in the order `meocord --help` lists them. The build writes it from the program the CLI runs, so it always matches the installed version, and a tool can read it as data, without running the CLI. `schemaVersion` changes only when a change to the shape would break a reader.
|
|
18
|
+
|
|
19
|
+
- [#261](https://github.com/meocord/meocord/pull/261) [`701890b`](https://github.com/meocord/meocord/commit/701890b82e7edad7fa020cc4764f4fd7c5fdfb51) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A message `@Defer` locked is put back when its handler answers only with a follow-up, returns without answering, or throws. On a message with an uploaded file, such as an image in a Components V2 card, a click soon after the message was sent could leave it locked instead: its buttons and selects disabled under the loading view, for good. The lock's edit has Discord process the uploaded file again, and MeoCord, seeing the message change from what its edit returned, took it for someone else's edit and left it alone. It now keeps the time Discord stamps on its own edit, and leaves a message as it is only when it shows a later edit. The file stays attached, and a select that was clicked comes back with its options and defaults as the message had them, not the user's pick.
|
|
20
|
+
|
|
21
|
+
`meocord/testing`: a mock interaction's `editReply()` and `fetchReply()` now keep its message as Discord does. Components get their ids, media resolves, a file uploaded with the message comes back loading from the first edit that keeps it, and each edit is stamped with its time. `createMockMessage` takes `editedTimestamp`.
|
|
22
|
+
|
|
23
|
+
- [#259](https://github.com/meocord/meocord/pull/259) [`01f11d4`](https://github.com/meocord/meocord/commit/01f11d4a0b024ce5a1fd5b83acea5476ae58ca56) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A bot whose commands only one handler or one builder could ever take stops when it is created, as `meocord start`, `meocord register`, a shard manager and a testing module build it, naming both:
|
|
24
|
+
|
|
25
|
+
- two handlers of one slash command name or subcommand path, or of one context menu name and kind, where only the first ever ran: `StatsController.stats and AdminController.adminStats both handle the slash command "stats", so only StatsController.stats would ever run.`;
|
|
26
|
+
- two builder classes that build one application command, where only the first was registered, with a warning: `StatsBuilder on StatsController.stats and CopiedStatsBuilder on StatsController.statistics both build the slash command "stats"…`.
|
|
27
|
+
|
|
28
|
+
One builder on a command and its own subcommand paths is still one command, and a user and a message context menu may still share a name. See [Upgrading to 4.1](https://meocord.dev/docs/4.1/migrating#two-handlers-of-one-command-stop-the-bot).
|
|
29
|
+
|
|
30
|
+
- [#254](https://github.com/meocord/meocord/pull/254) [`2b02b0e`](https://github.com/meocord/meocord/commit/2b02b0ea9e8ad9f8e594ae14e32ba76400ee3275) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A slash command builder given on a subcommand path, such as `@Command('settings notify email', SettingsCommandBuilder)`, is named for what it is. A builder that builds its name from the path fails as before, now with a message naming the handler: `SettingsSlashController.notifyEmail declares the builder SettingsCommandBuilder on "settings notify email", which is a subcommand path: the builder of its command, "settings", describes it`, with what to declare instead, where it said "Invalid string format" and advised checking name lengths. A builder that names its command itself keeps working, and is warned about once as the bot starts, with the same advice: declare the handler with `@Command('settings notify email', CommandType.SLASH)`, and give the builder to `@Command('settings')`.
|
|
31
|
+
|
|
32
|
+
- [#255](https://github.com/meocord/meocord/pull/255) [`2852892`](https://github.com/meocord/meocord/commit/2852892922694fb883c8244b9469781e152468c1) Thanks [@l7aromeo](https://github.com/l7aromeo)! - An observer is told a message command's usage error as `'invalid'`, the user's input that doesn't fit, as it is told a `ValidationError`. It was told `'error'`, so metrics counted a user's typo as a fault of the bot. This covers a word of the wrong type, a param left out, a flag the command lacks, a command sent where it doesn't work, and a parent's words alone, answered with its subcommands. A dashboard that counts `'error'` sees these under `'invalid'` from this release.
|
|
33
|
+
|
|
3
34
|
## 4.1.0-beta.6
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var node_crypto = require('node:crypto');
|
|
4
|
-
var themeResolvers = require('./theme-resolvers-
|
|
4
|
+
var themeResolvers = require('./theme-resolvers-B4BV5Ikl.cjs');
|
|
5
5
|
|
|
6
6
|
/** Whether an IPC message is one of MeoCord's own, rather than one of discord.js's. */ function isShardMessage(message) {
|
|
7
7
|
return typeof message === 'object' && message !== null && 'meocord' in message;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
require('reflect-metadata');
|
|
4
|
-
var themeResolvers = require('./theme-resolvers-
|
|
4
|
+
var themeResolvers = require('./theme-resolvers-B4BV5Ikl.cjs');
|
|
5
5
|
var discord_js = require('discord.js');
|
|
6
6
|
var enum_index = require('../enum/index.cjs');
|
|
7
7
|
var node_async_hooks = require('node:async_hooks');
|
|
@@ -1025,7 +1025,7 @@ function observersOf(container) {
|
|
|
1025
1025
|
/** How a call that threw `error` ended. */ function outcomeOf(error) {
|
|
1026
1026
|
if (error instanceof themeResolvers.GuardDeniedError) return 'denied';
|
|
1027
1027
|
if (error instanceof themeResolvers.CooldownError) return 'cooldown';
|
|
1028
|
-
if (error instanceof themeResolvers.ValidationError) return 'invalid';
|
|
1028
|
+
if (error instanceof themeResolvers.ValidationError || error instanceof themeResolvers.MessageUsageError) return 'invalid';
|
|
1029
1029
|
if (error instanceof themeResolvers.UserError) return 'refused';
|
|
1030
1030
|
if (error instanceof themeResolvers.CommandNotFoundError) return 'not-found';
|
|
1031
1031
|
return 'error';
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var themeResolvers = require('./theme-resolvers-
|
|
4
|
-
var translateError = require('./translate-error-
|
|
3
|
+
var themeResolvers = require('./theme-resolvers-B4BV5Ikl.cjs');
|
|
4
|
+
var translateError = require('./translate-error-DtdMVhf7.cjs');
|
|
5
5
|
var discord_js = require('discord.js');
|
|
6
|
-
var providers = require('./providers-
|
|
6
|
+
var providers = require('./providers-CIA3ykpw.cjs');
|
|
7
7
|
require('reflect-metadata');
|
|
8
8
|
var enum_index = require('../enum/index.cjs');
|
|
9
|
+
var crypto = require('crypto');
|
|
10
|
+
var fs = require('fs');
|
|
11
|
+
var path = require('path');
|
|
12
|
+
var Table = require('cli-table3');
|
|
9
13
|
require('node:crypto');
|
|
10
14
|
|
|
11
15
|
const NO_GLOBAL_STAGES = {
|
|
@@ -613,6 +617,368 @@ function describeCall(context) {
|
|
|
613
617
|
}
|
|
614
618
|
}
|
|
615
619
|
|
|
620
|
+
/** Discord's rule for a chat input command or option name, localised or not. */ const CHAT_INPUT_NAME = /^[-_'\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
|
|
621
|
+
const DISCORD_LOCALES = new Set(Object.values(discord_js.Locale));
|
|
622
|
+
/** The entries of a localization map, or none when it is absent or null. */ const entriesOf$1 = (map)=>typeof map === 'object' && map !== null ? Object.entries(map) : [];
|
|
623
|
+
/**
|
|
624
|
+
* What Discord would reject in a command's localised names and descriptions, checked before sending
|
|
625
|
+
* so the error names the field instead of arriving as an opaque 50035. The command's own names and
|
|
626
|
+
* descriptions are left to discord.js's builders and to Discord.
|
|
627
|
+
*
|
|
628
|
+
* @returns One line per problem, such as `"ban" description_localizations.ja: 104 characters (limit 100)`.
|
|
629
|
+
*/ function localizationProblems(commandName, body) {
|
|
630
|
+
const problems = [];
|
|
631
|
+
const chatInput = body.type === undefined || body.type === discord_js.ApplicationCommandType.ChatInput;
|
|
632
|
+
const check = (path, node, rules)=>{
|
|
633
|
+
for (const [locale, value] of entriesOf$1(node.name_localizations)){
|
|
634
|
+
const field = `${path}name_localizations.${locale}`;
|
|
635
|
+
// An unknown locale is the problem to report; lowercasing for it could throw on a malformed tag.
|
|
636
|
+
if (!DISCORD_LOCALES.has(locale)) {
|
|
637
|
+
problems.push(`"${commandName}" ${field}: "${locale}" is not a Discord locale`);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (value === null) continue;
|
|
641
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > (rules.chatInputName ? 32 : 100)) {
|
|
642
|
+
problems.push(`"${commandName}" ${field}: ${describeLength(value)} (1 to ${rules.chatInputName ? 32 : 100})`);
|
|
643
|
+
} else if (rules.chatInputName && (!CHAT_INPUT_NAME.test(value) || value !== value.toLocaleLowerCase(locale))) {
|
|
644
|
+
problems.push(`"${commandName}" ${field}: "${value}" must be lowercase letters, numbers, - _ or ' with no spaces`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (!rules.descriptions) return;
|
|
648
|
+
for (const [locale, value] of entriesOf$1(node.description_localizations)){
|
|
649
|
+
const field = `${path}description_localizations.${locale}`;
|
|
650
|
+
if (!DISCORD_LOCALES.has(locale)) problems.push(`"${commandName}" ${field}: "${locale}" is not a Discord locale`);
|
|
651
|
+
if (value === null) continue;
|
|
652
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 100) {
|
|
653
|
+
problems.push(`"${commandName}" ${field}: ${describeLength(value)} (1 to 100)`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
// Context menu names allow spaces and capitals, and none of them has a description.
|
|
658
|
+
const contextMenu = body.type === discord_js.ApplicationCommandType.User || body.type === discord_js.ApplicationCommandType.Message;
|
|
659
|
+
check('', body, {
|
|
660
|
+
chatInputName: chatInput,
|
|
661
|
+
descriptions: !contextMenu
|
|
662
|
+
});
|
|
663
|
+
const walk = (path, options)=>{
|
|
664
|
+
if (!Array.isArray(options)) return;
|
|
665
|
+
for (const option of options){
|
|
666
|
+
const at = `${path}${String(option.name)}.`;
|
|
667
|
+
check(`options.${at}`, option, {
|
|
668
|
+
chatInputName: true,
|
|
669
|
+
descriptions: true
|
|
670
|
+
});
|
|
671
|
+
if (Array.isArray(option.choices)) {
|
|
672
|
+
for (const choice of option.choices){
|
|
673
|
+
check(`options.${at}choices.${String(choice.name)}.`, choice, {
|
|
674
|
+
chatInputName: false,
|
|
675
|
+
descriptions: false
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
walk(at, option.options);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
walk('', body.options);
|
|
683
|
+
return problems;
|
|
684
|
+
}
|
|
685
|
+
function describeLength(value) {
|
|
686
|
+
return typeof value === 'string' ? `${value.length} characters` : `not a string`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** What Discord assumes a command body is when it carries no type of its own. */ const DEFAULT_APPLICATION_COMMAND_TYPE = discord_js.ApplicationCommandType.ChatInput;
|
|
690
|
+
/** The body a builder registers, which is what Discord sees; throws when the builder is incomplete. */ function serialise(builder) {
|
|
691
|
+
return typeof builder.toJSON === 'function' ? builder.toJSON() : builder;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The identity Discord gives a command. The numeric type leads, so the halves never read apart
|
|
695
|
+
* wrongly; an absent type is the chat input one Discord infers, so untyped slash builders collide.
|
|
696
|
+
*/ function registrationKey(body, fallbackName) {
|
|
697
|
+
const type = typeof body.type === 'number' ? body.type : DEFAULT_APPLICATION_COMMAND_TYPE;
|
|
698
|
+
return `${type}:${typeof body.name === 'string' ? body.name : fallbackName}`;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Every command the controllers declare, read from their prototypes: nothing is constructed.
|
|
702
|
+
*
|
|
703
|
+
* @returns The commands, or `undefined` when a builder cannot be serialised. Registration then sends
|
|
704
|
+
* nothing, since a bulk update without that command would delete it from Discord.
|
|
705
|
+
*/ function collectCommands(controllerClasses, logger) {
|
|
706
|
+
// Keyed by type and name: Discord treats that pair as one command, so a user and a message context
|
|
707
|
+
// menu may share a name, and a command split across methods sends its builder once.
|
|
708
|
+
const byKey = new Map();
|
|
709
|
+
const broken = [];
|
|
710
|
+
const unlocalizable = [];
|
|
711
|
+
for (const controllerClass of controllerClasses){
|
|
712
|
+
const commandMap = themeResolvers.getCommandMap(controllerClass.prototype) ?? {};
|
|
713
|
+
for(const commandName in commandMap){
|
|
714
|
+
for (const { builder, builderClass, type, guilds } of commandMap[commandName]){
|
|
715
|
+
if (!(type in enum_index.CommandType) || !builder) continue;
|
|
716
|
+
let body;
|
|
717
|
+
try {
|
|
718
|
+
body = serialise(builder);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
broken.push(`"${commandName}" (${error instanceof Error ? error.message : String(error)})`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const key = registrationKey(body, commandName);
|
|
724
|
+
const existing = byKey.get(key);
|
|
725
|
+
if (existing === undefined) {
|
|
726
|
+
const name = typeof body.name === 'string' ? body.name : commandName;
|
|
727
|
+
unlocalizable.push(...localizationProblems(name, body));
|
|
728
|
+
byKey.set(key, {
|
|
729
|
+
builderClass: builderClass ?? builder,
|
|
730
|
+
command: {
|
|
731
|
+
name,
|
|
732
|
+
body,
|
|
733
|
+
...guilds && {
|
|
734
|
+
guilds
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
} else if (existing.builderClass !== (builderClass ?? builder)) {
|
|
739
|
+
// Refused when the app is created; kept for a caller that collects commands on its own, never thrown
|
|
740
|
+
logger.warn(`Command "${existing.command.name}" is built more than once for the same application command type; ` + `only the first builder is registered. Two builders of one type cannot both own a name, so declare ` + `the builder on a single @Command and give the others the plain CommandType.`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (broken.length > 0) {
|
|
746
|
+
logger.error(`No commands were registered: ${broken.length} builder(s) could not be serialised: ${broken.join(', ')}. ` + `Registering the rest would remove these from Discord.`);
|
|
747
|
+
return undefined;
|
|
748
|
+
}
|
|
749
|
+
if (unlocalizable.length > 0) {
|
|
750
|
+
logger.error(`No commands were registered: Discord would reject ${unlocalizable.length} localization(s):\n` + unlocalizable.map((problem)=>` ${problem}`).join('\n'));
|
|
751
|
+
return undefined;
|
|
752
|
+
}
|
|
753
|
+
return [
|
|
754
|
+
...byKey.values()
|
|
755
|
+
].map(({ command })=>command);
|
|
756
|
+
}
|
|
757
|
+
const ids = (list)=>[
|
|
758
|
+
...new Set((list ?? []).map((id)=>id?.trim()).filter((id)=>!!id))
|
|
759
|
+
];
|
|
760
|
+
const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`;
|
|
761
|
+
/** Where every command goes regardless of `guilds`: `--guild`, or `developmentGuild` in development. */ const everythingTarget = ({ config, development, onlyGuild })=>onlyGuild?.trim() || (development ? config?.developmentGuild?.trim() : undefined);
|
|
762
|
+
/**
|
|
763
|
+
* Whether `commands.guilds` names guilds but no ids, as `[process.env.GUILD_ID]` does with the variable
|
|
764
|
+
* unset. The default scope is then nowhere: registering globally instead would publish the commands
|
|
765
|
+
* everywhere.
|
|
766
|
+
*/ function defaultGuildsEmpty(options) {
|
|
767
|
+
const guilds = options.config?.guilds ?? [];
|
|
768
|
+
return !everythingTarget(options) && guilds.length > 0 && ids(guilds).length === 0;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Where each command is sent: one bulk update per scope. A scope's update replaces everything the
|
|
772
|
+
* application has there, so the default scope is always included, even when it ends up empty.
|
|
773
|
+
*/ function planTargets(commands, options, logger) {
|
|
774
|
+
const everythingTo = everythingTarget(options);
|
|
775
|
+
if (everythingTo) return [
|
|
776
|
+
{
|
|
777
|
+
scope: {
|
|
778
|
+
guild: everythingTo
|
|
779
|
+
},
|
|
780
|
+
commands
|
|
781
|
+
}
|
|
782
|
+
];
|
|
783
|
+
const defaultGuilds = ids(options.config?.guilds);
|
|
784
|
+
const nowhere = defaultGuildsEmpty(options);
|
|
785
|
+
const targets = new Map();
|
|
786
|
+
const add = (scope, command)=>{
|
|
787
|
+
const key = scopeKey(scope);
|
|
788
|
+
if (!targets.has(key)) targets.set(key, {
|
|
789
|
+
scope,
|
|
790
|
+
commands: []
|
|
791
|
+
});
|
|
792
|
+
if (command) targets.get(key).commands.push(command);
|
|
793
|
+
};
|
|
794
|
+
if (defaultGuilds.length === 0 && !nowhere) add('global');
|
|
795
|
+
for (const guild of defaultGuilds)add({
|
|
796
|
+
guild
|
|
797
|
+
});
|
|
798
|
+
const unregistered = [];
|
|
799
|
+
for (const command of commands){
|
|
800
|
+
if (command.guilds) {
|
|
801
|
+
const own = ids(command.guilds);
|
|
802
|
+
// Falling back to the default scope could publish a staff-only command everywhere.
|
|
803
|
+
if (own.length === 0) logger.warn(`Command "${command.name}" lists no guild ids in its builder, so it is not registered.`);
|
|
804
|
+
for (const guild of own)add({
|
|
805
|
+
guild
|
|
806
|
+
}, command);
|
|
807
|
+
} else if (nowhere) {
|
|
808
|
+
unregistered.push(`"${command.name}"`);
|
|
809
|
+
} else if (defaultGuilds.length === 0) {
|
|
810
|
+
add('global', command);
|
|
811
|
+
} else {
|
|
812
|
+
for (const guild of defaultGuilds)add({
|
|
813
|
+
guild
|
|
814
|
+
}, command);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (nowhere) {
|
|
818
|
+
const outcome = unregistered.length === 0 ? 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
|
|
819
|
+
logger.warn(`commands.guilds lists no guild id, as an unset environment variable leaves it, so ${outcome}. ` + `Set the guild ids, or remove commands.guilds to register globally.`);
|
|
820
|
+
}
|
|
821
|
+
return [
|
|
822
|
+
...targets.values()
|
|
823
|
+
];
|
|
824
|
+
}
|
|
825
|
+
/** The scopes this configuration names that registration is not sending to, where leftovers can sit. */ function otherScopes(commands, targets, config) {
|
|
826
|
+
const sent = new Set(targets.map(({ scope })=>scopeKey(scope)));
|
|
827
|
+
const named = [
|
|
828
|
+
'global',
|
|
829
|
+
...ids([
|
|
830
|
+
...config?.guilds ?? [],
|
|
831
|
+
config?.developmentGuild,
|
|
832
|
+
...commands.flatMap((command)=>command.guilds ?? [])
|
|
833
|
+
]).map((guild)=>({
|
|
834
|
+
guild
|
|
835
|
+
}))
|
|
836
|
+
];
|
|
837
|
+
return named.filter((scope)=>!sent.has(scopeKey(scope)));
|
|
838
|
+
}
|
|
839
|
+
const routeFor = (applicationId, scope)=>scope === 'global' ? discord_js.Routes.applicationCommands(applicationId) : discord_js.Routes.applicationGuildCommands(applicationId, scope.guild);
|
|
840
|
+
const describeScope = (scope)=>scope === 'global' ? 'globally' : `to guild ${scope.guild}`;
|
|
841
|
+
/** The development cache of what was last sent, per application and scope. Undefined without node_modules. */ function cacheFile(applicationId, scope) {
|
|
842
|
+
const nodeModules = path.resolve(process.cwd(), 'node_modules');
|
|
843
|
+
if (!fs.existsSync(nodeModules)) return undefined;
|
|
844
|
+
return path.join(nodeModules, '.cache', 'meocord', `commands-${applicationId}-${scopeKey(scope)}.json`);
|
|
845
|
+
}
|
|
846
|
+
const hashOf = (commands)=>crypto.createHash('sha256').update(JSON.stringify(commands.map(({ body })=>body))).digest('hex');
|
|
847
|
+
function readHash(file, logger) {
|
|
848
|
+
if (!file || !fs.existsSync(file)) return undefined;
|
|
849
|
+
try {
|
|
850
|
+
return JSON.parse(fs.readFileSync(file, 'utf8')).hash;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
logger.debug(`Could not read the registered commands, so they are sent again: ${String(error)}`);
|
|
853
|
+
return undefined;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
function writeHash(file, hash, logger) {
|
|
857
|
+
if (!file) return;
|
|
858
|
+
try {
|
|
859
|
+
fs.mkdirSync(path.dirname(file), {
|
|
860
|
+
recursive: true
|
|
861
|
+
});
|
|
862
|
+
fs.writeFileSync(file, `${JSON.stringify({
|
|
863
|
+
hash
|
|
864
|
+
})}\n`);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
logger.debug(`Could not record the registered commands: ${String(error)}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function logRegistered(target, logger) {
|
|
870
|
+
const table = new Table({
|
|
871
|
+
head: [
|
|
872
|
+
'Name',
|
|
873
|
+
'Type',
|
|
874
|
+
'Sub-commands'
|
|
875
|
+
],
|
|
876
|
+
colWidths: [
|
|
877
|
+
null,
|
|
878
|
+
null,
|
|
879
|
+
30
|
|
880
|
+
],
|
|
881
|
+
wordWrap: true
|
|
882
|
+
});
|
|
883
|
+
const typeNames = {
|
|
884
|
+
1: 'SlashCommand',
|
|
885
|
+
2: 'UserContextMenu',
|
|
886
|
+
3: 'MessageContextMenu',
|
|
887
|
+
4: 'PrimaryEntryPoint'
|
|
888
|
+
};
|
|
889
|
+
for (const { name, body } of target.commands){
|
|
890
|
+
const options = Array.isArray(body.options) ? body.options : [];
|
|
891
|
+
table.push([
|
|
892
|
+
name,
|
|
893
|
+
typeNames[typeof body.type === 'number' ? body.type : 1] ?? 'Command',
|
|
894
|
+
options.map((option)=>option.name).join(', ')
|
|
895
|
+
]);
|
|
896
|
+
}
|
|
897
|
+
logger.log(`Registered ${target.commands.length} bot commands ${describeScope(target.scope)}:\n${table.toString()}`);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Registers the application's commands with Discord: one bulk update per scope.
|
|
901
|
+
*
|
|
902
|
+
* Production always sends: a bulk update is idempotent, so comparing with what Discord holds first
|
|
903
|
+
* would only add a request. In development (`development: true`) a scope whose payload matches the
|
|
904
|
+
* last one sent from this project is skipped, unless `force` is set. Afterwards, the scopes the
|
|
905
|
+
* configuration names but did not send to are checked for leftovers, which `clearOther` removes, except
|
|
906
|
+
* in a development run sending to `developmentGuild`, which only warns.
|
|
907
|
+
* Failures are logged, never thrown.
|
|
908
|
+
*
|
|
909
|
+
* @returns Whether every scope was registered or skipped as unchanged.
|
|
910
|
+
*/ async function registerCommands(options) {
|
|
911
|
+
const { rest, applicationId, controllerClasses, logger, config, development, onlyGuild, force = false } = options;
|
|
912
|
+
const commands = collectCommands(controllerClasses, logger);
|
|
913
|
+
if (!commands) return false;
|
|
914
|
+
const targets = planTargets(commands, {
|
|
915
|
+
config,
|
|
916
|
+
development,
|
|
917
|
+
onlyGuild
|
|
918
|
+
}, logger);
|
|
919
|
+
// Commands the configuration meant for guilds went nowhere, and what it would clear cannot be trusted
|
|
920
|
+
const guildsEmpty = defaultGuildsEmpty({
|
|
921
|
+
config,
|
|
922
|
+
development,
|
|
923
|
+
onlyGuild
|
|
924
|
+
});
|
|
925
|
+
let succeeded = !guildsEmpty;
|
|
926
|
+
let sent = false;
|
|
927
|
+
for (const target of targets){
|
|
928
|
+
const file = development ? cacheFile(applicationId, target.scope) : undefined;
|
|
929
|
+
const hash = hashOf(target.commands);
|
|
930
|
+
if (development && !force && readHash(file, logger) === hash) {
|
|
931
|
+
logger.log(`Commands ${describeScope(target.scope)} are unchanged; not registering them again.`);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
await rest.put(routeFor(applicationId, target.scope), {
|
|
936
|
+
body: target.commands.map(({ body })=>body)
|
|
937
|
+
});
|
|
938
|
+
sent = true;
|
|
939
|
+
if (development) writeHash(file, hash, logger);
|
|
940
|
+
logRegistered(target, logger);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
succeeded = false;
|
|
943
|
+
logger.error(`Error registering commands ${describeScope(target.scope)}:`, error);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// Development and production often share one application, so a development guild run never clears.
|
|
947
|
+
const toDevelopmentGuild = development && !!config?.developmentGuild?.trim();
|
|
948
|
+
const keptBecause = toDevelopmentGuild ? 'while commands go to the development guild, since a production bot sharing this application may own them' : guildsEmpty ? 'while commands.guilds lists no guild id' : undefined;
|
|
949
|
+
if (sent && !onlyGuild) await reportOtherScopes(options, commands, targets, keptBecause);
|
|
950
|
+
return succeeded;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Warns about, or with `clearOther` removes, this application's commands left in scopes not sent to.
|
|
954
|
+
* Given `keptBecause`, as for a development guild run, it only warns, saying why.
|
|
955
|
+
*/ async function reportOtherScopes({ rest, applicationId, config, logger }, commands, targets, keptBecause) {
|
|
956
|
+
for (const scope of otherScopes(commands, targets, config)){
|
|
957
|
+
let existing;
|
|
958
|
+
try {
|
|
959
|
+
existing = await rest.get(routeFor(applicationId, scope));
|
|
960
|
+
} catch (error) {
|
|
961
|
+
// A guild the bot is not in answers 50001; there is nothing of ours to find there.
|
|
962
|
+
logger.debug(`Could not list the commands registered ${describeScope(scope)}: ${String(error)}`);
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (!Array.isArray(existing) || existing.length === 0) continue;
|
|
966
|
+
const names = existing.map(({ name })=>name).join(', ');
|
|
967
|
+
if (!config?.clearOther || keptBecause) {
|
|
968
|
+
logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ` + (config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : 'Set commands.clearOther to remove them.'));
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
await rest.put(routeFor(applicationId, scope), {
|
|
973
|
+
body: []
|
|
974
|
+
});
|
|
975
|
+
logger.log(`Removed ${existing.length} command(s) left registered ${describeScope(scope)}: ${names}`);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
logger.error(`Error removing the commands left registered ${describeScope(scope)}:`, error);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
616
982
|
/**
|
|
617
983
|
* Every customId-pattern route of the given controllers, most specific first, read from metadata alone.
|
|
618
984
|
* Throws for two handlers whose patterns match the same customIds of one component type; one handler
|
|
@@ -2271,7 +2637,7 @@ const COMMAND_TYPES = new Set([
|
|
|
2271
2637
|
return undefined;
|
|
2272
2638
|
}
|
|
2273
2639
|
}
|
|
2274
|
-
/** The description of the command or subcommand at `path` within a command's JSON. */ function describe$
|
|
2640
|
+
/** The description of the command or subcommand at `path` within a command's JSON. */ function describe$2(json, path) {
|
|
2275
2641
|
let node = json;
|
|
2276
2642
|
for (const segment of path.split(' ').slice(1)){
|
|
2277
2643
|
node = node?.options?.find((option)=>option.name === segment);
|
|
@@ -2412,7 +2778,7 @@ const COMMAND_TYPES = new Set([
|
|
|
2412
2778
|
for (const { methodName, type, builder } of metas){
|
|
2413
2779
|
if (COMMAND_TYPES.has(type)) {
|
|
2414
2780
|
const command = builderJson(builder) ?? commandJson.get(name.split(' ')[0]);
|
|
2415
|
-
const description = describe$
|
|
2781
|
+
const description = describe$2(command, name);
|
|
2416
2782
|
entries.push({
|
|
2417
2783
|
...base(methodName),
|
|
2418
2784
|
kind: 'command',
|
|
@@ -2484,6 +2850,74 @@ const COMMAND_TYPES = new Set([
|
|
|
2484
2850
|
}
|
|
2485
2851
|
}
|
|
2486
2852
|
|
|
2853
|
+
const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
|
|
2854
|
+
/** The context menu kind a handler's builder registers; `undefined` for one without a builder, which takes both. */ const contextMenuKind = (meta)=>meta.builder?.type;
|
|
2855
|
+
/** A command as an error names it: `slash command "settings notify"`, `user context menu command "Report"`. */ function describe$1(type, name, kind) {
|
|
2856
|
+
if (type !== enum_index.CommandType.CONTEXT_MENU) return `slash command "${name}"`;
|
|
2857
|
+
const label = kind === discord_js.ApplicationCommandType.User ? 'user ' : kind === discord_js.ApplicationCommandType.Message ? 'message ' : '';
|
|
2858
|
+
return `${label}context menu command "${name}"`;
|
|
2859
|
+
}
|
|
2860
|
+
/** Whether two handlers declared under one name would both be sent the same interactions. */ function overlap(a, b) {
|
|
2861
|
+
if (a.type !== enum_index.CommandType.CONTEXT_MENU) return true;
|
|
2862
|
+
const [kindA, kindB] = [
|
|
2863
|
+
contextMenuKind(a),
|
|
2864
|
+
contextMenuKind(b)
|
|
2865
|
+
];
|
|
2866
|
+
return kindA === undefined || kindB === undefined || kindA === kindB;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Refuses the commands of which only one could ever run or be registered: two handlers of one slash command
|
|
2870
|
+
* name or subcommand path, or of one context menu name and kind, and two builder classes that build one
|
|
2871
|
+
* application command. One builder on a command and its own subcommand paths builds one command, and a user and a
|
|
2872
|
+
* message context menu may share a name. The app's start, `meocord register`, the shard manager and the testing
|
|
2873
|
+
* module call it before anything registers or runs.
|
|
2874
|
+
*
|
|
2875
|
+
* @throws Error naming both handlers, and both builders when two build one command, with what to do instead.
|
|
2876
|
+
*/ function assertDistinctCommands(controllerClasses) {
|
|
2877
|
+
const handlers = new Map();
|
|
2878
|
+
const builders = new Map();
|
|
2879
|
+
// A controller listed twice is one controller
|
|
2880
|
+
for (const controllerClass of new Set(controllerClasses)){
|
|
2881
|
+
const commandMap = themeResolvers.getCommandMap(controllerClass.prototype) ?? {};
|
|
2882
|
+
for (const [name, metas] of Object.entries(commandMap)){
|
|
2883
|
+
for (const meta of metas){
|
|
2884
|
+
// Components route on a pattern, which the component routes check
|
|
2885
|
+
if (themeResolvers.isCustomIdRouted(meta.type)) continue;
|
|
2886
|
+
const here = {
|
|
2887
|
+
controllerClass,
|
|
2888
|
+
meta
|
|
2889
|
+
};
|
|
2890
|
+
const key = `${meta.type}\0${name}`;
|
|
2891
|
+
const earlier = (handlers.get(key) ?? []).find((other)=>overlap(other.meta, meta));
|
|
2892
|
+
if (earlier) {
|
|
2893
|
+
throw new Error(`${where(earlier)} and ${where(here)} both handle the ${describe$1(meta.type, name, contextMenuKind(meta) ?? contextMenuKind(earlier.meta))}, ` + `so only ${where(earlier)} would ever run. Keep one handler for it, or give the other a name or ` + `subcommand path of its own.`);
|
|
2894
|
+
}
|
|
2895
|
+
handlers.set(key, [
|
|
2896
|
+
...handlers.get(key) ?? [],
|
|
2897
|
+
here
|
|
2898
|
+
]);
|
|
2899
|
+
if (!meta.builder || !meta.builderClass) continue;
|
|
2900
|
+
let body;
|
|
2901
|
+
try {
|
|
2902
|
+
body = serialise(meta.builder);
|
|
2903
|
+
} catch {
|
|
2904
|
+
continue;
|
|
2905
|
+
}
|
|
2906
|
+
const registered = registrationKey(body, name);
|
|
2907
|
+
const built = builders.get(registered);
|
|
2908
|
+
if (!built) {
|
|
2909
|
+
builders.set(registered, {
|
|
2910
|
+
...here,
|
|
2911
|
+
name: typeof body.name === 'string' ? body.name : name
|
|
2912
|
+
});
|
|
2913
|
+
} else if (built.meta.builderClass !== meta.builderClass) {
|
|
2914
|
+
throw new Error(`${built.meta.builderClass.name} on ${where(built)} and ${meta.builderClass.name} on ${where(here)} both ` + `build the ${describe$1(meta.type, built.name, contextMenuKind(meta))}, and Discord registers one command ` + `per name and type, so only the first would be. Keep one builder, on a single @Command, and declare ` + `the other handlers with CommandType.${meta.type}.`);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2487
2921
|
/** Where each shard's client keeps the function that runs a `ShardContext.call` in that shard. */ const SHARD_CALL_KEY = Symbol.for('meocord.shardCall');
|
|
2488
2922
|
/** How long `ShardContext.call` waits for a shard to answer. */ const SHARD_CALL_TIMEOUT_MS = 10000;
|
|
2489
2923
|
/**
|
|
@@ -2661,6 +3095,7 @@ exports.SHARD_CALL_KEY = SHARD_CALL_KEY;
|
|
|
2661
3095
|
exports.ShardContext = ShardContext;
|
|
2662
3096
|
exports.appPresenterOf = appPresenterOf;
|
|
2663
3097
|
exports.appStages = appStages;
|
|
3098
|
+
exports.assertDistinctCommands = assertDistinctCommands;
|
|
2664
3099
|
exports.bindAppPresenter = bindAppPresenter;
|
|
2665
3100
|
exports.bindGlobalStages = bindGlobalStages;
|
|
2666
3101
|
exports.buildComponentRoutes = buildComponentRoutes;
|
|
@@ -2677,6 +3112,7 @@ exports.matchMessageRoute = matchMessageRoute;
|
|
|
2677
3112
|
exports.messageParamsFor = messageParamsFor;
|
|
2678
3113
|
exports.ownInteractionListener = ownInteractionListener;
|
|
2679
3114
|
exports.prepareHandlerStages = prepareHandlerStages;
|
|
3115
|
+
exports.registerCommands = registerCommands;
|
|
2680
3116
|
exports.replyWithUserError = replyWithUserError;
|
|
2681
3117
|
exports.runHandler = runHandler;
|
|
2682
3118
|
exports.runReadyHooks = runReadyHooks;
|