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.
Files changed (57) hide show
  1. package/README.md +37 -0
  2. package/dist/choose.js +27 -0
  3. package/dist/index.js +98 -4
  4. package/dist/node-version.js +13 -0
  5. package/dist/package-manager.js +59 -0
  6. package/dist/project.js +82 -0
  7. package/dist/templates.js +31 -0
  8. package/package.json +19 -5
  9. package/templates/community/.env.example +7 -0
  10. package/templates/community/_gitignore +5 -0
  11. package/templates/community/package.json +24 -0
  12. package/templates/community/src/community.test.ts +48 -0
  13. package/templates/community/src/messages.ts +49 -0
  14. package/templates/community/src/presence.ts +6 -0
  15. package/templates/community/src/roles/roles.ts +60 -0
  16. package/templates/community/src/stats/weekly.ts +50 -0
  17. package/templates/community/src/welcome/channel.ts +19 -0
  18. package/templates/community/src/welcome/welcome.ts +35 -0
  19. package/templates/community/tsconfig.json +20 -0
  20. package/templates/default/.env.example +7 -0
  21. package/templates/default/_gitignore +5 -0
  22. package/templates/default/package.json +24 -0
  23. package/templates/default/src/events/ready.ts +28 -0
  24. package/templates/default/tsconfig.json +20 -0
  25. package/templates/moderation/.env.example +7 -0
  26. package/templates/moderation/_gitignore +5 -0
  27. package/templates/moderation/package.json +24 -0
  28. package/templates/moderation/src/moderation/channel.ts +67 -0
  29. package/templates/moderation/src/moderation/log.ts +37 -0
  30. package/templates/moderation/src/moderation/messages.ts +158 -0
  31. package/templates/moderation/src/moderation/moderation.test.ts +76 -0
  32. package/templates/moderation/src/moderation/purge.ts +30 -0
  33. package/templates/moderation/src/moderation/timeout.ts +82 -0
  34. package/templates/moderation/src/moderation/warn.ts +61 -0
  35. package/templates/moderation/src/presence.ts +6 -0
  36. package/templates/moderation/tsconfig.json +20 -0
  37. package/templates/music/.env.example +7 -0
  38. package/templates/music/_gitignore +5 -0
  39. package/templates/music/package.json +24 -0
  40. package/templates/music/src/music/leave.ts +18 -0
  41. package/templates/music/src/music/messages.ts +83 -0
  42. package/templates/music/src/music/music.test.ts +29 -0
  43. package/templates/music/src/music/play.ts +120 -0
  44. package/templates/music/src/music/queue-command.ts +28 -0
  45. package/templates/music/src/music/queue.ts +61 -0
  46. package/templates/music/src/presence.ts +6 -0
  47. package/templates/music/tsconfig.json +20 -0
  48. package/templates/tickets/.env.example +7 -0
  49. package/templates/tickets/_gitignore +5 -0
  50. package/templates/tickets/package.json +24 -0
  51. package/templates/tickets/src/presence.ts +6 -0
  52. package/templates/tickets/src/tickets/close.ts +39 -0
  53. package/templates/tickets/src/tickets/messages.ts +68 -0
  54. package/templates/tickets/src/tickets/open.ts +81 -0
  55. package/templates/tickets/src/tickets/panel.ts +35 -0
  56. package/templates/tickets/src/tickets/tickets.test.ts +72 -0
  57. package/templates/tickets/tsconfig.json +20 -0
@@ -0,0 +1,61 @@
1
+ import { command, store, timestamp } from 'chapterjs';
2
+
3
+ interface Warning {
4
+ reason: string;
5
+ /** Who gave it: a user id. */
6
+ by: string;
7
+ /** When, in ms since the epoch: a Date would not survive the file. */
8
+ at: number;
9
+ }
10
+
11
+ // The warnings of each member, kept from one restart to the next, per
12
+ // server: `warnings.get(member.id)` in a command is the warnings of that
13
+ // member in the server the command was used in. A warning is forgotten
14
+ // thirty days after the last one was added.
15
+ export const warnings = store<Warning[]>({ expires: '30d' });
16
+
17
+ export default command({
18
+ name: 'warn add',
19
+ permissions: ['ModerateMembers'],
20
+ options: {
21
+ user: { type: 'user', required: true },
22
+ reason: { type: 'string', required: true, maxLength: 200 },
23
+ },
24
+ async run({ interaction, options, member, t }) {
25
+ // Reads, adds and keeps in one go; `list` is undefined the first time.
26
+ const list = await warnings.update(options.user.id, (list = []) => [
27
+ ...list,
28
+ { reason: options.reason, by: member.id, at: Date.now() },
29
+ ]);
30
+ await interaction.reply(
31
+ t('warned', { user: options.user.toString(), count: list.length })
32
+ );
33
+ },
34
+ });
35
+
36
+ export const list = command({
37
+ name: 'warn list',
38
+ permissions: ['ModerateMembers'],
39
+ ephemeral: true,
40
+ options: {
41
+ user: { type: 'user', required: true },
42
+ },
43
+ async run({ interaction, options, t }) {
44
+ const list = (await warnings.get(options.user.id)) ?? [];
45
+ if (list.length === 0) {
46
+ await interaction.reply(
47
+ t('noWarnings', { user: options.user.toString() })
48
+ );
49
+ return;
50
+ }
51
+ await interaction.reply(
52
+ [
53
+ t('warningsOf', { user: options.user.toString(), count: list.length }),
54
+ ...list.map(
55
+ warning =>
56
+ `• ${timestamp(warning.at, 'R')} <@${warning.by}>: ${warning.reason}`
57
+ ),
58
+ ].join('\n')
59
+ );
60
+ },
61
+ });
@@ -0,0 +1,6 @@
1
+ import { presence } from 'chapterjs';
2
+
3
+ // What the bot shows under its name.
4
+ export default presence({
5
+ activity: { type: 'watching', name: 'the server' },
6
+ });
@@ -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
+ }
@@ -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,5 @@
1
+ node_modules
2
+ dist
3
+ .env
4
+ .chapterjs
5
+ data
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "music-bot",
3
+ "description": "A music player in voice: a queue, buttons, pause, skip and stop",
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,18 @@
1
+ import { command } from 'chapterjs';
2
+ import { clearQueue } from './queue';
3
+
4
+ // /leave makes the bot leave the voice channel and forget the queue.
5
+ export default command({
6
+ name: 'leave',
7
+ async run({ interaction, guild, t }) {
8
+ const voice = guild.voice;
9
+ if (!voice) {
10
+ await interaction.reply({ content: t('notInVoice'), ephemeral: true });
11
+ return;
12
+ }
13
+ clearQueue(guild);
14
+ // leave() ends what plays: the loop of play.ts stops too.
15
+ await voice.leave();
16
+ await interaction.reply(t('left'));
17
+ },
18
+ });
@@ -0,0 +1,83 @@
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. A text with {count} can have one form per
5
+ // quantity (`one`, `other`...): Discord's language rules pick the form.
6
+ export const en = language({
7
+ locale: 'en-US',
8
+ default: true,
9
+ texts: {
10
+ nothingToPlay:
11
+ 'Send an audio file (.mp3, .ogg...) or a link starting with https://.',
12
+ joinVoice: 'Join a voice channel first, then ask again.',
13
+ playing: 'Now playing {song} in {channel}.',
14
+ queued: 'Added {song} to the queue, at position {position}.',
15
+ nowPlaying: '▶ {song}, asked by {by}',
16
+ upNext: {
17
+ one: 'Up next, {count} song:',
18
+ other: 'Up next, {count} songs:',
19
+ },
20
+ nothingPlaying: 'Nothing is playing.',
21
+ notWithMe: 'Join my voice channel to control what plays.',
22
+ notInVoice: 'I am not in a voice channel.',
23
+ pauseButton: 'Pause / resume',
24
+ skipButton: 'Skip',
25
+ stopButton: 'Stop',
26
+ paused: 'Paused.',
27
+ resumed: 'Playing again.',
28
+ skipped: 'Skipped.',
29
+ stopped: 'Stopped, the queue is empty.',
30
+ left: 'Bye!',
31
+ },
32
+ commands: {
33
+ play: {
34
+ description:
35
+ 'Plays a song in your voice channel, or adds it to the queue',
36
+ options: {
37
+ file: { description: 'A song to play' },
38
+ url: { description: 'Or the link of a song or a stream' },
39
+ },
40
+ },
41
+ queue: { description: 'Shows what plays and what comes next' },
42
+ leave: { description: 'Makes the bot leave the voice channel' },
43
+ },
44
+ });
45
+
46
+ export const fr = language({
47
+ locale: 'fr',
48
+ texts: {
49
+ nothingToPlay:
50
+ 'Envoyez un fichier audio (.mp3, .ogg...) ou un lien qui commence par https://.',
51
+ joinVoice: 'Rejoignez d’abord un salon vocal, puis redemandez.',
52
+ playing: 'Lecture de {song} dans {channel}.',
53
+ queued: '{song} ajouté à la file, en position {position}.',
54
+ nowPlaying: '▶ {song}, demandé par {by}',
55
+ upNext: {
56
+ one: 'Ensuite, {count} morceau :',
57
+ other: 'Ensuite, {count} morceaux :',
58
+ },
59
+ nothingPlaying: 'Rien n’est en cours de lecture.',
60
+ notWithMe: 'Rejoignez mon salon vocal pour contrôler la lecture.',
61
+ notInVoice: 'Je ne suis pas dans un salon vocal.',
62
+ pauseButton: 'Pause / reprendre',
63
+ skipButton: 'Passer',
64
+ stopButton: 'Stop',
65
+ paused: 'En pause.',
66
+ resumed: 'Reprise.',
67
+ skipped: 'Passé.',
68
+ stopped: 'Arrêté, la file est vide.',
69
+ left: 'À plus !',
70
+ },
71
+ commands: {
72
+ play: {
73
+ description:
74
+ 'Joue une musique dans votre salon vocal, ou l’ajoute à la file',
75
+ options: {
76
+ file: { description: 'Une musique à jouer' },
77
+ url: { description: 'Ou le lien d’une musique ou d’un flux' },
78
+ },
79
+ },
80
+ queue: { description: 'Montre ce qui joue et ce qui suit' },
81
+ leave: { description: 'Fait quitter le salon vocal au bot' },
82
+ },
83
+ });
@@ -0,0 +1,29 @@
1
+ import { scenario } from 'chapterjs/test';
2
+ import leave from './leave';
3
+ import play from './play';
4
+ import queue from './queue-command';
5
+
6
+ // `chapterjs test` runs these against a Discord in memory: no token, no
7
+ // server, and no voice yet: what can be checked is what the bot says
8
+ // before playing anything.
9
+ export default [
10
+ scenario('nothing plays at first', async bot => {
11
+ const reply = await bot.use(queue);
12
+ reply.isEphemeral().says('Nothing is playing.');
13
+ const left = await bot.use(leave);
14
+ left.isEphemeral().says('I am not in a voice channel.');
15
+ }),
16
+
17
+ scenario('play needs a song, then a voice channel', async bot => {
18
+ const nothing = await bot.use(play);
19
+ nothing.isEphemeral().says('Send an audio file');
20
+ const notAudio = await bot.use(play, {
21
+ file: { name: 'photo.png', type: 'image/png' },
22
+ });
23
+ notAudio.isEphemeral().says('Send an audio file');
24
+ const noVoice = await bot.use(play, {
25
+ url: 'https://example.com/song.mp3',
26
+ });
27
+ noVoice.isEphemeral().says('Join a voice channel first');
28
+ }),
29
+ ];
@@ -0,0 +1,120 @@
1
+ import { button, command, row } from 'chapterjs';
2
+ import { clearQueue, playQueue, queueOf, type Song } from './queue';
3
+
4
+ // /play adds a song to the queue of the server and starts playing when
5
+ // nothing is. A song is a file sent with the command (an .mp3, an .ogg...)
6
+ // or a link to an audio file or a stream; anything that is not Opus needs
7
+ // ffmpeg installed on the machine that runs the bot.
8
+ export default command({
9
+ name: 'play',
10
+ options: {
11
+ file: { type: 'attachment' },
12
+ url: { type: 'string' },
13
+ },
14
+ async run({ interaction, member, guild, channel, options, t }) {
15
+ const { file, url } = options;
16
+ if (
17
+ (!file && !url) ||
18
+ (file && !file.contentType?.startsWith('audio/')) ||
19
+ (url && !/^https?:\/\//.test(url))
20
+ ) {
21
+ await interaction.reply({ content: t('nothingToPlay'), ephemeral: true });
22
+ return;
23
+ }
24
+ const voiceChannel = member.voiceChannel;
25
+ if (!voiceChannel) {
26
+ await interaction.reply({ content: t('joinVoice'), ephemeral: true });
27
+ return;
28
+ }
29
+ const song: Song = {
30
+ source: file ?? url!,
31
+ title: file?.filename ?? url!,
32
+ requestedBy: member.toString(),
33
+ };
34
+ const queue = queueOf(guild);
35
+ queue.songs.push(song);
36
+ if (queue.current) {
37
+ await interaction.reply(
38
+ t('queued', { song: song.title, position: queue.songs.length })
39
+ );
40
+ return;
41
+ }
42
+ await interaction.reply({
43
+ content: t('playing', { song: song.title, channel: voiceChannel.name }),
44
+ components: [row([pause, skip, stop])],
45
+ });
46
+ // Joining the channel the bot is already in changes nothing. The loop
47
+ // runs until the queue is empty, and the bot leaves by itself.
48
+ const voice = await voiceChannel.join();
49
+ await playQueue(guild, voice, text => channel.send(text));
50
+ },
51
+ });
52
+
53
+ // The three buttons under "Now playing". A button is named by its export,
54
+ // and `run` receives the same things as a command.
55
+ export const pause = button({
56
+ label: ({ t }) => t('pauseButton'),
57
+ emoji: '⏯️',
58
+ async run({ interaction, guild, member, t }) {
59
+ const voice = guild.voice;
60
+ if (!voice?.playing) {
61
+ await interaction.reply({
62
+ content: t('nothingPlaying'),
63
+ ephemeral: true,
64
+ });
65
+ return;
66
+ }
67
+ if (member.voiceChannel?.id !== voice.channel.id) {
68
+ await interaction.reply({ content: t('notWithMe'), ephemeral: true });
69
+ return;
70
+ }
71
+ if (voice.paused) voice.resume();
72
+ else voice.pause();
73
+ await interaction.reply(voice.paused ? t('paused') : t('resumed'));
74
+ },
75
+ });
76
+
77
+ export const skip = button({
78
+ label: ({ t }) => t('skipButton'),
79
+ emoji: '⏭️',
80
+ async run({ interaction, guild, member, t }) {
81
+ const voice = guild.voice;
82
+ if (!voice?.playing) {
83
+ await interaction.reply({
84
+ content: t('nothingPlaying'),
85
+ ephemeral: true,
86
+ });
87
+ return;
88
+ }
89
+ if (member.voiceChannel?.id !== voice.channel.id) {
90
+ await interaction.reply({ content: t('notWithMe'), ephemeral: true });
91
+ return;
92
+ }
93
+ // stop() ends the current play(): the loop goes on with the next song.
94
+ voice.stop();
95
+ await interaction.reply(t('skipped'));
96
+ },
97
+ });
98
+
99
+ export const stop = button({
100
+ label: ({ t }) => t('stopButton'),
101
+ emoji: '⏹️',
102
+ style: 'danger',
103
+ async run({ interaction, guild, member, t }) {
104
+ const voice = guild.voice;
105
+ if (!voice) {
106
+ await interaction.reply({
107
+ content: t('nothingPlaying'),
108
+ ephemeral: true,
109
+ });
110
+ return;
111
+ }
112
+ if (member.voiceChannel?.id !== voice.channel.id) {
113
+ await interaction.reply({ content: t('notWithMe'), ephemeral: true });
114
+ return;
115
+ }
116
+ clearQueue(guild);
117
+ voice.stop();
118
+ await interaction.reply(t('stopped'));
119
+ },
120
+ });
@@ -0,0 +1,28 @@
1
+ import { command } from 'chapterjs';
2
+ import { queueOf } from './queue';
3
+
4
+ // /queue shows what plays and what comes next. A text with {count} may
5
+ // have one form per quantity in the language files: see messages.ts.
6
+ export default command({
7
+ name: 'queue',
8
+ ephemeral: true,
9
+ async run({ interaction, guild, t }) {
10
+ const queue = queueOf(guild);
11
+ if (!queue.current) {
12
+ await interaction.reply(t('nothingPlaying'));
13
+ return;
14
+ }
15
+ const next = queue.songs
16
+ .slice(0, 10)
17
+ .map((song, index) => `${index + 1}. ${song.title} (${song.requestedBy})`)
18
+ .join('\n');
19
+ const lines = [
20
+ t('nowPlaying', {
21
+ song: queue.current.title,
22
+ by: queue.current.requestedBy,
23
+ }),
24
+ ];
25
+ if (next) lines.push(t('upNext', { count: queue.songs.length }), next);
26
+ await interaction.reply(lines.join('\n'));
27
+ },
28
+ });
@@ -0,0 +1,61 @@
1
+ import type { Attachment, Guild, VoiceConnection } from 'chapterjs';
2
+
3
+ // Plain code: this file declares nothing, so the framework leaves it alone.
4
+ // It keeps the queue of each server and plays it, song after song.
5
+
6
+ export interface Song {
7
+ /** What play() takes: a file sent on Discord, or a link. */
8
+ source: Attachment | string;
9
+ title: string;
10
+ /** Who asked for it: a mention. */
11
+ requestedBy: string;
12
+ }
13
+
14
+ interface Queue {
15
+ songs: Song[];
16
+ current: Song | null;
17
+ }
18
+
19
+ const queues = new Map<string, Queue>();
20
+
21
+ /** The queue of a server, made on first use. */
22
+ export function queueOf(guild: Guild): Queue {
23
+ let queue = queues.get(guild.id);
24
+ if (!queue) {
25
+ queue = { songs: [], current: null };
26
+ queues.set(guild.id, queue);
27
+ }
28
+ return queue;
29
+ }
30
+
31
+ /**
32
+ * Plays the queue of a server until it is empty, then leaves the channel.
33
+ * Called once when the first song is added; a song added meanwhile is
34
+ * picked up by the loop. A song that can't be played (a dead link, ffmpeg
35
+ * missing) is skipped and said in the channel.
36
+ */
37
+ export async function playQueue(
38
+ guild: Guild,
39
+ voice: VoiceConnection,
40
+ say: (text: string) => Promise<unknown>
41
+ ): Promise<void> {
42
+ const queue = queueOf(guild);
43
+ if (queue.current) return;
44
+ let song: Song | undefined;
45
+ while ((song = queue.songs.shift())) {
46
+ queue.current = song;
47
+ try {
48
+ await voice.play(song.source);
49
+ } catch (error) {
50
+ await say(`⚠ ${song.title}: ${(error as Error).message}`);
51
+ }
52
+ }
53
+ queue.current = null;
54
+ // /leave may have made the bot leave already.
55
+ if (voice.connected) await voice.leave();
56
+ }
57
+
58
+ /** Empties the queue of a server: when the bot stops or leaves. */
59
+ export function clearQueue(guild: Guild): void {
60
+ queueOf(guild).songs.length = 0;
61
+ }
@@ -0,0 +1,6 @@
1
+ import { presence } from 'chapterjs';
2
+
3
+ // What the bot shows under its name.
4
+ export default presence({
5
+ activity: { type: 'listening', name: '/play' },
6
+ });
@@ -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
+ }
@@ -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,5 @@
1
+ node_modules
2
+ dist
3
+ .env
4
+ .chapterjs
5
+ data
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "ticket-bot",
3
+ "description": "Support tickets in private threads: a panel, a form, a close button",
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,6 @@
1
+ import { presence } from 'chapterjs';
2
+
3
+ // What the bot shows under its name.
4
+ export default presence({
5
+ activity: { type: 'watching', name: 'the tickets' },
6
+ });
@@ -0,0 +1,39 @@
1
+ import { button } from 'chapterjs';
2
+ import { tickets } from './open';
3
+
4
+ // The button at the top of every ticket. Everyone in the thread sees it;
5
+ // the code decides who may use it: the staff, or who opened the ticket.
6
+ // `data` is what the button carries from the message to the click: here
7
+ // who opened the ticket, kept by Discord in the button itself.
8
+ export const close = button({
9
+ label: ({ t }) => t('closeButton'),
10
+ style: 'danger',
11
+ data: { userId: 'string' },
12
+ async run({ interaction, data, channel, member, guild, t }) {
13
+ if (!channel.isThread()) {
14
+ await interaction.reply({ content: t('notATicket'), ephemeral: true });
15
+ return;
16
+ }
17
+ const staff = member.permissions.has('ManageThreads');
18
+ if (!staff && member.id !== data.userId) {
19
+ await interaction.reply({ content: t('notYours'), ephemeral: true });
20
+ return;
21
+ }
22
+ // Said in the thread first: archiving it closes it for everyone.
23
+ await interaction.reply(t('closing', { user: member.toString() }));
24
+ await channel.edit(
25
+ { locked: true, archived: true },
26
+ `Closed by ${member.user.username}`
27
+ );
28
+ // The person may open another one.
29
+ await tickets.delete(data.userId);
30
+ // Who opened it is told in private, unless they closed it themselves.
31
+ // A person who refuses private messages is not an error.
32
+ if (member.id !== data.userId) {
33
+ const opener = await guild.fetchMember(data.userId).catch(() => null);
34
+ await opener
35
+ ?.send(t('closedDm', { ticket: channel.name, server: guild.name }))
36
+ .catch(() => {});
37
+ }
38
+ },
39
+ });
@@ -0,0 +1,68 @@
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. `commands` is what Discord shows of each command.
6
+ export const en = language({
7
+ locale: 'en-US',
8
+ default: true,
9
+ texts: {
10
+ panelTitle: 'Need help?',
11
+ panelText:
12
+ 'Open a ticket and the staff will answer you in a private thread.',
13
+ panelPosted: 'The panel is posted.',
14
+ openButton: 'Open a ticket',
15
+ formTitle: 'New ticket',
16
+ formSubject: 'Subject',
17
+ formCategory: 'What is it about?',
18
+ formDetails: 'Tell us more',
19
+ notHere: 'Tickets can only be opened from a text channel.',
20
+ opened: 'Thanks {user}, the staff will be with you shortly.',
21
+ created: 'Your ticket is open: {thread}',
22
+ alreadyOpen: 'You already have a ticket open: {thread}. Close it first.',
23
+ category: 'Category',
24
+ openedBy: 'Opened by',
25
+ closeButton: 'Close the ticket',
26
+ notATicket: 'This button only works inside a ticket.',
27
+ notYours: 'Only the staff, or who opened this ticket, can close it.',
28
+ closing: 'Ticket closed by {user}.',
29
+ closedDm: 'Your ticket "{ticket}" on {server} was closed.',
30
+ },
31
+ commands: {
32
+ 'ticket panel': {
33
+ description: 'Posts the message people open tickets from',
34
+ },
35
+ },
36
+ });
37
+
38
+ export const fr = language({
39
+ locale: 'fr',
40
+ texts: {
41
+ panelTitle: 'Besoin d’aide ?',
42
+ panelText: 'Ouvrez un ticket et le staff vous répondra dans un fil privé.',
43
+ panelPosted: 'Le panneau est posté.',
44
+ openButton: 'Ouvrir un ticket',
45
+ formTitle: 'Nouveau ticket',
46
+ formSubject: 'Sujet',
47
+ formCategory: 'De quoi s’agit-il ?',
48
+ formDetails: 'Dites-nous en plus',
49
+ notHere: 'Un ticket ne peut être ouvert que depuis un salon textuel.',
50
+ opened: 'Merci {user}, le staff arrive.',
51
+ created: 'Votre ticket est ouvert : {thread}',
52
+ alreadyOpen:
53
+ 'Vous avez déjà un ticket ouvert : {thread}. Fermez-le d’abord.',
54
+ category: 'Catégorie',
55
+ openedBy: 'Ouvert par',
56
+ closeButton: 'Fermer le ticket',
57
+ notATicket: 'Ce bouton ne fonctionne que dans un ticket.',
58
+ notYours:
59
+ 'Seul le staff, ou la personne qui a ouvert ce ticket, peut le fermer.',
60
+ closing: 'Ticket fermé par {user}.',
61
+ closedDm: 'Votre ticket « {ticket} » sur {server} a été fermé.',
62
+ },
63
+ commands: {
64
+ 'ticket panel': {
65
+ description: 'Poste le message depuis lequel on ouvre un ticket',
66
+ },
67
+ },
68
+ });