diff --git a/guide/.vuepress/config.ts b/guide/.vuepress/config.ts index 493dcf29f..85b482bd1 100644 --- a/guide/.vuepress/config.ts +++ b/guide/.vuepress/config.ts @@ -2,6 +2,7 @@ import path from 'path'; import { defineUserConfig } from 'vuepress-vite'; import type { DefaultThemeOptions, ViteBundlerOptions } from 'vuepress-vite'; import sidebar from './sidebar'; +import container from 'markdown-it-container'; const config = defineUserConfig({ bundler: '@vuepress/vite', @@ -47,6 +48,20 @@ const config = defineUserConfig({ }, }, plugins: [], + extendsMarkdown: md => { + console.log('hello'); + md.use(container, 'typescript-tip', { + render: (tokens: { info: string, nesting: number }[], idx: number) => { + const token = tokens[idx]; + const info = token.info.trim().slice('typescript-tip'.length).trim(); + const content = info || 'TYPESCRIPT'; + if (token.nesting === 1) { + return `

${content}

\n`; + } + return `
\n`; + }, + }); + } }); const { ALGOLIA_DOCSEARCH_API_KEY, ALGOLIA_DOCSEARCH_APP_ID, GOOGLE_ANALYTICS_ID, NODE_ENV } = process.env; diff --git a/guide/.vuepress/styles/index.scss b/guide/.vuepress/styles/index.scss index 25e3e60db..f7733713d 100644 --- a/guide/.vuepress/styles/index.scss +++ b/guide/.vuepress/styles/index.scss @@ -71,3 +71,14 @@ div[class*=language-].line-numbers-mode::after { padding: 0.2em; } } + +.typescript-tip { + margin: 1rem 0; + padding: .1rem 1.5rem; + border-radius: 0.4rem; + background-color: #769FF0; + + .title { + font-weight: bold; + } +} diff --git a/guide/additional-features/cooldowns.md b/guide/additional-features/cooldowns.md index 6c20a1719..4fae64fc9 100644 --- a/guide/additional-features/cooldowns.md +++ b/guide/additional-features/cooldowns.md @@ -28,6 +28,39 @@ In your main file, initialize a [Collection](/additional-info/collections.md) to client.cooldowns = new Collection(); ``` +::::: ts-tip +You'll also need to edit the definitions for `ExtendedClient` and `SlashCommand`: +:::: code-group +::: code-group-item src/types/ExtendedClient.ts +```ts +import { Client, ClientOptions, Collection } from 'discord.js'; +import { SlashCommand } from '../types/SlashCommand'; + +export class ExtendedClient extends Client { + constructor( + options: ClientOptions, + public commands: Collection = new Collection(), + public cooldowns: Collection> = new Collection(), + ) { + super(options); + } +} +``` +::: +::: code-group-item src/types/SlashCommand.ts +```ts{6} +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; + +export interface SlashCommand { + data: SlashCommandBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; + cooldown?: number; +} +``` +::: +:::: +::::: + The key will be the command names, and the values will be Collections associating the user's id (key) to the last time (value) this user used this command. Overall the logical path to get a user's last usage of a command will be `cooldowns > command > user > timestamp`. In your `InteractionCreate` event, add the following code: @@ -55,6 +88,20 @@ try { } ``` +::: ts-tip +You'll need to use a type assertion on `interaction.client` to get the correct type: +```ts +const { cooldowns } = interaction.client as ExtendedClient; +``` +::: + +::: ts-tip +You may need to add non-null assertions around the code (notice the `!` at the end of the line): +```ts +const timestamps = cooldowns.get(command.data.name)!; +``` +::: + You check if the `cooldowns` Collection already has an entry for the command being used. If this is not the case, you can add a new entry, where the value is initialized as an empty Collection. Next, create the following variables: 1. `now`: The current timestamp. @@ -91,6 +138,58 @@ setTimeout(() => timestamps.delete(interaction.user.id), cooldownAmount); This line causes the entry for the user under the specified command to be deleted after the command's cooldown time has expired for them. + +### TypeScript +If you're using TypeScript, the setup will be similar but slightly different. + +First, if you've been following the guide so far, you have a structure called `ExtendedClient`. Similar to how we've added commands to this structure, we'll need to modify this structure to support cooldowns: +```ts {5-8} +import { Client, ClientOptions, Collection } from 'discord.js'; +import { SlashCommand } from '../types/SlashCommand'; + +export class ExtendedClient extends Client { + constructor(options: ClientOptions, + public commands: Collection = new Collection(), + public cooldowns: Collection> = new Collection(), + ) { + super(options); + } +} +``` + +Next, we'll need to modify `SlashCommand.ts`: +```ts {4} +import { SlashCommandBuilder } from 'discord.js'; + +export interface SlashCommand { + cooldown?: number; + data: SlashCommandBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; +} +``` + +Finally, we'll need to add cooldowns to each command that should support one: +```ts {5} +import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommand } from '../../types/SlashCommand'; + +const command: SlashCommand = { + cooldown: 5, + data: new SlashCommandBuilder() + .setName('ping') + .setDescription('Replies with Pong!'), + async execute(interaction) { + // ... + }, +}; + +export default command; +``` + +The rest of the code in the `InteractionCreate` event is almost the same as the JavaScript code shown above, with one small change -- you'll need to use a type assertion to get the correct `ExtendedClient` type: +```ts +const { cooldowns } = interaction.client as ExtendedClient; +``` ## Resulting code \ No newline at end of file diff --git a/guide/creating-your-bot/README.md b/guide/creating-your-bot/README.md index 3f7c9b6d2..ddc2496bd 100644 --- a/guide/creating-your-bot/README.md +++ b/guide/creating-your-bot/README.md @@ -31,6 +31,48 @@ console.log(token); If you're using Git, you should not commit this file and should [ignore it via `.gitignore`](/creating-your-bot/#git-and-gitignore). ::: +::: typescript-tip +You'll also want to create an interface for your config so you can get type checking when using config values. Create a file called `Config.ts` under `src/types` with the following contents: +```ts +export interface Config { + token: string; + guildId: string; + // Other values can be added here +} + +// Set up a rudimentary assertion function to type assert values +export function assertObjectIsConfig(obj: unknown): asserts obj is Config { + if (obj === null || obj === undefined) { + throw new TypeError('config cannot be null/undefined.'); + } + + const expectedValues = [ + { + key: 'token', + type: 'string', + }, + { + key: 'guildId', + type: 'string', + }, + ]; // Add more keys if necessary + + if (typeof obj !== 'object') { + throw new TypeError('config must be an object.'); + } + + for (const { key, type } of expectedValues) { + const value = (obj as Record)[key]; + if (typeof value !== type) { + throw new TypeError(`Expected '${key}' to be of type '${type}', but received '${typeof value}'`); + } + } +} +``` + +Note that we manually validated the `obj` parameter in the assertion function `objectIsConfig`. A cleaner, more robust approach would be to use a validation library to handle this, though this is outside the scope of this guide. +::: + ## Using environment variables Environment variables are special values for your environment (e.g., terminal session, Docker container, or environment variable file). You can pass these values into your code's scope so that you can use them. diff --git a/guide/creating-your-bot/command-deployment.md b/guide/creating-your-bot/command-deployment.md index a1497269b..f842c29e5 100644 --- a/guide/creating-your-bot/command-deployment.md +++ b/guide/creating-your-bot/command-deployment.md @@ -39,10 +39,16 @@ Add two more properties to your `config.json` file, which we'll need in the depl } ``` +::: typescript-tip +Don't forget to update `src/types/Config.ts` and add the additional properties to the type declaration. +::: + With these defined, you can use the deployment script below: +:::: code-group +::: code-group-item js ```js const { REST, Routes } = require('discord.js'); const { clientId, guildId, token } = require('./config.json'); @@ -91,6 +97,65 @@ const rest = new REST().setToken(token); } })(); ``` +::: +::: code-group-item ts +```ts +import { REST, Routes } from 'discord.js'; +import { Config, assertObjectIsConfig } from './types/Config'; +import fs from 'node:fs'; +import path from 'node:path'; + +const configRaw = fs.readFileSync('./config.json', { encoding: 'utf-8' }); +const config = JSON.parse(configRaw); + +assertObjectIsConfig(config); + +const { clientId, guildId, token } = config; + +(async () => { + const commands = []; + // Grab all the command folders from the commands directory you created earlier + const foldersPath = path.join(__dirname, 'commands'); + const commandFolders = fs.readdirSync(foldersPath); + + for (const folder of commandFolders) { + // Grab all the command files from the commands directory you created earlier + const commandsPath = path.join(foldersPath, folder); + const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); + // Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const { default: command } = await import(filePath); + if ('data' in command && 'execute' in command) { + commands.push(command.data.toJSON()); + } else { + console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); + } + } + } + + // Construct and prepare an instance of the REST module + const rest = new REST().setToken(token); + + // and deploy your commands! + try { + console.log(`Started refreshing ${commands.length} application (/) commands.`); + + // The put method is used to fully refresh all commands in the guild with the current set + const data = await rest.put( + Routes.applicationGuildCommands(clientId, guildId), + { body: commands }, + ); + + console.log(`Successfully reloaded ${data.length} application (/) commands.`); + } catch (error) { + // And of course, make sure you catch and log any errors! + console.error(error); + } +})(); +``` +::: +:::: Once you fill in these values, run `node deploy-commands.js` in your project directory to register your commands to the guild specified. If you see the success message, check for the commands in the server by typing `/`! If all goes well, you should be able to run them and see your bot's response in Discord! diff --git a/guide/creating-your-bot/command-handling.md b/guide/creating-your-bot/command-handling.md index 896f5277c..98a3ecb63 100644 --- a/guide/creating-your-bot/command-handling.md +++ b/guide/creating-your-bot/command-handling.md @@ -18,8 +18,32 @@ This page details how to complete **Step 2**. Make sure to also complete the oth Now that your command files have been created, your bot needs to load these files on startup. -In your `index.js` file, make these additions to the base template: +In your `index.js` or `index.ts` file, make these additions to the base template: + +::::: typescript-tip +TypeScript will not let you attach a `commands` property to your client instance without some work. We'll need to extend the base `Client` class and create our own `ExtendedClient` class with the additional properties we need. + +Add the following file: +:::: code-group +::: code-group-item src/structures/ExtendedClient.ts +```ts +import { Client, ClientOptions, Collection } from 'discord.js'; +import { SlashCommand } from '../types/SlashCommand'; + +export class ExtendedClient extends Client { + constructor(options: ClientOptions, public commands: Collection = new Collection()) { + super(options); + } +} +``` + +This class can be instantiated just like the `Client` class, except it also accepts a second `commands` parameter. This parameter will default to an empty `Collection` if nothing is passed as an argument. +::: +:::: +::::: +:::: code-group +::: code-group-item js ```js {1-3,8} const fs = require('node:fs'); const path = require('node:path'); @@ -30,8 +54,31 @@ const client = new Client({ intents: [GatewayIntentBits.Guilds] }); client.commands = new Collection(); ``` +::: +:::code-group-item ts +```ts {1-2} +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { Client, Events, GatewayIntentBits } from 'discord.js'; +import { ExtendedClient } from './structures/ExtendedClient'; +import { Config, assertObjectIsConfig } from './types/Config'; + +// Read the config file +const configRaw = fs.readFileSync('../config.json', { encoding: 'utf-8' }); +const config = JSON.parse(configRaw); + +assertObjectIsConfig(config); + + +const { token } = config; + +// ExtendedClient's second `commands` parameter defaults to an empty Collection +const client = new ExtendedClient({ intents: [GatewayIntentBits.Guilds] }); +``` +::: +:::: -We recommend attaching a `.commands` property to your client instance so that you can access your commands in other files. The rest of the examples in this guide will follow this convention. For TypeScript users, we recommend extending the base Client class to add this property, [casting](https://www.typescripttutorial.net/typescript-tutorial/type-casting/), or [augmenting the module type](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). +We recommend attaching a `.commands` property to your client instance so that you can access your commands in other files. The rest of the examples in this guide will follow this convention. ::: tip - The [`fs`](https://nodejs.org/api/fs.html) module is Node's native file system module. `fs` is used to read the `commands` directory and identify our command files. @@ -41,6 +88,8 @@ We recommend attaching a `.commands` property to your client instance so that yo Next, using the modules imported above, dynamically retrieve your command files with a few more additions to the `index.js` file: +:::: code-group +::: code-group-item js ```js {3-19} client.commands = new Collection(); @@ -62,6 +111,36 @@ for (const folder of commandFolders) { } } ``` +::: +::: code-group-item ts +```ts {3-19} +const foldersPath = path.join(__dirname, '../build/commands'); +const commandFolders = fs.readdirSync(foldersPath); + +for (const folder of commandFolders) { + const commandsPath = path.join(foldersPath, folder); + const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + import(filePath).then(module => { + const command = module.default; + // Set a new item in the Collection with the key as the command name and the value as the exported module + if ('data' in command && 'execute' in command) { + client.commands.set(command.data.name, command); + } else { + console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); + } + }); + } +} + +``` +::: +:::: + +::: typescript-tip +The code above may require slight modifications depending on your `tsconfig.json`. In particular, if the `module` option is changed from the default `commonjs` value to one of the ESM values, you will need to change the `filePath` inside the `import()` call, as ESM does not support importing modules from absolute paths without a `file:///` prefix. You will also need to use `import.meta.dirname` instead of `__dirname`. Your mileage may vary! +::: First, [`path.join()`](https://nodejs.org/api/path.html) helps to construct a path to the `commands` directory. The first [`fs.readdirSync()`](https://nodejs.org/api/fs.html#fs_fs_readdirsync_path_options) method then reads the path to the directory and returns an array of all the folder names it contains, currently `['utility']`. The second `fs.readdirSync()` method reads the path to this directory and returns an array of all the file names they contain, currently `['ping.js', 'server.js', 'user.js']`. To ensure only command files get processed, `Array.filter()` removes any non-JavaScript files from the array. diff --git a/guide/creating-your-bot/event-handling.md b/guide/creating-your-bot/event-handling.md index 17f700d78..cef475f98 100644 --- a/guide/creating-your-bot/event-handling.md +++ b/guide/creating-your-bot/event-handling.md @@ -53,6 +53,8 @@ You're only going to move these two events from `index.js`. The code for [loadin Your project directory should look something like this: +:::: code-group +::: code-group-item js ```:no-line-numbers discord-bot/ ├── commands/ @@ -63,6 +65,23 @@ discord-bot/ ├── package-lock.json └── package.json ``` +::: +::: code-group-item ts +```:no-line-numbers +discord-bot/ +├── node_modules +├── src + ├── types + ├── Config.ts + ├── index.ts +├── config.json +├── tsconfig.json +├── package-lock.json +└── package.json +``` +::: +:::: + Create an `events` folder in the same directory. You can then move the code from your event listeners in `index.js` to separate files: `events/ready.js` and `events/interactionCreate.js`. @@ -140,6 +159,80 @@ for (const folder of commandFolders) { client.login(token); ``` ::: +::: code-group-item src/types/EventHandler.ts +```ts +import { BaseInteraction, Events } from 'discord.js'; + +export interface EventHandler { + name: Events; + once?: boolean; + execute: (...args: unknown[]) => Promise; +} +``` +::: +::: code-groupitem src/events/ready.ts +```ts +import { Events } from 'discord.js'; +import { EventHandler } from '../types/EventHandler'; + +const eventHandler: EventHandler = { + name: Events.ClientReady, + once: true, + execute(client) { + console.log(`Ready! Logged in as ${client.user.tag}`); + }, +}; +``` +::: +::: code-group-item events/ready.ts +```js +import { Events } from 'discord.js'; +import { EventHandler } from '../../types/EventHandler'; + +const eventHandler: EventHandler = { + name: Events.ClientReady, + once: true, + execute(client) { + console.log(`Ready! Logged in as ${client.user.tag}`); + }, +}; + +export default eventHandler; +``` +::: +::: code-group-item src/events/interactionCreate.ts +```ts +import { Events } from 'discord.js'; +import { EventHandler } from '../types/EventHandler'; + +const eventHandler: EventHandler = { + name: Events.InteractionCreate, + async execute(interaction) { + if (!interaction.isChatInputCommand()) return; + + const command = interaction.client.commands.get(interaction.commandName); + + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true }); + } else { + await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); + } + } + }, +} + +export default eventHandler; +``` +::: :::: The `name` property states which event this file is for, and the `once` property holds a boolean value that specifies if the event should run only once. You don't need to specify this in `interactionCreate.js` as the default behavior will be to run on every event instance. The `execute` function holds your event logic, which will be called by the event handler whenever the event emits. @@ -150,6 +243,8 @@ Next, let's write the code for dynamically retrieving all the event files in the `fs.readdirSync().filter()` returns an array of all the file names in the given directory and filters for only `.js` files, i.e. `['ready.js', 'interactionCreate.js']`. +:::: code-group +::: code-group-item js ```js {26-37} const fs = require('node:fs'); const path = require('node:path'); @@ -191,6 +286,66 @@ for (const file of eventFiles) { client.login(token); ``` +::: +::: code-group-item ts +```ts +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { Client, Events, GatewayIntentBits } from 'discord.js'; +import { ExtendedClient } from './structures/ExtendedClient'; +import { Config, assertObjectIsConfig } from './types/Config'; + +// Read the config file +const configRaw = fs.readFileSync('../config.json', { encoding: 'utf-8' }); +const config = JSON.parse(configRaw); + +assertObjectIsConfig(config); + +const { token } = config; + +// ExtendedClient's second `commands` parameter defaults to an empty Collection +const client = new ExtendedClient({ intents: [GatewayIntentBits.Guilds] }); + +const foldersPath = path.join(__dirname, '../build/commands'); +const commandFolders = fs.readdirSync(foldersPath); + +for (const folder of commandFolders) { + const commandsPath = path.join(foldersPath, folder); + const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + import(filePath).then(module => { + const command = module.default; + // Set a new item in the Collection with the key as the command name and the value as the exported module + if ('data' in command && 'execute' in command) { + client.commands.set(command.data.name, command); + } else { + console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); + } + }); + } +} + +const eventsPath = path.join(__dirname, '../build/events'); +const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js')); + +for (const file of eventFiles) { + const filePath = path.join(eventsPath, file); + import(filePath).then(module => { + const event = module.default; + + if (event.once) { + client.once(event.name, (...args) => event.execute(...args)); + } else { + client.on(event.name, (...args) => event.execute(...args)); + } + }); +} + +client.login(token); +``` +::: +:::: You'll notice the code looks very similar to the command loading above it - read the files in the events folder and load each one individually. diff --git a/guide/creating-your-bot/main-file.md b/guide/creating-your-bot/main-file.md index ea1ea60ab..93558504e 100644 --- a/guide/creating-your-bot/main-file.md +++ b/guide/creating-your-bot/main-file.md @@ -8,6 +8,8 @@ Open your code editor and create a new file. We suggest that you save the file a Here's the base code to get you started: +:::: code-group +::: code-group-item js ```js // Require the necessary discord.js classes const { Client, Events, GatewayIntentBits } = require('discord.js'); @@ -26,6 +28,39 @@ client.once(Events.ClientReady, readyClient => { // Log in to Discord with your client's token client.login(token); ``` +::: +::: code-group-item ts +```ts +// Import the necessary discord.js classes +import { readFileSync } from 'node:fs'; +import { Client, Events, GatewayInventBits } from 'discord.js'; +import { Config } from './src/types/Config'; + +// Read the config file +const configRaw = fs.readFileSync('../config.json', { encoding: 'utf-8' }); +const configUnsafe = JSON.parse(configRaw); +const config: Config = JSON.parse(configRaw); + +const { token } = config; + +// Create a new client instance +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +// When the client is ready, run this code (only once). +// The distinction between `client: Client` and `readyClient: Client` is important for TypeScript developers. +// It makes some properties non-nullable. +client.once(Events.ClientReady, readyClient => { + console.log(`Ready! Logged in as ${readyClient.user.tag}`); +}); + +// Log in to Discord with your client's token +client.login(token); +``` +:::: + +::: typescript-tip +When using Typescript, the code will look slightly different. First, TypeScript uses ECMAScript Modules, not CommonJS Modules, which means the module import/export syntax is different. Additionally, Node's default ESM loader does not support importing JSON files into objects. Because of this, we'll need to use the `fs` module to read the file, and then use `JSON.parse` to turn the read JSON string into an object. +::: This is how you create a client instance for your Discord bot and log in to Discord. The `GatewayIntentBits.Guilds` intents option is necessary for the discord.js client to work as you expect it to, as it ensures that the caches for guilds, channels, and roles are populated and available for internal use. @@ -45,6 +80,10 @@ You can open your `package.json` file and edit the `"main": "index.js"` field to After closing the process with `Ctrl + C`, you can press the up arrow on your keyboard to bring up the latest commands you've run. Pressing up and then enter after closing the process is a quick way to start it up again. ::: +::: typescript-tip +You'll need to run the compiled code, not the TypeScript code. If following the guide so far, you'll have a `package.json` with a `scripts` entry called `start` that starts the application. Call this script by running `npm run start`. +::: + #### Resulting code \ No newline at end of file diff --git a/guide/creating-your-bot/slash-commands.md b/guide/creating-your-bot/slash-commands.md index dececbda8..1baa9ad4c 100644 --- a/guide/creating-your-bot/slash-commands.md +++ b/guide/creating-your-bot/slash-commands.md @@ -26,6 +26,8 @@ Slash commands provide a huge number of benefits over manual message parsing, in Assuming you've followed the guide so far, your project directory should look something like this: +:::: code-group +::: code-group-item js ```:no-line-numbers discord-bot/ ├── node_modules @@ -34,6 +36,23 @@ discord-bot/ ├── package-lock.json └── package.json ``` +::: + +::: code-group-item ts +```:no-line-numbers +discord-bot/ +├── node_modules +├── src + ├── types + ├── Config.ts + ├── index.ts +├── config.json +├── tsconfig.json +├── package-lock.json +└── package.json +``` +::: +:::: ::: tip For fully functional slash commands, there are three important pieces of code that need to be written. They are: @@ -64,7 +83,6 @@ A slash command also requires a function to run when the command is used, to res The simplest way to acknowledge and respond to an interaction is the `interaction.reply()` method. Other methods of replying are covered on the [Response methods](/slash-commands/response-methods.md) page later in this section. - ```js async execute(interaction) { await interaction.reply('Pong!') @@ -77,6 +95,22 @@ Put these two together by creating a `ping.js` file in the `commands/utility` fo These are placed inside `module.exports` so they can be read by other files; namely the command loader and command deployment scripts mentioned earlier. +::: typescript-tip +To assist in type checking, create a type file for your slash command exports: +:::: code-group +::: code-group-item src/types/SlashCommand.ts +```ts +import { SlashCommandBuilder } from 'discord.js'; + +export interface SlashCommand { + data: SlashCommandBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; +} +``` +::: +:::: +::: + :::: code-group ::: code-group-item commands/utility/ping.js ```js @@ -92,6 +126,23 @@ module.exports = { }; ``` ::: +::: code-group-item src/commands/utility/ping.ts +```ts +import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommand } from '../../types/SlashCommand'; + +const command: SlashCommand = { + data: new SlashCommandBuilder() + .setName('ping') + .setDescription('Replies with Pong!'), + async execute(interaction) { + await interaction.reply('Pong!'); + }, +}; + +export default command; +``` +::: :::: ::: tip @@ -100,6 +151,10 @@ module.exports = { If you need to access your client instance from inside a command file, you can access it via `interaction.client`. If you need to access external files, packages, etc., you should `require()` them at the top of the file. ::: +::: typescript-tip +TypeScript uses ECMAScript module syntax to export/import data between files. Instead of using `require()`, use `import` and `export` to share code and data between files. +::: + That's it for your basic ping command. Below are examples of two more commands we're going to build upon throughout the guide, so create two more files for these before you continue reading. :::: code-group @@ -134,6 +189,43 @@ module.exports = { }; ``` ::: +::: code-group-item src/commands/utility/user.ts +```ts +import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommand } from '../../types/SlashCommand'; + +const command: SlashCommand = { + data: new SlashCommandBuilder() + .setName('user') + .setDescription('Provides information about the user.'), + async execute(interaction) { + // interaction.user is the object representing the User who ran the command + // interaction.member is the GuildMember object, which represents the user in the specific guild + await interaction.reply(`This command was run by ${interaction.user.username}, who joined on ${interaction.member.joinedAt}.`); + }, +}; + +export default command; +``` +::: +::: code-group-item src/commands/utility/server.ts +```js +import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommand } from '../../types/SlashCommand'; + +const command: SlashCommand = { + data: new SlashCommandBuilder() + .setName('server') + .setDescription('Provides information about the server.'), + async execute(interaction) { + // interaction.guild is the object representing the Guild in which the command was run + await interaction.reply(`This server is ${interaction.guild.name} and has ${interaction.guild.memberCount} members.`); + }, +}; + +export default command; +``` +::: :::: #### Next steps diff --git a/guide/preparations/README.md b/guide/preparations/README.md index ffa3aed64..5eff2cb6e 100644 --- a/guide/preparations/README.md +++ b/guide/preparations/README.md @@ -123,3 +123,79 @@ And that's it! With all the necessities installed, you're almost ready to start ## Installing a linter While you are coding, it's possible to run into numerous syntax errors or code in an inconsistent style. You should [install a linter](/preparations/setting-up-a-linter.md) to ease these troubles. While code editors generally can point out syntax errors, linters coerce your code into a specific style as defined by the configuration. While this is not required, it is advised. + +## TypeScript + +### Installation + +Discord.js comes with TypeScript support out of the box. If you want to use TypeScript, you'll need to additionally install TypeScript in your project: + +:::: code-group +::: code-group-item npm +```sh:no-line-numbers +npm install typescript +``` +::: +::: code-group-item yarn +```sh:no-line-numbers +yarn add typescript +``` +::: +::: code-group-item pnpm +```sh:no-line-numbers +pnpm add typescript +``` +::: +:::: + +Next, you'll need to create a `tsconfig.json` file, which is used to configure the TypeScript environment in a project. A `tsconfig.json` with reasonable defaults can be generated using the following command: + +:::: code-group +::: code-group-item npm +```sh:no-line-numbers +npx tsc --init +``` +::: +::: code-group-item yarn +```sh:no-line-numbers +yarn exec tsc --init +``` +::: +::: code-group-item pnpm +```sh:no-line-numbers +pnpm exec tsc --init +``` +::: +:::: + +### Project organization + +The default `tsconfig.json` is mostly usable out of the box, but there's one particular change that can should be made. If you check the commented `outDir` option, you'll see that it defaults to emitting compiled code into the project root. To keep things organized, change this to `"./build"`. This will tell the compiler to emit all built code into the `build` folder. + +Another option that should be changed is the `rootDir` option, which defaults to the project root. Uncomment the option and set it to `"./src"`. + +::: tip +Feel free to explore all the other options that can be configured in the `tsconfig.json`. If generated with the above command, the file will contain comments explaining each option. +::: + +### package.json scripts + +Because TypeScript compiles down to JavaScript before execution, we'll need to add a build step to our project. The build step will be simple: all it will do is call `tsc`. The compiler will automatically pick up the `tsconfig.json`. To add the build step, add a `build` property with value `tsc` under the `scripts` object in your `package.json`. + +In addition to adding a build step, we won't be able to execute the TypeScript code directly, so for convenience, add another value in the `scripts` object that simple starts up node with the main file. + +Your final `package.json` should look similar to this: + +```jsonc{7,8} +{ + "name": "CoolBot", + "version": "1.0.0", + "description": "This project is for a very cool bot.", + "main": "build/index.js", + "scripts": { + "build": "tsc", + "start": "node ./build/index.js" + }, + // ... +} +``` \ No newline at end of file diff --git a/guide/preparations/setting-up-a-linter.md b/guide/preparations/setting-up-a-linter.md index 5c9b90774..5a3368a85 100644 --- a/guide/preparations/setting-up-a-linter.md +++ b/guide/preparations/setting-up-a-linter.md @@ -134,3 +134,85 @@ The major points of this setup would be: * Limiting nested callbacks to 4. If you hit this error, it is a good idea to consider refactoring your code. If your current code style is a bit different, or you don't like a few of these rules, that's perfectly fine! Just head over to the [ESLint docs](https://eslint.org/docs/rules/), find the rule(s) you want to modify, and change them accordingly. + +::: TypeScript + +Linting TypeScript code with ESLint requires a bit more work. First, you'll want to install the required packages: + +:::: code-group +::: code-group-item npm +```sh:no-line-numbers +npm install --save-dev eslint @eslint/js @types/eslint__js typescript typescript-eslint +``` +::: +::: code-group-item yarn +```sh:no-line-numbers +yarn add --dev eslint @eslint/js @types/eslint__js typescript typescript-eslint +``` +::: +::: code-group-item pnpm +```sh:no-line-numbers +pnpm add --save-dev eslint @eslint/js @types/eslint__js typescript typescript-eslint +``` +::: +:::: + +Your `.eslintrc.json` file will also look slightly different: + +```json {2-7} +{ + "extends": [ + "eslint:recommended" + "plugin:@typescript-eslint/recommended-type-checked", + "plugin:@typescript-eslint/stylistic-type-checked", + ], + "env": { + "node": true, + "es6": true + }, + "parserOptions": { + "ecmaVersion": 2021 + }, + "rules": { + "arrow-spacing": ["warn", { "before": true, "after": true }], + "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], + "comma-dangle": ["error", "always-multiline"], + "comma-spacing": "error", + "comma-style": "error", + "curly": ["error", "multi-line", "consistent"], + "dot-location": ["error", "property"], + "handle-callback-err": "off", + "indent": ["error", "tab"], + "keyword-spacing": "error", + "max-nested-callbacks": ["error", { "max": 4 }], + "max-statements-per-line": ["error", { "max": 2 }], + "no-console": "off", + "no-empty-function": "error", + "no-floating-decimal": "error", + "no-inline-comments": "error", + "no-lonely-if": "error", + "no-multi-spaces": "error", + "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], + "no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }], + "no-trailing-spaces": ["error"], + "no-var": "error", + "object-curly-spacing": ["error", "always"], + "prefer-const": "error", + "quotes": ["error", "single"], + "semi": ["error", "always"], + "space-before-blocks": "error", + "space-before-function-paren": ["error", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }], + "space-in-parens": "error", + "space-infix-ops": "error", + "space-unary-ops": "error", + "spaced-comment": "error", + "yoda": "error" + } +} +``` + +Note that the only difference is that extra base configs were added to the `extends` property. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 77f34e83d..e52321f4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "eslint-config-sora": "^3.1.0", "eslint-plugin-markdown": "^1.0.0", "eslint-plugin-vue": "^7.5.0", + "markdown-it-container": "^4.0.0", "vuepress-vite": "^2.0.0-beta.24" } }, @@ -1334,6 +1335,12 @@ "markdown-it-container": "^3.0.0" } }, + "node_modules/@vuepress/plugin-container/node_modules/markdown-it-container": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-3.0.0.tgz", + "integrity": "sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==", + "dev": true + }, "node_modules/@vuepress/plugin-docsearch": { "version": "2.0.0-beta.24", "resolved": "https://registry.npmjs.org/@vuepress/plugin-docsearch/-/plugin-docsearch-2.0.0-beta.24.tgz", @@ -4102,9 +4109,9 @@ } }, "node_modules/markdown-it-container": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-3.0.0.tgz", - "integrity": "sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-4.0.0.tgz", + "integrity": "sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==", "dev": true }, "node_modules/markdown-it-emoji": { @@ -7082,6 +7089,14 @@ "@vuepress/utils": "2.0.0-beta.24", "markdown-it": "^12.2.0", "markdown-it-container": "^3.0.0" + }, + "dependencies": { + "markdown-it-container": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-3.0.0.tgz", + "integrity": "sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==", + "dev": true + } } }, "@vuepress/plugin-docsearch": { @@ -9092,9 +9107,9 @@ "requires": {} }, "markdown-it-container": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-3.0.0.tgz", - "integrity": "sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-4.0.0.tgz", + "integrity": "sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==", "dev": true }, "markdown-it-emoji": { diff --git a/package.json b/package.json index 882718451..4bb94e8e6 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,14 @@ }, "homepage": "https://discordjs.guide", "devDependencies": { + "@babel/eslint-parser": "^7.17.0", "@vuepress/plugin-docsearch": "^2.0.0-beta.24", "@vuepress/plugin-google-analytics": "^2.0.0-beta.24", "eslint": "^7.18.0", "eslint-config-sora": "^3.1.0", "eslint-plugin-markdown": "^1.0.0", "eslint-plugin-vue": "^7.5.0", - "@babel/eslint-parser": "^7.17.0", + "markdown-it-container": "^4.0.0", "vuepress-vite": "^2.0.0-beta.24" }, "dependencies": {