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.
Files changed (84) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +3 -3
  5. package/dist/cli/dev.js +8 -5
  6. package/dist/cli/project.d.ts +31 -2
  7. package/dist/cli/project.js +166 -34
  8. package/dist/cli/redis.js +14 -2
  9. package/dist/cli/start.js +11 -1
  10. package/dist/cli/test.js +108 -16
  11. package/dist/commands/command.d.ts +140 -5
  12. package/dist/commands/command.js +17 -2
  13. package/dist/commands/declaration.d.ts +17 -4
  14. package/dist/commands/declaration.js +128 -2
  15. package/dist/commands/router.js +78 -18
  16. package/dist/commands/tree.d.ts +25 -4
  17. package/dist/commands/tree.js +95 -26
  18. package/dist/commands/twins.d.ts +8 -0
  19. package/dist/commands/twins.js +24 -6
  20. package/dist/components/button.d.ts +8 -3
  21. package/dist/components/declaration.d.ts +2 -2
  22. package/dist/components/declaration.js +16 -12
  23. package/dist/components/render.js +5 -0
  24. package/dist/components/router.js +41 -0
  25. package/dist/components/select.d.ts +12 -5
  26. package/dist/core/bot.d.ts +7 -0
  27. package/dist/core/bot.js +44 -0
  28. package/dist/discord/api.d.ts +2 -0
  29. package/dist/discord/api.js +2 -0
  30. package/dist/discord/permissions.d.ts +4 -4
  31. package/dist/discord/permissions.js +13 -0
  32. package/dist/events/registry.d.ts +19 -10
  33. package/dist/events/registry.js +10 -4
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.js +1 -1
  36. package/dist/interactions/dispatch.js +4 -2
  37. package/dist/loader/loader.d.ts +16 -0
  38. package/dist/loader/loader.js +52 -7
  39. package/dist/loader/usage.d.ts +15 -0
  40. package/dist/loader/usage.js +40 -0
  41. package/dist/loader/watch.d.ts +5 -3
  42. package/dist/loader/watch.js +88 -17
  43. package/dist/messages/commands.js +3 -0
  44. package/dist/messages/messages.d.ts +1 -0
  45. package/dist/messages/translate.js +5 -1
  46. package/dist/presence/declaration.d.ts +49 -2
  47. package/dist/presence/declaration.js +122 -39
  48. package/dist/presence/presence.d.ts +65 -6
  49. package/dist/presence/presence.js +11 -0
  50. package/dist/presence/rotation.d.ts +32 -0
  51. package/dist/presence/rotation.js +167 -0
  52. package/dist/rest/errors.d.ts +0 -1
  53. package/dist/rest/errors.js +15 -1
  54. package/dist/rest/rest.js +10 -1
  55. package/dist/store/file-backend.d.ts +0 -1
  56. package/dist/store/file-backend.js +18 -1
  57. package/dist/store/scope.d.ts +6 -1
  58. package/dist/store/scope.js +9 -3
  59. package/dist/structures/channel.d.ts +13 -0
  60. package/dist/structures/channel.js +24 -0
  61. package/dist/structures/context.d.ts +3 -0
  62. package/dist/structures/entities.js +1 -0
  63. package/dist/structures/interaction.d.ts +6 -0
  64. package/dist/structures/interaction.js +20 -0
  65. package/dist/structures/message.d.ts +17 -0
  66. package/dist/structures/message.js +33 -0
  67. package/dist/structures/wait.d.ts +105 -0
  68. package/dist/structures/wait.js +85 -0
  69. package/dist/testing/harness.d.ts +48 -8
  70. package/dist/testing/harness.js +156 -36
  71. package/dist/testing/index.d.ts +1 -1
  72. package/dist/testing/smoke.js +112 -1
  73. package/dist/testing/world.js +51 -2
  74. package/dist/util/call-site.d.ts +4 -0
  75. package/dist/util/call-site.js +22 -0
  76. package/dist/voice/connection.d.ts +2 -0
  77. package/dist/voice/connection.js +15 -1
  78. package/dist/voice/usage.d.ts +3 -2
  79. package/dist/voice/usage.js +7 -19
  80. package/dist/waiting/usage.d.ts +5 -0
  81. package/dist/waiting/usage.js +19 -0
  82. package/dist/waiting/waiters.d.ts +48 -0
  83. package/dist/waiting/waiters.js +107 -0
  84. package/package.json +1 -1
package/dist/cli/start.js CHANGED
@@ -58,7 +58,7 @@ export async function start(options) {
58
58
  version: options.version,
59
59
  log,
60
60
  built: files,
61
- joinsVoice: built.info.voice === true,
61
+ uses: built.info.uses ?? {},
62
62
  stores: {
63
63
  // A process started by another keeps no file: the first one does,
64
64
  // for all of them.
@@ -144,12 +144,19 @@ export async function start(options) {
144
144
  throw new SessionLimitError(plan.shards, limit.remaining, limit.reset_after);
145
145
  }
146
146
  const processes = planProcesses(plan.shards, options.processes);
147
+ if (options.processes !== undefined && processes < options.processes) {
148
+ log.info(`--processes ${options.processes} asks for more processes than your bot has shards (${plan.shards}): each process runs one at least, so it runs in ${processes === 1 ? '1 process' : `${processes} processes`}. Discord gives a bot more shards as it joins more servers.`);
149
+ }
147
150
  const online = (user, guilds) => {
148
151
  const split = [
149
152
  ...(plan.shards > 1 ? [`${plan.shards} shards`] : []),
150
153
  ...(processes > 1 ? [`${processes} processes`] : []),
151
154
  ].join(', ');
152
155
  log.success(`Online as ${user} in ${guilds} ${guilds === 1 ? 'server' : 'servers'}${split ? ` (${split})` : ''}`);
156
+ // A bot only in its dev server would read "in 0 servers".
157
+ if (devGuildId) {
158
+ log.info('The dev server (DEV_GUILD_ID) is not counted: chapterjs start leaves it to chapterjs dev, and answers nothing there.');
159
+ }
153
160
  log.info(`Intents computed from your files: ${describeIntents(intents)}`);
154
161
  (project.isEmpty() ? log.info : log.success)(project.summary());
155
162
  const kept = describeRedisStores(project, redis);
@@ -202,6 +209,8 @@ async function run(project, connection, stopped, connected, { tasks = true } = {
202
209
  // A task runs once for the whole bot: in one process only.
203
210
  if (tasks)
204
211
  project.startTasks(() => bot);
212
+ // The presence is shown by every process, on its own shards.
213
+ project.startPresence(() => bot);
205
214
  return await Promise.race([
206
215
  stopped.then(() => 'stopped'),
207
216
  refused,
@@ -209,6 +218,7 @@ async function run(project, connection, stopped, connected, { tasks = true } = {
209
218
  }
210
219
  finally {
211
220
  project.stopTasks();
221
+ project.stopPresence();
212
222
  memory.stop();
213
223
  await bot?.close();
214
224
  await project.closeStores();
package/dist/cli/test.js CHANGED
@@ -15,7 +15,7 @@ import { captureLog, Observer, Session } from '../testing/harness.js';
15
15
  import { scenarioDeclaration, } from '../testing/scenario.js';
16
16
  import { smokeChecks } from '../testing/smoke.js';
17
17
  import { World } from '../testing/world.js';
18
- import { createProject, hasSources, writeTypes, } from './project.js';
18
+ import { createProject, hasSources, reportLooseRejections, writeTypes, } from './project.js';
19
19
  /**
20
20
  * What a terminal shows while a file is tested: `↻ Testing src/close.ts`,
21
21
  * then one line per thing and case as it is tried. Once the file is done,
@@ -66,6 +66,8 @@ function where(error, cwd) {
66
66
  return found ? `${found.file}:${found.line} ` : '';
67
67
  }
68
68
  /** Runs the whole project in a fresh world; `close` ends it. */
69
+ /** What join() says in the Discord in memory, where nothing plays. */
70
+ const VOICE_NOT_SIMULATED = 'Voice is not simulated by chapterjs test yet: the bot can not join a voice channel here.';
69
71
  async function startSession(project, observer, stores) {
70
72
  stores.reset();
71
73
  observer.reset();
@@ -77,6 +79,7 @@ async function startSession(project, observer, stores) {
77
79
  token: 'test-token',
78
80
  apiUrl: world.discord.url,
79
81
  privateEvents: true,
82
+ voiceUnavailable: VOICE_NOT_SIMULATED,
80
83
  onFatal: () => { },
81
84
  });
82
85
  }
@@ -153,14 +156,19 @@ export async function test(options) {
153
156
  return 1;
154
157
  const src = join(cwd, 'src');
155
158
  const output = quiet();
159
+ const stops = [];
156
160
  try {
157
- return await run(options, src, output);
161
+ return await run(options, src, output, stop => stops.push(stop));
158
162
  }
159
163
  finally {
164
+ for (const stop of stops)
165
+ stop();
160
166
  output.restore();
161
167
  }
162
168
  }
163
- async function run(options, src, output) {
169
+ async function run(options, src, output,
170
+ /** What to undo when the run ends. */
171
+ onEnd) {
164
172
  const { cwd } = options;
165
173
  // The report, written while what the files print is kept out of it.
166
174
  const log = {
@@ -173,6 +181,9 @@ async function run(options, src, output) {
173
181
  // 1. The files of the project, every one of them running.
174
182
  enableProjectLoader(src, { reload: false });
175
183
  const capture = captureLog();
184
+ // A promise of the files that nobody awaited fails the check or the
185
+ // scenario it belongs to, as dev says it, instead of ending the run.
186
+ onEnd(reportLooseRejections(cwd, capture.log));
176
187
  const observer = new Observer();
177
188
  const stores = new MemoryStoreBackend();
178
189
  const project = createProject({
@@ -210,13 +221,23 @@ async function run(options, src, output) {
210
221
  // has one line per file: what was checked in it only shows when
211
222
  // something failed.
212
223
  const { checks, notSimulated } = smokeChecks(project);
213
- const smoke = await startSession(project, observer, stores);
224
+ const smoke = {
225
+ current: await startSession(project, observer, stores),
226
+ /** Whether a case ran on the current world: the next gets a new one. */
227
+ used: false,
228
+ async restart() {
229
+ await this.current.close();
230
+ this.current = await startSession(project, observer, stores);
231
+ },
232
+ };
233
+ /** The checks whose files join voice, which is not played here. */
234
+ const voiceSkipped = [];
214
235
  const progress = checkProgress(options.progress === true, log, text => output.report(() => process.stdout.write(text)));
215
236
  try {
216
237
  // The ready event runs first, as it does online; its result is
217
238
  // reported with the file it comes from.
218
- project.ready(smoke.session.bot);
219
- await smoke.session.settle();
239
+ project.ready(smoke.current.session.bot);
240
+ await smoke.current.session.settle();
220
241
  const atStart = problems(capture.take());
221
242
  const readyPrinted = output.take();
222
243
  const readyFiles = [
@@ -251,8 +272,10 @@ async function run(options, src, output) {
251
272
  }
252
273
  for (const check of own) {
253
274
  progress.step(check.subject);
254
- const result = await runCheck(check, smoke.session, capture, cwd, label => progress.step(`${check.subject}, ${label}`));
275
+ const result = await runCheck(check, smoke, capture, cwd, label => progress.step(`${check.subject}, ${label}`));
255
276
  const wrote = output.take();
277
+ if (result.notSimulated)
278
+ voiceSkipped.push(`${check.subject} (${file})`);
256
279
  if (result.failed.length === 0)
257
280
  passed++;
258
281
  else {
@@ -285,7 +308,10 @@ async function run(options, src, output) {
285
308
  }
286
309
  }
287
310
  finally {
288
- await smoke.close();
311
+ await smoke.current.close();
312
+ }
313
+ if (voiceSkipped.length > 0) {
314
+ log.info(`Voice is not played in memory, so what comes after join() was not run for: ${voiceSkipped.join(', ')}. Try it on your server with chapterjs dev.`);
289
315
  }
290
316
  if (notSimulated.length > 0) {
291
317
  log.info(`${notSimulated.length === 1 ? 'An event is' : 'Some events are'} not simulated yet, so ${notSimulated.length === 1 ? 'its file was' : 'their files were'} not run: ${notSimulated.join(', ')}.`);
@@ -387,34 +413,100 @@ function report(failure, cwd, log) {
387
413
  const found = locate(failure.error, cwd);
388
414
  log.error(`${found ? `${found.file}:${found.line}` : siteName(failure)} ${messageOf(failure.error)}`);
389
415
  }
390
- /** Runs the cases of a check, one after the other, in the same world. */
391
- async function runCheck(check, session, capture, cwd, onCase = () => { }) {
416
+ /**
417
+ * How long a case of a check may go without any answer: Discord gives an
418
+ * interaction 3 s, and the framework defers by itself after 2.
419
+ */
420
+ const ANSWER_WITHIN = 5000;
421
+ /**
422
+ * How long a case may take in all once every interaction it started was
423
+ * answered: a deferred answer may come later, as it may online.
424
+ */
425
+ const FINISH_WITHIN = 30_000;
426
+ /**
427
+ * Runs the cases of a check, one after the other, in the same world. A case
428
+ * still running when its time is up is abandoned with the world it ran in:
429
+ * the next case gets a fresh one, so what the abandoned case does later is
430
+ * never taken for what the next one did.
431
+ */
432
+ async function runCheck(check, smoke, capture, cwd, onCase = () => { }) {
392
433
  const failed = [];
393
434
  const warnings = [];
435
+ let notSimulated = false;
394
436
  for (const one of check.cases) {
395
437
  onCase(one.label);
438
+ // Every case on a server as it was: a /ban used on someone must not
439
+ // leave the next case without them.
440
+ if (smoke.used)
441
+ await smoke.restart();
442
+ smoke.used = true;
396
443
  capture.take();
444
+ const { session } = smoke.current;
445
+ const before = new Set(session.world.interactions.values());
446
+ const running = Promise.resolve().then(() => one.run(session));
397
447
  let thrown = null;
398
- try {
399
- await withTimeout(one.run(session), 5000, 'the bot did not answer within 5 s');
400
- }
401
- catch (error) {
448
+ let abandoned = false;
449
+ const fail = (error) => {
402
450
  thrown =
403
451
  error instanceof TestFailure
404
452
  ? error.message
405
453
  : `${where(error, cwd)}${messageOf(error)}`;
454
+ };
455
+ let outcome = await within(running, ANSWER_WITHIN);
456
+ if (outcome.late) {
457
+ const unanswered = [...session.world.interactions.values()].some(record => !before.has(record) && record.callbacks.length === 0);
458
+ if (unanswered) {
459
+ thrown = `the bot did not answer within ${ANSWER_WITHIN / 1000} s`;
460
+ abandoned = true;
461
+ }
462
+ else {
463
+ outcome = await within(running, FINISH_WITHIN - ANSWER_WITHIN);
464
+ if (outcome.late) {
465
+ thrown = `it did not finish within ${FINISH_WITHIN / 1000} s: is something waiting for what never comes?`;
466
+ abandoned = true;
467
+ }
468
+ }
406
469
  }
470
+ if (!outcome.late && outcome.failed)
471
+ fail(outcome.error);
407
472
  const captured = problems(capture.take());
408
473
  const messages = [...(thrown ? [thrown] : []), ...captured.failures];
409
- if (messages.length > 0) {
474
+ // Voice is not played in memory: a case that only failed there is not
475
+ // the file's fault, it was not run to its end.
476
+ if (messages.length > 0 &&
477
+ messages.every(message => message.includes(VOICE_NOT_SIMULATED))) {
478
+ notSimulated = true;
479
+ }
480
+ else if (messages.length > 0) {
410
481
  failed.push({ label: one.label, message: messages.join('; ') });
411
482
  }
412
483
  for (const warning of captured.warnings) {
413
484
  if (!warnings.includes(warning))
414
485
  warnings.push(warning);
415
486
  }
487
+ if (abandoned)
488
+ running.catch(() => { });
489
+ }
490
+ return { failed, warnings, notSimulated };
491
+ }
492
+ /** Whether `promise` settled within `ms`, and how. */
493
+ async function within(promise, ms) {
494
+ let timer;
495
+ try {
496
+ return await Promise.race([
497
+ promise.then(() => ({ late: false, failed: false }), (error) => ({
498
+ late: false,
499
+ failed: true,
500
+ error,
501
+ })),
502
+ new Promise(resolve => {
503
+ timer = setTimeout(() => resolve({ late: true }), ms);
504
+ }),
505
+ ]);
506
+ }
507
+ finally {
508
+ clearTimeout(timer);
416
509
  }
417
- return { failed, warnings };
418
510
  }
419
511
  /** Loads the test files and reads their scenarios; what fails is reported. */
420
512
  async function loadScenarios(files, cwd, log) {
@@ -5,7 +5,7 @@ import type { Channel, DMChannel, GuildTextBasedChannel } from '../structures/ch
5
5
  import type { Guild } from '../structures/guild.js';
6
6
  import type { DmCommandInteraction, GuildCommandInteraction, PrivateCommandInteraction } from '../structures/interaction.js';
7
7
  import type { GuildMember } from '../structures/member.js';
8
- import type { Attachment } from '../structures/message.js';
8
+ import type { Attachment, DmMessage, GuildMessage, PrivateMessage } from '../structures/message.js';
9
9
  import type { Role } from '../structures/role.js';
10
10
  import type { User } from '../structures/user.js';
11
11
  import type { TranslationContext } from '../messages/messages.js';
@@ -270,6 +270,121 @@ export type CommandContext<Options extends CommandOptions = CommandOptions, Wher
270
270
  /** Who used the command. */
271
271
  user: User;
272
272
  } & TranslationContext & (Where extends 'guild' ? CommandInGuild : Where extends 'dm' ? CommandInDm : CommandInGuild | CommandInPrivate);
273
+ /**
274
+ * What a menu command is used on, in the menu people open with a right
275
+ * click (or a long press): a message (`'message'`) or a person
276
+ * (`'user'`).
277
+ * @see https://docs.discord.com/developers/interactions/application-commands#application-command-object-application-command-types
278
+ */
279
+ export type MenuType = 'message' | 'user';
280
+ /** What a menu command receives about what it was used on, in a server. */
281
+ type MenuTargetInGuild<Type extends MenuType> = Type extends 'user' ? {
282
+ /** The person the menu was used on. */
283
+ target: User;
284
+ /**
285
+ * That person as a member of the server, or `null` when they are not
286
+ * in it (any more).
287
+ */
288
+ targetMember: GuildMember | null;
289
+ } : {
290
+ /** The message the menu was used on. */
291
+ message: GuildMessage;
292
+ };
293
+ /**
294
+ * What a menu command receives about what it was used on, in a private
295
+ * message with the bot.
296
+ */
297
+ type MenuTargetInPrivate<Type extends MenuType> = Type extends 'user' ? {
298
+ /** The person the menu was used on. */
299
+ target: User;
300
+ /** No server: no member. */
301
+ targetMember: null;
302
+ } : {
303
+ /** The message the menu was used on. */
304
+ message: PrivateMessage;
305
+ };
306
+ /** The same, for a menu command only used in private messages. */
307
+ type MenuTargetInDm<Type extends MenuType> = Type extends 'user' ? {
308
+ /** The person the menu was used on. */
309
+ target: User;
310
+ } : {
311
+ /** The message the menu was used on. */
312
+ message: DmMessage;
313
+ };
314
+ /**
315
+ * What the `run` function of a menu command receives: what it was used on
316
+ * (`message`, or `target` and `targetMember`), who used it, and the place,
317
+ * which follows `where` like the one of a slash command.
318
+ */
319
+ export type MenuContext<Type extends MenuType = 'message', Where extends CommandWhere = 'guild'> = {
320
+ /** Who used the menu. */
321
+ user: User;
322
+ } & TranslationContext & (Where extends 'guild' ? CommandInGuild & MenuTargetInGuild<Type> : Where extends 'dm' ? CommandInDm & MenuTargetInDm<Type> : (CommandInGuild & MenuTargetInGuild<Type>) | (CommandInPrivate & MenuTargetInPrivate<Type>));
323
+ /** The translation of a menu command in one language: its name. */
324
+ export interface MenuTranslation {
325
+ /** The name shown in the menu in that language (1-32 characters). */
326
+ name?: string;
327
+ }
328
+ /** The translations of a menu command, by language. */
329
+ export type MenuLocales = {
330
+ readonly [Language in Locale]?: MenuTranslation;
331
+ };
332
+ /**
333
+ * What a file gives to `command()` for a menu command: one used on a
334
+ * message or a person, from the menu of a right click. It has no
335
+ * description and no options: what it receives is what it was used on.
336
+ */
337
+ export interface MenuConfig<Type extends MenuType = 'message', Where extends CommandWhere = 'guild'> {
338
+ /**
339
+ * What the command is used on: `'message'` (a right click on a message)
340
+ * or `'user'` (a right click on a person).
341
+ */
342
+ type: Type;
343
+ /**
344
+ * What the menu shows (1-32 characters): capitals and spaces are fine,
345
+ * like `'Report message'`.
346
+ */
347
+ name: string;
348
+ /**
349
+ * The name in other languages: Discord shows each person the name of
350
+ * their language.
351
+ * @see https://docs.discord.com/developers/interactions/application-commands#localization
352
+ */
353
+ locales?: MenuLocales;
354
+ /**
355
+ * The permissions a member needs to use the command. Server admins can
356
+ * change who sees it in the server settings.
357
+ */
358
+ permissions?: readonly PermissionName[];
359
+ /**
360
+ * Where the command can be used: `'guild'` (in servers, the default),
361
+ * `'dm'` (in private messages with the bot) or `'both'`. What `run`
362
+ * receives follows it.
363
+ */
364
+ where?: Where;
365
+ /** Only show the command in age-restricted channels. */
366
+ nsfw?: boolean;
367
+ /** Make the answers of the command only visible to who used it. */
368
+ ephemeral?: boolean;
369
+ /**
370
+ * How long to wait before the command can be used again: `'10s'` for
371
+ * each person, or `{ user, channel, guild }` for each place.
372
+ */
373
+ cooldown?: Cooldown;
374
+ /** A menu command has no description: Discord only shows its name. */
375
+ description?: never;
376
+ /** A menu command has no options: it receives what it was used on. */
377
+ options?: never;
378
+ /** A menu command has no options to suggest values for. */
379
+ autocomplete?: never;
380
+ /** What to do when someone uses the command. */
381
+ run: (context: MenuContext<Type, Where>) => unknown;
382
+ }
383
+ /** What `command()` returns for a menu command. */
384
+ export interface MenuDeclaration<Type extends MenuType = MenuType, Where extends CommandWhere = CommandWhere> {
385
+ /** What the file gave to `command()`, not checked yet. */
386
+ readonly config: MenuConfig<Type, Where>;
387
+ }
273
388
  /**
274
389
  * The name of a slash command: lowercase letters, digits, `-` and `_`, 32
275
390
  * characters at most (`'ping'`). A subcommand is written with its parents,
@@ -330,6 +445,11 @@ export interface CommandConfig<Options extends CommandOptions = CommandOptions,
330
445
  * uses it too soon gets a private answer saying when they can.
331
446
  */
332
447
  cooldown?: Cooldown;
448
+ /**
449
+ * Leave it out for a slash command. `'message'` or `'user'` makes a menu
450
+ * command instead, used with a right click on a message or a person.
451
+ */
452
+ type?: undefined;
333
453
  /** What to do when someone uses the command. */
334
454
  run: (context: CommandContext<Options, Where>) => unknown;
335
455
  }
@@ -339,8 +459,10 @@ export interface CommandDeclaration<Options extends CommandOptions = CommandOpti
339
459
  readonly config: CommandConfig<Options, Where>;
340
460
  }
341
461
  /**
342
- * Declares a slash command. Export the result from any file of `src/`:
343
- * `name` is what people type after the `/` (`'mod ban'` for `/mod ban`).
462
+ * Declares a command. Export the result from any file of `src/`.
463
+ *
464
+ * A slash command: `name` is what people type after the `/` (`'mod ban'`
465
+ * for `/mod ban`).
344
466
  *
345
467
  * ```ts
346
468
  * import { command } from 'chapterjs';
@@ -353,8 +475,21 @@ export interface CommandDeclaration<Options extends CommandOptions = CommandOpti
353
475
  * },
354
476
  * });
355
477
  * ```
478
+ *
479
+ * A menu command, with `type`: used with a right click on a message
480
+ * (`'message'`) or on a person (`'user'`), `name` being what the menu shows.
481
+ *
482
+ * ```ts
483
+ * export const quote = command({
484
+ * type: 'message',
485
+ * name: 'Quote',
486
+ * async run({ interaction, message }) {
487
+ * await interaction.reply(`> ${message.content}`);
488
+ * },
489
+ * });
490
+ * ```
356
491
  */
357
- export declare function command<const Options extends CommandOptions = {}, const Where extends CommandWhere = 'guild'>(config: CommandConfig<Options, Where>): CommandDeclaration<Options, Where>;
492
+ export declare function command<const Options extends CommandOptions = {}, const Where extends CommandWhere = 'guild', const Type extends MenuType | undefined = undefined>(config: Type extends MenuType ? MenuConfig<Type, Where> : CommandConfig<Options, Where>): Type extends MenuType ? MenuDeclaration<Type, Where> : CommandDeclaration<Options, Where>;
358
493
  /** Whether a value was made by `command()`. */
359
- export declare function isCommandDeclaration(value: unknown): value is CommandDeclaration;
494
+ export declare function isCommandDeclaration(value: unknown): value is CommandDeclaration | MenuDeclaration;
360
495
  export {};
@@ -2,8 +2,10 @@
2
2
  // make the options a command declares typed in its `run` function.
3
3
  const BRAND = Symbol.for('chapterjs.command');
4
4
  /**
5
- * Declares a slash command. Export the result from any file of `src/`:
6
- * `name` is what people type after the `/` (`'mod ban'` for `/mod ban`).
5
+ * Declares a command. Export the result from any file of `src/`.
6
+ *
7
+ * A slash command: `name` is what people type after the `/` (`'mod ban'`
8
+ * for `/mod ban`).
7
9
  *
8
10
  * ```ts
9
11
  * import { command } from 'chapterjs';
@@ -16,6 +18,19 @@ const BRAND = Symbol.for('chapterjs.command');
16
18
  * },
17
19
  * });
18
20
  * ```
21
+ *
22
+ * A menu command, with `type`: used with a right click on a message
23
+ * (`'message'`) or on a person (`'user'`), `name` being what the menu shows.
24
+ *
25
+ * ```ts
26
+ * export const quote = command({
27
+ * type: 'message',
28
+ * name: 'Quote',
29
+ * async run({ interaction, message }) {
30
+ * await interaction.reply(`> ${message.content}`);
31
+ * },
32
+ * });
33
+ * ```
19
34
  */
20
35
  export function command(config) {
21
36
  return Object.freeze({ [BRAND]: true, config });
@@ -1,7 +1,13 @@
1
1
  import { Locale } from '../discord/types/common.js';
2
2
  import { type CooldownLimits } from '../interactions/cooldown.js';
3
3
  import { type Declaration } from '../loader/loader.js';
4
- import { type AutocompleteContext, type CommandContext, type CommandOption, type CommandWhere, type Suggestion } from './command.js';
4
+ import { type AutocompleteContext, type CommandContext, type CommandOption, type CommandWhere, type MenuType, type Suggestion } from './command.js';
5
+ /**
6
+ * The kinds of commands: a slash command, or a menu command used on a
7
+ * message or a person.
8
+ * @see https://docs.discord.com/developers/interactions/application-commands#application-command-object-application-command-types
9
+ */
10
+ export type CommandType = 'slash' | MenuType;
5
11
  /** A name or a description in other languages, as Discord takes them. */
6
12
  export type Localizations = Readonly<Partial<Record<Locale, string>>>;
7
13
  /** The translations of one option, checked. */
@@ -20,12 +26,17 @@ export type LoadedOption = CommandOption & {
20
26
  export type AutocompleteFunction = (context: AutocompleteContext) => readonly Suggestion[] | Promise<readonly Suggestion[]>;
21
27
  /** A command, checked: what the rest of the feature works with. */
22
28
  export interface LoadedCommand {
29
+ /** A slash command, or a menu command used on a message or a person. */
30
+ readonly type: CommandType;
23
31
  /** The name and description of the command in other languages. */
24
32
  readonly names: Localizations;
25
33
  readonly descriptions: Localizations;
26
34
  /** The translations of the options, by option name. */
27
35
  readonly optionLocales: Readonly<Record<string, OptionLocales>>;
28
- /** `['mod', 'ban']` for `name: 'mod ban'`: `/mod ban`. */
36
+ /**
37
+ * `['mod', 'ban']` for `name: 'mod ban'`: `/mod ban`. A menu command has
38
+ * one name, spaces included: `['Report message']`.
39
+ */
29
40
  readonly path: readonly string[];
30
41
  /**
31
42
  * Whether the file gives its own texts (`description`, `locales`). When
@@ -64,8 +75,9 @@ export declare function readCommandName(name: unknown): string[];
64
75
  /**
65
76
  * A slash command, declared anywhere in `src/` with `command()`: `name` is
66
77
  * what people type after the `/`, with its parents for a subcommand
67
- * (`'mod ban'` is `/mod ban`, `'mod roles add'` is `/mod roles add`). A
68
- * file may declare several.
78
+ * (`'mod ban'` is `/mod ban`, `'mod roles add'` is `/mod roles add`). With
79
+ * a `type`, a menu command, used on a message or a person. A file may
80
+ * declare several.
69
81
  */
70
82
  export declare const commandDeclaration: Declaration<LoadedCommand>;
71
83
  /**
@@ -73,5 +85,6 @@ export declare const commandDeclaration: Declaration<LoadedCommand>;
73
85
  * files: a text search for `command({ name: '...' })`. Approximate on
74
86
  * purpose, for `sync`, which runs nothing: `dev` and `build` then write
75
87
  * the exact ones. Whether a command is described is not known: none is.
88
+ * Menu commands are left out: their texts are always in their file.
76
89
  */
77
90
  export declare function scanCommands(cwd: string): Promise<string[]>;