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.
- package/README.md +65 -0
- package/dist/cli/build.js +14 -1
- package/dist/cli/cli.js +41 -6
- package/dist/cli/dev.js +29 -1
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +49 -7
- package/dist/cli/project.js +103 -19
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +57 -0
- package/dist/cli/start.js +43 -17
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +446 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/events/router.d.ts +9 -4
- package/dist/events/router.js +24 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- 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/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 -1
- package/dist/store/file-backend.js +29 -0
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +17 -0
- package/dist/store/memory-backend.js +82 -0
- 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 +11 -0
- package/dist/store/scope.js +23 -0
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +83 -33
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
package/dist/cli/project.js
CHANGED
|
@@ -16,13 +16,14 @@ import { componentDeclaration, findDuplicates, } from '../components/declaration
|
|
|
16
16
|
import { ComponentRouter } from '../components/router.js';
|
|
17
17
|
import { taskDeclaration } from '../tasks/declaration.js';
|
|
18
18
|
import { storeDeclaration } from '../store/declaration.js';
|
|
19
|
-
import {
|
|
19
|
+
import { rankingDeclaration, } from '../ranking/declaration.js';
|
|
20
|
+
import { nowhereToKeep, NowhereToKeepError, setStoreBackend, STORAGES, } from '../store/backend.js';
|
|
20
21
|
import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
|
|
21
22
|
import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
|
|
22
23
|
import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
|
|
23
24
|
import { translation } from '../messages/translate.js';
|
|
24
25
|
import { translateCommand, unknownCommands } from '../messages/commands.js';
|
|
25
|
-
import { TimerScheduler, } from '../tasks/scheduler.js';
|
|
26
|
+
import { runTask, TimerScheduler, } from '../tasks/scheduler.js';
|
|
26
27
|
import { createBot } from '../core/bot.js';
|
|
27
28
|
import { watchMemory } from '../core/memory.js';
|
|
28
29
|
import { GatewayIntent } from '../discord/intents.js';
|
|
@@ -68,15 +69,35 @@ export const DECLARATIONS = [
|
|
|
68
69
|
componentDeclaration,
|
|
69
70
|
taskDeclaration,
|
|
70
71
|
storeDeclaration,
|
|
72
|
+
rankingDeclaration,
|
|
71
73
|
presenceDeclaration,
|
|
72
74
|
languageDeclaration,
|
|
73
75
|
];
|
|
76
|
+
/**
|
|
77
|
+
* A promise of the files that nobody waited for (`store.set()` without
|
|
78
|
+
* `await`, in a `run` that is not `async`) rejects with nobody to tell:
|
|
79
|
+
* Node would stop the bot. Reported instead, with the file and the line,
|
|
80
|
+
* and what to write. A rejection from outside the files stays what it is:
|
|
81
|
+
* a bug of the framework, loud. Returns what stops watching.
|
|
82
|
+
*/
|
|
83
|
+
export function reportLooseRejections(cwd, log) {
|
|
84
|
+
const listener = (reason) => {
|
|
85
|
+
const where = locate(reason, cwd);
|
|
86
|
+
if (!where)
|
|
87
|
+
throw reason;
|
|
88
|
+
log.error(`${where.file}:${where.line} ${messageOf(reason)}\nThis call was not awaited: write await in front of it (and async before run), so that your function waits for it.`);
|
|
89
|
+
};
|
|
90
|
+
process.on('unhandledRejection', listener);
|
|
91
|
+
return () => {
|
|
92
|
+
process.off('unhandledRejection', listener);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
74
95
|
/** Whether the folder is a project: it has a `src` folder. */
|
|
75
96
|
export async function hasSources(cwd, log) {
|
|
76
97
|
if ((await stat(join(cwd, 'src')).catch(() => null))?.isDirectory()) {
|
|
77
98
|
return true;
|
|
78
99
|
}
|
|
79
|
-
log.error('There is no src folder here.\nRun this command in the folder of your bot (the one with package.json), or create a project with "pnpm create
|
|
100
|
+
log.error('There is no src folder here.\nRun this command in the folder of your bot (the one with package.json), or create a project with "pnpm create chapterjs".');
|
|
80
101
|
return false;
|
|
81
102
|
}
|
|
82
103
|
/** The intents that only bring what happens in private messages. */
|
|
@@ -108,10 +129,11 @@ function sameNames(entries, nameOf, what) {
|
|
|
108
129
|
seen.set(nameOf(entry), entry);
|
|
109
130
|
return true;
|
|
110
131
|
}
|
|
132
|
+
const kind = (one) => typeof what === 'string' ? what : what(one);
|
|
111
133
|
conflicts.push({
|
|
112
134
|
file: entry.file,
|
|
113
135
|
export: entry.export,
|
|
114
|
-
message: `There is already a ${
|
|
136
|
+
message: `There is already a ${kind(first)} named ${nameOf(entry)}, in ${first.file}: the name of the export is the name of the ${kind(entry)}, so rename one of them.`,
|
|
115
137
|
});
|
|
116
138
|
return false;
|
|
117
139
|
});
|
|
@@ -121,9 +143,18 @@ export function createProject(options) {
|
|
|
121
143
|
const { cwd, log } = options;
|
|
122
144
|
// Where `asset()` reads the files of the project from.
|
|
123
145
|
setPublicDir(cwd);
|
|
124
|
-
// Where the stores keep their data, for every store of the process
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
// Where the stores keep their data, for every store of the process: a
|
|
147
|
+
// backend per storage the project has one for.
|
|
148
|
+
const storeBackends = new Map();
|
|
149
|
+
for (const storage of STORAGES) {
|
|
150
|
+
const backend = options.stores?.[storage] ??
|
|
151
|
+
(storage === 'file'
|
|
152
|
+
? new FileStoreBackend(join(cwd, DATA_FOLDER))
|
|
153
|
+
: null);
|
|
154
|
+
setStoreBackend(storage, backend);
|
|
155
|
+
if (backend)
|
|
156
|
+
storeBackends.set(storage, backend);
|
|
157
|
+
}
|
|
127
158
|
const report = (failure) => {
|
|
128
159
|
const where = locate(failure.error, cwd);
|
|
129
160
|
log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
|
|
@@ -150,6 +181,7 @@ export function createProject(options) {
|
|
|
150
181
|
let components = new Map();
|
|
151
182
|
let tasks = new Map();
|
|
152
183
|
let stores = new Map();
|
|
184
|
+
let rankings = new Map();
|
|
153
185
|
let presence = null;
|
|
154
186
|
/** The files the presence was declared in, to keep it when they break. */
|
|
155
187
|
let presenceFiles = new Set();
|
|
@@ -182,7 +214,20 @@ export function createProject(options) {
|
|
|
182
214
|
get stores() {
|
|
183
215
|
return stores;
|
|
184
216
|
},
|
|
185
|
-
|
|
217
|
+
storeBackends,
|
|
218
|
+
get rankings() {
|
|
219
|
+
return rankings;
|
|
220
|
+
},
|
|
221
|
+
redisStores: () => ({
|
|
222
|
+
stores: [...stores.values()]
|
|
223
|
+
.filter(entry => entry.store.storage === 'redis')
|
|
224
|
+
.map(entry => entry.store.name)
|
|
225
|
+
.sort(),
|
|
226
|
+
rankings: [...rankings.values()]
|
|
227
|
+
.filter(entry => entry.ranking.storage === 'redis')
|
|
228
|
+
.map(entry => entry.ranking.name)
|
|
229
|
+
.sort(),
|
|
230
|
+
}),
|
|
186
231
|
get presence() {
|
|
187
232
|
return presence;
|
|
188
233
|
},
|
|
@@ -213,12 +258,13 @@ export function createProject(options) {
|
|
|
213
258
|
// then read, kind by kind.
|
|
214
259
|
const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
|
|
215
260
|
const read = (declaration) => readDeclarations(sources.modules, declaration);
|
|
216
|
-
const [eventItems, commandItems, componentItems, taskItems, storeItems, presenceItems, languageItems,] = [
|
|
261
|
+
const [eventItems, commandItems, componentItems, taskItems, storeItems, rankingItems, presenceItems, languageItems,] = [
|
|
217
262
|
read(eventDeclaration),
|
|
218
263
|
read(commandDeclaration),
|
|
219
264
|
read(componentDeclaration),
|
|
220
265
|
read(taskDeclaration),
|
|
221
266
|
read(storeDeclaration),
|
|
267
|
+
read(rankingDeclaration),
|
|
222
268
|
read(presenceDeclaration),
|
|
223
269
|
read(languageDeclaration),
|
|
224
270
|
];
|
|
@@ -272,15 +318,34 @@ export function createProject(options) {
|
|
|
272
318
|
], entry => entry.task.name, 'task');
|
|
273
319
|
tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
|
|
274
320
|
scheduler.set(tasks.values());
|
|
275
|
-
|
|
321
|
+
const keptOf = (entry) => 'store' in entry ? entry.store : entry.ranking;
|
|
276
322
|
const sameStore = sameNames([
|
|
277
323
|
...next(stores, storeItems, item => ({
|
|
278
324
|
file: item.file,
|
|
279
325
|
export: item.export,
|
|
280
326
|
store: item.value,
|
|
281
327
|
})).values(),
|
|
282
|
-
|
|
283
|
-
|
|
328
|
+
...next(rankings, rankingItems, item => ({
|
|
329
|
+
file: item.file,
|
|
330
|
+
export: item.export,
|
|
331
|
+
ranking: item.value,
|
|
332
|
+
})).values(),
|
|
333
|
+
], entry => keptOf(entry).name, entry => ('store' in entry ? 'store' : 'ranking'));
|
|
334
|
+
// What is kept where nothing keeps it (Redis without REDIS_URL) is
|
|
335
|
+
// reported and left out: it says so itself when used.
|
|
336
|
+
const unkept = sameStore.valid.filter(entry => !storeBackends.has(keptOf(entry).storage));
|
|
337
|
+
const kept = sameStore.valid.filter(entry => !unkept.includes(entry));
|
|
338
|
+
stores = new Map(kept
|
|
339
|
+
.filter((entry) => 'store' in entry)
|
|
340
|
+
.map(entry => [keyOf(entry), entry]));
|
|
341
|
+
rankings = new Map(kept
|
|
342
|
+
.filter((entry) => 'ranking' in entry)
|
|
343
|
+
.map(entry => [keyOf(entry), entry]));
|
|
344
|
+
const nowhere = unkept.map(entry => ({
|
|
345
|
+
file: entry.file,
|
|
346
|
+
export: entry.export,
|
|
347
|
+
error: new NowhereToKeepError(nowhereToKeep(keptOf(entry).storage, keptOf(entry).name, 'store' in entry ? 'store' : 'ranking')),
|
|
348
|
+
}));
|
|
284
349
|
// One presence. A second one is left out.
|
|
285
350
|
const [first, ...extra] = presenceItems.loaded;
|
|
286
351
|
if (first)
|
|
@@ -369,10 +434,12 @@ export function createProject(options) {
|
|
|
369
434
|
...componentItems.failed,
|
|
370
435
|
...taskItems.failed,
|
|
371
436
|
...storeItems.failed,
|
|
437
|
+
...rankingItems.failed,
|
|
372
438
|
...presenceItems.failed,
|
|
373
439
|
...languageItems.failed,
|
|
374
440
|
...badTexts,
|
|
375
441
|
...twice,
|
|
442
|
+
...nowhere,
|
|
376
443
|
...[
|
|
377
444
|
...conflicts,
|
|
378
445
|
...unique.conflicts,
|
|
@@ -404,6 +471,9 @@ export function createProject(options) {
|
|
|
404
471
|
: []),
|
|
405
472
|
...(tasks.size > 0 ? [count(tasks.size, 'task', 'tasks')] : []),
|
|
406
473
|
...(stores.size > 0 ? [count(stores.size, 'store', 'stores')] : []),
|
|
474
|
+
...(rankings.size > 0
|
|
475
|
+
? [count(rankings.size, 'ranking', 'rankings')]
|
|
476
|
+
: []),
|
|
407
477
|
...(presence ? ['a presence'] : []),
|
|
408
478
|
...(messages
|
|
409
479
|
? [
|
|
@@ -417,11 +487,15 @@ export function createProject(options) {
|
|
|
417
487
|
components.size === 0 &&
|
|
418
488
|
tasks.size === 0 &&
|
|
419
489
|
stores.size === 0 &&
|
|
490
|
+
rankings.size === 0 &&
|
|
420
491
|
presence === null &&
|
|
421
492
|
messages === null,
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
.
|
|
493
|
+
// A file declaring several events that need the intent is named once.
|
|
494
|
+
filesNeeding: intent => [
|
|
495
|
+
...new Set([...events.values()]
|
|
496
|
+
.filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
|
|
497
|
+
.map(({ file }) => file)),
|
|
498
|
+
],
|
|
425
499
|
intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
|
|
426
500
|
async connect(connection) {
|
|
427
501
|
const created = createBot({
|
|
@@ -449,14 +523,17 @@ export function createProject(options) {
|
|
|
449
523
|
},
|
|
450
524
|
beforeDispatch: (event, data) => router.before(created.ctx, event, data),
|
|
451
525
|
onDispatch(event, data, info) {
|
|
452
|
-
router.dispatch(created.ctx, event, data, {
|
|
526
|
+
const handled = router.dispatch(created.ctx, event, data, {
|
|
453
527
|
joined: info.joined,
|
|
454
528
|
before: info.before,
|
|
455
529
|
});
|
|
530
|
+
options.observe?.event?.(event, data, handled);
|
|
456
531
|
if (event === 'INTERACTION_CREATE') {
|
|
457
532
|
const interaction = data;
|
|
458
|
-
commandRouter.dispatch(created.ctx, interaction)
|
|
459
|
-
|
|
533
|
+
const answered = commandRouter.dispatch(created.ctx, interaction) ??
|
|
534
|
+
componentRouter.dispatch(created.ctx, interaction) ??
|
|
535
|
+
Promise.resolve();
|
|
536
|
+
options.observe?.interaction?.(interaction, answered);
|
|
460
537
|
}
|
|
461
538
|
},
|
|
462
539
|
onEvent(event) {
|
|
@@ -518,9 +595,16 @@ export function createProject(options) {
|
|
|
518
595
|
});
|
|
519
596
|
},
|
|
520
597
|
stopTasks: () => scheduler.stop(),
|
|
598
|
+
runTask(entry, bot, now = new Date()) {
|
|
599
|
+
const user = bot.ctx.cache.users.get(bot.ctx.self.userId);
|
|
600
|
+
return runTask(entry, { user, guilds: bot.ctx.cache.guilds, ...translation(bot.ctx, null) }, now, reporter.onError);
|
|
601
|
+
},
|
|
521
602
|
// The bot looks after its own memory: it remembers less before memory
|
|
522
603
|
// is full, and only speaks up when it can't give up anything more.
|
|
523
|
-
closeStores: () =>
|
|
604
|
+
closeStores: async () => {
|
|
605
|
+
for (const backend of storeBackends.values())
|
|
606
|
+
await backend.close();
|
|
607
|
+
},
|
|
524
608
|
watchMemory: current => watchMemory({
|
|
525
609
|
cache: () => current()?.ctx.cache,
|
|
526
610
|
onTightened: ({ members, forgotten }, { used, limit }) => log.info(`Memory was getting full (${megabytes(used)} of ${megabytes(limit)}): the bot now remembers ${members} members per server at most, and forgot ${forgotten} members and users it had not seen for the longest. Nothing changes for your code.`),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RedisAddress } from '../redis/url.js';
|
|
2
|
+
import { RedisStoreBackend } from '../store/redis-backend.js';
|
|
3
|
+
import type { Log } from './log.js';
|
|
4
|
+
import type { Project } from './project.js';
|
|
5
|
+
/**
|
|
6
|
+
* The backend of the stores kept in Redis. Nothing is connected until a
|
|
7
|
+
* store is used, or `checkRedis()` asks; an outage is said once with `⚠`
|
|
8
|
+
* and its end with `↻`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redisStores(address: RedisAddress, log: Log, options?: {
|
|
11
|
+
prefix?: string;
|
|
12
|
+
}): RedisStoreBackend;
|
|
13
|
+
/**
|
|
14
|
+
* The `ℹ` line naming the stores and rankings kept in Redis, or `null`
|
|
15
|
+
* without any: "The store sessions and the ranking xp are kept in Redis…".
|
|
16
|
+
*/
|
|
17
|
+
export declare function describeRedisStores(project: Project, backend: RedisStoreBackend | null): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Makes sure Redis answers before the bot goes online, when a store is
|
|
20
|
+
* kept there: a Redis that can't be reached is an error now, not when
|
|
21
|
+
* someone uses the bot.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkRedis(project: Project, backend: RedisStoreBackend | null, log: Log): Promise<boolean>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// What `dev` and `start` share about Redis: the backend made of
|
|
2
|
+
// `REDIS_URL`, checked before Discord is reached when a store needs it,
|
|
3
|
+
// and the line that says which stores are kept there.
|
|
4
|
+
import { RedisClient } from '../redis/client.js';
|
|
5
|
+
import { RedisStoreBackend } from '../store/redis-backend.js';
|
|
6
|
+
/**
|
|
7
|
+
* The backend of the stores kept in Redis. Nothing is connected until a
|
|
8
|
+
* store is used, or `checkRedis()` asks; an outage is said once with `⚠`
|
|
9
|
+
* and its end with `↻`.
|
|
10
|
+
*/
|
|
11
|
+
export function redisStores(address, log, options = {}) {
|
|
12
|
+
const client = new RedisClient(address, {
|
|
13
|
+
onError: message => log.warn(message),
|
|
14
|
+
onReconnect: () => log.reload(`Redis at ${client.where} is back`),
|
|
15
|
+
});
|
|
16
|
+
return new RedisStoreBackend(client, options);
|
|
17
|
+
}
|
|
18
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
19
|
+
const and = (items) => items.length <= 1
|
|
20
|
+
? (items[0] ?? '')
|
|
21
|
+
: `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
22
|
+
/**
|
|
23
|
+
* The `ℹ` line naming the stores and rankings kept in Redis, or `null`
|
|
24
|
+
* without any: "The store sessions and the ranking xp are kept in Redis…".
|
|
25
|
+
*/
|
|
26
|
+
export function describeRedisStores(project, backend) {
|
|
27
|
+
const { stores, rankings } = project.redisStores();
|
|
28
|
+
const total = stores.length + rankings.length;
|
|
29
|
+
if (!backend || total === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const group = (one, many, names) => names.length === 0
|
|
32
|
+
? []
|
|
33
|
+
: [`the ${names.length === 1 ? one : many} ${and(names)}`];
|
|
34
|
+
const list = and([
|
|
35
|
+
...group('store', 'stores', stores),
|
|
36
|
+
...group('ranking', 'rankings', rankings),
|
|
37
|
+
]);
|
|
38
|
+
return `${list[0].toUpperCase()}${list.slice(1)} ${total === 1 ? 'is' : 'are'} kept in Redis at ${backend.client.where}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Makes sure Redis answers before the bot goes online, when a store is
|
|
42
|
+
* kept there: a Redis that can't be reached is an error now, not when
|
|
43
|
+
* someone uses the bot.
|
|
44
|
+
*/
|
|
45
|
+
export async function checkRedis(project, backend, log) {
|
|
46
|
+
const { stores, rankings } = project.redisStores();
|
|
47
|
+
if (!backend || stores.length + rankings.length === 0)
|
|
48
|
+
return true;
|
|
49
|
+
try {
|
|
50
|
+
await backend.client.ping();
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
log.error(`${error instanceof Error ? error.message.replace(/ Trying again in \d+ s\.$/, '') : String(error)}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
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
61
|
joinsVoice: built.info.voice === true,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
process.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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,
|
|
@@ -132,6 +152,9 @@ export async function start(options) {
|
|
|
132
152
|
log.success(`Online as ${user} in ${guilds} ${guilds === 1 ? 'server' : 'servers'}${split ? ` (${split})` : ''}`);
|
|
133
153
|
log.info(`Intents computed from your files: ${describeIntents(intents)}`);
|
|
134
154
|
(project.isEmpty() ? log.info : log.success)(project.summary());
|
|
155
|
+
const kept = describeRedisStores(project, redis);
|
|
156
|
+
if (kept)
|
|
157
|
+
log.info(kept);
|
|
135
158
|
};
|
|
136
159
|
if (processes === 1) {
|
|
137
160
|
return await runAlone(project, options, connection, online);
|
|
@@ -146,7 +169,7 @@ export async function start(options) {
|
|
|
146
169
|
signal,
|
|
147
170
|
identifyInterval,
|
|
148
171
|
write: options.write,
|
|
149
|
-
stores: project.
|
|
172
|
+
stores: project.storeBackends.get('file'),
|
|
150
173
|
onReady: ({ user, guilds }) => online(user, guilds),
|
|
151
174
|
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
175
|
});
|
|
@@ -162,6 +185,9 @@ export async function start(options) {
|
|
|
162
185
|
explain(error, log);
|
|
163
186
|
return assignment ? REFUSED : 1;
|
|
164
187
|
}
|
|
188
|
+
finally {
|
|
189
|
+
loose();
|
|
190
|
+
}
|
|
165
191
|
}
|
|
166
192
|
/** Runs a bot until it is stopped or Discord refuses it. */
|
|
167
193
|
async function run(project, connection, stopped, connected, { tasks = true } = {}) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Log } from './log.js';
|
|
2
|
+
export interface TestOptions {
|
|
3
|
+
/** The folder of the project. */
|
|
4
|
+
cwd: string;
|
|
5
|
+
/** The version of the framework. */
|
|
6
|
+
version: string;
|
|
7
|
+
log: Log;
|
|
8
|
+
/** How long a scenario may take, in milliseconds. Default: 10 s. */
|
|
9
|
+
scenarioTimeout?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a person is watching a terminal: the check in progress is then
|
|
12
|
+
* shown, case by case, and replaced by its result once done.
|
|
13
|
+
*/
|
|
14
|
+
progress?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* What a terminal shows while a file is tested: `↻ Testing src/close.ts`,
|
|
18
|
+
* then one line per thing and case as it is tried. Once the file is done,
|
|
19
|
+
* those lines are erased and its result takes their place, so a report
|
|
20
|
+
* that passed reads as if nothing had moved. When nobody is watching (the
|
|
21
|
+
* output is not a terminal), nothing is shown meanwhile.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkProgress(enabled: boolean, log: Pick<Log, 'reload'>, write: (text: string) => void): {
|
|
24
|
+
start(title: string): void;
|
|
25
|
+
step(label: string): void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
};
|
|
28
|
+
/** Resolves with the exit code of the process. */
|
|
29
|
+
export declare function test(options: TestOptions): Promise<number>;
|