create-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.
- package/README.md +37 -0
- package/dist/choose.js +27 -0
- package/dist/index.js +98 -4
- package/dist/node-version.js +13 -0
- package/dist/package-manager.js +59 -0
- package/dist/project.js +82 -0
- package/dist/templates.js +31 -0
- package/package.json +19 -5
- package/templates/community/.env.example +7 -0
- package/templates/community/_gitignore +5 -0
- package/templates/community/package.json +24 -0
- package/templates/community/src/community.test.ts +48 -0
- package/templates/community/src/messages.ts +49 -0
- package/templates/community/src/presence.ts +6 -0
- package/templates/community/src/roles/roles.ts +60 -0
- package/templates/community/src/stats/weekly.ts +50 -0
- package/templates/community/src/welcome/channel.ts +19 -0
- package/templates/community/src/welcome/welcome.ts +35 -0
- package/templates/community/tsconfig.json +20 -0
- package/templates/default/.env.example +7 -0
- package/templates/default/_gitignore +5 -0
- package/templates/default/package.json +24 -0
- package/templates/default/src/events/ready.ts +28 -0
- package/templates/default/tsconfig.json +20 -0
- package/templates/moderation/.env.example +7 -0
- package/templates/moderation/_gitignore +5 -0
- package/templates/moderation/package.json +24 -0
- package/templates/moderation/src/moderation/channel.ts +67 -0
- package/templates/moderation/src/moderation/log.ts +37 -0
- package/templates/moderation/src/moderation/messages.ts +158 -0
- package/templates/moderation/src/moderation/moderation.test.ts +76 -0
- package/templates/moderation/src/moderation/purge.ts +30 -0
- package/templates/moderation/src/moderation/timeout.ts +82 -0
- package/templates/moderation/src/moderation/warn.ts +61 -0
- package/templates/moderation/src/presence.ts +6 -0
- package/templates/moderation/tsconfig.json +20 -0
- package/templates/music/.env.example +7 -0
- package/templates/music/_gitignore +5 -0
- package/templates/music/package.json +24 -0
- package/templates/music/src/music/leave.ts +18 -0
- package/templates/music/src/music/messages.ts +83 -0
- package/templates/music/src/music/music.test.ts +29 -0
- package/templates/music/src/music/play.ts +120 -0
- package/templates/music/src/music/queue-command.ts +28 -0
- package/templates/music/src/music/queue.ts +61 -0
- package/templates/music/src/presence.ts +6 -0
- package/templates/music/tsconfig.json +20 -0
- package/templates/tickets/.env.example +7 -0
- package/templates/tickets/_gitignore +5 -0
- package/templates/tickets/package.json +24 -0
- package/templates/tickets/src/presence.ts +6 -0
- package/templates/tickets/src/tickets/close.ts +39 -0
- package/templates/tickets/src/tickets/messages.ts +68 -0
- package/templates/tickets/src/tickets/open.ts +81 -0
- package/templates/tickets/src/tickets/panel.ts +35 -0
- package/templates/tickets/src/tickets/tickets.test.ts +72 -0
- package/templates/tickets/tsconfig.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# create-chapterjs
|
|
2
|
+
|
|
3
|
+
Creates a [ChapterJS](https://www.chapterjs.org) Discord bot: a project where every file of `src/` is a command, an event, a button, a task… and where the `chapterjs` CLI does everything else.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm create chapterjs my-bot
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm create chapterjs my-bot
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
yarn create chapterjs my-bot
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun create chapterjs my-bot
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`pnpm create chapter` works too.
|
|
22
|
+
|
|
23
|
+
## What it asks
|
|
24
|
+
|
|
25
|
+
- **The folder**, when it is not given: `.` is the current folder, which must be new or empty.
|
|
26
|
+
- **The package manager**, among those installed on your machine: the one you ran the command with is preselected.
|
|
27
|
+
- **A template**: `default` is one file, the `ready` event, and the [tutorial](https://www.chapterjs.org/tutorial) starts from it. The others are complete bots to start from or to read: `tickets` (support tickets in private threads), `music` (a player with a queue and buttons), `community` (welcome cards, roles people pick, weekly stats) and `moderation` (timeouts, purge, slowmode, lock, and a log of every action).
|
|
28
|
+
|
|
29
|
+
A question with only one possible answer is not asked: its answer is shown with `ℹ`. Node 24 or later is needed, and the command says so at once when it is older.
|
|
30
|
+
|
|
31
|
+
Then it copies the template, installs its dependencies, and tells you what to do next: fill `.env` with your bot token and your test server, and run `chapterjs dev`. Every template but `default` comes with a scenario file that `chapterjs test` runs, to start your own from. The project depends on the exact version of `chapterjs` this command had: `pnpm create chapterjs@1.0.0` gives a project on `chapterjs@1.0.0`.
|
|
32
|
+
|
|
33
|
+
📖 [Quickstart](https://www.chapterjs.org/quickstart) · [Project structure and templates](https://www.chapterjs.org/setup/project-structure) · [Source and issues](https://github.com/chapterjs/chapterjs)
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
[MIT](./LICENSE)
|
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,5 +1,99 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
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) {
|
|
12
|
+
if (p.isCancel(value)) {
|
|
13
|
+
p.cancel('Cancelled, nothing was created.');
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
// create-chapterjs 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);
|
|
25
|
+
}
|
|
26
|
+
// Started now so it runs while the user answers the first question.
|
|
27
|
+
const versionsCheck = installedVersions();
|
|
28
|
+
// `pnpm create chapterjs 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);
|
|
56
|
+
}
|
|
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-chapterjs and try again.');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
// The exact version, not a range: `create chapterjs@X` makes a project on
|
|
71
|
+
// `chapterjs@X`, and the project only changes version when its developer
|
|
72
|
+
// runs the command `chapterjs dev` gives them.
|
|
73
|
+
await createProject({
|
|
74
|
+
dir,
|
|
75
|
+
template,
|
|
76
|
+
chapterjsVersion: version,
|
|
77
|
+
packageManager: pm,
|
|
78
|
+
});
|
|
79
|
+
p.log.success(`Project created in ${resolve(dir)}`);
|
|
80
|
+
const spinner = p.spinner();
|
|
81
|
+
spinner.start(`Installing dependencies with ${pm}`);
|
|
82
|
+
const result = await install(pm, dir);
|
|
83
|
+
if (result.ok) {
|
|
84
|
+
spinner.stop(`Dependencies installed with ${pm}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
spinner.error(`The dependencies could not be installed with ${pm}`);
|
|
88
|
+
if (result.output)
|
|
89
|
+
p.log.message(result.output);
|
|
90
|
+
}
|
|
91
|
+
const cd = relative(process.cwd(), resolve(dir));
|
|
92
|
+
const steps = [
|
|
93
|
+
...(cd ? [`cd ${cd.includes(' ') ? `"${cd}"` : cd}`] : []),
|
|
94
|
+
...(result.ok ? [] : [`${pm} install`]),
|
|
95
|
+
'Copy .env.example to .env and fill in BOT_TOKEN and DEV_GUILD_ID',
|
|
96
|
+
runScript(pm, 'dev'),
|
|
97
|
+
];
|
|
98
|
+
p.note(steps.join('\n'), 'Next steps');
|
|
99
|
+
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 chapterjs`, 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
|
+
}
|
package/dist/project.js
ADDED
|
@@ -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,21 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-chapterjs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Creates a ChapterJS Discord bot: pnpm create chapterjs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"discord",
|
|
7
|
+
"discord-bot",
|
|
8
|
+
"bot",
|
|
9
|
+
"framework",
|
|
10
|
+
"typescript",
|
|
11
|
+
"slash-commands",
|
|
12
|
+
"esm",
|
|
13
|
+
"create",
|
|
14
|
+
"scaffold",
|
|
15
|
+
"template",
|
|
16
|
+
"chapterjs"
|
|
17
|
+
],
|
|
5
18
|
"license": "MIT",
|
|
6
19
|
"repository": {
|
|
7
20
|
"type": "git",
|
|
8
21
|
"url": "git+https://github.com/chapterjs/chapterjs.git",
|
|
9
22
|
"directory": "packages/create-chapterjs"
|
|
10
23
|
},
|
|
11
|
-
"homepage": "https://chapterjs.
|
|
24
|
+
"homepage": "https://www.chapterjs.org",
|
|
12
25
|
"bugs": "https://github.com/chapterjs/chapterjs/issues",
|
|
13
26
|
"type": "module",
|
|
14
27
|
"bin": {
|
|
15
28
|
"create-chapterjs": "./dist/index.js"
|
|
16
29
|
},
|
|
17
30
|
"files": [
|
|
18
|
-
"dist"
|
|
31
|
+
"dist",
|
|
32
|
+
"templates"
|
|
19
33
|
],
|
|
20
34
|
"devDependencies": {
|
|
21
35
|
"@chapterjs/test-utils": "0.0.0",
|
|
@@ -25,7 +39,7 @@
|
|
|
25
39
|
"vitest": "^5.0.3"
|
|
26
40
|
},
|
|
27
41
|
"dependencies": {
|
|
28
|
-
"
|
|
42
|
+
"@clack/prompts": "^1.8.1"
|
|
29
43
|
},
|
|
30
44
|
"engines": {
|
|
31
45
|
"node": ">=24"
|
|
@@ -0,0 +1,7 @@
|
|
|
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=
|
|
5
|
+
# Only for stores declared with storage: 'redis' (https://www.chapterjs.org/storage#in-redis)
|
|
6
|
+
# REDIS_URL=redis://localhost:6379
|
|
7
|
+
# REDIS_PASSWORD=
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "community-bot",
|
|
3
|
+
"description": "Welcome messages, roles people pick, and weekly server stats",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "chapterjs dev",
|
|
8
|
+
"start": "chapterjs start",
|
|
9
|
+
"build": "chapterjs build",
|
|
10
|
+
"test": "chapterjs test",
|
|
11
|
+
"typecheck": "tsc -b",
|
|
12
|
+
"postinstall": "chapterjs sync"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"chapterjs": "latest"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^24.0.0",
|
|
19
|
+
"typescript": "^7.0.2"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { scenario } from 'chapterjs/test';
|
|
3
|
+
import roles, { pick } from './roles/roles';
|
|
4
|
+
import { weekly } from './stats/weekly';
|
|
5
|
+
|
|
6
|
+
// `chapterjs test` runs these against a Discord in memory: no token, no
|
|
7
|
+
// server. The welcome channel does not exist there, so the bot writes in
|
|
8
|
+
// the system channel of the server, #general.
|
|
9
|
+
export default [
|
|
10
|
+
scenario('people are welcomed, and waved goodbye', async bot => {
|
|
11
|
+
const newbie = await bot.join('newbie');
|
|
12
|
+
bot
|
|
13
|
+
.channel('general')
|
|
14
|
+
.last!.says('Welcome to Test Server')
|
|
15
|
+
.hasEmbed('Newbie');
|
|
16
|
+
|
|
17
|
+
await bot.leave(newbie);
|
|
18
|
+
bot.channel('general').last!.says('Newbie left.');
|
|
19
|
+
}),
|
|
20
|
+
|
|
21
|
+
scenario('people pick their roles, once the roles exist', async bot => {
|
|
22
|
+
const menu = await bot.use(roles);
|
|
23
|
+
menu.isEphemeral().says('Pick the roles');
|
|
24
|
+
|
|
25
|
+
const missing = await bot.select(pick, ['Announcements']);
|
|
26
|
+
missing.updatesMessage().says('Your roles: Announcements.');
|
|
27
|
+
missing.followUps[0]!.says('do not exist in this server yet');
|
|
28
|
+
|
|
29
|
+
bot.role('Announcements');
|
|
30
|
+
bot.role('Events');
|
|
31
|
+
bot.role('Gaming');
|
|
32
|
+
const picked = await bot.select(pick, ['Announcements', 'Gaming']);
|
|
33
|
+
picked.says('Your roles: Announcements, Gaming.');
|
|
34
|
+
assert.deepEqual([...bot.someone.roles].sort(), [
|
|
35
|
+
'Announcements',
|
|
36
|
+
'Gaming',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const none = await bot.select(pick, []);
|
|
40
|
+
none.says('You have none of these roles now.');
|
|
41
|
+
assert.deepEqual(bot.someone.roles, []);
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
scenario('the weekly stats are posted', async bot => {
|
|
45
|
+
await bot.run(weekly);
|
|
46
|
+
bot.channel('general').last!.hasEmbed('A new week on Test Server');
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { language } from 'chapterjs';
|
|
2
|
+
|
|
3
|
+
// The texts of the bot, one language() per language. `t` picks the one of
|
|
4
|
+
// who will read the message: the person for a private answer, the server
|
|
5
|
+
// otherwise, and the default language for a task.
|
|
6
|
+
export const en = language({
|
|
7
|
+
locale: 'en-US',
|
|
8
|
+
default: true,
|
|
9
|
+
texts: {
|
|
10
|
+
welcome: 'Welcome to {server}, {user}!',
|
|
11
|
+
goodbye: '{name} left. See you!',
|
|
12
|
+
pickRoles: 'Pick the roles you want:',
|
|
13
|
+
rolesPlaceholder: 'Your roles',
|
|
14
|
+
noRoles: 'You have none of these roles now.',
|
|
15
|
+
rolesUpdated: 'Your roles: {roles}.',
|
|
16
|
+
rolesMissing:
|
|
17
|
+
'These roles do not exist in this server yet: {roles}. Ask an admin to create them.',
|
|
18
|
+
statsTitle: 'A new week on {server}',
|
|
19
|
+
members: 'Members',
|
|
20
|
+
textChannels: 'Text channels',
|
|
21
|
+
voiceChannels: 'Voice channels',
|
|
22
|
+
roles: 'Roles',
|
|
23
|
+
},
|
|
24
|
+
commands: {
|
|
25
|
+
roles: { description: 'Picks the roles you want' },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const fr = language({
|
|
30
|
+
locale: 'fr',
|
|
31
|
+
texts: {
|
|
32
|
+
welcome: 'Bienvenue sur {server}, {user} !',
|
|
33
|
+
goodbye: '{name} est parti. À bientôt !',
|
|
34
|
+
pickRoles: 'Choisissez vos rôles :',
|
|
35
|
+
rolesPlaceholder: 'Vos rôles',
|
|
36
|
+
noRoles: 'Vous n’avez plus aucun de ces rôles.',
|
|
37
|
+
rolesUpdated: 'Vos rôles : {roles}.',
|
|
38
|
+
rolesMissing:
|
|
39
|
+
'Ces rôles n’existent pas encore sur ce serveur : {roles}. Demandez à un admin de les créer.',
|
|
40
|
+
statsTitle: 'Une nouvelle semaine sur {server}',
|
|
41
|
+
members: 'Membres',
|
|
42
|
+
textChannels: 'Salons textuels',
|
|
43
|
+
voiceChannels: 'Salons vocaux',
|
|
44
|
+
roles: 'Rôles',
|
|
45
|
+
},
|
|
46
|
+
commands: {
|
|
47
|
+
roles: { description: 'Choisit les rôles que vous voulez' },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { command, select } from 'chapterjs';
|
|
2
|
+
|
|
3
|
+
// The roles people may give themselves: change the list, and create them
|
|
4
|
+
// in your server with the same names.
|
|
5
|
+
const SELF_ROLES = ['Announcements', 'Events', 'Gaming'] as const;
|
|
6
|
+
|
|
7
|
+
// /roles shows the menu, with what the person already has picked.
|
|
8
|
+
export default command({
|
|
9
|
+
name: 'roles',
|
|
10
|
+
ephemeral: true,
|
|
11
|
+
async run({ interaction, member, t }) {
|
|
12
|
+
const mine = member.roles
|
|
13
|
+
.map(role => role.name)
|
|
14
|
+
.filter((name): name is (typeof SELF_ROLES)[number] =>
|
|
15
|
+
(SELF_ROLES as readonly string[]).includes(name)
|
|
16
|
+
);
|
|
17
|
+
await interaction.reply({
|
|
18
|
+
content: t('pickRoles'),
|
|
19
|
+
components: [pick({ defaults: mine })],
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// A select menu is named by its export. `values` is what was picked, typed
|
|
25
|
+
// from `options`; `min: 0` lets the person remove every role.
|
|
26
|
+
export const pick = select({
|
|
27
|
+
placeholder: ({ t }) => t('rolesPlaceholder'),
|
|
28
|
+
options: SELF_ROLES,
|
|
29
|
+
min: 0,
|
|
30
|
+
max: SELF_ROLES.length,
|
|
31
|
+
async run({ interaction, values, member, guild, t }) {
|
|
32
|
+
const roles = [...guild.roles.values()];
|
|
33
|
+
const missing: string[] = [];
|
|
34
|
+
for (const name of SELF_ROLES) {
|
|
35
|
+
const role = roles.find(role => role.name === name);
|
|
36
|
+
if (!role) {
|
|
37
|
+
missing.push(name);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const wanted = values.includes(name);
|
|
41
|
+
if (wanted && !member.hasRole(role))
|
|
42
|
+
await member.addRole(role, 'Picked with /roles');
|
|
43
|
+
if (!wanted && member.hasRole(role))
|
|
44
|
+
await member.removeRole(role, 'Removed with /roles');
|
|
45
|
+
}
|
|
46
|
+
await interaction.update({
|
|
47
|
+
content:
|
|
48
|
+
values.length === 0
|
|
49
|
+
? t('noRoles')
|
|
50
|
+
: t('rolesUpdated', { roles: values.join(', ') }),
|
|
51
|
+
components: [],
|
|
52
|
+
});
|
|
53
|
+
if (missing.length > 0) {
|
|
54
|
+
await interaction.followUp({
|
|
55
|
+
content: t('rolesMissing', { roles: missing.join(', ') }),
|
|
56
|
+
ephemeral: true,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ChannelType, task } from 'chapterjs';
|
|
2
|
+
import { communityChannel } from '../welcome/channel';
|
|
3
|
+
|
|
4
|
+
// A task runs by itself: every Monday at 9:00, in the timezone given. The
|
|
5
|
+
// name of the export is the name of the task. `t` is the default language.
|
|
6
|
+
export const weekly = task({
|
|
7
|
+
cron: '0 9 * * 1',
|
|
8
|
+
timezone: 'Europe/Paris',
|
|
9
|
+
async run({ guilds, t }) {
|
|
10
|
+
for (const guild of guilds.values()) {
|
|
11
|
+
const channel = communityChannel(guild, 'general');
|
|
12
|
+
if (!channel) continue;
|
|
13
|
+
const channels = [...guild.channels.values()];
|
|
14
|
+
await channel.send({
|
|
15
|
+
embeds: [
|
|
16
|
+
{
|
|
17
|
+
title: t('statsTitle', { server: guild.name }),
|
|
18
|
+
color: 0xc026d3,
|
|
19
|
+
fields: [
|
|
20
|
+
{
|
|
21
|
+
name: t('members'),
|
|
22
|
+
value: String(guild.memberCount ?? '?'),
|
|
23
|
+
inline: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: t('textChannels'),
|
|
27
|
+
value: String(
|
|
28
|
+
channels.filter(c => c.type === ChannelType.GuildText).length
|
|
29
|
+
),
|
|
30
|
+
inline: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: t('voiceChannels'),
|
|
34
|
+
value: String(
|
|
35
|
+
channels.filter(c => c.type === ChannelType.GuildVoice).length
|
|
36
|
+
),
|
|
37
|
+
inline: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: t('roles'),
|
|
41
|
+
value: String(guild.roles.size - 1),
|
|
42
|
+
inline: true,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|