2025/02/01 / Discord

Reevaluating Discord Bot Architecture

Configuration notes for Discord Bot, adding commands/events like plugins

discord nodejs architecture plugin tool

Hello, I'm PanKUN.

In this article, I will summarize how I restructured the architecture of the Discord Bot I operate personally.

Roughly speaking, what I wanted to do was:

  • Be able to extend commands and events by "just adding files"
  • Resident management with pm2 on the server
  • Semi-automatic deployment with git pull + shell script
  • Make it easy to link with "Management Web Page" and "API" in the future

That is the configuration.


Goals and Prerequisites

Goals

  1. Separate Bot core and plugins
    • Treat commands and events as "modules that can be added later".
  2. Make operation easier
    • Leave it to pm2 on Ubuntu Server and it keeps running even if SSH is disconnected.
  3. Simplify deployment
    • Just push to the management repository -> pull + restart on the server side.

Prerequisites

  • Language: Node.js (Looking at TypeScript, currently mainly JS)
  • Library: discord.js
  • Execution environment: Ubuntu Server + pm2
  • External access: Possible to connect from outside by tunneling with playit.gg

Directory Structure

The configuration I finally settled on looks like this.

Text
bot-root/
โ”œโ”€โ”€ Command/        # Command modules
โ”œโ”€โ”€ Event/          # Event handlers
โ”œโ”€โ”€ Data/           # Settings and persistent data like JSON
โ”œโ”€โ”€ Class/          # Common classes and helpers
โ”œโ”€โ”€ Shell/          # Deployment shell scripts
โ”œโ”€โ”€ types/          # Type related like JSDoc
โ”œโ”€โ”€ logs/           # Log output destination for pm2
โ”œโ”€โ”€ config.json     # Tokens and settings (dotenv is also acceptable)
โ””โ”€โ”€ index.js        # Entry point (Core)

The point is that I completely separated Command / Event / Data.

  • index.js (core) "just reads modules and wires them"
  • The content of commands is handled by JS files under Command/
  • Events are handled by JS files under Event/ By doing this, I became able to add functions without touching the Bot body.

Command Plugin Structure

Shape of Command Module

Each command has a JS file roughly in this shape as a single file.

JavaScript
// Command/ping.js
/** @type {import('../types').BotCommand} */
module.exports = {
  name: "ping",
  description: "Command for connection confirmation",
  options: [],
  async execute(interaction, context) {
    const start = Date.now();
    await interaction.reply("Pong!");
    const ms = Date.now() - start;
    console.log(`[ping] latency: ${ms}ms`);
  },
};
  • name / description / options are diverted directly to slash command definitions
  • Pass interaction and common context (settings, logger, etc.) to execute

Dynamic Loading

On the core side, it scans the Command/ directory at startup and loads everything.

What it does is simple:

  1. Get list of .js files with fs.readdirSync("Command")
  2. require and register in Map with name as key
  3. Assemble JSON for slash command registration This is the flow.

Thanks to this, when adding a new command:

  1. Create JS file in Command/
  2. commit + push to Git
  3. pull and restart on server It only takes these steps now.

Event Handler Structure

Events are also file-based with the same vibe as commands.

JavaScript
// Event/ready.js
/** @type {import('../types').BotEvent} */
module.exports = {
  name: "ready",
  once: true,
  execute(client, context) {
    console.log(`[ready] Logged in as ${client.user.tag}`);
  },
};
JavaScript
// Event/interactionCreate.js
module.exports = {
  name: "interactionCreate",
  once: false,
  async execute(interaction, context) {
    if (!interaction.isChatInputCommand()) return;

    const command = context.commands.get(interaction.commandName);
    if (!command) return;

    try {
      await command.execute(interaction, context);
    } catch (err) {
      console.error(err);
      if (!interaction.replied) {
        await interaction.reply({
          content: "An error occurred.",
          ephemeral: true,
        });
      }
    }
  },
};

On the core side, it just scans Event/ and client.on(event.name, handler) or client.once(...) automatically registers it.


pm2 and Deployment Flow

Residing with pm2

On Ubuntu Server, I keep it resident using pm2.

Bash
pm2 start index.js --name bread-bot
pm2 save
pm2 startup
  • Bot keeps running even if SSH is disconnected
  • Check logs with pm2 logs bread-bot
  • Reflect with pm2 restart bread-bot

This is the basic operation.

Deployment Script

For deployment, I simply placed a single script in Shell/.

Bash
#!/usr/bin/env bash
set -eu

cd /opt/bread-bot

echo "[deploy] git pull"
git pull origin main

echo "[deploy] npm install"
npm install --production

echo "[deploy] restart pm2"
pm2 restart bread-bot
  • Update repository -> Execute this script on server side
  • In the future, it might be good to trigger from Webhook or CI

I'm doing it in this form.


Future Extensions

Even now, it has become "a Bot where commands can be added with a single JS file", but here is what I want to do further.

  1. Strengthening around types
  • Convert to TypeScript or add types to interaction / context with JSDoc
  • Summarize ApplicationCommandOptionData into helper dedicated to definition
  1. Linkage with management Web page
  • Set up API with Express / Fastify etc.
  • Check "List of currently registered commands" and "Guild status" from Web
  1. Logs and metrics
  • Send errors and specific events to another channel
  • Consider sending to log infrastructure on Cloud

Summary

  • Split commands/events by directory unit, and make Bot body "just read and connect"
  • Simplify operation and deployment with pm2 and simple shell script
  • Pass common object called context to commands anticipating future extensions (Web management screen and API)

By doing it in this form, I feel like I've largely graduated from "Discord Bot where everything is written in one huge index.js".

I hope this helps people who want to operate Bots for a long time in the same way.


โ† Tried Creatinโ€ฆโ† Back to BlogTried Introduโ€ฆ โ†’