When you create a Bot on Discord, you can receive common events from the client as webhooks. Discord will call a pre-configured HTTPS endpoint, and send details on the event in the JSON payload.
This bot is an example of writing a webhook based bot which:
- Uses the Discord Interactions API
- Uses Cloudflare Workers for hosting
To start, we're going to create the application and bot on the Discord Developer Dashboard:
- Visit https://discord.com/developers/applications
- Click
New Application
, and choose a name - Copy your Public Key and Application ID, and put them somewhere locally (we'll need these later)
- Click on the
Bot
tab, and create a bot! Choose the same name as your app. - Grab the token for your bot, and keep it somewhere safe locally (I like to put these tokens in 1password)
- Click on the
OAuth2
tab, and choose theURL Generator
. Click thebot
andapplications.commands
scopes. - Click on the
Send Messages
andUse Slash Commands
Bot Permissions - Copy the Generated Url, and paste it into the browser. Select the server where you'd like to develop your bot.
Cloudflare Workers are a convenient way to host Discord bots due to the free tier, simple development model, and automatically managed environment (no VMs!).
- Visit the Cloudflare dashboard
- Click on the
Workers
tab, and create a new service using the same name as your Discord bot - Make sure to install the Wrangler CLI and set it up.
The production service needs access to some of the information we saved earlier. To set those variables, run:
$ wrangler secret put DISCORD_TOKEN
$ wrangler secret put DISCORD_PUBLIC_KEY
$ wrangler secret put DISCORD_APPLICATION_ID
$ wrangler secret put DISCORD_TEST_GUILD_ID
‼️ This depends on the beta version of thewrangler
package, which better supports ESM on Cloudflare Workers.
Let's start by cloing the respository, and installing dependencies. This requires at least v16 of Node.js:
$ npm install
Before testing our bot, we need to register our desired slash commands. For this bot, we'll have a /joke
command, and a /hello
command, and a /invite
command. The name and description for these are kept separate in commands.js
:
module.exports = [
{
name: "invite",
description: "Get bot invite link"
},
{
name: "hello",
description: "Get hello world using HTTPS Request"
},
{
name: "joke",
description: "Get random joke."
}
];
The code to register our commands lives in register.js
. Commands can be registered globally, making them available for all servers with the bot installed, or they can be registered to a single server. In this example - we're just going to focus on global commands:
const { REST } = require('@discordjs/rest');
const { Routes } = require('discord-api-types/v9');
require('colors');
require('dotenv').config();
// setup slash commands
const commands = require('./commands')
const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN);
(async () => {
try {
console.log('[Discord API] Started refreshing application (/) commands.'.yellow);
await rest.put(
process.env.DEVELOPMENT ? Routes.applicationGuildCommands(process.env.DISCORD_APPLICATION_ID, process.env.DISCORD_TEST_GUILD_ID)
: Routes.applicationCommands(process.env.DISCORD_APPLICATION_ID),
{ body: commands },
);
console.log('[Discord API] Successfully reloaded application (/) commands.'.green);
} catch (error) {
console.error(error);
}
})();
This command needs to be run locally, once before getting started:
$ DISCORD_TOKEN=**** DISCORD_APPLICATION_ID=**** node src/register.js
We're finally ready to run this code locally! Let's start by running our local development server:
$ npm run dev
When a user types a slash command, Discord will send an HTTP request to a given endpoint. During local development this can be a little challenging, so we're going to use a tool called ngrok
to create an HTTP tunnel.
$ npm run ngrok
This is going to bounce requests off of an external endpoint, and foward them to your machine. Copy the HTTPS link provided by the tool. It should look something like https://8098-24-22-245-250.ngrok.io
. Now head back to the Discord Developer Dashboard, and update the "Interactions Endpoint Url" for your bot:
This is the process we'll use for local testing and development. When you've published your bot to Cloudflare, you will want to update this field to use your Cloudflare Worker url.
Most of the interesting code in this bot lives in src/server.js
. Cloudflare Workers require exposing a fetch
function, which is called as the entry point for each request. This code will largely do two things for us: validate the request is valid and actually came from Discord, and hand the request over to a router to help give us a little more control over execution.
export default {
/**
* Every request to a worker will start in the `fetch` method.
* Verify the signature with the request, and dispatch to the router.
* @param {*} request A Fetch Request object
* @param {*} env A map of key/value pairs with env vars and secrets from the cloudflare env.
* @returns
*/
async fetch(request, env) {
if (request.method === 'POST') {
// Using the incoming headers, verify this request actually came from discord.
const signature = request.headers.get('x-signature-ed25519');
const timestamp = request.headers.get('x-signature-timestamp');
const body = await request.clone().arrayBuffer();
const isValidRequest = verifyKey(
body,
signature,
timestamp,
env.DISCORD_PUBLIC_KEY
);
if (!isValidRequest) {
console.error('Invalid Request');
return new Response('Bad request signature.', { status: 401 });
}
}
// Dispatch the request to the appropriate route
return router.handle(request, env);
},
};
All of the API calls from Discord in this example will be POSTed to /
. From here, we will use the discord-interactions
npm module to help us interpret the event, and to send results.
router.post('/', async (request, env) => {
const message = await request.json();
console.log(message);
if (message.type === InteractionType.PING) {
// The `PING` message is used during the initial webhook handshake, and is
// required to configure the webhook in the developer portal.
console.log('Handling Ping request');
return new JsonResponse({
type: InteractionResponseType.PONG,
});
}
if (message.type === InteractionType.APPLICATION_COMMAND) {
// Most user commands will come as `APPLICATION_COMMAND`.
if (message.data.name === 'invite') {
const botId = env.DISCORD_APPLICATION_ID;
return new JsonResponse({
type: 4,
data: {
content: `[Click to use bot 🥳](https://discord.com/oauth2/authorize?client_id=${botId}&scope=applications.commands)`,
flags: 64
}
})
}
if (message.data.name === 'hello') {
return new JsonResponse({
type: 4,
data: {
content: "👋 Hey i'm using HTTPS request for sending this message using interactions"
}
})
}
if (message.data.name === 'joke') {
const joke = await getRandomJoke();
return new JsonResponse({
type: 4,
data: {
content: joke
}
})
}
console.error('Unknown Command');
return new JsonResponse({ error: 'Unknown Type' }, { status: 400 });
}
}
);