From 919574fd6cd2cb3ec5a6c3c1ed3b995fc30687d3 Mon Sep 17 00:00:00 2001 From: Mestery Date: Sun, 11 Jul 2021 23:15:17 +0200 Subject: [PATCH] feat(framework): slash commands --- README.md | 93 ++++++++++---- package-lock.json | 9 ++ package.json | 1 + src/bot.ts | 1 + src/commands/Hello.ts | 32 +++++ src/framework/Bot.ts | 25 +++- src/framework/command/Command.ts | 155 ++++++++++++++++++++++++ src/framework/command/CommandManager.ts | 108 +++++++++++++++++ src/framework/command/index.ts | 2 + src/framework/index.ts | 1 + tests/framework/Bot.spec.ts | 4 +- 11 files changed, 405 insertions(+), 26 deletions(-) create mode 100644 src/commands/Hello.ts create mode 100644 src/framework/command/Command.ts create mode 100644 src/framework/command/CommandManager.ts create mode 100644 src/framework/command/index.ts diff --git a/README.md b/README.md index e382fb4..fd76c61 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,27 @@ # Bot Discord de la communauté -## Développement - -### Prérequis +## Prérequis - Node.js v16 - npm v7 - Un bot Discord installé sur une copie du serveur ES Community. - - Template: https://discord.new/T3mtuFqjR8Tm + - Template : https://discord.new/T3mtuFqjR8Tm -### Préparation de l'environnement +## Préparation de l'environnement -Installez les dépendances avec npm: +Installez les dépendances avec npm : ```console npm ci ``` -Créez un fichier `.env` avec votre token de bot: +Créez un fichier `.env` avec votre token de bot : ```env DISCORD_TOKEN=votretoken ``` -### Exécution du bot +## Exécution du bot ```console npm start @@ -31,15 +29,15 @@ npm start Cette commande exécute le fichier `src/bot.ts`, qui démarre le bot. Les changements dans le dossier `src` sont observés par `nodemon` et le bot est redémarré automatiquement. -### Tests +## Tests -Le projet contient 3 scripts de test qui doivent passer pour tout commit poussé sur la branche `main`. Vous pouvez exécuter tous les tests avec la commande suivante: +Le projet contient 3 scripts de test qui doivent passer pour tout commit poussé sur la branche `main`. Vous pouvez exécuter tous les tests avec la commande suivante : ```console npm test ``` -#### Tests TS +### Tests TS ```console # Exécution des tests. @@ -51,7 +49,7 @@ npm run test-coverage Le framework de test [Jest](https://jestjs.io/) est utilisé pour exécuter les tests. Ceux-ci doivent être écrits en TypeScript dans le dossier `tests`. Essayez de conserver la même structure de dossiers que dans `src` pour organiser les tests. -#### Lint +### Lint ```console # Exécution d'ESLint @@ -61,9 +59,9 @@ npm run lint npm run lint-fix ``` -Nous utilisons [ESLint](https://eslint.org/) ainsi que [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint) pour l'analyse statique du code. +Nous utilisons [ESLint](https://eslint.org/) ainsi que [TypeScript ESLint](https://github.com/typescript-eslint/typescript-eslint) pour l'analyse statique du code. -#### Vérification des types TypeScript +### Vérification des types TypeScript ```console npm run check-types @@ -71,24 +69,75 @@ npm run check-types Cette commande exécute le compilateur TypeScript avec l'option `--noEmit`. Elle permet de valider les types de l'entier du projet, y compris sur les fichiers qui ne sont pas testés avec Jest. -### Écriture de fonctionnalités +## Écriture de fonctionnalités + +### Commandes + +Chaque commande doit être écrite dans un fichier du dossier `src/commands`. Ce +fichier doit instancier et exporter par défaut une instance de la classe `Command`, +en lui passant les paramètres de configuration suivants : + +- `enabled`: boolean. Peut être mis à `false` pour désactiver la commande. +- `name`: string. Nom de la commande.. +- `description`: string. Description de ce que fait la commande (en français). +- `options`?: object. Options (arguments) de la commande. +- `guildId`?: Snowflake. L'identifiant d'une guilde, si cette commande est spécifique à une guilde. +- `defaultPermission`?: boolean. Si la commande doit être activé par défaut quand le bot est ajouté à un serveur (`true` par défaut). +- `handle`: function. Fonction exécutée lorsque cette commande est appellé. Elle recevra un argument `context`, avec les propriétés : + - `args`: Objet correctement typé, contenant les options fournis par l'éxecuteur de la commande (abstraction d'`interaction.options`). + - `interaction`: Instance de CommandInteraction (discord.js). + - `client`: Instance du Client (discord.js). + - `logger`: Instance du Logger (pino). -#### Tâches cron +#### Exemple + +**Fichier exemplaire :** [src/commands/Hello.ts](src/commands/Hello.ts). + +```ts +import { Command, CommandOptionTypes } from '../framework'; + +// création d'une commande slash (https://discord.com/developers/docs/interactions/slash-commands) +export default new Command({ + enabled: true, + name: 'say', // nom de la commande + description: 'Dit ce que vous lui dites.', // description de la commande + options: { + message: { + // ceci est une option ("argument") de la commande slash + type: CommandOptionTypes.String, // type d'option (en l'occurence, chaine de caractère) + description: 'Ce que le bot doit dire.', // description de cette option + required: true, // option obligatoire (par défaut, false) + }, + }, + handle({ args, interaction }) { + // args aura comme type : `{ message: string }` + return interaction.reply( + `**${interaction.user.username}** m'a dit de dire : « ${args.message} ».`, + ); + // si toutefois, vous voulez accéder aux arguments fourni comme telle par discord.js : + // interaction.options.get('message').value; + }, +}); +``` + +### Tâches cron Chaque tâche cron doit être écrite dans un fichier du dossier `src/crons`. Ce -fichier doit instancier et exporter par défaut une instance de la classe Cron, -en lui passant les paramètres de configuration suivants: +fichier doit instancier et exporter par défaut une instance de la classe `Cron`, +en lui passant les paramètres de configuration suivants : - `enabled`: boolean. Peut être mis à `false` pour désactiver la tâche. - `name`: string. Nom de la tâche. Utilisé dans les logs. - `description`: string. Description de ce que fait la tâche (en français). - `schedule`: string. Programme d'exécution. Vous pouvez utiliser [crontab guru](https://crontab.guru/) pour le préparer. -- `handle`: function. Fonction exécutée selon le programme. Elle recevra un argument `context`, avec les propriétés: +- `handle`: function. Fonction exécutée selon le programme. Elle recevra un argument `context`, avec les propriétés : - `date`: Date théorique d'exécution de la tâche. - - `client`: Instance du client discord.js. - - `logger`: Instance du logger pino. + - `client`: Instance du Client (discord.js). + - `logger`: Instance du Logger (pino). + +#### Exemple -Exemple: +**Fichier exemplaire :** [src/crons/CommitStrip.ts](src/crons/CommitStrip.ts). ```ts import { Cron } from '../framework'; diff --git a/package-lock.json b/package-lock.json index 308d642..860a9ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/ws": "^7.4.6", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", + "discord-api-types": "^0.19.0-next.f393ba520d7d6d2aacaca7b3ca5d355fab614f6e", "eslint": "^7.30.0", "jest": "^27.0.6", "nodemon": "^2.0.12", @@ -2362,6 +2363,14 @@ "node": ">=8" } }, + "node_modules/discord-api-types": { + "version": "0.19.0-next.f393ba520d7d6d2aacaca7b3ca5d355fab614f6e", + "integrity": "sha512-ttRA/8e/WKHDbGFfED5WlS7gID+kalmNr6iMiWBCvkphQ7kFHiTOVbnj/zX9ksaRaYXp/I38SCQ+qZvLu8DJZg==", + "deprecated": "No longer supported. Install the latest @next release", + "engines": { + "node": ">=12" + } + }, "node_modules/discord.js": { "version": "13.0.0-dev.d433fe8.1626004976", "integrity": "sha512-niNeY8gI6Z4x6FeFIFu1T79NTlH5WbybfPVumWk+3ymFRogfs3mDebUTJLaoM99+UiaeN3nvEsgYp2kJ/kOKDw==", diff --git a/package.json b/package.json index 92618c8..a373256 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/ws": "^7.4.6", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", + "discord-api-types": "^0.19.0-next.f393ba520d7d6d2aacaca7b3ca5d355fab614f6e", "eslint": "^7.30.0", "jest": "^27.0.6", "nodemon": "^2.0.12", diff --git a/src/bot.ts b/src/bot.ts index 7942a7e..0e1571e 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -8,6 +8,7 @@ Dotenv.config(); const bot = new Bot({ token: process.env.DISCORD_TOKEN, + commands: path.join(__dirname, 'commands'), crons: path.join(__dirname, 'crons'), formatCheckers: path.join(__dirname, 'format-checkers'), }); diff --git a/src/commands/Hello.ts b/src/commands/Hello.ts new file mode 100644 index 0000000..89993d4 --- /dev/null +++ b/src/commands/Hello.ts @@ -0,0 +1,32 @@ +import { Command, CommandOptionTypes } from '../framework'; + +export default new Command({ + name: 'hello', + description: 'Vous salue.', + enabled: true, + options: { + stars: { + description: "Nombre d'étoiles accompagnant le salut.", + required: true, + type: CommandOptionTypes.Integer, + choices: [ + { name: '1 étoile', value: 1 }, + { name: '2 étoiles', value: 2 }, + { name: '3 étoiles', value: 3 }, + { name: '4 étoiles', value: 4 }, + { name: '5 étoiles', value: 5 }, + ] as const, + }, + user: { + description: 'Salut un utilisateur spécifique (vous par défaut).', + type: CommandOptionTypes.User, + }, + }, + handle({ args, interaction }) { + return interaction.reply( + `Salut ${(args.user ?? interaction.user).toString()} ${'⭐'.repeat( + args.stars, + )}`, + ); + }, +}); diff --git a/src/framework/Bot.ts b/src/framework/Bot.ts index 8d6fd7e..cd2e5ad 100644 --- a/src/framework/Bot.ts +++ b/src/framework/Bot.ts @@ -5,8 +5,9 @@ import path from 'path'; import { Client, Intents } from 'discord.js'; import pino from 'pino'; -import { Cron } from './Cron'; import { Base, BaseConfig } from './Base'; +import { Command, CommandManager } from './command'; +import { Cron } from './Cron'; import { FormatChecker } from './FormatChecker'; export interface BotOptions { @@ -15,6 +16,10 @@ export interface BotOptions { * Defaults to `process.env.DISCORD_TOKEN`. */ token?: string; + /** + * Directory that contains the `Command` definitions. + */ + commands?: string; /** * Directory that contains the `Cron` definitions. */ @@ -27,11 +32,14 @@ export interface BotOptions { type Constructor = { new (config: U): T; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + new (config: U): T; }; export class Bot { private readonly token?: string; private _client: Client | null; + private commandManager?: CommandManager; private crons: Cron[] = []; private formatCheckers: FormatChecker[] = []; @@ -42,9 +50,16 @@ export class Bot { this._client = null; this.logger = pino(); + if (options.commands) { + this.commandManager = new CommandManager( + this.loadDirectory(options.commands, 'commands', Command), + ); + } + if (options.crons) { this.crons = this.loadDirectory(options.crons, 'crons', Cron); } + if (options.formatCheckers) { this.formatCheckers = this.loadDirectory( options.formatCheckers, @@ -141,6 +156,9 @@ export class Bot { this.client.login(this.token), once(this.client, 'ready'), ]); + if (this.commandManager) { + await this.commandManager.start(this); + } this.startCrons(); this.startFormatCheckers(); } catch (error) { @@ -152,10 +170,13 @@ export class Bot { /** * Stop the bot. */ - public stop(): void { + public async stop(): Promise { if (!this._client) { throw new Error('Bot was not started'); } + if (this.commandManager) { + await this.commandManager.stop(this); + } this.stopCrons(); this.stopFormatCheckers(); this._client.destroy(); diff --git a/src/framework/command/Command.ts b/src/framework/command/Command.ts new file mode 100644 index 0000000..6b3eaa6 --- /dev/null +++ b/src/framework/command/Command.ts @@ -0,0 +1,155 @@ +import type { + Client, + CommandInteraction, + GuildChannel, + GuildMember, + Role, + Snowflake, + User, +} from 'discord.js'; +import type { + APIRole, + APIInteractionDataResolvedGuildMember, + APIInteractionDataResolvedChannel, +} from 'discord-api-types'; +import type { Logger } from 'pino'; +import { Base, BaseConfig } from '../Base'; + +export const enum CommandOptionTypes { + String = 3, + Integer, + Boolean, + User, + Channel, + Role, + Mentionable, +} + +interface CommandOptionsData< + T extends CommandOptionTypes = + | CommandOptionTypes.Boolean + | CommandOptionTypes.Channel + | CommandOptionTypes.Mentionable + | CommandOptionTypes.Role + | CommandOptionTypes.User, + C = never +> { + readonly type: T; + readonly description: string; + readonly required?: boolean; + readonly choices?: C extends never + ? never + : readonly { readonly name: string; readonly value: C }[]; +} + +export type CommandOptions = Record< + string, + | CommandOptionsData + | CommandOptionsData + | CommandOptionsData +>; + +interface OptionTypes { + [CommandOptionTypes.Boolean]: boolean; + [CommandOptionTypes.Integer]: number; + [CommandOptionTypes.String]: string; + [CommandOptionTypes.Role]: Role | APIRole; + [CommandOptionTypes.Channel]: + | GuildChannel + | APIInteractionDataResolvedChannel; + [CommandOptionTypes.User]: + | User + | GuildMember + | APIInteractionDataResolvedGuildMember; + [CommandOptionTypes.Mentionable]: + | OptionTypes[CommandOptionTypes.Role] + | OptionTypes[CommandOptionTypes.User]; +} + +type BuildArgs = { + [K in Keys]: T[K]['choices'] extends readonly unknown[] + ? T[K]['choices'][number]['value'] + : OptionTypes[T[K]['type']]; +}; + +type FinalCommandOptions< + T extends CommandOptions, + RequiredArgs = keyof { + [K in keyof T as T[K]['required'] extends true + ? K + : never]: never /* unimportant */; + } +> = BuildArgs> & + Partial>>; + +export type CommandHandler = ( + context: CommandContext, +) => Promise; + +export interface CommandContext { + /** + * discord.js Client instance. + */ + client: Client; + /** + * Pino logger. + */ + logger: Logger; + /** + * The arguments (options) provided by the user, with correct typings + */ + args: FinalCommandOptions; + /** + * CommandInteraction instance. + */ + interaction: CommandInteraction; +} + +export interface CommandConfig + extends BaseConfig { + /** + * The guild ID (if this command is specific to a guild) + */ + guildId?: Snowflake; + /** + * Whether the command is enabled by default when the app is added to a guild + * @default true + */ + defaultPermission?: boolean; + /** + * Command options + */ + options?: T; + /** + * Command handler + */ + handle: CommandHandler; +} + +export class Command extends Base { + /** + * The guild ID (if this command is specific to a guild) + */ + public readonly guildId: Snowflake | undefined; + /** + * Whether the command is enabled by default when the app is added to a guild + * @default true + */ + public readonly defaultPermission: boolean | undefined; + /** + * Command options + */ + public readonly options: T | undefined; + /** + * Command handler + */ + public readonly handler: CommandHandler; + + public constructor(config: CommandConfig) { + super(config); + this.guildId = config.guildId; + this.options = config.options; + this.defaultPermission = config.defaultPermission; + this.handler = config.handle; + } +} diff --git a/src/framework/command/CommandManager.ts b/src/framework/command/CommandManager.ts new file mode 100644 index 0000000..0689fa9 --- /dev/null +++ b/src/framework/command/CommandManager.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +import { randomUUID } from 'crypto'; +import { Snowflake, Interaction } from 'discord.js'; +import type { Bot } from '../Bot'; +import { Command } from './Command'; + +export class CommandManager { + /** + * Registered slash commands. + */ + public readonly commands = new Map(); + + private bot!: Bot; + + public constructor(private holds: Command[]) { + this._interactionHandler = this._interactionHandler.bind(this); + } + + public async _interactionHandler(interaction: Interaction): Promise { + if (!interaction.isCommand()) { + return; + } + + const command = this.commands.get(interaction.commandId); + + if (!command) { + return this.bot.logger + .child({ + id: randomUUID(), + type: 'CommandManager', + }) + .error( + 'unregistred slash command %s (id: %s)', + interaction.commandName, + interaction.commandId, + ); + } + + const logger = this.bot.logger.child({ + id: randomUUID(), + type: 'Command', + commandName: command.name, + }); + + try { + logger.debug('execute command handler'); + await command.handler({ + client: this.bot.client, + logger, + interaction, + args: Object.fromEntries( + interaction.options.map((option, name) => [ + name, + (option.member ?? + option.user ?? + option.channel ?? + option.role ?? + option.value) as any, + ]), + ), + }); + } catch (error) { + logger.error(error, 'command handler error'); + } + } + + public async start(bot: Bot): Promise { + this.bot = bot; + + await Promise.all( + this.holds.map(async (hold) => { + // The application cannot be null if the Client is ready. + const command = await this.bot.client.application!.commands.create( + { + name: hold.name, + description: hold.description, + defaultPermission: hold.defaultPermission, + options: + hold.options && + (Object.entries(hold.options).map(([key, value]) => ({ + name: key, + ...value, + })) as any), + }, + hold.guildId as any, + ); + this.commands.set(command.id, hold); + }), + ); + + // We don't need this.holds anymore. + this.holds = []; + + this.bot.client.on('interactionCreate', this._interactionHandler); + } + + public async stop(bot: Bot): Promise { + bot.client.off('interactionCreate', this._interactionHandler); + + // Unregister *all* registered slash commands. + await Promise.all( + // The application cannot be null if the Client is ready. + ( + await bot.client.application!.commands.fetch() + ).map((command) => command.delete()), + ); + } +} diff --git a/src/framework/command/index.ts b/src/framework/command/index.ts new file mode 100644 index 0000000..f7d8e84 --- /dev/null +++ b/src/framework/command/index.ts @@ -0,0 +1,2 @@ +export * from './Command'; +export * from './CommandManager'; diff --git a/src/framework/index.ts b/src/framework/index.ts index 7a4be7c..e7d8e8c 100644 --- a/src/framework/index.ts +++ b/src/framework/index.ts @@ -1,4 +1,5 @@ export * from './Bot'; export * from './Cron'; export * from './FormatChecker'; +export * from './command'; export * from './helpers'; diff --git a/tests/framework/Bot.spec.ts b/tests/framework/Bot.spec.ts index 1ec5b2f..f6538c3 100644 --- a/tests/framework/Bot.spec.ts +++ b/tests/framework/Bot.spec.ts @@ -15,9 +15,9 @@ test('bot.start() throws if called twice', async () => { await expect(bot.start()).rejects.toThrow(/can only be started once/); }); -test('bot.stop() throws if it was not started', () => { +test('bot.stop() throws if it was not started', async () => { const bot = new Bot(dummyOptions); - expect(() => bot.stop()).toThrow(/was not started/); + await expect(bot.stop()).rejects.toThrow(/was not started/); }); test('bot.client throws if it was not started', () => {