create-chapter 0.1.2 → 0.2.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 (35) hide show
  1. package/dist/choose.js +27 -0
  2. package/dist/index.js +84 -151
  3. package/dist/node-version.js +13 -0
  4. package/dist/package-manager.js +59 -0
  5. package/dist/project.js +82 -0
  6. package/dist/templates.js +31 -0
  7. package/package.json +21 -4
  8. package/templates/default/.env.example +4 -0
  9. package/{template → templates/default}/_gitignore +2 -1
  10. package/templates/default/package.json +23 -0
  11. package/templates/default/public/hello.ogg +0 -0
  12. package/templates/default/src/(moderation)/ban.ts +49 -0
  13. package/templates/default/src/(moderation)/checks.ts +19 -0
  14. package/templates/default/src/(moderation)/kick.ts +27 -0
  15. package/templates/default/src/(moderation)/messages.ts +81 -0
  16. package/templates/default/src/(moderation)/warn.ts +42 -0
  17. package/templates/default/src/commands/hello.ts +23 -0
  18. package/templates/default/src/commands/ping.ts +55 -0
  19. package/templates/default/src/commands/play.ts +41 -0
  20. package/templates/default/src/events/ready.ts +34 -0
  21. package/templates/default/src/events/wave.ts +14 -0
  22. package/templates/default/src/messages/en-US.ts +36 -0
  23. package/templates/default/src/messages/fr.ts +37 -0
  24. package/templates/default/src/presence.ts +8 -0
  25. package/templates/default/tsconfig.json +20 -0
  26. package/template/.env.example +0 -5
  27. package/template/package.json +0 -17
  28. package/template/src/components/buttons/sayHi.ts +0 -28
  29. package/template/src/events/interactionCreate.ts +0 -11
  30. package/template/src/events/messageCreate.ts +0 -25
  31. package/template/src/events/messageDelete.ts +0 -6
  32. package/template/src/events/messageReactionAdd.ts +0 -12
  33. package/template/src/events/ready.ts +0 -24
  34. package/template/src/presence.ts +0 -35
  35. package/template/tsconfig.json +0 -15
package/dist/choose.js ADDED
@@ -0,0 +1,27 @@
1
+ import * as p from '@clack/prompts';
2
+ /**
3
+ * A select menu that never asks a question with only one answer: when a single
4
+ * choice can be picked, it is taken right away and the user is told why.
5
+ * Returns `undefined` when no choice can be picked, and the cancel symbol when
6
+ * the user presses Ctrl+C or Esc.
7
+ */
8
+ export async function choose({ message, choices, initialValue, onlyChoice, }) {
9
+ const enabled = choices.filter(choice => !choice.disabled);
10
+ const [first] = enabled;
11
+ if (!first)
12
+ return undefined;
13
+ if (enabled.length === 1) {
14
+ p.log.info(onlyChoice(first.value));
15
+ return first.value;
16
+ }
17
+ // clack's option type can't be resolved for a generic `Value`, so the menu is
18
+ // typed with `string`: it only ever returns one of `choices`.
19
+ const picked = await p.select({
20
+ message,
21
+ initialValue: enabled.some(choice => choice.value === initialValue)
22
+ ? initialValue
23
+ : first.value,
24
+ options: choices,
25
+ });
26
+ return picked;
27
+ }
package/dist/index.js CHANGED
@@ -1,163 +1,96 @@
1
1
  #!/usr/bin/env node
2
2
  import * as p from '@clack/prompts';
3
- import { spawn } from 'node:child_process';
4
- import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
5
- import path from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- const TEMPLATE_DIR = fileURLToPath(new URL('../template', import.meta.url));
8
- const OWN_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
9
- const DEFAULT_NAME = 'my-chapter-app';
10
- const RENAME_FILES = {
11
- _gitignore: '.gitignore',
12
- };
13
- const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
14
- function exitIfCancelled(value) {
3
+ import { readFileSync } from 'node:fs';
4
+ import { relative, resolve } from 'node:path';
5
+ import { detectPackageManager, install, installedVersions, packageManagers, runScript, } from './package-manager.js';
6
+ import { choose } from './choose.js';
7
+ import { createProject, validateTargetDir } from './project.js';
8
+ import { listTemplates } from './templates.js';
9
+ import { nodeTooOld } from './node-version.js';
10
+ /** Stops cleanly when the user presses Ctrl+C or Esc on a prompt. */
11
+ function orExit(value) {
15
12
  if (p.isCancel(value)) {
16
- p.cancel('Operation cancelled.');
13
+ p.cancel('Cancelled, nothing was created.');
17
14
  process.exit(0);
18
15
  }
19
16
  return value;
20
17
  }
21
- function toValidPackageName(name) {
22
- return name
23
- .trim()
24
- .toLowerCase()
25
- .replace(/\s+/g, '-')
26
- .replace(/^[._]/, '')
27
- .replace(/[^a-z0-9-~]+/g, '-');
28
- }
29
- function isEmptyDir(dir) {
30
- if (!existsSync(dir))
31
- return true;
32
- // We tolerate .git to be able to do `git init` before `pnpm create chapter .`
33
- return readdirSync(dir).every(f => f === '.git');
18
+ // create-chapter and chapterjs are always released with the same version.
19
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
20
+ p.intro(`Create a ChapterJS bot (v${version})`);
21
+ const tooOld = nodeTooOld();
22
+ if (tooOld) {
23
+ p.cancel(tooOld);
24
+ process.exit(1);
34
25
  }
35
- function validateDir(value) {
36
- const dir = (value ?? '').trim() || DEFAULT_NAME;
37
- if (!isEmptyDir(path.resolve(dir))) {
38
- return dir === '.'
39
- ? 'The current directory is not empty'
40
- : `The directory "${dir}" already exists and is not empty`;
41
- }
42
- return undefined;
26
+ // Started now so it runs while the user answers the first question.
27
+ const versionsCheck = installedVersions();
28
+ // `pnpm create chapter my-bot` skips the first question when the folder is valid.
29
+ const argDir = process.argv[2];
30
+ const argError = argDir === undefined ? undefined : validateTargetDir(argDir);
31
+ if (argError)
32
+ p.log.warn(argError);
33
+ const dir = argDir !== undefined && argError === undefined
34
+ ? argDir.trim()
35
+ : orExit(await p.text({
36
+ message: 'Where should the project be created? (. for the current folder)',
37
+ placeholder: 'my-bot',
38
+ defaultValue: 'my-bot',
39
+ validate: value => validateTargetDir(value || 'my-bot'),
40
+ })).trim();
41
+ const versions = await versionsCheck;
42
+ const pm = orExit(await choose({
43
+ message: 'Which package manager do you want to use?',
44
+ initialValue: detectPackageManager(),
45
+ choices: packageManagers.map(value => {
46
+ const version = versions[value];
47
+ return version === undefined
48
+ ? { value, label: `${value} (not installed)`, disabled: true }
49
+ : { value, hint: `v${version}` };
50
+ }),
51
+ onlyChoice: value => `Using ${value}, the only package manager installed`,
52
+ }));
53
+ if (pm === undefined) {
54
+ p.cancel(`No package manager found. Install one of ${packageManagers.join(', ')}, then run this command again.`);
55
+ process.exit(1);
43
56
  }
44
- function detectPackageManager() {
45
- const agent = process.env.npm_config_user_agent ?? '';
46
- return PACKAGE_MANAGERS.find(pm => agent.startsWith(`${pm}/`));
57
+ const templates = await listTemplates();
58
+ const template = orExit(await choose({
59
+ message: 'Which template do you want to start from?',
60
+ choices: templates.map(({ name, description }) => ({
61
+ value: name,
62
+ hint: description,
63
+ })),
64
+ onlyChoice: value => `Using the ${value} template`,
65
+ }));
66
+ if (template === undefined) {
67
+ p.cancel('No template found. Reinstall create-chapter and try again.');
68
+ process.exit(1);
47
69
  }
48
- function install(pm, cwd) {
49
- const log = p.taskLog({
50
- title: `Installing dependencies with ${pm}`,
51
- limit: 8,
52
- });
53
- return new Promise((resolve, reject) => {
54
- const child = spawn(pm, ['install'], {
55
- cwd,
56
- stdio: ['ignore', 'pipe', 'pipe'],
57
- // Windows: pnpm/npm are .cmd
58
- shell: process.platform === 'win32',
59
- });
60
- // Ctrl+C during install: stop the child before exiting
61
- const onSigint = () => {
62
- child.kill('SIGINT');
63
- log.error('Installation cancelled.');
64
- process.exit(130);
65
- };
66
- process.once('SIGINT', onSigint);
67
- // raw: keep the chunks as-is (line breaks included)
68
- const onData = (chunk) => log.message(chunk.toString(), { raw: true });
69
- child.stdout.on('data', onData);
70
- child.stderr.on('data', onData);
71
- child.on('error', err => {
72
- process.off('SIGINT', onSigint);
73
- log.error(`Could not run ${pm}: ${err.message}`);
74
- reject(err);
75
- });
76
- child.on('close', code => {
77
- process.off('SIGINT', onSigint);
78
- if (code === 0) {
79
- log.success('Dependencies installed');
80
- resolve();
81
- }
82
- else {
83
- log.error(`${pm} install failed (code ${code})`, { showLog: true });
84
- reject(new Error(`${pm} install failed (code ${code})`));
85
- }
86
- });
87
- });
70
+ await createProject({
71
+ dir,
72
+ template,
73
+ chapterjsVersion: `^${version}`,
74
+ packageManager: pm,
75
+ });
76
+ p.log.success(`Project created in ${resolve(dir)}`);
77
+ const spinner = p.spinner();
78
+ spinner.start(`Installing dependencies with ${pm}`);
79
+ const result = await install(pm, dir);
80
+ if (result.ok) {
81
+ spinner.stop(`Dependencies installed with ${pm}`);
88
82
  }
89
- async function main() {
90
- const args = process.argv.slice(2);
91
- const noInstall = args.includes('--no-install');
92
- const argDir = args.find(arg => !arg.startsWith('--'));
93
- const interactive = p.isTTY(process.stdout) && !p.isCI();
94
- let projectDir;
95
- if (argDir && !validateDir(argDir)) {
96
- projectDir = argDir;
97
- }
98
- else {
99
- if (argDir)
100
- p.log.warn(validateDir(argDir));
101
- const answer = exitIfCancelled(await p.text({
102
- message: 'What is the name of your project? (. for the current directory)',
103
- placeholder: DEFAULT_NAME,
104
- defaultValue: DEFAULT_NAME,
105
- validate: validateDir,
106
- }));
107
- projectDir = answer.trim() || DEFAULT_NAME;
108
- }
109
- const targetDir = path.resolve(projectDir);
110
- const isCurrentDir = targetDir === process.cwd();
111
- // "." -> current directory
112
- const packageName = toValidPackageName(path.basename(targetDir));
113
- const s = p.spinner();
114
- s.start('Copying template');
115
- cpSync(TEMPLATE_DIR, targetDir, { recursive: true });
116
- for (const [from, to] of Object.entries(RENAME_FILES)) {
117
- const src = path.join(targetDir, from);
118
- if (existsSync(src))
119
- renameSync(src, path.join(targetDir, to));
120
- }
121
- const pkgPath = path.join(targetDir, 'package.json');
122
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
123
- pkg.name = packageName;
124
- // The template depends on `workspace:*` inside the monorepo; chapterjs and
125
- // create-chapter are released together, so pin the same version.
126
- pkg.dependencies.chapterjs = `^${OWN_VERSION}`;
127
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
128
- s.stop(`Project "${packageName}" created in ${targetDir}`);
129
- let pm = detectPackageManager();
130
- if (!pm && interactive) {
131
- pm = exitIfCancelled(await p.select({
132
- message: 'Which package manager do you want to use?',
133
- options: PACKAGE_MANAGERS.map(value => ({ value, label: value })),
134
- initialValue: 'pnpm',
135
- }));
136
- }
137
- pm ??= 'pnpm';
138
- let shouldInstall = !noInstall;
139
- if (shouldInstall && interactive) {
140
- shouldInstall = exitIfCancelled(await p.confirm({ message: `Install dependencies with ${pm}?` }));
141
- }
142
- let installed = false;
143
- if (shouldInstall) {
144
- try {
145
- await install(pm, targetDir);
146
- installed = true;
147
- }
148
- catch {
149
- // Error already shown by the task log
150
- }
151
- }
152
- const steps = [
153
- ...(isCurrentDir ? [] : [`cd ${projectDir}`]),
154
- ...(installed ? [] : [`${pm} install`]),
155
- `${pm === 'npm' ? 'npm run' : pm} dev`,
156
- ];
157
- p.note(steps.join('\n'), 'Next steps');
158
- p.outro('Happy dev!');
83
+ else {
84
+ spinner.error(`The dependencies could not be installed with ${pm}`);
85
+ if (result.output)
86
+ p.log.message(result.output);
159
87
  }
160
- main().catch(err => {
161
- p.log.error(err instanceof Error ? err.message : String(err));
162
- process.exit(1);
163
- });
88
+ const cd = relative(process.cwd(), resolve(dir));
89
+ const steps = [
90
+ ...(cd ? [`cd ${cd.includes(' ') ? `"${cd}"` : cd}`] : []),
91
+ ...(result.ok ? [] : [`${pm} install`]),
92
+ 'Copy .env.example to .env and fill in BOT_TOKEN and DEV_GUILD_ID',
93
+ runScript(pm, 'dev'),
94
+ ];
95
+ p.note(steps.join('\n'), 'Next steps');
96
+ p.outro('Happy building!');
@@ -0,0 +1,13 @@
1
+ /** The oldest Node.js a ChapterJS bot runs on, as `engines` says. */
2
+ export const MINIMUM_NODE = 24;
3
+ /**
4
+ * What to tell the person when this Node.js is older than ChapterJS needs,
5
+ * or `null` when it is recent enough: refused before creating anything,
6
+ * since the bot could not run.
7
+ */
8
+ export function nodeTooOld(version = process.versions.node) {
9
+ const major = Number(version.split('.')[0]);
10
+ if (major >= MINIMUM_NODE)
11
+ return null;
12
+ return `ChapterJS needs Node.js ${MINIMUM_NODE} or newer, and this is Node.js ${version}. Install the LTS version from https://nodejs.org, then run the command again.`;
13
+ }
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { tmpdir } from 'node:os';
3
+ /** In menu order: pnpm, the one ChapterJS itself uses, comes first. */
4
+ export const packageManagers = ['pnpm', 'npm', 'yarn', 'bun'];
5
+ /**
6
+ * The package manager that launched `create chapter`, read from the user agent
7
+ * it sets (e.g. `pnpm/11.0.0 npm/? node/v24.0.0 darwin arm64`).
8
+ */
9
+ export function detectPackageManager() {
10
+ const name = process.env.npm_config_user_agent?.split('/')[0];
11
+ return packageManagers.find(pm => pm === name) ?? 'pnpm';
12
+ }
13
+ /**
14
+ * The installed version of each package manager, or `undefined` when it isn't
15
+ * installed. All are checked at once, so this takes as long as the slowest one.
16
+ */
17
+ export async function installedVersions() {
18
+ const versions = await Promise.all(packageManagers.map(pm => new Promise(resolve => {
19
+ let output = '';
20
+ const child = spawn(pm, ['--version'], {
21
+ // Outside the current folder: a `packageManager` field in a parent
22
+ // package.json makes corepack refuse to run any other manager.
23
+ cwd: tmpdir(),
24
+ // Corepack must never stop to ask before downloading a manager.
25
+ env: { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' },
26
+ shell: process.platform === 'win32',
27
+ stdio: ['ignore', 'pipe', 'ignore'],
28
+ });
29
+ child.stdout.on('data', (chunk) => (output += chunk));
30
+ child.on('error', () => resolve(undefined));
31
+ child.on('close', code => resolve(code === 0 ? output.trim() || undefined : undefined));
32
+ })));
33
+ return Object.fromEntries(packageManagers.map((pm, i) => [pm, versions[i]]));
34
+ }
35
+ /** The command that runs a package.json script, e.g. `pnpm dev` or `npm run dev`. */
36
+ export function runScript(pm, script) {
37
+ return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
38
+ }
39
+ /** Runs `<pm> install` in `cwd`, capturing the output instead of printing it. */
40
+ export function install(pm, cwd) {
41
+ return new Promise(resolve => {
42
+ let output = '';
43
+ const child = spawn(pm, ['install'], {
44
+ cwd,
45
+ // Package managers are `.cmd` shims on Windows, which only a shell can run.
46
+ shell: process.platform === 'win32',
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ child.stdout.on('data', (chunk) => (output += chunk));
50
+ child.stderr.on('data', (chunk) => (output += chunk));
51
+ child.on('error', (error) => {
52
+ const reason = error.code === 'ENOENT'
53
+ ? `${pm} is not installed on this computer.`
54
+ : error.message;
55
+ resolve({ ok: false, output: reason });
56
+ });
57
+ child.on('close', code => resolve({ ok: code === 0, output: output.trim() }));
58
+ });
59
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { cp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { templatesDir } from './templates.js';
5
+ /** Files that don't make a folder "used": a project can still be created there. */
6
+ const ignoredFiles = new Set([
7
+ '.git',
8
+ '.DS_Store',
9
+ 'Thumbs.db',
10
+ '.idea',
11
+ '.vscode',
12
+ ]);
13
+ /** Returns why `input` can't hold a new project, or `undefined` when it can. */
14
+ export function validateTargetDir(input) {
15
+ const dir = input?.trim();
16
+ if (!dir)
17
+ return 'Type a folder name, or . to use the current folder.';
18
+ const path = resolve(dir);
19
+ if (!existsSync(path))
20
+ return undefined;
21
+ if (!statSync(path).isDirectory())
22
+ return `${dir} is a file, not a folder. Pick another name.`;
23
+ const used = readdirSync(path).some(file => !ignoredFiles.has(file));
24
+ if (used) {
25
+ return path === process.cwd()
26
+ ? 'The current folder is not empty. Type a name to create a new folder instead.'
27
+ : `The folder ${dir} is not empty. Pick another name.`;
28
+ }
29
+ return undefined;
30
+ }
31
+ /** A valid npm package name from a folder name, e.g. `Mon Bot Été` → `mon-bot-ete`. */
32
+ export function toPackageName(dir) {
33
+ const name = basename(resolve(dir))
34
+ // `é` → `e`: split letters from their accents, then drop the accents.
35
+ .normalize('NFD')
36
+ .replace(/\p{Diacritic}/gu, '')
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9-~._]+/g, '-')
39
+ .replace(/-{2,}/g, '-')
40
+ // npm names can't start with `.` or `_`, and 214 characters is the maximum.
41
+ .replace(/^[-._]+/, '')
42
+ .slice(0, 214)
43
+ .replace(/[-.]+$/, '');
44
+ return name || 'chapter-bot';
45
+ }
46
+ /**
47
+ * What pnpm needs to install a project whose framework comes with esbuild
48
+ * (which `chapterjs build` compiles the bot with). esbuild has an install
49
+ * script it does not need; pnpm refuses to go on until the project says whether that
50
+ * script may run. It may not: no script of a dependency runs.
51
+ * `allowBuilds` is read by pnpm 11, `ignoredBuiltDependencies` by pnpm 10.
52
+ */
53
+ export const PNPM_WORKSPACE = `# esbuild works without its install script: pnpm is told not to run it.
54
+ allowBuilds:
55
+ esbuild: false
56
+ ignoredBuiltDependencies:
57
+ - esbuild
58
+ `;
59
+ /** Copies the template into `dir` and names the project after its folder. */
60
+ export async function createProject({ dir, template, chapterjsVersion, packageManager, templatesRoot = templatesDir, }) {
61
+ const root = resolve(dir);
62
+ await cp(join(templatesRoot, template), root, { recursive: true });
63
+ // Where the files the bot sends go (`asset()`): git and npm keep no empty
64
+ // folder, so no template can ship it; every project gets it here.
65
+ await mkdir(join(root, 'public'), { recursive: true });
66
+ // npm strips `.gitignore` from published packages, so templates ship it as `_gitignore`.
67
+ const gitignore = join(root, '_gitignore');
68
+ if (existsSync(gitignore))
69
+ await rename(gitignore, join(root, '.gitignore'));
70
+ const pkgPath = join(root, 'package.json');
71
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
72
+ pkg.name = toPackageName(dir);
73
+ // The template's description is for the menu, not for the user's bot.
74
+ delete pkg.description;
75
+ pkg.dependencies = { ...pkg.dependencies, chapterjs: chapterjsVersion };
76
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
77
+ // Only for the package manager that reads it: nothing to explain to the
78
+ // others.
79
+ if (packageManager === 'pnpm') {
80
+ await writeFile(join(root, 'pnpm-workspace.yaml'), PNPM_WORKSPACE);
81
+ }
82
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
5
+ /** `templates/` ships next to `dist/` in the published package. */
6
+ export const templatesDir = fileURLToPath(new URL('../templates/', import.meta.url));
7
+ /**
8
+ * Every folder of `templates/` with a package.json is a template: adding one
9
+ * needs no code change.
10
+ */
11
+ export async function listTemplates(dir = templatesDir) {
12
+ const entries = await readdir(dir, { withFileTypes: true });
13
+ const templates = await Promise.all(entries
14
+ .filter(entry => entry.isDirectory())
15
+ .map(async (entry) => {
16
+ const pkgPath = join(dir, entry.name, 'package.json');
17
+ if (!existsSync(pkgPath))
18
+ return undefined;
19
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
20
+ const description = typeof pkg.description === 'string' ? pkg.description : undefined;
21
+ return { name: entry.name, description };
22
+ }));
23
+ // `default` first, so it is the preselected choice.
24
+ return templates
25
+ .filter(template => template !== undefined)
26
+ .sort((a, b) => a.name === 'default'
27
+ ? -1
28
+ : b.name === 'default'
29
+ ? 1
30
+ : a.name.localeCompare(b.name));
31
+ }
package/package.json CHANGED
@@ -1,24 +1,41 @@
1
1
  {
2
2
  "name": "create-chapter",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/chapterjs/chapterjs.git",
8
+ "directory": "packages/create-chapter"
9
+ },
10
+ "homepage": "https://chapterjs.dev",
11
+ "bugs": "https://github.com/chapterjs/chapterjs/issues",
5
12
  "type": "module",
6
13
  "bin": {
7
14
  "create-chapter": "./dist/index.js"
8
15
  },
9
16
  "files": [
10
17
  "dist",
11
- "template"
18
+ "templates"
12
19
  ],
13
20
  "devDependencies": {
21
+ "@chapterjs/test-utils": "0.0.0",
14
22
  "@types/node": "^24.0.0",
15
- "typescript": "^7.0.2"
23
+ "@vitest/coverage-v8": "^5.0.3",
24
+ "typescript": "^7.0.2",
25
+ "vitest": "^5.0.3"
16
26
  },
17
27
  "dependencies": {
18
28
  "@clack/prompts": "^1.8.1"
19
29
  },
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
20
33
  "scripts": {
21
34
  "build": "tsc",
22
- "dev": "tsc --watch"
35
+ "dev": "tsc --watch",
36
+ "check-types": "tsc --noEmit && tsc -p test",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "coverage": "vitest run --coverage"
23
40
  }
24
41
  }
@@ -0,0 +1,4 @@
1
+ # Your bot token: https://discord.com/developers/applications → your app → Bot → Reset Token
2
+ BOT_TOKEN=
3
+ # The ID of the server used by `chapterjs dev` (right-click the server → Copy Server ID)
4
+ DEV_GUILD_ID=
@@ -1,3 +1,4 @@
1
1
  node_modules
2
+ dist
2
3
  .env
3
- .DS_Store
4
+ .chapterjs
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "chapter-bot",
3
+ "description": "A minimal bot, ready to grow",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "chapterjs dev",
8
+ "start": "chapterjs start",
9
+ "build": "chapterjs build",
10
+ "typecheck": "tsc -b",
11
+ "postinstall": "chapterjs sync"
12
+ },
13
+ "dependencies": {
14
+ "chapterjs": "latest"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^24.0.0",
18
+ "typescript": "^7.0.2"
19
+ },
20
+ "engines": {
21
+ "node": ">=24"
22
+ }
23
+ }
@@ -0,0 +1,49 @@
1
+ import { button, command } from 'chapterjs';
2
+ import { canPunish } from './checks';
3
+
4
+ // A folder is only a folder: `(moderation)` groups what moderation needs,
5
+ // and the framework reads what these files export, wherever they are.
6
+ // /ban asks for a confirmation, then bans. The command and its button live
7
+ // in the same file: the button is named by its export (`confirmBan`), and
8
+ // `who: 'author'` keeps it for the person who used the command.
9
+ export default command({
10
+ name: 'ban',
11
+ permissions: ['BanMembers'],
12
+ ephemeral: true,
13
+ options: {
14
+ target: { type: 'user', required: true },
15
+ reason: { type: 'string' },
16
+ days: { type: 'integer', min: 0, max: 7 },
17
+ },
18
+ async run({ interaction, options, member, guild, t }) {
19
+ const target = guild.members.get(options.target.id);
20
+ if (target && !canPunish(member, target)) {
21
+ await interaction.reply(t('cannotPunish', { user: target.displayName }));
22
+ return;
23
+ }
24
+ await interaction.reply({
25
+ content: t('ban.confirm', { user: options.target.username }),
26
+ components: [
27
+ confirmBan({
28
+ userId: options.target.id,
29
+ days: options.days ?? 0,
30
+ reason: options.reason ?? '',
31
+ }),
32
+ ],
33
+ });
34
+ },
35
+ });
36
+
37
+ export const confirmBan = button({
38
+ label: ({ t }) => t('ban.yes'),
39
+ style: 'danger',
40
+ who: 'author',
41
+ data: { userId: 'string', days: 'number', reason: 'string' },
42
+ async run({ interaction, data, guild, user, t }) {
43
+ await guild.ban(data.userId, {
44
+ reason: data.reason || t('ban.defaultReason', { by: user.username }),
45
+ deleteMessageSeconds: data.days * 86_400,
46
+ });
47
+ await interaction.update({ content: t('ban.done'), components: [] });
48
+ },
49
+ });
@@ -0,0 +1,19 @@
1
+ import type { GuildMember } from 'chapterjs';
2
+
3
+ // Shared code: this file declares nothing, the commands import it. Nothing
4
+ // survives a restart: a real bot keeps its warnings in a database.
5
+ export function canPunish(by: GuildMember, target: GuildMember): boolean {
6
+ return by.highestRole.position > target.highestRole.position;
7
+ }
8
+
9
+ const warnings = new Map<string, number>();
10
+
11
+ export function addWarning(userId: string): number {
12
+ const count = (warnings.get(userId) ?? 0) + 1;
13
+ warnings.set(userId, count);
14
+ return count;
15
+ }
16
+
17
+ export function warningsOf(userId: string): number {
18
+ return warnings.get(userId) ?? 0;
19
+ }
@@ -0,0 +1,27 @@
1
+ import { command } from 'chapterjs';
2
+
3
+ // /kick: the texts come from the language files, like every command here.
4
+ import { canPunish } from './checks';
5
+
6
+ export default command({
7
+ name: 'kick',
8
+ permissions: ['KickMembers'],
9
+ ephemeral: true,
10
+ options: {
11
+ target: { type: 'user', required: true },
12
+ reason: { type: 'string' },
13
+ },
14
+ async run({ interaction, options, member, guild, t }) {
15
+ const target = guild.members.get(options.target.id);
16
+ if (!target) {
17
+ await interaction.reply(t('notHere', { user: options.target.username }));
18
+ return;
19
+ }
20
+ if (!canPunish(member, target)) {
21
+ await interaction.reply(t('cannotPunish', { user: target.displayName }));
22
+ return;
23
+ }
24
+ await target.kick(options.reason);
25
+ await interaction.reply(t('kick.done', { user: target.displayName }));
26
+ },
27
+ });
@@ -0,0 +1,81 @@
1
+ import { language } from 'chapterjs';
2
+
3
+ // A language may be declared in several places: these texts are added to the
4
+ // en-US and fr of src/messages/, next to the commands that use them. What
5
+ // says `default: true` there decides for the whole language.
6
+ export const en = language({
7
+ locale: 'en-US',
8
+ texts: {
9
+ cannotPunish: 'You can not act on {user}: their role is above yours.',
10
+ notHere: '{user} is not in this server.',
11
+ 'ban.confirm': 'Ban {user}?',
12
+ 'ban.yes': 'Ban',
13
+ 'ban.defaultReason': 'Banned by {by}',
14
+ 'ban.done': 'Banned.',
15
+ 'kick.done': '{user} was kicked.',
16
+ 'warn.added': '{user} now has {count} warning(s).',
17
+ // A plural: one form per quantity, picked by `count`.
18
+ 'warn.count': {
19
+ one: '{user} has {count} warning.',
20
+ other: '{user} has {count} warnings.',
21
+ },
22
+ },
23
+ // What Discord shows of these commands: their descriptions live here too.
24
+ commands: {
25
+ ban: {
26
+ description: 'Bans a member, after a confirmation',
27
+ options: {
28
+ target: { description: 'Who to ban' },
29
+ reason: { description: 'Why' },
30
+ days: { description: 'Days of messages to delete' },
31
+ },
32
+ },
33
+ kick: {
34
+ description: 'Kicks a member',
35
+ options: {
36
+ target: { description: 'Who to kick' },
37
+ reason: { description: 'Why' },
38
+ },
39
+ },
40
+ 'warn add': {
41
+ description: 'Warns a member',
42
+ options: {
43
+ target: { description: 'Who to warn' },
44
+ reason: { description: 'Why' },
45
+ },
46
+ },
47
+ 'warn list': {
48
+ description: 'Counts the warnings of a member',
49
+ options: { target: { description: 'Whose warnings' } },
50
+ },
51
+ },
52
+ });
53
+
54
+ export const fr = language({
55
+ locale: 'fr',
56
+ texts: {
57
+ cannotPunish:
58
+ 'Vous ne pouvez pas agir sur {user} : son rôle est au-dessus du vôtre.',
59
+ notHere: '{user} n’est pas sur ce serveur.',
60
+ 'ban.confirm': 'Bannir {user} ?',
61
+ 'ban.yes': 'Bannir',
62
+ 'ban.defaultReason': 'Banni par {by}',
63
+ 'ban.done': 'Banni.',
64
+ 'kick.done': '{user} a été expulsé.',
65
+ 'warn.added': '{user} a maintenant {count} avertissement(s).',
66
+ 'warn.count': {
67
+ one: '{user} a {count} avertissement.',
68
+ other: '{user} a {count} avertissements.',
69
+ },
70
+ },
71
+ commands: {
72
+ // A command can be renamed in a language too: /bannir for French users.
73
+ ban: {
74
+ name: 'bannir',
75
+ description: 'Bannit un membre, après confirmation',
76
+ },
77
+ kick: { name: 'expulser', description: 'Expulse un membre' },
78
+ 'warn add': { description: 'Avertit un membre' },
79
+ 'warn list': { description: 'Compte les avertissements d’un membre' },
80
+ },
81
+ });
@@ -0,0 +1,42 @@
1
+ import { command, event } from 'chapterjs';
2
+ import { addWarning, warningsOf } from './checks';
3
+
4
+ // Two subcommands in one file: /warn add and /warn list. A subcommand is a
5
+ // matter of `name`, not of folder.
6
+ export const add = command({
7
+ name: 'warn add',
8
+ permissions: ['ModerateMembers'],
9
+ options: {
10
+ target: { type: 'user', required: true },
11
+ reason: { type: 'string', required: true },
12
+ },
13
+ async run({ interaction, options, t }) {
14
+ const count = addWarning(options.target.id);
15
+ await interaction.reply(
16
+ t('warn.added', { user: options.target.username, count })
17
+ );
18
+ },
19
+ });
20
+
21
+ export const list = command({
22
+ name: 'warn list',
23
+ permissions: ['ModerateMembers'],
24
+ ephemeral: true,
25
+ options: { target: { type: 'user', required: true } },
26
+ async run({ interaction, options, t }) {
27
+ await interaction.reply(
28
+ t('warn.count', {
29
+ user: options.target.username,
30
+ count: warningsOf(options.target.id),
31
+ })
32
+ );
33
+ },
34
+ });
35
+
36
+ // The event that goes with moderation, next to the commands it follows.
37
+ export const logged = event({
38
+ name: 'banAdd',
39
+ run({ user, guild }) {
40
+ console.log(`${user.username} was banned from ${guild.name}`);
41
+ },
42
+ });
@@ -0,0 +1,23 @@
1
+ import { asset, command } from 'chapterjs';
2
+
3
+ // /hello joins your voice channel and plays public/hello.ogg. An Opus file
4
+ // (.ogg, .webm) plays as it is; an .mp3 or a link needs ffmpeg installed.
5
+ export default command({
6
+ name: 'hello',
7
+ async run({ interaction, member, t }) {
8
+ const channel = member.voiceChannel;
9
+ if (!channel) {
10
+ await interaction.reply({ content: t('joinVoice'), ephemeral: true });
11
+ return;
12
+ }
13
+ // Answer first: a command has 3 seconds, joining takes a moment.
14
+ await interaction.reply(t('hello', { channel: channel.name }));
15
+ const voice = await channel.join();
16
+ // Whatever happens while it plays, the bot leaves after.
17
+ try {
18
+ await voice.play(asset('hello.ogg'));
19
+ } finally {
20
+ await voice.leave();
21
+ }
22
+ },
23
+ });
@@ -0,0 +1,55 @@
1
+ import { button, command } from 'chapterjs';
2
+
3
+ // A command is declared with command() and exported: `name` is what people
4
+ // type after the /. Where the file is and what it is called are up to you:
5
+ // this one could live anywhere in src/.
6
+ export default command({
7
+ name: 'ping',
8
+ // Only the person sees the answer, so `t` speaks their language.
9
+ ephemeral: true,
10
+ async run({ interaction, t }) {
11
+ // The button below, with the data it carries, and a text of
12
+ // src/messages/ in the language of who will read the answer.
13
+ await interaction.reply({
14
+ content: t('pong'),
15
+ components: [again({ count: 1 })],
16
+ });
17
+ },
18
+ });
19
+
20
+ // A button is declared with button() and exported: the name of the export
21
+ // (here `again`) is what tells which one was clicked, you never write an
22
+ // id. `data` is what the button carries from one click to the next.
23
+ export const again = button({
24
+ label: 'Again',
25
+ data: { count: 'number' },
26
+ async run({ interaction, data, t }) {
27
+ const count = data.count + 1;
28
+ // Changes the message the button is on, instead of sending a new one.
29
+ await interaction.update({
30
+ content: t('again', { count }),
31
+ components: [again({ count })],
32
+ });
33
+ },
34
+ });
35
+
36
+ // A command can ask for options, typed for you in `run`, suggest values
37
+ // while the person types, and make each person wait between two uses:
38
+ //
39
+ // export const hello = command({
40
+ // name: 'hello',
41
+ // description: 'Says hello to someone',
42
+ // cooldown: '10s',
43
+ // options: {
44
+ // who: { type: 'user', description: 'Who to greet', required: true },
45
+ // greeting: { type: 'string', description: 'How to greet' },
46
+ // },
47
+ // autocomplete: {
48
+ // // Called while the person types in "greeting", with what they typed.
49
+ // greeting: ({ value }) =>
50
+ // ['Hello', 'Hi', 'Hey'].filter(word => word.startsWith(value)),
51
+ // },
52
+ // async run({ interaction, options }) {
53
+ // await interaction.reply(`${options.greeting ?? 'Hello'} ${options.who}!`);
54
+ // },
55
+ // });
@@ -0,0 +1,41 @@
1
+ import { command } from 'chapterjs';
2
+
3
+ // /play plays a song in your voice channel: a file you send with the
4
+ // command, or a link to an audio file or a stream. play() takes both.
5
+ export default command({
6
+ name: 'play',
7
+ options: {
8
+ file: { type: 'attachment' },
9
+ url: { type: 'string' },
10
+ },
11
+ async run({ interaction, member, options, t }) {
12
+ const { file, url } = options;
13
+ const song = file ?? url;
14
+ if (
15
+ !song ||
16
+ (file && !file.contentType?.startsWith('audio/')) ||
17
+ (url && !/^https?:\/\//.test(url))
18
+ ) {
19
+ await interaction.reply({ content: t('nothingToPlay'), ephemeral: true });
20
+ return;
21
+ }
22
+ const channel = member.voiceChannel;
23
+ if (!channel) {
24
+ await interaction.reply({ content: t('joinVoice'), ephemeral: true });
25
+ return;
26
+ }
27
+ const name = file?.filename ?? url ?? '';
28
+ await interaction.reply(
29
+ t('playing', { song: name, channel: channel.name })
30
+ );
31
+ // Joining the channel the bot is already in changes nothing, and a new
32
+ // play() replaces what was playing.
33
+ const voice = await channel.join();
34
+ // Whatever happens while it plays, the bot leaves after.
35
+ try {
36
+ await voice.play(song);
37
+ } finally {
38
+ await voice.leave();
39
+ }
40
+ },
41
+ });
@@ -0,0 +1,34 @@
1
+ import { event } from 'chapterjs';
2
+
3
+ // An event is declared with event() and exported: `name` is the event, and
4
+ // it types what `run` receives. This one runs once, when your bot is
5
+ // connected. A file may declare several events, commands or buttons.
6
+ export default event({
7
+ name: 'ready',
8
+ run({ user, guilds }) {
9
+ console.log(`${user.username} is online in ${guilds.size} server(s)`);
10
+ },
11
+ });
12
+
13
+ // Something to do by itself, every so often or at given times, is a task:
14
+ // the name of its export is the name of the task.
15
+ //
16
+ // import { task } from 'chapterjs';
17
+ //
18
+ // export const report = task({
19
+ // cron: '0 9 * * 1', // every Monday at 9:00
20
+ // async run({ guilds }) {
21
+ // console.log(`A new week in ${guilds.size} server(s)`);
22
+ // },
23
+ // });
24
+ //
25
+ // To react to something else, declare another event. For example, one that
26
+ // answers "Pong!" to "!ping" (reading messages needs the Message Content
27
+ // intent: `chapterjs dev` tells you how to enable it):
28
+ //
29
+ // export const pong = event({
30
+ // name: 'messageCreate',
31
+ // async run({ message }) {
32
+ // if (message.content === '!ping') await message.reply('Pong!');
33
+ // },
34
+ // });
@@ -0,0 +1,14 @@
1
+ import { event } from 'chapterjs';
2
+
3
+ // Runs every time someone adds a reaction to a message of your server.
4
+ // Reactions of bots, yours included, are left out: when yours waves back
5
+ // below, this does not run again. Other events work the same way:
6
+ // voiceJoin, pollVoteAdd, banAdd... (see the list of events in the docs).
7
+ export default event({
8
+ name: 'reactionAdd',
9
+ async run({ emoji, channel, messageId }) {
10
+ if (emoji.name !== '👋') return;
11
+ const message = await channel.fetchMessage(messageId);
12
+ await message.react('👋');
13
+ },
14
+ });
@@ -0,0 +1,36 @@
1
+ import { language } from 'chapterjs';
2
+
3
+ // A language is declared with language(): `locale` says which one. Each
4
+ // function of your project receives `t`, which picks the language of who
5
+ // will read the message: t('pong'), t('again', { count }).
6
+ export default language({
7
+ locale: 'en-US',
8
+ // The language used when the one of a person or of a server is not here.
9
+ default: true,
10
+ texts: {
11
+ pong: 'Pong! in en-US',
12
+ again: 'Pong in en-US ! ×{count}',
13
+ joinVoice: 'Join a voice channel first, then ask again.',
14
+ hello: 'Coming to {channel}!',
15
+ nothingToPlay:
16
+ 'Send an audio file (.mp3, .ogg...) or a link starting with https://.',
17
+ playing: 'Playing {song} in {channel}.',
18
+ },
19
+ // What Discord shows of each command, by its name: its description, and
20
+ // the ones of its options. A command described here has none in its file.
21
+ commands: {
22
+ ping: {
23
+ description: 'Replies with Pong!',
24
+ },
25
+ hello: {
26
+ description: 'Says hello in your voice channel',
27
+ },
28
+ play: {
29
+ description: 'Plays a song in your voice channel',
30
+ options: {
31
+ file: { description: 'A song to play' },
32
+ url: { description: 'Or the link of a song or a stream' },
33
+ },
34
+ },
35
+ },
36
+ });
@@ -0,0 +1,37 @@
1
+ import { language } from 'chapterjs';
2
+
3
+ // Every language has the texts of the default one, with the same
4
+ // {placeholders}: a missing one is reported when the file loads.
5
+ export default language({
6
+ locale: 'fr',
7
+ texts: {
8
+ pong: 'Pong! en fr',
9
+ again: 'Pong en fr ! ×{count}',
10
+ joinVoice: 'Rejoignez d’abord un salon vocal, puis redemandez.',
11
+ hello: 'J’arrive dans {channel} !',
12
+ nothingToPlay:
13
+ 'Envoyez un fichier audio (.mp3, .ogg...) ou un lien qui commence par https://.',
14
+ playing: 'Lecture de {song} dans {channel}.',
15
+ },
16
+ // What the bot answers by itself can be translated too, phrase by phrase:
17
+ //
18
+ // framework: {
19
+ // command: 'commande',
20
+ // failed: "Quelque chose s'est mal passé avec cette {what}.",
21
+ // },
22
+ commands: {
23
+ ping: {
24
+ description: 'Répond avec Pong !',
25
+ },
26
+ hello: {
27
+ description: 'Dit bonjour dans votre salon vocal',
28
+ },
29
+ play: {
30
+ description: 'Joue une musique dans votre salon vocal',
31
+ options: {
32
+ file: { description: 'Une musique à jouer' },
33
+ url: { description: 'Ou le lien d’une musique ou d’un flux' },
34
+ },
35
+ },
36
+ },
37
+ });
@@ -0,0 +1,8 @@
1
+ import { presence } from 'chapterjs';
2
+
3
+ // What your bot shows under its name: its status, and what it is doing.
4
+ // Save this file and chapterjs dev changes it at once.
5
+ export default presence({
6
+ status: 'online',
7
+ activity: { type: 'playing', name: 'with ChapterJS' },
8
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "Preserve",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "erasableSyntaxOnly": true,
8
+ "verbatimModuleSyntax": true,
9
+ "noEmit": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "types": ["node"]
13
+ },
14
+ "files": [],
15
+ "references": [
16
+ {
17
+ "path": "./.chapterjs"
18
+ }
19
+ ]
20
+ }
@@ -1,5 +0,0 @@
1
- # Required — Discord Developer Portal > your application > Bot > Token
2
- BOT_TOKEN=<YOUR_BOT_TOKEN>
3
-
4
- # Required — Discord > right-click your dev server > Copy Server ID
5
- DEV_GUILD_ID=<YOUR_DEV_GUILD_ID>
@@ -1,17 +0,0 @@
1
- {
2
- "name": "my-chapter-app",
3
- "version": "0.1.0",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "dev": "chapterjs dev",
8
- "build": "chapterjs build"
9
- },
10
- "dependencies": {
11
- "chapterjs": "workspace:*"
12
- },
13
- "devDependencies": {
14
- "@types/node": "^24.0.0",
15
- "typescript": "^7.0.2"
16
- }
17
- }
@@ -1,28 +0,0 @@
1
- import { defineButton } from 'chapterjs';
2
-
3
- // A button that handles its own clicks: they don't reach interactionCreate.
4
- // Its custom_id is generated from this file's path. Send it with
5
- // `components: [SayHi]`, or change it where it's used:
6
- // `new SayHi({ label: 'Hello', style: 'success' })`.
7
- export default defineButton({
8
- label: 'Say hi',
9
- emoji: '👋',
10
- async handler({ interaction, user }) {
11
- await interaction.reply({
12
- content: `Hi ${user}! Only you can see this message.`,
13
- ephemeral: true,
14
- });
15
- },
16
- });
17
-
18
- // Buttons can carry data, given back to the handler:
19
- //
20
- // export default defineButton<{ roleId: string }>({
21
- // label: 'Get the role',
22
- // async handler({ interaction, member, data }) {
23
- // await member?.addRole(data.roleId);
24
- // await interaction.reply({ content: 'Done!', ephemeral: true });
25
- // },
26
- // });
27
- //
28
- // message.reply({ components: [new GetRole({ data: { roleId: '…' } })] });
@@ -1,11 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- // Slash commands, and components whose custom_id you wrote yourself. Clicks
4
- // on components of src/components/ go to their own handler instead.
5
- // Answer within 3 seconds, or call `interaction.defer()` first and
6
- // `interaction.editReply()` later.
7
- export default defineEvent(async ({ interaction }) => {
8
- if (interaction.commandName === 'ping') {
9
- await interaction.reply({ content: 'Pong!', ephemeral: true });
10
- }
11
- });
@@ -1,25 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
- import SayHi from '../components/buttons/sayHi.ts';
3
-
4
- // Mention the bot: it reacts and replies with a button (src/components/buttons/sayHi.ts).
5
- // Messages from bots are ignored, unless you add allowBots: true.
6
- export default defineEvent(async ({ message, client }) => {
7
- if (!message.mentions.some(user => user.id === client.user?.id)) return;
8
-
9
- await message.react('👋');
10
- await message.reply({
11
- content: `Hi ${message.author.displayName}!`,
12
- mention: false,
13
- components: [SayHi],
14
- });
15
- });
16
-
17
- // Without the privileged MessageContent intent, `message.content` is empty
18
- // except in DMs and messages mentioning the bot. To read every message:
19
- //
20
- // export default defineEvent({
21
- // intents: ['MessageContent'],
22
- // async handler({ message }) {
23
- // if (message.content === '!ping') await message.reply('Pong!');
24
- // },
25
- // });
@@ -1,6 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- // Discord only sends ids: the deleted message's content is gone.
4
- export default defineEvent(({ messageId, channel }) => {
5
- console.log(`Message ${messageId} deleted in ${channel}`);
6
- });
@@ -1,12 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- export default defineEvent(async ({ reaction, client }) => {
4
- if (reaction.userId === client.user?.id) return;
5
- const name = reaction.member?.displayName ?? reaction.userId;
6
- console.log(`${name} reacted ${reaction} in ${reaction.channel}`);
7
-
8
- // Reaction roles: give a role when reacting on a given message
9
- // if (reaction.messageId === ROLES_MESSAGE_ID && reaction.emoji.name === '🎮') {
10
- // await reaction.member?.addRole(GAMER_ROLE_ID);
11
- // }
12
- });
@@ -1,24 +0,0 @@
1
- import { defineEvent } from 'chapterjs';
2
-
3
- // Each file in src/events/ handles the Discord event it is named after
4
- // (messageCreate.ts, guildMemberAdd.ts…), reloaded on save. Several handlers
5
- // for one event go in a folder: src/events/messageCreate/*.ts. Files and
6
- // folders starting with _ are ignored, for shared code.
7
- // The gateway intents each event needs are requested automatically.
8
- //
9
- // Handlers receive what the event is about: { user, guilds } here,
10
- // { message, channel } in messageCreate, { interaction } in
11
- // interactionCreate… plus `client` and the raw `data` sent by Discord.
12
- export default defineEvent(({ user, guilds }) => {
13
- console.log(`${user.displayName} is ready in ${guilds.length} server(s)`);
14
- });
15
-
16
- // Some events need a privileged intent, enabled in the Developer Portal
17
- // (Bot > Privileged Gateway Intents), otherwise the bot can't connect:
18
- //
19
- // src/events/guildMemberAdd.ts — GuildMembers intent:
20
- //
21
- // export default defineEvent(async ({ member, client }) => {
22
- // await member.addRole(MEMBER_ROLE_ID, 'New member');
23
- // await client.channel(WELCOME_CHANNEL_ID).send(`Welcome ${member}! 👋`);
24
- // });
@@ -1,35 +0,0 @@
1
- import { definePresence } from 'chapterjs';
2
-
3
- // Status and activity shown on the bot's profile, reloaded on save.
4
- export default definePresence({
5
- status: 'online', // 'online' | 'idle' | 'dnd' | 'invisible'
6
- // 'playing' | 'listening' | 'watching' | 'competing' | 'streaming' (+ url),
7
- // or a plain string for a custom status: activity: 'Coding 🚀'
8
- activity: { type: 'playing', name: 'with ChapterJS' },
9
- });
10
-
11
- // Rotation, each step shown for its own duration (minimum 5s):
12
- //
13
- // export default definePresence([
14
- // { duration: 5_000, status: 'online', activity: 'Hello everyone 👋' },
15
- // { duration: 10_000, status: 'dnd', activity: { type: 'watching', name: 'the server' } },
16
- // ]);
17
-
18
- // Dynamic presence, e.g. a rotation stored in a database. It is reloaded
19
- // after each full rotation, or right away when `watch` calls `refresh`
20
- // (you can also call `refreshPresence()` from anywhere in your bot):
21
- //
22
- // export default definePresence({
23
- // async presence({ client }) {
24
- // const rows = await db.presence.findMany();
25
- // return rows.map((row) => ({
26
- // duration: row.duration,
27
- // status: row.status, // 'online' | 'idle' | 'dnd' | 'invisible'
28
- // activity: row.message,
29
- // }));
30
- // },
31
- // watch(refresh) {
32
- // const unsubscribe = db.onPresenceChange(refresh);
33
- // return unsubscribe;
34
- // },
35
- // });
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "strict": true,
7
- "outDir": "dist",
8
- "rootDir": "src",
9
- "skipLibCheck": true,
10
- "rewriteRelativeImportExtensions": true,
11
- "erasableSyntaxOnly": true,
12
- "types": ["node"]
13
- },
14
- "include": ["src"]
15
- }