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.
- package/README.md +65 -0
- package/dist/cli/cli.js +41 -6
- package/dist/cli/project.d.ts +19 -0
- package/dist/cli/project.js +18 -8
- 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 +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -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 +9 -4
- package/dist/events/router.js +24 -11
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -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/memory-backend.d.ts +15 -0
- package/dist/store/memory-backend.js +56 -0
- package/dist/store/store.js +4 -1
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -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/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/project.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/project.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
.
|
|
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
|
-
|
|
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>;
|