chapterjs 0.2.2 → 0.3.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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/cli/cli.js +41 -6
- 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 +38 -0
- package/dist/cli/project.js +51 -10
- package/dist/cli/start.js +18 -0
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +444 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +16 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +17 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/router.d.ts +12 -4
- package/dist/events/router.js +29 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +7 -1
- package/dist/interactions/dispatch.js +7 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- package/dist/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/memory-backend.d.ts +15 -0
- package/dist/store/memory-backend.js +56 -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 +203 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +10 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
package/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/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# ChapterJS
|
|
2
|
+
|
|
3
|
+
**The Discord framework: ultra simple, but ultra customizable.**
|
|
4
|
+
|
|
5
|
+
Drop files into `src/`, and the `chapterjs` CLI does everything else: the connection, the intents, the registration of commands, hot reload, sharding and several processes for large bots. Everything you write is typed end to end, without a single type to write.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm create chapterjs my-bot
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
📖 Documentation, in English and in French: **[chapterjs.org](https://www.chapterjs.org)**
|
|
12
|
+
|
|
13
|
+
## A file is what it exports
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// src/commands/hello.ts
|
|
17
|
+
import { command } from 'chapterjs';
|
|
18
|
+
|
|
19
|
+
export default command({
|
|
20
|
+
name: 'hello',
|
|
21
|
+
description: 'Says hello to someone',
|
|
22
|
+
cooldown: '10s',
|
|
23
|
+
options: {
|
|
24
|
+
who: { type: 'user', description: 'Who to greet', required: true },
|
|
25
|
+
},
|
|
26
|
+
async run({ interaction, options }) {
|
|
27
|
+
// options.who is a User: typed from the options above.
|
|
28
|
+
await interaction.reply(`Hello ${options.who}!`);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The framework loads every file of `src/`, wherever it is and whatever it is called, and runs what the files export: `command()`, `event()`, `button()`, `select()`, `modal()`, `embed()`, `task()`, `store()`, `presence()`, `language()`. The folders are yours to organise with.
|
|
34
|
+
|
|
35
|
+
## What it does
|
|
36
|
+
|
|
37
|
+
- **Slash commands** with typed options, choices, autocomplete, permissions, cooldowns and translations.
|
|
38
|
+
- **Events**: the intents follow from the files you write, privileged ones included, with a direct link when one must be enabled.
|
|
39
|
+
- **Components**: buttons, select menus, forms and embeds, with typed `data` carried in the message and no id to write.
|
|
40
|
+
- **Tasks**: every so often or with a cron, in the timezone you choose.
|
|
41
|
+
- **Storage**: `store<Warning[]>({ expires: '30d' })` keeps data from one restart to the next, per server, typed, in a JSON file written for you.
|
|
42
|
+
- **Translated messages**: `t('welcome', { name })` speaks the language of who will read it.
|
|
43
|
+
- **Voice**: join a channel and play a file, an attachment or a link, end-to-end encrypted (DAVE).
|
|
44
|
+
- **Tests**: `chapterjs test` runs the bot against a Discord in memory, no token needed: every file is used once by itself, then your scenarios (`scenario('ping answers Pong', async bot => { … })`) say what the bot should answer.
|
|
45
|
+
|
|
46
|
+
## Four commands
|
|
47
|
+
|
|
48
|
+
- `chapterjs dev` runs the bot on a test server and reloads each file when you save it. A broken file is reported with its line and never stops the bot.
|
|
49
|
+
- `chapterjs test` checks the bot without Discord, with nothing written and with your scenarios.
|
|
50
|
+
- `chapterjs build` checks every file and the types, then compiles the bot into one JavaScript file.
|
|
51
|
+
- `chapterjs start` runs it in production, on several processes when the bot is large.
|
|
52
|
+
|
|
53
|
+
Nothing to install to run a bot: the framework only uses what Node has (Node 24 or later). Discord's rate limits, payload sizes and reconnections are handled for you.
|
|
54
|
+
|
|
55
|
+
## Versions
|
|
56
|
+
|
|
57
|
+
`chapterjs`, `create-chapterjs` and `create-chapter` are always released with the same version, and follow [semantic versioning](https://semver.org): what a bot's files use (the functions exported by `chapterjs`, their options, what your functions receive, the files and env vars of a project, the CLI commands) only changes in a major version. A new feature comes in a minor one, a fix in a patch. The [changelog](https://www.chapterjs.org/changelog) says what each version brings, and `chapterjs dev` tells you when a new one is out.
|
|
58
|
+
|
|
59
|
+
## Contributing
|
|
60
|
+
|
|
61
|
+
The source, the issues and the documentation live in [github.com/chapterjs/chapterjs](https://github.com/chapterjs/chapterjs). Read [CONTRIBUTING.md](https://github.com/chapterjs/chapterjs/blob/master/CONTRIBUTING.md) to get started.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
[MIT](./LICENSE)
|
package/dist/cli/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { writeTypes } from './project.js';
|
|
|
7
7
|
import { createLog } from './log.js';
|
|
8
8
|
import { nodeTooOld } from './node-version.js';
|
|
9
9
|
import { start } from './start.js';
|
|
10
|
+
import { test } from './test.js';
|
|
10
11
|
const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
11
12
|
const USAGE = `Usage: chapterjs <command>
|
|
12
13
|
|
|
@@ -14,6 +15,7 @@ Commands:
|
|
|
14
15
|
dev Run your bot on your dev server and reload it when you save a file
|
|
15
16
|
build Check your bot and prepare it for production
|
|
16
17
|
start Run for everyone the bot that was built
|
|
18
|
+
test Check your bot without Discord: every file once, then your scenarios
|
|
17
19
|
sync Write the types of your project (dev does it too)
|
|
18
20
|
|
|
19
21
|
Options of start:
|
|
@@ -29,19 +31,40 @@ if (tooOld) {
|
|
|
29
31
|
const [command, ...rest] = process.argv.slice(2);
|
|
30
32
|
/**
|
|
31
33
|
* Ctrl+C and a stop asked by the system both end the session cleanly, so
|
|
32
|
-
* the bot goes offline at once. A second one
|
|
34
|
+
* the bot goes offline at once. A second one, pressed while the first is
|
|
35
|
+
* still being handled, does not wait.
|
|
36
|
+
*
|
|
37
|
+
* One Ctrl+C reaches the bot twice when a package manager runs it (`pnpm
|
|
38
|
+
* dev`): once from the terminal, which signals every process of the
|
|
39
|
+
* foreground group, and once forwarded by the package manager. Two signals
|
|
40
|
+
* within a second are that one Ctrl+C, not a second one.
|
|
33
41
|
*/
|
|
34
42
|
function stopSignal() {
|
|
35
43
|
const controller = new AbortController();
|
|
44
|
+
let askedAt = 0;
|
|
36
45
|
for (const name of ['SIGINT', 'SIGTERM']) {
|
|
37
46
|
process.on(name, () => {
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (controller.signal.aborted) {
|
|
49
|
+
if (now - askedAt >= 1000)
|
|
50
|
+
process.exit(1);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
askedAt = now;
|
|
40
54
|
controller.abort();
|
|
41
55
|
});
|
|
42
56
|
}
|
|
43
57
|
return controller.signal;
|
|
44
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Ends the process once everything printed has reached the terminal. When
|
|
61
|
+
* the output is a pipe (`pnpm test` puts one between itself and the
|
|
62
|
+
* command), writes are asynchronous on macOS and Windows: `process.exit()`
|
|
63
|
+
* right after the last line would cut it.
|
|
64
|
+
*/
|
|
65
|
+
function exit() {
|
|
66
|
+
process.stdout.write('', () => process.exit());
|
|
67
|
+
}
|
|
45
68
|
/** `--processes 4` or `--processes=4`; `null` when it is not a count. */
|
|
46
69
|
function readProcesses(args) {
|
|
47
70
|
const index = args.findIndex(arg => arg === '--processes' || arg.startsWith('--processes='));
|
|
@@ -66,7 +89,7 @@ if (command === 'dev') {
|
|
|
66
89
|
signal: controller.signal,
|
|
67
90
|
});
|
|
68
91
|
// Nothing may keep the process alive once the bot is disconnected.
|
|
69
|
-
|
|
92
|
+
exit();
|
|
70
93
|
}
|
|
71
94
|
else if (command === 'start') {
|
|
72
95
|
const processes = readProcesses(rest);
|
|
@@ -91,7 +114,7 @@ else if (command === 'start') {
|
|
|
91
114
|
script: process.argv[1],
|
|
92
115
|
args: ['start'],
|
|
93
116
|
});
|
|
94
|
-
|
|
117
|
+
exit();
|
|
95
118
|
}
|
|
96
119
|
}
|
|
97
120
|
else if (command === 'sync') {
|
|
@@ -108,7 +131,19 @@ else if (command === undefined || command === '--help' || command === '-h') {
|
|
|
108
131
|
}
|
|
109
132
|
else if (command === 'build') {
|
|
110
133
|
process.exitCode = await build({ cwd: process.cwd(), version, log });
|
|
111
|
-
|
|
134
|
+
exit();
|
|
135
|
+
}
|
|
136
|
+
else if (command === 'test') {
|
|
137
|
+
// How long a scenario may take: only tests shorten it.
|
|
138
|
+
const scenarioTimeout = Number(process.env.CHAPTERJS_SCENARIO_TIMEOUT);
|
|
139
|
+
process.exitCode = await test({
|
|
140
|
+
cwd: process.cwd(),
|
|
141
|
+
version,
|
|
142
|
+
log,
|
|
143
|
+
...(scenarioTimeout > 0 ? { scenarioTimeout } : {}),
|
|
144
|
+
progress: process.stdout.isTTY === true,
|
|
145
|
+
});
|
|
146
|
+
exit();
|
|
112
147
|
}
|
|
113
148
|
else {
|
|
114
149
|
log.error(`"${command}" is not a command.\n${USAGE}`);
|
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';
|
|
@@ -7,6 +9,8 @@ import type { Locale } from '../discord/types/common.js';
|
|
|
7
9
|
import { type TaskEntry } from '../tasks/scheduler.js';
|
|
8
10
|
import { type Bot, type BotOptions } from '../core/bot.js';
|
|
9
11
|
import { type GatewayIntentName } from '../discord/intents.js';
|
|
12
|
+
import type { GatewayDispatchEventName } from '../discord/types/gateway-events.js';
|
|
13
|
+
import type { RawInteraction } from '../discord/types/interaction.js';
|
|
10
14
|
import { type LoadedEvent } from '../events/router.js';
|
|
11
15
|
import { type BuiltFile, type Declaration, type FailedFile } from '../loader/loader.js';
|
|
12
16
|
import type { Log } from './log.js';
|
|
@@ -26,8 +30,26 @@ export interface ProjectOptions {
|
|
|
26
30
|
* Without it, the files of `src` are read on every load.
|
|
27
31
|
*/
|
|
28
32
|
joinsVoice?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Where the stores of the project keep their data. A file per store in
|
|
35
|
+
* the `data/` folder of the project by default; the first process of a
|
|
36
|
+
* cluster, through IPC, for the other processes.
|
|
37
|
+
*/
|
|
38
|
+
stores?: StoreBackend;
|
|
29
39
|
/** Only tests change this. */
|
|
30
40
|
deferAfter?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Told what the bot is doing with the files, and when it is done with
|
|
43
|
+
* it: what `chapterjs test` waits for. Nothing of the bot waits.
|
|
44
|
+
*/
|
|
45
|
+
observe?: ProjectObserver;
|
|
46
|
+
}
|
|
47
|
+
/** What `chapterjs test` watches of a running project. */
|
|
48
|
+
export interface ProjectObserver {
|
|
49
|
+
/** An interaction was given to the files; `done` once they answered it. */
|
|
50
|
+
interaction?(raw: RawInteraction, done: Promise<void>): void;
|
|
51
|
+
/** A gateway event was given to the files; `done` once they handled it. */
|
|
52
|
+
event?(event: GatewayDispatchEventName, data: unknown, done: Promise<void>): void;
|
|
31
53
|
}
|
|
32
54
|
/** What the types of a project are written from, once its files ran. */
|
|
33
55
|
export interface TypesInfo {
|
|
@@ -55,6 +77,12 @@ export interface TypesInfo {
|
|
|
55
77
|
export declare function writeTypes(cwd: string, info?: TypesInfo): Promise<number>;
|
|
56
78
|
/** The kinds of declarations a project is made of: one per feature. */
|
|
57
79
|
export declare const DECLARATIONS: readonly Declaration<unknown>[];
|
|
80
|
+
/** A store, with where it is declared. */
|
|
81
|
+
export interface StoreEntry {
|
|
82
|
+
file: string;
|
|
83
|
+
export: string;
|
|
84
|
+
store: LoadedStore;
|
|
85
|
+
}
|
|
58
86
|
/** What a bot of the project is connected with. */
|
|
59
87
|
export interface ConnectOptions {
|
|
60
88
|
token: string;
|
|
@@ -82,6 +110,9 @@ export interface Project {
|
|
|
82
110
|
readonly commands: ReadonlyMap<string, CommandEntry>;
|
|
83
111
|
readonly components: ReadonlyMap<string, ComponentEntry>;
|
|
84
112
|
readonly tasks: ReadonlyMap<string, TaskEntry>;
|
|
113
|
+
readonly stores: ReadonlyMap<string, StoreEntry>;
|
|
114
|
+
/** Where the stores keep their data: what a cluster serves to its other processes. */
|
|
115
|
+
readonly storeBackend: StoreBackend;
|
|
85
116
|
/** The presence declared, or `null` without one. */
|
|
86
117
|
readonly presence: LoadedPresence | null;
|
|
87
118
|
/** The languages of the project, assembled, or `null` without any. */
|
|
@@ -123,10 +154,17 @@ export interface Project {
|
|
|
123
154
|
startTasks(current: () => Bot | null): void;
|
|
124
155
|
/** Stops the tasks: nothing runs after this. */
|
|
125
156
|
stopTasks(): void;
|
|
157
|
+
/**
|
|
158
|
+
* Runs one task now, as the scheduler would when its time comes, with
|
|
159
|
+
* the bot given. Resolves once it is done; an error is reported.
|
|
160
|
+
*/
|
|
161
|
+
runTask(entry: TaskEntry, bot: Bot, now?: Date): Promise<void>;
|
|
126
162
|
/** Looks after the memory of the bot `current` returns. */
|
|
127
163
|
watchMemory(current: () => Bot | null): {
|
|
128
164
|
stop(): void;
|
|
129
165
|
};
|
|
166
|
+
/** Writes what the stores still have to write. Nothing is kept after this. */
|
|
167
|
+
closeStores(): Promise<void>;
|
|
130
168
|
}
|
|
131
169
|
/** Whether the folder is a project: it has a `src` folder. */
|
|
132
170
|
export declare function hasSources(cwd: string, log: Log): Promise<boolean>;
|
package/dist/cli/project.js
CHANGED
|
@@ -15,11 +15,14 @@ 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';
|
|
21
24
|
import { translateCommand, unknownCommands } from '../messages/commands.js';
|
|
22
|
-
import { TimerScheduler, } from '../tasks/scheduler.js';
|
|
25
|
+
import { runTask, TimerScheduler, } from '../tasks/scheduler.js';
|
|
23
26
|
import { createBot } from '../core/bot.js';
|
|
24
27
|
import { watchMemory } from '../core/memory.js';
|
|
25
28
|
import { GatewayIntent } from '../discord/intents.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
|
];
|
|
@@ -72,7 +76,7 @@ export async function hasSources(cwd, log) {
|
|
|
72
76
|
if ((await stat(join(cwd, 'src')).catch(() => null))?.isDirectory()) {
|
|
73
77
|
return true;
|
|
74
78
|
}
|
|
75
|
-
log.error('There is no src folder here.\nRun this command in the folder of your bot (the one with package.json), or create a project with "pnpm create
|
|
79
|
+
log.error('There is no src folder here.\nRun this command in the folder of your bot (the one with package.json), or create a project with "pnpm create chapterjs".');
|
|
76
80
|
return false;
|
|
77
81
|
}
|
|
78
82
|
/** The intents that only bring what happens in private messages. */
|
|
@@ -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,11 +416,15 @@ 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
|
-
|
|
393
|
-
|
|
394
|
-
.
|
|
422
|
+
// A file declaring several events that need the intent is named once.
|
|
423
|
+
filesNeeding: intent => [
|
|
424
|
+
...new Set([...events.values()]
|
|
425
|
+
.filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
|
|
426
|
+
.map(({ file }) => file)),
|
|
427
|
+
],
|
|
395
428
|
intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
|
|
396
429
|
async connect(connection) {
|
|
397
430
|
const created = createBot({
|
|
@@ -419,14 +452,17 @@ export function createProject(options) {
|
|
|
419
452
|
},
|
|
420
453
|
beforeDispatch: (event, data) => router.before(created.ctx, event, data),
|
|
421
454
|
onDispatch(event, data, info) {
|
|
422
|
-
router.dispatch(created.ctx, event, data, {
|
|
455
|
+
const handled = router.dispatch(created.ctx, event, data, {
|
|
423
456
|
joined: info.joined,
|
|
424
457
|
before: info.before,
|
|
425
458
|
});
|
|
459
|
+
options.observe?.event?.(event, data, handled);
|
|
426
460
|
if (event === 'INTERACTION_CREATE') {
|
|
427
461
|
const interaction = data;
|
|
428
|
-
commandRouter.dispatch(created.ctx, interaction)
|
|
429
|
-
|
|
462
|
+
const answered = commandRouter.dispatch(created.ctx, interaction) ??
|
|
463
|
+
componentRouter.dispatch(created.ctx, interaction) ??
|
|
464
|
+
Promise.resolve();
|
|
465
|
+
options.observe?.interaction?.(interaction, answered);
|
|
430
466
|
}
|
|
431
467
|
},
|
|
432
468
|
onEvent(event) {
|
|
@@ -488,8 +524,13 @@ export function createProject(options) {
|
|
|
488
524
|
});
|
|
489
525
|
},
|
|
490
526
|
stopTasks: () => scheduler.stop(),
|
|
527
|
+
runTask(entry, bot, now = new Date()) {
|
|
528
|
+
const user = bot.ctx.cache.users.get(bot.ctx.self.userId);
|
|
529
|
+
return runTask(entry, { user, guilds: bot.ctx.cache.guilds, ...translation(bot.ctx, null) }, now, reporter.onError);
|
|
530
|
+
},
|
|
491
531
|
// The bot looks after its own memory: it remembers less before memory
|
|
492
532
|
// is full, and only speaks up when it can't give up anything more.
|
|
533
|
+
closeStores: () => storeBackend.close(),
|
|
493
534
|
watchMemory: current => watchMemory({
|
|
494
535
|
cache: () => current()?.ctx.cache,
|
|
495
536
|
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. */
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Log } from './log.js';
|
|
2
|
+
export interface TestOptions {
|
|
3
|
+
/** The folder of the project. */
|
|
4
|
+
cwd: string;
|
|
5
|
+
/** The version of the framework. */
|
|
6
|
+
version: string;
|
|
7
|
+
log: Log;
|
|
8
|
+
/** How long a scenario may take, in milliseconds. Default: 10 s. */
|
|
9
|
+
scenarioTimeout?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a person is watching a terminal: the check in progress is then
|
|
12
|
+
* shown, case by case, and replaced by its result once done.
|
|
13
|
+
*/
|
|
14
|
+
progress?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* What a terminal shows while a file is tested: `↻ Testing src/close.ts`,
|
|
18
|
+
* then one line per thing and case as it is tried. Once the file is done,
|
|
19
|
+
* those lines are erased and its result takes their place, so a report
|
|
20
|
+
* that passed reads as if nothing had moved. When nobody is watching (the
|
|
21
|
+
* output is not a terminal), nothing is shown meanwhile.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkProgress(enabled: boolean, log: Pick<Log, 'reload'>, write: (text: string) => void): {
|
|
24
|
+
start(title: string): void;
|
|
25
|
+
step(label: string): void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
};
|
|
28
|
+
/** Resolves with the exit code of the process. */
|
|
29
|
+
export declare function test(options: TestOptions): Promise<number>;
|