Creating a Discord HTTP Slash Command Bot with Fastify
I've been dying to try out Discord's new slash commands for a few months now, but unfortunately the framework I use for my bots (discord.js) hasn't shipped official support for them yet. I finally got impatient enough to decide to write a slash command bot using the new HTTP API (instead of the persistent Gateway connection), which is a lot easier to use without a library wrapper.
As it turns out, there aren't a lot of tutorials on how to do this yet. I thought it would be useful to publish how I did it - using the HTTP API is a great way to create simple bots! My project idea was to create a bot that imitates the old /slap <user>IRC command.
HTTP Discord bots are a relatively new interaction model. Previously, each bot had to set up a persistent connection to the Discord API Gateway. This connection would send and receive events for pretty much every change that happened on the servers the bot was in. This meant you would get a lot of data - often times, far more than was necessary for your application.
With the release of interactions and Slash Commands, you can now configure your bot to work over HTTP instead. You can think of the new system as a webhook - you give Discord a list of commands you want to register and explain their inputs with a JSON schema. When a user invokes one of your commands, Discord will validate the input and then call your API with a JSON payload. This system does have some drawbacks, though - you won't have a way of telling if your bot is added to a new server for instance.
Creating a Discord Bot
First things first - we need to create an official Discord Application for our slash command bot. Sign into the Discord Developer Portal and click "New Application" in the top right corner. Give it a name, and click create. Next up, navigate to "Bot" in the sidebar and click the "Add Bot" button. Verify with "Yes, do it!" on the warning prompt that comes up. With that, we'll have created our application! There are a few values we'll want to grab out of this portal.
Token: On the "Bot" sidebar tab there's a Token value you can copy - we'll be using this to authenticate our server. This value should never be published in public!
Application ID: On the "General Information" sidebar tab there's a field called Application ID. We'll need this to create an invite link.
Public Key: On the "General Information" sidebar tab there's a field called Public Key. We'll be using this to authenticate interaction requests.
We'll be copying these three values into a .env file in our project - more on that below. Also take note of the "Interactions Endpoint URL" field on the "General Information" tab. We'll be using that to link to our server.
Keep this tab open and the values handy. Now let's dive into programming our new bot!
Coding the Bot
Let's start by creating a new folder for our project and initializing it with npm.
mkdir trout && cd troutnpm init
For this particular project, let's use esm by default. All of the supported Node.js versions now fully support it (you can check what version you're on by running node -v in your terminal - it should be 10+) so let's make it a habit to use esm!
We can also add a start script that loads our bot's tokens from a .env file and boots the server.
package.json
json
{ "name": "discord-trout", "version": "1.0.0", "description": "Sometimes you gotta slap a person with a large trout", "main": "server.js", "type": "module", "scripts": { "start": "node -r dotenv/config server.js" "test": "echo \"Error: no test specified\" && exit 1" }, ...}
Let's get things kicked off by installing some dependencies for our project.
dotenv is used to load our Bot's secret Tokens from a .env file. This file should not be committed into your source code. If you are using git, add .env to your .gitignore file.
fastify is the server framework we'll be using. If you've used express before, it's ergonomically similar - there are performance benefits to using it though, and it comes with out of the box async/await support.
discord-interactions provides some utilities that we'll need when interacting with the Discord API.
npm install discord-interactions fastify dotenv
Now we can create a .env file and copy over our bot information from Discord's Applications page. Replace the values with the ones from the bot you created earlier.
.env
text
APPLICATION_ID=123PUBLIC_KEY=abcTOKEN=xyz
With that done, we can move onto the code! We'll start by creating a minimal fastify server. We'll copy most of the code from the fastify getting started guide, and add a GET request for the root URL.
server.js
javascript
import fastify from "fastify";const server = fastify({ logger: true,});server.get("/", (request, response) => { server.log.info("Handling GET request");});server.listen(3000, async (error, address) => { if (error) { server.log.error(error); process.exit(1); } server.log.info(`server listening on ${address}`);});
If you start the server with npm start you should be able to navigate to http://localhost:3000 and see a message in your terminal that fastify has received and responded to a request!
Now it's time for the fun part - let's start writing the bot logic. The slash command documentation starts off by telling us that we need to respond to a PING interaction type that it will send via a POST request. Let's modify our server code to define a new POST route on the root url, and then have it look at the request object to determine if it's a PING interaction or not. If it is, we should respond with the appropriate PONG interaction. The discord-interactions library we installed earlier has a couple of helpers we can use to make our code clearer!
If we start our server and try to connect our application to Discord at this point, we'll get a failure message. It turns out that our application can't accept every request that it is sent - we need to do some verification work to ensure the request is valid.
ℹ️ Connecting Discord to your local bot
You can't use localhost:3000 as your domain for your Discord bot since Discord won't know where to look for that - you'll need to give it an actual URL that's pointed to your localhost:3000 application. I personally use ngrok for this. If you're on macOS, you can run the following to use it:
brew install ngrokngrok http 3000
Then copy the HTTPS URL in the terminal output and paste it into the Discord Bot "Interactions Endpoint URL" field.
There's a helper method in discord-interactions to verify requests, but it requires the raw request body to work. Fastify doesn't have built-in support for that, but luckily there is a community plugin we can set up to get things working. We'll want the plugin to run and transform the request before any handlers run, so we'll register it in fastify with a runFirst option set.
npm install fastify-raw-body
server.js
javascript
import fastify from 'fastify';+import rawBody from 'fastify-raw-body';import { InteractionResponseType, InteractionType } from 'discord-interactions';const server = fastify({ logger: true,});server.register(rawBody, { runFirst: true, }); server.get('/', (request, response) => { server.log.info('Handling GET request');});// ...
Great! Now we have a request.rawBody value that discord-interactions needs. Next we need to set up a fastify hook that checks all incoming requests - we'll want this check to run before any route handler logic, so we'll register it as a preHandler hook. The discord-interactions library explains how to use it to verify a request, so we'll largely copy over what they have written while making some small tweaks to fit it to fastify instead of express.
server.js
javascript
import fastify from "fastify";import rawBody from "fastify-raw-body"; import { InteractionResponseType, InteractionType, verifyKey, } from "discord-interactions";const server = fastify({ logger: true,});server.register(rawBody, { runFirst: true,});server.get("/", (request, response) => { server.log.info("Handling GET request");});server.addHook("preHandler", async (request, response) => { // We don't want to check GET requests to our root url if (request.method === "POST") { const signature = request.headers["x-signature-ed25519"]; const timestamp = request.headers["x-signature-timestamp"]; const isValidRequest = verifyKey( request.rawBody, signature, timestamp, process.env.PUBLIC_KEY, ); if (!isValidRequest) { server.log.info("Invalid Request"); return response.status(401).send({ error: "Bad request signature " }); } } }); server.post("/", async (request, response) => { const message = request.body; if (message.type === InteractionType.PING) { server.log.info("Handling Ping request"); response.send({ type: InteractionResponseType.PONG, }); } else { server.log.error("Unknown Type"); response.status(400).send({ error: "Unknown Type" }); }});server.listen(3000, async (error, address) => { if (error) { server.log.error(error); process.exit(1); } server.log.info(`server listening on ${address}`);});
Now if we start the server and try to connect it with the Discord applications page, we should get a success message!
It's finally time to begin working on our commands. We'll create two of them - one that implements the famous /slap command from IRC, and another that sends a bot invitation link to users who also want to add the bot to their own servers.
For our Slap command, we'll need the person interacting with the bot to input a user to target. The Discord API docs say that a user mention is type 6 - we'll mark that option as required, since we need someone to slap. The invite command doesn't require an input, so we'll just define it with a name and description.
Since we'll need to register the commands with Discord when we're done defining them, we'll put the definitions in a new commands.js file.
commands.js
javascript
export const SLAP_COMMAND = { name: "Slap", description: "Sometimes you gotta slap a person with a large trout", options: [ { name: "user", description: "The user to slap", type: 6, required: true, }, ],};export const INVITE_COMMAND = { name: "Invite", description: "Get an invite link to add the bot to your server",};
Then we can import them and add some helper definitions back in our server code:
server.js
javascript
import fastify from "fastify";import rawBody from "fastify-raw-body";import { InteractionResponseType, InteractionType, verifyKey,} from "discord-interactions";import { SLAP_COMMAND, INVITE_COMMAND } from "./servers.js"; const INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${process.env.APPLICATION_ID}&scope=applications.commands`; const server = fastify({ logger: true,});// ...
Next, let's write the code that handles these interactions! We'll go back into our POST route and add checks for someone using our slash commands (the Discord API refers to these events as an Application Command Interaction). The docs have an example schema for an interaction - we can use that to write our function definition.
To start with, we'll want different responses depending on which slash command was used. We can use a switch statement that looks at the command name, and then write out our different responses accordingly.
For our slap message, we want it to display as {user} slaps {target} around a bit with a large trout. Discord messages use simplified Markdown formatting, with special sequences you can use to mention different things - to get this display, we'll format our message like this:
`*<@${userId}> slaps <@${targetId}> around a bit with a large trout*`;
For our invitation link, let's make the response message ephemeral1 so we don't spam servers. The Discord API docs say that if you want to make a response ephemeral, all you need to do is pass flags: 64 into the response!
Let's see what the completed interaction handler looks like for our server.
server.js
javascript
//...server.post("/", async (request, response) => { const message = request.body; if (message.type === InteractionType.PING) { server.log.info("Handling Ping request"); response.send({ type: InteractionResponseType.PONG, }); } else if (message.type === InteractionType.APPLICATION_COMMAND) { switch ( message.data.name.toLowerCase() ) { case SLAP_COMMAND.name.toLowerCase(): response.status(200).send({ type: 4, data: { content: `*<@${message.member.user.id}> slaps <@${message.data.options[0].value}> around a bit with a large trout*`, }, }); server.log.info("Slap Request"); break; case INVITE_COMMAND.name.toLowerCase(): response.status(200).send({ type: 4, data: { content: INVITE_URL, flags: 64, }, }); server.log.info("Invite request"); break; default: server.log.error("Unknown Command"); response.status(400).send({ error: "Unknown Type" }); break; } } else { server.log.error("Unknown Type"); response.status(400).send({ error: "Unknown Type" }); }});// ...
ℹ️ When to use Global vs Guild Commands
Even though we have been building out a bot that uses Global Commands, you should start by registering commands as Guild Commands. Global Commands can take up to an hour to roll out to the client - this makes developing with them a rather painful process. It's much faster if you do 'local' development using Guild Commands first, since you get instant feedback. Then, once you are happy with how things are working, promote them up to a Global Command!
The code below only works with Global Commands. If you'd like to try the Guild Command → Global Command process, you can use the same register script we're about to create below with some minor changes. To start, change the URL out:
const guildId = "my guild id here";const response = await fetch( `https://discord.com/api/v8/applications/${process.env.APPLICATION_ID}/guilds/${guildId}/commands`,// ...
Then, once you've finished development and you want to register commands as Global Commands, you can change it back and register them globally! Next, you'll want to remove your development Guild Commands by getting a list of their IDs and calling the delete endpoint with them. The script would look something like this:
Now that we have our handler finished, it's time to register our commands with Discord! We'll create a special register script here that we'll call manually. We'll import our command definitions from server.js and then make a PUT request to the commands API with them.
Running it should successfully register the commands:
npm run register
If you boot up your server, you should see the slash commands in your guild! Go ahead and try them out - everything should work!
Next Steps
Now that you have a finished slash command bot, there are a few places you can go from here. For starters, add some more commands! There's a lot you can do with slash commands. Have some fun trying things out! You can also look into hosting this bot on DigitalOcean, AWS, or something else so that you don't need to worry about running it locally. Personally, I took my finished bot and turned it into a serverless function hosted on Vercel. I'll be writing a (shorter) post on how I did that sometime this week, but if you're curious the current source code can be found at IanMitchell/discord-trout.
There's also a lot of room for code improvement - I have a few ideas of things I want to try to make more of a slash command framework, but there is a lot to explore. Try making writing some reusable functions to make your own personal framework!
If you have any questions or suggestions, shoot me a tweet or email. I'm happy to write more on this subject and do more tutorials on Discord bots if anyone is interested. If there's something you want to do but aren't sure how, let me know and I'm happy to try and help out!
Footnotes
An ephemeral message is a message that's only sent to a single user - it won't be visible for anyone else in the server. ↩