Reevaluating Discord Bot Architecture
Configuration notes for Discord Bot, adding commands/events like plugins
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
pm2on 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
- Separate Bot core and plugins
- Treat commands and events as "modules that can be added later".
- Make operation easier
- Leave it to
pm2on Ubuntu Server and it keeps running even if SSH is disconnected.
- Leave it to
- 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.
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.
// 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:
- Get list of .js files with fs.readdirSync("Command")
- require and register in Map with name as key
- Assemble JSON for slash command registration This is the flow.
Thanks to this, when adding a new command:
- Create JS file in Command/
- commit + push to Git
- 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.
// 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}`);
},
};
// 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.
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/.
#!/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.
- Strengthening around types
- Convert to TypeScript or add types to interaction / context with JSDoc
- Summarize ApplicationCommandOptionData into helper dedicated to definition
- Linkage with management Web page
- Set up API with Express / Fastify etc.
- Check "List of currently registered commands" and "Guild status" from Web
- 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.
Loading comments...