chapterjs 0.4.0 → 0.5.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/README.md +3 -1
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +3 -3
- package/dist/cli/dev.js +8 -5
- package/dist/cli/project.d.ts +31 -2
- package/dist/cli/project.js +166 -34
- package/dist/cli/redis.js +14 -2
- package/dist/cli/start.js +11 -1
- package/dist/cli/test.js +108 -16
- package/dist/commands/command.d.ts +140 -5
- package/dist/commands/command.js +17 -2
- package/dist/commands/declaration.d.ts +17 -4
- package/dist/commands/declaration.js +128 -2
- package/dist/commands/router.js +78 -18
- package/dist/commands/tree.d.ts +25 -4
- package/dist/commands/tree.js +95 -26
- package/dist/commands/twins.d.ts +8 -0
- package/dist/commands/twins.js +24 -6
- package/dist/components/button.d.ts +8 -3
- package/dist/components/declaration.d.ts +2 -2
- package/dist/components/declaration.js +16 -12
- package/dist/components/render.js +5 -0
- package/dist/components/router.js +41 -0
- package/dist/components/select.d.ts +12 -5
- package/dist/core/bot.d.ts +7 -0
- package/dist/core/bot.js +44 -0
- package/dist/discord/api.d.ts +2 -0
- package/dist/discord/api.js +2 -0
- package/dist/discord/permissions.d.ts +4 -4
- package/dist/discord/permissions.js +13 -0
- package/dist/events/registry.d.ts +19 -10
- package/dist/events/registry.js +10 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/interactions/dispatch.js +4 -2
- package/dist/loader/loader.d.ts +16 -0
- package/dist/loader/loader.js +52 -7
- package/dist/loader/usage.d.ts +15 -0
- package/dist/loader/usage.js +40 -0
- package/dist/loader/watch.d.ts +5 -3
- package/dist/loader/watch.js +88 -17
- package/dist/messages/commands.js +3 -0
- package/dist/messages/messages.d.ts +1 -0
- package/dist/messages/translate.js +5 -1
- package/dist/presence/declaration.d.ts +49 -2
- package/dist/presence/declaration.js +122 -39
- package/dist/presence/presence.d.ts +65 -6
- package/dist/presence/presence.js +11 -0
- package/dist/presence/rotation.d.ts +32 -0
- package/dist/presence/rotation.js +167 -0
- package/dist/rest/errors.d.ts +0 -1
- package/dist/rest/errors.js +15 -1
- package/dist/rest/rest.js +10 -1
- package/dist/store/file-backend.d.ts +0 -1
- package/dist/store/file-backend.js +18 -1
- package/dist/store/scope.d.ts +6 -1
- package/dist/store/scope.js +9 -3
- package/dist/structures/channel.d.ts +13 -0
- package/dist/structures/channel.js +24 -0
- package/dist/structures/context.d.ts +3 -0
- package/dist/structures/entities.js +1 -0
- package/dist/structures/interaction.d.ts +6 -0
- package/dist/structures/interaction.js +20 -0
- package/dist/structures/message.d.ts +17 -0
- package/dist/structures/message.js +33 -0
- package/dist/structures/wait.d.ts +105 -0
- package/dist/structures/wait.js +85 -0
- package/dist/testing/harness.d.ts +48 -8
- package/dist/testing/harness.js +156 -36
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/smoke.js +112 -1
- package/dist/testing/world.js +51 -2
- package/dist/util/call-site.d.ts +4 -0
- package/dist/util/call-site.js +22 -0
- package/dist/voice/connection.d.ts +2 -0
- package/dist/voice/connection.js +15 -1
- package/dist/voice/usage.d.ts +3 -2
- package/dist/voice/usage.js +7 -19
- package/dist/waiting/usage.d.ts +5 -0
- package/dist/waiting/usage.js +19 -0
- package/dist/waiting/waiters.d.ts +48 -0
- package/dist/waiting/waiters.js +107 -0
- package/package.json +1 -1
|
@@ -40,6 +40,7 @@ const OPTION_KEYS = {
|
|
|
40
40
|
channel: ['channelTypes'],
|
|
41
41
|
};
|
|
42
42
|
const COMMAND_KEYS = [
|
|
43
|
+
'type',
|
|
43
44
|
'name',
|
|
44
45
|
'description',
|
|
45
46
|
'options',
|
|
@@ -52,6 +53,20 @@ const COMMAND_KEYS = [
|
|
|
52
53
|
'autocomplete',
|
|
53
54
|
'run',
|
|
54
55
|
];
|
|
56
|
+
const MENU_KEYS = [
|
|
57
|
+
'type',
|
|
58
|
+
'name',
|
|
59
|
+
'locales',
|
|
60
|
+
'permissions',
|
|
61
|
+
'where',
|
|
62
|
+
'nsfw',
|
|
63
|
+
'ephemeral',
|
|
64
|
+
'cooldown',
|
|
65
|
+
'run',
|
|
66
|
+
];
|
|
67
|
+
const MENU_TYPES = ['message', 'user'];
|
|
68
|
+
/** The longest name of a menu command. */
|
|
69
|
+
const MAX_MENU_NAME = 32;
|
|
55
70
|
/** The kinds of options whose suggestions can come from the bot. */
|
|
56
71
|
const SUGGESTABLE_TYPES = ['string', 'integer', 'number'];
|
|
57
72
|
const fail = (message) => {
|
|
@@ -411,11 +426,91 @@ export function readCommandName(name) {
|
|
|
411
426
|
}
|
|
412
427
|
return parts;
|
|
413
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* The name of a menu command: what the menu shows, 1 to 32 characters,
|
|
431
|
+
* capitals and spaces included.
|
|
432
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#application-command-object-application-command-naming
|
|
433
|
+
*/
|
|
434
|
+
function readMenuName(name, what) {
|
|
435
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
436
|
+
return fail(`${what} has no "name": what the menu shows, like name: 'Report message'.`);
|
|
437
|
+
}
|
|
438
|
+
if (name !== name.trim()) {
|
|
439
|
+
return fail(`The name ${JSON.stringify(name)} of ${what} starts or ends with a space: remove it.`);
|
|
440
|
+
}
|
|
441
|
+
if ([...name].length > MAX_MENU_NAME) {
|
|
442
|
+
return fail(`The name ${JSON.stringify(name)} of ${what} is ${[...name].length} characters long: Discord accepts ${MAX_MENU_NAME} at most.`);
|
|
443
|
+
}
|
|
444
|
+
return name;
|
|
445
|
+
}
|
|
446
|
+
/** Checks the `locales` of a menu command: a name per language. */
|
|
447
|
+
function checkMenuLocales(locales) {
|
|
448
|
+
const names = {};
|
|
449
|
+
if (locales === undefined)
|
|
450
|
+
return names;
|
|
451
|
+
if (!isRecord(locales)) {
|
|
452
|
+
return fail(`"locales" is an object whose keys are languages: locales: { fr: { name: '...' } }`);
|
|
453
|
+
}
|
|
454
|
+
for (const [locale, translation] of Object.entries(locales)) {
|
|
455
|
+
const where = `the "${locale}" translation`;
|
|
456
|
+
if (!LOCALES.includes(locale)) {
|
|
457
|
+
fail(`"${locale}" is not a language Discord knows. Languages are: ${LOCALES.join(', ')}.`);
|
|
458
|
+
}
|
|
459
|
+
if (!isRecord(translation)) {
|
|
460
|
+
return fail(`${where} must be an object like { name: '...' }.`);
|
|
461
|
+
}
|
|
462
|
+
checkKeys(where, translation, ['name']);
|
|
463
|
+
if (translation.name === undefined)
|
|
464
|
+
continue;
|
|
465
|
+
names[locale] = readMenuName(translation.name, `this command in ${where}`);
|
|
466
|
+
}
|
|
467
|
+
return names;
|
|
468
|
+
}
|
|
469
|
+
/** A menu command, checked: one used on a message or a person. */
|
|
470
|
+
function readMenu(config) {
|
|
471
|
+
const type = config.type;
|
|
472
|
+
if (!MENU_TYPES.includes(type)) {
|
|
473
|
+
return fail(`"type" is what a menu command is used on: 'message' (a right click on a message) or 'user' (a right click on a person). Got ${JSON.stringify(type) ?? typeof type}. Leave it out for a slash command.`);
|
|
474
|
+
}
|
|
475
|
+
const what = `this ${type === 'user' ? 'user' : 'message'} menu command`;
|
|
476
|
+
for (const key of ['description', 'options', 'autocomplete']) {
|
|
477
|
+
if (config[key] !== undefined) {
|
|
478
|
+
fail(key === 'description'
|
|
479
|
+
? `A menu command has no "description": Discord only shows its name. Remove it.`
|
|
480
|
+
: `A menu command has no "${key}": it receives what it was used on (${type === 'user' ? 'target' : 'message'}). Remove it, or make it a slash command (no "type").`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
checkKeys('a menu command', config, MENU_KEYS);
|
|
484
|
+
const name = readMenuName(config.name, what);
|
|
485
|
+
if (typeof config.run !== 'function') {
|
|
486
|
+
fail(`This command has no "run": the function to run when someone uses it, like async run({ interaction }) { await interaction.reply('Done!'); }`);
|
|
487
|
+
}
|
|
488
|
+
const where = checkWhere(config.where);
|
|
489
|
+
return Object.freeze({
|
|
490
|
+
type: type,
|
|
491
|
+
names: Object.freeze(checkMenuLocales(config.locales)),
|
|
492
|
+
descriptions: Object.freeze({}),
|
|
493
|
+
optionLocales: Object.freeze({}),
|
|
494
|
+
path: Object.freeze([name]),
|
|
495
|
+
// Its texts are its name and its translations, all in its file.
|
|
496
|
+
described: true,
|
|
497
|
+
description: '',
|
|
498
|
+
options: Object.freeze([]),
|
|
499
|
+
permissions: checkPermissions(config.permissions),
|
|
500
|
+
where,
|
|
501
|
+
nsfw: checkBoolean('"nsfw"', config.nsfw),
|
|
502
|
+
ephemeral: checkBoolean('"ephemeral"', config.ephemeral),
|
|
503
|
+
cooldown: checkCooldown(config.cooldown, where),
|
|
504
|
+
autocomplete: Object.freeze({}),
|
|
505
|
+
run: config.run,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
414
508
|
/**
|
|
415
509
|
* A slash command, declared anywhere in `src/` with `command()`: `name` is
|
|
416
510
|
* what people type after the `/`, with its parents for a subcommand
|
|
417
|
-
* (`'mod ban'` is `/mod ban`, `'mod roles add'` is `/mod roles add`).
|
|
418
|
-
*
|
|
511
|
+
* (`'mod ban'` is `/mod ban`, `'mod roles add'` is `/mod roles add`). With
|
|
512
|
+
* a `type`, a menu command, used on a message or a person. A file may
|
|
513
|
+
* declare several.
|
|
419
514
|
*/
|
|
420
515
|
export const commandDeclaration = {
|
|
421
516
|
one: 'command',
|
|
@@ -427,6 +522,8 @@ export const commandDeclaration = {
|
|
|
427
522
|
if (!isRecord(config)) {
|
|
428
523
|
return fail(`command() needs an object: ${EXAMPLE}`);
|
|
429
524
|
}
|
|
525
|
+
if (config.type !== undefined)
|
|
526
|
+
return readMenu(config);
|
|
430
527
|
checkKeys('a command', config, COMMAND_KEYS);
|
|
431
528
|
const parts = readCommandName(config.name);
|
|
432
529
|
// No description: the language files describe the command, options
|
|
@@ -453,6 +550,7 @@ export const commandDeclaration = {
|
|
|
453
550
|
options.sort((a, b) => Number(b.required === true) - Number(a.required === true));
|
|
454
551
|
const where = checkWhere(config.where);
|
|
455
552
|
return Object.freeze({
|
|
553
|
+
type: 'slash',
|
|
456
554
|
...checkLocales(config.locales, options),
|
|
457
555
|
path: Object.freeze(parts),
|
|
458
556
|
described,
|
|
@@ -470,11 +568,35 @@ export const commandDeclaration = {
|
|
|
470
568
|
};
|
|
471
569
|
/** `command({ ... name: '...' ... })`, as written in a file. */
|
|
472
570
|
const DECLARED = /\bcommand\s*\(\s*\{[^]*?\bname\s*:\s*['"`]([^'"`]+)['"`]/g;
|
|
571
|
+
/** `type: 'message'` or `type: 'user'`: a menu command, which no language file describes. */
|
|
572
|
+
const MENU = /\btype\s*:\s*['"`](?:message|user)['"`]/;
|
|
573
|
+
/**
|
|
574
|
+
* The text of an object written in a file, without what is nested in it:
|
|
575
|
+
* the `type` of an option is not the `type` of the command. `code` starts
|
|
576
|
+
* right after the `{` of the object.
|
|
577
|
+
*/
|
|
578
|
+
function topLevel(code) {
|
|
579
|
+
let depth = 0;
|
|
580
|
+
let kept = '';
|
|
581
|
+
for (const char of code) {
|
|
582
|
+
if (char === '{' || char === '[' || char === '(')
|
|
583
|
+
depth++;
|
|
584
|
+
else if (char === '}' || char === ']' || char === ')') {
|
|
585
|
+
if (depth === 0)
|
|
586
|
+
break;
|
|
587
|
+
depth--;
|
|
588
|
+
}
|
|
589
|
+
else if (depth === 0)
|
|
590
|
+
kept += char;
|
|
591
|
+
}
|
|
592
|
+
return kept;
|
|
593
|
+
}
|
|
473
594
|
/**
|
|
474
595
|
* The names of the commands of a project, found without running its
|
|
475
596
|
* files: a text search for `command({ name: '...' })`. Approximate on
|
|
476
597
|
* purpose, for `sync`, which runs nothing: `dev` and `build` then write
|
|
477
598
|
* the exact ones. Whether a command is described is not known: none is.
|
|
599
|
+
* Menu commands are left out: their texts are always in their file.
|
|
478
600
|
*/
|
|
479
601
|
export async function scanCommands(cwd) {
|
|
480
602
|
const names = new Set();
|
|
@@ -483,6 +605,10 @@ export async function scanCommands(cwd) {
|
|
|
483
605
|
if (!code.includes('command('))
|
|
484
606
|
continue;
|
|
485
607
|
for (const match of code.matchAll(DECLARED)) {
|
|
608
|
+
// The config of this declaration, without its options.
|
|
609
|
+
const open = code.indexOf('{', match.index);
|
|
610
|
+
if (MENU.test(topLevel(code.slice(open + 1))))
|
|
611
|
+
continue;
|
|
486
612
|
const name = match[1].trim().split(/\s+/).join(' ');
|
|
487
613
|
if (name !== '')
|
|
488
614
|
names.add(name);
|
package/dist/commands/router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Runs the command someone used: finds its file, turns what Discord sent
|
|
2
2
|
// into what `run` receives, and makes sure the person always gets an answer.
|
|
3
|
-
import { Permissions } from '../discord/permissions.js';
|
|
3
|
+
import { Permissions, permissionWords } from '../discord/permissions.js';
|
|
4
4
|
import { ApplicationCommandOptionType, ApplicationCommandType, } from '../discord/types/application-command.js';
|
|
5
5
|
import { InteractionType, } from '../discord/types/interaction.js';
|
|
6
6
|
import { timestamp, TimestampStyle } from '../discord/formatting.js';
|
|
@@ -11,13 +11,18 @@ import { CommandInteraction } from '../structures/interaction.js';
|
|
|
11
11
|
import { remember } from '../structures/known.js';
|
|
12
12
|
import { toCamelCase } from '../util/case.js';
|
|
13
13
|
import { suggest } from './autocomplete.js';
|
|
14
|
-
import {
|
|
14
|
+
import { commandKey, describeCommand } from './tree.js';
|
|
15
15
|
/**
|
|
16
16
|
* How long a refusal for a cooldown may wait before deleting itself: the
|
|
17
17
|
* token of an interaction lives 15 minutes, with room for the request.
|
|
18
18
|
* @see https://docs.discord.com/developers/interactions/receiving-and-responding#interaction-object
|
|
19
19
|
*/
|
|
20
20
|
const DELETE_WITHIN = 14 * 60_000;
|
|
21
|
+
/** The menu commands, by the type Discord sends. */
|
|
22
|
+
const MENUS = {
|
|
23
|
+
[ApplicationCommandType.User]: 'user',
|
|
24
|
+
[ApplicationCommandType.Message]: 'message',
|
|
25
|
+
};
|
|
21
26
|
const SUB = ApplicationCommandOptionType.SubCommand;
|
|
22
27
|
const GROUP = ApplicationCommandOptionType.SubCommandGroup;
|
|
23
28
|
export class CommandRouter {
|
|
@@ -35,7 +40,7 @@ export class CommandRouter {
|
|
|
35
40
|
set(entries) {
|
|
36
41
|
const commands = new Map();
|
|
37
42
|
for (const entry of entries) {
|
|
38
|
-
commands.set(entry.command
|
|
43
|
+
commands.set(commandKey(entry.command), entry);
|
|
39
44
|
}
|
|
40
45
|
this.#commands = commands;
|
|
41
46
|
}
|
|
@@ -52,20 +57,35 @@ export class CommandRouter {
|
|
|
52
57
|
return null;
|
|
53
58
|
}
|
|
54
59
|
const data = raw.data;
|
|
55
|
-
if (!data
|
|
60
|
+
if (!data)
|
|
56
61
|
return null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
const menu = MENUS[data.type];
|
|
63
|
+
if (data.type !== ApplicationCommandType.ChatInput && !menu)
|
|
64
|
+
return null;
|
|
65
|
+
let path;
|
|
66
|
+
let given = [];
|
|
67
|
+
if (menu) {
|
|
68
|
+
// Discord sends no autocomplete for a command without options.
|
|
69
|
+
if (autocomplete)
|
|
70
|
+
return null;
|
|
71
|
+
path = [data.name];
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// `/mod roles add` comes as a command with nested options.
|
|
75
|
+
path = [data.name];
|
|
76
|
+
given = data.options ?? [];
|
|
77
|
+
while (given[0] && (given[0].type === SUB || given[0].type === GROUP)) {
|
|
78
|
+
path.push(given[0].name);
|
|
79
|
+
given = given[0].options ?? [];
|
|
80
|
+
}
|
|
63
81
|
}
|
|
64
|
-
const
|
|
82
|
+
const type = menu ?? 'slash';
|
|
83
|
+
const key = commandKey({ type, path });
|
|
84
|
+
const entry = this.#commands.get(key);
|
|
65
85
|
if (autocomplete) {
|
|
66
86
|
return suggest(ctx, raw, entry, given, this.#options, this.#warnedOnce);
|
|
67
87
|
}
|
|
68
|
-
const name =
|
|
88
|
+
const name = describeCommand({ type, path });
|
|
69
89
|
const author = authorOf(raw);
|
|
70
90
|
if (!author)
|
|
71
91
|
return null;
|
|
@@ -73,7 +93,7 @@ export class CommandRouter {
|
|
|
73
93
|
const user = ctx.entities.user(author.user);
|
|
74
94
|
const interaction = new CommandInteraction(ctx, rest, user, {
|
|
75
95
|
ephemeral: entry?.command.ephemeral ?? false,
|
|
76
|
-
commandName: name,
|
|
96
|
+
commandName: menu ? data.name : name,
|
|
77
97
|
});
|
|
78
98
|
if (!entry) {
|
|
79
99
|
// Still registered on Discord, but its file is gone or broken.
|
|
@@ -91,13 +111,13 @@ export class CommandRouter {
|
|
|
91
111
|
const missing = new Permissions(author.member.permissions ?? '0').missing(command.permissions);
|
|
92
112
|
if (missing.length > 0) {
|
|
93
113
|
return refuse(interaction, 'needsPermissions', {
|
|
94
|
-
permissions: missing.join(', '),
|
|
114
|
+
permissions: missing.map(permissionWords).join(', '),
|
|
95
115
|
count: missing.length,
|
|
96
116
|
});
|
|
97
117
|
}
|
|
98
118
|
}
|
|
99
119
|
// Used too soon: the person is told when, and nothing runs.
|
|
100
|
-
const again = checkCooldown(this.#cooldowns,
|
|
120
|
+
const again = checkCooldown(this.#cooldowns, key, command.cooldown, {
|
|
101
121
|
user: user.id,
|
|
102
122
|
channel: place.channel.id,
|
|
103
123
|
guild: place.guild?.id,
|
|
@@ -105,9 +125,15 @@ export class CommandRouter {
|
|
|
105
125
|
if (again > 0) {
|
|
106
126
|
return this.#refuseUntil(interaction, again);
|
|
107
127
|
}
|
|
108
|
-
|
|
128
|
+
// What `run` receives besides the place: the options of a slash
|
|
129
|
+
// command, or what a menu command was used on.
|
|
130
|
+
let received;
|
|
109
131
|
try {
|
|
110
|
-
|
|
132
|
+
received = menu
|
|
133
|
+
? this.#readTarget(ctx, raw, data, menu)
|
|
134
|
+
: {
|
|
135
|
+
options: Object.freeze(this.#readOptions(ctx, raw, data, given, entry)),
|
|
136
|
+
};
|
|
111
137
|
}
|
|
112
138
|
catch (error) {
|
|
113
139
|
this.#options.onError(file, error);
|
|
@@ -120,7 +146,7 @@ export class CommandRouter {
|
|
|
120
146
|
});
|
|
121
147
|
const context = Object.freeze({
|
|
122
148
|
interaction,
|
|
123
|
-
|
|
149
|
+
...received,
|
|
124
150
|
user,
|
|
125
151
|
...placeContext(place, command.where),
|
|
126
152
|
// `t` speaks the language of who will read the answer.
|
|
@@ -164,6 +190,40 @@ export class CommandRouter {
|
|
|
164
190
|
timer.unref();
|
|
165
191
|
return refused;
|
|
166
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* What a menu command was used on: the person (`target`, and
|
|
195
|
+
* `targetMember` in a server, `null` when they are not in it), or the
|
|
196
|
+
* message. Discord sends it whole in `resolved`, keyed by `target_id`.
|
|
197
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#user-commands
|
|
198
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#message-commands
|
|
199
|
+
*/
|
|
200
|
+
#readTarget(ctx, raw, data, type) {
|
|
201
|
+
const id = data.target_id;
|
|
202
|
+
const resolved = data.resolved ?? {};
|
|
203
|
+
const guildId = raw.guild_id;
|
|
204
|
+
if (type === 'message') {
|
|
205
|
+
const message = id ? resolved.messages?.[id] : undefined;
|
|
206
|
+
if (!message) {
|
|
207
|
+
throw new Error(`Discord sent the message menu "${data.name}" without the message it was used on.`);
|
|
208
|
+
}
|
|
209
|
+
return { message: ctx.entities.message(message, guildId) };
|
|
210
|
+
}
|
|
211
|
+
const rawUser = id ? resolved.users?.[id] : undefined;
|
|
212
|
+
if (!rawUser) {
|
|
213
|
+
throw new Error(`Discord sent the user menu "${data.name}" without the person it was used on.`);
|
|
214
|
+
}
|
|
215
|
+
const target = ctx.entities.user(rawUser);
|
|
216
|
+
// Outside a server, there is no member.
|
|
217
|
+
if (!guildId)
|
|
218
|
+
return { target, targetMember: null };
|
|
219
|
+
const member = resolved.members?.[id];
|
|
220
|
+
return {
|
|
221
|
+
target,
|
|
222
|
+
targetMember: member && guildId
|
|
223
|
+
? ctx.entities.member(guildId, member, rawUser)
|
|
224
|
+
: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
167
227
|
/** The value of each declared option, as structures where it applies. */
|
|
168
228
|
#readOptions(ctx, raw, data, given, { command }) {
|
|
169
229
|
const values = new Map(given.map(option => [option.name, option.value]));
|
package/dist/commands/tree.d.ts
CHANGED
|
@@ -16,14 +16,34 @@ export interface CommandConflict {
|
|
|
16
16
|
export type CommandPayload = BulkOverwriteGuildApplicationCommandsJSONParams;
|
|
17
17
|
/** `/mod ban` for `['mod', 'ban']`. */
|
|
18
18
|
export declare const commandName: (path: readonly string[]) => string;
|
|
19
|
+
/**
|
|
20
|
+
* A command as messages name it: `/mod ban` for a slash command, `the
|
|
21
|
+
* message menu "Report"` for a menu command.
|
|
22
|
+
*/
|
|
23
|
+
export declare const describeCommand: (command: Pick<LoadedCommand, 'type' | 'path'>) => string;
|
|
24
|
+
/** The same text, starting with a capital: the start of a sentence. */
|
|
25
|
+
export declare const capitalize: (text: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* What tells one command from another: its path for a slash command, its
|
|
28
|
+
* type and name for a menu command (Discord lets a slash command, a
|
|
29
|
+
* message menu and a user menu share a name).
|
|
30
|
+
*/
|
|
31
|
+
export declare const commandKey: (command: Pick<LoadedCommand, 'type' | 'path'>) => string;
|
|
32
|
+
/**
|
|
33
|
+
* How many menu commands of each type Discord accepts, for everyone and
|
|
34
|
+
* in each server.
|
|
35
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#registering-a-command
|
|
36
|
+
*/
|
|
37
|
+
export declare const MAX_MENUS = 15;
|
|
19
38
|
/**
|
|
20
39
|
* Finds the declarations that can't be commands together, and leaves them
|
|
21
40
|
* out:
|
|
22
|
-
* - two commands with the same name;
|
|
41
|
+
* - two commands with the same name (and type);
|
|
23
42
|
* - a command that also has subcommands: Discord only lets people use the
|
|
24
|
-
* subcommands
|
|
43
|
+
* subcommands;
|
|
44
|
+
* - a menu command beyond the 15 of its type Discord accepts.
|
|
25
45
|
*/
|
|
26
|
-
export declare function findConflicts(entries: readonly CommandEntry[]): {
|
|
46
|
+
export declare function findConflicts(entries: readonly CommandEntry[], running?: ReadonlySet<string>): {
|
|
27
47
|
valid: CommandEntry[];
|
|
28
48
|
conflicts: CommandConflict[];
|
|
29
49
|
};
|
|
@@ -34,7 +54,8 @@ export declare function findConflicts(entries: readonly CommandEntry[]): {
|
|
|
34
54
|
*/
|
|
35
55
|
export declare const privateOnly: (entries: readonly CommandEntry[]) => CommandEntry[];
|
|
36
56
|
/**
|
|
37
|
-
* What to register for a set of commands
|
|
57
|
+
* What to register for a set of commands: the slash commands, then the
|
|
58
|
+
* menu commands. `guild` is for commands of one
|
|
38
59
|
* server (dev), where Discord takes no contexts: the commands that only
|
|
39
60
|
* work in private messages are left out, they could never run there.
|
|
40
61
|
*
|
package/dist/commands/tree.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ApplicationCommandOptionType, ApplicationCommandType, } from '../discord/types/application-command.js';
|
|
5
5
|
import { ApplicationIntegrationType } from '../discord/types/application.js';
|
|
6
6
|
import { InteractionContextType } from '../discord/types/interaction.js';
|
|
7
|
-
import { siteName } from '../loader/loader.js';
|
|
7
|
+
import { bySite, siteName } from '../loader/loader.js';
|
|
8
8
|
const TYPES = {
|
|
9
9
|
string: ApplicationCommandOptionType.String,
|
|
10
10
|
integer: ApplicationCommandOptionType.Integer,
|
|
@@ -76,26 +76,46 @@ function toOption(option, locales, autocomplete) {
|
|
|
76
76
|
}
|
|
77
77
|
/** `/mod ban` for `['mod', 'ban']`. */
|
|
78
78
|
export const commandName = (path) => `/${path.join(' ')}`;
|
|
79
|
+
/**
|
|
80
|
+
* A command as messages name it: `/mod ban` for a slash command, `the
|
|
81
|
+
* message menu "Report"` for a menu command.
|
|
82
|
+
*/
|
|
83
|
+
export const describeCommand = (command) => command.type === 'slash'
|
|
84
|
+
? commandName(command.path)
|
|
85
|
+
: `the ${command.type} menu "${command.path[0]}"`;
|
|
86
|
+
/** The same text, starting with a capital: the start of a sentence. */
|
|
87
|
+
export const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
88
|
+
/**
|
|
89
|
+
* What tells one command from another: its path for a slash command, its
|
|
90
|
+
* type and name for a menu command (Discord lets a slash command, a
|
|
91
|
+
* message menu and a user menu share a name).
|
|
92
|
+
*/
|
|
93
|
+
export const commandKey = (command) => command.type === 'slash'
|
|
94
|
+
? command.path.join(' ')
|
|
95
|
+
: `${command.type}:${command.path[0]}`;
|
|
96
|
+
/**
|
|
97
|
+
* How many menu commands of each type Discord accepts, for everyone and
|
|
98
|
+
* in each server.
|
|
99
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#registering-a-command
|
|
100
|
+
*/
|
|
101
|
+
export const MAX_MENUS = 15;
|
|
102
|
+
const MENU_TYPES = {
|
|
103
|
+
user: ApplicationCommandType.User,
|
|
104
|
+
message: ApplicationCommandType.Message,
|
|
105
|
+
};
|
|
79
106
|
/**
|
|
80
107
|
* Finds the declarations that can't be commands together, and leaves them
|
|
81
108
|
* out:
|
|
82
|
-
* - two commands with the same name;
|
|
109
|
+
* - two commands with the same name (and type);
|
|
83
110
|
* - a command that also has subcommands: Discord only lets people use the
|
|
84
|
-
* subcommands
|
|
111
|
+
* subcommands;
|
|
112
|
+
* - a menu command beyond the 15 of its type Discord accepts.
|
|
85
113
|
*/
|
|
86
|
-
export function findConflicts(entries) {
|
|
114
|
+
export function findConflicts(entries, running) {
|
|
87
115
|
const conflicts = [];
|
|
88
116
|
const seen = new Map();
|
|
89
|
-
const unique =
|
|
90
|
-
|
|
91
|
-
? a.export < b.export
|
|
92
|
-
? -1
|
|
93
|
-
: 1
|
|
94
|
-
: a.file < b.file
|
|
95
|
-
? -1
|
|
96
|
-
: 1)
|
|
97
|
-
.filter(entry => {
|
|
98
|
-
const key = entry.command.path.join(' ');
|
|
117
|
+
const unique = bySite(entries, running).filter(entry => {
|
|
118
|
+
const key = commandKey(entry.command);
|
|
99
119
|
const first = seen.get(key);
|
|
100
120
|
if (!first) {
|
|
101
121
|
seen.set(key, entry);
|
|
@@ -104,13 +124,25 @@ export function findConflicts(entries) {
|
|
|
104
124
|
conflicts.push({
|
|
105
125
|
file: entry.file,
|
|
106
126
|
export: entry.export,
|
|
107
|
-
message: `${
|
|
127
|
+
message: `${capitalize(describeCommand(entry.command))} is already declared in ${siteName(first)}: two ${entry.command.type === 'slash' ? 'commands' : `${entry.command.type} menu commands`} can't have the same name.`,
|
|
108
128
|
});
|
|
109
129
|
return false;
|
|
110
130
|
});
|
|
131
|
+
const menus = { user: 0, message: 0 };
|
|
111
132
|
const valid = unique.filter(entry => {
|
|
112
|
-
const { path } = entry.command;
|
|
133
|
+
const { path, type } = entry.command;
|
|
134
|
+
if (type !== 'slash') {
|
|
135
|
+
if (++menus[type] <= MAX_MENUS)
|
|
136
|
+
return true;
|
|
137
|
+
conflicts.push({
|
|
138
|
+
file: entry.file,
|
|
139
|
+
export: entry.export,
|
|
140
|
+
message: `${capitalize(describeCommand(entry.command))} is one ${type} menu command too many: Discord accepts ${MAX_MENUS} at most. Remove one, or make it a slash command (no "type").`,
|
|
141
|
+
});
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
113
144
|
const child = unique.find(other => other !== entry &&
|
|
145
|
+
other.command.type === 'slash' &&
|
|
114
146
|
other.command.path.length > path.length &&
|
|
115
147
|
path.every((part, index) => other.command.path[index] === part));
|
|
116
148
|
if (!child)
|
|
@@ -131,7 +163,8 @@ export function findConflicts(entries) {
|
|
|
131
163
|
*/
|
|
132
164
|
export const privateOnly = (entries) => entries.filter(entry => entry.command.where === 'dm');
|
|
133
165
|
/**
|
|
134
|
-
* What to register for a set of commands
|
|
166
|
+
* What to register for a set of commands: the slash commands, then the
|
|
167
|
+
* menu commands. `guild` is for commands of one
|
|
135
168
|
* server (dev), where Discord takes no contexts: the commands that only
|
|
136
169
|
* work in private messages are left out, they could never run there.
|
|
137
170
|
*
|
|
@@ -148,14 +181,19 @@ export function buildCommands(entries, { guild }) {
|
|
|
148
181
|
...(command.options.length > 0 ? { options: optionsOf(command) } : {}),
|
|
149
182
|
}, command.names, command.descriptions);
|
|
150
183
|
const roots = new Map();
|
|
184
|
+
const menus = [];
|
|
151
185
|
for (const { command } of entries) {
|
|
152
186
|
if (guild && command.where === 'dm')
|
|
153
187
|
continue;
|
|
188
|
+
if (command.type !== 'slash') {
|
|
189
|
+
menus.push(command);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
154
192
|
const list = roots.get(command.path[0]) ?? [];
|
|
155
193
|
list.push(command);
|
|
156
194
|
roots.set(command.path[0], list);
|
|
157
195
|
}
|
|
158
|
-
|
|
196
|
+
const slash = [...roots]
|
|
159
197
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
160
198
|
.map(([name, commands]) => {
|
|
161
199
|
const [first] = commands;
|
|
@@ -175,14 +213,7 @@ export function buildCommands(entries, { guild }) {
|
|
|
175
213
|
integration_types: [ApplicationIntegrationType.GuildInstall],
|
|
176
214
|
// A command is offered wherever one of its subcommands
|
|
177
215
|
// works; each one refuses the place it does not work in.
|
|
178
|
-
contexts:
|
|
179
|
-
...(commands.some(command => command.where !== 'dm')
|
|
180
|
-
? [InteractionContextType.Guild]
|
|
181
|
-
: []),
|
|
182
|
-
...(commands.some(command => command.where !== 'guild')
|
|
183
|
-
? [InteractionContextType.BotDm]
|
|
184
|
-
: []),
|
|
185
|
-
],
|
|
216
|
+
contexts: contextsOf(commands),
|
|
186
217
|
}),
|
|
187
218
|
...(commands.some(command => command.nsfw) ? { nsfw: true } : {}),
|
|
188
219
|
}, leaf?.names, leaf?.descriptions);
|
|
@@ -213,4 +244,42 @@ export function buildCommands(entries, { guild }) {
|
|
|
213
244
|
payload.options = options;
|
|
214
245
|
return payload;
|
|
215
246
|
});
|
|
247
|
+
return [
|
|
248
|
+
...slash,
|
|
249
|
+
...menus.sort(byTypeAndName).map(menu => toMenu(menu, guild)),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
/** Menu commands in a stable order: by type, then by name. */
|
|
253
|
+
const byTypeAndName = (a, b) => commandKey(a) < commandKey(b) ? -1 : 1;
|
|
254
|
+
/**
|
|
255
|
+
* What Discord registers for a menu command: a name and no description
|
|
256
|
+
* ("The `description` field is not allowed when creating user commands"),
|
|
257
|
+
* nor options.
|
|
258
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#user-commands
|
|
259
|
+
* @see https://docs.discord.com/developers/interactions/application-commands#message-commands
|
|
260
|
+
*/
|
|
261
|
+
function toMenu(command, guild) {
|
|
262
|
+
const names = localized(command.names);
|
|
263
|
+
return {
|
|
264
|
+
type: MENU_TYPES[command.type],
|
|
265
|
+
name: command.path[0],
|
|
266
|
+
...(names ? { name_localizations: names } : {}),
|
|
267
|
+
default_member_permissions: command.permissions === 0n ? null : String(command.permissions),
|
|
268
|
+
...(guild
|
|
269
|
+
? {}
|
|
270
|
+
: {
|
|
271
|
+
integration_types: [ApplicationIntegrationType.GuildInstall],
|
|
272
|
+
contexts: contextsOf([command]),
|
|
273
|
+
}),
|
|
274
|
+
...(command.nsfw ? { nsfw: true } : {}),
|
|
275
|
+
};
|
|
216
276
|
}
|
|
277
|
+
/** Where Discord offers a command: wherever one of these works. */
|
|
278
|
+
const contextsOf = (commands) => [
|
|
279
|
+
...(commands.some(command => command.where !== 'dm')
|
|
280
|
+
? [InteractionContextType.Guild]
|
|
281
|
+
: []),
|
|
282
|
+
...(commands.some(command => command.where !== 'guild')
|
|
283
|
+
? [InteractionContextType.BotDm]
|
|
284
|
+
: []),
|
|
285
|
+
];
|
package/dist/commands/twins.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { RawApplicationCommand } from '../discord/types/application-command.js';
|
|
2
2
|
import type { CommandPayload } from './tree.js';
|
|
3
|
+
/**
|
|
4
|
+
* A registered command as messages name it: `/ping`, or `the message menu
|
|
5
|
+
* "Report"`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function shownName(command: {
|
|
8
|
+
type?: number;
|
|
9
|
+
name: string;
|
|
10
|
+
}): string;
|
|
3
11
|
/** Whether a command of the project is the one Discord has for everyone. */
|
|
4
12
|
export declare function sameDefinition(ours: CommandPayload, theirs: RawApplicationCommand): boolean;
|
|
5
13
|
/**
|
package/dist/commands/twins.js
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
// twice in the dev server. So the dev server only gets the commands that
|
|
5
5
|
// are not already there for everyone, exactly as the project declares them.
|
|
6
6
|
// https://docs.discord.com/developers/interactions/application-commands#registering-a-command
|
|
7
|
+
import { ApplicationCommandType } from '../discord/types/application-command.js';
|
|
8
|
+
/**
|
|
9
|
+
* A registered command as messages name it: `/ping`, or `the message menu
|
|
10
|
+
* "Report"`.
|
|
11
|
+
*/
|
|
12
|
+
export function shownName(command) {
|
|
13
|
+
switch (command.type ?? ApplicationCommandType.ChatInput) {
|
|
14
|
+
case ApplicationCommandType.User:
|
|
15
|
+
return `the user menu "${command.name}"`;
|
|
16
|
+
case ApplicationCommandType.Message:
|
|
17
|
+
return `the message menu "${command.name}"`;
|
|
18
|
+
default:
|
|
19
|
+
return `/${command.name}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
7
22
|
/** What says how a command looks and works: the rest is not compared. */
|
|
8
23
|
const DEFINITION = new Set([
|
|
9
24
|
'type',
|
|
@@ -25,6 +40,9 @@ const DEFINITION = new Set([
|
|
|
25
40
|
'autocomplete',
|
|
26
41
|
]);
|
|
27
42
|
const isEmpty = (value) => value === null ||
|
|
43
|
+
// The description of a menu command: "an empty string (instead of
|
|
44
|
+
// null) when fetching commands", and never sent.
|
|
45
|
+
value === '' ||
|
|
28
46
|
value === undefined ||
|
|
29
47
|
value === false ||
|
|
30
48
|
(Array.isArray(value) && value.length === 0) ||
|
|
@@ -58,20 +76,20 @@ export function sameDefinition(ours, theirs) {
|
|
|
58
76
|
* next to the one in production until that one is updated.
|
|
59
77
|
*/
|
|
60
78
|
export function withoutGlobalTwins(commands, global) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
// A slash command and a menu command may share a name.
|
|
80
|
+
const keyOf = (command) => `${command.type ?? 1}:${command.name}`;
|
|
81
|
+
const byName = new Map(global.map(command => [keyOf(command), command]));
|
|
64
82
|
const kept = [];
|
|
65
83
|
const twins = [];
|
|
66
84
|
const changed = [];
|
|
67
85
|
for (const command of commands) {
|
|
68
|
-
const other = byName.get(command
|
|
86
|
+
const other = byName.get(keyOf(command));
|
|
69
87
|
if (other && sameDefinition(command, other)) {
|
|
70
|
-
twins.push(command
|
|
88
|
+
twins.push(shownName(command));
|
|
71
89
|
continue;
|
|
72
90
|
}
|
|
73
91
|
if (other)
|
|
74
|
-
changed.push(command
|
|
92
|
+
changed.push(shownName(command));
|
|
75
93
|
kept.push(command);
|
|
76
94
|
}
|
|
77
95
|
return { kept, twins, changed };
|