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;
}
}
99 changes: 99 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 Expand Up @@ -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<string, SlashCommand> = new Collection(),
public cooldowns: Collection<string, Collection<string, Date>> = 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<void>;
}
```

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

<ResultingCode path="additional-features/cooldowns" />
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 '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!

Expand Down
Loading