Hosting a Discord Bot on Cloudflare Workers

Hosting a Discord Bot on Cloudflare Workers

ยท

8 min read

Hi everyone,

Welcome to my blog. This post will show you How to host your Discord Bot on Cloudflare Worker.

The bot is written in TypeScript and will be hosted on Cloudflare workers.

All of the code for this app can be found on GitHub.


How to Create a bot on Discord

Step 1: Create a personal discord server.

Step 2: Visit Discord Developer Portal for creating a Bot.

Step 3: Click New Application, and choose a name

Step 4: Copy your Public Key and Application ID, and put them somewhere locally (we'll need these later)

Step 5: Now click on the Bot tab on the left sidebar, and create a bot! You can choose the same name as your app.

Step 6: Grab the token for your bot after clicking the reset token button, and store it somewhere safe.

โš ๏ธ For security reasons, you can only view your bot token once. If you misplace your token, you'll have to generate a new one.

Step 7: Adding bot permissions

Now we'll configure the bot with permissions required to create and use slash commands.

  • Click on the OAuth2 tab, and choose the URL Generator. Click the bot and applications.commands scopes.

  • Check the boxes to set following permissions for your bot.

  • After providing all the permissions you will get an url at the bottom of the page use that to invite the bot to your server.

    Open the Url you get and invite the bot to your test server.


Creating your Cloudflare Worker

Cloudflare Workers are a convenient way to host Discord Bot due to the free tier, simple development model, and automatically managed environment.


Setting Up Local Development

  • Clone the Repository to your machine

  • Create a .env file in the project folder because we need a few environment variables for the bot to work which is given below.

      DISCORD_TOKEN="" //The token generated for your bot while creating a discord application
      DISCORD_PUBLIC_KEY="" //Public key of your Discord bot helps to verify the bot and apply interaction url
      DISCORD_APPLICATION_ID=""  //The application id of your bot.
      DISCORD_GUILD_ID=""   //Id of the guild where you want to install the slash commands.
    
  • run npm install

Project structure

Registering commands

Discord provides us with an endpoint to register all our commands to our bots.
For this app, we'll have a /hello command, and a /verify command. The name and description for these are kept separate in commands.ts:

export const HELLO = {
  name: "hello",
  description: "Replies with hello in the channel",
};

export const VERIFY = {
  name: "verify",
  description:
    "Generate a link with user specific token to link with RDS backend.",
};

The code to register commands lives in register.js. It hist that endpoint with all the commands that we want to register.

Step 1: Setting up the staging environment variable to register the commands and deploy code to the Cloudflare workers.

name: Register and deploy Slash Commands
on:
  push:
    branches: develop
jobs:
  Register-Commands:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v2
      - run: npm install
      - run: npm run register
        env:
          DISCORD_TOKEN: ${{secrets.DISCORD_TOKEN}}
          DISCORD_APPLICATION_ID: ${{secrets.DISCORD_APPLICATION_ID}}
          DISCORD_GUILD_ID: ${{secrets.DISCORD_GUILD_ID}}
  Deploy-to-Cloudflare:
    needs: [Register-Commands]
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v2
      - run: npm install
      - uses: cloudflare/wrangler-action@2.0.0
        with:
          apiToken: ${{secrets.CLOUDFLARE_API_TOKEN}}
          secrets: |
            DISCORD_PUBLIC_KEY
            DISCORD_TOKEN
        env:
          CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}}
          DISCORD_PUBLIC_KEY: ${{secrets.DISCORD_PUBLIC_KEY}}
          DISCORD_TOKEN: ${{secrets.DISCORD_TOKEN}}

Step 2: Created a few files to register all the commands given in commands.ts to the bot.

import { HELLO, VERIFY } from "./constants/commands";
import { config } from "dotenv";
import { DISCORD_BASE_URL } from "./constants/urls";
import { registerCommands } from "./utils/registerCommands";

config();

/**
 *
 * @param discordBotToken { String }: Token for your Discord Bot
 * @param discordApplicationId { String }: Application Id of your discord bot
 * @param discordGuildId { String }: Guild id in which commands are to be installed.
 */

async function registerGuildCommands(
  discordBotToken?: string,
  discordApplicationId?: string,
  discordGuildId?: string
) {
  const commands = [HELLO, VERIFY];

  try {
    if (!discordBotToken) throw new Error("Please provide a BOT TOKEN");
    if (!discordApplicationId)
      throw new Error("Please provide a DISCORD_APPLICATION_ID");
    if (!discordGuildId) throw new Error("Please provide a GUILD_ID");

    const registrationUrl = `${DISCORD_BASE_URL}/applications/${discordApplicationId}/guilds/${discordGuildId}/commands`;
    await registerCommands(registrationUrl, discordBotToken, commands);
  } catch (e) {
    console.log(e);
  }
}

registerGuildCommands(
  process.env.DISCORD_TOKEN,
  process.env.DISCORD_APPLICATION_ID,
  process.env.DISCORD_GUILD_ID
);
/**
 * handle fetch request
*/
/**
 *
 * @param url { String }: DISCORD HTTP end point for command registration
 * @param discordBotToken { String }: Token of your bot to send as authorization header
 * @param commands { Array }: Array of commands to be registered
 */
import { fetch } from "./fetch";
import { commandTypes } from "../typeDefinitions/register.types";

export async function registerCommands(
  url: string,
  discordBotToken: string,
  commands: Array<commandTypes>
) {
  try {
    const response = await fetch(url, {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bot ${discordBotToken}`,
      },
      method: "PUT",
      body: JSON.stringify(commands),
    });

    if (response.ok) console.log("Registered all commands");
    else console.error("Error Registering Commands");
  } catch (error) {
    console.error(error);
  }
}
  • Now, run the command npm run register(This will register all the commands to your discord bot.)

  • Now let's link our local development server to our bot.

    After all the commands are installed we need to save a few secrets in wrangler cli.

    • run command wrangler secret put DISCORD_TOKEN and then enter the value of your token.

    • run command wrangler secret put DISCORD_PUBLIC_KEY and enter the public key.

Setup Route in Cloudflare Worker and Verify Bot Request from Discord to Cloudflare Worker

Note:- We are using itty-router, a lightweight router developed for Cloudflare workers.

  • Define Route in the case get('/')

  • Define Route in the case of any apart from get('/')i.e. configured it to return 404

  • We want to verify if the request we received is a valid discord request.

      export const UNKNOWN_INTERACTION = {
        error: "Unknown Interaction",
      };
    
      export const NOT_FOUND = {
        error: "๐Ÿฅน oops! No fish ๐ŸŸ caught ๐ŸŽฃ",
      };
    
      export const BAD_SIGNATURE = {
        error: "๐Ÿšซ Bad Request Signature",
      };
    
      export const STATUS_CHECK = {
        message: "Welcome to our discord Bot Server ๐Ÿ‘‹",
      };
    
      export const COMMAND_NOT_FOUND = "Command Not Found";
    
      import { Router } from "itty-router";
      import { InteractionResponseType, InteractionType } from "discord-interactions";
      import * as response from "./constants/responses";
      import { baseHandler } from "./controllers/baseHandler";
      import { env } from "./typeDefinitions/default.types";
      import { discordMessageRequest } from "./typeDefinitions/discordMessage.types";
      import JSONResponse from "./utils/JsonResponse";
      import { verifyBot } from "./utils/verifyBot";
    
      const router = Router();
    
      router.get("/", async () => {
        return new JSONResponse(response.STATUS_CHECK, {
          status: 200,
        });
      });
    
      router.post("/", async (request, env) => {
        const message: discordMessageRequest = await request.json();
        if (message.type === InteractionType.PING) {
          return new JSONResponse({
            type: InteractionResponseType.PONG,
          });
        }
        if (message.type === InteractionType.APPLICATION_COMMAND) {
          return baseHandler(message, env);
        }
        return new JSONResponse(response.UNKNOWN_INTERACTION, { status: 400 });
      });
    
      router.all("*", async () => {
        return new JSONResponse(response.NOT_FOUND, {
          status: 404,
        });
      });
    
      export default {
        async fetch(request: Request, env: env): Promise<Response> {
          if (request.method === "POST") {
            const isVerifiedRequest = await verifyBot(request, env);
            if (!isVerifiedRequest) {
              console.error("Invalid Request");
              return new JSONResponse(response.BAD_SIGNATURE, { status: 401 });
            }
          }
          return router.handle(request, env);
        },
      };
    

Add Handler for /hello command and /verify command in chat

  • We want to add a base handler that checks for commands in chat and call its respective handler

  • When we write /hello in the discord server chat the handler return `Hello <@${userId}>`

  • When we write /verify in the discord server chat the handler "Please check the DM"
    In Dm you see "Hello" message

import { HELLO, VERIFY } from "../constants/commands";
import { env } from "../typeDefinitions/default.types";
import { discordMessageRequest } from "../typeDefinitions/discordMessage.types";
import { getCommandName } from "../utils/getCommandName";
import JSONResponse from "../utils/JsonResponse";
import { lowerCaseMessageCommands } from "../utils/lowerCaseMessageCommand";
import { commandNotFound } from "./commandNotFound";
import { helloCommand } from "./helloCommand";
import { verifyCommand } from "./verifyCommand";

export async function baseHandler(
  message: discordMessageRequest,
  env: env
): Promise<JSONResponse> {
  const command = lowerCaseMessageCommands(message);
  switch (command) {
    case getCommandName(HELLO): {
      return helloCommand(message.member.user.id);
    }
    case getCommandName(VERIFY): {
      return await verifyCommand(message.member.user.id, env);
    }
    default: {
      return commandNotFound();
    }
  }
}
import { env } from "../typeDefinitions/default.types";
import { discordTextResponse } from "../utils/discordResponse";
import { sendDiscordDm } from "../utils/sendDiscordDm";
import JSONResponse from "../utils/JsonResponse";

export function helloCommand(userId: number): JSONResponse {
  return discordTextResponse(`Hello <@${userId}>`);
}

export async function verifyCommand(userId: number, env: env) {
  await sendDiscordDm(userId, env);
  return discordTextResponse("Please check the DM");
}
import { DISCORD_BASE_URL } from "../constants/urls";
import { env } from "../typeDefinitions/default.types";
import { createDmChannel } from "../typeDefinitions/discordMessage.types";

/**
 *
 * @param userId {number}: user id of the person using the slash command received from the interaction.
 * @param env {env}: contains environment variables.
 */

export const sendDiscordDm = async (
  userId: number,
  env: env,
) => {
  // "/users/@me/channels" is an endpoint provided by discord to create a dm channel
  try {
    const createDmChannel: createDmChannel = await fetch(
      `${DISCORD_BASE_URL}/users/@me/channels`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bot ${env.DISCORD_TOKEN}`,
        },
        body: JSON.stringify({
          content: discordTextResponse("Hello"),
          recipient_id: userId,
        }),
      }
    ).then((res) => {
      return res.json();
    });

    const channelId = createDmChannel.id;

    // "/channels/{channel.id}/messages" is used to send a message to the specified channel
    await fetch(`${DISCORD_BASE_URL}/channels/${channelId}/messages`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bot ${env.DISCORD_TOKEN}`,
      },
      body: JSON.stringify({
        content: "Hello",
      }),
    });
  } catch (e) {
    console.log("Could not send the DM. Error: ", e);
  }
};

Running the server

  1. This command needs to be run locally, once before getting started: npm start

  2. Once the wrangler starts make sure it is running on port 8787->click [b]

Setting up ngrok

When a user types a slash command, Discord will send an HTTP request to a public 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.

Once the server starts on desired port 8787 open another terminal and type in the command npm run ngrok

This is going to bounce requests off of an external endpoint, and forward 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 app:


Conclusion

In this tutorial, you have learned how to create discord bot and host on Cloudflare workers.

ย