Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/typescript: add TypeScript examples to the guide #1560

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
15 changes: 15 additions & 0 deletions guide/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DefaultThemeOptions, ViteBundlerOptions>({
bundler: '@vuepress/vite',
Expand Down Expand Up @@ -47,6 +48,20 @@ const config = defineUserConfig<DefaultThemeOptions, ViteBundlerOptions>({
},
},
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 `<div class="typescript-tip custom-block"><p class="custom-block-title">${content}</p>\n`;
}
return `</div>\n`;
},
});
}
});

const { ALGOLIA_DOCSEARCH_API_KEY, ALGOLIA_DOCSEARCH_APP_ID, GOOGLE_ANALYTICS_ID, NODE_ENV } = process.env;
Expand Down
11 changes: 11 additions & 0 deletions guide/.vuepress/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
47 changes: 47 additions & 0 deletions guide/additional-features/cooldowns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SlashCommand> = new Collection(),
public cooldowns: Collection<string, Collection<string, number>> = 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<void>;
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:
Expand Down Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions guide/creating-your-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[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.
Expand Down
65 changes: 65 additions & 0 deletions guide/creating-your-bot/command-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<!-- eslint-skip -->

:::: code-group
::: code-group-item js
```js
const { REST, Routes } = require('discord.js');
const { clientId, guildId, token } = require('./config.json');
Expand Down Expand Up @@ -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 'fs';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import fs from 'fs';
import fs from 'node:fs';

import path from 'path';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import path from 'path';
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!

Expand Down
83 changes: 81 additions & 2 deletions guide/creating-your-bot/command-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SlashCommand> = 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');
Expand All @@ -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 'fs';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { readFileSync } from 'fs';
import { readFileSync } from 'node:fs';

import path from 'path';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import path from 'path';
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.
Expand All @@ -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();

Expand All @@ -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.

Expand Down
Loading