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
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
391
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
|
343
|
-
*
|
|
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 {};
|
package/dist/commands/command.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
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`).
|
|
68
|
-
*
|
|
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[]>;
|