chapterjs 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +65 -0
  2. package/dist/cli/build.js +14 -1
  3. package/dist/cli/cli.js +41 -6
  4. package/dist/cli/dev.js +29 -1
  5. package/dist/cli/env.d.ts +5 -0
  6. package/dist/cli/env.js +39 -3
  7. package/dist/cli/project.d.ts +49 -7
  8. package/dist/cli/project.js +103 -19
  9. package/dist/cli/redis.d.ts +23 -0
  10. package/dist/cli/redis.js +57 -0
  11. package/dist/cli/start.js +43 -17
  12. package/dist/cli/test.d.ts +29 -0
  13. package/dist/cli/test.js +446 -0
  14. package/dist/commands/autocomplete.d.ts +1 -1
  15. package/dist/commands/autocomplete.js +37 -41
  16. package/dist/commands/router.d.ts +4 -1
  17. package/dist/commands/router.js +15 -17
  18. package/dist/components/router.d.ts +7 -2
  19. package/dist/components/router.js +16 -16
  20. package/dist/discord/types/channel.d.ts +9 -1
  21. package/dist/discord/types/guild.d.ts +5 -5
  22. package/dist/discord/types/permissions.d.ts +6 -0
  23. package/dist/discord/types/poll.d.ts +7 -1
  24. package/dist/events/registry.d.ts +252 -239
  25. package/dist/events/registry.js +125 -100
  26. package/dist/events/router.d.ts +9 -4
  27. package/dist/events/router.js +24 -11
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.js +1 -0
  30. package/dist/interactions/dispatch.d.ts +5 -1
  31. package/dist/interactions/dispatch.js +6 -2
  32. package/dist/loader/hot.js +22 -2
  33. package/dist/loader/loader.d.ts +7 -0
  34. package/dist/loader/loader.js +22 -12
  35. package/dist/ranking/declaration.d.ts +12 -0
  36. package/dist/ranking/declaration.js +48 -0
  37. package/dist/ranking/ranking.d.ts +102 -0
  38. package/dist/ranking/ranking.js +129 -0
  39. package/dist/redis/client.d.ts +36 -0
  40. package/dist/redis/client.js +252 -0
  41. package/dist/redis/protocol.d.ts +22 -0
  42. package/dist/redis/protocol.js +99 -0
  43. package/dist/redis/url.d.ts +24 -0
  44. package/dist/redis/url.js +50 -0
  45. package/dist/store/backend.d.ts +49 -4
  46. package/dist/store/backend.js +66 -11
  47. package/dist/store/declaration.d.ts +3 -0
  48. package/dist/store/declaration.js +8 -3
  49. package/dist/store/file-backend.d.ts +3 -1
  50. package/dist/store/file-backend.js +29 -0
  51. package/dist/store/ipc-backend.d.ts +24 -1
  52. package/dist/store/ipc-backend.js +49 -0
  53. package/dist/store/memory-backend.d.ts +17 -0
  54. package/dist/store/memory-backend.js +82 -0
  55. package/dist/store/ranking-backend.d.ts +45 -0
  56. package/dist/store/ranking-backend.js +82 -0
  57. package/dist/store/redis-backend.d.ts +38 -0
  58. package/dist/store/redis-backend.js +245 -0
  59. package/dist/store/redis-ranking.d.ts +15 -0
  60. package/dist/store/redis-ranking.js +69 -0
  61. package/dist/store/scope.d.ts +11 -0
  62. package/dist/store/scope.js +23 -0
  63. package/dist/store/store.d.ts +39 -5
  64. package/dist/store/store.js +83 -33
  65. package/dist/structures/invite.d.ts +6 -0
  66. package/dist/structures/invite.js +11 -0
  67. package/dist/tasks/scheduler.d.ts +6 -0
  68. package/dist/tasks/scheduler.js +9 -3
  69. package/dist/testing/failure.d.ts +9 -0
  70. package/dist/testing/failure.js +20 -0
  71. package/dist/testing/fake-discord.d.ts +59 -0
  72. package/dist/testing/fake-discord.js +120 -0
  73. package/dist/testing/fake-gateway.d.ts +59 -0
  74. package/dist/testing/fake-gateway.js +123 -0
  75. package/dist/testing/harness.d.ts +387 -0
  76. package/dist/testing/harness.js +1418 -0
  77. package/dist/testing/index.d.ts +4 -0
  78. package/dist/testing/index.js +5 -0
  79. package/dist/testing/scenario.d.ts +39 -0
  80. package/dist/testing/scenario.js +58 -0
  81. package/dist/testing/smoke.d.ts +20 -0
  82. package/dist/testing/smoke.js +659 -0
  83. package/dist/testing/websocket.d.ts +34 -0
  84. package/dist/testing/websocket.js +167 -0
  85. package/dist/testing/world.d.ts +145 -0
  86. package/dist/testing/world.js +1479 -0
  87. package/package.json +20 -3
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()`, `ranking()`, `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, or in Redis with `storage: 'redis'` and a `REDIS_URL`; `ranking()` keeps a score per member in order, for leaderboards.
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/build.js CHANGED
@@ -15,6 +15,7 @@ import { pathToFileURL } from 'node:url';
15
15
  import { enableProjectLoader } from '../loader/hot.js';
16
16
  import { listSources } from '../loader/loader.js';
17
17
  import { sourcesJoinVoice } from '../voice/usage.js';
18
+ import { MemoryStoreBackend } from '../store/memory-backend.js';
18
19
  import { createProject, hasSources, writeTypes } from './project.js';
19
20
  /** Where the build of a project is, from its folder. */
20
21
  export const buildDir = (cwd) => join(cwd, '.chapterjs', 'build');
@@ -105,7 +106,18 @@ export async function build(options) {
105
106
  };
106
107
  // 1. Every file must run, one by one: each says what is wrong with it.
107
108
  enableProjectLoader(src, { reload: false });
108
- const sources = createProject({ cwd, version: options.version, log });
109
+ // The files run once and keep nothing: a store, wherever it says it
110
+ // keeps its data, has nowhere to go (so a build needs no Redis).
111
+ const nothing = {
112
+ file: new MemoryStoreBackend(),
113
+ redis: new MemoryStoreBackend(),
114
+ };
115
+ const sources = createProject({
116
+ cwd,
117
+ version: options.version,
118
+ log,
119
+ stores: nothing,
120
+ });
109
121
  const failures = await sources.load();
110
122
  // 2. Types: what the editor underlines, for the whole project. Written
111
123
  // once the files ran, so a language only offers the commands they do
@@ -181,6 +193,7 @@ export async function build(options) {
181
193
  version: options.version,
182
194
  log,
183
195
  built: await loadBundle(cwd),
196
+ stores: nothing,
184
197
  });
185
198
  const broken = await built.load();
186
199
  if (broken.length > 0) {
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}`);
package/dist/cli/dev.js CHANGED
@@ -16,7 +16,9 @@ import { snapshotFolder, watchFolder } from '../loader/watch.js';
16
16
  import { RestClient } from '../rest/rest.js';
17
17
  import { readDevEnv } from './env.js';
18
18
  import { describeIntents, ensureInDevGuild, ensurePrivilegedIntents, fetchApplication, } from './preflight.js';
19
- import { createProject, explain, hasSources, writeTypes } from './project.js';
19
+ import { createProject, explain, hasSources, reportLooseRejections, writeTypes, } from './project.js';
20
+ import { checkRedis, describeRedisStores, redisStores } from './redis.js';
21
+ import { DEV_PREFIX } from '../store/redis-backend.js';
20
22
  import { checkForUpdate, describeUpdate } from './update-check.js';
21
23
  /** Runs until stopped. Resolves with the exit code of the process. */
22
24
  export async function dev(options) {
@@ -30,8 +32,16 @@ export async function dev(options) {
30
32
  }
31
33
  const { token, devGuildId } = read.env;
32
34
  const src = join(cwd, 'src');
35
+ // Every key written from here starts with `dev:`: a Redis shared with
36
+ // production, which the docs advise against, is never mixed up.
37
+ const redis = read.env.redis
38
+ ? redisStores(read.env.redis, log, { prefix: DEV_PREFIX })
39
+ : null;
33
40
  if (!(await hasSources(cwd, log)))
34
41
  return 1;
42
+ // A promise of the files left alone is an error of the file, not the end
43
+ // of the bot.
44
+ const loose = reportLooseRejections(cwd, log);
35
45
  // Asked now, said once the bot is online: the registry answers while the
36
46
  // files load and Discord is checked, so nobody waits for it.
37
47
  const update = checkForUpdate({
@@ -44,6 +54,7 @@ export async function dev(options) {
44
54
  cwd,
45
55
  version: options.version,
46
56
  log,
57
+ ...(redis ? { stores: { redis } } : {}),
47
58
  ...(options.deferAfter === undefined
48
59
  ? {}
49
60
  : { deferAfter: options.deferAfter }),
@@ -57,6 +68,12 @@ export async function dev(options) {
57
68
  // Now that the files ran, a language only offers the commands they do
58
69
  // not describe, and `t` is typed from the default language.
59
70
  await writeTypes(cwd, project.typesInfo());
71
+ // A store kept in Redis needs it now, not when someone uses the bot.
72
+ if (!(await checkRedis(project, redis, log))) {
73
+ await project.closeStores();
74
+ return 1;
75
+ }
76
+ let redisLine = describeRedisStores(project, redis);
60
77
  // 3. What Discord must agree with before connecting.
61
78
  const apiUrl = options.env.CHAPTERJS_API_URL;
62
79
  const rest = new RestClient({
@@ -154,6 +171,8 @@ export async function dev(options) {
154
171
  log.success(`Connected to ${guild.name} as ${self.username}`);
155
172
  log.info(`Intents computed from your files: ${describeIntents(connectedIntents)}`);
156
173
  (project.isEmpty() ? log.info : log.success)(project.summary());
174
+ if (redisLine)
175
+ log.info(redisLine);
157
176
  project.ready(bot);
158
177
  // Tasks follow the bot through reconnections: they read the current one.
159
178
  project.startTasks(() => bot);
@@ -215,6 +234,13 @@ export async function dev(options) {
215
234
  // offer it, or not.
216
235
  if ((await writeTypes(cwd, project.typesInfo())) > 0)
217
236
  log.reload('Types updated');
237
+ // A store moved to Redis, or out of it, is said once.
238
+ const kept = describeRedisStores(project, redis);
239
+ if (kept !== redisLine) {
240
+ redisLine = kept;
241
+ if (kept)
242
+ log.info(kept);
243
+ }
218
244
  const intents = project.intents(false);
219
245
  if ((intents & ~connectedIntents) !== 0 && bot) {
220
246
  // Intents are given when connecting: new ones need a new session.
@@ -256,6 +282,7 @@ export async function dev(options) {
256
282
  memory.stop();
257
283
  await bot?.close();
258
284
  await project.closeStores();
285
+ loose();
259
286
  if (code === 0)
260
287
  log.success('Disconnected');
261
288
  return code;
@@ -267,6 +294,7 @@ export async function dev(options) {
267
294
  memory.stop();
268
295
  await bot?.close();
269
296
  await project.closeStores();
297
+ loose();
270
298
  if (signal.aborted)
271
299
  return 0;
272
300
  explain(error, log);
package/dist/cli/env.d.ts CHANGED
@@ -1,12 +1,17 @@
1
+ import { type RedisAddress } from '../redis/url.js';
1
2
  export interface DevEnv {
2
3
  token: string;
3
4
  devGuildId: string;
5
+ /** Where the stores with `storage: 'redis'` go, when `REDIS_URL` is set. */
6
+ redis: RedisAddress | null;
4
7
  }
5
8
  /** What `chapterjs start` runs with. */
6
9
  export interface StartEnv {
7
10
  token: string;
8
11
  /** The dev server, when the project has one: production leaves it alone. */
9
12
  devGuildId: string | null;
13
+ /** Where the stores with `storage: 'redis'` go, when `REDIS_URL` is set. */
14
+ redis: RedisAddress | null;
10
15
  }
11
16
  /**
12
17
  * Reads what `chapterjs dev` needs from the `.env` file of the project (the
package/dist/cli/env.js CHANGED
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
4
  import { isSnowflake } from '../discord/snowflake.js';
5
+ import { parseRedisUrl } from '../redis/url.js';
5
6
  /**
6
7
  * Values people leave in a `.env` copied from an example. Whole values only:
7
8
  * a real token may start with any letters.
@@ -24,6 +25,27 @@ async function readEnv(projectDir, processEnv) {
24
25
  hasFile,
25
26
  };
26
27
  }
28
+ /**
29
+ * `REDIS_URL` and `REDIS_PASSWORD`, when they are there: nothing is needed
30
+ * unless a store asks for Redis, which the files say once they loaded.
31
+ */
32
+ function redisProblem(read, where) {
33
+ const url = read('REDIS_URL');
34
+ const password = read('REDIS_PASSWORD');
35
+ if (url === '') {
36
+ if (password !== '') {
37
+ return {
38
+ problem: `${where}: REDIS_PASSWORD is set, but REDIS_URL is not.\nAdd REDIS_URL, the address of your Redis (like redis://localhost:6379), or remove REDIS_PASSWORD.`,
39
+ };
40
+ }
41
+ return { redis: null };
42
+ }
43
+ const parsed = parseRedisUrl(url, password);
44
+ if ('problem' in parsed) {
45
+ return { problem: `${where}: REDIS_URL ${parsed.problem}` };
46
+ }
47
+ return { redis: parsed.address };
48
+ }
27
49
  const TOKEN_HELP = 'Copy the token of your bot from https://discord.com/developers/applications → your application → Bot → Reset Token.';
28
50
  /** What is wrong with a token, if anything. */
29
51
  function tokenProblem(token, where) {
@@ -61,7 +83,12 @@ export async function readDevEnv(projectDir, processEnv) {
61
83
  else if (!isSnowflake(devGuildId)) {
62
84
  problems.push(`.env: DEV_GUILD_ID is "${devGuildId}", which is not a server ID (an ID only has digits).\nIn Discord, right-click your test server → Copy Server ID.`);
63
85
  }
64
- return problems.length > 0 ? { problems } : { env: { token, devGuildId } };
86
+ const redis = redisProblem(read, '.env');
87
+ if ('problem' in redis)
88
+ problems.push(redis.problem);
89
+ return problems.length > 0 || 'problem' in redis
90
+ ? { problems }
91
+ : { env: { token, devGuildId, redis: redis.redis } };
65
92
  }
66
93
  /**
67
94
  * Reads what `chapterjs start` needs. In production the values often come
@@ -87,7 +114,16 @@ export async function readStartEnv(projectDir, processEnv) {
87
114
  if (devGuildId !== '' && !isSnowflake(devGuildId)) {
88
115
  problems.push(`DEV_GUILD_ID is "${devGuildId}", which is not a server ID (an ID only has digits).\nRemove it, or copy it again: in Discord, right-click your test server → Copy Server ID.`);
89
116
  }
90
- return problems.length > 0
117
+ const redis = redisProblem(read, hasFile ? '.env' : 'Environment');
118
+ if ('problem' in redis)
119
+ problems.push(redis.problem);
120
+ return problems.length > 0 || 'problem' in redis
91
121
  ? { problems }
92
- : { env: { token, devGuildId: devGuildId === '' ? null : devGuildId } };
122
+ : {
123
+ env: {
124
+ token,
125
+ devGuildId: devGuildId === '' ? null : devGuildId,
126
+ redis: redis.redis,
127
+ },
128
+ };
93
129
  }
@@ -1,7 +1,8 @@
1
1
  import { type CommandEntry } from '../commands/tree.js';
2
2
  import { type ComponentEntry } from '../components/router.js';
3
3
  import { type LoadedStore } from '../store/declaration.js';
4
- import { type StoreBackend } from '../store/backend.js';
4
+ import { type LoadedRanking } from '../ranking/declaration.js';
5
+ import { type StoreBackend, type StoreStorage } from '../store/backend.js';
5
6
  import { type LoadedPresence } from '../presence/declaration.js';
6
7
  import { type DeclaredCommand, type DeclaredLanguage } from '../messages/declaration.js';
7
8
  import { type LoadedMessages } from '../messages/translate.js';
@@ -9,6 +10,8 @@ import type { Locale } from '../discord/types/common.js';
9
10
  import { type TaskEntry } from '../tasks/scheduler.js';
10
11
  import { type Bot, type BotOptions } from '../core/bot.js';
11
12
  import { type GatewayIntentName } from '../discord/intents.js';
13
+ import type { GatewayDispatchEventName } from '../discord/types/gateway-events.js';
14
+ import type { RawInteraction } from '../discord/types/interaction.js';
12
15
  import { type LoadedEvent } from '../events/router.js';
13
16
  import { type BuiltFile, type Declaration, type FailedFile } from '../loader/loader.js';
14
17
  import type { Log } from './log.js';
@@ -29,13 +32,27 @@ export interface ProjectOptions {
29
32
  */
30
33
  joinsVoice?: boolean;
31
34
  /**
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
+ * Where the stores of the project keep their data, by storage. `file`: a
36
+ * file per store in the `data/` folder of the project by default, the
37
+ * first process of a cluster through IPC for the other processes.
38
+ * `redis`: what the CLI made of `REDIS_URL`; without one, a store that
39
+ * asks for Redis is reported and left out.
35
40
  */
36
- stores?: StoreBackend;
41
+ stores?: Partial<Record<StoreStorage, StoreBackend>>;
37
42
  /** Only tests change this. */
38
43
  deferAfter?: number;
44
+ /**
45
+ * Told what the bot is doing with the files, and when it is done with
46
+ * it: what `chapterjs test` waits for. Nothing of the bot waits.
47
+ */
48
+ observe?: ProjectObserver;
49
+ }
50
+ /** What `chapterjs test` watches of a running project. */
51
+ export interface ProjectObserver {
52
+ /** An interaction was given to the files; `done` once they answered it. */
53
+ interaction?(raw: RawInteraction, done: Promise<void>): void;
54
+ /** A gateway event was given to the files; `done` once they handled it. */
55
+ event?(event: GatewayDispatchEventName, data: unknown, done: Promise<void>): void;
39
56
  }
40
57
  /** What the types of a project are written from, once its files ran. */
41
58
  export interface TypesInfo {
@@ -63,6 +80,12 @@ export interface TypesInfo {
63
80
  export declare function writeTypes(cwd: string, info?: TypesInfo): Promise<number>;
64
81
  /** The kinds of declarations a project is made of: one per feature. */
65
82
  export declare const DECLARATIONS: readonly Declaration<unknown>[];
83
+ /** A ranking, with where it is declared. */
84
+ export interface RankingEntry {
85
+ file: string;
86
+ export: string;
87
+ ranking: LoadedRanking;
88
+ }
66
89
  /** A store, with where it is declared. */
67
90
  export interface StoreEntry {
68
91
  file: string;
@@ -97,8 +120,14 @@ export interface Project {
97
120
  readonly components: ReadonlyMap<string, ComponentEntry>;
98
121
  readonly tasks: ReadonlyMap<string, TaskEntry>;
99
122
  readonly stores: ReadonlyMap<string, StoreEntry>;
100
- /** Where the stores keep their data: what a cluster serves to its other processes. */
101
- readonly storeBackend: StoreBackend;
123
+ readonly rankings: ReadonlyMap<string, RankingEntry>;
124
+ /** Where the stores keep their data, by storage: what a cluster serves to its other processes. */
125
+ readonly storeBackends: ReadonlyMap<StoreStorage, StoreBackend>;
126
+ /** What is kept in Redis: the names of the stores and of the rankings, sorted. */
127
+ redisStores(): {
128
+ stores: string[];
129
+ rankings: string[];
130
+ };
102
131
  /** The presence declared, or `null` without one. */
103
132
  readonly presence: LoadedPresence | null;
104
133
  /** The languages of the project, assembled, or `null` without any. */
@@ -140,6 +169,11 @@ export interface Project {
140
169
  startTasks(current: () => Bot | null): void;
141
170
  /** Stops the tasks: nothing runs after this. */
142
171
  stopTasks(): void;
172
+ /**
173
+ * Runs one task now, as the scheduler would when its time comes, with
174
+ * the bot given. Resolves once it is done; an error is reported.
175
+ */
176
+ runTask(entry: TaskEntry, bot: Bot, now?: Date): Promise<void>;
143
177
  /** Looks after the memory of the bot `current` returns. */
144
178
  watchMemory(current: () => Bot | null): {
145
179
  stop(): void;
@@ -147,6 +181,14 @@ export interface Project {
147
181
  /** Writes what the stores still have to write. Nothing is kept after this. */
148
182
  closeStores(): Promise<void>;
149
183
  }
184
+ /**
185
+ * A promise of the files that nobody waited for (`store.set()` without
186
+ * `await`, in a `run` that is not `async`) rejects with nobody to tell:
187
+ * Node would stop the bot. Reported instead, with the file and the line,
188
+ * and what to write. A rejection from outside the files stays what it is:
189
+ * a bug of the framework, loud. Returns what stops watching.
190
+ */
191
+ export declare function reportLooseRejections(cwd: string, log: Log): () => void;
150
192
  /** Whether the folder is a project: it has a `src` folder. */
151
193
  export declare function hasSources(cwd: string, log: Log): Promise<boolean>;
152
194
  export declare function createProject(options: ProjectOptions): Project;