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/project.js
CHANGED
|
@@ -7,7 +7,10 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { setPublicDir } from '../assets/asset.js';
|
|
8
8
|
import { listPublic, publicDeclarations } from '../assets/public.js';
|
|
9
9
|
import { MissingForEvent } from '../events/registry.js';
|
|
10
|
-
import {
|
|
10
|
+
import { VOICE_USE } from '../voice/usage.js';
|
|
11
|
+
import { MESSAGES_USE, REACTIONS_USE } from '../waiting/usage.js';
|
|
12
|
+
import { MemoryWaiters } from '../waiting/waiters.js';
|
|
13
|
+
import { scanUses, usesIntents } from '../loader/usage.js';
|
|
11
14
|
import { writeGenerated } from '../loader/generated.js';
|
|
12
15
|
import { commandDeclaration, scanCommands } from '../commands/declaration.js';
|
|
13
16
|
import { CommandRouter } from '../commands/router.js';
|
|
@@ -16,9 +19,12 @@ import { componentDeclaration, findDuplicates, } from '../components/declaration
|
|
|
16
19
|
import { ComponentRouter } from '../components/router.js';
|
|
17
20
|
import { taskDeclaration } from '../tasks/declaration.js';
|
|
18
21
|
import { storeDeclaration } from '../store/declaration.js';
|
|
19
|
-
import {
|
|
22
|
+
import { rankingDeclaration, } from '../ranking/declaration.js';
|
|
23
|
+
import { nowhereToKeep, NowhereToKeepError, setStoreBackend, STORAGES, } from '../store/backend.js';
|
|
20
24
|
import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
|
|
21
|
-
import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
|
|
25
|
+
import { checkText, DEFAULT_PRESENCE, presenceDeclaration, readActivities, } from '../presence/declaration.js';
|
|
26
|
+
import { PresenceRotation } from '../presence/rotation.js';
|
|
27
|
+
import { GetCurrentApplication } from '../discord/endpoints.js';
|
|
22
28
|
import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
|
|
23
29
|
import { translation } from '../messages/translate.js';
|
|
24
30
|
import { translateCommand, unknownCommands } from '../messages/commands.js';
|
|
@@ -29,7 +35,7 @@ import { GatewayIntent } from '../discord/intents.js';
|
|
|
29
35
|
import { eventDeclaration } from '../events/declaration.js';
|
|
30
36
|
import { EventRouter, intentsFor, intentsOf, limitsFor, } from '../events/router.js';
|
|
31
37
|
import { GatewayFatalError, SessionLimitError } from '../gateway/errors.js';
|
|
32
|
-
import { loadSources, readDeclarations, siteName, } from '../loader/loader.js';
|
|
38
|
+
import { loadSources, bySite, readDeclarations, siteKey, siteName, } from '../loader/loader.js';
|
|
33
39
|
import { locate, messageOf } from '../loader/locate.js';
|
|
34
40
|
import { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from '../rest/errors.js';
|
|
35
41
|
import { PreflightFailure } from './preflight.js';
|
|
@@ -68,9 +74,34 @@ export const DECLARATIONS = [
|
|
|
68
74
|
componentDeclaration,
|
|
69
75
|
taskDeclaration,
|
|
70
76
|
storeDeclaration,
|
|
77
|
+
rankingDeclaration,
|
|
71
78
|
presenceDeclaration,
|
|
72
79
|
languageDeclaration,
|
|
73
80
|
];
|
|
81
|
+
/**
|
|
82
|
+
* What the files may do that only their code shows, each with the intents
|
|
83
|
+
* it needs: read in `src/` (dev, at every load) or kept by the build.
|
|
84
|
+
*/
|
|
85
|
+
export const CODE_USES = [VOICE_USE, MESSAGES_USE, REACTIONS_USE];
|
|
86
|
+
/**
|
|
87
|
+
* A promise of the files that nobody waited for (`store.set()` without
|
|
88
|
+
* `await`, in a `run` that is not `async`) rejects with nobody to tell:
|
|
89
|
+
* Node would stop the bot. Reported instead, with the file and the line,
|
|
90
|
+
* and what to write. A rejection from outside the files stays what it is:
|
|
91
|
+
* a bug of the framework, loud. Returns what stops watching.
|
|
92
|
+
*/
|
|
93
|
+
export function reportLooseRejections(cwd, log) {
|
|
94
|
+
const listener = (reason) => {
|
|
95
|
+
const where = locate(reason, cwd);
|
|
96
|
+
if (!where)
|
|
97
|
+
throw reason;
|
|
98
|
+
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.`);
|
|
99
|
+
};
|
|
100
|
+
process.on('unhandledRejection', listener);
|
|
101
|
+
return () => {
|
|
102
|
+
process.off('unhandledRejection', listener);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
74
105
|
/** Whether the folder is a project: it has a `src` folder. */
|
|
75
106
|
export async function hasSources(cwd, log) {
|
|
76
107
|
if ((await stat(join(cwd, 'src')).catch(() => null))?.isDirectory()) {
|
|
@@ -86,32 +117,25 @@ const PRIVATE_INTENTS = GatewayIntent.DirectMessages |
|
|
|
86
117
|
GatewayIntent.DirectMessagePolls;
|
|
87
118
|
const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
88
119
|
/** What tells one declaration from another: its file and its export. */
|
|
89
|
-
const keyOf =
|
|
120
|
+
const keyOf = siteKey;
|
|
90
121
|
/**
|
|
91
122
|
* Two declarations with the same name, where the name is the export's
|
|
92
123
|
* (a task): the later one is left out, with a message saying so.
|
|
93
124
|
*/
|
|
94
|
-
function sameNames(entries, nameOf, what) {
|
|
125
|
+
function sameNames(entries, nameOf, what, running) {
|
|
95
126
|
const seen = new Map();
|
|
96
127
|
const conflicts = [];
|
|
97
|
-
const valid =
|
|
98
|
-
.sort((a, b) => a.file === b.file
|
|
99
|
-
? a.export < b.export
|
|
100
|
-
? -1
|
|
101
|
-
: 1
|
|
102
|
-
: a.file < b.file
|
|
103
|
-
? -1
|
|
104
|
-
: 1)
|
|
105
|
-
.filter(entry => {
|
|
128
|
+
const valid = bySite(entries, running).filter(entry => {
|
|
106
129
|
const first = seen.get(nameOf(entry));
|
|
107
130
|
if (!first) {
|
|
108
131
|
seen.set(nameOf(entry), entry);
|
|
109
132
|
return true;
|
|
110
133
|
}
|
|
134
|
+
const kind = (one) => typeof what === 'string' ? what : what(one);
|
|
111
135
|
conflicts.push({
|
|
112
136
|
file: entry.file,
|
|
113
137
|
export: entry.export,
|
|
114
|
-
message: `There is already a ${
|
|
138
|
+
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
139
|
});
|
|
116
140
|
return false;
|
|
117
141
|
});
|
|
@@ -121,9 +145,18 @@ export function createProject(options) {
|
|
|
121
145
|
const { cwd, log } = options;
|
|
122
146
|
// Where `asset()` reads the files of the project from.
|
|
123
147
|
setPublicDir(cwd);
|
|
124
|
-
// Where the stores keep their data, for every store of the process
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
// Where the stores keep their data, for every store of the process: a
|
|
149
|
+
// backend per storage the project has one for.
|
|
150
|
+
const storeBackends = new Map();
|
|
151
|
+
for (const storage of STORAGES) {
|
|
152
|
+
const backend = options.stores?.[storage] ??
|
|
153
|
+
(storage === 'file'
|
|
154
|
+
? new FileStoreBackend(join(cwd, DATA_FOLDER))
|
|
155
|
+
: null);
|
|
156
|
+
setStoreBackend(storage, backend);
|
|
157
|
+
if (backend)
|
|
158
|
+
storeBackends.set(storage, backend);
|
|
159
|
+
}
|
|
127
160
|
const report = (failure) => {
|
|
128
161
|
const where = locate(failure.error, cwd);
|
|
129
162
|
log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
|
|
@@ -150,20 +183,80 @@ export function createProject(options) {
|
|
|
150
183
|
let components = new Map();
|
|
151
184
|
let tasks = new Map();
|
|
152
185
|
let stores = new Map();
|
|
186
|
+
let rankings = new Map();
|
|
153
187
|
let presence = null;
|
|
188
|
+
/** Where the presence is declared, for what its functions do wrong. */
|
|
189
|
+
let presenceSite = { file: 'the presence', export: 'default' };
|
|
154
190
|
/** The files the presence was declared in, to keep it when they break. */
|
|
155
191
|
let presenceFiles = new Set();
|
|
156
|
-
// Joining voice
|
|
157
|
-
|
|
158
|
-
|
|
192
|
+
// Joining voice, waiting for messages or reactions: intents the code
|
|
193
|
+
// needs without any declaration saying it.
|
|
194
|
+
let uses = options.uses ?? {};
|
|
195
|
+
const neededIntents = () => intentsFor(events.values()) | usesIntents(CODE_USES, uses);
|
|
159
196
|
let messages = null;
|
|
160
197
|
/** The last version of each language that loaded. */
|
|
161
198
|
let languages = new Map();
|
|
162
199
|
let languageDefaults = [];
|
|
200
|
+
/** Whether the files were loaded before: what a reload compares with. */
|
|
201
|
+
let loadedOnce = false;
|
|
163
202
|
/** The bot running the files now, to give it what a reload changes. */
|
|
164
203
|
let running = null;
|
|
165
204
|
/** The presence each bot was given last, to send only what changed. */
|
|
166
205
|
const given = new WeakMap();
|
|
206
|
+
/** What each bot shows now, to send only what changed. */
|
|
207
|
+
const shown = new WeakMap();
|
|
208
|
+
/** Whether the running bot is only part of the bot (a cluster). */
|
|
209
|
+
let partial = false;
|
|
210
|
+
/** How many servers the whole bot is in, as Discord counted it last. */
|
|
211
|
+
let counted = null;
|
|
212
|
+
let counting = false;
|
|
213
|
+
/**
|
|
214
|
+
* How many servers the bot is in: what this process knows when it runs
|
|
215
|
+
* every shard; else what Discord counts for the application, asked at
|
|
216
|
+
* most every 10 minutes.
|
|
217
|
+
* @see https://docs.discord.com/developers/resources/application#application-object-application-structure
|
|
218
|
+
*/
|
|
219
|
+
const guildCount = (bot) => {
|
|
220
|
+
const local = bot.ctx.cache.guilds.size;
|
|
221
|
+
if (!partial)
|
|
222
|
+
return local;
|
|
223
|
+
if (!counting && (!counted || Date.now() - counted.at > 600_000)) {
|
|
224
|
+
counting = true;
|
|
225
|
+
bot.ctx.rest
|
|
226
|
+
.request(GetCurrentApplication, [])
|
|
227
|
+
.then(application => {
|
|
228
|
+
const guilds = application.approximate_guild_count;
|
|
229
|
+
if (guilds !== undefined)
|
|
230
|
+
counted = { guilds, at: Date.now() };
|
|
231
|
+
})
|
|
232
|
+
.catch(() => { })
|
|
233
|
+
.finally(() => (counting = false));
|
|
234
|
+
}
|
|
235
|
+
return counted?.guilds ?? local;
|
|
236
|
+
};
|
|
237
|
+
let presenceBot = () => null;
|
|
238
|
+
const rotation = new PresenceRotation({
|
|
239
|
+
apply(raw) {
|
|
240
|
+
const bot = presenceBot();
|
|
241
|
+
if (!bot)
|
|
242
|
+
return;
|
|
243
|
+
const text = JSON.stringify(raw);
|
|
244
|
+
if (shown.get(bot) === text)
|
|
245
|
+
return;
|
|
246
|
+
shown.set(bot, text);
|
|
247
|
+
bot.setPresence(raw);
|
|
248
|
+
},
|
|
249
|
+
context() {
|
|
250
|
+
const bot = presenceBot();
|
|
251
|
+
if (!bot?.ctx.self)
|
|
252
|
+
return null;
|
|
253
|
+
return {
|
|
254
|
+
guilds: guildCount(bot),
|
|
255
|
+
...translation(bot.ctx, null),
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
onError: error => report({ ...presenceSite, error }),
|
|
259
|
+
});
|
|
167
260
|
let lost = false;
|
|
168
261
|
const count = (size, one, many) => `${size} ${size === 1 ? one : many}`;
|
|
169
262
|
return {
|
|
@@ -182,7 +275,20 @@ export function createProject(options) {
|
|
|
182
275
|
get stores() {
|
|
183
276
|
return stores;
|
|
184
277
|
},
|
|
185
|
-
|
|
278
|
+
storeBackends,
|
|
279
|
+
get rankings() {
|
|
280
|
+
return rankings;
|
|
281
|
+
},
|
|
282
|
+
redisStores: () => ({
|
|
283
|
+
stores: [...stores.values()]
|
|
284
|
+
.filter(entry => entry.store.storage === 'redis')
|
|
285
|
+
.map(entry => entry.store.name)
|
|
286
|
+
.sort(),
|
|
287
|
+
rankings: [...rankings.values()]
|
|
288
|
+
.filter(entry => entry.ranking.storage === 'redis')
|
|
289
|
+
.map(entry => entry.ranking.name)
|
|
290
|
+
.sort(),
|
|
291
|
+
}),
|
|
186
292
|
get presence() {
|
|
187
293
|
return presence;
|
|
188
294
|
},
|
|
@@ -198,7 +304,10 @@ export function createProject(options) {
|
|
|
198
304
|
})),
|
|
199
305
|
...(messages ? { defaultLocale: messages.default } : {}),
|
|
200
306
|
languageDefaults,
|
|
201
|
-
|
|
307
|
+
// Only slash commands have texts in the language files.
|
|
308
|
+
commands: [...commands.values()]
|
|
309
|
+
.filter(({ command }) => command.type === 'slash')
|
|
310
|
+
.map(({ command }) => ({
|
|
202
311
|
name: command.path.join(' '),
|
|
203
312
|
described: command.described,
|
|
204
313
|
})),
|
|
@@ -206,19 +315,20 @@ export function createProject(options) {
|
|
|
206
315
|
report,
|
|
207
316
|
async load() {
|
|
208
317
|
const { built } = options;
|
|
209
|
-
if (options.
|
|
210
|
-
|
|
318
|
+
if (options.uses === undefined) {
|
|
319
|
+
uses = await scanUses(cwd, CODE_USES);
|
|
211
320
|
}
|
|
212
321
|
// Every file of src/ runs (or ran, in a build); what each exports is
|
|
213
322
|
// then read, kind by kind.
|
|
214
323
|
const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
|
|
215
324
|
const read = (declaration) => readDeclarations(sources.modules, declaration);
|
|
216
|
-
const [eventItems, commandItems, componentItems, taskItems, storeItems, presenceItems, languageItems,] = [
|
|
325
|
+
const [eventItems, commandItems, componentItems, taskItems, storeItems, rankingItems, presenceItems, languageItems,] = [
|
|
217
326
|
read(eventDeclaration),
|
|
218
327
|
read(commandDeclaration),
|
|
219
328
|
read(componentDeclaration),
|
|
220
329
|
read(taskDeclaration),
|
|
221
330
|
read(storeDeclaration),
|
|
331
|
+
read(rankingDeclaration),
|
|
222
332
|
read(presenceDeclaration),
|
|
223
333
|
read(languageDeclaration),
|
|
224
334
|
];
|
|
@@ -246,13 +356,14 @@ export function createProject(options) {
|
|
|
246
356
|
event: item.value,
|
|
247
357
|
}));
|
|
248
358
|
router.set(events.values());
|
|
359
|
+
// A name two declarations share stays with the one that ran before.
|
|
249
360
|
const { valid, conflicts } = findConflicts([
|
|
250
361
|
...next(commands, commandItems, item => ({
|
|
251
362
|
file: item.file,
|
|
252
363
|
export: item.export,
|
|
253
364
|
command: item.value,
|
|
254
365
|
})).values(),
|
|
255
|
-
]);
|
|
366
|
+
], new Set(commands.keys()));
|
|
256
367
|
commands = new Map(valid.map(entry => [keyOf(entry), entry]));
|
|
257
368
|
const unique = findDuplicates([
|
|
258
369
|
...next(components, componentItems, item => ({
|
|
@@ -260,7 +371,7 @@ export function createProject(options) {
|
|
|
260
371
|
export: item.export,
|
|
261
372
|
component: item.value,
|
|
262
373
|
})).values(),
|
|
263
|
-
]);
|
|
374
|
+
], new Set(components.keys()));
|
|
264
375
|
components = new Map(unique.valid.map(entry => [keyOf(entry), entry]));
|
|
265
376
|
componentRouter.set(components.values());
|
|
266
377
|
const sameTask = sameNames([
|
|
@@ -269,22 +380,43 @@ export function createProject(options) {
|
|
|
269
380
|
export: item.export,
|
|
270
381
|
task: item.value,
|
|
271
382
|
})).values(),
|
|
272
|
-
], entry => entry.task.name, 'task');
|
|
383
|
+
], entry => entry.task.name, 'task', new Set(tasks.keys()));
|
|
273
384
|
tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
|
|
274
385
|
scheduler.set(tasks.values());
|
|
275
|
-
|
|
386
|
+
const keptOf = (entry) => 'store' in entry ? entry.store : entry.ranking;
|
|
276
387
|
const sameStore = sameNames([
|
|
277
388
|
...next(stores, storeItems, item => ({
|
|
278
389
|
file: item.file,
|
|
279
390
|
export: item.export,
|
|
280
391
|
store: item.value,
|
|
281
392
|
})).values(),
|
|
282
|
-
|
|
283
|
-
|
|
393
|
+
...next(rankings, rankingItems, item => ({
|
|
394
|
+
file: item.file,
|
|
395
|
+
export: item.export,
|
|
396
|
+
ranking: item.value,
|
|
397
|
+
})).values(),
|
|
398
|
+
], entry => keptOf(entry).name, entry => ('store' in entry ? 'store' : 'ranking'), new Set([...stores.keys(), ...rankings.keys()]));
|
|
399
|
+
// What is kept where nothing keeps it (Redis without REDIS_URL) is
|
|
400
|
+
// reported and left out: it says so itself when used.
|
|
401
|
+
const unkept = sameStore.valid.filter(entry => !storeBackends.has(keptOf(entry).storage));
|
|
402
|
+
const kept = sameStore.valid.filter(entry => !unkept.includes(entry));
|
|
403
|
+
stores = new Map(kept
|
|
404
|
+
.filter((entry) => 'store' in entry)
|
|
405
|
+
.map(entry => [keyOf(entry), entry]));
|
|
406
|
+
rankings = new Map(kept
|
|
407
|
+
.filter((entry) => 'ranking' in entry)
|
|
408
|
+
.map(entry => [keyOf(entry), entry]));
|
|
409
|
+
const nowhere = unkept.map(entry => ({
|
|
410
|
+
file: entry.file,
|
|
411
|
+
export: entry.export,
|
|
412
|
+
error: new NowhereToKeepError(nowhereToKeep(keptOf(entry).storage, keptOf(entry).name, 'store' in entry ? 'store' : 'ranking')),
|
|
413
|
+
}));
|
|
284
414
|
// One presence. A second one is left out.
|
|
285
415
|
const [first, ...extra] = presenceItems.loaded;
|
|
286
|
-
if (first)
|
|
416
|
+
if (first) {
|
|
287
417
|
presence = first.value;
|
|
418
|
+
presenceSite = { file: first.file, export: first.export };
|
|
419
|
+
}
|
|
288
420
|
else if (presenceItems.failed.length === 0 &&
|
|
289
421
|
![...broken].some(file => presenceFiles.has(file))) {
|
|
290
422
|
presence = null;
|
|
@@ -311,14 +443,20 @@ export function createProject(options) {
|
|
|
311
443
|
languages = nextLanguages;
|
|
312
444
|
let assembled = assembleMessages([...languages.values()]);
|
|
313
445
|
// A language that no longer matches the others keeps its last version
|
|
314
|
-
// that did, like a declaration that no longer loads
|
|
446
|
+
// that did, like a declaration that no longer loads; one that was not
|
|
447
|
+
// there before is left out. What already ran is never what breaks
|
|
448
|
+
// the languages: a second default: true saved in a new file must not
|
|
449
|
+
// take the texts of every command away.
|
|
315
450
|
const restored = assembled.failed.filter(failure => {
|
|
316
451
|
const key = keyOf({
|
|
317
452
|
file: failure.file,
|
|
318
453
|
export: failure.export ?? 'default',
|
|
319
454
|
});
|
|
455
|
+
const current = languages.get(key);
|
|
456
|
+
if (current === undefined)
|
|
457
|
+
return false;
|
|
320
458
|
const previous = previousLanguages.get(key);
|
|
321
|
-
return previous
|
|
459
|
+
return previous === undefined ? loadedOnce : previous !== current;
|
|
322
460
|
});
|
|
323
461
|
if (restored.length > 0) {
|
|
324
462
|
for (const failure of restored) {
|
|
@@ -326,7 +464,11 @@ export function createProject(options) {
|
|
|
326
464
|
file: failure.file,
|
|
327
465
|
export: failure.export ?? 'default',
|
|
328
466
|
});
|
|
329
|
-
|
|
467
|
+
const previous = previousLanguages.get(key);
|
|
468
|
+
if (previous)
|
|
469
|
+
languages.set(key, previous);
|
|
470
|
+
else
|
|
471
|
+
languages.delete(key);
|
|
330
472
|
}
|
|
331
473
|
const again = assembleMessages([...languages.values()]);
|
|
332
474
|
assembled = {
|
|
@@ -338,6 +480,7 @@ export function createProject(options) {
|
|
|
338
480
|
};
|
|
339
481
|
}
|
|
340
482
|
messages = assembled.messages;
|
|
483
|
+
loadedOnce = true;
|
|
341
484
|
if (running)
|
|
342
485
|
running.ctx.messages = messages;
|
|
343
486
|
// A command has its texts in its file, or in the language files: the
|
|
@@ -345,7 +488,9 @@ export function createProject(options) {
|
|
|
345
488
|
// be described is left out.
|
|
346
489
|
const badTexts = [...assembled.failed];
|
|
347
490
|
if (messages) {
|
|
348
|
-
const names = new Set([...commands.values()]
|
|
491
|
+
const names = new Set([...commands.values()]
|
|
492
|
+
.filter(entry => entry.command.type === 'slash')
|
|
493
|
+
.map(entry => entry.command.path.join(' ')));
|
|
349
494
|
for (const { file, path } of unknownCommands(messages, names)) {
|
|
350
495
|
badTexts.push({
|
|
351
496
|
file,
|
|
@@ -369,10 +514,12 @@ export function createProject(options) {
|
|
|
369
514
|
...componentItems.failed,
|
|
370
515
|
...taskItems.failed,
|
|
371
516
|
...storeItems.failed,
|
|
517
|
+
...rankingItems.failed,
|
|
372
518
|
...presenceItems.failed,
|
|
373
519
|
...languageItems.failed,
|
|
374
520
|
...badTexts,
|
|
375
521
|
...twice,
|
|
522
|
+
...nowhere,
|
|
376
523
|
...[
|
|
377
524
|
...conflicts,
|
|
378
525
|
...unique.conflicts,
|
|
@@ -404,6 +551,9 @@ export function createProject(options) {
|
|
|
404
551
|
: []),
|
|
405
552
|
...(tasks.size > 0 ? [count(tasks.size, 'task', 'tasks')] : []),
|
|
406
553
|
...(stores.size > 0 ? [count(stores.size, 'store', 'stores')] : []),
|
|
554
|
+
...(rankings.size > 0
|
|
555
|
+
? [count(rankings.size, 'ranking', 'rankings')]
|
|
556
|
+
: []),
|
|
407
557
|
...(presence ? ['a presence'] : []),
|
|
408
558
|
...(messages
|
|
409
559
|
? [
|
|
@@ -417,13 +567,17 @@ export function createProject(options) {
|
|
|
417
567
|
components.size === 0 &&
|
|
418
568
|
tasks.size === 0 &&
|
|
419
569
|
stores.size === 0 &&
|
|
570
|
+
rankings.size === 0 &&
|
|
420
571
|
presence === null &&
|
|
421
572
|
messages === null,
|
|
422
573
|
// A file declaring several events that need the intent is named once.
|
|
423
574
|
filesNeeding: intent => [
|
|
424
|
-
...new Set([
|
|
425
|
-
.
|
|
426
|
-
|
|
575
|
+
...new Set([
|
|
576
|
+
...[...events.values()]
|
|
577
|
+
.filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
|
|
578
|
+
.map(({ file }) => file),
|
|
579
|
+
...CODE_USES.filter(use => (use.intents & GatewayIntent[intent]) !== 0).flatMap(use => uses[use.name] ?? []),
|
|
580
|
+
]),
|
|
427
581
|
],
|
|
428
582
|
intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
|
|
429
583
|
async connect(connection) {
|
|
@@ -435,6 +589,7 @@ export function createProject(options) {
|
|
|
435
589
|
guildFilter: connection.guildFilter,
|
|
436
590
|
privateEvents: connection.privateEvents,
|
|
437
591
|
presence: presence?.raw,
|
|
592
|
+
waiters: new MemoryWaiters(options.observe?.waits ? { watch: options.observe.waits } : {}),
|
|
438
593
|
shards: connection.shards,
|
|
439
594
|
identifyGate: connection.identifyGate,
|
|
440
595
|
...(connection.identifyInterval === undefined
|
|
@@ -443,7 +598,12 @@ export function createProject(options) {
|
|
|
443
598
|
// Only what the files of the project use is remembered.
|
|
444
599
|
cache: { limits: limitsFor(events.values()) },
|
|
445
600
|
// A local fake of Discord (tests) serves voice without TLS too.
|
|
446
|
-
voice: {
|
|
601
|
+
voice: {
|
|
602
|
+
secure: !connection.apiUrl?.startsWith('http://'),
|
|
603
|
+
...(connection.voiceUnavailable === undefined
|
|
604
|
+
? {}
|
|
605
|
+
: { unavailable: connection.voiceUnavailable }),
|
|
606
|
+
},
|
|
447
607
|
rest: {
|
|
448
608
|
...(connection.apiUrl ? { baseUrl: connection.apiUrl } : {}),
|
|
449
609
|
...(connection.globalLimit === undefined
|
|
@@ -487,19 +647,62 @@ export function createProject(options) {
|
|
|
487
647
|
},
|
|
488
648
|
});
|
|
489
649
|
given.set(created, presence?.key ?? '');
|
|
650
|
+
shown.set(created, JSON.stringify(presence?.raw ?? DEFAULT_PRESENCE));
|
|
651
|
+
partial =
|
|
652
|
+
connection.shards !== undefined &&
|
|
653
|
+
connection.shards.ids.length < connection.shards.count;
|
|
490
654
|
created.ctx.messages = messages;
|
|
491
655
|
running = created;
|
|
492
656
|
await created.connect();
|
|
493
657
|
return created;
|
|
494
658
|
},
|
|
495
659
|
applyPresence(bot) {
|
|
660
|
+
// Always the functions of the files as they are now: a file they
|
|
661
|
+
// import may have changed. What is shown is only sent when it differs.
|
|
662
|
+
rotation.set(presence);
|
|
496
663
|
const key = presence?.key ?? '';
|
|
497
664
|
if (given.get(bot) === key)
|
|
498
665
|
return false;
|
|
499
666
|
given.set(bot, key);
|
|
500
|
-
bot.setPresence(presence?.raw ?? DEFAULT_PRESENCE);
|
|
501
667
|
return true;
|
|
502
668
|
},
|
|
669
|
+
startPresence(current) {
|
|
670
|
+
presenceBot = current;
|
|
671
|
+
rotation.set(presence);
|
|
672
|
+
rotation.start();
|
|
673
|
+
},
|
|
674
|
+
stopPresence: () => rotation.stop(),
|
|
675
|
+
get presenceFile() {
|
|
676
|
+
return presence ? presenceSite.file : null;
|
|
677
|
+
},
|
|
678
|
+
async runPresence(bot) {
|
|
679
|
+
if (!presence)
|
|
680
|
+
return;
|
|
681
|
+
const context = {
|
|
682
|
+
guilds: guildCount(bot),
|
|
683
|
+
...translation(bot.ctx, null),
|
|
684
|
+
};
|
|
685
|
+
const failed = (error) => report({ ...presenceSite, error });
|
|
686
|
+
let activities = presence.activities;
|
|
687
|
+
if (typeof activities === 'function') {
|
|
688
|
+
try {
|
|
689
|
+
activities = readActivities(await activities(context));
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
return failed(error);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
for (const { name } of activities) {
|
|
696
|
+
if (typeof name !== 'function')
|
|
697
|
+
continue;
|
|
698
|
+
try {
|
|
699
|
+
checkText(await name(context));
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
failed(error);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
},
|
|
503
706
|
ready(bot) {
|
|
504
707
|
const user = bot.ctx.cache.users.get(bot.ctx.self.userId);
|
|
505
708
|
router.emit('ready', {
|
|
@@ -530,7 +733,10 @@ export function createProject(options) {
|
|
|
530
733
|
},
|
|
531
734
|
// The bot looks after its own memory: it remembers less before memory
|
|
532
735
|
// is full, and only speaks up when it can't give up anything more.
|
|
533
|
-
closeStores: () =>
|
|
736
|
+
closeStores: async () => {
|
|
737
|
+
for (const backend of storeBackends.values())
|
|
738
|
+
await backend.close();
|
|
739
|
+
},
|
|
534
740
|
watchMemory: current => watchMemory({
|
|
535
741
|
cache: () => current()?.ctx.cache,
|
|
536
742
|
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,69 @@
|
|
|
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
|
+
// While checkRedis() asks, a failure is what stops the bot, said once
|
|
14
|
+
// by it: "trying again" would not be true.
|
|
15
|
+
onError: message => {
|
|
16
|
+
if (!checking.has(backend))
|
|
17
|
+
log.warn(message);
|
|
18
|
+
},
|
|
19
|
+
onReconnect: () => log.reload(`Redis at ${client.where} is back`),
|
|
20
|
+
});
|
|
21
|
+
const backend = new RedisStoreBackend(client, options);
|
|
22
|
+
return backend;
|
|
23
|
+
}
|
|
24
|
+
/** The backends `checkRedis()` is asking right now. */
|
|
25
|
+
const checking = new WeakSet();
|
|
26
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
27
|
+
const and = (items) => items.length <= 1
|
|
28
|
+
? (items[0] ?? '')
|
|
29
|
+
: `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
30
|
+
/**
|
|
31
|
+
* The `ℹ` line naming the stores and rankings kept in Redis, or `null`
|
|
32
|
+
* without any: "The store sessions and the ranking xp are kept in Redis…".
|
|
33
|
+
*/
|
|
34
|
+
export function describeRedisStores(project, backend) {
|
|
35
|
+
const { stores, rankings } = project.redisStores();
|
|
36
|
+
const total = stores.length + rankings.length;
|
|
37
|
+
if (!backend || total === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const group = (one, many, names) => names.length === 0
|
|
40
|
+
? []
|
|
41
|
+
: [`the ${names.length === 1 ? one : many} ${and(names)}`];
|
|
42
|
+
const list = and([
|
|
43
|
+
...group('store', 'stores', stores),
|
|
44
|
+
...group('ranking', 'rankings', rankings),
|
|
45
|
+
]);
|
|
46
|
+
return `${list[0].toUpperCase()}${list.slice(1)} ${total === 1 ? 'is' : 'are'} kept in Redis at ${backend.client.where}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Makes sure Redis answers before the bot goes online, when a store is
|
|
50
|
+
* kept there: a Redis that can't be reached is an error now, not when
|
|
51
|
+
* someone uses the bot.
|
|
52
|
+
*/
|
|
53
|
+
export async function checkRedis(project, backend, log) {
|
|
54
|
+
const { stores, rankings } = project.redisStores();
|
|
55
|
+
if (!backend || stores.length + rankings.length === 0)
|
|
56
|
+
return true;
|
|
57
|
+
checking.add(backend);
|
|
58
|
+
try {
|
|
59
|
+
await backend.client.ping();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
log.error(`${error instanceof Error ? error.message.replace(/ Trying again in \d+ s\.$/, '') : String(error)}`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
checking.delete(backend);
|
|
68
|
+
}
|
|
69
|
+
}
|