chapterjs 0.2.3 → 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 (47) hide show
  1. package/README.md +65 -0
  2. package/dist/cli/cli.js +41 -6
  3. package/dist/cli/project.d.ts +19 -0
  4. package/dist/cli/project.js +18 -8
  5. package/dist/cli/test.d.ts +29 -0
  6. package/dist/cli/test.js +444 -0
  7. package/dist/commands/autocomplete.d.ts +1 -1
  8. package/dist/commands/autocomplete.js +37 -41
  9. package/dist/commands/router.d.ts +4 -1
  10. package/dist/commands/router.js +15 -17
  11. package/dist/components/router.d.ts +7 -2
  12. package/dist/components/router.js +16 -16
  13. package/dist/discord/types/channel.d.ts +9 -1
  14. package/dist/discord/types/guild.d.ts +5 -5
  15. package/dist/discord/types/permissions.d.ts +6 -0
  16. package/dist/discord/types/poll.d.ts +7 -1
  17. package/dist/events/router.d.ts +9 -4
  18. package/dist/events/router.js +24 -11
  19. package/dist/interactions/dispatch.d.ts +5 -1
  20. package/dist/interactions/dispatch.js +6 -2
  21. package/dist/loader/hot.js +22 -2
  22. package/dist/loader/loader.d.ts +7 -0
  23. package/dist/loader/loader.js +22 -12
  24. package/dist/store/memory-backend.d.ts +15 -0
  25. package/dist/store/memory-backend.js +56 -0
  26. package/dist/store/store.js +4 -1
  27. package/dist/tasks/scheduler.d.ts +6 -0
  28. package/dist/tasks/scheduler.js +9 -3
  29. package/dist/testing/failure.d.ts +9 -0
  30. package/dist/testing/failure.js +20 -0
  31. package/dist/testing/fake-discord.d.ts +59 -0
  32. package/dist/testing/fake-discord.js +120 -0
  33. package/dist/testing/fake-gateway.d.ts +59 -0
  34. package/dist/testing/fake-gateway.js +123 -0
  35. package/dist/testing/harness.d.ts +387 -0
  36. package/dist/testing/harness.js +1418 -0
  37. package/dist/testing/index.d.ts +4 -0
  38. package/dist/testing/index.js +5 -0
  39. package/dist/testing/scenario.d.ts +39 -0
  40. package/dist/testing/scenario.js +58 -0
  41. package/dist/testing/smoke.d.ts +20 -0
  42. package/dist/testing/smoke.js +659 -0
  43. package/dist/testing/websocket.d.ts +34 -0
  44. package/dist/testing/websocket.js +167 -0
  45. package/dist/testing/world.d.ts +145 -0
  46. package/dist/testing/world.js +1479 -0
  47. 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()`, `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}`);
@@ -9,6 +9,8 @@ import type { Locale } from '../discord/types/common.js';
9
9
  import { type TaskEntry } from '../tasks/scheduler.js';
10
10
  import { type Bot, type BotOptions } from '../core/bot.js';
11
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';
12
14
  import { type LoadedEvent } from '../events/router.js';
13
15
  import { type BuiltFile, type Declaration, type FailedFile } from '../loader/loader.js';
14
16
  import type { Log } from './log.js';
@@ -36,6 +38,18 @@ export interface ProjectOptions {
36
38
  stores?: StoreBackend;
37
39
  /** Only tests change this. */
38
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;
39
53
  }
40
54
  /** What the types of a project are written from, once its files ran. */
41
55
  export interface TypesInfo {
@@ -140,6 +154,11 @@ export interface Project {
140
154
  startTasks(current: () => Bot | null): void;
141
155
  /** Stops the tasks: nothing runs after this. */
142
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>;
143
162
  /** Looks after the memory of the bot `current` returns. */
144
163
  watchMemory(current: () => Bot | null): {
145
164
  stop(): void;
@@ -22,7 +22,7 @@ import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.
22
22
  import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
23
23
  import { translation } from '../messages/translate.js';
24
24
  import { translateCommand, unknownCommands } from '../messages/commands.js';
25
- import { TimerScheduler, } from '../tasks/scheduler.js';
25
+ import { runTask, TimerScheduler, } from '../tasks/scheduler.js';
26
26
  import { createBot } from '../core/bot.js';
27
27
  import { watchMemory } from '../core/memory.js';
28
28
  import { GatewayIntent } from '../discord/intents.js';
@@ -76,7 +76,7 @@ export async function hasSources(cwd, log) {
76
76
  if ((await stat(join(cwd, 'src')).catch(() => null))?.isDirectory()) {
77
77
  return true;
78
78
  }
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 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".');
80
80
  return false;
81
81
  }
82
82
  /** The intents that only bring what happens in private messages. */
@@ -419,9 +419,12 @@ export function createProject(options) {
419
419
  stores.size === 0 &&
420
420
  presence === null &&
421
421
  messages === null,
422
- filesNeeding: intent => [...events.values()]
423
- .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
424
- .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
+ ],
425
428
  intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
426
429
  async connect(connection) {
427
430
  const created = createBot({
@@ -449,14 +452,17 @@ export function createProject(options) {
449
452
  },
450
453
  beforeDispatch: (event, data) => router.before(created.ctx, event, data),
451
454
  onDispatch(event, data, info) {
452
- router.dispatch(created.ctx, event, data, {
455
+ const handled = router.dispatch(created.ctx, event, data, {
453
456
  joined: info.joined,
454
457
  before: info.before,
455
458
  });
459
+ options.observe?.event?.(event, data, handled);
456
460
  if (event === 'INTERACTION_CREATE') {
457
461
  const interaction = data;
458
- commandRouter.dispatch(created.ctx, interaction);
459
- 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);
460
466
  }
461
467
  },
462
468
  onEvent(event) {
@@ -518,6 +524,10 @@ export function createProject(options) {
518
524
  });
519
525
  },
520
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
+ },
521
531
  // The bot looks after its own memory: it remembers less before memory
522
532
  // is full, and only speaks up when it can't give up anything more.
523
533
  closeStores: () => storeBackend.close(),
@@ -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>;