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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/cli/cli.js +41 -6
  4. package/dist/cli/cluster.d.ts +3 -0
  5. package/dist/cli/cluster.js +4 -1
  6. package/dist/cli/dev.js +2 -0
  7. package/dist/cli/project.d.ts +38 -0
  8. package/dist/cli/project.js +51 -10
  9. package/dist/cli/start.js +18 -0
  10. package/dist/cli/test.d.ts +29 -0
  11. package/dist/cli/test.js +444 -0
  12. package/dist/commands/autocomplete.d.ts +1 -1
  13. package/dist/commands/autocomplete.js +37 -41
  14. package/dist/commands/router.d.ts +4 -1
  15. package/dist/commands/router.js +16 -17
  16. package/dist/components/router.d.ts +7 -2
  17. package/dist/components/router.js +17 -16
  18. package/dist/discord/types/channel.d.ts +9 -1
  19. package/dist/discord/types/guild.d.ts +5 -5
  20. package/dist/discord/types/permissions.d.ts +6 -0
  21. package/dist/discord/types/poll.d.ts +7 -1
  22. package/dist/events/router.d.ts +12 -4
  23. package/dist/events/router.js +29 -12
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +1 -0
  26. package/dist/interactions/dispatch.d.ts +7 -1
  27. package/dist/interactions/dispatch.js +7 -2
  28. package/dist/loader/hot.js +22 -2
  29. package/dist/loader/loader.d.ts +7 -0
  30. package/dist/loader/loader.js +22 -12
  31. package/dist/store/backend.d.ts +33 -0
  32. package/dist/store/backend.js +27 -0
  33. package/dist/store/declaration.d.ts +15 -0
  34. package/dist/store/declaration.js +53 -0
  35. package/dist/store/file-backend.d.ts +22 -0
  36. package/dist/store/file-backend.js +172 -0
  37. package/dist/store/ipc-backend.d.ts +53 -0
  38. package/dist/store/ipc-backend.js +87 -0
  39. package/dist/store/memory-backend.d.ts +15 -0
  40. package/dist/store/memory-backend.js +56 -0
  41. package/dist/store/scope.d.ts +8 -0
  42. package/dist/store/scope.js +20 -0
  43. package/dist/store/store.d.ts +115 -0
  44. package/dist/store/store.js +203 -0
  45. package/dist/tasks/scheduler.d.ts +6 -0
  46. package/dist/tasks/scheduler.js +10 -3
  47. package/dist/testing/failure.d.ts +9 -0
  48. package/dist/testing/failure.js +20 -0
  49. package/dist/testing/fake-discord.d.ts +59 -0
  50. package/dist/testing/fake-discord.js +120 -0
  51. package/dist/testing/fake-gateway.d.ts +59 -0
  52. package/dist/testing/fake-gateway.js +123 -0
  53. package/dist/testing/harness.d.ts +387 -0
  54. package/dist/testing/harness.js +1418 -0
  55. package/dist/testing/index.d.ts +4 -0
  56. package/dist/testing/index.js +5 -0
  57. package/dist/testing/scenario.d.ts +39 -0
  58. package/dist/testing/scenario.js +58 -0
  59. package/dist/testing/smoke.d.ts +20 -0
  60. package/dist/testing/smoke.js +659 -0
  61. package/dist/testing/websocket.d.ts +34 -0
  62. package/dist/testing/websocket.js +167 -0
  63. package/dist/testing/world.d.ts +145 -0
  64. package/dist/testing/world.js +1479 -0
  65. 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 does not wait.
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
- if (controller.signal.aborted)
39
- process.exit(1);
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
- process.exit();
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
- process.exit();
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
- process.exit();
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}`);
@@ -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;
@@ -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
- if (message.type === 'identify') {
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);
@@ -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>;
@@ -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 chapter".');
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
- ...[...conflicts, ...unique.conflicts, ...sameTask.conflicts].map(({ file, export: exported, message }) => ({
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
- filesNeeding: intent => [...events.values()]
393
- .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
394
- .map(({ file }) => file),
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
- componentRouter.dispatch(created.ctx, interaction);
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>;