chapterjs 0.3.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.
Files changed (115) hide show
  1. package/README.md +5 -3
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +17 -4
  5. package/dist/cli/dev.js +37 -6
  6. package/dist/cli/env.d.ts +5 -0
  7. package/dist/cli/env.js +39 -3
  8. package/dist/cli/project.d.ts +61 -9
  9. package/dist/cli/project.js +250 -44
  10. package/dist/cli/redis.d.ts +23 -0
  11. package/dist/cli/redis.js +69 -0
  12. package/dist/cli/start.js +54 -18
  13. package/dist/cli/test.js +112 -18
  14. package/dist/commands/command.d.ts +140 -5
  15. package/dist/commands/command.js +17 -2
  16. package/dist/commands/declaration.d.ts +17 -4
  17. package/dist/commands/declaration.js +128 -2
  18. package/dist/commands/router.js +78 -18
  19. package/dist/commands/tree.d.ts +25 -4
  20. package/dist/commands/tree.js +95 -26
  21. package/dist/commands/twins.d.ts +8 -0
  22. package/dist/commands/twins.js +24 -6
  23. package/dist/components/button.d.ts +8 -3
  24. package/dist/components/declaration.d.ts +2 -2
  25. package/dist/components/declaration.js +16 -12
  26. package/dist/components/render.js +5 -0
  27. package/dist/components/router.js +41 -0
  28. package/dist/components/select.d.ts +12 -5
  29. package/dist/core/bot.d.ts +7 -0
  30. package/dist/core/bot.js +44 -0
  31. package/dist/discord/api.d.ts +2 -0
  32. package/dist/discord/api.js +2 -0
  33. package/dist/discord/permissions.d.ts +4 -4
  34. package/dist/discord/permissions.js +13 -0
  35. package/dist/events/registry.d.ts +265 -243
  36. package/dist/events/registry.js +131 -100
  37. package/dist/index.d.ts +7 -4
  38. package/dist/index.js +2 -1
  39. package/dist/interactions/dispatch.js +4 -2
  40. package/dist/loader/loader.d.ts +16 -0
  41. package/dist/loader/loader.js +52 -7
  42. package/dist/loader/usage.d.ts +15 -0
  43. package/dist/loader/usage.js +40 -0
  44. package/dist/loader/watch.d.ts +5 -3
  45. package/dist/loader/watch.js +88 -17
  46. package/dist/messages/commands.js +3 -0
  47. package/dist/messages/messages.d.ts +1 -0
  48. package/dist/messages/translate.js +5 -1
  49. package/dist/presence/declaration.d.ts +49 -2
  50. package/dist/presence/declaration.js +122 -39
  51. package/dist/presence/presence.d.ts +65 -6
  52. package/dist/presence/presence.js +11 -0
  53. package/dist/presence/rotation.d.ts +32 -0
  54. package/dist/presence/rotation.js +167 -0
  55. package/dist/ranking/declaration.d.ts +12 -0
  56. package/dist/ranking/declaration.js +48 -0
  57. package/dist/ranking/ranking.d.ts +102 -0
  58. package/dist/ranking/ranking.js +129 -0
  59. package/dist/redis/client.d.ts +36 -0
  60. package/dist/redis/client.js +252 -0
  61. package/dist/redis/protocol.d.ts +22 -0
  62. package/dist/redis/protocol.js +99 -0
  63. package/dist/redis/url.d.ts +24 -0
  64. package/dist/redis/url.js +50 -0
  65. package/dist/rest/errors.d.ts +0 -1
  66. package/dist/rest/errors.js +15 -1
  67. package/dist/rest/rest.js +10 -1
  68. package/dist/store/backend.d.ts +49 -4
  69. package/dist/store/backend.js +66 -11
  70. package/dist/store/declaration.d.ts +3 -0
  71. package/dist/store/declaration.js +8 -3
  72. package/dist/store/file-backend.d.ts +3 -2
  73. package/dist/store/file-backend.js +47 -1
  74. package/dist/store/ipc-backend.d.ts +24 -1
  75. package/dist/store/ipc-backend.js +49 -0
  76. package/dist/store/memory-backend.d.ts +4 -2
  77. package/dist/store/memory-backend.js +27 -1
  78. package/dist/store/ranking-backend.d.ts +45 -0
  79. package/dist/store/ranking-backend.js +82 -0
  80. package/dist/store/redis-backend.d.ts +38 -0
  81. package/dist/store/redis-backend.js +245 -0
  82. package/dist/store/redis-ranking.d.ts +15 -0
  83. package/dist/store/redis-ranking.js +69 -0
  84. package/dist/store/scope.d.ts +17 -1
  85. package/dist/store/scope.js +32 -3
  86. package/dist/store/store.d.ts +39 -5
  87. package/dist/store/store.js +79 -32
  88. package/dist/structures/channel.d.ts +13 -0
  89. package/dist/structures/channel.js +24 -0
  90. package/dist/structures/context.d.ts +3 -0
  91. package/dist/structures/entities.js +1 -0
  92. package/dist/structures/interaction.d.ts +6 -0
  93. package/dist/structures/interaction.js +20 -0
  94. package/dist/structures/invite.d.ts +6 -0
  95. package/dist/structures/invite.js +11 -0
  96. package/dist/structures/message.d.ts +17 -0
  97. package/dist/structures/message.js +33 -0
  98. package/dist/structures/wait.d.ts +105 -0
  99. package/dist/structures/wait.js +85 -0
  100. package/dist/testing/harness.d.ts +48 -8
  101. package/dist/testing/harness.js +156 -36
  102. package/dist/testing/index.d.ts +1 -1
  103. package/dist/testing/smoke.js +112 -1
  104. package/dist/testing/world.js +51 -2
  105. package/dist/util/call-site.d.ts +4 -0
  106. package/dist/util/call-site.js +22 -0
  107. package/dist/voice/connection.d.ts +2 -0
  108. package/dist/voice/connection.js +15 -1
  109. package/dist/voice/usage.d.ts +3 -2
  110. package/dist/voice/usage.js +7 -19
  111. package/dist/waiting/usage.d.ts +5 -0
  112. package/dist/waiting/usage.js +19 -0
  113. package/dist/waiting/waiters.d.ts +48 -0
  114. package/dist/waiting/waiters.js +107 -0
  115. 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`). A
418
- * file may declare several.
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);
@@ -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 { commandName } from './tree.js';
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.path.join(' '), entry);
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 || data.type !== ApplicationCommandType.ChatInput)
60
+ if (!data)
56
61
  return null;
57
- // `/mod roles add` comes as a command with nested options.
58
- const path = [data.name];
59
- let given = data.options ?? [];
60
- while (given[0] && (given[0].type === SUB || given[0].type === GROUP)) {
61
- path.push(given[0].name);
62
- given = given[0].options ?? [];
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 entry = this.#commands.get(path.join(' '));
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 = commandName(path);
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, path.join(' '), command.cooldown, {
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
- let options;
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
- options = this.#readOptions(ctx, raw, data, given, entry);
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
- options: Object.freeze(options),
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]));
@@ -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. `guild` is for commands of one
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
  *
@@ -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 = [...entries]
90
- .sort((a, b) => a.file === b.file
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: `${commandName(entry.command.path)} is already declared in ${siteName(first)}: two commands can't have the same name.`,
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. `guild` is for commands of one
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
- return [...roots]
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
+ ];
@@ -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
  /**
@@ -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
- const byName = new Map(global
62
- .filter(command => (command.type ?? 1) === 1)
63
- .map(command => [command.name, command]));
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.name);
86
+ const other = byName.get(keyOf(command));
69
87
  if (other && sameDefinition(command, other)) {
70
- twins.push(command.name);
88
+ twins.push(shownName(command));
71
89
  continue;
72
90
  }
73
91
  if (other)
74
- changed.push(command.name);
92
+ changed.push(shownName(command));
75
93
  kept.push(command);
76
94
  }
77
95
  return { kept, twins, changed };