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.
- package/README.md +5 -3
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +17 -4
- package/dist/cli/dev.js +37 -6
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +61 -9
- package/dist/cli/project.js +250 -44
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +69 -0
- package/dist/cli/start.js +54 -18
- package/dist/cli/test.js +112 -18
- 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 +265 -243
- package/dist/events/registry.js +131 -100
- package/dist/index.d.ts +7 -4
- package/dist/index.js +2 -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/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -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/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -2
- package/dist/store/file-backend.js +47 -1
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +4 -2
- package/dist/store/memory-backend.js +27 -1
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +17 -1
- package/dist/store/scope.js +32 -3
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +79 -32
- 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/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Nothing is reloaded, commands are registered for every server, and a
|
|
3
3
|
// large bot is spread over several processes by itself.
|
|
4
4
|
import { IpcStoreBackend } from '../store/ipc-backend.js';
|
|
5
|
+
import { NowhereToKeepError } from '../store/backend.js';
|
|
6
|
+
import { checkRedis, describeRedisStores, redisStores } from './redis.js';
|
|
5
7
|
import { registerCommands } from '../commands/register.js';
|
|
6
8
|
import { buildCommands } from '../commands/tree.js';
|
|
7
9
|
import { GLOBAL_REQUESTS_PER_SECOND } from '../discord/api.js';
|
|
@@ -13,7 +15,7 @@ import { connectToPrimary, planProcesses, REFUSED, runCluster, } from './cluster
|
|
|
13
15
|
import { loadBundle, readBuild } from './build.js';
|
|
14
16
|
import { readStartEnv } from './env.js';
|
|
15
17
|
import { describeIntents, ensurePrivilegedIntents, fetchApplication, } from './preflight.js';
|
|
16
|
-
import { createProject, explain } from './project.js';
|
|
18
|
+
import { createProject, explain, reportLooseRejections, } from './project.js';
|
|
17
19
|
/** Runs until stopped. Resolves with the exit code of the process. */
|
|
18
20
|
export async function start(options) {
|
|
19
21
|
const { cwd, log, signal } = options;
|
|
@@ -49,25 +51,30 @@ export async function start(options) {
|
|
|
49
51
|
}
|
|
50
52
|
return 1;
|
|
51
53
|
}
|
|
54
|
+
// Every process reaches Redis itself: it is made to be shared.
|
|
55
|
+
const redis = read.env.redis ? redisStores(read.env.redis, log) : null;
|
|
52
56
|
const project = createProject({
|
|
53
57
|
cwd,
|
|
54
58
|
version: options.version,
|
|
55
59
|
log,
|
|
56
60
|
built: files,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
process.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
uses: built.info.uses ?? {},
|
|
62
|
+
stores: {
|
|
63
|
+
// A process started by another keeps no file: the first one does,
|
|
64
|
+
// for all of them.
|
|
65
|
+
...(assignment
|
|
66
|
+
? {
|
|
67
|
+
file: new IpcStoreBackend({
|
|
68
|
+
send: message => {
|
|
69
|
+
if (process.connected)
|
|
70
|
+
process.send?.(message);
|
|
71
|
+
},
|
|
72
|
+
on: listener => process.on('message', listener),
|
|
73
|
+
}),
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
...(redis ? { redis } : {}),
|
|
77
|
+
},
|
|
71
78
|
...(options.deferAfter === undefined
|
|
72
79
|
? {}
|
|
73
80
|
: { deferAfter: options.deferAfter }),
|
|
@@ -77,7 +84,12 @@ export async function start(options) {
|
|
|
77
84
|
for (const failure of failures)
|
|
78
85
|
project.report(failure);
|
|
79
86
|
if (!assignment) {
|
|
80
|
-
|
|
87
|
+
// A store kept in Redis without REDIS_URL is about the environment,
|
|
88
|
+
// not the build.
|
|
89
|
+
const unkept = failures.every(failure => failure.error instanceof NowhereToKeepError);
|
|
90
|
+
log.error(unkept
|
|
91
|
+
? 'The bot was not started. Give REDIS_URL to the bot the way your host does it (its "environment variables" or "secrets"), or in a .env file in this folder.'
|
|
92
|
+
: `${failures.length === 1 ? 'This file' : `These ${failures.length} files`} of the build can't run any more, so the bot was not started. Run "chapterjs build" again: it says what to fix.`);
|
|
81
93
|
}
|
|
82
94
|
return 1;
|
|
83
95
|
}
|
|
@@ -94,10 +106,18 @@ export async function start(options) {
|
|
|
94
106
|
? { guildFilter: (id) => id !== devGuildId }
|
|
95
107
|
: {}),
|
|
96
108
|
};
|
|
109
|
+
// A promise of the files left alone is an error of the file, not the end
|
|
110
|
+
// of the bot: in every process, since every one runs the files.
|
|
111
|
+
const loose = reportLooseRejections(cwd, log);
|
|
97
112
|
try {
|
|
98
113
|
if (assignment)
|
|
99
114
|
return await runShare(project, options, connection, assignment);
|
|
100
|
-
// 3. What Discord must agree with, checked once for every
|
|
115
|
+
// 3. What Redis, then Discord, must agree with, checked once for every
|
|
116
|
+
// process.
|
|
117
|
+
if (!(await checkRedis(project, redis, log))) {
|
|
118
|
+
await project.closeStores();
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
101
121
|
const rest = new RestClient({
|
|
102
122
|
token,
|
|
103
123
|
version: options.version,
|
|
@@ -124,14 +144,24 @@ export async function start(options) {
|
|
|
124
144
|
throw new SessionLimitError(plan.shards, limit.remaining, limit.reset_after);
|
|
125
145
|
}
|
|
126
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
|
+
}
|
|
127
150
|
const online = (user, guilds) => {
|
|
128
151
|
const split = [
|
|
129
152
|
...(plan.shards > 1 ? [`${plan.shards} shards`] : []),
|
|
130
153
|
...(processes > 1 ? [`${processes} processes`] : []),
|
|
131
154
|
].join(', ');
|
|
132
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
|
+
}
|
|
133
160
|
log.info(`Intents computed from your files: ${describeIntents(intents)}`);
|
|
134
161
|
(project.isEmpty() ? log.info : log.success)(project.summary());
|
|
162
|
+
const kept = describeRedisStores(project, redis);
|
|
163
|
+
if (kept)
|
|
164
|
+
log.info(kept);
|
|
135
165
|
};
|
|
136
166
|
if (processes === 1) {
|
|
137
167
|
return await runAlone(project, options, connection, online);
|
|
@@ -146,7 +176,7 @@ export async function start(options) {
|
|
|
146
176
|
signal,
|
|
147
177
|
identifyInterval,
|
|
148
178
|
write: options.write,
|
|
149
|
-
stores: project.
|
|
179
|
+
stores: project.storeBackends.get('file'),
|
|
150
180
|
onReady: ({ user, guilds }) => online(user, guilds),
|
|
151
181
|
onRestart: (index, why) => log.warn(`Process ${index + 1} of ${processes} stopped by itself (${why}): starting it again. Its servers are back in a moment.`),
|
|
152
182
|
});
|
|
@@ -162,6 +192,9 @@ export async function start(options) {
|
|
|
162
192
|
explain(error, log);
|
|
163
193
|
return assignment ? REFUSED : 1;
|
|
164
194
|
}
|
|
195
|
+
finally {
|
|
196
|
+
loose();
|
|
197
|
+
}
|
|
165
198
|
}
|
|
166
199
|
/** Runs a bot until it is stopped or Discord refuses it. */
|
|
167
200
|
async function run(project, connection, stopped, connected, { tasks = true } = {}) {
|
|
@@ -176,6 +209,8 @@ async function run(project, connection, stopped, connected, { tasks = true } = {
|
|
|
176
209
|
// A task runs once for the whole bot: in one process only.
|
|
177
210
|
if (tasks)
|
|
178
211
|
project.startTasks(() => bot);
|
|
212
|
+
// The presence is shown by every process, on its own shards.
|
|
213
|
+
project.startPresence(() => bot);
|
|
179
214
|
return await Promise.race([
|
|
180
215
|
stopped.then(() => 'stopped'),
|
|
181
216
|
refused,
|
|
@@ -183,6 +218,7 @@ async function run(project, connection, stopped, connected, { tasks = true } = {
|
|
|
183
218
|
}
|
|
184
219
|
finally {
|
|
185
220
|
project.stopTasks();
|
|
221
|
+
project.stopPresence();
|
|
186
222
|
memory.stop();
|
|
187
223
|
await bot?.close();
|
|
188
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,8 +66,10 @@ 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
|
-
stores.
|
|
72
|
+
stores.reset();
|
|
71
73
|
observer.reset();
|
|
72
74
|
const world = new World();
|
|
73
75
|
await world.start();
|
|
@@ -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,13 +181,18 @@ 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({
|
|
179
190
|
cwd,
|
|
180
191
|
version: options.version,
|
|
181
192
|
log: capture.log,
|
|
182
|
-
|
|
193
|
+
// Every store, wherever it says it keeps its data, is in memory here:
|
|
194
|
+
// a test never needs a Redis, and starts from nothing.
|
|
195
|
+
stores: { file: stores, redis: stores },
|
|
183
196
|
observe: observer.observe,
|
|
184
197
|
});
|
|
185
198
|
await writeTypes(cwd);
|
|
@@ -208,13 +221,23 @@ async function run(options, src, output) {
|
|
|
208
221
|
// has one line per file: what was checked in it only shows when
|
|
209
222
|
// something failed.
|
|
210
223
|
const { checks, notSimulated } = smokeChecks(project);
|
|
211
|
-
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 = [];
|
|
212
235
|
const progress = checkProgress(options.progress === true, log, text => output.report(() => process.stdout.write(text)));
|
|
213
236
|
try {
|
|
214
237
|
// The ready event runs first, as it does online; its result is
|
|
215
238
|
// reported with the file it comes from.
|
|
216
|
-
project.ready(smoke.session.bot);
|
|
217
|
-
await smoke.session.settle();
|
|
239
|
+
project.ready(smoke.current.session.bot);
|
|
240
|
+
await smoke.current.session.settle();
|
|
218
241
|
const atStart = problems(capture.take());
|
|
219
242
|
const readyPrinted = output.take();
|
|
220
243
|
const readyFiles = [
|
|
@@ -249,8 +272,10 @@ async function run(options, src, output) {
|
|
|
249
272
|
}
|
|
250
273
|
for (const check of own) {
|
|
251
274
|
progress.step(check.subject);
|
|
252
|
-
const result = await runCheck(check, smoke
|
|
275
|
+
const result = await runCheck(check, smoke, capture, cwd, label => progress.step(`${check.subject}, ${label}`));
|
|
253
276
|
const wrote = output.take();
|
|
277
|
+
if (result.notSimulated)
|
|
278
|
+
voiceSkipped.push(`${check.subject} (${file})`);
|
|
254
279
|
if (result.failed.length === 0)
|
|
255
280
|
passed++;
|
|
256
281
|
else {
|
|
@@ -283,7 +308,10 @@ async function run(options, src, output) {
|
|
|
283
308
|
}
|
|
284
309
|
}
|
|
285
310
|
finally {
|
|
286
|
-
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.`);
|
|
287
315
|
}
|
|
288
316
|
if (notSimulated.length > 0) {
|
|
289
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(', ')}.`);
|
|
@@ -385,34 +413,100 @@ function report(failure, cwd, log) {
|
|
|
385
413
|
const found = locate(failure.error, cwd);
|
|
386
414
|
log.error(`${found ? `${found.file}:${found.line}` : siteName(failure)} ${messageOf(failure.error)}`);
|
|
387
415
|
}
|
|
388
|
-
/**
|
|
389
|
-
|
|
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 = () => { }) {
|
|
390
433
|
const failed = [];
|
|
391
434
|
const warnings = [];
|
|
435
|
+
let notSimulated = false;
|
|
392
436
|
for (const one of check.cases) {
|
|
393
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;
|
|
394
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));
|
|
395
447
|
let thrown = null;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
catch (error) {
|
|
448
|
+
let abandoned = false;
|
|
449
|
+
const fail = (error) => {
|
|
400
450
|
thrown =
|
|
401
451
|
error instanceof TestFailure
|
|
402
452
|
? error.message
|
|
403
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
|
+
}
|
|
404
469
|
}
|
|
470
|
+
if (!outcome.late && outcome.failed)
|
|
471
|
+
fail(outcome.error);
|
|
405
472
|
const captured = problems(capture.take());
|
|
406
473
|
const messages = [...(thrown ? [thrown] : []), ...captured.failures];
|
|
407
|
-
|
|
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) {
|
|
408
481
|
failed.push({ label: one.label, message: messages.join('; ') });
|
|
409
482
|
}
|
|
410
483
|
for (const warning of captured.warnings) {
|
|
411
484
|
if (!warnings.includes(warning))
|
|
412
485
|
warnings.push(warning);
|
|
413
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);
|
|
414
509
|
}
|
|
415
|
-
return { failed, warnings };
|
|
416
510
|
}
|
|
417
511
|
/** Loads the test files and reads their scenarios; what fails is reported. */
|
|
418
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[]>;
|