chapterjs 0.2.1 → 0.2.3
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/LICENSE +21 -0
- package/dist/cli/cluster.d.ts +3 -0
- package/dist/cli/cluster.js +4 -1
- package/dist/cli/dev.js +2 -0
- package/dist/cli/project.d.ts +19 -0
- package/dist/cli/project.js +33 -2
- package/dist/cli/start.js +18 -0
- package/dist/commands/router.js +1 -0
- package/dist/components/router.js +1 -0
- package/dist/events/router.d.ts +5 -2
- package/dist/events/router.js +8 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +2 -0
- package/dist/interactions/dispatch.js +2 -1
- package/dist/store/backend.d.ts +33 -0
- package/dist/store/backend.js +27 -0
- package/dist/store/declaration.d.ts +15 -0
- package/dist/store/declaration.js +53 -0
- package/dist/store/file-backend.d.ts +22 -0
- package/dist/store/file-backend.js +172 -0
- package/dist/store/ipc-backend.d.ts +53 -0
- package/dist/store/ipc-backend.js +87 -0
- package/dist/store/scope.d.ts +8 -0
- package/dist/store/scope.js +20 -0
- package/dist/store/store.d.ts +115 -0
- package/dist/store/store.js +200 -0
- package/dist/tasks/scheduler.js +2 -1
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quentin Truffy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cli/cluster.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StoreBackend } from '../store/backend.js';
|
|
1
2
|
import { type IdentifyGate } from '../gateway/identify-queue.js';
|
|
2
3
|
/** How many shards one process runs when nothing else is asked. */
|
|
3
4
|
export declare const SHARDS_PER_PROCESS = 4;
|
|
@@ -50,6 +51,8 @@ export interface ClusterOptions {
|
|
|
50
51
|
signal: AbortSignal;
|
|
51
52
|
/** Shows a line printed by one of the processes. */
|
|
52
53
|
write: (line: string) => void;
|
|
54
|
+
/** Where the data of the stores is: this process keeps it for the others. */
|
|
55
|
+
stores: StoreBackend;
|
|
53
56
|
/** Every process is connected, for the first time. */
|
|
54
57
|
onReady: (info: {
|
|
55
58
|
guilds: number;
|
package/dist/cli/cluster.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// identify. A setup on several machines would give each process another
|
|
10
10
|
// `IdentifyGate`, and nothing else here would change.
|
|
11
11
|
// https://docs.discord.com/developers/events/gateway#sharding
|
|
12
|
+
import { serveStores, } from '../store/ipc-backend.js';
|
|
12
13
|
import { fork } from 'node:child_process';
|
|
13
14
|
import { availableParallelism } from 'node:os';
|
|
14
15
|
import { createInterface } from 'node:readline';
|
|
@@ -134,8 +135,10 @@ export async function runCluster(options) {
|
|
|
134
135
|
for (const stream of [child.stdout, child.stderr]) {
|
|
135
136
|
createInterface({ input: stream }).on('line', line => options.write(`[${index + 1}] ${line}`));
|
|
136
137
|
}
|
|
138
|
+
const stores = serveStores(options.stores, reply => tell(child, reply));
|
|
137
139
|
child.on('message', (message) => {
|
|
138
|
-
|
|
140
|
+
stores(message);
|
|
141
|
+
if (message.type !== 'store' && message.type === 'identify') {
|
|
139
142
|
void gate.wait(message.shard).then(() => {
|
|
140
143
|
tell(child, { type: 'identify', id: message.id });
|
|
141
144
|
});
|
package/dist/cli/dev.js
CHANGED
|
@@ -255,6 +255,7 @@ export async function dev(options) {
|
|
|
255
255
|
assets.close();
|
|
256
256
|
memory.stop();
|
|
257
257
|
await bot?.close();
|
|
258
|
+
await project.closeStores();
|
|
258
259
|
if (code === 0)
|
|
259
260
|
log.success('Disconnected');
|
|
260
261
|
return code;
|
|
@@ -265,6 +266,7 @@ export async function dev(options) {
|
|
|
265
266
|
assets.close();
|
|
266
267
|
memory.stop();
|
|
267
268
|
await bot?.close();
|
|
269
|
+
await project.closeStores();
|
|
268
270
|
if (signal.aborted)
|
|
269
271
|
return 0;
|
|
270
272
|
explain(error, log);
|
package/dist/cli/project.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type CommandEntry } from '../commands/tree.js';
|
|
2
2
|
import { type ComponentEntry } from '../components/router.js';
|
|
3
|
+
import { type LoadedStore } from '../store/declaration.js';
|
|
4
|
+
import { type StoreBackend } from '../store/backend.js';
|
|
3
5
|
import { type LoadedPresence } from '../presence/declaration.js';
|
|
4
6
|
import { type DeclaredCommand, type DeclaredLanguage } from '../messages/declaration.js';
|
|
5
7
|
import { type LoadedMessages } from '../messages/translate.js';
|
|
@@ -26,6 +28,12 @@ export interface ProjectOptions {
|
|
|
26
28
|
* Without it, the files of `src` are read on every load.
|
|
27
29
|
*/
|
|
28
30
|
joinsVoice?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Where the stores of the project keep their data. A file per store in
|
|
33
|
+
* the `data/` folder of the project by default; the first process of a
|
|
34
|
+
* cluster, through IPC, for the other processes.
|
|
35
|
+
*/
|
|
36
|
+
stores?: StoreBackend;
|
|
29
37
|
/** Only tests change this. */
|
|
30
38
|
deferAfter?: number;
|
|
31
39
|
}
|
|
@@ -55,6 +63,12 @@ export interface TypesInfo {
|
|
|
55
63
|
export declare function writeTypes(cwd: string, info?: TypesInfo): Promise<number>;
|
|
56
64
|
/** The kinds of declarations a project is made of: one per feature. */
|
|
57
65
|
export declare const DECLARATIONS: readonly Declaration<unknown>[];
|
|
66
|
+
/** A store, with where it is declared. */
|
|
67
|
+
export interface StoreEntry {
|
|
68
|
+
file: string;
|
|
69
|
+
export: string;
|
|
70
|
+
store: LoadedStore;
|
|
71
|
+
}
|
|
58
72
|
/** What a bot of the project is connected with. */
|
|
59
73
|
export interface ConnectOptions {
|
|
60
74
|
token: string;
|
|
@@ -82,6 +96,9 @@ export interface Project {
|
|
|
82
96
|
readonly commands: ReadonlyMap<string, CommandEntry>;
|
|
83
97
|
readonly components: ReadonlyMap<string, ComponentEntry>;
|
|
84
98
|
readonly tasks: ReadonlyMap<string, TaskEntry>;
|
|
99
|
+
readonly stores: ReadonlyMap<string, StoreEntry>;
|
|
100
|
+
/** Where the stores keep their data: what a cluster serves to its other processes. */
|
|
101
|
+
readonly storeBackend: StoreBackend;
|
|
85
102
|
/** The presence declared, or `null` without one. */
|
|
86
103
|
readonly presence: LoadedPresence | null;
|
|
87
104
|
/** The languages of the project, assembled, or `null` without any. */
|
|
@@ -127,6 +144,8 @@ export interface Project {
|
|
|
127
144
|
watchMemory(current: () => Bot | null): {
|
|
128
145
|
stop(): void;
|
|
129
146
|
};
|
|
147
|
+
/** Writes what the stores still have to write. Nothing is kept after this. */
|
|
148
|
+
closeStores(): Promise<void>;
|
|
130
149
|
}
|
|
131
150
|
/** Whether the folder is a project: it has a `src` folder. */
|
|
132
151
|
export declare function hasSources(cwd: string, log: Log): Promise<boolean>;
|
package/dist/cli/project.js
CHANGED
|
@@ -15,6 +15,9 @@ import { findConflicts } from '../commands/tree.js';
|
|
|
15
15
|
import { componentDeclaration, findDuplicates, } from '../components/declaration.js';
|
|
16
16
|
import { ComponentRouter } from '../components/router.js';
|
|
17
17
|
import { taskDeclaration } from '../tasks/declaration.js';
|
|
18
|
+
import { storeDeclaration } from '../store/declaration.js';
|
|
19
|
+
import { setStoreBackend } from '../store/backend.js';
|
|
20
|
+
import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
|
|
18
21
|
import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
|
|
19
22
|
import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
|
|
20
23
|
import { translation } from '../messages/translate.js';
|
|
@@ -64,6 +67,7 @@ export const DECLARATIONS = [
|
|
|
64
67
|
commandDeclaration,
|
|
65
68
|
componentDeclaration,
|
|
66
69
|
taskDeclaration,
|
|
70
|
+
storeDeclaration,
|
|
67
71
|
presenceDeclaration,
|
|
68
72
|
languageDeclaration,
|
|
69
73
|
];
|
|
@@ -117,6 +121,9 @@ export function createProject(options) {
|
|
|
117
121
|
const { cwd, log } = options;
|
|
118
122
|
// Where `asset()` reads the files of the project from.
|
|
119
123
|
setPublicDir(cwd);
|
|
124
|
+
// Where the stores keep their data, for every store of the process.
|
|
125
|
+
const storeBackend = options.stores ?? new FileStoreBackend(join(cwd, DATA_FOLDER));
|
|
126
|
+
setStoreBackend(storeBackend);
|
|
120
127
|
const report = (failure) => {
|
|
121
128
|
const where = locate(failure.error, cwd);
|
|
122
129
|
log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
|
|
@@ -142,6 +149,7 @@ export function createProject(options) {
|
|
|
142
149
|
let commands = new Map();
|
|
143
150
|
let components = new Map();
|
|
144
151
|
let tasks = new Map();
|
|
152
|
+
let stores = new Map();
|
|
145
153
|
let presence = null;
|
|
146
154
|
/** The files the presence was declared in, to keep it when they break. */
|
|
147
155
|
let presenceFiles = new Set();
|
|
@@ -171,6 +179,10 @@ export function createProject(options) {
|
|
|
171
179
|
get tasks() {
|
|
172
180
|
return tasks;
|
|
173
181
|
},
|
|
182
|
+
get stores() {
|
|
183
|
+
return stores;
|
|
184
|
+
},
|
|
185
|
+
storeBackend,
|
|
174
186
|
get presence() {
|
|
175
187
|
return presence;
|
|
176
188
|
},
|
|
@@ -201,11 +213,12 @@ export function createProject(options) {
|
|
|
201
213
|
// then read, kind by kind.
|
|
202
214
|
const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
|
|
203
215
|
const read = (declaration) => readDeclarations(sources.modules, declaration);
|
|
204
|
-
const [eventItems, commandItems, componentItems, taskItems, presenceItems, languageItems,] = [
|
|
216
|
+
const [eventItems, commandItems, componentItems, taskItems, storeItems, presenceItems, languageItems,] = [
|
|
205
217
|
read(eventDeclaration),
|
|
206
218
|
read(commandDeclaration),
|
|
207
219
|
read(componentDeclaration),
|
|
208
220
|
read(taskDeclaration),
|
|
221
|
+
read(storeDeclaration),
|
|
209
222
|
read(presenceDeclaration),
|
|
210
223
|
read(languageDeclaration),
|
|
211
224
|
];
|
|
@@ -259,6 +272,15 @@ export function createProject(options) {
|
|
|
259
272
|
], entry => entry.task.name, 'task');
|
|
260
273
|
tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
|
|
261
274
|
scheduler.set(tasks.values());
|
|
275
|
+
// Two stores with one name would share one file: the later is left out.
|
|
276
|
+
const sameStore = sameNames([
|
|
277
|
+
...next(stores, storeItems, item => ({
|
|
278
|
+
file: item.file,
|
|
279
|
+
export: item.export,
|
|
280
|
+
store: item.value,
|
|
281
|
+
})).values(),
|
|
282
|
+
], entry => entry.store.name, 'store');
|
|
283
|
+
stores = new Map(sameStore.valid.map(entry => [keyOf(entry), entry]));
|
|
262
284
|
// One presence. A second one is left out.
|
|
263
285
|
const [first, ...extra] = presenceItems.loaded;
|
|
264
286
|
if (first)
|
|
@@ -346,11 +368,17 @@ export function createProject(options) {
|
|
|
346
368
|
...commandItems.failed,
|
|
347
369
|
...componentItems.failed,
|
|
348
370
|
...taskItems.failed,
|
|
371
|
+
...storeItems.failed,
|
|
349
372
|
...presenceItems.failed,
|
|
350
373
|
...languageItems.failed,
|
|
351
374
|
...badTexts,
|
|
352
375
|
...twice,
|
|
353
|
-
...[
|
|
376
|
+
...[
|
|
377
|
+
...conflicts,
|
|
378
|
+
...unique.conflicts,
|
|
379
|
+
...sameTask.conflicts,
|
|
380
|
+
...sameStore.conflicts,
|
|
381
|
+
].map(({ file, export: exported, message }) => ({
|
|
354
382
|
file,
|
|
355
383
|
export: exported,
|
|
356
384
|
error: new TypeError(message),
|
|
@@ -375,6 +403,7 @@ export function createProject(options) {
|
|
|
375
403
|
? [count(components.size, 'component', 'components')]
|
|
376
404
|
: []),
|
|
377
405
|
...(tasks.size > 0 ? [count(tasks.size, 'task', 'tasks')] : []),
|
|
406
|
+
...(stores.size > 0 ? [count(stores.size, 'store', 'stores')] : []),
|
|
378
407
|
...(presence ? ['a presence'] : []),
|
|
379
408
|
...(messages
|
|
380
409
|
? [
|
|
@@ -387,6 +416,7 @@ export function createProject(options) {
|
|
|
387
416
|
commands.size === 0 &&
|
|
388
417
|
components.size === 0 &&
|
|
389
418
|
tasks.size === 0 &&
|
|
419
|
+
stores.size === 0 &&
|
|
390
420
|
presence === null &&
|
|
391
421
|
messages === null,
|
|
392
422
|
filesNeeding: intent => [...events.values()]
|
|
@@ -490,6 +520,7 @@ export function createProject(options) {
|
|
|
490
520
|
stopTasks: () => scheduler.stop(),
|
|
491
521
|
// The bot looks after its own memory: it remembers less before memory
|
|
492
522
|
// is full, and only speaks up when it can't give up anything more.
|
|
523
|
+
closeStores: () => storeBackend.close(),
|
|
493
524
|
watchMemory: current => watchMemory({
|
|
494
525
|
cache: () => current()?.ctx.cache,
|
|
495
526
|
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.`),
|
package/dist/cli/start.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `chapterjs start`: runs for everyone the bot `chapterjs build` prepared.
|
|
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
|
+
import { IpcStoreBackend } from '../store/ipc-backend.js';
|
|
4
5
|
import { registerCommands } from '../commands/register.js';
|
|
5
6
|
import { buildCommands } from '../commands/tree.js';
|
|
6
7
|
import { GLOBAL_REQUESTS_PER_SECOND } from '../discord/api.js';
|
|
@@ -54,6 +55,19 @@ export async function start(options) {
|
|
|
54
55
|
log,
|
|
55
56
|
built: files,
|
|
56
57
|
joinsVoice: built.info.voice === true,
|
|
58
|
+
// A process started by another keeps no data: the first one does, for
|
|
59
|
+
// all of them.
|
|
60
|
+
...(assignment
|
|
61
|
+
? {
|
|
62
|
+
stores: new IpcStoreBackend({
|
|
63
|
+
send: message => {
|
|
64
|
+
if (process.connected)
|
|
65
|
+
process.send?.(message);
|
|
66
|
+
},
|
|
67
|
+
on: listener => process.on('message', listener),
|
|
68
|
+
}),
|
|
69
|
+
}
|
|
70
|
+
: {}),
|
|
57
71
|
...(options.deferAfter === undefined
|
|
58
72
|
? {}
|
|
59
73
|
: { deferAfter: options.deferAfter }),
|
|
@@ -132,14 +146,17 @@ export async function start(options) {
|
|
|
132
146
|
signal,
|
|
133
147
|
identifyInterval,
|
|
134
148
|
write: options.write,
|
|
149
|
+
stores: project.storeBackend,
|
|
135
150
|
onReady: ({ user, guilds }) => online(user, guilds),
|
|
136
151
|
onRestart: (index, why) => log.warn(`Process ${index + 1} of ${processes} stopped by itself (${why}): starting it again. Its servers are back in a moment.`),
|
|
137
152
|
});
|
|
153
|
+
await project.closeStores();
|
|
138
154
|
if (code === 0)
|
|
139
155
|
log.success('Disconnected');
|
|
140
156
|
return code;
|
|
141
157
|
}
|
|
142
158
|
catch (error) {
|
|
159
|
+
await project.closeStores();
|
|
143
160
|
if (signal.aborted)
|
|
144
161
|
return 0;
|
|
145
162
|
explain(error, log);
|
|
@@ -168,6 +185,7 @@ async function run(project, connection, stopped, connected, { tasks = true } = {
|
|
|
168
185
|
project.stopTasks();
|
|
169
186
|
memory.stop();
|
|
170
187
|
await bot?.close();
|
|
188
|
+
await project.closeStores();
|
|
171
189
|
}
|
|
172
190
|
}
|
|
173
191
|
/** The whole bot in this process. */
|
package/dist/commands/router.js
CHANGED
|
@@ -139,6 +139,7 @@ export class CommandRouter {
|
|
|
139
139
|
name,
|
|
140
140
|
what: 'command',
|
|
141
141
|
reporter: this.#options,
|
|
142
|
+
guildId: place.guild?.id ?? null,
|
|
142
143
|
defer: () => interaction.defer(),
|
|
143
144
|
unanswered: 'finished without answering: the person sees "The application did not respond". Call interaction.reply() in run.',
|
|
144
145
|
run: () => command.run(context),
|
|
@@ -150,6 +150,7 @@ export class ComponentRouter {
|
|
|
150
150
|
name,
|
|
151
151
|
what,
|
|
152
152
|
reporter: this.#options,
|
|
153
|
+
guildId: place.guild?.id ?? null,
|
|
153
154
|
// Nothing changes on screen while a component is answered; a form
|
|
154
155
|
// sent is like a command used.
|
|
155
156
|
defer: () => interaction instanceof ComponentInteraction
|
package/dist/events/router.d.ts
CHANGED
|
@@ -41,6 +41,9 @@ export declare class EventRouter {
|
|
|
41
41
|
joined: boolean;
|
|
42
42
|
before: unknown[] | undefined;
|
|
43
43
|
}): void;
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Runs the handlers of an event with what they receive, for the server
|
|
46
|
+
* it happened in (what a store per server reads).
|
|
47
|
+
*/
|
|
48
|
+
emit<Name extends EventName>(name: Name, context: EventContexts[Name], guildId?: string | null): void;
|
|
46
49
|
}
|
package/dist/events/router.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Delivers gateway events to the handlers of the project.
|
|
2
|
+
import { withGuild } from '../store/scope.js';
|
|
2
3
|
import { DEFAULT_CACHE_LIMITS } from '../cache/cache.js';
|
|
3
4
|
import { GatewayIntent } from '../discord/intents.js';
|
|
4
5
|
import { audienceLocale, translation } from '../messages/translate.js';
|
|
@@ -106,7 +107,7 @@ export class EventRouter {
|
|
|
106
107
|
this.emit(source.name, {
|
|
107
108
|
...context,
|
|
108
109
|
...translation(ctx, audienceLocale({ guild })),
|
|
109
|
-
});
|
|
110
|
+
}, guild?.id ?? null);
|
|
110
111
|
}
|
|
111
112
|
};
|
|
112
113
|
const preparing = source.prepare?.(ctx, data);
|
|
@@ -115,8 +116,11 @@ export class EventRouter {
|
|
|
115
116
|
preparing.then(deliver, (error) => this.#onSkipped(source.name, error));
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
|
-
/**
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Runs the handlers of an event with what they receive, for the server
|
|
121
|
+
* it happened in (what a store per server reads).
|
|
122
|
+
*/
|
|
123
|
+
emit(name, context, guildId = null) {
|
|
120
124
|
const handlers = this.#handlers.get(name);
|
|
121
125
|
if (!handlers)
|
|
122
126
|
return;
|
|
@@ -127,7 +131,7 @@ export class EventRouter {
|
|
|
127
131
|
if (accepts && !accepts(frozen, event.options))
|
|
128
132
|
continue;
|
|
129
133
|
// One handler failing never stops the others, nor the bot.
|
|
130
|
-
new Promise(resolve => resolve(event.handler(frozen))).catch((error) => this.#onError(file, error));
|
|
134
|
+
new Promise(resolve => resolve(withGuild(guildId, () => event.handler(frozen)))).catch((error) => this.#onError(file, error));
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export { language } from './messages/messages.js';
|
|
|
29
29
|
export type { CommandsTranslations, FrameworkTexts, LanguageConfig, LanguageDeclaration, MergedMessages, MessagesOf, MessageText, MessageTexts, PluralText, ProjectCommands, ProjectLanguageDefaults, ParamsArgs, ParamsOf, Placeholders, ProjectLocale, ProjectLocales, LocaleGiven, ProjectMessages, TranslationContext, Translator, } from './messages/messages.js';
|
|
30
30
|
export { presence } from './presence/presence.js';
|
|
31
31
|
export type { Activity, ActivityKind, PlainActivity, PresenceConfig, PresenceDeclaration, PresenceStatusName, StreamingActivity, } from './presence/presence.js';
|
|
32
|
+
export { store } from './store/store.js';
|
|
33
|
+
export type { ScopedStore, Store, StoreConfig, StoreEntry, StoreKey, StoreScope, StoreSetOptions, } from './store/store.js';
|
|
32
34
|
export { task } from './tasks/task.js';
|
|
33
35
|
export type { CronTaskConfig, EveryTaskConfig, TaskConfig, TaskContext, TaskDeclaration, } from './tasks/task.js';
|
|
34
36
|
export type { AssetFile, AssetOptions } from './assets/asset.js';
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export { event } from './events/event.js';
|
|
|
21
21
|
// Tasks: what the files of src/tasks/ are written with.
|
|
22
22
|
export { language } from './messages/messages.js';
|
|
23
23
|
export { presence } from './presence/presence.js';
|
|
24
|
+
export { store } from './store/store.js';
|
|
24
25
|
export { task } from './tasks/task.js';
|
|
25
26
|
// Errors handlers can catch.
|
|
26
27
|
export { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from './rest/errors.js';
|
|
@@ -80,6 +80,8 @@ export interface RunOptions {
|
|
|
80
80
|
defer: () => Promise<void>;
|
|
81
81
|
/** What to tell the developer when `run` finishes without answering. */
|
|
82
82
|
unanswered: string;
|
|
83
|
+
/** The server the function runs for: what a store per server reads. */
|
|
84
|
+
guildId: string | null;
|
|
83
85
|
run: () => unknown;
|
|
84
86
|
}
|
|
85
87
|
/**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// there, and how its function is run so that the person always gets an
|
|
4
4
|
// answer and the developer always gets the error. Written once here;
|
|
5
5
|
// `commands/router.ts` and `components/router.ts` add what is theirs.
|
|
6
|
+
import { withGuild } from '../store/scope.js';
|
|
6
7
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
8
|
import { phrase } from '../messages/phrases.js';
|
|
8
9
|
import { DiscordApiError } from '../rest/errors.js';
|
|
@@ -81,7 +82,7 @@ export function runInteraction(options) {
|
|
|
81
82
|
sleep(reporter.deferAfter ?? 2000, undefined, { signal: stop.signal })
|
|
82
83
|
.then(() => options.defer())
|
|
83
84
|
.catch(() => { });
|
|
84
|
-
new Promise(resolve => resolve(options.run
|
|
85
|
+
new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
|
|
85
86
|
.then(() => {
|
|
86
87
|
if (!interaction.answered) {
|
|
87
88
|
reporter.onWarning(file, `${name} ${options.unanswered}`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** One value of a store, with when it expires. */
|
|
2
|
+
export interface StoreEntry {
|
|
3
|
+
value: unknown;
|
|
4
|
+
/** When the value expires, in ms since the epoch, or `null` for never. */
|
|
5
|
+
expiresAt: number | null;
|
|
6
|
+
}
|
|
7
|
+
/** A key as the backend sees it: every part, the scope included. */
|
|
8
|
+
export type StoreKeyParts = readonly string[];
|
|
9
|
+
/** An entry with its key, as `entries()` lists them. */
|
|
10
|
+
export interface StoreListedEntry extends StoreEntry {
|
|
11
|
+
key: StoreKeyParts;
|
|
12
|
+
}
|
|
13
|
+
/** What keeps the data of every store. Expired entries are never given. */
|
|
14
|
+
export interface StoreBackend {
|
|
15
|
+
get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
|
|
16
|
+
set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
|
|
17
|
+
delete(store: string, key: StoreKeyParts): Promise<boolean>;
|
|
18
|
+
/** The entries whose key starts with `prefix`, in no particular order. */
|
|
19
|
+
entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
|
|
20
|
+
/** Writes what is pending, if anything. */
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** The map key of a key: unambiguous whatever the parts hold. */
|
|
24
|
+
export declare const encodeKey: (key: StoreKeyParts) => string;
|
|
25
|
+
export declare const decodeKey: (encoded: string) => string[];
|
|
26
|
+
/** Whether `key` starts with every part of `prefix`. */
|
|
27
|
+
export declare function startsWith(key: StoreKeyParts, prefix: StoreKeyParts): boolean;
|
|
28
|
+
/** Whether an entry is still good at `now`. */
|
|
29
|
+
export declare const alive: (entry: StoreEntry, now?: number) => boolean;
|
|
30
|
+
/** Gives every store its backend: called by the CLI, once per process. */
|
|
31
|
+
export declare function setStoreBackend(backend: StoreBackend | null): void;
|
|
32
|
+
/** The backend of the process, or an error that says when stores work. */
|
|
33
|
+
export declare function storeBackend(): StoreBackend;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Where the data of the stores is kept, behind an interface: a file per
|
|
2
|
+
// store by default (`file-backend.ts`), the first process of a cluster for
|
|
3
|
+
// the others (`ipc-backend.ts`), and later anything else. A backend knows
|
|
4
|
+
// nothing of servers: the scope of a store is part of the key it gets.
|
|
5
|
+
/** The map key of a key: unambiguous whatever the parts hold. */
|
|
6
|
+
export const encodeKey = (key) => JSON.stringify(key);
|
|
7
|
+
export const decodeKey = (encoded) => JSON.parse(encoded);
|
|
8
|
+
/** Whether `key` starts with every part of `prefix`. */
|
|
9
|
+
export function startsWith(key, prefix) {
|
|
10
|
+
if (prefix.length > key.length)
|
|
11
|
+
return false;
|
|
12
|
+
return prefix.every((part, index) => key[index] === part);
|
|
13
|
+
}
|
|
14
|
+
/** Whether an entry is still good at `now`. */
|
|
15
|
+
export const alive = (entry, now = Date.now()) => entry.expiresAt === null || entry.expiresAt > now;
|
|
16
|
+
let current = null;
|
|
17
|
+
/** Gives every store its backend: called by the CLI, once per process. */
|
|
18
|
+
export function setStoreBackend(backend) {
|
|
19
|
+
current = backend;
|
|
20
|
+
}
|
|
21
|
+
/** The backend of the process, or an error that says when stores work. */
|
|
22
|
+
export function storeBackend() {
|
|
23
|
+
if (!current) {
|
|
24
|
+
throw new Error('Stores only work while the bot runs: use a store in the functions of your files (run), not when a file loads.');
|
|
25
|
+
}
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Declaration } from '../loader/loader.js';
|
|
2
|
+
import { type StoreScope } from './store.js';
|
|
3
|
+
/** A store, checked: what the project keeps of it. */
|
|
4
|
+
export interface LoadedStore {
|
|
5
|
+
/** The name of the store: the name of the export that declares it. */
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly scope: StoreScope;
|
|
8
|
+
/** The default expiration, in ms, or `null` for never. */
|
|
9
|
+
readonly expires: number | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A store, declared anywhere in `src/` with `store()` and exported: the
|
|
13
|
+
* name of the export is the name of the store.
|
|
14
|
+
*/
|
|
15
|
+
export declare const storeDeclaration: Declaration<LoadedStore>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// What a file declares with `store()`, checked when it loads, so a wrong
|
|
2
|
+
// scope or expiration is explained before anything is kept; the store is
|
|
3
|
+
// then bound to the name of its export, which names its data.
|
|
4
|
+
import { DURATION_EXAMPLE, parseDuration } from '../util/duration.js';
|
|
5
|
+
import { bindStore, isStoreDeclaration, storeStateOf, } from './store.js';
|
|
6
|
+
const fail = (message) => {
|
|
7
|
+
throw new TypeError(message);
|
|
8
|
+
};
|
|
9
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
const KEYS = ['scope', 'expires'];
|
|
11
|
+
const SCOPES = ['guild', 'global'];
|
|
12
|
+
/** What a name can be: a file in `data/` is called after it. */
|
|
13
|
+
const NAME = /^[\w.-]+$/;
|
|
14
|
+
/**
|
|
15
|
+
* A store, declared anywhere in `src/` with `store()` and exported: the
|
|
16
|
+
* name of the export is the name of the store.
|
|
17
|
+
*/
|
|
18
|
+
export const storeDeclaration = {
|
|
19
|
+
one: 'store',
|
|
20
|
+
many: 'stores',
|
|
21
|
+
is: isStoreDeclaration,
|
|
22
|
+
list: false,
|
|
23
|
+
read(value, { name }) {
|
|
24
|
+
const config = storeStateOf(value)?.config;
|
|
25
|
+
if (!isRecord(config)) {
|
|
26
|
+
return fail(`store() takes an object, or nothing: store<Warning[]>({ expires: '30d' }).`);
|
|
27
|
+
}
|
|
28
|
+
for (const key of Object.keys(config)) {
|
|
29
|
+
if (!KEYS.includes(key)) {
|
|
30
|
+
fail(`"${key}" is not something a store has. It can have: ${KEYS.join(', ')}.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!NAME.test(name)) {
|
|
34
|
+
fail(`"${name}" can't name a store: the name of the export is the name of the store and of its file in data/, made of letters, digits, _ . and -.`);
|
|
35
|
+
}
|
|
36
|
+
const scope = (config.scope ?? 'guild');
|
|
37
|
+
if (!SCOPES.includes(scope)) {
|
|
38
|
+
fail(`"scope" is 'guild' (the data is kept per server, the default) or 'global' (one set of data for the whole bot), got ${JSON.stringify(config.scope)}.`);
|
|
39
|
+
}
|
|
40
|
+
let expires = null;
|
|
41
|
+
if (config.expires !== undefined) {
|
|
42
|
+
const ms = typeof config.expires === 'string'
|
|
43
|
+
? parseDuration(config.expires)
|
|
44
|
+
: null;
|
|
45
|
+
if (ms === null || ms < 1000) {
|
|
46
|
+
fail(`"expires" is how long a value is kept: ${DURATION_EXAMPLE}, 1 s at least. Got ${JSON.stringify(config.expires)}.`);
|
|
47
|
+
}
|
|
48
|
+
expires = ms;
|
|
49
|
+
}
|
|
50
|
+
bindStore(value, { name, scope, expires });
|
|
51
|
+
return Object.freeze({ name, scope, expires });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type StoreBackend, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
|
|
2
|
+
/** The folder of a project where the stores keep their files. */
|
|
3
|
+
export declare const DATA_FOLDER = "data";
|
|
4
|
+
export interface FileStoreBackendOptions {
|
|
5
|
+
/** How long a change waits for others before the file is written. */
|
|
6
|
+
writeDelay?: number;
|
|
7
|
+
/** How often expired entries are dropped. */
|
|
8
|
+
sweepEvery?: number;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
}
|
|
11
|
+
/** The data of every store, one file each, loaded on first use. */
|
|
12
|
+
export declare class FileStoreBackend implements StoreBackend {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(dir: string, options?: FileStoreBackendOptions);
|
|
15
|
+
/** The file of a store. */
|
|
16
|
+
file(store: string): string;
|
|
17
|
+
get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
|
|
18
|
+
set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
|
|
19
|
+
delete(store: string, key: StoreKeyParts): Promise<boolean>;
|
|
20
|
+
entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The default backend: one JSON file per store in the `data/` folder of
|
|
2
|
+
// the project, kept in memory and written back shortly after each change,
|
|
3
|
+
// atomically (a temporary file, then a rename), so a crash never leaves a
|
|
4
|
+
// half-written file. What is pending when the process exits is written
|
|
5
|
+
// then. Expired entries are dropped when a file is read, when they are
|
|
6
|
+
// asked for, and by a sweep every minute, so a file never grows forever.
|
|
7
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { alive, decodeKey, encodeKey, startsWith, } from './backend.js';
|
|
10
|
+
/** The folder of a project where the stores keep their files. */
|
|
11
|
+
export const DATA_FOLDER = 'data';
|
|
12
|
+
/** A name a file can be called: what an export is called. */
|
|
13
|
+
const FILE_NAME = /^[\w.-]+$/;
|
|
14
|
+
/** The data of every store, one file each, loaded on first use. */
|
|
15
|
+
export class FileStoreBackend {
|
|
16
|
+
#dir;
|
|
17
|
+
#stores = new Map();
|
|
18
|
+
#dirty = new Set();
|
|
19
|
+
#now;
|
|
20
|
+
#writeDelay;
|
|
21
|
+
#writeTimer = null;
|
|
22
|
+
#sweeper;
|
|
23
|
+
#onExit;
|
|
24
|
+
constructor(dir, options = {}) {
|
|
25
|
+
this.#dir = dir;
|
|
26
|
+
this.#now = options.now ?? Date.now;
|
|
27
|
+
this.#writeDelay = options.writeDelay ?? 100;
|
|
28
|
+
this.#sweeper = setInterval(() => this.#sweep(), options.sweepEvery ?? 60_000);
|
|
29
|
+
this.#sweeper.unref();
|
|
30
|
+
// What was changed in the last moments is not lost with the process.
|
|
31
|
+
this.#onExit = () => this.#flush();
|
|
32
|
+
process.on('exit', this.#onExit);
|
|
33
|
+
}
|
|
34
|
+
/** The file of a store. */
|
|
35
|
+
file(store) {
|
|
36
|
+
return join(this.#dir, `${store}.json`);
|
|
37
|
+
}
|
|
38
|
+
#load(store) {
|
|
39
|
+
let entries = this.#stores.get(store);
|
|
40
|
+
if (entries)
|
|
41
|
+
return entries;
|
|
42
|
+
if (!FILE_NAME.test(store)) {
|
|
43
|
+
throw new TypeError(`"${store}" can't name a store: its name is the name of its export, made of letters, digits, _ . and -.`);
|
|
44
|
+
}
|
|
45
|
+
entries = new Map();
|
|
46
|
+
let text = null;
|
|
47
|
+
try {
|
|
48
|
+
text = readFileSync(this.file(store), 'utf8');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code !== 'ENOENT')
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (text !== null) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) {
|
|
59
|
+
throw new Error('not a store file');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error(`${this.file(store)} is not a file ChapterJS wrote: fix it, or delete it to start the store empty.`);
|
|
64
|
+
}
|
|
65
|
+
const now = this.#now();
|
|
66
|
+
for (const [key, value, expiresAt] of parsed.entries) {
|
|
67
|
+
const entry = { value, expiresAt };
|
|
68
|
+
if (alive(entry, now))
|
|
69
|
+
entries.set(encodeKey(key), entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.#stores.set(store, entries);
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
#changed(store) {
|
|
76
|
+
this.#dirty.add(store);
|
|
77
|
+
if (this.#writeTimer)
|
|
78
|
+
return;
|
|
79
|
+
this.#writeTimer = setTimeout(() => this.#flush(), this.#writeDelay);
|
|
80
|
+
this.#writeTimer.unref();
|
|
81
|
+
}
|
|
82
|
+
/** Writes every store that changed. */
|
|
83
|
+
#flush() {
|
|
84
|
+
if (this.#writeTimer) {
|
|
85
|
+
clearTimeout(this.#writeTimer);
|
|
86
|
+
this.#writeTimer = null;
|
|
87
|
+
}
|
|
88
|
+
if (this.#dirty.size === 0)
|
|
89
|
+
return;
|
|
90
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
91
|
+
for (const store of this.#dirty) {
|
|
92
|
+
const entries = this.#stores.get(store);
|
|
93
|
+
if (!entries)
|
|
94
|
+
continue;
|
|
95
|
+
const file = {
|
|
96
|
+
version: 1,
|
|
97
|
+
entries: [...entries].map(([key, { value, expiresAt }]) => [
|
|
98
|
+
decodeKey(key),
|
|
99
|
+
value,
|
|
100
|
+
expiresAt,
|
|
101
|
+
]),
|
|
102
|
+
};
|
|
103
|
+
const path = this.file(store);
|
|
104
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
105
|
+
writeFileSync(temporary, `${JSON.stringify(file)}\n`);
|
|
106
|
+
renameSync(temporary, path);
|
|
107
|
+
}
|
|
108
|
+
this.#dirty.clear();
|
|
109
|
+
}
|
|
110
|
+
#sweep() {
|
|
111
|
+
const now = this.#now();
|
|
112
|
+
for (const [store, entries] of this.#stores) {
|
|
113
|
+
let dropped = false;
|
|
114
|
+
for (const [key, entry] of entries) {
|
|
115
|
+
if (!alive(entry, now)) {
|
|
116
|
+
entries.delete(key);
|
|
117
|
+
dropped = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (dropped)
|
|
121
|
+
this.#changed(store);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async get(store, key) {
|
|
125
|
+
const entries = this.#load(store);
|
|
126
|
+
const encoded = encodeKey(key);
|
|
127
|
+
const entry = entries.get(encoded);
|
|
128
|
+
if (!entry)
|
|
129
|
+
return undefined;
|
|
130
|
+
if (alive(entry, this.#now()))
|
|
131
|
+
return entry;
|
|
132
|
+
entries.delete(encoded);
|
|
133
|
+
this.#changed(store);
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
async set(store, key, entry) {
|
|
137
|
+
this.#load(store).set(encodeKey(key), entry);
|
|
138
|
+
this.#changed(store);
|
|
139
|
+
}
|
|
140
|
+
async delete(store, key) {
|
|
141
|
+
const entries = this.#load(store);
|
|
142
|
+
const encoded = encodeKey(key);
|
|
143
|
+
const entry = entries.get(encoded);
|
|
144
|
+
if (!entry)
|
|
145
|
+
return false;
|
|
146
|
+
entries.delete(encoded);
|
|
147
|
+
this.#changed(store);
|
|
148
|
+
return alive(entry, this.#now());
|
|
149
|
+
}
|
|
150
|
+
async entries(store, prefix) {
|
|
151
|
+
const entries = this.#load(store);
|
|
152
|
+
const now = this.#now();
|
|
153
|
+
const listed = [];
|
|
154
|
+
for (const [encoded, entry] of entries) {
|
|
155
|
+
const key = decodeKey(encoded);
|
|
156
|
+
if (!startsWith(key, prefix))
|
|
157
|
+
continue;
|
|
158
|
+
if (alive(entry, now))
|
|
159
|
+
listed.push({ key, ...entry });
|
|
160
|
+
else {
|
|
161
|
+
entries.delete(encoded);
|
|
162
|
+
this.#changed(store);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return listed;
|
|
166
|
+
}
|
|
167
|
+
async close() {
|
|
168
|
+
clearInterval(this.#sweeper);
|
|
169
|
+
process.off('exit', this.#onExit);
|
|
170
|
+
this.#flush();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { StoreBackend, StoreEntry, StoreKeyParts, StoreListedEntry } from './backend.js';
|
|
2
|
+
/** What a process asks the first one. */
|
|
3
|
+
export type StoreRequest = {
|
|
4
|
+
type: 'store';
|
|
5
|
+
id: number;
|
|
6
|
+
} & ({
|
|
7
|
+
op: 'get';
|
|
8
|
+
store: string;
|
|
9
|
+
key: StoreKeyParts;
|
|
10
|
+
} | {
|
|
11
|
+
op: 'set';
|
|
12
|
+
store: string;
|
|
13
|
+
key: StoreKeyParts;
|
|
14
|
+
entry: StoreEntry;
|
|
15
|
+
} | {
|
|
16
|
+
op: 'delete';
|
|
17
|
+
store: string;
|
|
18
|
+
key: StoreKeyParts;
|
|
19
|
+
} | {
|
|
20
|
+
op: 'entries';
|
|
21
|
+
store: string;
|
|
22
|
+
prefix: StoreKeyParts;
|
|
23
|
+
});
|
|
24
|
+
/** What the first process answers. */
|
|
25
|
+
export interface StoreReply {
|
|
26
|
+
type: 'store';
|
|
27
|
+
id: number;
|
|
28
|
+
result?: unknown;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
/** The two ends of a channel, as a process and its child have them. */
|
|
32
|
+
export interface Channel<Out, In> {
|
|
33
|
+
send(message: Out): void;
|
|
34
|
+
on(listener: (message: In) => void): void;
|
|
35
|
+
}
|
|
36
|
+
export declare const isStoreRequest: (message: unknown) => message is StoreRequest;
|
|
37
|
+
export declare const isStoreReply: (message: unknown) => message is StoreReply;
|
|
38
|
+
/** The backend of a process that is not the first: every call is a message. */
|
|
39
|
+
export declare class IpcStoreBackend implements StoreBackend {
|
|
40
|
+
#private;
|
|
41
|
+
constructor(channel: Channel<StoreRequest, unknown>);
|
|
42
|
+
get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
|
|
43
|
+
set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
|
|
44
|
+
delete(store: string, key: StoreKeyParts): Promise<boolean>;
|
|
45
|
+
entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* In the first process: answers the requests of one other process with
|
|
50
|
+
* the real backend. Returns what to call with each message received from
|
|
51
|
+
* that process; a message that is not a request is ignored.
|
|
52
|
+
*/
|
|
53
|
+
export declare function serveStores(backend: StoreBackend, reply: (message: StoreReply) => void): (message: unknown) => void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A bot split over several processes has one copy of its data: the first
|
|
2
|
+
// process keeps it (with the file backend) and the others ask it over
|
|
3
|
+
// IPC, the channel they already share with it. Each process then sees the
|
|
4
|
+
// same thing, whichever shard a command was used on.
|
|
5
|
+
export const isStoreRequest = (message) => typeof message === 'object' &&
|
|
6
|
+
message !== null &&
|
|
7
|
+
message.type === 'store' &&
|
|
8
|
+
'op' in message;
|
|
9
|
+
export const isStoreReply = (message) => typeof message === 'object' &&
|
|
10
|
+
message !== null &&
|
|
11
|
+
message.type === 'store' &&
|
|
12
|
+
!('op' in message);
|
|
13
|
+
/** The backend of a process that is not the first: every call is a message. */
|
|
14
|
+
export class IpcStoreBackend {
|
|
15
|
+
#channel;
|
|
16
|
+
#waiting = new Map();
|
|
17
|
+
#lastId = 0;
|
|
18
|
+
constructor(channel) {
|
|
19
|
+
this.#channel = channel;
|
|
20
|
+
channel.on(message => {
|
|
21
|
+
if (!isStoreReply(message))
|
|
22
|
+
return;
|
|
23
|
+
const pending = this.#waiting.get(message.id);
|
|
24
|
+
if (!pending)
|
|
25
|
+
return;
|
|
26
|
+
this.#waiting.delete(message.id);
|
|
27
|
+
if (message.error !== undefined)
|
|
28
|
+
pending.reject(new Error(message.error));
|
|
29
|
+
else
|
|
30
|
+
pending.resolve(message.result);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
#ask(request) {
|
|
34
|
+
const id = ++this.#lastId;
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
this.#waiting.set(id, {
|
|
37
|
+
resolve: resolve,
|
|
38
|
+
reject,
|
|
39
|
+
});
|
|
40
|
+
this.#channel.send({ type: 'store', id, ...request });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
get(store, key) {
|
|
44
|
+
return this.#ask({ op: 'get', store, key });
|
|
45
|
+
}
|
|
46
|
+
set(store, key, entry) {
|
|
47
|
+
return this.#ask({ op: 'set', store, key, entry });
|
|
48
|
+
}
|
|
49
|
+
delete(store, key) {
|
|
50
|
+
return this.#ask({ op: 'delete', store, key });
|
|
51
|
+
}
|
|
52
|
+
entries(store, prefix) {
|
|
53
|
+
return this.#ask({ op: 'entries', store, prefix });
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
// Nothing is kept here: the first process writes.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* In the first process: answers the requests of one other process with
|
|
61
|
+
* the real backend. Returns what to call with each message received from
|
|
62
|
+
* that process; a message that is not a request is ignored.
|
|
63
|
+
*/
|
|
64
|
+
export function serveStores(backend, reply) {
|
|
65
|
+
return message => {
|
|
66
|
+
if (!isStoreRequest(message))
|
|
67
|
+
return;
|
|
68
|
+
const { id } = message;
|
|
69
|
+
const run = () => {
|
|
70
|
+
switch (message.op) {
|
|
71
|
+
case 'get':
|
|
72
|
+
return backend.get(message.store, message.key);
|
|
73
|
+
case 'set':
|
|
74
|
+
return backend.set(message.store, message.key, message.entry);
|
|
75
|
+
case 'delete':
|
|
76
|
+
return backend.delete(message.store, message.key);
|
|
77
|
+
case 'entries':
|
|
78
|
+
return backend.entries(message.store, message.prefix);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
run().then(result => reply({ type: 'store', id, result }), (error) => reply({
|
|
82
|
+
type: 'store',
|
|
83
|
+
id,
|
|
84
|
+
error: error instanceof Error ? error.message : String(error),
|
|
85
|
+
}));
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Runs `fn` for a server (or for none, with `null`). */
|
|
2
|
+
export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
|
|
3
|
+
/**
|
|
4
|
+
* The id of the server the current function runs for: `null` when it runs
|
|
5
|
+
* for none (a task, a private message), `undefined` outside any function
|
|
6
|
+
* the framework runs.
|
|
7
|
+
*/
|
|
8
|
+
export declare function currentGuildId(): string | null | undefined;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The server the code runs for, known to the framework and not written by
|
|
2
|
+
// the user: a store per server reads it, so `warnings.get(member.id)` in a
|
|
3
|
+
// command is the warnings of the server the command was used in. Set by
|
|
4
|
+
// the routers around every function they run, on an asynchronous context
|
|
5
|
+
// of Node, so it follows the `await`s into the files the function imports.
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
7
|
+
/** The id of the server the current function runs for, or `null` for none. */
|
|
8
|
+
const scope = new AsyncLocalStorage();
|
|
9
|
+
/** Runs `fn` for a server (or for none, with `null`). */
|
|
10
|
+
export function withGuild(guildId, fn) {
|
|
11
|
+
return scope.run(guildId, fn);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The id of the server the current function runs for: `null` when it runs
|
|
15
|
+
* for none (a task, a private message), `undefined` outside any function
|
|
16
|
+
* the framework runs.
|
|
17
|
+
*/
|
|
18
|
+
export function currentGuildId() {
|
|
19
|
+
return scope.getStore();
|
|
20
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Duration } from '../commands/command.js';
|
|
2
|
+
import type { Guild } from '../structures/guild.js';
|
|
3
|
+
/** Where the data of a store is kept apart: per server, or one for the whole bot. */
|
|
4
|
+
export type StoreScope = 'guild' | 'global';
|
|
5
|
+
/** What a file gives to `store()`. */
|
|
6
|
+
export interface StoreConfig {
|
|
7
|
+
/**
|
|
8
|
+
* `'guild'` (the default): the data is kept per server, and a function
|
|
9
|
+
* that runs in a server reads and writes the data of that server, with
|
|
10
|
+
* nothing to say. `'global'`: one set of data for the whole bot.
|
|
11
|
+
*/
|
|
12
|
+
scope?: StoreScope;
|
|
13
|
+
/**
|
|
14
|
+
* How long a value is kept, unless `set()` says otherwise: a number and
|
|
15
|
+
* a unit, `s`, `m`, `h` or `d`, like `'10m'`, `'1d'` or `'1h30m'`. Kept
|
|
16
|
+
* forever by default.
|
|
17
|
+
*/
|
|
18
|
+
expires?: Duration;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A key of a store: a text, or a list of texts for a key made of several
|
|
22
|
+
* parts (`[channel.id, message.id]`), which `entries()` can then list by
|
|
23
|
+
* prefix.
|
|
24
|
+
*/
|
|
25
|
+
export type StoreKey = string | readonly string[];
|
|
26
|
+
/** What `set()` and `update()` take besides the value. */
|
|
27
|
+
export interface StoreSetOptions {
|
|
28
|
+
/**
|
|
29
|
+
* How long the value is kept: a duration like `'10m'`, a `Date`, or
|
|
30
|
+
* `null` to keep it forever. The `expires` of the store by default.
|
|
31
|
+
*/
|
|
32
|
+
expires?: Duration | Date | null;
|
|
33
|
+
}
|
|
34
|
+
/** One entry of a store, as `entries()` lists them. */
|
|
35
|
+
export interface StoreEntry<T> {
|
|
36
|
+
/** The key, every part of it (the server left out for a store per server). */
|
|
37
|
+
key: readonly string[];
|
|
38
|
+
value: T;
|
|
39
|
+
/** When the value expires, or `null` when it is kept forever. */
|
|
40
|
+
expires: Date | null;
|
|
41
|
+
}
|
|
42
|
+
/** A store, read and written for one server or for the whole bot. */
|
|
43
|
+
export interface ScopedStore<T> {
|
|
44
|
+
/** The value at `key`, or `undefined` when there is none, or it expired. */
|
|
45
|
+
get(key: StoreKey): Promise<T | undefined>;
|
|
46
|
+
/** Keeps `value` at `key`. The value must be JSON: no `Date`, `Map` or class instance. */
|
|
47
|
+
set(key: StoreKey, value: T, options?: StoreSetOptions): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Reads the value at `key` and keeps what `change` returns, in one go.
|
|
50
|
+
* `change` receives `undefined` when there is no value yet. The
|
|
51
|
+
* expiration stays what it was, unless `options` say otherwise.
|
|
52
|
+
*/
|
|
53
|
+
update(key: StoreKey, change: (current: T | undefined) => T, options?: StoreSetOptions): Promise<T>;
|
|
54
|
+
/** Forgets the value at `key`. `true` when there was one. */
|
|
55
|
+
delete(key: StoreKey): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Every entry, or the ones whose key starts with `prefix`, in no
|
|
58
|
+
* particular order. Expired ones are left out.
|
|
59
|
+
*/
|
|
60
|
+
entries(prefix?: StoreKey): Promise<StoreEntry<T>[]>;
|
|
61
|
+
/**
|
|
62
|
+
* How long the value at `key` is still kept, in milliseconds: `null`
|
|
63
|
+
* when it is kept forever, `undefined` when there is no value.
|
|
64
|
+
*/
|
|
65
|
+
ttl(key: StoreKey): Promise<number | null | undefined>;
|
|
66
|
+
}
|
|
67
|
+
/** What `store()` returns: a declaration the framework finds in the exports of a file. */
|
|
68
|
+
export interface Store<T> extends ScopedStore<T> {
|
|
69
|
+
/**
|
|
70
|
+
* The same store, for one server: what a task, or a function that runs
|
|
71
|
+
* outside a server, uses to read and write the data of that server.
|
|
72
|
+
*/
|
|
73
|
+
in(guild: Guild | string): ScopedStore<T>;
|
|
74
|
+
}
|
|
75
|
+
/** A store as the loader knows it: its config, and once loaded, its name. */
|
|
76
|
+
export interface StoreState {
|
|
77
|
+
readonly config: unknown;
|
|
78
|
+
/** The name of the export that declares it, once bound by the loader. */
|
|
79
|
+
name?: string;
|
|
80
|
+
/** What `read` found in the config. */
|
|
81
|
+
scope: StoreScope;
|
|
82
|
+
/** The default expiration, in ms, or `null` for never. */
|
|
83
|
+
expires: number | null;
|
|
84
|
+
}
|
|
85
|
+
/** The state of a declared store, if the value is one. */
|
|
86
|
+
export declare function storeStateOf(value: unknown): StoreState | undefined;
|
|
87
|
+
export declare const isStoreDeclaration: (value: unknown) => boolean;
|
|
88
|
+
/** Binds a declared store to the name of its export. */
|
|
89
|
+
export declare function bindStore(declared: unknown, bound: {
|
|
90
|
+
name: string;
|
|
91
|
+
scope: StoreScope;
|
|
92
|
+
expires: number | null;
|
|
93
|
+
}): void;
|
|
94
|
+
/**
|
|
95
|
+
* Refuses what would not survive a trip through JSON: a `Date` would come
|
|
96
|
+
* back as a text, a `Map` as `{}`, a class instance as a plain object.
|
|
97
|
+
*/
|
|
98
|
+
export declare function checkJson(value: unknown, path?: string): void;
|
|
99
|
+
/**
|
|
100
|
+
* Declares a store: data the bot keeps from one restart to the next,
|
|
101
|
+
* typed by what it holds. Export the result from any file of `src/`: the
|
|
102
|
+
* name of the export is the name of the store, and its file in `data/`.
|
|
103
|
+
* Per server unless `scope: 'global'` is given, with an expiration if
|
|
104
|
+
* `expires` is.
|
|
105
|
+
*
|
|
106
|
+
* ```ts
|
|
107
|
+
* import { store } from 'chapterjs';
|
|
108
|
+
*
|
|
109
|
+
* export const warnings = store<Warning[]>({ expires: '30d' });
|
|
110
|
+
*
|
|
111
|
+
* // In a command, in a server:
|
|
112
|
+
* await warnings.update(member.id, (list = []) => [...list, warning]);
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export declare function store<T>(config?: StoreConfig): Store<T>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// What a file declares with `store()`: a place to keep data from one
|
|
2
|
+
// restart to the next, per server by default, typed by what it holds,
|
|
3
|
+
// with an expiration if wanted. Named by its export, like a button:
|
|
4
|
+
// `export const warnings = store<Warning[]>()` is the store `warnings`.
|
|
5
|
+
// What `store()` returns works once the loader bound it to that name and
|
|
6
|
+
// the bot runs; the data itself lives in a backend (`backend.ts`).
|
|
7
|
+
import { parseDuration } from '../util/duration.js';
|
|
8
|
+
import { storeBackend, } from './backend.js';
|
|
9
|
+
import { currentGuildId } from './scope.js';
|
|
10
|
+
const STATE = Symbol.for('chapterjs.store');
|
|
11
|
+
/** The state of a declared store, if the value is one. */
|
|
12
|
+
export function storeStateOf(value) {
|
|
13
|
+
if (typeof value !== 'object' || value === null)
|
|
14
|
+
return undefined;
|
|
15
|
+
return value[STATE];
|
|
16
|
+
}
|
|
17
|
+
export const isStoreDeclaration = (value) => storeStateOf(value) !== undefined;
|
|
18
|
+
/** Binds a declared store to the name of its export. */
|
|
19
|
+
export function bindStore(declared, bound) {
|
|
20
|
+
const state = storeStateOf(declared);
|
|
21
|
+
if (!state)
|
|
22
|
+
throw new TypeError('Not a store.');
|
|
23
|
+
state.name = bound.name;
|
|
24
|
+
state.scope = bound.scope;
|
|
25
|
+
state.expires = bound.expires;
|
|
26
|
+
}
|
|
27
|
+
const parts = (key) => {
|
|
28
|
+
const list = typeof key === 'string' ? [key] : [...key];
|
|
29
|
+
if (list.length === 0) {
|
|
30
|
+
throw new TypeError('A key of a store is a text, or a list of texts with at least one.');
|
|
31
|
+
}
|
|
32
|
+
for (const part of list) {
|
|
33
|
+
if (typeof part !== 'string') {
|
|
34
|
+
throw new TypeError(`A key of a store is made of texts, got ${JSON.stringify(part) ?? typeof part}. Write an id as a text: member.id, not a number.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return list;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Refuses what would not survive a trip through JSON: a `Date` would come
|
|
41
|
+
* back as a text, a `Map` as `{}`, a class instance as a plain object.
|
|
42
|
+
*/
|
|
43
|
+
export function checkJson(value, path = 'the value') {
|
|
44
|
+
if (value === undefined) {
|
|
45
|
+
throw new TypeError(`${path} is undefined: a store keeps JSON. To forget a value, use delete().`);
|
|
46
|
+
}
|
|
47
|
+
if (value === null)
|
|
48
|
+
return;
|
|
49
|
+
switch (typeof value) {
|
|
50
|
+
case 'string':
|
|
51
|
+
case 'boolean':
|
|
52
|
+
return;
|
|
53
|
+
case 'number':
|
|
54
|
+
if (!Number.isFinite(value)) {
|
|
55
|
+
throw new TypeError(`${path} is ${value}, which JSON can't hold: a store keeps JSON.`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
case 'bigint':
|
|
59
|
+
case 'function':
|
|
60
|
+
case 'symbol':
|
|
61
|
+
throw new TypeError(`${path} is a ${typeof value}, which JSON can't hold: a store keeps JSON (texts, numbers, booleans, lists and plain objects).`);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
value.forEach((item, index) => checkJson(item, `${path}[${index}]`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const proto = Object.getPrototypeOf(value);
|
|
68
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
69
|
+
const name = value.constructor?.name ??
|
|
70
|
+
'an object of a class';
|
|
71
|
+
throw new TypeError(`${path} is a ${name}, which JSON can't hold: a store keeps JSON (texts, numbers, booleans, lists and plain objects). Keep what you need of it: an id, a text, a number.`);
|
|
72
|
+
}
|
|
73
|
+
for (const [key, item] of Object.entries(value)) {
|
|
74
|
+
if (item !== undefined)
|
|
75
|
+
checkJson(item, `${path}.${key}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** When a value set now expires, from what was asked. */
|
|
79
|
+
function expiresAt(options, fallback, now) {
|
|
80
|
+
if (!options || options.expires === undefined) {
|
|
81
|
+
return fallback === null ? null : now + fallback;
|
|
82
|
+
}
|
|
83
|
+
const { expires } = options;
|
|
84
|
+
if (expires === null)
|
|
85
|
+
return null;
|
|
86
|
+
if (expires instanceof Date) {
|
|
87
|
+
const ms = expires.getTime();
|
|
88
|
+
if (!Number.isFinite(ms)) {
|
|
89
|
+
throw new TypeError('"expires" is a Date that is not a valid date.');
|
|
90
|
+
}
|
|
91
|
+
return ms;
|
|
92
|
+
}
|
|
93
|
+
const ms = typeof expires === 'string' ? parseDuration(expires) : null;
|
|
94
|
+
if (ms === null || ms < 1000) {
|
|
95
|
+
throw new TypeError(`"expires" is a duration (a number and a unit: s, m, h or d, like '10m' or '1d', 1 s at least), a Date, or null to keep the value forever. Got ${JSON.stringify(expires)}.`);
|
|
96
|
+
}
|
|
97
|
+
return now + ms;
|
|
98
|
+
}
|
|
99
|
+
/** The store as its functions see it: the backend, the name, the scope prefix. */
|
|
100
|
+
function scoped(state, guildOf) {
|
|
101
|
+
const ready = () => {
|
|
102
|
+
if (!state.name) {
|
|
103
|
+
throw new Error('This store was not loaded by ChapterJS: a store is exported from a file of src/ (export const name = store()), and used once the bot started.');
|
|
104
|
+
}
|
|
105
|
+
const backend = storeBackend();
|
|
106
|
+
if (state.scope === 'global') {
|
|
107
|
+
return { backend, name: state.name, prefix: [] };
|
|
108
|
+
}
|
|
109
|
+
const guildId = guildOf();
|
|
110
|
+
if (!guildId) {
|
|
111
|
+
throw new Error(`${state.name} is a store per server, and this code does not run in one: use ${state.name}.in(guild) to say which server, or declare the store with scope: 'global'.`);
|
|
112
|
+
}
|
|
113
|
+
return { backend, name: state.name, prefix: [guildId] };
|
|
114
|
+
};
|
|
115
|
+
const full = (prefix, key) => [
|
|
116
|
+
...prefix,
|
|
117
|
+
...parts(key),
|
|
118
|
+
];
|
|
119
|
+
const set = async (key, value, options, keep) => {
|
|
120
|
+
const { backend, name, prefix } = ready();
|
|
121
|
+
checkJson(value);
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
const at = keep !== undefined && (!options || options.expires === undefined)
|
|
124
|
+
? keep
|
|
125
|
+
: expiresAt(options, state.expires, now);
|
|
126
|
+
await backend.set(name, full(prefix, key), { value, expiresAt: at });
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
async get(key) {
|
|
130
|
+
const { backend, name, prefix } = ready();
|
|
131
|
+
const entry = await backend.get(name, full(prefix, key));
|
|
132
|
+
return entry?.value;
|
|
133
|
+
},
|
|
134
|
+
set: (key, value, options) => set(key, value, options, undefined),
|
|
135
|
+
async update(key, change, options) {
|
|
136
|
+
const { backend, name, prefix } = ready();
|
|
137
|
+
const entry = await backend.get(name, full(prefix, key));
|
|
138
|
+
const next = change(entry?.value);
|
|
139
|
+
await set(key, next, options, entry ? entry.expiresAt : undefined);
|
|
140
|
+
return next;
|
|
141
|
+
},
|
|
142
|
+
async delete(key) {
|
|
143
|
+
const { backend, name, prefix } = ready();
|
|
144
|
+
return backend.delete(name, full(prefix, key));
|
|
145
|
+
},
|
|
146
|
+
async entries(prefixKey) {
|
|
147
|
+
const { backend, name, prefix } = ready();
|
|
148
|
+
const listed = await backend.entries(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
|
|
149
|
+
return listed.map(entry => ({
|
|
150
|
+
key: entry.key.slice(prefix.length),
|
|
151
|
+
value: entry.value,
|
|
152
|
+
expires: entry.expiresAt === null ? null : new Date(entry.expiresAt),
|
|
153
|
+
}));
|
|
154
|
+
},
|
|
155
|
+
async ttl(key) {
|
|
156
|
+
const { backend, name, prefix } = ready();
|
|
157
|
+
const entry = await backend.get(name, full(prefix, key));
|
|
158
|
+
if (!entry)
|
|
159
|
+
return undefined;
|
|
160
|
+
return entry.expiresAt === null
|
|
161
|
+
? null
|
|
162
|
+
: Math.max(0, entry.expiresAt - Date.now());
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Declares a store: data the bot keeps from one restart to the next,
|
|
168
|
+
* typed by what it holds. Export the result from any file of `src/`: the
|
|
169
|
+
* name of the export is the name of the store, and its file in `data/`.
|
|
170
|
+
* Per server unless `scope: 'global'` is given, with an expiration if
|
|
171
|
+
* `expires` is.
|
|
172
|
+
*
|
|
173
|
+
* ```ts
|
|
174
|
+
* import { store } from 'chapterjs';
|
|
175
|
+
*
|
|
176
|
+
* export const warnings = store<Warning[]>({ expires: '30d' });
|
|
177
|
+
*
|
|
178
|
+
* // In a command, in a server:
|
|
179
|
+
* await warnings.update(member.id, (list = []) => [...list, warning]);
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
export function store(config = {}) {
|
|
183
|
+
const state = { config, scope: 'guild', expires: null };
|
|
184
|
+
const guildIdOf = (guild) => {
|
|
185
|
+
const id = typeof guild === 'string' ? guild : guild?.id;
|
|
186
|
+
if (typeof id !== 'string' || id === '') {
|
|
187
|
+
throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
|
|
188
|
+
}
|
|
189
|
+
return id;
|
|
190
|
+
};
|
|
191
|
+
const declared = {
|
|
192
|
+
...scoped(state, () => currentGuildId() ?? null),
|
|
193
|
+
in: guild => {
|
|
194
|
+
const id = guildIdOf(guild);
|
|
195
|
+
return scoped(state, () => id);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
Object.defineProperty(declared, STATE, { value: state });
|
|
199
|
+
return declared;
|
|
200
|
+
}
|
package/dist/tasks/scheduler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Runs the tasks of the project when they are due. Behind an interface:
|
|
2
2
|
// this one keeps timers in the process; a bot on several machines would
|
|
3
3
|
// share one through another implementation, with no change to the files.
|
|
4
|
+
import { withGuild } from '../store/scope.js';
|
|
4
5
|
import { describeSchedule, nextRun, scheduleKey } from './schedule.js';
|
|
5
6
|
/** The longest a timer of Node can wait. */
|
|
6
7
|
const MAX_DELAY = 2 ** 31 - 1;
|
|
@@ -98,7 +99,7 @@ export class TimerScheduler {
|
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
100
101
|
scheduled.running = true;
|
|
101
|
-
new Promise(resolve => resolve(scheduled.entry.task.run(Object.freeze({ ...context, now }))))
|
|
102
|
+
new Promise(resolve => resolve(withGuild(null, () => scheduled.entry.task.run(Object.freeze({ ...context, now })))))
|
|
102
103
|
.catch((error) => this.#options.onError(file, error))
|
|
103
104
|
.finally(() => {
|
|
104
105
|
scheduled.running = false;
|