A TypeScript template for a Discord bot, now powered by Deno!
- 🟦 TypeScript
- 💬 Slash commands
- 📆 Event handlers
- 📄 Built-in pagination
- ❔ Automatic help command
- ⏳ Command cooldowns
- 🚗 Support for autocomplete
- 🆔 Interaction ID handling
- 🗞️ Advanced logging
- ✅ Action confirmations
Some knowledge of TypeScript and Discord.js is recommended, and you'll need the following installed:
- Deno (v2.0 or higher)
If you are unaware, Deno is an alternative to Node.js, and is used in this template to run the bot. If you want to know more about Deno and how it compares to Node.js, you can read the Deno documentation.
You will need a Discord bot token, which you can get by creating a new application in the Discord developer portal by following the steps outlined in the Discord developer documentation.
To get started, create a new repository from this template, as explained in the GitHub docs.
Set the required environment variables (see section Environment variables). Then run the following commands to install the dependencies and start the bot (in development mode):
deno install
deno task dev
An example .env
file is provided under example.env
. You can copy this file and rename it to .env
to get started.
Environment variables are encapsulated in the ENV
object, located in the src/env.ts
file.
When adding a new environment variable, you should add it to the ENV
object as well.
This will check that the environment variable is set, and throw an error if it is not.
You should provide at least the following environment variables:
BOT_TOKEN
: the token of your botTEST_GUILD
: the ID of the guild to register slash commands in during development
In case you want to use a separate bot token for development, you can provide it in the TEST_TOKEN
environment variable.
If no TEST_TOKEN
is provided, the BOT_TOKEN
will be used for development as well.
To ensure consistent styling throughout the bot's embeds and messages, some definitions are provided in the src/utils/style.ts
file.
Currently, this includes the following:
COLORS
: common colors used for specific purposes (e.g. to make sure embeds always have the same color)EMOJIS
: common emojis for specific purposes (e.g. to indicate success or error)
The EMOJIS
constant is currently being used to add emojis to the reply messages created through the reply()
function (see the Interaction replies section), as well as some of the log messages sent to Discord wehbooks (if enabled; see the Logging section).
The COLORS
constant is only being used to give the same color to every embed the bot sends out, namely COLORS.embed
. The warn
and error
colors are not being used anywhere, but are provided as examples.
You are, of course, free to change these, or add new ones and new fields to the existing ones, to customize the bot to your liking.
Commands are located in the src/commands
folder.
Commands are grouped into categories, each of which has its own folder.
The index.ts
file in each folder exports the commands in that category and provides some metadata about the category, which includes at least the name of the category, and optionally a description and emoji (which will be used for example in the /help
commmand).
The index.ts file imports the categories from the various folders and exports them as a single array, which is used to register them in the src/events/interactionCreate/commands.ts
file.
Each command gets its own file, and consists of a meta
object, built using a SlashCommandBuilder
from discord.js
, and an exec
function, which is invoked when the command is executed.
This exec
function is passed a context containing the bot client, a Logger instance (instantiated with the command name; see the Logging section), and the interaction itself.
Note that errors thrown in the exec
function will be caught and logged automatically, so you don't have to worry about catching them yourself (unless you want to customize the message shown to the user upon an error, for which the default is imply "Oops. Something went wrong!").
Do, however, make sure that you await asynchronous functions, such that errors thrown inside such functions will be caught as well!
You can add additional fields to the command context by modifying the CommandContext
type in the src/types/commands.ts
file and adding the new fields to the call to command.exec(...)
call in the src/events/interactionCreate/command.ts
file.
You may ask yourself why you would propagate a context like this through the event and command executions.
The reason, essentially, is to avoid having to import the client and logger in every file, but you are free to import them in every file if you prefer.
A command file should look something like this:
import { SlashCommandBuilder } from 'discord.js';
import { command, reply } from 'utils';
const meta = new SlashCommandBuilder()
.setName('example')
.setDescription('Example command.');
export default command({
meta,
exec: async ({ interaction }) => {
await reply(interaction, {
ephemeral: true,
content: 'Hello world!',
});
},
});
A /help
command which automatically generates an embed that allows for navigating through all of the commands (and their subcommands and subcommand groups!) is provided out of the box.
More information about this command can be found in the Help menu section.
The command()
function takes an object containing at least the meta
object created using the SlashCommandBuilder
from discord.js
and the exec
function responsible for executing the command.
There's some optional fields that can be passed into the object to further configure the command.
The following optional fields are available:
private
: whether the command should be private to the test guild (default:false
)adminOnly
: whether the command should only be available to users with theADMINISTRATOR
permission (default:false
)guildOnly
: whether the command should only be available in guilds and not in DMs (default:false
)cooldown
: the cooldown of the command, in seconds, or as an object withseconds
andscope
(user-specific or guild-wide) fields (see the Command cooldowns section)
When a command is private, it will only be registered in the test guild, never in any other servers. This could be useful for commands that you, as the bot creator, want to use, but do not want others to use.
Subcommands are supported by the SlashCommandBuilder
from discord.js
, and can be added to a command by calling the addSubcommand()
or addSubcommandGroup()
methods on the SlashCommandBuilder
object.
The addSubcommand()
method takes a SlashCommandSubcommandBuilder
object, which is created in the same way as the SlashCommandBuilder
object, and the addSubcommandGroup()
method takes a SlashCommandSubcommandGroupBuilder
object, which is created in the same way as the SlashCommandBuilder
object, but with the addition of the addSubcommand()
method.
Determining which subcommand was used is done by using interaction.options.getSubcommand()
and interaction.options.getSubcommandGroup()
, which return the name of the subcommand or subcommand group, respectively.
An example of the usage of subcommands can be found in the src/commands/general/info.ts
file.
Remember, subcommands will be picked up automatically by the help command, however deep they are nested in subcommand groups!
Command cooldowns can be set using the cooldown
field in the command's options, as shown in the below snippet.
This can be a number, representing the cooldown in seconds, or an object with seconds
and scope
fields.
The scope
field can be either 'user'
or 'guild'
, and determines whether the cooldown is user-specific or guild-wide.
If the scope
field is not provided, the cooldown will be user-specific by default.
export default command(
// This command has a cooldown of 30 seconds, and works on a per-user basis
{ meta, cooldown: 30 /* ... */ },
);
export default command(
// This command has a cooldown of 60 seconds, and works on a guild-wide basis
{ meta, cooldown: { seconds: 60, scope: 'guild' } /* ... */ },
);
When a command is on cooldown, the bot will reply with a message indicating this, and the command will not be executed. Notably, server members with the admin permission automatically bypass the cooldown, but their command usage will still be recorded. That is, if the cooldown is guild-wide, an admin may use the command however often they want, but after an admin uses the command, it will still be on cooldown for non-admin members.
The logic for command cooldowns is located in the src/events/interactionCreate/commands.ts
file, in the checkCooldown
function.
Support for autocomplete, as described in this discord.js
guide, is provided in the template.
You can simply enable autocomplete for a command option by using setAutocomplete(true)
on a SlashCommandStringOption
.
You have to provide the autcompletion yourself (see the guide linked above), in an event handler specified under the autocomplete
field in the argument of the command()
function (similar to how you would define the exec
function).
For instance:
const meta = new SlashCommandBuilder()
.setName('example')
.setDescription('Example command.')
.addStringOption((option) =>
option
.setName('example-option')
.setDescription('Example option.')
.setRequired(true)
.setAutocomplete(true),
);
export default command({
meta,
exec: () => {
// ...
},
autocomplete: async ({ interaction }) => {
const focusedValue = interaction.options.getFocused();
const choices = [
'Popular Topics: Threads',
'Sharding: Getting started',
'Library: Voice Connections',
'Interactions: Replying to slash commands',
'Popular Topics: Embed preview',
];
const filtered = choices.filter((choice) =>
choice.toLowerCase().startsWith(focusedValue.toLowerCase()),
);
await interaction.respond(
filtered.map((choice) => ({ name: choice, value: choice })),
);
},
});
The logic to delegate autocomplete interactions to their respective event handlers is located in the src/events/interactionCreate/commands.ts
file.
Events are located in the src/events
folder.
The index.ts file exports all event handlers, which is used to register them in the src/index.ts
file, which in turn calls the registerEvents
function from src/utils/event.ts
.
All interactionCreate
events are grouped together in the src/events/interactionCreate
folder, and are exported in the corresponding index.ts
file.
When adding new events, it is recommended to group them in a similar way.
Each event handler consists of the event name and a listener function, which is called when the event is triggered. The listener function recieves a context with the bot client and a Logger instance (instantiated with the event name; see the Logging section), as well as a list of arguments specific to the event.
An event handler file should look something like this:
import { event } from 'utils';
export default event('ready', ({ logger }, client) => {
logger.system(
`\x1b[4m${client.user.tag}\x1b[0m\x1b[36m is up and ready to go!`,
);
});
A /help
command is provided in the src/commands/general/help.ts
file, which automatically generates an embed that allows for navigating through all of the commands (and their subcommands and subcommand groups!).
It uses the pagination functionality described in section Pagination.
The command takes into account the options set on the commands, such as whether they are private (restricted to test guild) or admin-only, and only shows the commands that are applicable to the user using the command. If a command is private or admin-only, this will be shown in the help menu.
There are two options to set the bot's activity and status:
- Set the desired activity and status in the
presence
object inside theClient
constructor in thesrc/client/index.ts
file:
import { Client, ActivityType } from 'discord.js';
const client = new Client({
// ...
presence: {
activities: [
{
name: 'your commands',
type: ActivityType.Listening,
},
],
status: 'online', // online, idle, dnd, invisible
},
});
- Set the desired activity and status through the
client.user.setActivity()
andclient.user.setStatus()
methods wherever you want in your code:
client.user.setActivity('your commands', { type: ActivityType.Listening });
client.user.setStatus('online');
Generally, if you want to set the activity and status once on startup, you should use the first option, but if you want to change the activity and status dynamically, you should use the second option.
Remember that the client object is included in the context of every event handler, including commands, so you can easily change the activity and status when a command is executed.
For example, for the /ping
command in the src/commands/debug/ping.ts
file:
import { SlashCommandBuilder, ActivityType } from 'discord.js';
// ...
export default command({
meta,
private: true,
exec: async ({ client, interaction }) => {
// ...
client.user?.setActivity(`Pinging ${interaction.user.username}`, {
type: ActivityType.Custom,
});
// ...
},
});
This example will set the bot's activity to "Pinging {username}" when the /ping
command is executed.
The src/utils/log.ts
file exports a Logger
class, which can be used to log messages to the console, to a file, to a Discord webhook, or all of the above.
It is instantiated with a category
string, which is used to prefix the log messages.
The file also exports a log()
function which can be used to directly log messages without having to instantiate a Logger
object.
The different types of log messages are color-coded when printed to the console, and can be used to easily distinguish between important and less important messages.
A separate method is provided for each log type, e.g. log.error()
, log.warn()
, etc. (or, in the case of the Logger
class, logger.error()
, logger.warn()
, etc.).
The following log message types are currently supported:
info
: default log message (white text)error
: error messages (red text)warn
: warning messages (yellow text)debug
: debug messages (gray text)system
: system messages, such as startup messages (cyan text)
The Logger
class is used in the contexts of events and commands to automatically prefix every logged message in specific commands or events with the corresponding command or event name.
By default, a Logger
instance will only print log messages to the console.
If you want a specific Logger
instance to write to a file or Discord webhook (i.e. send the log messages to a Discord channel), then you can provide a config object to the constructor:
type LoggerConfig = {
/**
* Whether to log messages to the console.
* @default true
*/
console?: boolean;
/**
* Whether to log messages to a file.
*
* If it does not yet exist, a file will be created in the `/logs` directory, with the ISO formatted date in the name (so `<YY-MM-DD>.log`).
* Optionally, you may overwrite the file name by providing a string.
* @default false
*/
file?: boolean | string;
/**
* Discord webhook to log messages to (i.e. send messages to a Discord channel).
*
* The webhook must be a Discord webhook.
* If is not set (i.e. `undefined`), which is the default, messages will not be logged to a webhook.
* @default undefined
* @example 'https://discord.com/api/webhooks/...'
* @see https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
*/
webhook?: string;
/**
* Log types to ignore when logging to the webhook.
*
* If not set (i.e. `undefined`), which is the default, all log types will be logged to the webhook.
* @default undefined
*/
webhookIgnore?: LogType[];
};
To adjust the existing Logger
instances in the code, just search for new Logger
and add the desired configuration object as the second argument.
The webhook option is particularly useful, as it allows you to send logs to a Discord channel (even one which the bot itself does not have access to), where you can easily view them and set up alerts for certain log messages.
The messages sent to the webhook are formatted nicely using Discord's markdown and emojis.
If you want to send only specific types of log messages to the webhook, you can provide a list of types to ignore in the webhookIgnore
field of the config object.
You can customize the message sent to the webhook by editing the code in the Logger.log()
method in the src/utils/logger.ts
file.
The src/utils/reply.ts
file exports a reply
class, which can be used to send replies to interactions, without having to worry about whether you should be using interaction.reply()
or interaction.editReply()
, as it automatically uses the correct method.
The function takes the interaction to reply to, the options for the reply (meaning you can provide the same options as you would to interaction.reply()
or interaction.editReply()
), and optionally a reply type.
The reply will be made ephemeral by default, but it can be overwritten by providing a value for it yourself in the options (second argument).
The reply type can be one of the following:
default
: send a normal replysuccess
: send a success reply, which is prepended with a success emojierror
: send an error reply, which is prepended with an error emojiwarn
: send a warning reply, which is prepended with a warning emojideny
: send a reply that denies the interaction, which is prepended with a deny emojiwait
: send a reply that indicates a waiting state, which is prepended with an hourglass emoji
Similar to the log()
function (see the Logging section), the reply()
function provides easy-to-use sub-functions for each type, e.g. reply.error()
, reply.warn()
and reply.deny()
.
If you want these replies to use embeds by default (instead of simply sending a text message), this can be easily changed by modifying the getOptions()
function in the src/utils/reply.ts
file.
There is built-in support for pagination of content using embeds.
Currently, this is only used in the /help
command, but you can create your own pagination by following these steps:
- Create a paginator in the
src/utils/paginators.ts
folder, using the constructor of thePaginator
class defined insrc/utils/pagination.ts
. - Add the paginator you created to the
paginators
array in thesrc/utils/paginators/index.ts
file. - Use the
paginationReply()
function from the utils to create an embed that can be used to navigate through the pages. Don't forget to await this function!
The pagination for the /help
command uses a separate paginator for each category of commands, which are defined in the src/utils/paginators/help.ts
file.
The pagination embed for a selected category is created in the src/events/interactionCreate/help.ts
file.
The Paginator
class takes a getData()
function to fetch the data to paginate.
It is passed a context object, whose type is defined in the src/types/pagination.ts
file as PaginationContext
, which extends the BaseContext
defined in src/types/context.ts
.
The context object currently contains the bot client, the interaction and a logger, but you can add additional fields to it if you need to.
To avoid having to fetch the data every time the page is changed, the Paginator
class offers the option to cache the data by setting cacheData
to true
in the constructor.
When you set this to true
, you must also specify the getCacheKey()
function in the constructor, which takes the context object and returns a string that is used as the key for the cache.
The getData()
function is then only called when the data is not yet cached, and the cached data is used otherwise.
This allows you to cache the data for a specific user (and guild), for example, by using the user ID as the cache key.
The pagination uses embed fields to display the content, and thus the limit of items to show on a single page is 25 (the maximum number of fields allowed in an embed).
You can customize the components of the embed being sent using the optional embedData
prop, except the fields and footer, since those are set by the pagination.
Additionally, you can change the options of the reply message using the optional replyOptions
prop.
The pagination embed, the next and back buttons and the page selector will be added onto the given reply options to compose the final message.
The reply is made ephemeral by default, so if you want it to not be ephemeral, you have to explicitly pass ephemeral: false
to the replyOptions
.
Note: both the embedData
and replyOptions
props can either be the actual data, or a function that returns the data. The function is passed the context object, so you can use the context to determine the data to return (e.g. make it user-specific).
Discord interaction IDs are used to identify interactions, such as slash commands, buttons, and select menus. This allows the bot to differentiate between multiple interactions of the same type. For example, if you have a slash command that sends a message with a button, and you click that button, the bot needs to know which button was clicked. This is done by setting a custom ID on that button.
This template uses a concept of namespaces to distinguish between different interactions.
By using a string that separates different pieces of data using ;
, we can create a custom interaction ID that can be parsed to retrieve the namespace and custom arguments.
The namespace is an enum that identifies the type of interaction, while the arguments are a list of strings that provide additional data (e.g. the name of the paginator and the current offset).
Thus, the general structure of a custom interaction ID under the hood will be as follows:
<namespace>;<arg1>;<arg2>;<...>
The namespaces can be used to filter out other interactions in the interactionCreate
event handler, and the arguments can be used to pass specific data along with the interaction.
For example, the pagination (see the Pagination section) uses the Pagination
namespace, and passes the paginator name and current offset as arguments.
The event handler for the pagination buttons can then filter out other interactions by checking the namespace, and use the arguments to determine which paginator to use and what the current offset is.
In particular, it looks something like this:
import { Namespace, parseId } from 'utils';
// ...
if (!interaction.isButton() && !interaction.isStringSelectMenu()) return;
const [namespace] = parseId(interaction.customId);
if (namespace !== Namespace.Pagination) return;
The first line filters out other types of interactions, such as slash commands, and the second line parses the interaction ID to retrieve the namespace, which is then used to check that the interaction is indeed a pagination interaction.
Notice the use of the Namespace
object to make sure we always use a valid namespace.
There are two utility functions available in the src/utils/interaction.ts
file:
createId()
: create an interaction ID from a namespace and a list of argumentsparseId()
: parse an interaction ID into a namespace and a list of arguments
If you want to add new namespaces, you can simply add them to the Namespace
enum in the src/utils/interaction.ts
file.
A common occurrence in Discord bots is the need to confirm an action, such as deleting a message or banning a user. This template provides a simple way to set up such a confirmation for any action.
To set up a confirmation flow, follow these steps:
- Create a new file in the
src/utils/confirmations
directory. - Export a function to handle a confirmation, wrapped inside the
confirmation()
function (to type check the function signature you provide). The handler function receives a boolean indicating whether the action was confirmed or not, as well as the interaction and event context. An example is provided in thesrc/utils/confirmations/example.ts
file. - Import the function you created in the
src/utils/confirmations/index.ts
file. - Add the function to the
confirmationHandlers
object, using the name you want to use to trigger the confirmation flow. - Use the
confirmationResponse()
function wherever you want to create an interaction reply options object (i.e. a message) that contains an embed alongside theCancel
andConfirm
buttons. This function takes three arguments:
- The first argument is the name of the confirmation handler to use (i.e. the key which your confirmation function is stored under in the
confirmationHandlers
object). - The second argument is optional and can contain your own reply options, allowing you to customize the message.
By default, the
confirmationResponse()
function adds an embed with the title "Confirmation" and a description that asks the user to confirm the action, but this will be overwritten if you provide your own embed object. - The third argument can contain additional (short) arguments, which will be passed through the flow using custom IDs on the buttons (see the Interaction IDs section). These arguments will be passed along to your confirmation function, so you can use them to pass data like a user id or message id.
Essentially, this setup allows you to skip the step of setting up your own event handler for the confirmation buttons.
Such an event handler is provided in the src/events/interactionCreate/confirmation.ts
file, which will automatically call the confirmation function you set up with the boolean value of the button clicked, as well as the interaction and event context.
The /confirm
command implements an example of this setup, which simply sends the default confirmation embed and edits the message to show the result of the confirmation flow.
(You can remove this command if you so desire.)
splitSend
: split a a list of lines over multiple embeds, respecting Discord's embed description length limit.chunk
: split an array into chunks of a given size (jagged array / matrix).
If you wish to use a database, you can add a src/client/db.ts
file, which exports a db
object, and re-export it in src/client/index.ts
.
This db
object should be imported in src/index.ts
and passed to the registerEvents
function.
In the registerEvents
function (located in src/utils/event.ts
), you can then add it to the context for the event handlers, giving your commands and events access to the database.
Note that passing the database in the context like this is recommended over importing a database object in every file.
For example, the following snippet initializes a database connection using the firebase-admin
package and would be added to the src/client/db.ts
file:
import admin from 'firebase-admin';
import ENV from 'env';
const serviceAccount = JSON.parse(ENV.FIREBASE_SDK);
// Initialize Firebase with the realtime database
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: ENV.DB_URL,
});
export const db = admin.database();
Don't forget to add the FIREBASE_SDK
and DB_URL
environment variables to your .env
and env.ts
files if you use the above snippet.
You'll also have to install the firebase-admin
package using deno add firebase-admin
.
If you need to run a server alongside the bot, for example to create an API which you can use to control the bot from a dashboard, you can add a src/server
folder.
Create an index.ts
file in this folder, and add the following snippet to run an Express server:
import express from 'express';
import { log } from 'utils';
const app = express();
app.get('/', (req, res) => {
res.send('Hello world!');
});
app.listen(process.env.PORT || 3000, () => {
log.system('server', 'Listening on port 3000!');
});
In this snippet, you can access the bot client by importing it from src/client/index.ts
.
The process.env.PORT environment variable is usually provided by the hosting service and defaults to 3000 if not provided.
You can do whatever you want with this Express server, including adding routes, middleware, etc. (see the Express documentation). Folder structure is up to you, but you can use the following as a starting point:
src
├── server
│ ├── index.ts
│ ├── routers
│ │ ├── index.ts
│ │ └── ...
│ ├── middleware
│ │ ├── index.ts
│ │ └── ...
├── ...
This template uses the Deno built-in code formatter to automatically format the code.
A command, deno task fmt
, is provided to run the Deno formatter on all files in the src
folder and immediately fix any issues.
You can also use the Deno extension, with some VS Code settings to automatically format the code on save.
If you prefer to use Prettier, you may include a .prettierrc
file in the root of the project.
The file will be ignored by git.
To achieve the same formatting as the current settings in deno.json, you can use the following configuration:
{
"endOfLine": "auto",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all"
}
The linter used is also the Deno built-in linter, which can be run using the deno task lint
command (which is really just deno lint --fix
).
Contributions are welcome! Feel free to open an issue or submit a pull request.
Try to make sure the code is formatted correctly, as specified in the Code style section. A GitHub Action will automatically check this for you when you open a pull request. You can make sure that everything is formatted correctly by running the deno task fmt
command.
Please make sure to use semantic commit messages to keep the commit history clean and readable.
This project is licensed under the MIT license.