chapterjs 0.2.3 → 0.4.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 (87) hide show
  1. package/README.md +65 -0
  2. package/dist/cli/build.js +14 -1
  3. package/dist/cli/cli.js +41 -6
  4. package/dist/cli/dev.js +29 -1
  5. package/dist/cli/env.d.ts +5 -0
  6. package/dist/cli/env.js +39 -3
  7. package/dist/cli/project.d.ts +49 -7
  8. package/dist/cli/project.js +103 -19
  9. package/dist/cli/redis.d.ts +23 -0
  10. package/dist/cli/redis.js +57 -0
  11. package/dist/cli/start.js +43 -17
  12. package/dist/cli/test.d.ts +29 -0
  13. package/dist/cli/test.js +446 -0
  14. package/dist/commands/autocomplete.d.ts +1 -1
  15. package/dist/commands/autocomplete.js +37 -41
  16. package/dist/commands/router.d.ts +4 -1
  17. package/dist/commands/router.js +15 -17
  18. package/dist/components/router.d.ts +7 -2
  19. package/dist/components/router.js +16 -16
  20. package/dist/discord/types/channel.d.ts +9 -1
  21. package/dist/discord/types/guild.d.ts +5 -5
  22. package/dist/discord/types/permissions.d.ts +6 -0
  23. package/dist/discord/types/poll.d.ts +7 -1
  24. package/dist/events/registry.d.ts +252 -239
  25. package/dist/events/registry.js +125 -100
  26. package/dist/events/router.d.ts +9 -4
  27. package/dist/events/router.js +24 -11
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.js +1 -0
  30. package/dist/interactions/dispatch.d.ts +5 -1
  31. package/dist/interactions/dispatch.js +6 -2
  32. package/dist/loader/hot.js +22 -2
  33. package/dist/loader/loader.d.ts +7 -0
  34. package/dist/loader/loader.js +22 -12
  35. package/dist/ranking/declaration.d.ts +12 -0
  36. package/dist/ranking/declaration.js +48 -0
  37. package/dist/ranking/ranking.d.ts +102 -0
  38. package/dist/ranking/ranking.js +129 -0
  39. package/dist/redis/client.d.ts +36 -0
  40. package/dist/redis/client.js +252 -0
  41. package/dist/redis/protocol.d.ts +22 -0
  42. package/dist/redis/protocol.js +99 -0
  43. package/dist/redis/url.d.ts +24 -0
  44. package/dist/redis/url.js +50 -0
  45. package/dist/store/backend.d.ts +49 -4
  46. package/dist/store/backend.js +66 -11
  47. package/dist/store/declaration.d.ts +3 -0
  48. package/dist/store/declaration.js +8 -3
  49. package/dist/store/file-backend.d.ts +3 -1
  50. package/dist/store/file-backend.js +29 -0
  51. package/dist/store/ipc-backend.d.ts +24 -1
  52. package/dist/store/ipc-backend.js +49 -0
  53. package/dist/store/memory-backend.d.ts +17 -0
  54. package/dist/store/memory-backend.js +82 -0
  55. package/dist/store/ranking-backend.d.ts +45 -0
  56. package/dist/store/ranking-backend.js +82 -0
  57. package/dist/store/redis-backend.d.ts +38 -0
  58. package/dist/store/redis-backend.js +245 -0
  59. package/dist/store/redis-ranking.d.ts +15 -0
  60. package/dist/store/redis-ranking.js +69 -0
  61. package/dist/store/scope.d.ts +11 -0
  62. package/dist/store/scope.js +23 -0
  63. package/dist/store/store.d.ts +39 -5
  64. package/dist/store/store.js +83 -33
  65. package/dist/structures/invite.d.ts +6 -0
  66. package/dist/structures/invite.js +11 -0
  67. package/dist/tasks/scheduler.d.ts +6 -0
  68. package/dist/tasks/scheduler.js +9 -3
  69. package/dist/testing/failure.d.ts +9 -0
  70. package/dist/testing/failure.js +20 -0
  71. package/dist/testing/fake-discord.d.ts +59 -0
  72. package/dist/testing/fake-discord.js +120 -0
  73. package/dist/testing/fake-gateway.d.ts +59 -0
  74. package/dist/testing/fake-gateway.js +123 -0
  75. package/dist/testing/harness.d.ts +387 -0
  76. package/dist/testing/harness.js +1418 -0
  77. package/dist/testing/index.d.ts +4 -0
  78. package/dist/testing/index.js +5 -0
  79. package/dist/testing/scenario.d.ts +39 -0
  80. package/dist/testing/scenario.js +58 -0
  81. package/dist/testing/smoke.d.ts +20 -0
  82. package/dist/testing/smoke.js +659 -0
  83. package/dist/testing/websocket.d.ts +34 -0
  84. package/dist/testing/websocket.js +167 -0
  85. package/dist/testing/world.d.ts +145 -0
  86. package/dist/testing/world.js +1479 -0
  87. package/package.json +20 -3
@@ -0,0 +1,446 @@
1
+ // `chapterjs test`: runs the bot of the current folder against a Discord
2
+ // in memory and checks it, without a token and without touching Discord.
3
+ // First what needs nothing written: every command, button, menu, form,
4
+ // event and task is used once with ordinary values and with hard ones,
5
+ // and must answer without throwing. Then the scenarios of the project
6
+ // (`src/**/*.test.ts`, written with `scenario()` from 'chapterjs/test'),
7
+ // each on a fresh server.
8
+ import { join } from 'node:path';
9
+ import { enableProjectLoader } from '../loader/hot.js';
10
+ import { listTests, loadFiles, readDeclarations, siteName, } from '../loader/loader.js';
11
+ import { locate, messageOf } from '../loader/locate.js';
12
+ import { MemoryStoreBackend } from '../store/memory-backend.js';
13
+ import { TestFailure } from '../testing/failure.js';
14
+ import { captureLog, Observer, Session } from '../testing/harness.js';
15
+ import { scenarioDeclaration, } from '../testing/scenario.js';
16
+ import { smokeChecks } from '../testing/smoke.js';
17
+ import { World } from '../testing/world.js';
18
+ import { createProject, hasSources, writeTypes, } from './project.js';
19
+ /**
20
+ * What a terminal shows while a file is tested: `↻ Testing src/close.ts`,
21
+ * then one line per thing and case as it is tried. Once the file is done,
22
+ * those lines are erased and its result takes their place, so a report
23
+ * that passed reads as if nothing had moved. When nobody is watching (the
24
+ * output is not a terminal), nothing is shown meanwhile.
25
+ */
26
+ export function checkProgress(enabled, log, write) {
27
+ let lines = 0;
28
+ return {
29
+ start(title) {
30
+ if (!enabled)
31
+ return;
32
+ log.reload(title);
33
+ lines = 1;
34
+ },
35
+ step(label) {
36
+ if (!enabled)
37
+ return;
38
+ write(` → ${label}\n`);
39
+ lines++;
40
+ },
41
+ clear() {
42
+ if (!enabled || lines === 0)
43
+ return;
44
+ // Up to the first line of the check, then everything below it goes.
45
+ write(`\x1b[${lines}A\x1b[0J`);
46
+ lines = 0;
47
+ },
48
+ };
49
+ }
50
+ /** What the files said to the developer during a check, as failures. */
51
+ function problems(captured) {
52
+ const failures = [...captured.errors];
53
+ const warnings = [];
54
+ for (const warning of captured.warnings) {
55
+ // The person sees "The application did not respond": a failure.
56
+ if (warning.includes('finished without answering'))
57
+ failures.push(warning);
58
+ else
59
+ warnings.push(warning);
60
+ }
61
+ return { failures, warnings };
62
+ }
63
+ /** Where a failure of a scenario comes from, when it is in the project. */
64
+ function where(error, cwd) {
65
+ const found = locate(error, cwd);
66
+ return found ? `${found.file}:${found.line} ` : '';
67
+ }
68
+ /** Runs the whole project in a fresh world; `close` ends it. */
69
+ async function startSession(project, observer, stores) {
70
+ stores.reset();
71
+ observer.reset();
72
+ const world = new World();
73
+ await world.start();
74
+ let bot;
75
+ try {
76
+ bot = await project.connect({
77
+ token: 'test-token',
78
+ apiUrl: world.discord.url,
79
+ privateEvents: true,
80
+ onFatal: () => { },
81
+ });
82
+ }
83
+ catch (error) {
84
+ await world.close();
85
+ throw error;
86
+ }
87
+ const session = new Session(world, project, bot, observer);
88
+ return {
89
+ session,
90
+ close: async () => {
91
+ await bot.close();
92
+ await world.close();
93
+ },
94
+ };
95
+ }
96
+ /**
97
+ * What the files print while they are tested, kept out of the report: a
98
+ * `console.log` of a `ready` event saying "online" would read as the bot
99
+ * being online. The runner writes through `report()`; what the files wrote
100
+ * is given back by `take()`, to show under a failure.
101
+ */
102
+ function quiet() {
103
+ const original = {
104
+ out: process.stdout.write.bind(process.stdout),
105
+ err: process.stderr.write.bind(process.stderr),
106
+ };
107
+ let printed = '';
108
+ const swallow = (chunk) => {
109
+ printed +=
110
+ typeof chunk === 'string'
111
+ ? chunk
112
+ : Buffer.from(chunk).toString();
113
+ return true;
114
+ };
115
+ const silence = () => {
116
+ process.stdout.write = swallow;
117
+ process.stderr.write = swallow;
118
+ };
119
+ const restore = () => {
120
+ process.stdout.write = original.out;
121
+ process.stderr.write = original.err;
122
+ };
123
+ silence();
124
+ return {
125
+ report(write) {
126
+ restore();
127
+ try {
128
+ write();
129
+ }
130
+ finally {
131
+ silence();
132
+ }
133
+ },
134
+ take() {
135
+ const taken = printed;
136
+ printed = '';
137
+ return taken;
138
+ },
139
+ restore,
140
+ };
141
+ }
142
+ /** What the files printed, as the lines under a failure. */
143
+ function printed(output) {
144
+ const text = output.trim();
145
+ if (text === '')
146
+ return '';
147
+ return `\nWhat the files printed meanwhile:\n${text}`;
148
+ }
149
+ /** Resolves with the exit code of the process. */
150
+ export async function test(options) {
151
+ const { cwd } = options;
152
+ if (!(await hasSources(cwd, options.log)))
153
+ return 1;
154
+ const src = join(cwd, 'src');
155
+ const output = quiet();
156
+ try {
157
+ return await run(options, src, output);
158
+ }
159
+ finally {
160
+ output.restore();
161
+ }
162
+ }
163
+ async function run(options, src, output) {
164
+ const { cwd } = options;
165
+ // The report, written while what the files print is kept out of it.
166
+ const log = {
167
+ success: message => output.report(() => options.log.success(message)),
168
+ info: message => output.report(() => options.log.info(message)),
169
+ reload: message => output.report(() => options.log.reload(message)),
170
+ warn: message => output.report(() => options.log.warn(message)),
171
+ error: message => output.report(() => options.log.error(message)),
172
+ };
173
+ // 1. The files of the project, every one of them running.
174
+ enableProjectLoader(src, { reload: false });
175
+ const capture = captureLog();
176
+ const observer = new Observer();
177
+ const stores = new MemoryStoreBackend();
178
+ const project = createProject({
179
+ cwd,
180
+ version: options.version,
181
+ log: capture.log,
182
+ // Every store, wherever it says it keeps its data, is in memory here:
183
+ // a test never needs a Redis, and starts from nothing.
184
+ stores: { file: stores, redis: stores },
185
+ observe: observer.observe,
186
+ });
187
+ await writeTypes(cwd);
188
+ const failures = await project.load();
189
+ await writeTypes(cwd, project.typesInfo());
190
+ if (failures.length > 0) {
191
+ for (const failure of failures)
192
+ report(failure, cwd, log);
193
+ log.error(`${failures.length === 1 ? 'This file' : `These ${failures.length} files`} can't run, so your bot was not tested. Fix ${failures.length === 1 ? 'it' : 'them'} and test again: chapterjs dev shows the same errors while you write.${printed(output.take())}`);
194
+ await project.closeStores();
195
+ return 1;
196
+ }
197
+ if (project.isEmpty()) {
198
+ log.info(project.summary());
199
+ await project.closeStores();
200
+ return 0;
201
+ }
202
+ log.success(project.summary());
203
+ output.take();
204
+ let checksPassed = 0;
205
+ let checksFailed = 0;
206
+ let scenariosPassed = 0;
207
+ let scenariosFailed = 0;
208
+ try {
209
+ // 2. What needs nothing written: every file used once. The report
210
+ // has one line per file: what was checked in it only shows when
211
+ // something failed.
212
+ const { checks, notSimulated } = smokeChecks(project);
213
+ const smoke = await startSession(project, observer, stores);
214
+ const progress = checkProgress(options.progress === true, log, text => output.report(() => process.stdout.write(text)));
215
+ try {
216
+ // The ready event runs first, as it does online; its result is
217
+ // reported with the file it comes from.
218
+ project.ready(smoke.session.bot);
219
+ await smoke.session.settle();
220
+ const atStart = problems(capture.take());
221
+ const readyPrinted = output.take();
222
+ const readyFiles = [
223
+ ...new Set([...project.events.values()]
224
+ .filter(({ event }) => event.name === 'ready')
225
+ .map(({ file }) => file)),
226
+ ];
227
+ for (const warning of atStart.warnings)
228
+ log.warn(warning);
229
+ /** What was found in each file, in the order of the files. */
230
+ const byFile = new Map();
231
+ for (const file of readyFiles)
232
+ byFile.set(file, []);
233
+ for (const check of checks) {
234
+ byFile.set(check.file, [...(byFile.get(check.file) ?? []), check]);
235
+ }
236
+ // The files that passed are one line at the end: these checks are
237
+ // the framework's, what the developer reads is the scenarios.
238
+ const quietFiles = [];
239
+ let quietChecks = 0;
240
+ for (const [file, own] of [...byFile].sort(([a], [b]) => a < b ? -1 : 1)) {
241
+ progress.start(`Checking ${file}`);
242
+ const failures = [];
243
+ const warnings = [];
244
+ let passed = 0;
245
+ if (readyFiles.includes(file)) {
246
+ if (atStart.failures.length > 0) {
247
+ failures.push(`event ready failed: ${atStart.failures.map(failure => failure.split('\n').join(' ')).join('; ')}${printed(readyPrinted)}`);
248
+ }
249
+ else
250
+ passed++;
251
+ }
252
+ for (const check of own) {
253
+ progress.step(check.subject);
254
+ const result = await runCheck(check, smoke.session, capture, cwd, label => progress.step(`${check.subject}, ${label}`));
255
+ const wrote = output.take();
256
+ if (result.failed.length === 0)
257
+ passed++;
258
+ else {
259
+ // One case: its label says nothing more than the subject.
260
+ failures.push(check.cases.length === 1
261
+ ? `${check.subject} failed\n${result.failed[0].message}${printed(wrote)}`
262
+ : `${check.subject} failed in ${result.failed.length === check.cases.length ? 'every case' : `${result.failed.length} of ${check.cases.length} cases`}\n${result.failed.map(one => `${one.label}: ${one.message}`).join('\n')}${printed(wrote)}`);
263
+ }
264
+ for (const warning of result.warnings)
265
+ warnings.push(`${check.subject}: ${warning}`);
266
+ }
267
+ progress.clear();
268
+ checksPassed += passed;
269
+ checksFailed += failures.length;
270
+ if (failures.length === 0) {
271
+ quietFiles.push(file);
272
+ quietChecks += passed;
273
+ }
274
+ for (const failure of failures)
275
+ log.error(`Checked ${file}: ${failure}`);
276
+ for (const warning of warnings)
277
+ log.warn(`Checked ${file}: ${warning}`);
278
+ }
279
+ if (quietFiles.length > 0) {
280
+ const files = `${quietFiles.length} ${quietFiles.length === 1 ? 'file' : 'files'}`;
281
+ const count = `${quietChecks} ${quietChecks === 1 ? 'check' : 'checks'}`;
282
+ log.success(quietFiles.length === byFile.size
283
+ ? `Checked every file once: ${count} in ${files}`
284
+ : `Checked the other ${files} once: ${count}`);
285
+ }
286
+ }
287
+ finally {
288
+ await smoke.close();
289
+ }
290
+ if (notSimulated.length > 0) {
291
+ 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(', ')}.`);
292
+ }
293
+ // 3. The scenarios of the project, each on a fresh server: one line
294
+ // per test file, the scenarios of it only shown when one failed.
295
+ const files = await listTests(cwd);
296
+ const { loaded, broken } = await loadScenarios(files, cwd, log);
297
+ scenariosFailed += broken;
298
+ const scenariosByFile = new Map();
299
+ for (const { scenario, file } of loaded) {
300
+ scenariosByFile.set(file, [
301
+ ...(scenariosByFile.get(file) ?? []),
302
+ scenario,
303
+ ]);
304
+ }
305
+ for (const [file, own] of scenariosByFile) {
306
+ progress.start(`Testing ${file}`);
307
+ const failures = [];
308
+ const warnings = [];
309
+ let passed = 0;
310
+ for (const scenario of own) {
311
+ progress.step(`"${scenario.label}"`);
312
+ const run = await startSession(project, observer, stores);
313
+ let failure = null;
314
+ try {
315
+ project.ready(run.session.bot);
316
+ await run.session.settle();
317
+ capture.take();
318
+ output.take();
319
+ await withTimeout(Promise.resolve(scenario.run(run.session)), options.scenarioTimeout ?? 10_000, `took more than ${(options.scenarioTimeout ?? 10_000) / 1000} s: is something waiting for an answer that never comes?`);
320
+ await run.session.settle();
321
+ }
322
+ catch (error) {
323
+ failure =
324
+ error instanceof TestFailure
325
+ ? `${where(error, cwd)}${error.message}`
326
+ : `${where(error, cwd)}${messageOf(error)}`;
327
+ }
328
+ finally {
329
+ await run.close();
330
+ }
331
+ const captured = problems(capture.take());
332
+ const wrote = output.take();
333
+ if (failure === null && captured.failures.length === 0)
334
+ passed++;
335
+ else {
336
+ failures.push(`"${scenario.label}" failed\n${[...(failure ? [failure] : []), ...captured.failures].join('\n')}${printed(wrote)}`);
337
+ }
338
+ for (const warning of captured.warnings)
339
+ warnings.push(`"${scenario.label}": ${warning}`);
340
+ }
341
+ progress.clear();
342
+ scenariosPassed += passed;
343
+ scenariosFailed += failures.length;
344
+ if (failures.length === 0)
345
+ log.success(`Tested ${file}`);
346
+ for (const failure of failures)
347
+ log.error(`Tested ${file}: ${failure}`);
348
+ for (const warning of warnings)
349
+ log.warn(`Tested ${file}: ${warning}`);
350
+ }
351
+ if (files.length === 0) {
352
+ log.info(`No scenario yet. Write one in a *.test.ts file of src/ with scenario() from 'chapterjs/test' to check what your bot answers.`);
353
+ }
354
+ }
355
+ finally {
356
+ await project.closeStores();
357
+ }
358
+ // 4. The bottom line.
359
+ const checksTotal = checksPassed + checksFailed;
360
+ const scenariosTotal = scenariosPassed + scenariosFailed;
361
+ const parts = [
362
+ `${checksTotal} ${checksTotal === 1 ? 'check' : 'checks'}`,
363
+ ...(scenariosTotal > 0
364
+ ? [`${scenariosTotal} ${scenariosTotal === 1 ? 'scenario' : 'scenarios'}`]
365
+ : []),
366
+ ];
367
+ if (checksFailed === 0 && scenariosFailed === 0) {
368
+ log.success(`${parts.join(' and ')} passed`);
369
+ return 0;
370
+ }
371
+ log.error(`${[
372
+ ...(checksFailed > 0
373
+ ? [
374
+ `${checksFailed} of ${checksTotal} ${checksTotal === 1 ? 'check' : 'checks'}`,
375
+ ]
376
+ : []),
377
+ ...(scenariosFailed > 0
378
+ ? [
379
+ `${scenariosFailed} of ${scenariosTotal} ${scenariosTotal === 1 ? 'scenario' : 'scenarios'}`,
380
+ ]
381
+ : []),
382
+ ].join(' and ')} failed`);
383
+ return 1;
384
+ }
385
+ /** Shows a file (or an export) that could not run. */
386
+ function report(failure, cwd, log) {
387
+ const found = locate(failure.error, cwd);
388
+ log.error(`${found ? `${found.file}:${found.line}` : siteName(failure)} ${messageOf(failure.error)}`);
389
+ }
390
+ /** Runs the cases of a check, one after the other, in the same world. */
391
+ async function runCheck(check, session, capture, cwd, onCase = () => { }) {
392
+ const failed = [];
393
+ const warnings = [];
394
+ for (const one of check.cases) {
395
+ onCase(one.label);
396
+ capture.take();
397
+ let thrown = null;
398
+ try {
399
+ await withTimeout(one.run(session), 5000, 'the bot did not answer within 5 s');
400
+ }
401
+ catch (error) {
402
+ thrown =
403
+ error instanceof TestFailure
404
+ ? error.message
405
+ : `${where(error, cwd)}${messageOf(error)}`;
406
+ }
407
+ const captured = problems(capture.take());
408
+ const messages = [...(thrown ? [thrown] : []), ...captured.failures];
409
+ if (messages.length > 0) {
410
+ failed.push({ label: one.label, message: messages.join('; ') });
411
+ }
412
+ for (const warning of captured.warnings) {
413
+ if (!warnings.includes(warning))
414
+ warnings.push(warning);
415
+ }
416
+ }
417
+ return { failed, warnings };
418
+ }
419
+ /** Loads the test files and reads their scenarios; what fails is reported. */
420
+ async function loadScenarios(files, cwd, log) {
421
+ const sources = await loadFiles(files);
422
+ const read = readDeclarations(sources.modules, scenarioDeclaration);
423
+ const failures = [...sources.failed, ...read.failed];
424
+ for (const failure of failures)
425
+ report(failure, cwd, log);
426
+ const loaded = read.loaded.map(item => ({
427
+ scenario: item.value,
428
+ file: item.file,
429
+ }));
430
+ for (const { file, exports } of sources.modules) {
431
+ if (!read.loaded.some(item => item.file === file) &&
432
+ !read.failed.some(item => item.file === file)) {
433
+ log.warn(`${file} declares no scenario: export one made with scenario() from 'chapterjs/test'${Object.keys(exports).length === 0 ? '' : ` (its exports are: ${Object.keys(exports).join(', ')})`}.`);
434
+ }
435
+ }
436
+ return { loaded, broken: failures.length };
437
+ }
438
+ function withTimeout(promise, ms, message) {
439
+ let timer;
440
+ return Promise.race([
441
+ promise.finally(() => clearTimeout(timer)),
442
+ new Promise((_, reject) => {
443
+ timer = setTimeout(() => reject(new TestFailure(message)), ms);
444
+ }),
445
+ ]);
446
+ }
@@ -10,4 +10,4 @@ export declare const MAX_SUGGESTIONS = 25;
10
10
  * is empty, so every path ends with an answer, even when the function
11
11
  * fails: the person is never left with a spinner.
12
12
  */
13
- export declare function suggest(ctx: Context, raw: RawInteraction, entry: CommandEntry | undefined, given: readonly RawApplicationCommandInteractionDataOption[], reporter: Reporter, warnedOnce: Set<string>): void;
13
+ export declare function suggest(ctx: Context, raw: RawInteraction, entry: CommandEntry | undefined, given: readonly RawApplicationCommandInteractionDataOption[], reporter: Reporter, warnedOnce: Set<string>): Promise<void>;
@@ -27,39 +27,37 @@ const UNKNOWN_INTERACTION = 10062;
27
27
  export function suggest(ctx, raw, entry, given, reporter, warnedOnce) {
28
28
  const focused = given.find(option => option.focused);
29
29
  const started = Date.now();
30
- const answer = (choices) => {
31
- ctx.rest
32
- .request(CreateInteractionResponse, [raw.id, raw.token], {
33
- body: {
34
- type: InteractionCallbackType.ApplicationCommandAutocompleteResult,
35
- data: { choices },
36
- },
37
- auth: false,
38
- })
39
- .catch((error) => {
40
- if (!entry || !focused)
41
- return;
42
- const name = `${commandName(entry.command.path)} ${focused.name}`;
43
- if (error instanceof DiscordApiError &&
44
- error.code === UNKNOWN_INTERACTION) {
45
- // Too late, or the person typed on and Discord dropped this one.
46
- if (Date.now() - started < DEADLINE)
47
- return;
48
- reporter.onWarning(entry.file, `The suggestions for ${name} took ${((Date.now() - started) / 1000).toFixed(1)} s: Discord waits 3 seconds, then shows none. Make the autocomplete function faster.`);
30
+ const answer = (choices) => ctx.rest
31
+ .request(CreateInteractionResponse, [raw.id, raw.token], {
32
+ body: {
33
+ type: InteractionCallbackType.ApplicationCommandAutocompleteResult,
34
+ data: { choices },
35
+ },
36
+ auth: false,
37
+ })
38
+ .then(() => { }, (error) => {
39
+ if (!entry || !focused)
40
+ return;
41
+ const name = `${commandName(entry.command.path)} ${focused.name}`;
42
+ if (error instanceof DiscordApiError &&
43
+ error.code === UNKNOWN_INTERACTION) {
44
+ // Too late, or the person typed on and Discord dropped this one.
45
+ if (Date.now() - started < DEADLINE)
49
46
  return;
50
- }
51
- reporter.onError(entry.file, error);
52
- });
53
- };
47
+ reporter.onWarning(entry.file, `The suggestions for ${name} took ${((Date.now() - started) / 1000).toFixed(1)} s: Discord waits 3 seconds, then shows none. Make the autocomplete function faster.`);
48
+ return;
49
+ }
50
+ reporter.onError(entry.file, error);
51
+ });
54
52
  // The command, the option or its function is gone: nothing to suggest.
55
53
  const fn = focused && entry?.command.autocomplete[focused.name];
56
54
  if (!entry || !focused || !fn) {
57
- answer([]);
55
+ const sent = answer([]);
58
56
  if (entry && focused) {
59
57
  // Still registered with suggestions, but the file has no function.
60
58
  reporter.onWarning(entry.file, `Discord asked suggestions for ${commandName(entry.command.path)} ${focused.name}, which has none in "autocomplete": the command was registered with an older version of this file.`);
61
59
  }
62
- return;
60
+ return sent;
63
61
  }
64
62
  const { command, file } = entry;
65
63
  const name = `${commandName(command.path)} ${focused.name}`;
@@ -97,24 +95,22 @@ export function suggest(ctx, raw, entry, given, reporter, warnedOnce) {
97
95
  // Suggestions are shown to the person alone: their language.
98
96
  ...translation(ctx, raw.locale ?? null),
99
97
  });
100
- new Promise(resolve => resolve(fn(context)))
101
- .then(suggestions => {
102
- answer(toChoices(suggestions, {
103
- name,
104
- isText,
105
- integer: option.type === 'integer',
106
- warn: message => {
107
- // Said once per option: it happens on every keystroke.
108
- if (warnedOnce.has(`${file} ${name} ${message}`))
109
- return;
110
- warnedOnce.add(`${file} ${name} ${message}`);
111
- reporter.onWarning(file, `${name}: ${message}`);
112
- },
113
- }));
114
- })
98
+ return new Promise(resolve => resolve(fn(context)))
99
+ .then(suggestions => answer(toChoices(suggestions, {
100
+ name,
101
+ isText,
102
+ integer: option.type === 'integer',
103
+ warn: message => {
104
+ // Said once per option: it happens on every keystroke.
105
+ if (warnedOnce.has(`${file} ${name} ${message}`))
106
+ return;
107
+ warnedOnce.add(`${file} ${name} ${message}`);
108
+ reporter.onWarning(file, `${name}: ${message}`);
109
+ },
110
+ })))
115
111
  .catch((error) => {
116
112
  reporter.onError(file, error);
117
- answer([]);
113
+ return answer([]);
118
114
  });
119
115
  }
120
116
  /**
@@ -15,6 +15,9 @@ export declare class CommandRouter {
15
15
  /**
16
16
  * Handles an Interaction Create event, if it is a slash command being
17
17
  * used, or someone typing in one of its options with suggestions.
18
+ * Returns a promise of everything being done (the function run, the
19
+ * person answered), or `null` when the interaction is not for this
20
+ * router. Nothing of the bot waits for it: `chapterjs test` does.
18
21
  */
19
- dispatch(ctx: Context, raw: RawInteraction): void;
22
+ dispatch(ctx: Context, raw: RawInteraction): Promise<void> | null;
20
23
  }
@@ -42,15 +42,18 @@ export class CommandRouter {
42
42
  /**
43
43
  * Handles an Interaction Create event, if it is a slash command being
44
44
  * used, or someone typing in one of its options with suggestions.
45
+ * Returns a promise of everything being done (the function run, the
46
+ * person answered), or `null` when the interaction is not for this
47
+ * router. Nothing of the bot waits for it: `chapterjs test` does.
45
48
  */
46
49
  dispatch(ctx, raw) {
47
50
  const autocomplete = raw.type === InteractionType.ApplicationCommandAutocomplete;
48
51
  if (raw.type !== InteractionType.ApplicationCommand && !autocomplete) {
49
- return;
52
+ return null;
50
53
  }
51
54
  const data = raw.data;
52
55
  if (!data || data.type !== ApplicationCommandType.ChatInput)
53
- return;
56
+ return null;
54
57
  // `/mod roles add` comes as a command with nested options.
55
58
  const path = [data.name];
56
59
  let given = data.options ?? [];
@@ -60,13 +63,12 @@ export class CommandRouter {
60
63
  }
61
64
  const entry = this.#commands.get(path.join(' '));
62
65
  if (autocomplete) {
63
- suggest(ctx, raw, entry, given, this.#options, this.#warnedOnce);
64
- return;
66
+ return suggest(ctx, raw, entry, given, this.#options, this.#warnedOnce);
65
67
  }
66
68
  const name = commandName(path);
67
69
  const author = authorOf(raw);
68
70
  if (!author)
69
- return;
71
+ return null;
70
72
  const { user: _user, member: _member, message: _message, ...rest } = raw;
71
73
  const user = ctx.entities.user(author.user);
72
74
  const interaction = new CommandInteraction(ctx, rest, user, {
@@ -75,14 +77,12 @@ export class CommandRouter {
75
77
  });
76
78
  if (!entry) {
77
79
  // Still registered on Discord, but its file is gone or broken.
78
- refuse(interaction, 'unavailable');
79
- return;
80
+ return refuse(interaction, 'unavailable');
80
81
  }
81
82
  const { command, file } = entry;
82
83
  const placed = resolvePlace(ctx, raw, user, author.member, command.where, message => this.#options.onWarning(file, `${name} ${message}`));
83
84
  if (!placed.place) {
84
- refuse(interaction, placed.refusal, { what: 'command' });
85
- return;
85
+ return refuse(interaction, placed.refusal, { what: 'command' });
86
86
  }
87
87
  const { place } = placed;
88
88
  // What the member can do where the command was used, overwrites
@@ -90,11 +90,10 @@ export class CommandRouter {
90
90
  if (command.permissions !== 0n && author.member) {
91
91
  const missing = new Permissions(author.member.permissions ?? '0').missing(command.permissions);
92
92
  if (missing.length > 0) {
93
- refuse(interaction, 'needsPermissions', {
93
+ return refuse(interaction, 'needsPermissions', {
94
94
  permissions: missing.join(', '),
95
95
  count: missing.length,
96
96
  });
97
- return;
98
97
  }
99
98
  }
100
99
  // Used too soon: the person is told when, and nothing runs.
@@ -104,8 +103,7 @@ export class CommandRouter {
104
103
  guild: place.guild?.id,
105
104
  });
106
105
  if (again > 0) {
107
- this.#refuseUntil(interaction, again);
108
- return;
106
+ return this.#refuseUntil(interaction, again);
109
107
  }
110
108
  let options;
111
109
  try {
@@ -113,8 +111,7 @@ export class CommandRouter {
113
111
  }
114
112
  catch (error) {
115
113
  this.#options.onError(file, error);
116
- refuse(interaction, 'failed', { what: 'command' });
117
- return;
114
+ return refuse(interaction, 'failed', { what: 'command' });
118
115
  }
119
116
  remember(interaction, {
120
117
  guild: place.guild ?? undefined,
@@ -133,7 +130,7 @@ export class CommandRouter {
133
130
  ephemeral: command.ephemeral,
134
131
  })),
135
132
  });
136
- runInteraction({
133
+ return runInteraction({
137
134
  interaction,
138
135
  file,
139
136
  name,
@@ -159,12 +156,13 @@ export class CommandRouter {
159
156
  const when = timestamp(at, deletable ? TimestampStyle.Relative : TimestampStyle.LongDateShortTime);
160
157
  const refused = refuse(interaction, 'cooldown', { what: 'command', when });
161
158
  if (!deletable)
162
- return;
159
+ return refused;
163
160
  const timer = setTimeout(() => {
164
161
  refused.then(() => interaction.delete()).catch(() => { });
165
162
  }, wait);
166
163
  // Nothing of the bot waits for it: the process may stop meanwhile.
167
164
  timer.unref();
165
+ return refused;
168
166
  }
169
167
  /** The value of each declared option, as structures where it applies. */
170
168
  #readOptions(ctx, raw, data, given, { command }) {