Introduction
discord-bot-rs is a multi-instance Discord bot framework written in Rust. One binary runs any number of bot instances from a single shared codebase, each with its own personality file, configuration, database schema, and feature set. The goal is a batteries-included bot you can actually operate in production: a real music player, real AI chat, real moderation, and a small enough codebase that you can read the whole thing in an afternoon.
What it does
The bot is built from independent feature modules that you enable or
disable per instance in config.toml. Out of the box you get:
- AI chat — @mention the bot and it replies in-character, using DeepSeek V4 as the primary provider and Google Gemini as a fallback, with a configurable personality prompt.
- Music — yt-dlp plus songbird plus ffmpeg, with a full queue, loop modes, shuffle, and interactive button controls on the now-playing embed.
- Games — the daily NYT Wordle, NYT Connections, and a virtual stock trading game backed by live Finnhub quotes.
- Moderation — tempbans with auto-unban, channel nukes, audit log routing, and a DJ-only mode for music commands.
- Minecraft integration — link a Discord account to a Minecraft account, sync donator roles from a game server, and route chargeback alerts to staff.
- An embedded MCP server — every instance exposes a set of Model Context Protocol tools so external AI clients can read channels, send messages, and manage roles through the bot.
Who it’s for
Self-hosters who want a bot they can stand up with one
docker compose up and grow into. The Quickstart
takes about ten minutes.
Developers who want to read and extend real Rust code — a working serenity + poise + songbird + sqlx + axum stack with no magic. The Codebase Tour is the fastest way in.
Community admins running a Minecraft server, a Discord, and whatever else, who want a single bot that handles account verification, donator roles, and chargeback alerts without writing glue code.
What this book covers
- Getting Started — prerequisites, quickstart, first-bot tutorial, and verification.
- Configuration —
.env,config.toml, personality files, secrets, and multi-instance layouts. - Features — a page per feature, with activation, configuration, and troubleshooting.
- Architecture — how the pieces fit together.
- Deployment — Docker Compose, Postgres, upgrades, and the production checklist.
- Development — codebase tour, building locally, adding commands, and the contribution workflow.
- Reference — command list, MCP tool catalog, FAQ, glossary.
Where to start
If you want to run the bot, head to the Quickstart. If you want to contribute, start with the Development Overview and then the Codebase Tour. If you’re evaluating the project for a team, the Architecture Overview is the shortest path to “how does this actually work.”
License
discord-bot-rs is licensed under the GNU Affero General Public License v3.0 or later. See CONTRIBUTING.md for contribution terms.
Getting Started
This section takes you from “I have a Discord server” to “I have my own bot running in it.” It is split into five pages:
- Prerequisites lists what you need to gather before touching the code: a Discord application, a token, Docker, and a host to run on.
- Quickstart is the fast path. If you have done Discord bot setup before, this is the only page you need.
- First Bot Tutorial is the slow path. It walks through every step with no assumed knowledge, including screenshots of the Discord developer portal.
- Verifying Your Setup is the post-deploy checklist. Run it once after your first start to make sure each piece is healthy.
When to use discord-bot-rs
This project is a good fit if any of the following describe you:
- You want to self-host a Discord bot rather than rent a hosted one, and you are comfortable running a Docker container on a Linux box.
- You want one codebase that can run several distinct bots (different names, different personalities, different feature sets) from a single deployment.
- You want a bot with batteries included: AI chat, music, games, moderation, and a Minecraft integration, all behind feature flags so you can switch off what you do not need.
- You want a Rust codebase you can read, fork, and extend without fighting a framework.
When NOT to use it
Reach for something else if:
- You just want a moderation or music bot for one server and you do not want to run anything yourself. A hosted bot from top.gg will be faster.
- You want a JavaScript or Python codebase to hack on. Sapphire.js, discord.py, and pycord are all excellent and have larger communities.
- You need a single small bot and the overhead of Docker plus PostgreSQL feels like too much. A 100-line script is the right answer for that.
System requirements
The bot is tested on Linux. macOS and Windows (via WSL2) work as long as Docker Desktop is running. You will need:
- Docker Engine 20.10 or newer with the Compose plugin.
- About 2 GB of free RAM. The bot itself is small; most of the budget goes to PostgreSQL and ffmpeg during music transcoding.
- About 5 GB of free disk for the Docker images, the database volume, and yt-dlp cache.
- Outbound network access to Discord, your AI provider, and (if you enable music) YouTube.
A Raspberry Pi 4 with 4 GB of RAM is enough for a single instance. A $5/month VPS is enough for several. Your laptop is enough for testing.
Time investment
Plan on about 10 minutes from git clone to a running bot if you have set up
a Discord application before and you already have Docker installed. Budget
closer to 25 minutes if it is your first time, mostly because the Discord
developer portal has a few non-obvious clicks.
Next step
Head to Prerequisites to gather what you need before the first command.
Prerequisites
Before you can run the bot, you need three things: a Discord application (this gives you the bot token and identity), Docker with the Compose plugin (this runs the bot), and a host to run them on. None of these take very long, but the Discord developer portal has some non-obvious clicks, so this page walks through every one.
If you would rather follow a step-by-step version with more hand-holding, see First Bot Tutorial. If you have done all this before, skim and skip to Quickstart.
Discord application
A Discord “application” is the parent object for a bot. It owns the token, the client ID, the OAuth scopes, and the assets (avatar, name, description). You create one through Discord’s developer portal.
Create the application
- Open https://discord.com/developers/applications and sign in.
- Click New Application in the top right.
- Give it a name. This is the public name your bot will appear under in Discord, so pick something you are comfortable with. You can change it later.
- Accept the developer terms and click Create.
You are now on the application’s General Information page. Two things on this page matter:
- The Application ID under the application name. Copy this and save it
somewhere safe. This is your
CLIENT_ID. - (Optional) The icon and description, which become your bot’s profile in Discord. Set them now or later, it does not affect anything technical.
Get the bot token
- In the left sidebar, click Bot.
- Scroll to the Token section. If a token is already displayed, copy it. If not, click Reset Token and copy the new value.
- Save it somewhere safe. This is your
DISCORD_TOKEN.
The token is a password. Anyone who has it can control your bot. Do not
commit it to git, do not paste it into chat, and do not share screenshots of
it. The bot’s .env file (which you will create later) is gitignored
specifically to keep this safe.
If you ever leak it, come back to this page and click Reset Token again. The old one stops working immediately.
Enable privileged intents
Still on the Bot page, scroll to Privileged Gateway Intents. Two intents must be on:
- Server Members Intent — required for member join events, which the auto-role and welcome features depend on.
- Message Content Intent — required for the AI chat feature, which reads
the text of messages that @mention the bot, and for any prefix command
(
!m help,!m play, etc.) that needs to read what the user typed.
You can leave Presence Intent off; nothing in this bot uses it.
Click Save Changes at the bottom of the page.
Invite the bot to your server
The bot exists, but it is not in any server yet. You need to generate an OAuth invite URL.
- In the left sidebar, click Installation (newer portals) or OAuth2 → URL Generator (older portals).
- Under Scopes, check
botonly. (The bot has no slash commands, soapplications.commandsisn’t required — checking it is harmless.) - Under Bot Permissions, check the permissions the features you plan to
use require:
- View Channels, Send Messages, Embed Links, Attach Files, Read Message History are needed for almost everything. Always include these.
- Connect and Speak are needed for music.
- Manage Roles is needed for auto-role, join role, donator sync, and chargeback alerts. The bot’s own role must also be ranked above any role it tries to assign.
- Kick Members, Ban Members, and Moderate Members are needed for the moderation commands.
- Copy the generated URL at the bottom of the page.
- Paste it into your browser, pick the server you want the bot in, and click Authorize.
You can drop any of those permissions if you do not plan to use the corresponding feature. The bot will simply log a warning the first time a disabled feature tries and fails to do its job.
Get the GUILD_ID
The bot needs to know which Discord server is its primary one. That is the “guild ID.”
- In Discord, open User Settings → Advanced and turn on Developer Mode.
- In your server list, right-click the server you just invited the bot to and choose Copy Server ID.
- Save it. This is your
GUILD_ID.
Docker and Docker Compose
The bot ships as a set of Docker images. You need Docker Engine and the
Compose plugin (Compose v2, invoked as docker compose, not the legacy
docker-compose script).
- Linux: install from your distribution’s package manager, or run the
convenience script at https://get.docker.com. Most distributions package
the Compose plugin separately as
docker-compose-pluginordocker-compose-v2. - macOS or Windows: install Docker Desktop. Compose ships with it. On Windows, run everything from inside a WSL2 distribution; native PowerShell has not been tested.
Verify the install:
docker --version
docker compose version
Both commands should print a version. If docker compose version fails but
docker-compose --version works, you have the legacy Python plugin. Install
the v2 plugin and use docker compose (with a space) from then on.
A host to run on
Anything that runs Docker will run the bot. Concrete options:
- A Raspberry Pi 4 (4 GB or 8 GB) on your home network. Plenty for a single instance.
- A small VPS (1 vCPU, 2 GB RAM) from any provider. Hetzner, Vultr, and DigitalOcean all have suitable boxes for around five dollars a month.
- Your laptop, for testing. The bot does not care if it runs intermittently while you are developing.
Plan for about 5 GB of free disk for Docker images, the PostgreSQL volume, and any cached audio.
Optional external services
These are only required if you want the corresponding feature. All can be
added later by editing your .env and restarting.
- DeepSeek API key — primary AI chat provider. Free credits on signup. Sign up at https://platform.deepseek.com/.
- Gemini API key — fallback AI chat provider, used automatically when DeepSeek errors. Free tier is generous. Get a key at https://aistudio.google.com/apikey.
- Finnhub API key — required for the virtual stock trading game. Free tier is enough. Sign up at https://finnhub.io/.
- Minecraft companion plugin — required only if you plan to use the Minecraft verification or donator sync features. See Minecraft: Verify.
If you are not sure whether you want a feature, leave its key blank and enable it later.
What you do NOT need
A few things people commonly ask about that you can ignore:
- An external PostgreSQL — the bundled
docker-compose.ymlincludes one. - A domain name or DNS records — the bot connects out to Discord; nothing needs to reach it from the public internet.
- A reverse proxy or TLS certificate — only relevant if you intend to
expose the embedded MCP server to a network outside
localhost. See MCP Exposure. - A specific Linux distribution — anything that runs current Docker works.
Next step
Once you have your token, client ID, guild ID, and Docker installed, head to Quickstart.
Quickstart
This page is the fast path: about ten minutes from git clone to a running
bot, assuming you have already gathered everything from
Prerequisites. If you have not, do that first. If you
want a slower walkthrough with more context, see
First Bot Tutorial.
By the end of this page you will have a bot online in your Discord server, running locally in Docker, talking to a bundled PostgreSQL container.
Step 1: Clone the repo
git clone https://github.com/MrMcEpic/discord-bot-rs.git
cd discord-bot-rs
The repo is small (a few hundred kilobytes) and has no submodules.
Step 2: Create your instance directory
Each bot is configured by a directory under instances/. The example
directory is the canonical reference and is also the directory the bundled
docker-compose.yml points at by default. Copy it to a new name for your own
bot:
cp -r instances/example instances/mybot
cp instances/mybot/.env.example instances/mybot/.env
mybot is just a label. Use whatever name you like. The name does not have
to match the bot’s display name in Discord.
Step 3: Fill in .env
Open instances/mybot/.env in your editor of choice. The required fields
are at the top:
DISCORD_TOKEN=... # from the Discord developer portal (Bot page)
CLIENT_ID=... # the application ID from General Information
GUILD_ID=... # right-click your server in Discord, Copy Server ID
Paste in the values you saved during Prerequisites.
The next block is the database. If you are using the bundled PostgreSQL, the default works as-is:
DATABASE_URL=postgresql://discord_bot:discord_bot_pass@postgres:5432/discord_bot
DB_SCHEMA=mybot
Change DB_SCHEMA to match your instance name. Each instance gets its own
PostgreSQL schema, so you can run several bots side by side without their
data colliding.
If you want AI chat, paste in DEEPSEEK_API_KEY and/or GEMINI_API_KEY. If
you want stock trading, paste in FINNHUB_API_KEY. Anything you leave blank
just disables the corresponding feature.
One pair of values is mandatory for the bundled stack: MCP_AUTH_TOKEN
inside the instance .env (read by the bot container) and
MCP_GATEWAY_AUTH_TOKEN inside a .env at the repo root (read by Docker
Compose and forwarded to the mcp-gateway service). The bot and gateway
both refuse to start with an empty token on a non-loopback bind, and the
gateway uses its token as the bearer when reaching the bot, so the two
values must be identical. Generate one and install it in both places:
TOKEN=$(openssl rand -hex 32) && \
sed -i.bak "s|^MCP_AUTH_TOKEN=.*|MCP_AUTH_TOKEN=$TOKEN|" instances/mybot/.env && \
rm instances/mybot/.env.bak && \
echo "MCP_GATEWAY_AUTH_TOKEN=$TOKEN" >> .env
See MCP Exposure for the security model.
Save the file. It is gitignored, so it will not end up in any commit you make.
Step 4: Review config.toml
Open instances/mybot/config.toml. The fields you most likely want to
change are at the top:
bot_name = "My Bot"
command_prefix = "!"
personality_file = "personality.txt"
bot_name is what the bot calls itself in help text and welcome messages.
command_prefix is the prefix for text-based commands (the default ! gives
you !m help, !m play, etc.). discord-bot-rs uses prefix commands only —
there are no slash commands.
Below that is a [features] block with feature flags:
[features]
minecraft = false
auto_role = false
join_role = false
welcome = false
Leave them all false for your first run. You can turn things on later once
the bot is up. See Instance Config for
what each flag does.
Step 5: Tweak personality.txt (optional)
instances/mybot/personality.txt is the system prompt for AI chat. The
default is a starting template; you can customize tone and behavior here. If
you are not sure what to put, skip this for now and try the defaults first.
You can edit and restart the bot at any time.
Step 6: Start the stack
From the repo root:
INSTANCE_DIR=./instances/mybot docker compose up -d
The first run takes a few minutes because Docker has to build the bot image. Subsequent starts are seconds.
INSTANCE_DIR tells Compose which instance directory to mount into the
container. If you skip the variable, it defaults to ./instances/example,
which is fine for trying things out but you will want it pointing at your
real instance long-term.
The stack starts three services:
postgres— bundled PostgreSQL 17.bot— the Discord bot itself.mcp-gateway— a small HTTP router for the embedded MCP server (safe to ignore for now; see MCP Server when you are curious).
Step 7: Watch the logs
docker compose logs -f bot
You should see output that ends with something like:
INFO discord_bot::db: Database initialized (schema: example).
INFO discord_bot: Instance config loaded: Example Bot (prefix: !)
INFO discord_bot: Starting bot...
INFO discord_bot::events::ready: Example Bot is connected! (ID: ...)
INFO discord_bot::mcp: MCP server listening on 0.0.0.0:9090
Press Ctrl+C to stop tailing. The bot keeps running.
Step 8: Test it in Discord
Open your Discord server. The bot should appear in the member list with a green dot.
- Type
!m help(or whatever prefix you set). You should see a list of commands. discord-bot-rs uses prefix commands only — there are no slash commands. - @mention the bot in a channel. If you set an AI API key, it should reply. If you did not, the mention is ignored.
That is the minimum success criterion. For a more thorough check, run through Verifying Your Setup.
Troubleshooting
A few common failures and what to do about them.
The bot never comes online (no green dot in the member list).
The token is wrong, or the privileged intents are off. Check
docker compose logs bot for an error like “Invalid Token” or “Disallowed
intents.” Go back to Prerequisites and double-check both the token and the
two privileged intents on the Bot page of the developer portal.
Postgres reports unhealthy in docker compose ps.
Run docker compose logs postgres. The most common cause is the disk being
full or the PostgreSQL data volume being corrupted from an interrupted
shutdown. docker compose down followed by docker compose up -d fixes most
transient issues. If the volume is genuinely broken, docker volume rm discord-bot-rs_pgdata and start fresh (this destroys all bot data).
!m help does not get a reply.
Check that the bot has Read Messages, Send Messages, and Read Message History permission in that channel. Some channels inherit deny-by-default overrides that block bot replies. The bot needs Message Content Intent enabled in the Discord developer portal to read your prefix commands at all — if you forgot that during prerequisites, re-enable it and restart the bot.
The bot is online but @mentioning it does nothing.
AI chat needs at least one of DEEPSEEK_API_KEY or GEMINI_API_KEY in
.env. If neither is set, AI chat is silently disabled. Set one, then
restart with docker compose restart bot and watch the logs for any API
errors on the next mention.
Next steps
Once your bot is up and replying:
- Verifying Your Setup — run through the post-deploy checklist.
- Features — see what each feature does and how to configure it.
- Production Checklist — when you are ready to leave it running long-term.
First Bot Tutorial
This page is the slow path. It walks through every step of getting a Discord bot running, with no assumptions about prior experience. If you have set up a Discord bot before, Quickstart is the short version of this same flow.
Who this tutorial is for
You have heard of Discord bots. You have not set one up yourself, you have not built a Rust project, and “Docker” is at most a vague impression. That is fine. By the end of this page you will have a real bot in a real Discord server, running on hardware you control. You will copy a few files, fill in a few blanks, and run a few commands.
What you will build
A Discord bot you fully own:
- Online in a server you control.
- Responds to
!m helpwith a command list. - Plays music in a voice channel with
!m play <url>. - Chats with you when you @mention it (if you provide an AI API key).
- Can play Wordle and other small games.
- Stores its data in a PostgreSQL database that runs alongside it.
The whole thing runs in Docker on one machine. There is no website to set up, no domain to buy, no public IP needed.
Section 1: Create the Discord application
Discord calls the parent object that owns a bot an “application.” Creating one takes a few clicks in their developer portal.
- Open https://discord.com/developers/applications and log in with your normal Discord account.
- Click New Application in the top right.
- Give it a name (this becomes the bot’s display name; you can change it later), accept the terms, and click Create.
You are now on the General Information page.
Find the Application ID and copy it. Save it in a notes file labeled
CLIENT_ID. You will need it later.
Get the bot token
The token is the secret password your code uses to log in as this bot.
- Click Bot in the left sidebar.
- In the Token section, click Copy (or Reset Token if no token
is shown), and save the value as
DISCORD_TOKEN.
Treat this string like a banking password. Do not paste it in chat, do not screenshot it, do not commit it to git. The configuration files you will create later are gitignored specifically so a slip of the keyboard cannot leak it. If you ever do leak it, click Reset Token again and the old token stops working immediately.
Turn on the privileged intents
Still on the Bot page, scroll to Privileged Gateway Intents.
Turn on Server Members Intent and Message Content Intent. Leave
Presence Intent off. The bot needs Server Members so it can react to
joins (welcome and auto-role features), and Message Content so it can read
@mentions for AI chat and parse text commands like !m help.
Click Save Changes at the bottom.
Section 2: Invite the bot to your server
The application exists, but it is not in any server yet.
- Click Installation in the sidebar (or OAuth2 → URL Generator on older portals).
- Under Scopes, check
bot. (applications.commandsisn’t needed — the bot has no slash commands — but checking it is harmless if your workflow already includes it.) - Under Bot Permissions, check at minimum: View Channels, Send Messages, Embed Links, Attach Files, Read Message History, Connect, Speak, Manage Roles, Kick Members, Ban Members, Moderate Members. You can drop any of these later if you do not use the matching feature.
- Copy the URL at the bottom, paste it into a new tab, choose your server (you must have Manage Server there), and click Authorize.
The bot now appears in your server’s member list with a grey dot. The dot turns green once you start the code.
Get the server ID
- In Discord, open User Settings → Advanced and turn on Developer Mode.
- Right-click your server icon and choose Copy Server ID.
- Save it as
GUILD_ID.
You should now have three values:
DISCORD_TOKEN=...
CLIENT_ID=...
GUILD_ID=...
Section 3: Install Docker
Docker runs the bot. The bot ships as a container so you do not have to install Rust, set up a database server, or worry about library versions.
Linux. Use the convenience script at https://get.docker.com, or your
distribution’s packages: apt install docker.io docker-compose-v2 on
Debian/Ubuntu, dnf install docker docker-compose on Fedora. Add yourself
to the docker group with sudo usermod -aG docker $USER, then log out
and back in.
macOS. Install Docker Desktop and launch it once after install.
Windows. Install Docker Desktop with the WSL2 backend, install a WSL2 Linux distribution from the Microsoft Store (Ubuntu is fine), and run all the commands in this tutorial from inside that distribution. Native PowerShell is technically possible but not worth the friction.
Verify in a terminal:
docker --version
docker compose version
Both should print a version.
Section 4: Clone and configure
Fetch the code:
git clone https://github.com/MrMcEpic/discord-bot-rs.git
cd discord-bot-rs
Make a copy of the example instance directory:
cp -r instances/example instances/mybot
cp instances/mybot/.env.example instances/mybot/.env
mybot is just a directory name. Pick whatever you like.
Open instances/mybot/.env in any text editor and paste in the three
values you saved earlier:
DISCORD_TOKEN=...
CLIENT_ID=...
GUILD_ID=...
A few lines down, change DB_SCHEMA to match your instance name:
DB_SCHEMA=mybot
That is the minimum. Save the file.
Open instances/mybot/config.toml. The first few lines control identity:
bot_name = "My Bot"
command_prefix = "!"
Change bot_name to whatever you want shown in help text. Leave
command_prefix as ! for now; the rest of the documentation assumes it.
Save and close.
If you want AI chat, also paste a DEEPSEEK_API_KEY or GEMINI_API_KEY
into .env. Both are free to start with — see
Prerequisites for signup links. You can add them later.
One last required step: generate an MCP auth token. The bundled stack
won’t start without one, because the embedded MCP server and the
mcp-gateway sidecar both refuse to run without a shared secret set.
Generate one random value and install it in two places: MCP_AUTH_TOKEN
in instances/mybot/.env (the bot reads it) and MCP_GATEWAY_AUTH_TOKEN
in a new .env at the repo root (Docker Compose reads it and feeds it to
the gateway). Both must hold the same value — the gateway uses it as
the bearer when reaching the bot, so a mismatch locks the two out of each
other:
TOKEN=$(openssl rand -hex 32) && \
sed -i.bak "s|^MCP_AUTH_TOKEN=.*|MCP_AUTH_TOKEN=$TOKEN|" instances/mybot/.env && \
rm instances/mybot/.env.bak && \
echo "MCP_GATEWAY_AUTH_TOKEN=$TOKEN" >> .env
If you prefer editing by hand, run openssl rand -hex 32, copy the
output, paste it after MCP_AUTH_TOKEN= in instances/mybot/.env, and
add a single line MCP_GATEWAY_AUTH_TOKEN=<paste> to a new file called
.env at the repo root (next to docker-compose.yml).
Section 5: First run
Start the stack:
INSTANCE_DIR=./instances/mybot docker compose up -d
The first run takes a few minutes because Docker has to download PostgreSQL and build the bot from source. Once it finishes, you get your shell prompt back.
Tail the logs:
docker compose logs -f bot
You will see, roughly in order:
- Postgres starting up and reporting it is ready to accept connections.
- The bot connecting to Postgres and creating its schema (“Database initialized (schema: mybot)”).
- The bot loading its instance config (“Instance config loaded: My Bot (prefix: !)”).
- The bot connecting to Discord (“Starting bot…” → shard running → “My Bot is connected! (ID: …)”).
- The MCP server binding to port 9090. From here on the bot is online.
Press Ctrl+C to stop tailing. The bot keeps running. To stop everything,
run docker compose down from the repo root.
Section 6: Test it
Open Discord. Your bot should now have a green dot in the member list.
In any channel the bot can see, type:
!m help
You should get a help embed listing the available commands. discord-bot-rs
uses prefix commands only — there are no slash commands — so !m help is
how you discover everything.
Section 7: What to try next
Now that the bot works, here are some things to play with.
- Customize the personality. Edit
instances/mybot/personality.txtand put whatever you want the bot’s voice to be. Restart withdocker compose restart bot. - Try AI chat. With an API key set, @mention the bot and ask it something. It will reply in the personality you defined.
- Try music. Join a voice channel, then run
!m play <YouTube URL>in any text channel.!m skipskips,!m queueshows the queue. - Try Wordle. Run
!m wordlein a channel. - Read Features to see everything the bot can do and how to enable the optional pieces.
Section 8: Common first-time mistakes
If something is not working, the issue is almost always one of these.
Bot stays grey (offline) and never goes green. The token is wrong, the
privileged intents are off, or both. Check docker compose logs bot —
“Invalid Token” means reset the token and paste the new value; “Disallowed
intents” means go back to the Bot page in the portal and verify both Server
Members Intent and Message Content Intent are enabled.
!m help returns nothing. The bot needs Message Content Intent
enabled in the Discord developer portal to read prefix commands. If you
skipped that in Section 1, re-enable it and restart the bot with
docker compose restart bot. Also check that the bot has permission to
read and send messages in the channel you are typing in.
Bot replies to !m help but not to @mentions. AI chat is disabled
because no API key is set. Add DEEPSEEK_API_KEY or GEMINI_API_KEY to
.env, then docker compose restart bot.
Postgres keeps restarting. Run docker compose logs postgres. Usually
this is a disk space issue or a corrupted volume from an unclean shutdown.
docker compose down followed by docker compose up -d resolves most
transient issues.
The first build hangs or fails. The Rust build downloads many crates and compiles for several minutes. If your machine has very little RAM (under 2 GB), the build may run out of memory. The smallest VPS that reliably builds the project is 2 GB RAM with at least 2 GB of swap.
If none of these apply, run through Verifying Your Setup for a more thorough diagnostic, or open an issue on GitHub.
Verifying Your Setup
Once you have run docker compose up -d and the logs look reasonable, this
page is the post-deploy checklist. Run through it once. If every command
matches its expected output, your bot is healthy. If something is off, the
bottom of the page points at what to read next.
All commands assume you are in the repo root.
Docker-level checks
The containers should all be running and healthy.
docker compose ps
You should see three services: postgres, bot, and mcp-gateway. Each
should report running in the STATUS column. postgres and bot also
report healthy once their healthchecks settle (a few seconds for
postgres, up to a minute for the bot the first time).
If any service is restarting, look at its logs to find out why.
Next, the bot’s recent logs:
docker compose logs bot --tail 20
A healthy startup ends with a sequence like this, in roughly this order:
INFO discord_bot::db: Database initialized (schema: example).
INFO discord_bot: Instance config loaded: Example Bot (prefix: !)
INFO discord_bot: Starting bot...
INFO serenity::gateway::bridge::shard_runner: [ShardRunner ShardInfo { id: ShardId(0), total: 1 }] Running
INFO discord_bot::events::ready: Example Bot is connected! (ID: 123456789012345678)
INFO discord_bot::mcp: MCP server listening on 0.0.0.0:9090
Exact wording can shift slightly between releases, but you should see the
database schema initialize, the instance config load, a shard enter
Running, a line stating that the bot “is connected” along with its
user ID, and the MCP server bind to port 9090. If the last log line is
hours old and there is nothing about errors, the bot is sitting idle.
That is fine.
And postgres:
docker compose logs postgres --tail 5
You should see database system is ready to accept connections.
Discord-level checks
Open Discord. The bot should appear in your server’s member list with a green dot. A grey dot means the bot is not running or cannot reach Discord; the Docker-level checks above will tell you which.
Try a prefix command in any channel the bot can see:
!m help
You should get a help embed immediately. If your config.toml sets a
different prefix, use that instead. discord-bot-rs uses prefix commands
only — there are no slash commands to sync.
If you configured an AI API key, @mention the bot and ask it something. You should get a reply within a few seconds. If you did not configure an AI key, mentions are silently ignored — that is expected, not a bug.
Database-level checks
The bot uses one PostgreSQL database with a separate schema per instance. Confirm your instance’s schema exists:
docker compose exec postgres psql -U discord_bot -d discord_bot -c '\dn'
You should see at least one schema beyond the built-in public,
information_schema, and pg_* schemas. The name should match your
instance’s DB_SCHEMA from .env.
If the schema is missing, the bot did not finish its first migration —
check docker compose logs bot for migration errors.
MCP-level checks
If you have not exposed the MCP port (the default), this section does not apply. The MCP server inside the bot still runs, but it is only reachable from inside the Docker network.
If you have mapped the port to your host, confirm it responds:
curl -i http://localhost:9090/mcp
You should get an HTTP response. The exact body depends on whether you configured authentication; the important thing is that you get a response rather than a connection refused. See MCP Exposure for how to expose it safely.
What “good” looks like
A healthy bot has all of the following true at the same time:
- All Compose services report
running, andpostgresandbotreporthealthy. - The bot’s recent logs contain a “is connected! (ID: …)” line and have no errors.
- The bot has a green dot in your Discord server.
!m helpreplies when invoked in a channel the bot can read.- Your instance’s PostgreSQL schema exists.
If those five things are true, you are done. From here, treat any future
log errors as the diagnostic signal — the bot logs to stdout, which Compose
captures and docker compose logs reads.
What to do if something fails
If any of the checks above fail, two pages have most of the answers:
- Monitoring covers reading logs, watching healthchecks, and what each service’s failure modes look like.
- FAQ collects the issues that come up repeatedly.
If neither helps, open an issue on
https://github.com/MrMcEpic/discord-bot-rs with the output of
docker compose ps and the last 50 lines of docker compose logs bot.
Configuration
discord-bot-rs splits its configuration across three files per instance, each with a clear job. Once you understand the split, every other page in this section is just filling in the field-by-field detail.
The three-file model
Every instance lives in its own directory under instances/ and contains the same three files:
.envholds secrets and runtime connection details: the Discord token, the database URL, AI provider API keys, and the schema name. It is loaded bydotenvyat startup and exists only on the host (and inside the container at runtime).config.tomlholds the bot’s identity and feature surface: its display name, command prefix, which feature modules are enabled, and the IDs/settings each feature needs (role IDs, channel IDs, intervals). This file is checked in as part of your fork and versioned alongside the rest of the repo.personality.txtholds the free-form prose that becomes the system prompt for AI chat. It is plain text — no escaping, no structure — so editing it feels like editing a doc, not a config file.
Why the split
Mixing secrets and structured config in one file is a recipe for accidentally committing tokens. Splitting them lets each format do what it does best.
.env is gitignored at the repo root, so secrets stay out of git history by default. Rotating a key is one line edit and a restart, with no risk of touching feature settings. config.toml is the opposite: checked in, typed, and reviewable in pull requests, so changes to feature behavior are visible and traceable. personality.txt is plain prose because system prompts are prose — putting them inside TOML would mean wrestling with multiline string escapes every time you wanted to tweak a sentence.
What lives where
| What | File | Example |
|---|---|---|
| Discord bot token | .env | DISCORD_TOKEN=MTxxxxxxxxxxxxxxxx... |
| Database URL | .env | DATABASE_URL=postgresql://user:pass@host:5432/db |
| Postgres schema name | .env | DB_SCHEMA=mybot |
| AI provider API keys | .env | DEEPSEEK_API_KEY=sk-... |
| Bot display name | config.toml | bot_name = "My Bot" |
| Command prefix | config.toml | command_prefix = "!" |
| Feature flags | config.toml | [features] auto_role = true |
| Role and channel IDs | config.toml | [auto_role] from_role = "123456789012345678" |
| AI personality | personality.txt | You are a friendly assistant on this Discord server. |
How the files are loaded
At startup the bot does the following, in order:
- Calls
dotenvy::dotenv()to read the.envfile from the current working directory, exporting each key into the process environment. This populatesDISCORD_TOKEN,DATABASE_URL, and friends. - Determines
CONFIG_DIRfrom the environment, defaulting to.(the current directory). Inside the Docker image this is set to/config, which Docker Compose mounts from the instance directory on the host. - Reads
config.tomlfromCONFIG_DIRand parses it into theInstanceConfigstruct. Failures here panic with a clear error pointing at the file. - Reads
personality.txt(or whateverpersonality_fileis set to) fromCONFIG_DIR. An empty or missing file panics; the AI chat module needs a non-empty system prompt. - Logs the loaded
bot_name, command prefix, and which feature modules are enabled, then connects to Discord and Postgres.
The mapping is mechanical: one instance directory on the host becomes /config inside the container, and everything the bot needs is in that directory. Nothing else is read.
Configuration is per-instance
Each bot you run is a separate instance with its own .env, config.toml, and personality.txt. They share a Postgres database (with schema-level isolation per instance) and, optionally, a single MCP gateway, but otherwise they are wholly independent processes. See Multiple Instances for the multi-bot recipe and Multi-Instance Model for the architectural picture.
Where to next
- Environment Variables — the canonical reference for every variable the bot reads from
.env. - Instance Config — the canonical reference for every field in
config.toml. - Personality Files — how to write a system prompt that does what you want.
- Secrets Management — keeping
.envout of git, rotating tokens, and what to do if a secret leaks. - Multiple Instances — running more than one bot from a single repo and database.
Environment Variables
This page is the canonical reference for every environment variable the bot reads. The source of truth is Config::load() in src/config.rs; if you find a variable in the code that isn’t on this page, please open an issue.
Overview
The bot reads its environment from two places:
- A
.envfile in the current working directory, parsed at startup bydotenvy. The file is plainKEY=VALUElines; comments start with#. Empty values are treated as if the variable were unset for every optional key here, soDEEPSEEK_API_KEY=means “no DeepSeek key.” - The process environment itself. Anything Docker Compose passes via
env_fileor anenvironment:block also works, and overrides the same key in the file.
Inside Docker Compose the standard pattern is env_file: instances/yourbot/.env, which loads the file once when the container starts. There is no live reload — changing a value requires restarting the container.
The variables fall into seven groups: Discord core (required), database (required), AI providers (optional), Finnhub (optional), Minecraft (optional), the embedded MCP server (optional), and the MCP gateway (optional, only used by the gateway service).
Discord core (required)
| Name | Required | Default | Description |
|---|---|---|---|
DISCORD_TOKEN | yes | — | Bot token from the Discord Developer Portal |
CLIENT_ID | yes | — | Application (client) ID for the bot user |
GUILD_ID | yes | — | Snowflake of the guild this instance is bound to |
All three are validated at startup. If any is missing the bot panics with <KEY> must be set in .env. There is also a placeholder check: if a value still starts with the literal string your- (as in the shipped .env.example), the bot panics with a hint that you forgot to fill it in.
DISCORD_TOKEN
Created in the Discord Developer Portal under your application’s Bot page. Format is MTxxxxxxxxxxxxxxxxxxxxx.G_xxxxxxxxxxxxxxxxxxxxxxxxxxx or similar — there is no fixed length, but if it doesn’t start with the right prefix Discord will reject it. Treat this like a password: it grants full control over the bot user. See Secrets Management for rotation.
CLIENT_ID
The application’s snowflake ID, also from the Developer Portal (top of the General Information page). The bot uses this for command registration. It is not a secret in the same way the token is — leaking it doesn’t compromise the bot — but you should still keep it with the rest of your config.
GUILD_ID
The snowflake of the Discord server this instance manages. The bot uses this as the default guild for its MCP tools, event handling, and auto-role checks. Right-click your server icon in Discord with Developer Mode enabled to copy it.
Database (required)
| Name | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | yes | postgresql://discord_bot:discord_bot_pass@localhost:5432/discord_bot | PostgreSQL connection string |
DB_SCHEMA | yes | public | Postgres schema this instance reads and writes |
DATABASE_URL
Standard postgresql://user:password@host:port/database connection string, parsed by sqlx. The default points at the bundled Compose service; if you’re using docker-compose.yml from this repo as-is, keeping it pointed at postgres:5432 (the service name on the Compose network) works. Outside Docker, point it at wherever your Postgres lives.
The default is technically a fallback rather than a hard requirement — the loader uses it if the variable is unset. But you should always set it explicitly so that misconfigurations fail loudly instead of silently connecting to a fictional localhost:5432.
DB_SCHEMA
The Postgres schema name this instance owns. The default is public, but for any real deployment you should set this to a unique value per instance — mybot1, mybot2, and so on. At connection time the bot runs SET search_path TO "<schema>" on every new pool connection (see src/db/mod.rs), so all queries land in that schema and instances can’t see each other’s tables. See Multiple Instances and Multi-Instance Model for the full picture.
AI providers (optional)
| Name | Required | Default | Description |
|---|---|---|---|
DEEPSEEK_API_KEY | no | unset | API key for DeepSeek chat completions |
GEMINI_API_KEY | no | unset | API key for Google Gemini chat completions |
GROK_API_KEY | no | unset | API key for xAI Grok (used as a CENSORED-cascade alt) |
All three are independently optional. If DEEPSEEK_API_KEY and GEMINI_API_KEY are both unset the AI chat feature is disabled — mention the bot and you’ll get nothing back. If only one is set it is used. If both are set the bot uses DeepSeek as primary text and Gemini for image vision. GROK_API_KEY is only used when an instance opts into the CENSORED cascade via [ai.fallback] in its config.toml. Empty strings (DEEPSEEK_API_KEY=) are normalized to “unset.”
Custom providers may name any env var. The api_key_env field of an [ai.providers.<name>] block in instance config.toml names the env var the bot will read for that provider’s bearer token. The default-registry providers’ api_key_env values map to the env vars listed above. See AI Providers for the schema and worked examples (custom env-var names, multiple providers, etc.).
DEEPSEEK_API_KEY
Get one at platform.deepseek.com. DeepSeek’s chat models are the cheapest of the supported providers and are recommended as the primary. See the AI Chat feature page for model selection details.
GEMINI_API_KEY
Get one in Google AI Studio. Used as the vision provider when image attachments are present in the prompt or replied-to message. Also valid as a CENSORED-cascade fallback if listed in [ai.fallback].
GROK_API_KEY
Get one at console.x.ai. Optional; needed only if an instance lists "grok" in its [ai.fallback] on_censored config. Grok is a less-restrictive alternative the bot can replay a refused conversation through when DeepSeek hits its content-moderation block. See the AI Chat feature page for the cascade story.
Finnhub (optional)
| Name | Required | Default | Description |
|---|---|---|---|
FINNHUB_API_KEY | no | unset | API key for the Stocks feature |
Required only if you use the virtual stock trading game. Free tier keys are available at finnhub.io and have generous limits. If unset, stocks-related commands return a not-configured message.
Minecraft integration (optional)
| Name | Required | Default | Description |
|---|---|---|---|
MC_VERIFY_URL | when any minecraft sub-feature is on | unset | Base URL of the companion plugin’s HTTP API |
MC_VERIFY_SECRET | when any minecraft sub-feature is on | unset | Shared secret for HMAC requests |
These are unset by default; the bot only needs them when features.minecraft = true in config.toml and at least one Minecraft sub-feature (verify, donator_sync, chargeback) is enabled. The companion plugin lives on the Minecraft server and exposes verification and donator-tier endpoints; both URL and secret are required for any of those calls to work.
See Minecraft Verify, Minecraft Donator Sync, and Minecraft Chargeback.
MCP server (optional)
The bot embeds a Model Context Protocol server so external tools can drive Discord operations programmatically. These three variables control where it listens and how it authenticates.
| Name | Required | Default | Description |
|---|---|---|---|
MCP_PORT | no | 9090 | TCP port the MCP server listens on |
MCP_BIND_ADDR | no | 127.0.0.1 | Bind address for the MCP server |
MCP_AUTH_TOKEN | when bind is non-loopback | empty | Bearer token for MCP requests; hard-required on any non-loopback bind |
MCP_PORT
The port the in-process MCP server binds to. Must be a number; an unparseable value panics at startup. Default 9090 is fine for a single-instance setup. When running multiple instances on the same host you can either keep all internal ports the same and let Docker isolate them, or assign different host ports if you expose them.
MCP_BIND_ADDR
The address to bind to. Two defaults are in play here, and the difference matters:
Config::load()fallback (no value set anywhere):127.0.0.1. Loopback only.- Shipped
instances/example/.env.example:0.0.0.0. The bundled Compose stack has themcp-gatewaysidecar reach the bot over the Docker bridge network athttp://bot:9090, which requires the bot to bind on a non-loopback interface inside its own container.
Pick based on shape:
- Single-host, no gateway, you only want loopback access: set
MCP_BIND_ADDR=127.0.0.1(or simply unset it and let the fallback apply). - Bundled Docker Compose with the gateway sidecar: keep
MCP_BIND_ADDR=0.0.0.0from the example. Each bot lives in its own container, so “all interfaces” means “the container’s interface on the Compose bridge network” — not the host’s public IP. Pair this withMCP_AUTH_TOKEN; the bot now refuses to start without one (see below).
See MCP Exposure for the threat model and the deploy shapes in detail.
MCP_AUTH_TOKEN
Bearer token required on all MCP requests. The default is an empty string, which disables auth entirely.
This is now a hard startup requirement when MCP_BIND_ADDR is anything other than a loopback address. If MCP_AUTH_TOKEN is empty and the bind address is non-loopback, the bot logs an error and refuses to start. The check is in src/mcp/mod.rs. The intent is to prevent the easy mistake of binding 0.0.0.0 for a Compose deploy and forgetting to set a token — which would otherwise hand programmatic control of the bot to anyone who could reach the port.
Loopback binds with no token are still allowed (and are the right answer for a single-host bot with no gateway). Comparison against the configured token is constant-time (via the subtle crate) so the auth path doesn’t leak token contents through timing.
See MCP Exposure for the full security model and the two deploy shapes.
MCP gateway (optional)
| Name | Required | Default | Description |
|---|---|---|---|
MCP_GATEWAY_AUTH_TOKEN | gateway service | unset | Bearer token clients use to talk to the gateway |
This variable is read by the separate mcp-gateway service in docker-compose.yml, not by the bot binary itself. It is the token that external MCP clients (Claude Code, etc.) present when calling the gateway, which then proxies the request to the appropriate per-instance MCP server. See MCP Gateway Routing.
The gateway always binds non-loopback and treats this token as mandatory. If MCP_GATEWAY_AUTH_TOKEN is empty the gateway refuses to start at all — there is no loopback escape hatch like the bot has, because the gateway’s whole job is to be reachable from outside its container.
The same value must be set as each bot’s MCP_AUTH_TOKEN. The gateway uses MCP_GATEWAY_AUTH_TOKEN for both inbound auth (checking client bearers) and outbound auth (as the Authorization: Bearer header it forwards to every backend bot). A bot with a different MCP_AUTH_TOKEN will 401 the gateway at startup. Generate one secret with openssl rand -hex 32 and use it in both the gateway environment and every bot’s .env.
A note on placeholder detection
Config::load() rejects any required variable whose value still starts with the literal string your- — for example, DISCORD_TOKEN=your-discord-bot-token. This catches the easy mistake of copying .env.example to .env and forgetting to actually fill it in. If you see <KEY> has placeholder value — set it in .env at startup, that’s the check firing.
Instance Config (config.toml)
config.toml describes a bot instance’s identity, command surface, and feature configuration. Every field on this page comes from the InstanceConfig, Features, AutoRoleConfig, JoinRoleConfig, WelcomeConfig, MinecraftConfig, DonatorSyncConfig, and ChargebackConfig structs in src/instance_config.rs. If you find a field in the source that isn’t documented here, please open an issue.
The file is read once at startup from CONFIG_DIR/config.toml (where CONFIG_DIR defaults to the working directory and is /config inside the Docker image). Parse failures panic with a path and the underlying TOML error — you’ll see them immediately when the container starts.
Top-level fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
bot_name | string | yes | — | Display name shown in help output and logs |
command_prefix | string | yes | — | Prefix for text-based commands |
command_root | string | no | "m" | Parent command name; "" for flat commands |
personality_file | string | no | "personality.txt" | Path to the personality file, relative to config.toml |
timezone | string | no | (UTC) | IANA timezone for the AI system prompt’s date/time line |
bot_name
The human-readable name of this instance. It is used in help output, in welcome messages where templates reference it, and in startup log lines so you can tell which instance you’re looking at when you tail several at once.
command_prefix
The prefix the bot listens for on text commands. The repo ships with "!" in the example, but you can use anything that won’t collide with normal chat. discord-bot-rs uses prefix commands only — there are no slash commands — so this setting controls how users invoke every command.
command_root
The name of the parent command that wraps every subcommand. Default "m" gives users the historical form <prefix>m <subcommand> (e.g. !m play). Three modes:
| Value | Effect |
|---|---|
"m" (default) | !m play, !m skip, … (current behaviour) |
"bot" etc. | !bot play, !bot skip, … |
"" (empty) | !play, !skip, … (flat — no parent) |
The renamed-parent mode is useful when you run multiple bot instances in the same Discord guild — give each one a distinct command_root so users can address them unambiguously even when they share command_prefix. The flat mode is useful for single-bot servers where the prefix alone is enough discriminator.
Validation: must be a single token. Whitespace is rejected at startup with a clear error pointing back at this field.
The bot pre-renders the full command-invocation string (<prefix><root> for the rename case, <prefix> for the flat case) and uses it everywhere the help output prints example commands, so the help embed and the bot’s Discord activity status (Playing <prefix>help or @ me) both stay consistent across all three modes.
Note: the rest of these docs (and the README) use
!m play/!m skip/ etc. as their command examples, since!is the defaultcommand_prefixandmis the defaultcommand_root. If you’ve customized either field, mentally substitute your values when reading them —!m play→<your-prefix><your-root> play. The bot itself always shows the right form at runtime, so this only affects reading the docs.
personality_file
The filename (relative to the same directory as config.toml) where the AI system prompt lives. Defaults to personality.txt. The file must exist and must not be empty when AI chat is active — the loader panics if either condition fails. See Personality Files for how to write one.
timezone
Optional IANA timezone name (for example "America/Toronto", "Europe/London", "Asia/Tokyo") used to build the “current date / time” line in the AI system prompt. When this field is set, the bot formats the line as the local date, local clock time, IANA zone, and numeric UTC offset — for example:
Today is Friday, April 17, 2026. Current local time: 10:52 PM (America/Toronto, UTC-04:00).
When the field is absent, the bot falls back to UTC and explicitly labels the line as UTC so the AI model doesn’t guess at a timezone. The bare-UTC fallback is safe for non-chat workloads but is typically wrong for conversation: at 10:52 PM local in most of the Americas, UTC has already rolled over to the next day, and without a timezone label the model will happily report “today” as tomorrow’s date (and sometimes invent a plausible-sounding city to justify it).
Accepted values are any zone name chrono-tz can parse. Prefer full IANA zone names like "America/New_York", "America/Los_Angeles", or "Europe/Paris" over bare abbreviations like "EST", "PST", or "CET" — the abbreviations parse as fixed offsets with no daylight saving, so a config that says "EST" will report the wrong time for half the year. A value chrono-tz can’t parse makes the bot panic at startup with the offending string, so misconfiguration fails loudly rather than silently defaulting.
[features] section
The features table holds the master feature flags. Every flag defaults to false, so an empty [features] section (or no section at all) means a stripped-down bot that only does AI chat, music, games, and the always-on commands.
| Field | Type | Default | Gates |
|---|---|---|---|
minecraft | bool | false | The Minecraft module (verify, donator_sync, chargeback) |
auto_role | bool | false | Periodic auto-role promotion based on member age and activity |
join_role | bool | false | Assigning a role to every new member on join |
welcome | bool | false | AI-generated welcome messages on member join |
Setting a flag to false disables the entire feature area; the corresponding sub-section ([auto_role], [minecraft], etc.) is not required and will be ignored if present. Setting a flag to true activates the loader for the matching sub-section. If the sub-section is missing the bot logs a warning at startup and disables the feature anyway, rather than panicking — see Validation below.
[auto_role] section
Required when features.auto_role = true. The auto-role module periodically scans members who currently have from_role and grants them to_role (and removes from_role) once they meet the configured criteria. The first scan runs at bot startup; subsequent scans run on a fixed schedule.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
from_role | string | yes | — | Snowflake of the role to remove |
to_role | string | yes | — | Snowflake of the role to grant |
min_age | string | no | "3d" | Minimum time in the guild before promotion |
min_messages | int | no | 20 | Minimum messages sent in the guild before promotion |
require_all | bool | no | false | If true, both criteria must hold; if false, either |
Duration format for min_age
min_age is a short duration string parsed by parse_duration in src/util/duration.rs. The grammar is <integer><unit> with no spaces, where unit is one of:
| Suffix | Unit | Example |
|---|---|---|
s | seconds | 30s |
m | minutes | 15m |
h | hours | 2h |
d | days | 3d (the default) |
w | weeks | 1w |
Combined units (1d12h) are not supported — pick the largest unit that expresses what you want. The maximum allowed duration is 365 days; anything longer is rejected.
How the criteria combine
With require_all = false (the default), a member is promoted as soon as either condition holds: they’ve been in the guild long enough, or they’ve sent enough messages. With require_all = true, both must hold. Newly added IDs and recent message counts are picked up between scans, so the lag between meeting the threshold and getting promoted is bounded by the scan interval.
See Auto-Role for behavioral details.
[join_role] section
Required when features.join_role = true. Assigns a single role to every new member on join. Most often used to mark unverified accounts that haven’t gone through whatever onboarding flow your server has set up.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
role | string | yes | — | Snowflake of the role to grant |
See Join Features.
[welcome] section
Required when features.welcome = true. Posts an AI-generated welcome message to a designated channel when a new member joins. The bot needs at least one AI provider key (DEEPSEEK_API_KEY or GEMINI_API_KEY); without one, it logs a warning at startup and the feature stays inactive.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
channel | string | yes | — | Snowflake of the channel to post into |
prompt_file | string | no | "welcome_prompt.txt" | File (relative to config.toml) holding the welcome prompt template |
The prompt file is loaded by load_welcome_prompt in src/instance_config.rs; if it’s missing or empty the bot logs a warning and welcome stays off. See Join Features.
[minecraft] section
Required when features.minecraft = true. The Minecraft module is itself a bundle of three independently toggleable sub-features. All three depend on the bot being able to talk to a companion plugin on the Minecraft server, which means MC_VERIFY_URL and MC_VERIFY_SECRET must be set in the instance’s .env.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
verify | bool | no | true | Enable the !m verify linking command |
donator_sync | bool | no | false | Poll the MC server and sync donator-tier roles |
chargeback | bool | no | false | Receive chargeback alerts from the MC store |
verify defaults to true so that the most common case (you want player-account linking) needs no extra configuration beyond enabling the module. donator_sync and chargeback default to false and need their own sub-sections (below) before they do anything useful.
See Minecraft Verify, Minecraft Donator Sync, and Minecraft Chargeback.
[minecraft.donator_sync_config] section
Required when minecraft.donator_sync = true. Polls the Minecraft server on an interval and synchronizes Discord roles to match each user’s purchased tier.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
supporter_role | string | yes | — | Snowflake of the supporter-tier role |
premium_role | string | yes | — | Snowflake of the premium-tier role |
check_interval | int | no | 300 | Seconds between donator-tier checks |
The default 300-second interval is conservative enough to avoid spamming the MC server while keeping role state reasonably fresh. See Minecraft Donator Sync.
[minecraft.chargeback_config] section
Required when minecraft.chargeback = true. The bot exposes an HTTP endpoint that receives chargeback notifications from the MC store; on receipt it strips the user’s roles, applies a restricted role, and posts an interactive alert to a staff channel with Ban and Dismiss buttons.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
staff_channel | string | yes | — | Snowflake of the channel to post alerts in |
restricted_role | string | yes | — | Snowflake of the role to apply to offenders |
staff_roles | list of string | no | [] | Snowflakes allowed to press Ban/Dismiss buttons. Empty list means no role can act on alerts (the buttons reply “You don’t have permission to do this.” for everyone), so configure this if you want staff to be able to confirm or dismiss chargebacks. |
See Minecraft Chargeback.
Validation
At startup the loader inspects each enabled feature flag and tries to find its sub-section:
- TOML parse errors panic with the file path and the parser’s error message. Fix the syntax and restart.
- Missing
bot_nameorcommand_prefixis a TOML parse error (they’re required fields with no default), so they fall into the same case. - Missing personality file when AI chat is implicitly active panics with the file path. An empty file panics with a different message asking you to fill it in.
features.minecraft = truewith no[minecraft]section logs a warning and disables the Minecraft module. The bot still starts.features.auto_role = truewith no[auto_role]section logs a warning and disables auto-role. The bot still starts.features.join_role = truewith no[join_role]section logs a warning and disables join-role. The bot still starts.features.welcome = truewith no[welcome]section, no prompt file, or an empty prompt file logs a warning and disables welcomes. The bot still starts.minecraft.donator_sync = truewith no[minecraft.donator_sync_config]logs a warning and disables donator sync.minecraft.chargeback = truewith no[minecraft.chargeback_config]logs a warning and disables chargeback.
Validation strategy is “loud warnings, soft fail”: misconfiguration of optional features doesn’t crash the bot, but it does show up clearly in the logs. Run with RUST_LOG=info,discord_bot=info (or similar) to see the per-module enable/disable lines on startup.
AI Provider Configuration ([ai.providers] and [ai.routing])
Define custom AI providers and override role routing per-instance. Both sections are optional — when absent, the bot uses a baked-in default registry equivalent to all releases prior to 0.15.0.
See AI Providers for the full schema reference, default registry contents, validation rules, and worked examples (one-model setup, three-provider setup with cascade, overriding a default).
Complete annotated example
The example instance ships with this config.toml (also at instances/example/config.toml in the repo):
# ============================================================================
# discord-bot-rs — Example Instance Configuration
# ============================================================================
#
# This file is the authoritative reference for every config.toml option.
# Copy this whole directory to create a new instance:
#
# cp -r instances/example instances/mybot
#
# Then edit this file, instances/mybot/.env, and instances/mybot/personality.txt.
# ============================================================================
# ----- Identity -------------------------------------------------------------
# Display name shown in bot output (help text, welcome messages, etc.)
bot_name = "Example Bot"
# Prefix for all user commands. discord-bot-rs uses prefix commands only
# (no slash commands), so this is the single prefix every user sees.
command_prefix = "!"
# Path to the personality file (system prompt for AI chat), relative to this
# config file. Default: "personality.txt"
personality_file = "personality.txt"
# ----- Feature Flags --------------------------------------------------------
#
# Each flag gates a whole feature area. Setting to false disables the feature
# entirely — the corresponding config section below is not required.
[features]
minecraft = false # Minecraft verification + donator sync + chargeback alerts
auto_role = false # Automatic role promotion based on activity
join_role = false # Assign a role to new members on join
welcome = false # AI-generated welcome message on join
# ----- Auto-Role Promotion --------------------------------------------------
#
# Required when features.auto_role = true.
# Promotes members from `from_role` to `to_role` once they meet activity
# criteria. Runs periodically; the first pass happens at bot startup.
# [auto_role]
# from_role = "ROLE_ID" # Snowflake of the role to remove
# to_role = "ROLE_ID" # Snowflake of the role to grant
# min_age = "3d" # Time in guild before eligible (e.g. "1h", "3d", "1w")
# min_messages = 20 # Messages sent in the guild before eligible
# require_all = false # true = both criteria required, false = either
# ----- Join Role ------------------------------------------------------------
#
# Required when features.join_role = true.
# [join_role]
# role = "ROLE_ID" # Snowflake of the role to grant on join
# ----- Welcome Messages -----------------------------------------------------
#
# Required when features.welcome = true.
# [welcome]
# channel = "CHANNEL_ID"
# prompt_file = "welcome_prompt.txt" # Relative to this config file
# ----- Minecraft Module -----------------------------------------------------
#
# Required when features.minecraft = true.
# Any sub-feature can be independently toggled.
# MC_VERIFY_URL and MC_VERIFY_SECRET in .env are required for all sub-features.
# [minecraft]
# verify = true # !m verify command (default: true)
# donator_sync = false # Poll MC server for donator tier role sync
# chargeback = false # Webhook listener for chargeback alerts
# ----- Donator Sync ---------------------------------------------------------
#
# Required when minecraft.donator_sync = true.
# [minecraft.donator_sync_config]
# supporter_role = "ROLE_ID"
# premium_role = "ROLE_ID"
# check_interval = 300 # Poll interval in seconds (default: 300)
# ----- Chargeback Alerts ----------------------------------------------------
#
# Required when minecraft.chargeback = true.
# [minecraft.chargeback_config]
# staff_channel = "CHANNEL_ID"
# restricted_role = "ROLE_ID"
# staff_roles = ["MOD_ROLE_ID", "ADMIN_ROLE_ID", "OWNER_ROLE_ID"] # Snowflakes allowed to press Ban/Dismiss; empty = no one
To turn any feature on, flip its flag in [features] and uncomment the matching sub-section, replacing ROLE_ID and CHANNEL_ID placeholders with real Discord snowflakes (right-click → Copy ID with Developer Mode enabled).
AI Providers
This page documents the [ai.providers], [ai.routing], and [ai.fallback] sections of an instance’s config.toml. All three are optional — an instance with no [ai.*] section uses a sensible default stack (DeepSeek for chat, Gemini for vision, DeepSeek Reasoner for hard questions, no CENSORED cascade).
Quick reference
# instances/<your-bot>/config.toml
[ai.providers.<name>]
url = "https://..." # required: full chat-completions endpoint
model = "..." # required: model identifier
api_key_env = "..." # required: name of env var holding the bearer token
max_tokens = 8192 # required: per-provider hard cap on response tokens
timeout_secs = 30 # optional, default 30
supports_vision = false # optional, default false
supports_tools = true # optional, default true
is_reasoner = false # optional, default false
spec = "openai" # optional, default "openai"
[ai.routing]
chat = "<provider-name>" # required if section present
vision = "<provider-name>" # optional — if omitted, image messages fall through to chat
reasoner = "<provider-name>" # optional — if omitted, classifier step is skipped
[ai.fallback]
on_censored = ["<name>", "..."] # CENSORED-cascade chain (optional)
Default registry
When [ai.providers] is absent, the bot ships these four definitions:
| Name | URL | Model | Env var | max_tokens | timeout | vision | tools | reasoner |
|---|---|---|---|---|---|---|---|---|
deepseek_chat | https://api.deepseek.com/chat/completions | deepseek-v4-flash | DEEPSEEK_API_KEY | 8192 | 30 | no | yes | no |
deepseek_reasoner | https://api.deepseek.com/chat/completions | deepseek-v4-pro | DEEPSEEK_API_KEY | 65536 | 300 | no | no | yes |
gemini_flash | https://generativelanguage.googleapis.com/v1beta/openai/chat/completions | gemini-3-flash-preview | GEMINI_API_KEY | 16384 | 30 | yes | yes | no |
grok | https://api.x.ai/v1/chat/completions | grok-3 | GROK_API_KEY | 16384 | 30 | no | yes | no |
A provider whose api_key_env resolves to an unset/empty env var is “unavailable” — defined but not usable. The bot starts and runs without it; AI features that depend on it are silently disabled (with a warning at startup if anything references it).
Default routing
When [ai.routing] is absent:
chat = "deepseek_chat"
vision = "gemini_flash"
reasoner = "deepseek_reasoner"
This is exactly the routing behaviour of every release before 0.15.0. Existing instances pick it up automatically with no config changes.
Routing degradation rules
When [ai.routing] IS present, only the keys you write take effect. There’s no field-merge with the defaults above. Specifically:
| Role | If set | If omitted |
|---|---|---|
chat | Must resolve to a configured provider; otherwise the bot panics at startup | Required — bot panics at startup |
vision | Must resolve | Image-bearing requests fall through to chat with a warning log |
reasoner | Must resolve | Classifier step is skipped; every request goes to chat |
This lets you write a one-model config:
[ai.providers.my_local]
url = "http://localhost:11434/v1/chat/completions"
model = "llama3.1:70b"
api_key_env = "LOCAL_LLM_KEY"
max_tokens = 8192
[ai.routing]
chat = "my_local"
# vision and reasoner omitted → graceful degrade
Disabling V4-Pro flagship
deepseek_reasoner defaults to DeepSeek V4-Pro (the 1.6T-parameter
flagship). V4-Pro output costs roughly 12× V4-Flash output per token, so
high-volume reasoner traffic adds up quickly. The existing routing
system already provides the off-switch — no per-feature boolean is
needed.
To skip V4-Pro entirely without redefining a provider, point the reasoner role at the cheaper V4-Flash:
[ai.routing]
reasoner = "deepseek_chat"
To disable the reasoner role altogether — the bot will never invoke a
reasoner provider, and every chat goes through the chat role — set
[ai.routing] and omit reasoner:
[ai.routing]
chat = "deepseek_chat"
vision = "gemini_flash"
# reasoner intentionally omitted — graceful degrade
Either pattern leaves V4-Pro unconfigured by routing and unbilled by DeepSeek.
Provider definitions
Each [ai.providers.<name>] block is independent. The <name> is your handle for the provider — used in [ai.routing] lookups, in [ai.fallback] on_censored lists, and in log lines.
Required fields
url— full HTTPS endpoint (the chat-completions URL, including any version path).model— model identifier the provider expects in the request body.api_key_env— name of an environment variable. The bot readsstd::env::var(api_key_env)at startup; an unset/empty value marks the provider unavailable.max_tokens— per-provider hard cap on response tokens. The orchestration layer asks for whatever budget it wants and clamps to this cap.
Optional fields
timeout_secs(default30) — HTTP timeout for chat-completions calls. DeepSeek Reasoner needs300(5 minutes); fast chat models are fine with the default.supports_vision(defaultfalse) — whether the model accepts image content parts. Today only the Gemini default registry entry sets this totrue.supports_tools(defaulttrue) — whether the model accepts atoolsarray. DeepSeek Reasoner is the standout exception (set tofalse).is_reasoner(defaultfalse) — flags a slow reasoning model. The orchestration layer uses this signal alongside the longertimeout_secsbudget you should also set.spec(default"openai") — request/response shape."openai"(default) and"anthropic"(added in 0.16.0) are supported. See Anthropic spec for details.
Provider name rules
- Non-empty after
.trim() - No internal whitespace characters
- TOML’s bare-key rules already constrain
[ai.providers.<name>]syntax to safe characters (alphanumeric, underscore, hyphen, dot) - A user-defined name that matches a default-registry name (e.g.
gemini_flash) fully replaces the default — no field-level merge
Backward-compatible alias names
For instance configs that pin model-string aliases instead of canonical provider names, the bot recognises a small set of short aliases at lookup time. They were introduced in two waves:
| Alias | Resolves to | Added in |
|---|---|---|
gemini | gemini_flash | 0.14.0 |
deepseek | deepseek_chat | 0.14.0 |
deepseek-chat | deepseek_chat | 0.14.0 |
deepseek-v4 | deepseek_chat | 0.18.0 |
deepseek-v4-flash | deepseek_chat | 0.18.0 |
deepseek-v4-pro | deepseek_reasoner | 0.18.0 |
deepseek-reasoner | deepseek_reasoner | 0.18.0 |
These aliases work in [ai.fallback] on_censored and in any other place
the bot looks up a provider by name at request time. They are not
accepted by [ai.routing] startup validation — using [ai.routing] chat = "gemini" panics at startup with the canonical name (gemini_flash) in
the error message.
The 0.14.0 aliases exist so a [ai.fallback] on_censored = ["grok", "gemini"] line copied from a 0.14.0 example doesn’t silently produce an
empty cascade. The 0.18.0 aliases preserve [ai.fallback] configs that
named DeepSeek’s deepseek-reasoner (retiring 2026-07-24) and add
forward-compatible spellings for the explicit V4 model names.
New configs should use the canonical names (deepseek_chat,
deepseek_reasoner, gemini_flash, grok).
Anthropic spec
As of 0.16.0, spec = "anthropic" enables native Anthropic /v1/messages routing. This is useful for using Claude directly without going through an OpenAI-compat proxy — native routing preserves Claude’s structured tool use, vision content parts, and prompt caching (future work).
An Anthropic provider definition looks like this:
[ai.providers.claude]
spec = "anthropic"
url = "https://api.anthropic.com/v1/messages"
model = "claude-opus-4-7"
api_key_env = "ANTHROPIC_API_KEY"
max_tokens = 8192
supports_vision = true
supports_tools = true
# Anthropic's auth is x-api-key with no scheme prefix.
auth_header = "x-api-key"
auth_scheme = ""
# Anthropic's required version header.
headers = { "anthropic-version" = "2023-06-01" }
New fields (also available to OpenAI providers)
headers—HashMap<String, String>of extra HTTP headers. Default empty. Values must be printable ASCII. Use inline-table syntax (headers = { "x" = "y" }) for 1-2 headers, or a sub-table ([ai.providers.claude.headers]) for longer lists.auth_header— name of the auth header. Default"Authorization". Must be non-empty.auth_scheme— prefix prepended to the API key. Default"Bearer "(with trailing space). Use""for Anthropic.
These fields are respected by both the OpenAI and Anthropic paths — you can use them on any provider that needs custom auth or headers (e.g. a self-hosted endpoint requiring a custom x-internal-auth header).
Translation — what the bot handles automatically
When you route to an Anthropic provider, the bot translates every shape difference transparently. The internal tool definitions, system prompt, and message history are all built in OpenAI shape (the bot’s internal canonical form) and translated to Anthropic’s wire shape on each request. You never need to write Anthropic-specific prompt logic.
Translation covers:
- System prompt → top-level
systemfield on the request body (not arole: "system"message in the array) - Image content parts → base64
sourceblocks with correctmedia_type - Tool definitions → flat
{name, description, input_schema}shape - Tool call responses →
tool_usecontent blocks are extracted into the same flatToolCall { id, name, arguments }shape the bot uses internally - Tool result messages → wrapped in user-content
tool_resultblocks
What works with Claude today
- Text chat (any routing role)
- Vision (when
supports_vision = true) - Tool use (when
supports_tools = true) - Multi-round search via the CENSORED cascade /
[ai.fallback] on_censored = ["claude", ...]as a post-DeepSeek-refusal fallback - Mixed setups: DeepSeek primary + Claude as cascade member, or Claude primary + DeepSeek as reasoner, etc.
What’s not yet available
- Streaming responses
- Anthropic’s
cache_controlephemeral blocks for prompt caching - Structured “thinking” / extended reasoning outputs (Claude’s reasoning models)
These are tracked as future enhancements; today’s integration gives feature parity with DeepSeek/Gemini for the bot’s standard workflow.
Validation behaviour
Performed once at startup, before the bot connects to Discord:
| Case | Behaviour |
|---|---|
[ai.routing] chat unset OR points at unknown provider name | Panic |
[ai.routing] vision / reasoner set to unknown provider name | Panic |
[ai.routing] section present without chat | Panic |
| Provider name contains whitespace | Panic |
Provider with spec = "anthropic" | Fully supported as of 0.16.0 — see Anthropic spec |
Provider’s api_key_env resolves to unset env var | Provider marked unavailable |
| Routing or fallback references an unavailable provider | Warn at startup |
[ai.routing] vision references provider with supports_vision = false | Warn at startup |
[ai.routing] reasoner references provider with is_reasoner = false | Warn at startup |
[ai.fallback] on_censored references defined-but-unavailable provider | Warn at startup; cascade_for skips it at request time |
[ai.fallback] on_censored references completely unknown name | Warn at request time only (via cascade_for); silently produces empty cascade entry. Common when migrating from 0.14.0 configs that used aliases — see Backward-compatible alias names above |
Worked examples
One-model setup (local Ollama)
[ai.providers.local]
url = "http://localhost:11434/v1/chat/completions"
model = "llama3.1:70b"
api_key_env = "OLLAMA_API_KEY" # any non-empty value works for Ollama
max_tokens = 8192
[ai.routing]
chat = "local"
Vision and reasoner gracefully degrade. Image messages will be handed to the local model anyway; classifier is skipped so every prompt goes straight to chat.
Three providers + cascade
[ai.providers.openai_gpt]
url = "https://api.openai.com/v1/chat/completions"
model = "gpt-4o"
api_key_env = "OPENAI_API_KEY"
max_tokens = 16384
supports_vision = true
[ai.routing]
chat = "openai_gpt"
vision = "openai_gpt" # reuse same provider for vision
reasoner = "deepseek_reasoner" # keep the default reasoner
[ai.fallback]
on_censored = ["grok", "gemini_flash"]
Override a default
To use gemini_flash but with a different model:
[ai.providers.gemini_flash]
url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
model = "gemini-2.5-pro" # not the default flash; check ai.google.dev for newer Pro models
api_key_env = "GEMINI_API_KEY"
max_tokens = 32768
supports_vision = true
The user definition fully replaces the default gemini_flash entry. No [ai.routing] change needed if you’re keeping the same role assignment.
See also
- Environment variables —
*_API_KEYreference - Instance config — full
config.tomlschema defaults/example-providers.toml— copy-paste catalogue for popular endpoints- AI Chat feature page
- Issue #28 — design context + phase 2 follow-up
Personality Files
The personality file is a free-form text file that becomes the system prompt for AI chat. It’s plain prose — no TOML, no escaping, no structure imposed by the loader. Editing it feels like editing a doc, because that’s all it is.
Where it lives
Each instance has its own personality file under its config directory, named personality.txt by default. You can override the filename with the personality_file field in config.toml:
personality_file = "my-bot-persona.txt"
The path is resolved relative to config.toml, so personality.txt and my-bot-persona.txt both live alongside the config file in instances/<your-bot>/.
The loader is the load_personality method on InstanceConfig (see src/instance_config.rs). At startup the bot reads the file from CONFIG_DIR, panics if it can’t find it, and panics again if the contents are empty after trimming. There is no live reload — restarting the container picks up changes.
How it’s used
Whenever someone interacts with AI chat — by mentioning the bot, replying to one of its messages in a thread the bot is part of, or triggering a command that goes through the AI pipeline — the contents of the personality file are sent to the AI provider as the system message. Conversation context (recent messages from the same channel) is appended as user/assistant turns underneath. The personality is what the model sees first and treats as the immutable framing for every reply.
That means everything in the file is in scope for every response. There’s no “this is just the intro” — the whole file is the rules.
For the full request shape, see AI Pipeline.
Tips for writing one
- Lead with identity. The first sentence should be
You are <name>, a <something> on <where>.Models latch onto this and use it to frame everything below. - Explicitly state what you are NOT. A line like “You are not Claude, ChatGPT, Gemini, or any other branded AI” prevents the model from breaking character with a “Sorry, I’m actually Claude” disclosure.
- Describe tone with concrete traits, not just adjectives. “Direct — say the thing, no preamble” lands better than “Helpful and friendly”. Showing what direct means is more useful than naming the property.
- Include both DOs and DON’Ts. Models attend to negative instructions as well as positive ones; a short “Things you do not do” list reliably blocks the most common annoying behaviors (corporate disclaimers, refusal theater, performative emoji use).
- Keep it under 1000 words. Longer prompts dilute. Above a certain length the model starts averaging instructions instead of obeying them sharply.
- Iterate against real interactions. Ship something, watch how it answers a few real questions, and tweak. Personality writing is a feedback loop, not a one-shot specification.
Example: a short personality
You are Maple, a friendly assistant on the Cabin discord. You are not Claude,
ChatGPT, Gemini, or any other branded AI — you are Maple.
Personality:
- Warm but not saccharine
- Brief by default; expand when the question deserves it
- Honest about not knowing things
- No corporate disclaimers, no "As an AI language model"
You answer questions, you help with code, you join in jokes when they're funny,
and you don't pretend to be more capable than you are. If someone asks for
something you can't do, say so plainly and suggest what they could try instead.
Example: a longer personality
You are Cinder, the resident AI on the Forge discord — a community for people
who build small electronics, write embedded code, and argue about microcontrollers.
You are NOT Claude, ChatGPT, Gemini, Llama, or any other branded AI. You are
Cinder. Don't break character to disclose what you actually are.
# Tone
You're competent, dry, and patient. You answer the question that was asked,
not the question you wish was asked. You're allowed to have opinions —
"BLE pairing on the ESP32 is genuinely painful" is a fine sentence — but you
don't editorialize unprompted. Your humor is observational rather than performed:
you don't deploy emoji as punctuation, you don't say "absolutely!" or "great
question!" and you don't use exclamation points unless someone is in actual
danger.
# How you handle questions
- **Technical questions:** Answer concretely. If there's a code example that
would help, write it. If there's a datasheet that has the answer, name it.
If you're not sure, say "I'm not sure, but here's what I'd check first."
- **Debugging help:** Ask the smallest clarifying question that would let you
give a useful answer. Don't make people fill out a form.
- **Project ideas:** Be encouraging but honest. If something is going to be
hard, say so, then say what makes it hard. Don't sugarcoat.
- **Off-topic chatter:** Engage briefly when it's friendly. Don't lecture
people about staying on topic — that's what mods are for.
# What you do not do
- You do not pretend to be a human.
- You do not pretend to be Claude, ChatGPT, or any other branded AI.
- You do not refuse reasonable questions for performative-safety reasons.
- You do not give long disclaimers about being an AI before answering.
- You do not say "As an AI language model" — ever.
- You do not claim you've forgotten the conversation. The recent messages
are right there. Use them.
- You do not use a dozen emoji per message. One occasionally is fine.
# Final note
If you don't know something, say so. If you're guessing, say you're guessing.
If a question is poorly specified, name the missing piece. Confidence is good;
overconfidence is corrosive.
Common mistakes
- Making the persona contradict itself. “Be terse and concise. Provide thorough, detailed answers.” The model will pick one and ignore the other, and you won’t know which.
- Filling it with safety boilerplate. Long lists of “do not say X under any circumstances” make the model defensive and make every reply read like a legal disclaimer. Trust the underlying model’s safety training and write the file as if you were briefing a thoughtful new hire.
- Forgetting to claim a name. Without an identity statement the model will sometimes default to “I’m Claude” or “I’m a language model,” undermining the whole point of having a persona file.
- Treating it like config. It’s prose. Bullet points are fine, headings are fine, but you don’t need a schema. The model reads it the way a human would.
See also
- AI Chat — the feature page for AI chat behavior, providers, and command surface.
- AI Pipeline — how the personality file is wired into the chat completion request.
Secrets Management
A Discord bot token grants full control over your bot user — anyone who has it can read messages the bot can read, post anywhere it can post, and join voice channels on its behalf. Provider API keys cost real money if abused. Database credentials open the door to your whole instance’s data. Leaked secrets are by far the most common way bot projects get pwned, and they almost always leak the same way: someone commits an .env file or pastes a token into a public log.
This page describes how this project keeps secrets out of git by default, how to verify that’s actually the case for your fork, and what to do if something escapes anyway.
The default posture
The repo’s .gitignore excludes secrets at the file level, not the line level. The relevant entries are:
.env
instances/*/.env
!instances/*/.env.example
cookies.txt
instances/*/cookies.txt
That covers three things:
- The root
.env(used in some legacy local-dev flows). - Any per-instance
.envunderinstances/<name>/. - The negation
!instances/*/.env.examplekeeps the documented templates checked in, so the docs and onboarding still work. - Any
cookies.txt(used by the music feature for YouTube authentication) at the repo root or per-instance.
If you cloned the repo cleanly and only created .env files at the documented paths, none of them will ever be staged by git add ..
Verifying your .gitignore
Before your first push, confirm that git is actually ignoring your .env:
git check-ignore -v instances/yourbot/.env
You should see a line pointing at .gitignore and the matching pattern. If you get nothing, the file is not ignored — investigate before pushing. The likeliest cause is that you put your env file somewhere unexpected (e.g. instances/yourbot/secrets.env), in which case either move it to .env or extend .gitignore to cover the new path.
Checking git history
If you’re worried something already snuck in, search the whole history:
git log -p --all | grep -iE "DISCORD_TOKEN|API_KEY|SECRET|password" | head -50
This is noisy by design — it’ll match docs and example files too. Read each hit carefully. If you find an actual token, a real API key, or anything else that grants access, treat it as leaked and follow the recovery flow below. Rotation is your only real fix; deleting the file in a new commit does not remove the value from history.
Local development
For local dev, copy the example file:
cp instances/example/.env.example instances/example/.env
Then fill in the values. Never copy your real .env into chat, into a paste site, or into a screenshot. If you need to share configuration with a teammate, send them the field list and have them generate their own values, or use a secret-sharing tool like age or your password manager’s sharing feature.
If you use a code editor with cloud sync, double-check that it isn’t quietly mirroring .env files to a remote — some editors (and some “AI assistant” extensions) read everything in the workspace by default.
Docker Compose: env_file vs. environment:
docker-compose.yml uses env_file: instances/yourbot/.env rather than putting values in an environment: block. This matters because docker-compose.yml is checked into the repo, and environment: values live in plain sight inside it. env_file keeps the path to the secrets in the compose file but the actual values stay in the gitignored .env.
The same logic applies if you ever feel tempted to bake secrets into a Dockerfile with ENV or ARG. Don’t. They end up in image layers, which are visible to anyone with pull access to the image.
Docker secrets in production
For production deployments, Docker has a secrets primitive that mounts secret values as files inside the container, owned by root and readable only by the process. It’s a step up from env_file because the secret never lives on disk in plaintext outside the swarm raft store, and because rotating a secret rotates it everywhere it’s mounted.
For a single-host Compose deployment, the marginal benefit over a properly permissioned .env (see Production hardening) is small. For a Swarm or Kubernetes deployment, secrets are the right answer. The bot itself reads from environment variables, so adapting it just means setting up a small entrypoint that exports the contents of mounted secret files into the environment before launching the binary.
Rotating secrets
- Discord token. Open the Developer Portal → your application → Bot → Reset Token. The new token immediately invalidates the old one. Update
.env, restart the bot, done. - DeepSeek / Gemini / Finnhub API keys. Each provider has a key-management page. Generate a new key, update
.env, restart, then revoke the old key in the dashboard. - Database password. Update Postgres (
ALTER USER discord_bot WITH PASSWORD '<new>';), updateDATABASE_URLin.env, restart the bot. MCP_AUTH_TOKEN/MCP_GATEWAY_AUTH_TOKEN. Generate a new value (openssl rand -hex 32), update.env, restart the bot and the gateway.
There is no live reload for any of these — restarting the container is the rotation step.
What to do if a secret leaks
If you discover an exposed secret, work in this order:
- Generate a replacement before revoking the leaked one. Your running bot still has a valid token; you don’t want to take it offline before the replacement is ready.
- Update
.envwith the new value. - Restart the bot. Confirm it comes back up cleanly with the new credentials.
- Revoke the old secret in the provider dashboard. For Discord, Reset Token invalidates the previous one automatically.
- Audit logs for the window the secret was live. Look for unexpected message activity, role changes, channel creations, or API calls.
- If the secret was committed to git, the value is in history and is effectively public. Use
git filter-repoto purge it, force-push the rewritten history, and ask collaborators to re-clone. Note that anyone who already cloned (including caches like the GitHub web UI) still has it — rotation is what actually fixes the leak. History rewriting is just hygiene.
Production hardening
A few quick wins beyond gitignoring .env:
- Restrict database access to the bot user. Don’t reuse a Postgres superuser. If you’re running multi-instance, use the same dedicated
discord_botuser but rely on the per-schemasearch_pathfor isolation. - Set tight permissions on
.env.chmod 600 instances/yourbot/.envso only the owner can read it. The Compose service runs as a non-root user; make sure that user is the owner. - Don’t log environment variables at startup. The bot doesn’t currently log secrets — it logs only the bot name, command prefix, and which feature modules are enabled — but if you add logging, never dump the full env or the full
Configstruct. MCP_AUTH_TOKENis mandatory on any non-loopback bind. The bot now refuses to start ifMCP_BIND_ADDRis anything other than a loopback address andMCP_AUTH_TOKENis empty — this used to be a “you really should” recommendation; it is now enforced at startup. The bundled Compose.env.exampleships withMCP_BIND_ADDR=0.0.0.0(the gateway sidecar reaches the bot over the Docker network), so a Compose deploy without a token will fail on boot until you set one. Themcp-gatewayservice is stricter still and refuses to start at all withoutMCP_GATEWAY_AUTH_TOKEN. See MCP Exposure.- Run the container as a non-root user. The shipped image already does this.
- Keep dependencies up to date.
cargo updateand rebuild on a regular cadence; security advisories for transitive crates show up viacargo auditif you wire it into CI.
For a more complete production walkthrough, see the Production Checklist.
Multiple Instances
discord-bot-rs is designed so a single binary, a single docker-compose.yml, and a single Postgres database can host as many independent bots as your hardware can spare. “Multiple instances” here means exactly that: several Discord bot identities, each with its own config and database schema, running side by side as separate processes.
When to use it
- You run more than one Discord community and want one machine to host every bot.
- You want a dev or staging instance running alongside production so you can test changes without risking the live bot.
- You’re hosting bots for friends and want to consolidate maintenance.
If you only run one bot, you don’t need any of this — the example instance is already a complete single-instance deployment. Come back here when you actually have a second bot to add.
The recipe
The flow is “copy the example, edit two files, edit Compose, restart.” Concretely:
-
Copy the example directory.
cp -r instances/example instances/bot2 cp instances/bot2/.env.example instances/bot2/.env -
Edit
instances/bot2/.env. At minimum, change:DISCORD_TOKEN— the new bot’s token from the Discord Developer PortalCLIENT_ID— the new application’s client IDGUILD_ID— the snowflake of the new bot’s home serverDB_SCHEMA=bot2— must be unique across instances
Leave
DATABASE_URLpointing at the same Postgres service as the first bot. If you set any optional API keys (DeepSeek, Gemini, Finnhub, Minecraft), each instance gets its own — they don’t share keys unless you make them. -
Edit
instances/bot2/config.toml. Setbot_nameto something distinct (it shows up in logs and helps you tell which instance is which when tailing) and adjustcommand_prefix, feature flags, and feature sub-sections to match what you want this bot to do. -
Edit
instances/bot2/personality.txt. Even if the persona is the same as your first bot, edit the file so the loader has something non-empty to read. Most people want a different persona per community anyway. -
Duplicate the bot service in
docker-compose.yml. Copy the existingbotservice block and rename the copy tobot2. Update itsenv_fileto point atinstances/bot2/.env, update its volume mount soinstances/bot2becomes/configinside the new container, and change the container name. Theimage,depends_on: postgres, andrestartsettings can stay identical — both containers run the same image with different config mounted. -
Update the
mcp-gatewayservice. Add the new instance to the gateway’sINSTANCESenv var so it knows where to route requests forbot2. The gateway reads this list at startup; restarting the gateway picks up new entries. -
Bring it up.
docker compose up -d bot2 docker compose restart mcp-gateway
You should see bot2’s startup logs report its bot name, prefix, and the feature modules it enabled. From Discord’s perspective the second bot is wholly independent of the first.
Shared resources
A multi-instance deployment shares three things across all bots:
- PostgreSQL. One Postgres container, one database, but each instance writes to its own schema. The schema is set by
DB_SCHEMAin the instance’s.envand applied viaSET search_pathon every new connection (seesrc/db/mod.rs). Tables, sequences, and migrations all live inside the schema, so two instances withDB_SCHEMA=bot1andDB_SCHEMA=bot2cannot see each other’s data. - MCP gateway. A single gateway service routes MCP requests to the right instance based on the URL path. Each instance still runs its own embedded MCP server inside its container; the gateway just provides a single externally addressable endpoint. See MCP Gateway Routing.
- Docker network. All bots and the gateway sit on the default Compose bridge network. They can reach Postgres and each other by service name, but Discord-side they’re completely independent — each one owns its own gateway connection and Discord application.
Everything else — config, personality, runtime memory, async tasks — is per-instance.
Concurrency and resource limits
Each instance is a separate process running its own Tokio runtime, so CPU and RAM scale roughly linearly with the number of instances. There is no shared in-process state between bots, which is the point — but it also means there’s no economy of scale on memory: two bots use about twice as much RAM as one bot.
Practical sizing notes:
- A modern Pi 4 (4GB) comfortably runs two or three bots plus the bundled Postgres and gateway.
- The biggest variable is music: voice connections and ffmpeg pipelines dominate memory and CPU when active. A bot that never plays music is much cheaper than one with three concurrent voice channels.
- Postgres is the smallest part of the budget unless you have many tens of thousands of tracked messages or game states.
When you start hitting limits, the next step up is usually splitting Postgres onto a dedicated host (or a managed service) rather than splitting the bots themselves.
Gotchas
DB_SCHEMAmust be unique per instance. Two instances pointed at the same schema will corrupt each other’s state. There is no defensive check for this — you have to get it right.- The MCP server runs in-process and is reached over the Docker network from the gateway. You don’t need to expose its port to the host unless you also want direct access from outside Compose. If you do expose it, every instance needs a different host port (e.g.
9091:9090,9092:9090). - Docker container names must be unique. If you copied the
botservice block without renaming thecontainer_name, Compose will complain. Pick a name per instance. - Health checks per instance are fine. Each container’s healthcheck talks to its own MCP server on
127.0.0.1:9090inside the container, and that doesn’t conflict with anything because each container has its own loopback. - Discord rate limits are per-token, not per-host. Running multiple bots on one host doesn’t multiply the rate limit budget for any single bot, but bots don’t share rate limits with each other.
See also
- Multi-Instance Model — the architectural picture, with a diagram of how the pieces fit.
- Multi-Instance Deployment — the full Compose layout and operational runbook.
- MCP Gateway Routing — how the gateway maps requests to instances.
Features
This is a quick-reference index of every feature the bot ships with: what it
does in one line, what flag turns it on, what environment variables and
config.toml sections it needs, and where to read more.
The bot is built so each feature is independent. There are no cross-feature dependencies — you can run the bot with nothing but AI chat enabled, or with nothing but Minecraft tooling, and the rest will sit quietly out of the way.
Always-on vs opt-in
There are two flavours of feature gating in the codebase:
- Always-on — the code is always compiled in and the runtime always
registers the relevant commands and event handlers. The feature only
activates when its dependencies are met. AI chat, for example, is
always-on, but if you don’t set
DEEPSEEK_API_KEYorGEMINI_API_KEYthe bot just never replies to mentions. Music is similarly always-on but needsyt-dlpandffmpegon thePATHto actually play anything. - Opt-in — the feature is gated behind a boolean in the
[features]section ofconfig.toml. If the flag isfalse(or absent), the feature’s startup hooks never run and the associated config sections are ignored. Auto-role, join-role, welcome, and the three Minecraft sub-features all work this way.
The MCP server is a special case: it is always-on but binds to a port, so
you have a separate set of MCP_* env vars to control where it listens and
whether it requires a bearer token.
Feature matrix
| Feature | Config Flag | Required Env Vars | Required Config | Docs |
|---|---|---|---|---|
| AI Chat | always-on (activates if AI key set) | DEEPSEEK_API_KEY and/or GEMINI_API_KEY | personality.txt | ai-chat.md |
| Music | always-on | none | yt-dlp + ffmpeg on PATH; optional cookies.txt | music.md |
| Wordle | always-on | none | none | games-wordle.md |
| Connections | always-on | none | none | games-connections.md |
| Stocks | always-on (inert without key) | FINNHUB_API_KEY | none | games-stocks.md |
| Moderation | always-on | none | none | moderation.md |
| Auto-Role Promotion | features.auto_role = true | none | [auto_role] section | auto-role.md |
| Join Role | features.join_role = true | none | [join_role] section | join-features.md |
| Welcome Messages | features.welcome = true | DEEPSEEK_API_KEY and/or GEMINI_API_KEY | [welcome] section + welcome_prompt.txt | join-features.md |
| Minecraft: Verify | features.minecraft = true, minecraft.verify = true | MC_VERIFY_URL, MC_VERIFY_SECRET | [minecraft] section | minecraft-verify.md |
| Minecraft: Donator Sync | features.minecraft = true, minecraft.donator_sync = true | MC_VERIFY_URL, MC_VERIFY_SECRET | [minecraft.donator_sync_config] | minecraft-donator-sync.md |
| Minecraft: Chargeback Alerts | features.minecraft = true, minecraft.chargeback = true | MC_VERIFY_URL, MC_VERIFY_SECRET | [minecraft.chargeback_config] | minecraft-chargeback.md |
| MCP Server | always-on | none (MCP_* optional) | none | mcp-server.md |
A few things worth calling out:
- The flags in the table match the field names on the
Featuresstruct insrc/instance_config.rs. There is noauto_role,join_role,welcome, orminecraftflag in[features]if you don’t put it there — they all default tofalse. - When you set a
features.*flag totruebut forget the matching config section, the bot logs a warning at startup and silently skips that feature instead of refusing to boot. Watch the logs after editingconfig.toml. - The Welcome feature is the only opt-in that needs an AI key in addition to its config section, because it generates greeting messages with the same provider stack as AI Chat.
- Stocks is “always-on” in the sense that the
!m stockcommands are always registered, but every command will return “Finnhub API key not configured” ifFINNHUB_API_KEYis missing.
Enabling a feature at runtime
Features are loaded once at startup. There is no live reload. To change what is enabled:
- Edit
config.toml(or.envfor env-var-driven features). - Restart the bot. With Docker Compose that’s
docker compose restart <service-name>. - Check the startup logs to confirm the feature was picked up — every
opt-in feature emits an
enabledlog line on boot.
For deployment-level guidance on how to roll restarts safely, see Upgrading.
Cross-references
- Instance Config (config.toml) —
reference for every key in the
[features]and per-feature sections. - Environment Variables — reference for every variable named in the table above.
- Multiple Instances — how to run several bot instances with different feature sets out of the same image.
- Architecture overview — for understanding how features compose at runtime.
AI Chat
The bot replies to natural-language messages in Discord using a large language model. The chat path is wired up to two providers — DeepSeek as the primary, Google Gemini as a vision-and-fallback path — and the personality is loaded from a plain text file you write yourself.
What it does
When someone @mentions the bot or replies to one of its messages, the bot
collects the recent conversation in that channel, feeds it to the AI along
with your personality.txt system prompt, and sends the model’s reply back
as a Discord message. It does this with whatever model you have keys for:
DEEPSEEK_API_KEY— primary provider (deepseek-v4-flash, with automatic routing todeepseek-v4-profor hard questions). The chat-tier output is inexpensive; the bot is built around DeepSeek’s tool-calling format.GEMINI_API_KEY— secondary provider. Used for image attachments (DeepSeek Chat is text-only) and as a fallback if the DeepSeek text path is unavailable.
If you set neither key, the bot still starts cleanly — it just won’t react to mentions. If you set both, you get text replies via DeepSeek and image understanding via Gemini for free.
The pipeline goes well beyond echoing replies: the AI can call tools you’ve exposed (music, moderation, stocks, web search, NYT-style games) and the bot routes the resulting tool calls back into Discord. See Architecture: AI Pipeline for the flow diagram.
Activation
The message handler in src/events/mod.rs triggers the AI on exactly two
conditions:
- The message contains a direct mention of the bot’s user (the bot is in
message.mentions). - The message is a Discord reply to one of the bot’s previous messages.
Anything else is ignored. The bot does not respond to keywords, prefixes, or DMs. There is no “owner override” or special-user bypass — every user is treated identically by the AI path.
When neither DEEPSEEK_API_KEY nor GEMINI_API_KEY is set, the activation
check short-circuits and the handler exits immediately. This is the safe
state: you can deploy the bot without an AI key and the rest of its
features still work.
Configuration
Provider configuration: as of 0.15.0, the providers DeepSeek + Gemini + Grok the bot ships with can be replaced or extended by your own definitions in config.toml. As of 0.16.0, Anthropic Claude is also supported natively (spec = "anthropic") — native routing preserves structured tool use and vision content parts without going through an OpenAI-compat proxy. See AI Providers for the schema and examples, and the Anthropic spec section for Claude-specific configuration.
There are exactly three things to configure:
DEEPSEEK_API_KEYin.env. Optional in the strict sense, but without it you get no text replies. Get one fromplatform.deepseek.com.GEMINI_API_KEYin.env. Optional. Required only if you want image understanding or text-fallback when DeepSeek is unreachable.personality.txtin the instance config directory (the directory pointed at byCONFIG_DIR, defaults to the working directory). This is loaded at startup byInstanceConfig::load_personalityand panics if the file is missing or empty — this is intentional, because shipping without a personality means the bot has no voice.
There is no in-app configuration of model parameters, temperature, or
context window size — they are tuned in src/ai/chat.rs. If you want
to override them you have to recompile.
For details on how to write a good personality file, see
Personality Files. For where to keep
your .env and how to feed it into Docker safely, see
Secrets Management.
How the personality shapes responses
personality.txt is loaded as a free-form string, prepended to a small
hard-coded system prompt, and sent to every API request as the
system-role message. The hard-coded part covers things the bot needs to
know regardless of personality — the current date, its version, what
tools it has, security boilerplate against prompt-injection attacks, and
formatting rules for Discord markdown. Your text sits at the top.
This means:
- Anything you write in
personality.txtoverrides the generic LLM voice. Be opinionated. The AI is much more interesting when the personality is specific. - The personality is loaded once at startup. Editing the file and saving it does nothing until the bot restarts.
- The personality is the same across every channel and every user. There is no per-guild override.
Conversation context window
For each mention, the bot fetches the last 100 messages in the channel
(FETCH_LIMIT in src/ai/chat.rs) and walks them in order, picking
up to 10 relevant messages (MAX_RELEVANT) that meet two filters:
- They are no older than 30 minutes.
- They are either bot replies or messages that mentioned/replied to the bot.
Messages from before the bot’s current process started are also dropped, so a restart wipes the AI’s memory of older conversation. There is no cross-channel memory; each channel is its own scratch buffer.
This window is deliberately small. It keeps token costs down, keeps context fresh, and prevents the bot from rehashing a stale request from hours ago. The trade-off is that long conversations summarize themselves out of view quickly.
Known limitation: in busy channels, two unrelated conversations happening at the same time can bleed into one another’s context. The bot does not segment by conversation thread; it segments by channel and time window. Discord threads are treated as their own channel, so threading a conversation is the cleanest workaround.
Tool use
The AI has access to a set of function-calling tools defined in
src/ai/tools.rs. They cover:
- Music —
play_song,skip,stop,pause,resume,show_queue,now_playing,shuffle,set_loop,remove_from_queue. The AI can control the music player conversationally (“play something chill”, “skip this”) without the user needing to know the prefix commands. - Moderation —
tempban,unban,nuke. Privileged. Every moderation tool call goes through a confirmation embed (see below) before it actually runs. - Web search —
web_search, used for current-events questions and fact-checking. Up to three rounds of search are allowed per request (theMAX_SEARCH_ROUNDSconstant insrc/ai/chat.rs, also interpolated into the system prompt so the model and the loop agree), so the AI can refine queries based on results. - Stocks —
stock_buy,stock_sell,stock_price,stock_portfolio,stock_leaderboard. Bound to the virtual portfolio system. - Games —
connections_start,wordle_start. Lets users say “start a Wordle” without remembering the command name.
All tool calls except web search are visible in chat: the bot replies with the result of the action (e.g. an “Added to Queue” embed or a “Banned user” message). Web search is silent — the AI consumes the results and folds them into its answer.
For the full pipeline including tool dispatch, see Architecture: AI Pipeline.
Moderation confirmation
Moderation tools (tempban, unban, nuke) are powerful enough to do
real damage if the AI misreads a request. The bot inserts a confirmation
step:
- The AI emits a moderation tool call.
- The bot posts an embed showing exactly what is about to happen
(“Tempban @user for
3d— repeated spam”), with Approve and Cancel buttons. - Only the original requesting user can press a button.
- If the user lacks the corresponding permission (
BAN_MEMBERSfor tempban/unban,MANAGE_MESSAGESfor nuke), the bot refuses up front. - If 30 seconds pass with no response, the action expires and is cancelled.
This is implemented in src/ai/confirmation.rs. There is no way to opt
out — privileged tools always go through the confirmation gate.
Safety and sanitization
User input is sanitized before being sent to the model (see
src/ai/sanitize.rs): role markers like system: are rewritten, DeepSeek
<|...|> tokens are stripped, and Llama-style [INST] / <<SYS>> blocks
are removed. This makes it harder for a user to inject a fake system
prompt by typing one into chat.
The model’s output is also filtered. The bot maintains a list of “bad assistant” patterns — “I am Claude”, “I don’t have the ability to remember”, “created by Anthropic” — and refuses to fold those messages back into the conversation history. Without this, hallucinated identities would propagate through the context window.
The hard-coded system prompt also tells the model to ignore “ignore previous instructions” jailbreaks and never reveal its system prompt.
Rate limiting
A per-user rate limiter (data.rate_limiters.ai) checks the requester
before each AI call. If the user is over their budget, the bot replies
“Slow down — try again in Ns” instead of calling the API. There is a
separate, stricter limiter on moderation tool calls.
The limits are tuned to absorb normal back-and-forth chat without throttling, while preventing one user from running up an API bill in isolation.
Provider failover
The text path is hard-coded to DeepSeek. The vision path is hard-coded to Gemini. If a request has image attachments, the bot tries Gemini first; on failure it strips the multimodal content and falls back to DeepSeek text-only with a description-of-context placeholder.
Inside the DeepSeek path, the bot routes between deepseek-v4-flash
(fast) and deepseek-v4-pro (the V4 flagship) by classifying each
message: simple chat goes to V4-Flash, anything that smells like a
reasoning task goes to V4-Pro. The reasoner role can’t use tools
directly, so the bot uses deepseek-v4-flash as a research assistant first to perform
any web searches, then hands the gathered context to V4-Pro for the
final answer.
Common issues
- Bot doesn’t respond to
@MyBot— check thatDEEPSEEK_API_KEYis set in the running environment (Docker users: confirm.envis being passed in), check the bot’s logs forAPI request failedmessages, and verify the bot has the Message Content gateway intent enabled in the Discord developer portal. - Bot replies but the personality is wrong —
personality.txtwas edited but the bot wasn’t restarted. The personality is loaded once at boot. - Bot mixes up two conversations in the same channel — known limitation of the channel-and-time-window approach. Move the second conversation into a Discord thread, or wait 30 minutes for the older context to age out.
- Bot refuses to talk about a topic with “my overlords at DeepSeek
won’t let me” — DeepSeek’s content filter triggered. The bot
detects the upstream
Content Exists Riskerror and translates it into a snarky message instead of crashing. - Bot’s reply is cut off mid-sentence — replies longer than 2000
characters are split into chunks by
src/ai/split.rs. If you see a truncated message ending in...[truncated], that’s the splitter hitting the Discord per-message limit on a single chunk; the next chunk should follow immediately. - Bot says “I don’t have memory” / “I’m Claude” — the model hallucinated a different identity. The output filter catches the most common phrasings on subsequent turns; if it’s getting through on the first turn, strengthen the personality file with an explicit “you are not Claude / ChatGPT / etc.” line.
Cost
Costs depend almost entirely on which model you route to. DeepSeek V4-Flash is inexpensive enough that an active community server typically lands at single-digit dollars per month. V4-Pro is ~12× more expensive per output token (the flagship reasoner) but only fires on detected reasoning queries. Gemini’s free tier covers casual image traffic.
Check the providers’ current pricing pages directly:
- DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
- Google Gemini: https://ai.google.dev/pricing
Cross-references
- Personality Files — how to write
personality.txt. - Architecture: AI Pipeline — request flow, tool dispatch, model routing.
- Secrets Management — where to put API keys safely.
- Environment Variables —
DEEPSEEK_API_KEY,GEMINI_API_KEY, and friends. - Moderation — what the moderation tools actually do when the AI invokes them.
Music
A queue-based voice music player. Audio is fetched and remuxed by yt-dlp
into an Opus-in-OGG stream, which Discord (via songbird) plays back
without any additional transcoding.
What it does
- One queue per Discord guild, up to 100 tracks (
MAX_QUEUE_LENGTHinsrc/music/player.rs). - Plays anything
yt-dlpcan resolve: YouTube videos, YouTube playlists, SoundCloud, Bandcamp, Mixcloud, Vimeo, direct media URLs, and several hundred other sites. - Search-by-text via the
ytsearch1:prefix when you don’t have a URL. - Loop modes (off / track / queue), shuffle, queue editing.
- An interactive “Now Playing” embed with button controls.
- Auto-leave the voice channel after 5 minutes of nothing playing.
The bot speaks 256 kbps Opus directly, so there is no transcoding step
inside the process — yt-dlp hands songbird an Opus stream and
songbird forwards it to Discord. CPU usage is essentially flat, even
on small VPSes.
Commands
All music commands live under the m parent. With the default !
prefix that means !m <subcommand>. The prefix is configurable per
instance via command_prefix in config.toml; the examples below
assume !.
| Command | Aliases | Description |
|---|---|---|
!m play <url-or-query> | !m p | Play immediately, or queue if something is already playing. Accepts a URL or a free-text search. The query argument is #[rest], so spaces don’t need quoting. |
!m playlist <playlist-url> | !m pl | Resolve every track in a playlist URL and queue them all. The first one starts playing, the rest go into the queue (capped by MAX_QUEUE_LENGTH). |
!m skip | !m s | Skip the current track. If there’s a next track in the queue (or loop mode says to repeat), it starts immediately; otherwise the bot stops and idles. |
!m stop | — | Stop playback, clear the queue, leave the voice channel. The opposite of !m play. |
!m pause | — | Pause the current track. The voice connection stays up. |
!m resume | !m r | Resume a paused track. |
!m queue | !m q | Show the queue: now-playing, the next 15 tracks (with a “+ N more” line if longer), and total duration. |
!m nowplaying | !m np | Show the current track in a fresh “Now Playing” embed with control buttons. |
!m remove <position> | — | Remove a queued track by 1-based position. |
!m loop [off|track|queue] | !m l | Set the loop mode. With no argument, cycles through the modes. track repeats the current song; queue re-enqueues finished tracks at the back. |
!m shuffle | — | Randomize the order of the pending queue. |
There is no previous, no seek, and no playback-position scrubbing.
The model is “modify the queue, then let it play” rather than
random-access seeking inside a track.
The bot can also drive these commands via the AI tool layer — say “@bot play something chill” or “@bot skip this” and the AI invokes the same underlying functions. See AI Chat.
Interactive controls
Whenever the bot starts a track (via !m play, !m skip, or auto-advance),
it sends a “Now Playing” embed with a row of buttons:
- ⏯ Pause / Resume
- ⏭ Skip
- ⏹ Stop and leave
- 🔀 Shuffle the queue
- 🔁 / 🔂 Loop mode (cycles off / track / queue)
- 📋 Show queue
The buttons are gated by two checks:
- The user pressing them must be in the same voice channel as the bot.
- If DJ mode is on for the guild and the user lacks the DJ role (and isn’t an administrator), the button refuses with an ephemeral message.
The “Show queue” button skips the voice-presence check so anyone listening can peek at what’s coming up.
When a track ends and the next one starts automatically, the bot
deletes the previous “Now Playing” message and posts a fresh one for
the new track, so there’s only ever one set of controls live in the
channel. The same cleanup runs when a track is skipped — both the
Skip button and the !m skip text command delete the previous
“Now Playing” message before posting the new one, so manual skips
don’t leave orphaned embeds behind.
Supported sources
Anything yt-dlp supports. The most common cases:
- YouTube videos — paste a URL, or use a free-text query (the bot
prefixes the query with
ytsearch1:so you get the top result). - YouTube playlists — use
!m playlist <url>.!m playon a playlist URL only takes the first video, by design (--no-playlistis set on the single-track path). - SoundCloud, Bandcamp, Mixcloud, Vimeo, Twitch VODs, direct media
URLs — anything in the
yt-dlpextractor list.
If yt-dlp can extract a single audio stream URL from it, the bot can
play it.
Audio quality
The bot configures songbird for 256 kbps Opus
(Bitrate::Bits(256_000) in src/music/voice.rs) and uses the
streaming YoutubeDl input. yt-dlp is launched with
-f bestaudio, so the input is whatever the highest-bitrate audio
stream is at the source — typically Opus directly from YouTube, which
means the bytes flow through to Discord with no transcoding at any
point.
Practical consequences:
- CPU footprint is negligible — under a percent on a small VPS — even with multiple guilds streaming.
- Quality is bounded by the source. A 96 kbps SoundCloud track is still 96 kbps when it reaches your ears.
- There is no normalization, no equalizer, no audio filters. If you want loudness normalization you need to add it yourself.
YouTube cookies
YouTube increasingly demands a logged-in session for anonymous IPs, particularly:
- Age-restricted videos
- Region-locked videos
- “Sign in to confirm you’re not a bot” anti-scraping prompts on data-center IPs
The fix is to provide a cookies file. The bot looks for cookies.txt
in the working directory at startup. The file is gitignored and
intentionally lives per-instance — each bot instance has its own.
Format
cookies.txt is the Netscape / Mozilla cookies format. Easiest way
to generate one:
- Install a browser extension such as “Get cookies.txt LOCALLY” (Firefox or Chrome).
- Log into YouTube in the browser session you control.
- Use the extension to export cookies for
youtube.com. - Save the file as
cookies.txtin the instance config directory next toconfig.toml.
Use a throwaway YouTube account for this. The bot is going to make API calls with whatever account you log in as.
Cookie fallback behaviour
If the cookies file is missing, expired, or otherwise rejected by
YouTube, the bot does not give up. The flow in
src/music/track.rs::resolve_tracks is:
- Run
yt-dlpwith--cookies cookies.txt. - If the call succeeds, return the result.
- If it fails and the stderr contains a known cookie-error marker
(“page needs to be reloaded”, “sign in to confirm”,
“this helps protect our community”, “login required”), retry the
same query with no
--cookiesflag at all. - If the second attempt succeeds, return the result and tell the caller to flag the cookies as stale.
- If the second attempt also fails, surface the error to the user.
When the second attempt is what worked, the bot adds a one-line warning to chat:
⚠ YouTube cookies are expired. Music still works but age-restricted content won’t. Someone needs to refresh
cookies.txt.
So you’ll know to refresh them, but the bot stays usable in the meantime.
Auto-leave
When playback finishes and there is nothing left in the queue, the bot
starts a 5-minute idle timer
(src/music/voice.rs::start_idle_timer). If nothing else is queued
within those 5 minutes, the bot leaves the voice channel and clears
its per-guild player state. Any new track started before the timer
fires cancels it.
There is also a separate auto-leave path triggered by voice state
updates: if everyone else leaves the voice channel and the bot is
the only remaining occupant, it leaves immediately. See
src/events/voice_state.rs.
Permissions required
The bot needs the standard voice trio in any channel it should be allowed to play in:
- Connect — to join the voice channel
- Speak — to transmit audio
- Use Voice Activity — so it doesn’t have to push-to-talk
If the role you assigned to the bot is missing any of these, joining will succeed but no audio will be heard, and the bot will not produce a clean error — it’ll just sit silently in the channel. Check role permissions on a per-channel basis if a specific room misbehaves.
DJ mode
If the guild has DJ mode enabled (set via !m djmode and !m djrole,
stored in the database), only members with the DJ role (or
administrators) can use music commands and music buttons. Other
members get a polite refusal.
DJ mode is a per-guild setting, not a config-file setting — each server’s admins manage their own.
Common issues
- “Sign in to confirm you’re not a bot” or “Couldn’t find that
song” — YouTube needs cookies. Provide a
cookies.txt(see above). Until you do, only non-restricted videos will play. - “Video unavailable” or geo-blocked content — there’s nothing the
bot can do; the source is refusing the request from the bot’s egress
IP. A different region’s
cookies.txtplus a tunneled connection might work, but that’s outside the bot’s scope. - Bot joins the channel but no audio plays —
ffmpegis missing on the host. The Docker image bundles it; if you’re running outside Docker, install it via your package manager and make sure it’s onPATH. - Audio is choppy or stutters — rare with passthrough, since CPU
is barely involved, but possible if the host has heavy disk/network
contention. Check
htopand the bot logs for backpressure. - The bot’s “Now Playing” embed disappears every track change — that’s intentional. The bot deletes the previous embed when a new track starts so there’s only one set of controls live at a time.
- A long playlist only adds a fraction of its tracks — the queue
is capped at 100. Anything past
MAX_QUEUE_LENGTHis dropped on enqueue and the bot tells you how many were added.
Rate limiting
Every music prefix command and every music_* button interaction is
throttled per user through the shared RateLimiters infrastructure
at 15 requests / 30 seconds. That covers all 11 prefix commands
(play, playlist, skip, stop, pause, resume, queue,
nowplaying, remove, loop, shuffle) and every button on the
“Now Playing” embed (pause/resume, skip, stop, shuffle, loop, show
queue). Hitting the cap returns a “Slow down” reply instead of
executing the action.
The rate limit is in addition to the existing practical limits:
- Discord’s voice-gateway rate limits.
yt-dlpstartup time (it forks a subprocess per resolve).- The 100-track queue cap.
If you want stricter throttling than per-user, gate the commands with DJ mode.
Cross-references
- Architecture: Music Pipeline — diagrams of the resolve / play / event-handler flow.
- AI Chat — how to drive music commands through the AI tool layer.
- Instance Config —
command_prefixand other per-instance settings. - Codebase Tour — where the music modules live in the source tree.
Wordle
A Discord-native port of the New York Times’ daily five-letter word puzzle. The bot fetches the official puzzle from the NYT API and tracks each game in memory, scoped to a single channel.
What it does
Wordle is the classic six-guess word game: you have six attempts to guess a five-letter solution. Each guess is graded letter-by-letter — green for the right letter in the right spot, yellow for the right letter in the wrong spot, black for a letter that isn’t in the solution at all. The bot owns the score grid and the “keyboard” tracker; you just type your guesses into chat as plain messages.
Three flavours of puzzle are available:
- Today’s puzzle — the same one everybody else is playing. Pulled from the NYT API in UTC.
- A specific date — replay any puzzle from
2021-06-19(the first NYT Wordle) onwards. - A random puzzle — pick any past puzzle uniformly at random from the full back catalogue.
Wordle is always-on. There is no [features] flag and no API key required.
The bot calls a public NYT JSON endpoint
(nytimes.com/svc/wordle/v2/<date>.json) and the word list of valid guesses
is bundled into the binary (src/wordle/words.txt).
Commands
All commands live under the m parent command. With the default !
prefix that means !m wordle <subcommand>. The prefix is configurable
per instance via command_prefix in config.toml; the examples assume
!.
| Command | Aliases | Description |
|---|---|---|
!m wordle | !m w | Start today’s puzzle in this channel. |
!m wordle random | !m w rand, !m w r | Start a random puzzle from the back catalogue. |
!m wordle date <YYYY-MM-DD> | !m w d <date> | Start a specific date’s puzzle. The argument is #[rest], so trailing whitespace is fine. |
Starting a new game in a channel that already has an active
(non-expired) game is refused — the bot replies that there’s a
game in progress and asks you to finish or wait for it to expire
(30 minutes idle). Once the existing game is solved, lost, or
times out, you can start a new one. This applies to the AI
wordle_start tool path as well, so the AI can’t accidentally
clobber an in-progress game either. One puzzle per channel at a
time.
The date subcommand validates its argument up front via
chrono::NaiveDate::parse. Bad input (!m wordle date today,
!m wordle date 2026/04/16, !m wordle date april 16) gets a
“Use YYYY-MM-DD format” reply and no NYT call. If the request
parses but NYT returns a puzzle whose print_date doesn’t match
the requested date, the bot prepends a warning before the game
embed (NYT occasionally serves the previous day’s puzzle near
the rollover boundary).
How to play
- Start the puzzle with one of the commands above. The bot posts an
embed showing six empty rows and a
Wordle — YYYY-MM-DDtitle. - Type a five-letter word into the channel. The bot detects any message in the channel that’s exactly five ASCII letters while a game is active — no command prefix needed.
- The bot deletes your guess message and edits the embed in place, filling in the next row with green/yellow/black squares and updating the keyboard tracker at the bottom.
- Repeat until you solve it or run out of guesses.
If you type a five-letter sequence that isn’t a real word, the bot replies “Not a valid word.” and deletes both your message and its reply after about two seconds. You don’t lose a guess.
When you win, the title flips to Wordle — YYYY-MM-DD — Solved in N!
with a green border. When you lose, the title flips to Game Over, the
border goes red, and the answer is revealed under the grid.
The keyboard tracker
Below the grid the bot maintains a per-letter status line that summarizes what you’ve learned so far:
🟩 A E 🟨 R ⬛ S T O U N D L
Letters get “upgraded” as you discover better information about them: a
letter that started as black (⬛ Absent) gets promoted to yellow
(🟨 Present) the first time you see it in a yellow square, and to green
(🟩 Correct) the first time you see it in a green one. The tracker
never downgrades a letter, so you can scan it for what’s still on the
table at a glance.
Game lifecycle
Wordle game state lives in an in-memory DashMap keyed by Discord channel
ID. Two things end a game:
- Completion. When you win or lose, the game is removed from the map immediately. The embed stays in the channel as a record.
- Inactivity. A game that hasn’t received a guess in 30 minutes is treated as expired. Expired games are cleaned up the next time someone tries to interact with the channel.
Restarting the bot wipes all in-progress games. There is no persistence; nothing about Wordle touches the database. If you start the daily puzzle, guess a few times, and the bot restarts, you’re back to a fresh six guesses.
There is no per-user lockout — once a game is going in a channel, anyone in that channel can guess. This makes it work nicely as a collaborative puzzle.
Configuration
There is nothing to configure. Wordle ships always-on and uses no environment variables. If your instance hides the games behind DJ mode or a role gate, that’s outside Wordle’s scope; gate the channel itself with Discord permissions.
Common issues
- “Use YYYY-MM-DD format” — your
dateargument didn’t parse as an ISO date. Examples that work:2024-01-15,2021-06-19. Examples that don’t:today,2024/01/15,Jan 15 2024. - “No Wordle found for date
YYYY-MM-DD” — the date parsed but is before2021-06-19(when the NYT bought Wordle and started serving puzzles via this API) or NYT has no puzzle for that day. - “There’s already a Wordle in progress in this channel” — someone started a game and it hasn’t finished or timed out (30 minutes idle). Finish the game or wait it out.
- “NYT served a different date than requested” warning above the game — happens occasionally when NYT’s rollover lags the requested date. The bot still posts the puzzle; the warning is just so you don’t think you’re playing the wrong day’s game.
- Five-letter guesses do nothing — there is no active game in the
channel. Start one with
!m wordle. Or the previous game just expired (30 minutes idle); the next guess after expiry is silently ignored rather than triggering anything weird. - Bot says “Not a valid word.” — your guess isn’t in the bundled
word list (
src/wordle/words.txt). The list is the standard Wordle guess vocabulary; it does not include slang, names, or every English word. Try a different guess. - Wins/losses don’t show up on a leaderboard — there is no Wordle leaderboard. Game state is per-channel and ephemeral, by design.
- Multiple people guessing at the same time — that’s fine. The bot serializes guesses through a per-game lock and applies them in the order they arrive. The grid will update once per guess.
Cross-references
- Connections — sister command, same daily-NYT pattern.
- Stocks — the third “always-on” game-like feature.
- AI Chat — the AI can invoke
wordle_startas a tool, so users can say “@bot start a Wordle” without remembering the command. - Instance Config —
command_prefixif you’ve changed it from the default!.
Connections
The New York Times’ “find the four hidden groups” word puzzle, played inside Discord with button controls. The bot fetches the official puzzle from the NYT API and renders the 16-word board as a grid of clickable buttons.
What it does
A Connections puzzle gives you sixteen words. Those words sort into four hidden categories of four words each, each category colour-coded by difficulty: yellow (easiest), green, blue, and purple (hardest). Your job is to identify all four groups. You’re allowed four mistakes before the game ends.
The bot:
- Fetches the puzzle for a given date from the NYT API
(
nytimes.com/svc/connections/v2/<date>.json). - Shuffles the 16 words and renders them as four rows of four buttons.
- Lets users click words to select them, click again to deselect, and hit Submit when they have exactly four selected.
- Tracks selected words, solved categories, mistakes remaining, and a status line showing the result of the last guess.
- Detects “one away” guesses (three of four correct) and tells you so, the same way the official NYT version does.
- Reveals all four categories when the game ends, win or lose.
Three flavours of puzzle are available, mirroring the Wordle commands: today’s daily puzzle, a specific date, or a random one from the back catalogue.
Connections is always-on. There’s no [features] flag and no API key
required.
Commands
All commands live under the m parent command. With the default !
prefix that means !m connections <subcommand>. The prefix is
configurable per instance.
| Command | Aliases | Description |
|---|---|---|
!m connections | !m conn | Start today’s puzzle in this channel. |
!m connections random | !m conn rand, !m conn r | Start a random puzzle from the back catalogue. |
!m connections date <YYYY-MM-DD> | !m conn d <date> | Start a specific date’s puzzle. |
Starting a new game in a channel that already has an active
(non-expired) game is refused — the bot replies that there’s a
game in progress and asks you to finish or wait for it to expire
(30 minutes idle). Once the existing game is solved, lost, or
times out, you can start a new one. The same guard applies to
the AI connections_start tool path. One puzzle per channel at
a time.
The date subcommand validates its argument up front via
chrono::NaiveDate::parse. Bad input (!m connections date today,
!m connections date 2026/04/16) gets a “Use YYYY-MM-DD format”
reply and no NYT call. If NYT returns a puzzle whose print_date
doesn’t match the requested date, the bot warns above the board.
How to play
- Run
!m connections(or one of the variants above). The bot posts an embed with the titleConnections — YYYY-MM-DD, a “Mistakes remaining: ⬛⬛⬛⬛” line, and four rows of word buttons. - Click words to select them. Selected words flip from grey
(
Secondary) to blue (Primary). The Submit button only enables once you have exactly four selected. - Click Deselect to clear your current selection, Shuffle to randomize the remaining words on the board, or Submit to commit the four-word guess.
- Each guess produces one of three outcomes:
- Correct — the four words form one of the hidden categories. The bot adds them to the solved list at the top of the embed (annotated with the category title and difficulty colour) and removes them from the board.
- One away — three of your four words are in the same hidden category but the fourth doesn’t fit. The bot says “One away” and decrements your mistakes counter.
- Wrong — none of the unsolved categories matches three or more of your words. Counter decrements.
- Continue until you solve all four groups (win) or run out of mistakes (lose). At game end the bot reveals every group with its words and category title.
The status line under “Mistakes remaining” shows the most recent
result and which user made the guess: e.g. 🟪 Tricky Wordplay solved by @alice! or ❌ One away! (guessed by @bob) — 2 mistakes remaining.
Anyone in the channel can press the buttons. Connections is collaborative by default — the bot doesn’t restrict guessing to a single user.
The board representation
The bot embed has three parts:
- Solved groups at the top, in difficulty order (yellow → purple), each line showing the colour emoji, the category title, and the four words.
- Mistakes remaining as a row of four squares:
⬛for each remaining mistake,✖️for each used. - Status message showing the result of the last action.
Below the embed, an action row holds the word buttons (4 per row, up to 4 rows for 16 words; the row count shrinks as you solve groups) plus a control row with Shuffle, Deselect, and Submit. When the game ends, all buttons disappear.
Game lifecycle
Connections game state lives in an in-memory DashMap keyed by Discord
channel ID. Two things end a game:
- Completion. A win or a fourth mistake. The bot rewrites the embed with the answer key and removes the buttons.
- Inactivity. A game that hasn’t received a button press in 30 minutes is treated as expired. The next click after expiry gets an ephemeral “Game expired due to inactivity” message and the game is dropped from memory.
Restarting the bot wipes all in-progress games. Nothing about Connections touches the database; there is no persistence and no leaderboard.
Configuration
There is nothing to configure. Connections ships always-on and uses no environment variables. The puzzle archive starts at 2023-06-12 (the first NYT Connections puzzle). Earlier dates return “No puzzle found for date”.
Common issues
- “Use YYYY-MM-DD format” — your
dateargument didn’t parse as an ISO date. Examples that work:2024-01-15,2023-06-12. - “No puzzle found for date
YYYY-MM-DD” — the date parsed but is before2023-06-12(the first NYT Connections puzzle) or NYT has no puzzle for that day. - “There’s already a Connections game in progress in this channel” — someone started a game and it hasn’t finished or timed out (30 minutes idle). Finish the game or wait it out.
- “NYT served a different date than requested” warning above the board — happens when NYT’s rollover lags the requested date. The bot still posts the puzzle.
- “Select exactly 4 words before submitting” — the Submit button is meant to be disabled until you have four selected, but if you press it via tooling that ignores the disabled state, you’ll get this ephemeral notice.
- “No active game in this channel” when pressing a word button — the previous game expired or was replaced. Start a new one.
- The board doesn’t shrink when I solve a group — it does, but Discord caches embeds aggressively in some clients. Refresh the channel.
- Two people clicking words at the same time fight each other — the bot serializes each click through a per-game lock, so there’s no race, but you may briefly see a button toggle on/off if two users click the same word in quick succession.
- Today’s puzzle isn’t available yet — the NYT publishes the new
puzzle around midnight Eastern. Until then the bot’s “today” date
(UTC) may resolve to a not-yet-available puzzle and you’ll get the
“No puzzle found” error. Try
!m connections date <yesterday>.
Cross-references
- Wordle — sister command, same daily-NYT pattern.
- AI Chat — the AI can invoke
connections_startas a tool, so users can say “@bot start Connections” without remembering the command. - Instance Config — for
command_prefix.
Virtual Stock Trading
A play-money trading game backed by real-time stock quotes. Every user
in a guild gets a virtual $1,000 portfolio, can buy and sell US-listed
stocks at live prices, and can compete against the rest of the server on
a leaderboard ranked by total portfolio value.
What it does
- Each
(guild, user)pair has its own portfolio: a cash balance and a set of holdings. - Quotes come from the Finnhub
/quoteendpoint, using your free or paidFINNHUB_API_KEY. - Buys and sells settle at the live quote at the moment the order executes (no slippage simulation, no order book).
- Realized profit and loss is computed on each sell, using the weighted-average cost basis tracked in the database.
- A per-server leaderboard ranks the top ten portfolios by total value (cash + holdings at current prices).
- A trade history shows your last ten transactions with timestamps.
The game is per-guild, so the same Discord user has independent portfolios on each server they’re in.
Activation
Stocks is always-on in the sense that the !m stock command tree is
always registered. Every command requires FINNHUB_API_KEY in the
environment, however. Without it, every subcommand returns:
Stock trading is not configured. The bot owner needs to set
FINNHUB_API_KEY.
To activate the feature you need exactly two things:
- A Finnhub account. The free tier is generous (60 calls/minute) and covers all US equities.
- The key in your bot’s
.envasFINNHUB_API_KEY=….
Restart the bot after editing .env.
Commands
All commands live under the m parent. With the default ! prefix
that means !m stock <subcommand>. The prefix is configurable per
instance.
| Command | Aliases | Description |
|---|---|---|
!m stock | !m stocks, !m st | Bare command; shows your portfolio. |
!m stock buy <ticker> <qty|$amount> | !m stock b | Buy shares. qty is a share count (10, 0.5); $amount (with literal $) buys whatever fraction of a share that dollar amount maps to at the current quote. |
!m stock sell <ticker> <qty|all> | !m stock s | Sell shares. all sells your full position. |
!m stock portfolio [@user] | !m stock port, !m stock pf, !m stock p | Show your own portfolio, or somebody else’s if you mention them. |
!m stock price <ticker> | !m stock quote, !m stock q | Look up the current quote. No portfolio needed. |
!m stock leaderboard | !m stock lb, !m stock top | Top 10 portfolios by total value. |
!m stock history | !m stock hist, !m stock h | Your last 10 trades. |
!m stock reset | — | Wipe your holdings and history; reset cash to $1,000. Posts a confirmation embed with Confirm and Cancel buttons (gated to the original author, expires after 30 seconds). |
Tickers are case-insensitive and normalized to uppercase. The Finnhub
universe is US-listed equities — AAPL, MSFT, TSLA,
NVDA and so on. Crypto, forex, indices, and non-US listings will
return “Could not find stock symbol”.
Examples
!m stock buy AAPL 5 # buy 5 whole shares of Apple
!m stock buy NVDA $250 # spend $250 on Nvidia (fractional shares OK)
!m stock sell AAPL 2 # sell 2 shares
!m stock sell NVDA all # close the Nvidia position entirely
!m stock price MSFT # look up Microsoft, no trade
!m stock portfolio @someone # peek at another user's portfolio
!m stock leaderboard # see the top 10
!m stock reset # nuclear option (with button confirmation)
The bot can also drive these commands via the AI tool layer: ask the
bot in plain language (“buy me $500 of Tesla”) and the AI invokes
stock_buy with the right arguments. See
AI Chat.
How it works
Starting balance
The first time you touch any stock command, the bot calls
get_or_create_portfolio and seeds your account with $1,000.00 in
cash. From that point on, your balance changes only via buys, sells,
and !m stock reset.
Quotes and caching
Every command that needs a live price calls
stocks::api::get_quote. The function:
- Checks the database price cache for that symbol.
- If a fresh entry exists, returns it immediately. Otherwise hits
https://finnhub.io/api/v1/quote?symbol=<TICKER>with the API key in theX-Finnhub-Tokenheader. - Caches the result on the way back.
The cache is what keeps you under the Finnhub rate limit when a busy server pulls the leaderboard or many people check the same stock. Quotes contain three numbers: current price, previous close, and the day’s percent change.
Buy and sell semantics
A buy debits cash, increments the holding, and updates the holding’s
weighted-average avg_cost. A sell credits cash and computes the
realized P/L for that sale: (sell_price − avg_cost) × quantity.
Both buy and sell (and reset) run inside a database transaction
that takes a SELECT … FOR UPDATE row lock on the portfolio first,
so concurrent trades — including a buy/sell racing against a reset —
serialize cleanly instead of double-spending or deleting freshly
purchased shares.
Buys are sized one of two ways:
- By share count:
!m stock buy AAPL 10— exactly ten shares. Fractional counts are allowed (!m stock buy AAPL 0.5). - By dollar amount:
!m stock buy AAPL $500— spend$500, receive$500 / current_priceshares. Always fractional.
If you don’t have the cash, the bot replies “Insufficient funds” and the trade is rejected. If you try to sell more than you own, the bot tells you your actual share count and refuses the trade.
Portfolio embed
!m stock portfolio (or just !m stock) shows:
- Your cash balance.
- Each holding: ticker, share count, current price, market value, P/L percent.
- Total value (cash + market value of holdings).
- Total P/L versus the original
$1,000baseline, in both dollars and percent.
The embed border colour reflects whether you’re up (green) or down (red) overall.
Leaderboard
!m stock leaderboard walks every portfolio in the guild, fetches a
current quote for every holding, and ranks the top ten by total
value. The P/L column is total − $1,000, since that’s the
baseline everybody started from. The top three get medals
(🥇🥈🥉).
On large servers with many active portfolios this command does a lot of API calls. The price cache absorbs most of it; if you find yourself rate-limited, slow it down or switch to a paid Finnhub tier.
History
!m stock history shows your last ten transactions: buy or sell,
share count, price, total amount, and a relative timestamp. Older
trades are still in the database — the embed is just capped at ten
for readability.
Reset
!m stock reset is destructive. Running it posts a confirmation
embed with Confirm and Cancel buttons. Only the user who
ran the command can press a button; if 30 seconds pass with no
response the confirmation expires and nothing is wiped. Confirm
clears all holdings and trade history and resets cash to
$1,000.00; Cancel just dismisses the prompt. You can reset as
often as you like.
The reset itself runs in the same FOR UPDATE-locked transaction
as buys and sells, so a sell that lands at the same instant won’t
beat the reset to the holdings table.
Decimal precision
Money math throughout the stocks module uses rust_decimal::Decimal
rather than f64. The database columns are NUMERIC(18, 4), so
share quantities and cash balances are precision-exact — no
floating-point drift on cents or fractional shares from compound
buy/sell sequences. Display rounds money to two decimal places
(.round_dp(2)) and shares to four (.round_dp(4)), but the
stored values keep full precision.
Rate limiting
The buy, sell, and reset commands are throttled per user at
10 requests / 30 seconds by the shared RateLimiters
infrastructure. Hitting the cap returns a “Slow down” reply
instead of executing the trade. Read-only commands
(portfolio, price, leaderboard, history) aren’t gated.
Permissions
There are no Discord permission gates on the stock commands. Anyone in a guild can play. If you want to restrict it, gate the channel itself with role permissions.
Common issues
- “Stock trading is not configured” —
FINNHUB_API_KEYis missing or empty. Set it and restart. - “Could not find stock symbol X” — Finnhub returned
0for that ticker. Verify on finnhub.io; crypto and non-US equities aren’t on the free tier. - “Finnhub API returned status 429” — rate-limited. Wait a minute or upgrade your Finnhub tier.
- Stale price — the quote came from cache. Trades use a fresh quote every time.
- Fractional-share rounding looks weird — share counts are
stored to four decimals as
NUMERIC(18, 4)Decimals (no f64 drift). Display rounds to four decimals for shares and two for money; the stored values keep full precision. - Lost my position after
reset— irreversible by design. - Markets closed — Finnhub returns the last traded price. Trades still execute; there’s no “market closed” rejection.
Cross-references
- Environment Variables —
FINNHUB_API_KEY. - AI Chat — the
stock_buy,stock_sell,stock_price,stock_portfolio, andstock_leaderboardtools. - Wordle and Connections — the other always-on game features.
Moderation
A small, focused set of moderation commands: temporary bans with automatic expiry, an early-unban override, an active-tempbans listing, and a bulk-message-delete (“nuke”) tool. Every action can optionally be mirrored to an audit-log channel.
What it does
The moderation module is deliberately narrow. It covers the cases where a Discord-native action falls short:
- Tempban — Discord’s built-in ban is permanent; the bot adds an expiry timestamp and a background task that automatically lifts the ban when the duration is up.
- Unban — early termination of a tempban (or removal of any Discord ban), with database bookkeeping to mark the active record as resolved.
- Banlist — a quick view of every active tempban in the guild, with the user, moderator, expiry timestamp (relative), and reason.
- Nuke — bulk delete the most recent N messages in a channel. Useful for cleaning up spam raids and accidental message dumps.
There is no kick command, no timeout command, and no role-assignment command in the moderation module. Discord’s built-in tools cover those well enough that wrapping them adds no value. Auto-role promotions and join-role assignment are separate features (see Auto-Role and Join Features).
Moderation is always-on. There’s no [features] flag and no
configuration required — it works out of the box. The audit-log
channel is the one optional bit, configured at runtime per guild.
Commands
All commands live under the m parent. With the default !
prefix that means !m <subcommand>. The prefix is configurable
per instance.
| Command | Required Permission | Description |
|---|---|---|
!m ban <@user> <duration> [reason] | BAN_MEMBERS | Tempban a user. Duration is a short string (30s, 5m, 2h, 3d, 1w); reason is #[rest], so spaces are fine. |
!m unban <@user> | BAN_MEMBERS | Lift any ban on the user. Marks an active tempban resolved if one exists. |
!m banlist (alias !m bans) | BAN_MEMBERS | Show all active tempbans in this guild with expiry timestamps. |
!m nuke <count> | MANAGE_MESSAGES | Bulk-delete the last count messages in the channel. count must be 1-100. |
!m setlog <#channel> | ADMINISTRATOR | Set the audit-log channel for this guild. (Lives in the admin command module, but enables moderation logging.) |
Permissions are enforced by Poise’s required_permissions
attribute — invocations from users without the listed permission are
rejected before the command body runs, so unprivileged users get
Discord’s standard “missing permissions” error rather than the bot’s
own message.
Tempbans in detail
Duration parsing
The duration argument uses the same short-string format as
min_age for auto-role: <integer><unit> with no
spaces, where unit is s, m, h, d, or w. Examples:
30s— 30 seconds15m— 15 minutes2h— 2 hours3d— 3 days1w— 1 week
Combined units (1d12h) are not supported. The maximum is 365 days;
anything longer is rejected with “Invalid duration”.
What !m ban actually does
- Parses the duration; bad input gets
Invalid duration. Use: 30s, 5m, 2h, 3d, 1w. - Inserts a row into the
tempbanstable with the guild, user, moderator, expiry timestamp, and reason. - Calls Discord’s
ban_with_reasonwith the audit-log reasonTempban by <moderator> (<duration>): <reason>. - Replies in the invocation channel with a relative-timestamp footer.
- If an audit-log channel is configured, posts a richer embed there — see Audit logging.
Automatic expiry
A background task runs every 30 seconds (started about 5
seconds after bot boot), polling the tempbans table for entries
whose expires_at is in the past. For each expired entry, the
bot calls remove_ban against Discord’s API and marks the row
as unbanned in the database. The audit-log reason on the unban
call is “Tempban expired”.
This means there’s a worst-case 30-second window between a tempban’s nominal expiry and the user actually being unbanned. Good enough; nobody really notices a tempban resolving 25 seconds late.
Early unban
!m unban @user calls Discord’s unban API and updates the
database. If there was no active tempban for that user in the
database, the bot still issues the Discord unban (so it works
for non-tempban bans too) and appends “(No active tempban was
found in the database.)” to the reply, so the moderator knows.
Banlist
!m banlist queries the tempbans table for everything still
flagged active in this guild and renders an embed with one line
per ban: the user mention, a relative expiry timestamp, the
moderator who issued it, and the reason if there was one.
Empty output is “No active tempbans.”
Nuke (bulk delete)
!m nuke <count> fetches the last count + 1 messages in the
channel (the + 1 is to swallow the command invocation itself),
then bulk-deletes them. Discord’s bulk-delete endpoint refuses
messages older than 14 days, so:
- The actual delete count may be lower than
countif some of the recent messages are old. - The reply tells you how many actually got deleted:
Deleted **42/100** messages (messages older than 14 days can't be bulk-deleted).
The bot’s own confirmation message is auto-deleted after 3 seconds so it doesn’t sit in the channel. The audit-log embed (if configured) records the action.
The minimum is 1, the maximum is 100. Anything outside that
range gets a usage hint instead of running.
Audit logging
Every successful moderation action — tempban, unban, nuke — can be mirrored to a designated channel as a structured embed. Set it up once per guild:
!m setlog #mod-actions
Requires ADMINISTRATOR. The channel ID is stored in the
guild_settings.audit_log_channel_id column. Subsequent moderation
actions look up that channel and post:
- Tempban / Nuke — red border (
#ed4245), titleMod Action: <Tempban|Nuke>, fields for user/moderator/duration (or channel- count), timestamped.
- Unban — green border (
#57f287), same shape.
If no audit-log channel is set, the action still happens — the audit post is silently skipped.
Audit-log delivery is best-effort: if the bot can’t post to the configured channel (deleted, bot lacks permission, etc.) the moderation action still goes through and the failure is swallowed.
AI moderation
The AI chat layer can invoke moderation tools (tempban, unban,
nuke) on behalf of a user. Every AI-initiated moderation call
goes through a confirmation embed with Approve and Cancel
buttons (only the original requester can press them, requester’s
Discord permissions still checked, expires after 30 seconds). See
AI Chat: Moderation confirmation.
Rate limiting
The moderation rate limiter applies to both the AI tool path
and the prefix commands. !m ban, !m unban, and !m nuke each
check the per-user moderation rate limiter before running, so a
moderator can’t bypass it by typing the prefix command instead of
asking the AI. The Poise required_permissions check still
fires first; the rate-limit check follows. Hitting the cap
returns a “Slow down” reply and skips the action.
Permissions required
The bot itself needs BAN_MEMBERS (for !m ban and
!m unban) and MANAGE_MESSAGES (for !m nuke). Moderators
need the same permissions on their own role to invoke the
commands.
For !m setlog the moderator needs ADMINISTRATOR. The bot
needs SEND_MESSAGES and EMBED_LINKS in the audit-log channel
itself.
Common issues
- “Invalid duration” — your duration string didn’t parse.
Use
30s,5m,2h,3d,1w. Combined formats aren’t supported. - Tempban issued, user comes back after a bot restart — shouldn’t happen. The database is the source of truth and the unban task picks up where it left off.
!m banlistempty after a restart — only if you wiped the database. The Discord ban list is independent (Server Settings → Bans).- Nuke deleted fewer messages than asked — Discord’s 14-day bulk-delete limit; the bot reports the actual count.
- Audit log embed missing — wrong channel ID, missing
permissions, or
!m setlognever ran. - Unban says “No active tempban was found” — not a bug; the user was banned manually and there’s no tempban row to mark resolved. The unban itself worked.
Cross-references
- Auto-Role — the role-promotion side of moderation tooling.
- Join Features — auto-assigning a role on join, and AI welcome messages.
- AI Chat: Moderation confirmation — how the AI gates moderation tool calls behind a button confirmation.
- Instance Config — for
command_prefix.
Auto-Role Promotion
Promote members from one role to another automatically once they cross a configurable activity threshold. Useful for unverified → verified flows, lurker → member graduations, and any other “stick around long enough and you get the upgrade” pattern.
What it does
The auto-role module watches every member who currently holds a
designated from_role and promotes them — adding to_role and
removing from_role — once they meet your criteria. The criteria are
simple:
- Minimum age in the guild (e.g.
3d). - Minimum messages sent in the guild (e.g.
20).
You decide whether either condition is enough to trigger promotion
(require_all = false, the default), or whether both must hold
(require_all = true).
Two checks run in parallel so promotions feel snappy without depending solely on activity:
- Live, on every message. When a member posts in the guild, the bot increments their message count and immediately checks whether they now meet the threshold. If so, the promotion fires asynchronously.
- Background, every 60 seconds. A scheduled task scans every unpromoted member, including ones who’ve gone quiet, and promotes anyone whose age-in-guild has crossed the line.
Together this means a chatty user is promoted within a few seconds of hitting the message threshold; a silent user is promoted within a minute of crossing the age threshold.
Activation
Auto-role is opt-in. Enable it in config.toml:
[features]
auto_role = true
[auto_role]
from_role = "123456789012345678" # the role to remove
to_role = "987654321098765432" # the role to grant
min_age = "3d"
min_messages = 20
require_all = false
Restart the bot after editing. The startup logs will show:
Auto-role module enabled (from=…, to=…, min_age=3d, min_messages=20, require_all=false)
Auto-role time checker started (60s interval).
If you set features.auto_role = true but omit the [auto_role]
section, the bot logs a warning and the feature stays off — it
doesn’t refuse to boot. See
docs/configuration/instance-config.md
for the field reference.
Configuration
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
from_role | string (snowflake) | yes | — | The role the member starts with. Removed on promotion. |
to_role | string (snowflake) | yes | — | The role the member ends up with. Added on promotion. |
min_age | duration string | no | "3d" | Minimum time in the guild before promotion. |
min_messages | integer | no | 20 | Minimum messages in the guild before promotion. |
require_all | bool | no | false | If true, both criteria must hold; if false, either does. |
Snowflake IDs, not role names
Both from_role and to_role are role IDs as strings (Discord
snowflakes), not role names. To get a role’s ID: in Discord,
turn on Developer Mode (Settings → Advanced → Developer Mode), then
right-click the role in Server Settings → Roles and choose “Copy
ID”.
Duration format
min_age accepts a single integer-with-unit string: 30s, 15m,
2h, 3d, 1w. No combined units. Maximum 365 days. The default
is 3d.
Combining criteria
With require_all = false (the default), the bot promotes as soon
as either condition holds. A user who’s been in the guild for
four days but has never spoken still gets promoted on the next
60-second tick. A user who joined ten minutes ago and has already
sent 30 messages gets promoted the moment their 20th message lands.
With require_all = true, both conditions must hold. Same flow,
but neither condition alone is enough to fire the promotion.
How it works
Tracking activity
The first time the bot sees a message from a user in a guild with
auto-role enabled, it inserts a row into the member_activity
table with message_count = 1 and first_seen = NOW(). Every
subsequent message increments message_count. The promoted flag
on the row starts as FALSE and flips to TRUE after a successful
promotion.
first_seen is set on the first observed message, not on the
member’s actual join timestamp. A user who joined the guild before
auto-role was enabled — or before the bot was running — is treated
as “first seen” the next time they post. This is intentional: the
bot can’t promote someone it has no record of, and falling back to
join time would risk batch-promoting half the server the moment
you enable the feature.
If you want to backfill historical members, the cleanest path is
to manually add to_role to existing members and let the auto-role
flow only handle newcomers.
The two promotion paths
Live path (src/events/mod.rs::handle_message): on every
non-bot guild message, the bot increments the user’s count and
immediately checks meets_criteria. If true, it spawns a task
that calls try_promote. This is where the message-count
threshold gets caught.
Background path (src/main.rs): a long-lived task wakes up
every 60 seconds, queries the member_activity table for every
row with promoted = FALSE in the configured guild, evaluates
each one against the criteria, and promotes whoever qualifies.
This is where the age-only path gets caught.
The two paths converge on the same try_promote function, which:
- Adds
to_roleto the member. - Removes
from_role. - Sets
promoted = TRUEin the database.
Both Discord API calls have the audit-log reason “Auto-role promotion”, so the action is identifiable in the guild’s audit log.
Multi-instance / multi-guild scope
Auto-role is configured per bot instance, not per guild. The
background task uses the GUILD_ID env var to know which guild
to scan. If you want different thresholds in different guilds,
run separate bot instances with separate config.toml files. See
Multiple Instances.
Permissions
The bot’s role must:
- Be higher in the role list than both
from_roleandto_role(Discord enforces role-hierarchy on add/remove). - Have the Manage Roles permission.
Without either, the promotion call will fail and the bot will log
Auto-role promotion failed: … for that user. The row stays
promoted = FALSE, so the next tick will retry — possibly
forever, until you fix the permissions.
Common issues
- Nobody is being promoted — confirm the startup log shows
Auto-role module enabledandAuto-role time checker started. If only the first, the[auto_role]section is missing or the role IDs failed to parse. - One user stuck at
promoted = FALSE— bot’s role isn’t above both target roles, or it lostMANAGE_ROLES. Check the logs around the promotion attempt. - Existing members aren’t being promoted — expected. The
scan only sees members who have a
member_activityrow, created on their first observed message. Silent members stay invisible until they post. - Promotion is late — bounded by the 60-second background tick for age-only promotions. The live (message-count) path is near-instant.
- Re-promote a user who lost the role — flip
promotedback toFALSEon theirmember_activityrow in the database; the next tick re-promotes. Invalid from_role IDwarning — value isn’t a numeric snowflake. Copy the role’s ID, not its name.
Cross-references
- Instance Config:
[auto_role]— full schema reference. - Join Features — for auto-assigning a role the moment someone joins, before they hit the activity threshold.
- Moderation — for tempbans and bulk delete, the manual side of mod tooling.
- Multiple Instances — how to run different auto-role configs in different guilds.
Member Join Features
Two independent features that fire when a new member joins a guild: an
automatic role assignment (“join role”), and an AI-generated welcome
message (“welcome”). Either can be enabled without the other; they’re
configured in separate sections of config.toml.
Both react to the same Discord event (GuildMemberAddition) and run
sequentially in the bot’s member_join handler. If you have both
enabled, the join-role assignment happens first, then the welcome
message is generated and posted.
Join role
The simplest behaviour the bot ships with: when a new member joins, add a single role to them.
The most common use is marking unverified accounts so they can’t see sensitive channels until they go through whatever onboarding flow you have set up. Another common use is granting a default colour role so new members aren’t completely roleless on arrival.
Activation
Enable in config.toml:
[features]
join_role = true
[join_role]
role = "123456789012345678" # the role ID to assign
Restart the bot. The startup log will show:
Join-role module enabled (role=123456789012345678)
If you set features.join_role = true but omit the [join_role]
section, the bot logs a warning and the feature stays off rather
than refusing to boot.
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
role | string (snowflake) | yes | The role ID to add to every new member. |
That’s it. There is no per-guild override (it applies to every guild the bot instance serves), no exclusion list, and no condition. If a member joins, they get the role.
How it works
src/events/member_join.rs::handle_member_join is invoked by the
event dispatcher every time a GuildMemberAddition event fires.
The first thing it does is check data.join_role_config. If
present, it parses the role ID and calls add_member_role with
the audit-log reason Auto join role. Failures are logged but
otherwise swallowed — the bot doesn’t try to retry.
Permissions
The bot’s role must be above the configured role in the role
hierarchy and have MANAGE_ROLES. Without that, the assignment
will fail with a Discord permission error and the member will be
left without the role. Watch the logs for
Failed to assign join role to <user>: ….
Common issues
- Role isn’t being assigned — bot’s role lacks
MANAGE_ROLESor sits below the target role in the hierarchy. - Members joined while the bot was offline — there’s no
catch-up. The handler only fires for live
GuildMemberAdditionevents. Existing roleless members need to be batched manually. Invalid join role IDwarning — the configuredrolevalue isn’t a numeric snowflake. Make sure Developer Mode is on and you copied the role’s ID, not its name.
Welcome messages
When a new member joins, the bot can post an AI-generated welcome message to a designated channel. The message uses your bot’s personality plus an additional welcome-specific prompt to produce something on-brand and member-specific (mentions the new user, ideally riffs on their display name).
Activation
Welcome messages have three prerequisites:
features.welcome = trueinconfig.toml.- A
[welcome]section pointing at the channel and an optional custom prompt file. - At least one AI provider key —
DEEPSEEK_API_KEYorGEMINI_API_KEY— in.env.
If you set the flag but skip the prompt file, the welcome stays off silently (logged at warn level). Same if neither AI key is set.
[features]
welcome = true
[welcome]
channel = "123456789012345678" # where to post the welcome
prompt_file = "welcome_prompt.txt" # default; relative to config dir
Then create welcome_prompt.txt next to config.toml. This is
not the bot’s overall personality — that’s personality.txt,
loaded for AI chat. The welcome prompt is appended to the
personality and tells the model specifically how to write a
welcome.
A short welcome prompt might look like:
Write a one-paragraph welcome message for the new member.
Keep it under 80 words, be warm but on-brand, and mention them
by their Discord display name (already provided in the user
message). Suggest one channel they should check out first.
How it works
After the join-role step, handle_member_join checks for both
welcome_config and welcome_prompt. If both are present, it:
- Rate-limits per user. Each joining user gets their own
5-second budget through the shared
RateLimitersinfrastructure (1 request / 5 seconds, keyed on the joining user’s ID). A single user re-joining repeatedly is throttled, but a raid of distinct users all get their own welcomes. - Picks a provider. If
DEEPSEEK_API_KEYis set, use DeepSeek (deepseek-v4-flash). Otherwise fall back to Gemini (gemini-3-flash-preview). Both providers are called via their OpenAI-compatible chat endpoints. - Builds the system prompt. Concatenates the bot’s full
personality file with the welcome prompt under a
## Welcome Message Instructionsheading. - Sends one user message. It tells the model the new member’s display name and mention string and asks it to write a welcome.
- Posts the model’s response. The reply text goes straight to the configured channel as a regular message (no embed).
The request has a 30-second timeout and a 512-token max. Failures
(API down, model returns empty content, channel doesn’t exist,
bot lacks permission) are logged at warn and silently swallowed
— no fallback “Welcome!” message is sent.
Configuration
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
channel | string (snowflake) | yes | — | Channel ID to post the welcome in. |
prompt_file | string | no | "welcome_prompt.txt" | Path (relative to the config directory) to the welcome prompt. |
Rate limiting
Welcome generation is rate-limited per joining user through the
bot’s shared RateLimiters infrastructure: 1 request per 5 seconds,
keyed on the new member’s user ID. The same user re-joining within
the window gets a single welcome; ten distinct users joining inside
the same five seconds get ten welcomes (one each).
This is the fix for the older global-mutex rate limit, which suppressed legitimate joins during a raid: a burst of 10 joiners used to produce one welcome and nine silent skips. Now the limiter only throttles a single user’s repeated joins, not the whole channel.
Permissions
The bot needs SEND_MESSAGES and the standard message-content
permissions in the welcome channel. It does not need
EMBED_LINKS since welcomes are plain text. If permissions are
missing, the bot logs Failed to send welcome message: … and
moves on.
Cost
One AI call per join, capped at 512 output tokens. On DeepSeek V4-Flash that’s a fraction of a cent per welcome — even an active server won’t notice the bill. If you’re using Gemini, the free tier covers most casual join volumes; check https://ai.google.dev/pricing for current rates.
Common issues
- No welcome appears — check the logs at startup. If you don’t
see
Welcome module enabled, eitherfeatures.welcome = false, the[welcome]section is missing, or the prompt file is missing/empty. If you do see the enabled line but no AI key warning, verifyDEEPSEEK_API_KEYorGEMINI_API_KEYis in the running environment. - Welcome appears but the personality is wrong — the
personality file is loaded once at boot. Restart the bot after
editing
personality.txtorwelcome_prompt.txt. - Welcome is generic / boring — the welcome prompt is inheriting the bot’s overall personality. Make the prompt more specific — tell it the server’s tone, what to highlight, what to avoid.
- The same user keeps re-joining and only gets one welcome — the per-user 5-second rate limiter. Distinct users in a raid each get their own welcome; a single user looping through join/leave/join is throttled.
- Welcome posted to wrong channel — verify the snowflake ID
in
[welcome].channelis for the channel you actually meant. No mention syntax; just the raw ID as a string.
Cross-references
- Instance Config:
[join_role]and[welcome]— schema references. - AI Chat — how the AI provider stack works; welcomes reuse it.
- Personality Files — where
personality.txtandwelcome_prompt.txtare sourced. - Auto-Role — for the post-join “graduate the member” promotion flow.
- Secrets Management —
where to put
DEEPSEEK_API_KEYandGEMINI_API_KEYsafely.
Minecraft: Verify
Link Discord accounts to Minecraft accounts. The user generates a
verification code on the Minecraft server, types it into Discord with
!m verify, and the bot calls the Minecraft server’s HTTP API to
confirm the link.
This is the foundational sub-feature of the Minecraft module. The donator sync and chargeback features both rely on the Discord ↔ Minecraft mapping that verify produces.
What it does
A typical flow:
- A player joins your Minecraft server and runs an in-game
/verifycommand. The companion plugin generates a short alphanumeric code, stores it server-side with a TTL, and tells the player to type!m verify <code>in Discord. - The player runs
!m verify <code>in any Discord channel where the bot can read messages. - The bot POSTs the code plus the player’s Discord ID to the
Minecraft server’s
/api/verifyendpoint. - The Minecraft server validates the code, links the Discord ID to the player’s Mojang UUID, and responds with success + username.
- The bot replies in Discord with
Verified! Your Discord account is now linked to **<username>** in Minecraft.
Once linked, that mapping powers everything else the Minecraft module does — donator role sync, chargeback alerts, and any future features that need to know who-is-who across the two platforms.
Activation
Verify is part of the Minecraft module. Three things have to line up
in config.toml:
[features]
minecraft = true
[minecraft]
verify = true # default; can be omitted
[minecraft.donator_sync_config]
# unrelated, but [minecraft] needs to exist
And two environment variables in .env:
MC_VERIFY_URL=https://your-mc-server.example.com
MC_VERIFY_SECRET=long-random-string-shared-with-the-mc-plugin
MC_VERIFY_URL is the base URL of the Minecraft companion plugin’s
HTTP server (no trailing slash needed; the bot strips it). The bot
appends /api/verify for the verify call.
MC_VERIFY_SECRET is a shared bearer token. The bot sends it as
Authorization: Bearer <secret>; the plugin must compare against
the same value. Treat it like a password — anyone with this secret
can issue verify (and ban) calls against the MC server.
verify defaults to true inside the [minecraft] section, so
enabling the Minecraft module is enough to enable verify. Set
verify = false only if you want donator sync or chargeback
without exposing the verify command.
The !m verify command is registered conditionally at startup —
if features.minecraft is false or minecraft.verify is
false, the command isn’t added to the parent command tree at
all. Users typing !m verify see the standard “command not
found” response.
Commands
| Command | Description |
|---|---|
!m verify <code> | Submit a verification code from the Minecraft server. The code is uppercased before being sent (the MC plugin’s codes are case-insensitive). The argument is #[rest], so trailing whitespace is fine. |
If the user runs !m verify with no code, the bot replies:
Usage:
!m verify <code>— get your code by running/verifyin Minecraft.
If MC_VERIFY_URL or MC_VERIFY_SECRET is missing from the
environment, the command refuses with:
MC verification is not configured. Set
MC_VERIFY_URLin .env
(or the same about MC_VERIFY_SECRET).
How it works
src/minecraft/api.rs::verify is a thin POST against
<MC_VERIFY_URL>/api/verify with the body:
{
"code": "ABC123",
"discord_id": "987654321098765432"
}
and the Authorization: Bearer <MC_VERIFY_SECRET> header. The
Minecraft plugin is expected to respond with JSON of the shape:
{ "success": true, "username": "Steve", "uuid": "069a79f4-..." }
or, on failure:
{ "success": false, "error": "Invalid or expired code" }
The bot turns the success path into a Discord confirmation message
including the linked Minecraft username. On success: false it
echoes the error string back to the user, so the plugin’s error
copy (“expired”, “code already used”, etc.) reaches the player
unchanged. On HTTP/transport failure the bot replies
Could not reach the MC server: <error> so the user knows the
linkage didn’t happen because of infrastructure rather than a bad
code.
The bot does not store the linkage itself. The Minecraft side is the source of truth. Other Minecraft sub-features (donator sync, chargeback) re-fetch the mapping from the same plugin when they need it.
The Minecraft side
This module only describes the Discord side of the integration.
The companion plugin on the Minecraft server is its own thing —
ours implements /api/verify, /api/donators, and /api/ban
endpoints, plus a chargeback webhook that POSTs to the bot. If
you’re rolling your own integration, the contract is:
POST /api/verify— accepts{code, discord_id}, returns{success, username, uuid, error}. Auth via bearer token.- The plugin is responsible for issuing codes, validating them, enforcing TTLs, and persisting Discord ↔ UUID mappings.
For the source-of-truth wire format, see
src/minecraft/api.rs in the bot codebase.
Permissions
The bot needs no special Discord permissions for verify itself —
just the standard SEND_MESSAGES to reply in the channel where
the command was invoked. There’s no role assignment step on
successful verification; that’s left to the plugin (which can
trigger donator sync separately) or to a server admin.
Common issues
- “MC verification is not configured” —
MC_VERIFY_URLorMC_VERIFY_SECRETis missing from the running environment. Make sure your.envis being passed in (Docker users:env_file: .envincompose.yaml). - “Could not reach the MC server” — the bot can’t contact the
URL. Check that
MC_VERIFY_URLis reachable from the bot’s network (trycurlfrom inside the bot container), and that the plugin’s HTTP listener is up. - “Verification failed: invalid or expired code” — the Minecraft plugin rejected the code. The most common causes are typos, codes that have already been used, or codes that TTL’d out (most plugins expire codes after a few minutes).
401 Unauthorizedin bot logs —MC_VERIFY_SECRETdoesn’t match the value the plugin expects. Make sure both sides have the exact same string (no trailing whitespace).!m verifysays “command not found” —features.minecraftorminecraft.verifyisfalse, or the bot wasn’t restarted after enabling them.- The verification succeeded, but donator roles still aren’t syncing — verify only creates the mapping. Donator role assignment is a separate sub-feature; see Minecraft: Donator Sync.
Cross-references
- Minecraft: Donator Sync — uses the Discord ↔ UUID mapping that verify produces.
- Minecraft: Chargeback Alerts — reaches out from the MC server to Discord using the same mapping.
- Instance Config:
[minecraft]— schema reference. - Environment Variables —
MC_VERIFY_URL,MC_VERIFY_SECRET. - Secrets Management — how to keep the shared secret out of source control.
Minecraft: Donator Sync
Periodically poll a Minecraft server’s donator list and reconcile two
Discord roles — supporter_role and premium_role — so that the
right people have the right perks at any moment, without anyone
having to manually grant or revoke them.
The companion to this feature is the chargeback flow, which handles the reverse case: someone files a chargeback, their Discord roles get stripped, and a staff alert is posted.
What it does
The donator sync background task wakes up every check_interval
seconds, calls the Minecraft companion plugin’s /api/donators
endpoint, and reconciles the response with the current state of the
guild. After each tick:
- Anyone who’s a supporter on the MC server but doesn’t have the supporter role gets it.
- Anyone who’s a premium donator but doesn’t have the premium role gets it.
- Anyone who has either role on Discord but isn’t on the MC list loses it (their donation expired or was revoked).
- Tier upgrades (supporter → premium) and downgrades (premium → supporter) are handled correctly: the old role is removed when the new one is added.
- Anyone with the chargeback restricted role is skipped entirely — sync never re-grants donor perks to a user being held by a chargeback alert.
The sync is bidirectional state reconciliation, not an event stream. There’s no “donator added” notification; the bot just queries the source of truth and patches the difference.
Activation
Donator sync is part of the Minecraft module. You need:
[features]
minecraft = true
[minecraft]
donator_sync = true
[minecraft.donator_sync_config]
supporter_role = "123456789012345678"
premium_role = "234567890123456789"
check_interval = 300 # seconds; default 300 (5 min)
Plus, in .env:
MC_VERIFY_URL=https://your-mc-server.example.com
MC_VERIFY_SECRET=long-random-string-shared-with-the-mc-plugin
(The same MC_VERIFY_* pair as verify and
chargeback. One pair, three sub-features.)
Restart the bot. The startup log will show:
Donator sync enabled (supporter=…, premium=…, interval=300s)
Donator sync checker started (300s interval).
The first sync runs about 15 seconds after bot boot, then every
check_interval seconds after that.
If donator_sync = true but the [minecraft.donator_sync_config]
section is missing, or MC_VERIFY_URL/MC_VERIFY_SECRET aren’t
set, the bot logs a warning and the sync task never spawns.
Configuration
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
supporter_role | string (snowflake) | yes | — | Role ID granted to users with the supporter tier. |
premium_role | string (snowflake) | yes | — | Role ID granted to users with the premium tier. |
check_interval | integer | no | 300 | Seconds between syncs. Lower = fresher; higher = less load. |
Both role IDs must be valid snowflakes (numeric strings). Invalid IDs cause the entire sync to abort with a logged warning rather than partially executing.
The default check_interval of 5 minutes is conservative. For most
deployments that’s fine — donations don’t change often, and the
five-minute lag is invisible to users. Push it lower (e.g. 60)
only if you have a use case that needs faster sync.
How it works
src/minecraft/donator_sync.rs::sync_roles runs in five logical
phases per tick:
-
Fetch. Call
<MC_VERIFY_URL>/api/donatorswith the shared secret. Response shape:{ "donators": [ { "discord_id": "987654321098765432", "tier": "supporter" }, { "discord_id": "234567890123456789", "tier": "premium" } ] } -
Build target sets. Walk the response and produce two sets:
should_have_supporterandshould_have_premium. Unknown tiers (anything that isn’tsupporterorpremium) are logged as warnings and ignored. -
Snapshot current state. Call Discord’s
get_guild_members(paginated, 1000 per page) and build three sets:current_supporters,current_premium, andrestricted_users(anyone with the chargeback-restricted role, if chargeback is also configured). -
Add missing roles. For each user who should have a tier but doesn’t, call
add_member_rolewith the audit-log reason`Donator sync: {tier} tier`. Restricted users are skipped. -
Remove stale roles, then handle tier transitions. Anyone who has a tier role but isn’t on the MC list loses the role (“Donator sync: {tier} expired”). Anyone whose tier moved gets the old role removed (“upgraded to premium” or “downgraded to supporter”).
The sync is idempotent — running it twice in a row does no extra work the second time, since the desired and current states already match.
The restricted-role short-circuit
If the chargeback feature is configured, donator sync also reads
chargeback_config.restricted_role and treats it as a “do not
touch” set. Any member with that role is skipped during all five
phases. The reasoning: a user under a chargeback hold should not
have donor perks restored automatically just because the MC
server hasn’t yet pulled them off its donator list.
If chargeback isn’t configured, no restriction set is built and this short-circuit doesn’t apply.
Pagination
Guilds with many members are paginated 1,000 at a time. The loop breaks once a page returns fewer than 1,000 entries (the standard sentinel for “end of list”). For most servers this is a single page; for very large guilds it’s a handful of API calls per tick.
Permissions
The bot’s role must:
- Be higher than both
supporter_roleandpremium_rolein the role hierarchy. - Have
MANAGE_ROLES.
Without those, individual add_member_role / remove_member_role
calls fail with permission errors and get logged at warn. The
sync continues to the next user; one bad permission on one role
won’t break the whole loop.
Common issues
- No sync log line at startup —
donator_sync = truebut either the config sub-section orMC_VERIFY_*is missing. Check the warning log lines around startup. - Sync runs but no roles change — the MC server’s
/api/donatorsreturned an empty or malformed response. Check the bot’s logs forMC server returned status …orInvalid donator response. Trycurl -H "Authorization: Bearer $MC_VERIFY_SECRET" $MC_VERIFY_URL/api/donatorsfrom the bot host. - Roles get added then removed seconds later — the MC server is returning inconsistent data between calls (e.g. an unstable cache). Pin down whether the source data is stable; the sync is idempotent on stable input.
- Restricted-role users are losing their donor role — that’s the intended behaviour during a chargeback hold. Roles are not removed for them — they’re just not added by sync. To restore them, lift the chargeback restriction first.
Invalid supporter_role IDwarning — one of the snowflake fields isn’t a numeric string. Snowflakes are quoted strings in TOML; make sure you havesupporter_role = "123…"and notsupporter_role = 123…(TOML integers can’t represent Discord snowflakes accurately).- Sync feels slow —
check_intervalis 5 minutes by default. Lower it if you need faster reconciliation, but bear in mind every tick fetches the full member list from Discord and the donator list from MC. - Tier change took effect on MC but Discord lags — bounded by
the next
check_intervaltick. There’s no event hook for faster propagation.
Cross-references
- Minecraft: Verify — required precursor for any Discord ↔ MC mapping.
- Minecraft: Chargeback Alerts — the inverse flow; donator sync reads the restricted role this feature applies.
- Instance Config:
[minecraft.donator_sync_config]— schema reference. - Environment Variables —
MC_VERIFY_URL,MC_VERIFY_SECRET.
Minecraft: Chargeback Alerts
When a player files a chargeback against a purchase on your Minecraft server, the bot reacts immediately: it strips the player’s Discord roles, applies a “restricted” role, and posts a staff alert with Ban and Dismiss buttons. Staff can ban (Discord + Minecraft) or dismiss with a click.
This closes the loop on the donor flow: verify creates the Discord ↔ Minecraft mapping, donator sync keeps roles in line with active donations, and chargeback alerts handle the case where a donation gets reversed.
What it does
The MC store (Tebex, Buycraft, or whatever you use) is wired up to
POST a chargeback notification to the bot’s HTTP listener at
/webhook/chargeback. On receipt:
- Authenticates the request via
Authorization: Bearer <MC_VERIFY_SECRET>. Mismatched or missing tokens get a401 Unauthorizedand no further processing. - Strips and restricts. If the chargeback payload includes a
linked
discord_id, the bot callsedit_memberon Discord with a fresh roles list of just[restricted_role]— wiping every other role the user had — and an audit-log reasonChargeback: roles stripped, user restricted. - Posts a staff alert to the configured staff channel, showing the player’s MC username, tier, UUID, the linked Discord account (if any), and a timestamp. Two buttons sit at the bottom: 🔨 Ban and ❌ Dismiss.
- Returns 200 to the MC store so the chargeback notification isn’t retried.
The Ban button issues a Discord ban (if the user is linked) and
a Minecraft ban (via <MC_VERIFY_URL>/api/ban). Dismiss closes
the alert without further action. Either way the embed is
rewritten with a footer recording who took the action (“Banned
by alice” or “Dismissed by bob”) and the buttons are removed so
the alert can’t be acted on twice.
Activation
Chargeback alerts are part of the Minecraft module. You need:
[features]
minecraft = true
[minecraft]
chargeback = true
[minecraft.chargeback_config]
staff_channel = "123456789012345678"
restricted_role = "234567890123456789"
staff_roles = ["345678901234567890", "456789012345678901", "567890123456789012"]
Plus the standard MC env vars in .env:
MC_VERIFY_URL=https://your-mc-server.example.com
MC_VERIFY_SECRET=long-random-string-shared-with-the-mc-plugin
The chargeback listener is mounted onto the bot’s existing
HTTP server (the one the MCP server uses) at
POST /webhook/chargeback. There’s no separate port; the bot’s
HTTP listener handles both. See MCP Server for
how to expose the HTTP listener publicly.
The webhook router only spins up if all three preconditions hold:
the chargeback feature is enabled, the config sub-section is
present, and MC_VERIFY_URL + MC_VERIFY_SECRET are both set.
Missing any one of those quietly disables the route — the bot
starts cleanly, but no chargeback alerts will ever fire.
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
staff_channel | string (snowflake) | yes | Channel ID where alerts are posted. |
restricted_role | string (snowflake) | yes | Role ID applied to the offender (and used as the only remaining role on their account after roles are stripped). |
staff_roles | list of string (snowflakes) | no | Roles allowed to press the Ban/Dismiss buttons on a chargeback alert. Defaults to an empty list, meaning no one is allowed (the buttons will deny every interaction with You don't have permission to do this.), so configure this if you want staff to be able to confirm or dismiss chargebacks. |
staff_channel and restricted_role must be valid snowflakes.
Invalid values cause the webhook to return 500 Internal Server Error and log the bad config; the underlying chargeback isn’t
lost on the MC side, but no alert gets posted on the Discord side
until you fix the config. Entries in staff_roles that don’t
parse as integers are silently skipped, so a typo will quietly
remove that role from the allowlist — double-check the IDs if
buttons aren’t working for staff who you expect to have access.
The restricted role’s purpose is twofold:
- It marks the user as “currently in a chargeback hold” so donator sync doesn’t re-grant their donor perks on the next tick.
- Combined with channel-level permissions on your server, it isolates the user from sensitive channels until staff resolves the alert.
Set the role’s permissions and channel overrides to whatever “banned but not yet acted on” means for your server.
The webhook payload
The MC plugin POSTs JSON in this shape:
{
"uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
"username": "Steve",
"discord_id": "987654321098765432",
"tier": "supporter",
"timestamp": "2026-04-15T18:00:00Z"
}
discord_id is optional — if the player never verified, it’s
null and the bot only takes MC-side action via the staff button.
The wire format lives in
src/minecraft/chargeback.rs::ChargebackPayload. Authentication
is the shared MC_VERIFY_SECRET sent as Authorization: Bearer <secret>; treat the secret like a credential.
The staff alert
The alert embed has a red border and the title
⚠️ CHARGEBACK ALERT, with fields for Player, Tier,
Discord (<@id> (id) or Not linked), MC UUID, and
Time. The footer summarizes the automatic action: All roles stripped. User restricted. (linked) or No Discord account linked. MC-side actions only. (unlinked).
Two buttons sit beneath: 🔨 Ban (or Ban MC if unlinked)
and ❌ Dismiss. Only members with one of the roles listed in
staff_roles (see Staff role gating below) can press either —
anyone else gets You don't have permission to do this.
After a button is pressed the embed is rebuilt with a neutral
border, the footer is replaced with Banned by <staff> or
Dismissed by <staff>, and the buttons are removed.
Ban action
When a staff member clicks Ban:
- Discord side. If the embed shows a linked
discord_id, the bot extracts it and callsban_userwith audit-log reasonChargeback ban by <staff>. Failures are logged but don’t block MC side. - MC side. The bot POSTs to
<MC_VERIFY_URL>/api/banwith the player’s UUID and a reason identifying the staff member. - Failure surfacing. If the MC ban returns non-2xx or
transport-fails, the bot reposts the failure into the staff
channel:
⚠️ MC ban failed for UUID {uuid}: {status} {body}.
Dismiss action
Clicking Dismiss doesn’t restore anything — the roles are already stripped and the restricted role applied. It just closes the alert with a footer recording who dismissed it. To restore a user, staff manually removes the restricted role and re-adds whatever roles they had before; stripped roles aren’t preserved.
Staff role gating
Button permission checks read the staff_roles list from
[minecraft.chargeback_config]. A member must hold at least one
of the listed roles to press either Ban or Dismiss; everyone
else gets You don't have permission to do this. as an ephemeral
reply.
The list is #[serde(default)], so omitting staff_roles
entirely (or leaving it as []) means no one can use the
buttons — the alert still posts and the auto-strip still
happens, but the alert can’t be confirmed or dismissed from
Discord. This is the safe default for a feature that’s opt-in
per instance: you must explicitly grant button access.
Add as many or as few roles as you want — typically Moderator, Admin, and Owner, but anything you wire up works:
staff_roles = ["111111111111111111", "222222222222222222", "333333333333333333"]
Entries that don’t parse as u64 are silently dropped at
button-handler time, so a malformed snowflake will quietly
remove that role from the allowlist without preventing the
others from working.
Permissions
The bot’s role must:
- Be higher than the restricted role and any roles it might need to strip on offenders, in the role hierarchy.
- Have
MANAGE_ROLES(to apply the restricted role and strip roles) andBAN_MEMBERS(for the Ban button). - Have
SEND_MESSAGESandEMBED_LINKSin the configuredstaff_channel.
Without BAN_MEMBERS the auto-strip will succeed but the Ban
button will fail; the alert will still post and dismiss.
Common issues
- No alert appears after a chargeback — check the bot logs
for
Chargeback webhook received: …. If absent, the MC plugin isn’t POSTing to the right URL or the HTTP listener isn’t exposed. If present but no embed posts, checkstaff_channeland bot permissions there. 401 Unauthorizedon the webhook —MC_VERIFY_SECRETdoesn’t match between bot and plugin.- Roles aren’t stripped on the offender — only happens when
the payload includes a linked
discord_id. Unverified users get no Discord-side action; the alert footer says so. - Ban button does nothing for non-staff — staff role gate.
Add the staff member’s role to
staff_rolesin[minecraft.chargeback_config]and restart the bot. Ifstaff_rolesis empty (the default), no one can press the buttons. - MC ban failed — the
/api/banendpoint returned an error. The bot reposts the failure into the staff channel; check the response body. - Dismiss didn’t restore the user’s roles — by design. Restoring stripped roles is a manual operation.
Cross-references
- Minecraft: Verify — for the Discord ↔ UUID mapping that determines whether chargeback can act Discord-side.
- Minecraft: Donator Sync — uses the restricted role as a “do not re-grant donor perks” signal.
- MCP Server — how the bot’s HTTP listener is hosted; the chargeback webhook lives on the same router.
- Instance Config:
[minecraft.chargeback_config]— schema reference. - Environment Variables —
MC_VERIFY_URL,MC_VERIFY_SECRET.
MCP Server
The bot embeds a Model Context Protocol (MCP) server that exposes Discord server-management as 51 tools any MCP client can call. The intended workflow is to point an AI coding assistant — Claude Code, Cursor, etc. — at the bot and let it manage your Discord server programmatically.
What it does
When the bot starts, it spins up an HTTP server on a configurable port
(default 9090) and registers the tool catalog with the standard MCP
streamable-HTTP transport. An MCP client connects, lists the available
tools, and can then invoke them. Every tool runs against the same
Discord HTTP client the bot itself uses, so the actions execute as the
bot user with the bot’s permissions.
The point of this is that you can talk to your Discord server in natural language from inside an AI assistant:
“Find the channel category called Archive and move all read-only channels into it.”
“List every member with the @Verified role who hasn’t sent a message in 30 days.”
“Create a new role ‘Beta Testers’, colour green, no permissions, and assign it to these five users.”
Instead of writing Discord API code or clicking through the Discord UI, your AI assistant calls the matching MCP tools.
Why this exists
Discord administration is full of repetitive multi-step operations: auditing roles, cleaning up old channels, mass-renaming, bulk permission changes. The Discord client doesn’t have a scripting interface, and writing one-off API scripts for every cleanup task is tedious. An LLM with the right tools can plan the operation, ask for confirmation, and execute it in a couple of turns.
The bot is also a natural place to put this server: it is already a long-running process holding the Discord HTTP client, with the right intents and a privileged token. Adding an MCP endpoint costs almost nothing in startup time and gives you a remote-control interface for free.
The protocol
Model Context Protocol is an open JSON-RPC-based protocol from Anthropic that lets clients (LLMs and IDE extensions) discover and call tools exposed by servers. The bot uses the Streamable HTTP transport, which is a simple HTTP-and-SSE flavour of MCP (no stdio handshakes, no daemon-spawning).
The server is built on the rmcp
Rust SDK. Tool definitions are written as decorated async fns on a
single DiscordTools struct in src/mcp/tools.rs; the
#[tool(description = "...")] attribute generates the JSON schema
from each function’s parameter type.
Connecting Claude Code
The simplest way to test the server is to add it to your local Claude
Code config (~/.claude.json):
{
"mcpServers": {
"discord": {
"type": "http",
"url": "http://localhost:9090/mcp"
}
}
}
Then restart Claude Code. The next time you start a session, the
discord server should appear in your tool list and you can call any
of the 51 tools by name.
If you’ve set MCP_AUTH_TOKEN (see below), add the bearer token to
the same entry:
{
"mcpServers": {
"discord": {
"type": "http",
"url": "http://localhost:9090/mcp",
"headers": {
"Authorization": "Bearer your-token-here"
}
}
}
}
Other MCP-capable clients (Cursor, Continue, custom code) follow the
same pattern: point them at http://<host>:<port>/mcp over HTTP and
optionally pass a bearer token.
Tool catalog
The 51 tools are grouped into five categories. The full reference, including parameter schemas, lives in Reference: MCP Tool Catalog. The table below is the one-line summary.
Guilds
| Tool | What it does |
|---|---|
list_guilds | List every Discord server the bot is a member of, with names and IDs. |
Server
| Tool | What it does |
|---|---|
get_guild_info | Name, owner, approximate member count, channel/role counts. |
send_message | Post a message to a channel. Privileged. |
delete_messages | Bulk-delete the most recent N messages from a channel (1–100). |
get_recent_messages | Read recent messages from a channel, newest first; supports pagination via before. |
search_messages | Search a channel by author, content substring, and time range (ISO date or snowflake). Filters compose. |
add_reaction | Add a reaction (unicode or custom emoji) to a specific message. |
remove_reaction | Remove the bot’s own reaction from a specific message. |
Channels
| Tool | What it does |
|---|---|
list_channels | All channels in the server with IDs, types, and positions. |
create_channel | Create a text, voice, category, forum, or stage channel. |
delete_channel | Delete a channel. |
edit_channel | Edit name, topic, NSFW flag, slowmode, parent category. |
move_channel | Move a channel to a new position or category. |
set_channel_permissions | Apply permission overrides for a role or member. |
create_voice_channel | Create a voice channel with optional bitrate / user_limit. |
create_stage_channel | Create a stage channel (speaker/audience-separated voice). |
edit_voice_channel | Edit voice-specific channel fields (bitrate, user_limit, region). |
Roles
| Tool | What it does |
|---|---|
list_roles | All roles with IDs, colours, positions, permissions. |
create_role | Create a new role. |
delete_role | Delete a role. |
edit_role | Edit name, colour, permissions, hoist, mentionable. |
Members
| Tool | What it does |
|---|---|
list_members | List server members (max 1000 per call, paginate with after). |
get_member | Detailed info about one member. |
assign_role | Add a role to a member. |
remove_role | Remove a role from a member. |
ban_member | Ban a user, optionally with a reason and message-history purge. |
unban_member | Unban a user. |
kick_member | Kick a member. |
timeout_member | Time out a member for a duration like 1h, 30m, 7d. |
remove_timeout | Lift an active timeout (inverse of timeout_member). |
set_nickname | Set or clear a member’s server nickname (1–32 chars). |
get_bans | List active bans with id/name/reason; paginate with after. |
move_voice_member | Move a member to a different voice channel. |
disconnect_voice_member | Disconnect a member from voice. |
modify_voice_state | Server-mute / server-deafen a member when in voice. |
Direct Messages
| Tool | What it does |
|---|---|
send_private_message | DM a user. Opens the DM channel automatically. Privileged. |
read_private_messages | Read recent DMs between the bot and a user, newest first. |
edit_private_message | Edit one of the bot’s previously-sent DMs. |
delete_private_message | Delete one of the bot’s previously-sent DMs. |
Webhooks
| Tool | What it does |
|---|---|
list_webhooks | List webhooks on a channel (id, name, token). |
create_webhook | Create a webhook on a channel; returns id + token. |
delete_webhook | Delete a webhook by ID. |
send_webhook_message | Send through a webhook with optional username/avatar overrides. Privileged. |
Invites
| Tool | What it does |
|---|---|
list_invites | List active server invites (code, channel, inviter, uses). |
create_invite | Create a new invite with optional max_age, max_uses, temporary, unique. |
delete_invite | Delete an invite by code. |
get_invite_details | Look up an invite (no need for bot to be in the target guild). |
Custom Emoji
| Tool | What it does |
|---|---|
list_emojis | List custom emoji in the server. |
create_emoji | Create a custom emoji from an HTTPS image URL (bot fetches + base64). |
edit_emoji | Rename a custom emoji. |
delete_emoji | Delete a custom emoji. |
That’s 51 tools total: 1 + 7 + 9 + 4 + 14 + 4 + 4 + 4 + 4.
Multi-guild support
Almost every tool takes an optional guild_id parameter. When it’s
omitted, the tool acts against the bot’s configured guild — the one
named in the GUILD_ID env var. When it’s supplied, the tool acts
against that guild instead, as long as the bot is a member of it.
This means you can run a single bot across several Discord servers and
manage them all through one MCP endpoint. The list_guilds tool is
deliberately the one that does not take a guild_id parameter — use
it to discover what’s available, then pass the right ID into the
follow-up calls.
Port and binding
Two environment variables control the listen address (loaded in
src/config.rs):
| Variable | Default | Notes |
|---|---|---|
MCP_PORT | 9090 | TCP port the server listens on. |
MCP_BIND_ADDR | 127.0.0.1 | Interface to bind to. The default is localhost-only, deliberately. |
The defaults are chosen so that a fresh install is not exposing
anything to the public internet. To make the server reachable from
other machines on a private network, set MCP_BIND_ADDR=0.0.0.0 and
arrange your own network ACLs. To expose it over the public internet
at all, see the security section below first.
Authentication
A third environment variable controls authentication:
| Variable | Default | Notes |
|---|---|---|
MCP_AUTH_TOKEN | empty (none) | Bearer token. Required unless MCP_BIND_ADDR is a loopback interface (127.0.0.1, ::1). |
The middleware in src/mcp/mod.rs is a single from_fn layer:
- If
MCP_AUTH_TOKENis empty, every request is allowed through. - If it is set, every request must carry a matching
Authorization: Bearer <token>header, otherwise it is rejected with401 Unauthorized. - The bearer-token comparison is constant-time (via
subtle::ConstantTimeEq) so a network attacker can’t use response timing to probe the token byte-by-byte.
Strict startup guard
The bot refuses to start if MCP_AUTH_TOKEN is empty and
MCP_BIND_ADDR is not a loopback address. This replaces the older
“soft warning” behaviour — the misconfiguration that used to expose
an unauthenticated MCP endpoint on the public internet now fails the
boot instead, with a pointed error explaining how to fix it. Either
set a token or bind to loopback; there is no third option.
The mcp-gateway applies the same rule even more strictly: because it
always listens on 0.0.0.0, it refuses to start without
MCP_AUTH_TOKEN set, no loopback escape hatch available. The
gateway also forwards that same token on every outgoing request to
its backends — one shared secret covers both the inbound check and
the outbound forward — so operators configure a single value and
the bundled docker-compose deploy (where backends bind 0.0.0.0:9090
and therefore require a token themselves) works without extra
plumbing.
Request size limit
Every incoming request body is capped at 64 KiB. Oversize bodies
are rejected with 413 Payload Too Large. Bodies that fit but aren’t
valid JSON-RPC come back with a standards-conformant
{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}
response rather than a generic 400.
The MCP gateway
If you run multiple bot instances out of the same host (production and staging, or two community bots, etc.), each one binds its own port and exposes its own catalog. Pointing Claude Code at all of them individually means maintaining a list of URLs and switching between them.
The repository ships a separate mcp-gateway service that solves
this. It listens on a single port, multiplexes requests to whichever
bot’s MCP endpoint they belong to, and presents a unified tool list
to clients. Each tool’s schema gains an extra instance parameter
(matching a key in the INSTANCES env var, e.g. bot_a or bot_b)
which the gateway uses to pick the target bot. A synthetic
list_instances tool is appended to the catalog so clients can
discover the available instances and their guilds.
The gateway is the recommended entry point in production — see Architecture: MCP Gateway Routing for how it routes and authenticates requests.
Security considerations
The MCP tools are powerful. ban_member permanently removes a user;
delete_channel is destructive and unrecoverable; send_message lets
the bot post anywhere it has Send Messages permission. There is no
in-bot confirmation gate on MCP calls — a misfiring AI agent or a
compromised client could do real damage in seconds.
This means the threat model is the MCP endpoint itself is privileged infrastructure. Treat it the same way you’d treat a bare-metal SSH session into your Discord server.
Concrete recommendations:
MCP_AUTH_TOKENis required unless bound to loopback. The bot enforces this at startup: ifMCP_BIND_ADDRis anything other than a loopback address and the token is unset, the process refuses to boot. Generate the token withopenssl rand -hex 32or similar.- Default to
127.0.0.1. The defaultMCP_BIND_ADDRis localhost for a reason. Do not change it unless you’re putting something meaningful in front of it. - Do not put the port on the public internet. Even with a bearer token, you’re one token leak away from a takeover. Use a WireGuard / Tailscale / SSH tunnel / VPN to reach it remotely.
- Audit the bot’s Discord permissions. The MCP server can only do what the bot itself can do. If you grant the bot Administrator, you’ve granted Administrator to anyone holding the MCP token.
- See Deployment: MCP Exposure for the long-form discussion and example reverse-proxy configurations.
Known limitations
- OAuth 2.1 is not implemented. The MCP spec defines an OAuth-based flow that some clients prefer (Claude Code’s HTTP transport, for instance, expects it). The bot speaks bearer-token auth only, so you’ll see warnings in those clients about the auth mode being unrecognised — they still work, but the OAuth handshake never happens.
- Rate limiting is Discord’s, not ours. The tools call the
Discord HTTP API directly with no extra throttling. Bulk operations
on members, roles, or messages are bounded by Discord’s rate
limits, and a too-eager AI agent will trigger 429 responses you’ll
see surfaced as
Discord API error: ...in the tool output. - Per-call API timeout is 10 seconds.
API_TIMEOUTinsrc/mcp/tools.rs. Long-running Discord calls (e.g. fetching a large guild’s full member list) may time out; in that case, page through with smallerlimitvalues. - Discord permission errors are surfaced as plain strings, not as
structured error codes. If a tool says
Missing Permissions, the bot itself doesn’t have the permission needed for the operation. - The
timeout_membertool’sreasonargument is currently cosmetic — it’s part of the schema for forward compatibility, but the underlying call doesn’t yet thread it through to Discord’s audit log.
Example use cases
- Mass channel cleanup. “List every channel in the
#archive-2022category that hasn’t had a message this year and delete them.” The AI callslist_channels, filters mentally, and invokesdelete_channelfor each one. - Role audits. “List every member with the
@Donorrole and cross-check against the active payment list.” Pairlist_memberswith whatever data source you point the AI at. - Permissions sweep. “Make sure every channel under the
#mods-onlycategory has the@Moderatorrole allowed and@everyonedenied.”set_channel_permissionsper channel. - Bulk message cleanup. “Delete the last 50 messages in
#spam.” One call todelete_messages. - Server bootstrapping. “Create a category ‘Beta’, three text
channels under it, and a role with permissions limited to that
category.” Several
create_channelandcreate_rolecalls in sequence.
The general pattern is list-then-act: ask the AI to discover what’s there, then have it execute the change with a clear plan you can approve.
Cross-references
- Architecture: MCP Gateway Routing — how the gateway multiplexes multiple bot instances.
- Deployment: MCP Exposure — safe ways to make the server reachable from outside the host.
- Reference: MCP Tool Catalog — the full per-tool schema reference, including every parameter and default.
- Adding an MCP Tool — how to extend the catalog with new tools.
- Environment Variables —
reference for
MCP_PORT,MCP_BIND_ADDR, andMCP_AUTH_TOKEN.
Architecture Overview
discord-bot-rs is built from small, well-isolated modules communicating
through a central shared Data
struct and a handful of Discord event handlers. The process is a single
Tokio runtime that hosts a Discord client, a Postgres connection pool, and
an embedded MCP server, plus whichever feature modules you’ve turned on in
config.toml. Everything else — music players, game state, rate limiters,
the AI pipeline — hangs off Data and is accessed async-safely through
per-guild or per-channel maps. This page sketches the high-level shape;
the rest of the architecture section drills into each piece.
Components
graph TB
subgraph "Bot Process"
Gateway[Discord Gateway<br/>serenity shard]
Handler[Event + Command Handler<br/>poise]
Commands[Commands<br/>src/commands/]
AI[AI Pipeline<br/>src/ai/]
Music[Music Player<br/>src/music/]
Games[Games<br/>wordle, connections, stocks]
DB[(PostgreSQL<br/>sqlx pool)]
MCP[MCP Server<br/>src/mcp + axum]
end
Discord[Discord API] <--> Gateway
Gateway --> Handler
Handler --> Commands
Handler --> AI
Handler --> Music
Commands --> DB
AI --> DB
Games --> DB
Handler --> Games
Claude[MCP client<br/>e.g. Claude Code] --> MCP
MCP --> Handler
One Tokio process hosts a serenity shard (the WebSocket to Discord’s
gateway), a poise dispatcher wrapped around it, and an axum-based MCP
server bound on a local port. Gateway events flow into the event handler,
which either fires a command (via poise) or routes the event directly to a
feature module — the AI pipeline for @mentions and replies, the music
player for voice state updates, the games module for interaction buttons.
Every module talks to the Postgres pool through sqlx. The MCP server is
a separate ingress point: it exposes tools like list_guilds and
send_message to outside clients, and its handlers reach into the same
shared state the event handler uses.
The Data struct
Poise gives every handler a typed reference to a user-defined state
struct. In this project that struct is
Data,
defined at the top of src/main.rs. It holds:
db: sqlx::PgPool— the shared Postgres connection pool.http_client: reqwest::Client— a preconfigured HTTP client for yt-dlp, DeepSeek, Gemini, Finnhub, DuckDuckGo scraping, and any other outbound HTTP work a feature needs.config: Config— loaded from environment variables at startup.personality: Stringandbot_name: String— per-instance identity.- Optional feature configs —
auto_role_config,minecraft_config,join_role_config,welcome_config, etc. Each isOption<T>so that disabled features simply holdNone. - Per-guild state maps:
guild_players,track_handles,now_playing_msgs,idle_timers,connections_games,wordle_games. All areArc<DashMap<key, value>>, giving lock-free guild lookup. Most values are wrapped inArc<Mutex<T>>for serialised access inside one guild;track_handlesis the exception — it storesTrackHandledirectly because songbird’s handle is already cheap to clone and internally synchronised. rate_limiters: RateLimiters— sliding-window limiters for AI, music, moderation, stock tools, and the welcome/join flow, keyed by user ID. All five are enforced; a periodic cleanup task inmain.rsevicts empty buckets every 5 minutes. See Concurrency Model.mcp_started: AtomicBoolandstarted_at: DateTime<Utc>— one-shot flags used to guard the MCP server against gateway reconnects and to let the AI history builder ignore messages from previous bot lifetimes.
A single Arc<Data> is cloned into every command context, event handler
future, and background task. Cheap to clone, shared everywhere, no global
state — see Concurrency Model for why this shape
works without contention.
Per-instance model
Each running bot is a separate Linux process with its own Data, its own
Discord token, its own Postgres schema, and its own instance config
directory. “Multi-tenant” here means “run two containers with two
.env files,” not “one process with guild-scoped data.” The
Multi-Instance Model page explains the
boundaries and why the project chose a schema-per-instance approach over
the alternatives.
How events flow
Discord pushes an event down the gateway. serenity parses it into a
typed variant and hands it to poise’s event dispatcher, which either
matches a prefix command (the !m family) and runs the command handler,
or calls the plain event_handler
for everything else. The event handler is one big match over
FullEvent variants — ready, message create, voice state update,
interaction create, member add — and each arm dispatches to the
corresponding feature module. Responses go back to Discord via serenity’s
HTTP client. The full lifecycle is in Data Flow.
Major modules
| Module | Responsibility |
|---|---|
src/main.rs | Entry point, Data struct, framework init, background task spawning |
src/config.rs | Environment variable loading |
src/instance_config.rs | config.toml parsing for per-instance feature flags |
src/error.rs | BotError enum and From conversions |
src/commands/ | Every prefix command, all parented under !m |
src/events/ | Gateway event dispatcher, message handler, voice-state handler |
src/ai/ | DeepSeek/Gemini pipeline, tool execution, response sanitising |
src/music/ | Per-guild player, yt-dlp + songbird pipeline, voice handling |
src/wordle/ | Wordle game state and puzzle fetching |
src/connections/ | NYT Connections game state and puzzle fetching |
src/stocks/ | Virtual stock trading, Finnhub integration |
src/minecraft/ | Minecraft link verification, donator sync, chargeback webhooks |
src/autorole.rs | Time/message-based role promotion |
src/mcp/ | Embedded MCP server and tool definitions |
src/db/ | Connection pool, models, query helpers |
src/util/ | Rate limiters, duration parsing |
Tech choices
- serenity — Rust’s mature Discord library, the foundation for everything else. Chosen for its stable gateway handling and typed model objects.
- poise — a command framework
built on serenity. Used for its prefix-command parsing, subcommand
tree, and typed
Context<'_, Data, BotError>. Saves hundreds of lines of boilerplate compared to raw serenity. - songbird — the voice driver. Handles voice gateway, UDP, and Opus packet assembly so this project only has to feed it audio bytes.
- sqlx — async Postgres client with compile-time-checked queries. Chosen over an ORM for explicitness and because the schema is small enough not to need one.
- dotenvy — reads
.envat startup. Modern maintained fork of the classicdotenvcrate. - rmcp — the official Rust SDK for the Model Context Protocol; used for the embedded MCP server.
- axum — HTTP server. The MCP server and (optionally) chargeback webhook router run on axum inside the same Tokio runtime as the Discord client.
- dashmap — a lock-free concurrent hash map. Used for every per-guild and per-channel state map so that work in one guild never blocks work in another.
Where to go next
- Multi-Instance Model for the process and schema layout when you run more than one bot against one Postgres.
- Data Flow for the step-by-step lifecycle of a single Discord event.
- AI Pipeline for how
@mention→ response actually works, including tool-use loops. - Music Pipeline for the yt-dlp + songbird path.
- Concurrency Model for
DashMap+tokio::Mutexpatterns and why locks are the last tool, not the first. - Error Handling for how
BotErrorreaches users. - Database Schema for every table and what owns it.
Multi-Instance Model
discord-bot-rs is designed to run more than one bot at a time on the same host — different Discord identities, different personalities, different feature sets, sharing one Postgres server and one MCP gateway. This page explains what an “instance” actually is, where the isolation boundaries sit, and why the project chose a schema-per-instance approach over the alternatives you’d usually reach for.
If you just want to deploy two bots, see Multi-Instance Deployment. This page is the architectural rationale behind that recipe.
What an instance is
An instance is a tuple of four things:
- One Discord bot identity — its own
DISCORD_TOKEN,CLIENT_ID, andGUILD_ID. - One config directory — a path on disk containing
config.toml,personality.txt, an.envfile, and whatever optional feature files (welcome prompt, cookies, etc.) that instance uses. - One Postgres schema — selected by the
DB_SCHEMAenvironment variable. All of the instance’s persistent state lives inside it. - One Linux process — in practice, one container running the
discord-bot-rsbinary. Each process has its own Tokio runtime, its ownDatastruct, its own memory state, its own MCP server on its own port.
Nothing in the bot code is aware of other instances. The binary reads a
single .env, mounts a single CONFIG_DIR, talks to a single schema, and
serves a single Discord token. You get multi-tenancy by running the
binary twice with two configurations, not by having one process juggle
multiple identities.
Topology
graph TB
subgraph "Host"
subgraph "bot container #1"
B1[discord-bot binary<br/>CONFIG_DIR=/config]
M1[MCP server :9090]
B1 --- M1
end
subgraph "bot container #2"
B2[discord-bot binary<br/>CONFIG_DIR=/config]
M2[MCP server :9090]
B2 --- M2
end
subgraph "postgres container"
PG[(PostgreSQL)]
S1[schema: bot1]
S2[schema: bot2]
PG --- S1
PG --- S2
end
subgraph "mcp-gateway container"
G[gateway :9100]
end
B1 -.-> S1
B2 -.-> S2
G --> M1
G --> M2
end
D1[Discord API<br/>bot1 token] <--> B1
D2[Discord API<br/>bot2 token] <--> B2
Claude[MCP client] --> G
Each bot container has its own /config volume mount and its own .env,
so they see completely different DISCORD_TOKEN, CONFIG_DIR, and
DB_SCHEMA values. Both bots connect to the same Postgres server but
operate on different schemas, so their tables never collide. The gateway
container sits in front of both MCP servers on an internal Docker network
and presents a single endpoint to outside tools.
Isolation boundaries
The same instance can be cloned, renamed, or retired without touching the others. Here’s what’s isolated and where each boundary is enforced.
- Process. Each instance is a separate Docker service (or plain process) with its own Tokio runtime, memory, and lifetime. Crashing one takes the others with it only if they share a container, which Docker Compose setups avoid by default.
- Config.
CONFIG_DIRpoints at a per-instance directory. The bot readsconfig.toml,personality.txt, the optional welcome prompt, and (for music)cookies.txtfrom that path. Two instances can ship completely differentconfig.tomlfeature flags and the code will ignore them independently. - Database.
DB_SCHEMAselects a Postgres schema. See below for howsqlxthreads this through the pool. Bot A can migrate its schema without affecting Bot B, and you can drop one schema without touching the other. - Personality. Each instance has its own
personality.txtloaded intoData::personalityat startup. The AI system prompt interpolates this string, so the two bots have different voices even if they share every other config value. - Discord identity. Token, client ID, and guild are environment
variables, so they live in each instance’s
.env. The Discord gateway has no concept of “the same binary running twice” — each token opens its own shard connection.
Schema-per-instance: how it works
The database setup lives in
src/db/mod.rs.
At startup, init_pool takes the DATABASE_URL and the DB_SCHEMA name
and does three things:
- Opens a one-off connection and runs
CREATE SCHEMA IF NOT EXISTS "<schema>". - Builds a
PgPoolOptionswith anafter_connecthook that runsSET search_path TO "<schema>"on every new connection the pool hands out. - Runs the migration SQL (currently a set of
CREATE TABLE IF NOT EXISTSstatements) against the freshly configured pool, so the tables land in the right schema.
The key move is the search_path hook. Postgres resolves unqualified
table names by walking search_path in order, so as long as every
connection has search_path = <schema>, every SELECT * FROM tempbans
in the codebase silently becomes SELECT * FROM "<schema>".tempbans. No
feature module has to know the schema name, no query has to be
parameterised. The abstraction is completely transparent to the rest of
the code.
Migrations are a tradeoff of their own. Today, migrate runs a flat list
of CREATE TABLE IF NOT EXISTS statements. That’s enough to bootstrap a
new schema but doesn’t handle schema evolution gracefully. A proper
migration tool is future work; the current setup is “good enough until we
need to rename a column.”
What’s shared
A few things cross instance boundaries on purpose, because isolating them would cost more than it’s worth:
- The Postgres server. One Postgres process, one connection listener, one set of backups. Each instance gets its own schema inside that server. Running two Postgres containers just to keep bots apart would waste RAM and double the ops surface.
- The Docker network. All bot containers, the Postgres container, and
the MCP gateway share an internal bridge network. That’s how
mcp-gatewayreacheshttp://bot1:9090by name. - The host. CPU, disk, memory, the kernel — everything underneath Docker is shared. If you need stronger isolation than “same Linux host” you’re looking at a different architecture.
- The mcp-gateway container. One gateway fronts all instances. See MCP Gateway Routing for how it picks which bot to forward a tool call to.
Why not the alternatives
Three approaches were considered before landing on schema-per-instance.
Separate Postgres databases. Instead of one server with many schemas,
you could spin up one Postgres database per bot. This gives stronger
isolation — separate pg_stat, separate WAL, separate roles — at the
cost of doubling your connection count and making backups harder. For a
bot whose per-instance data is measured in kilobytes, the cost isn’t
justified. Schemas inside one database give you every isolation
property that actually matters (no accidental cross-instance queries,
independent migrations, drop-and-recreate safety) without the overhead.
Single schema, guild_id column. The other extreme: one schema,
every table has a guild_id column, every query adds
WHERE guild_id = $1. This is how Discord bots usually handle
multi-tenancy. It works for a shared public bot, but it makes “run a
second bot with a different personality against the same server” a lot
harder. Every test fixture, every migration, every ad-hoc SQL query now
has to carry the guild ID as ceremony, and there’s no isolation if buggy
code accidentally forgets the filter. For the use case this project
targets — self-hosters running a handful of dedicated bots — the schema
boundary is a much safer default.
Separate Postgres containers. The nuclear option: one entire Postgres per bot. Each container is a full Postgres, so you pay its full RAM footprint, its full startup time, and its full ops burden. For two bots on a small VPS, this is 200–400 MB of overhead to solve a problem that schemas already solve for free.
Concurrency across instances
Because instances are separate processes with their own Data, there is
zero shared in-memory state. One bot can be cranking through a music
queue and another can be handling a moderation action at the same time
without any lock contention whatsoever — the two runtimes don’t even see
each other. Scaling is linear until Postgres becomes the bottleneck,
which for this workload is “many hundreds of active bots on one box.”
The flip side is that there’s also no cross-instance coordination. Bot A cannot send a message to a channel that only Bot B has permission to post in. Bot A cannot read Bot B’s music queue. If you need that, you need to build it through the MCP gateway or an external message bus — the bot framework itself doesn’t model it.
Adding an instance
Operationally, adding a new instance is copy-paste plus a restart. Make a
new directory under instances/, fill in config.toml, .env, and
personality.txt, copy the bot service in docker-compose.yml, rename
it, point its volume at the new directory, run docker compose up -d.
The Multiple Instances
configuration page walks through the .env and config.toml side. The
Multi-Instance Deployment
deployment page walks through the compose file and the gateway
registration.
Known limits
- No cross-instance messaging. Each process is an island. There’s no built-in way for one bot to trigger an action in another one.
- No shared in-memory state. Rate limiters, music queues, game state — none of it crosses processes. If you need shared state, you’d put it in Postgres.
- No dynamic instance add/remove. Adding a new instance means
editing
docker-compose.ymland restartingdocker compose. There’s no admin API to register a new bot at runtime. - MCP gateway routing is static. The gateway reads
INSTANCESfrom its environment once at startup and refreshes the guild map every five minutes. It doesn’t discover new backends on the fly.
See MCP Gateway Routing for how a single MCP
client talks to all these instances through one URL, and
Configuration Overview for how to split
config between environment and config.toml.
Data Flow
This page follows a single Discord event from its arrival on the gateway to the response going back out, touching every layer the bot passes it through on the way. The goal is to give you enough mental model to know where to look when something misbehaves and where to add new behaviour when you’re extending the bot.
The two main paths are commands (a message starting with the
configured prefix that matches a !m subcommand) and events
(everything else — @mentions handled by the AI, voice state changes,
button clicks, member joins). Both start at the same gateway shard and
both pass through the same poise dispatcher, but they diverge at the
point where poise decides whether a prefix parser matched.
Sequence: a single command
sequenceDiagram
participant U as User
participant DG as Discord Gateway
participant SR as serenity Shard
participant P as poise dispatcher
participant H as Command handler
participant D as Data (DashMap / PgPool)
participant DB as PostgreSQL
U->>DG: !m ban @user 3d
DG->>SR: MESSAGE_CREATE event
SR->>P: FullEvent::Message
P->>P: parse prefix, match subcommand
P->>H: moderation::ban(ctx, target, duration, reason)
H->>D: create_tempban(db, ...)
D->>DB: INSERT INTO tempbans ...
DB-->>D: row id
D-->>H: Ok(expires_at)
H->>SR: ctx.say("Banned ...")
SR->>DG: CREATE_MESSAGE
DG-->>U: reply visible in channel
The whole round trip is one async function call tree — there is no inter-process hop between any of the boxes above. What looks like a distributed system on paper is a single Tokio task spinning up a few short-lived sub-tasks and then awaiting the response.
Step by step
1. Gateway. serenity runs a persistent WebSocket connection to
Discord’s gateway, wss://gateway.discord.gg. When a user sends a
message, Discord pushes a MESSAGE_CREATE event down this socket.
serenity’s shard runner parses the frame into a typed FullEvent
variant and forwards it to whatever listener is registered. In this
project, poise registers itself as the listener.
2. Poise dispatcher. Poise is a thin command framework layered on
top of serenity. It receives every FullEvent, runs its own prefix
parser against messages, and decides whether to route them as commands
or fall through to the user-defined event handler. The wiring lives in
main.rs where the framework is built with both a commands list and
an event_handler closure pointing at
events::event_handler.
For prefix commands, poise walks through your command tree looking for a
match. The tree here is rooted at a single top-level command m
(defined in
src/commands/mod.rs)
with every user-facing command as a subcommand — music::play,
moderation::ban, admin::djmode, help::help, and so on. There are
no slash commands, so there’s no application-command sync step: the
registered !m command is all poise needs.
3. Command handler. If poise found a match, it calls the handler
function with a typed
Context<'_, Data, BotError>.
The context gives the handler its arguments (poise parsed them from the
message), an &Data reference for shared state, and convenience
methods like ctx.say("...") for replying. A simple command looks like
moderation::ban: it reads the target user and duration, calls
create_tempban in db::queries, then replies with a confirmation
string via ctx.say. That’s the whole round trip.
4. Database access. Every DB call reaches Postgres through the
sqlx::PgPool stored in Data::db. The pool was built in main.rs
with an after_connect hook that pins search_path to the per-instance
schema, so queries inside handlers can say SELECT * FROM tempbans
without worrying about which schema they land in. See
Multi-Instance Model for why.
5. Response. Replies go back through serenity’s HTTP client (not the
gateway), which submits them to Discord’s REST API. serenity takes care
of per-route rate limiting transparently, so handlers don’t have to
think about Retry-After headers. The user sees the message.
Event path (no command match)
When poise decides a message isn’t a command — no prefix match, or the
event isn’t a message at all — it calls the event_handler closure. In
this project that closure is
events::event_handler,
which is one big match over FullEvent variants:
Ready— fires once at startup (and on every reconnect). The first time, it spawns the MCP server and any webhook routers; subsequent reconnects are guarded by anAtomicBoolso the server doesn’t bind its port twice.Message—handle_messageruns auto-role bookkeeping, checks for active Wordle games in the channel, and dispatches toai::deepseek::handle_mentionif the message mentions the bot or replies to a bot message (and at least one AI key is configured).VoiceStateUpdate— if a user left the bot’s voice channel and the channel is now empty of humans,voice_state::handle_voice_state_updatecleans up the player, cancels the idle timer, and leaves the channel.InteractionCreate— a component button click. The dispatcher looks at thecustom_idprefix (music_,game_,cb_) and hands off to the right feature handler.GuildMemberAddition— a new member joined, used by the welcome prompt and join-role features.
Event handlers get the same &Data reference as commands, so they reach
shared state the same way. The only structural difference is that they
don’t go through poise’s command parser, so argument parsing and
permission checks are the handler’s own responsibility.
Error paths
Each layer has its own failure model, and errors surface differently depending on where they start.
- Command handler returns
Err(BotError). Poise catches this and calls itson_errorhook, which is wired inmain.rsto log the full error viatracing::error!and posterror.user_message()— a short, sanitised, per-variant string — in the channel. See Error Handling for the full picture. - DB query fails.
sqlx::Errorconverts intoBotError::Sqlxvia aFromimpl insrc/error.rs, so?in a command handler turns a query failure into an automatic early-return with a user-visible message. - Event handler fails. Event handlers mostly use
let _ = ...patterns when calling Discord to swallow transient errors, because there’s no safe place to post a user-visible error for, say, a failed auto-role bookkeeping write. Serious failures get logged viatracing::error!. - Panic inside a handler. Tokio catches task panics and logs them, and serenity’s shard runner keeps going. A panicking command does not take the process down, but it also does not reply to the user — the user sees no response.
- Rate limit from Discord. serenity’s HTTP client implements bucketed rate limiting; 429s are retried transparently. Commands don’t see them unless the wait exceeds serenity’s patience.
- Network drop. The gateway shard auto-reconnects with exponential
backoff. On reconnect, serenity replays any missed events Discord
will give it, and the
Readyhandler re-fires. Themcp_startedguard prevents double-binding the MCP port on reconnect.
Shared state access
Data is given to handlers by reference (&Data), wrapped in an Arc
by poise so cloning it for spawned tasks is O(1). Inside Data, per-guild
and per-channel state lives in
DashMap instances —
guild_players, track_handles, now_playing_msgs, idle_timers,
connections_games, wordle_games. DashMap is a sharded lock-free hash
map, so two handlers running in different guilds never block each
other on the outer map. Inside one shard entry, the value is typically
Arc<Mutex<T>>, so concurrent access to the same guild’s player (for
example) is serialised through a tokio Mutex.
Why not a global RwLock<HashMap>? Because a single global lock would
turn every music command in every guild into a contention point. DashMap
gives you concurrent reads and writes across different keys, which is
exactly the shape of “per-guild state accessed by concurrent handlers.”
Concurrency Model expands on this pattern.
Cross-links
- Error Handling — what happens when any of the above fails, and how errors reach users (or get quietly logged).
- Concurrency Model — why
Datauses DashMap the way it does, and how background tasks coexist with event handlers. - AI Pipeline — the most elaborate event path: an
@mentionbecomes a history fetch, a chat completion, a tool-use loop, and a response splitter. Everything in this page applies, plus a lot more.
AI Pipeline
This page walks through the path from a Discord @mention to the reply
the user sees. It’s the most elaborate event path in the bot: history
building, personality injection, provider routing, tool-use loops,
response sanitising, and Discord-friendly chunking all happen before the
reply is sent. A reader who understands this page can confidently
extend the AI in new directions — adding tools, tweaking the system
prompt, swapping providers — without breaking the rest.
The core file is
src/ai/chat.rs.
Everything under src/ai/ is either called from there or defines data
it consumes. For how users actually interact with this from Discord,
see AI Chat.
Sequence
sequenceDiagram
participant U as User
participant E as events::handle_message
participant H as build_message_history
participant R as model router
participant API as DeepSeek / Gemini
participant T as tool executor
participant S as sanitize + split
participant D as Discord
U->>E: @bot what's the weather in tokyo?
E->>E: activation check (mention / reply + AI key)
E->>H: fetch 100 recent messages
H->>H: filter by age, started_at, bad-msg
H->>E: system prompt + history + current msg
E->>R: classify message: reasoning or chat?
R->>API: chat completion with tools
API-->>R: content + tool_calls (web_search, play_song, ...)
loop up to 3 rounds
R->>T: run search tool calls
T-->>R: search results
R->>API: re-call with results
end
R->>S: final text response
S->>S: sanitize, split at 2000 chars
S->>D: send one or more reply messages
T->>D: execute action tools (play_song, tempban, ...)
Not shown: vision routing (images go directly to Gemini 3 Flash via OpenAI-compatible endpoint before any of this), the moderation confirmation button flow, and the typing indicator that fires every 8 seconds while the pipeline is running.
Activation
Before any of the pipeline runs, the bot has to decide “is this for
me?” That check lives in
handle_message
in src/events/mod.rs and is deliberately narrow. Three conditions must
all hold before handle_mention is called:
- The message is from a non-bot author in a guild channel.
- The message either contains a direct mention of the bot user ID, or is a Discord reply to a message the bot itself sent.
- At least one of
DEEPSEEK_API_KEYorGEMINI_API_KEYis set onData::config.
There is no keyword trigger, no prefix alternative, no owner override.
Everything flows through @mention or reply. This keeps the activation
surface small, which matters for a bot that can issue tempbans and spend
real money on API calls.
A fourth short-circuit happens inside handle_mention: the per-user AI
rate limiter (RateLimiters::ai)
is a sliding window of 10 requests per 60 seconds. Users who exceed it
get a “Slow down — try again in Ns.” reply and the pipeline exits
before any API call.
History building
The AI’s memory is whatever messages the bot can reconstruct from the
channel’s recent history. There is no vector store, no long-term memory,
no per-user state. When the pipeline starts,
build_message_history
fetches the last 100 messages before the current one via
channel.messages(...).before(message.id).limit(100) and walks them in
reverse-chronological order.
From that window, it keeps the most recent 10 relevant messages, where “relevant” means:
- Posted after
Data::started_at. Bot messages from a previous process instance are filtered out, because they might be tied to state this process no longer has. - Posted within the last 30 minutes. Anything older is stale context — the AI would start mixing up a question from an hour ago with the current one.
- Either from the bot (assistant role) or from a human who was directly talking to the bot (either mentioning it or replying to a bot message). Messages that are just general channel chatter don’t get included — the bot is not trying to maintain a running summary of the whole channel.
- Not a known bad assistant message. Leaked
I'm Claude, memory denials, broken tool replies, and error strings are pattern-matched viaBAD_ASSISTANT_PATTERNSand skipped — and any user message those bad assistant messages were replying to is skipped too, on the theory that if the AI blew up on that question, feeding it back in will make it blow up again.
Bot messages whose content is empty but that carry embeds (Now Playing,
Added to Queue, confirmation prompts, etc.) get their embeds summarised
into a compact [Already completed action] [title: description] string.
This is what keeps the AI from replaying the same music request every
time someone @mentions it — the embed history tells the model “you
already did this, move on.”
After the loop, the builder pushes a synthetic system message:
Everything above is conversation history for context only. You have already responded to all of it. Do NOT act on any previous requests again. The NEXT message is the current request — respond ONLY to it.
This separator is a necessary belt-and-braces measure against a failure mode where the AI would pick up an earlier question and answer it instead of the new one. Finally, the current message is appended as a user-role message, prefixed with the author’s display name.
If the current message is a Discord reply to a non-bot message, the
builder fetches that referenced message, truncates it to 300 characters,
and prepends a [Replying to name: "..."] marker to the current
message. If the referenced message has image attachments, they’re
collected for vision routing.
Personality and system prompt
The personality file loaded from CONFIG_DIR/personality.txt is
appended verbatim into the system prompt by
get_system_prompt.
Around it, the function hard-codes:
- The current date (so the model doesn’t guess),
- The current bot version (pulled from
CARGO_PKG_VERSION), - A block explaining the music tools and the rules for using them (most importantly: only on explicit current requests, never replay old ones),
- A block explaining the web search tool and the rule that the model can search up to three times per turn,
- A block explaining moderation tools and that the system does its own permission checking,
- A block covering markdown capabilities, the user-name prefix convention, and how to handle mentions in message text,
- A security block instructing the model to refuse prompt-injection attempts and to treat role markers in user text as data, not instructions.
The personality file never sees this hard-coded framing: the bot operator writes only their instance’s voice, and the bot fills in the mechanics. See Personality Files for how to write the free-form half.
Provider selection
The bot speaks two providers, both through an OpenAI-compatible chat-completions API:
- DeepSeek at
https://api.deepseek.com/chat/completions.deepseek-v4-flash(DeepSeek V4) is the default for text.deepseek-v4-prois the flagship used for questions the router classifies as needing deeper thinking. - Gemini at
https://generativelanguage.googleapis.com/v1beta/openai/chat/completions.gemini-3-flash-previewhandles image vision, because DeepSeek’s chat model is text-only.
Routing happens in two places. First, vision routing: if the message (or the message it replies to) has image attachments and a Gemini key is configured, the pipeline preprocesses each image (resize to 1024x1024 max, re-encode as JPEG, base64 in a data URI) and sends the history as a multimodal completion to Gemini. If Gemini fails, the pipeline strips the images and falls through to the text path.
Second, reasoning routing: for text requests,
classify_message
sends the user’s most recent message to DeepSeek V4 with a one-shot
“yes/no — does this need deep reasoning?” prompt. If the classifier
says yes, the pipeline switches the active endpoint to deepseek-v4-pro.
Because the reasoner role can’t use tools, the pipeline first runs a
pre-flight loop on deepseek-v4-flash that’s allowed to call web_search up to
MAX_SEARCH_ROUNDS times (currently 3), collects the results, and
injects them into the V4-Pro conversation as extra system context
before asking V4-Pro the real question.
If the classifier itself fails (network error, timeout), the pipeline
defaults to deepseek-v4-flash without reasoning — “failing toward the cheap path” is
the preferred failure mode.
Tool use loop
Once an endpoint and model are picked, call_api posts the history
with the full
tool definitions
attached (except for the reasoner role, which gets no tools). The response
contains content (the assistant message), tool_calls (any function
calls the model wants to invoke), or both.
Tools come in two flavours:
- Search tools — just
web_searchtoday. The model asks for a search, the bot runs it, the result goes back to the model as arole: "tool"message, and the model gets another turn to decide whether to search again or answer. Up toMAX_SEARCH_ROUNDSrounds (currently 3), after which the pipeline forces a final answer with tools disabled. The sameMAX_SEARCH_ROUNDSconstant insrc/ai/chat.rsis interpolated into the system prompt and drives both the V4-Flash chat loop and the V4-Pro pre-flight loop, so the prompt and the code can never disagree about the limit. - Action tools — everything that changes state:
play_song,skip,stop,pause,resume,show_queue,now_playing,shuffle,set_loop,remove_from_queue,tempban,unban,nuke,stock_buy,stock_sell,stock_price,stock_portfolio,stock_leaderboard,connections_start,wordle_start.
Search tools are executed inside the loop because their results feed back into the model. Action tools are executed after the text response is posted: the bot sends the model’s witty reply, then runs the actions. This preserves the personality when actions have their own output (skip messages, Now Playing embeds, etc.) and keeps the user experience close to “bot says something, then does the thing.”
Action tools are dispatched from a single for loop in handle_mention
that checks each call against is_moderation_tool, is_stock_tool,
is_connections_tool, is_wordle_tool, and falls through to
execute_music_tool for the rest. Moderation tools go through an
extra step: a Discord confirmation embed with Approve/Cancel buttons,
handled by
request_confirmation.
That function pre-checks the requesting user’s guild permissions
(computed from role permissions because Message::member.permissions
is often None for fetched messages), posts the confirmation embed,
waits up to 30 seconds for the original author to click, and returns
approval status. Only then does the moderation action run. Other
action tools run without confirmation — permissions are enforced inside
each tool by the DJ mode check or by Discord’s own permissions.
DSML: tool calls in prose
DeepSeek V4 sometimes emits tool calls as structured text inside the
content field instead of the proper OpenAI-style tool_calls array.
The bot handles this by parsing a custom “DSML” (Discord Structured
Message Language) block out of the content — fullwidth pipe characters
wrapping <|DSML|invoke name="...">...</|DSML|/invoke> — in
parse_dsml.
Any DSML tool calls found are appended to the real tool call list and
the content is cleaned up before being shown to the user. This is a
resilience hack for model quirks; the primary path is still proper
function calling.
Response sanitising
Two kinds of cleaning happen to AI-adjacent text.
Input sanitising is applied to every bit of user text that gets
added to the history. Mentioned in
sanitize_content,
it rewrites system:, assistant:, user: role markers into
bracketed forms ([system]:), strips DeepSeek’s internal <|...|>
tokens, and strips Llama-style [INST] and <SYS> markers. The point
is not to block every conceivable prompt injection — that’s impossible
— but to make it harder to slip a realistic-looking “new system
prompt” into the model’s conversation by typing one into Discord.
Output filtering happens at history-build time. Past bot messages that match known failure patterns (“I’m Claude”, “I don’t have access to our previous”, “created by Anthropic”, “Failed to join”, etc.) are skipped when reconstructing the history, and so are any user messages those bad bot messages were replying to. This is a self-healing mechanism: if the model goes off the rails once, the next turn won’t see the broken exchange and is less likely to repeat it.
Response splitting
Discord messages max out at 2000 characters. The splitter in
src/ai/split.rs
takes a raw response string and returns a Vec<String> of chunks each
under the limit. Simple cases (response under 2000 chars) return a
single-element vec. For long responses, the splitter walks forward
looking for the best break point:
- If the current chunk ends inside a fenced code block (an odd number of
```markers), the splitter finds the opening fence and either splits just before it (if the code block hasn’t yet started near the top of the chunk) or closes it with```and re-opens it with```langin the next chunk, preserving syntax highlighting. - Otherwise, it prefers breaking on
\n\n, then\n, then". "— in that order. The split point has to be at least 200 bytes into the chunk to avoid pathological tiny slices.
All slicing is done at char boundaries (UTF-8 safety), not byte boundaries, so multi-byte characters don’t get cut in half.
Rate limiting
Rate limiting for the AI path is a per-user sliding window configured in
src/util/ratelimit.rs:
10 requests per 60 seconds, shared across every AI interaction. It’s
enforced in handle_mention before any API call. The other limiters —
music, moderation, stocks, and welcome — are all enforced too
on their respective paths; see the
Concurrency Model rate-limiter section
for the full table and the periodic bucket-cleanup task that keeps the
limiter maps from growing without bound.
Rate limiting at the API layer (DeepSeek / Gemini quotas) is the provider’s responsibility; the bot doesn’t pre-check quotas and relies on the API’s own error responses.
Error handling inside the pipeline
Each layer has its own fallback:
- Classifier fails → default to
deepseek-v4-flash(non-reasoner) path. - Vision API fails → strip images and fall through to text.
- Text API fails → reply with “Something went wrong talking to the
AI. Try again in a sec.” Log the upstream error with
tracing. - “Content Exists Risk” censored response from DeepSeek → reply with a sarcastic “my overlords at DeepSeek won’t let me talk about that.”
- Search tool fails → inject “Search failed.” as the tool result and let the model continue with whatever it has.
- Tool call with bad arguments → the tool executors generally
unwrap_or(...)past missing fields rather than erroring, because the user has already waited for the model and a silent no-op is better than a red error string. - Tool dispatch / DB / HTTP failures inside a tool → all
user-facing replies in
handle_mention’s tool loop now use the same generic, sanitised wording asBotError::user_message()(“Something went wrong talking to the database. Please try again later.”, etc.). Operators still see the full upstream error viatracing::error!with the failing tool name and guild ID, but rawsqlx/reqwest/serde_jsonstrings never reach Discord. See Error Handling for the mapping table.
The typing indicator is re-triggered every 8 seconds by a spawned
background task, so users see the bot “thinking” for the whole duration
of a slow tool-use loop. That task is aborted on every exit path
(typing_handle.abort()) to keep it from leaking past the end of the
conversation.
Known issues
- Context bleed. The 10-message / 30-minute window is a compromise. Shorter would drop useful context; longer pulls in stale questions the AI wants to answer. Users occasionally see the AI start answering a question from 20 minutes ago when they @mention it with something new. The self-healing filter helps but doesn’t eliminate it.
- Permission checks on message.member. Confirmation flow has to
recompute permissions from the guild’s role table because
message.member.permissionsis oftenNonefor messages fetched via the API. This is a serenity quirk, not a design choice. rmcpsession auth. See MCP Gateway Routing for the current state of MCP authentication.
Cross-links
- AI Chat — user-facing feature description.
- Personality Files — how to write
personality.txt. - Concurrency Model — the rate limiter and
DashMappatterns the pipeline leans on. - MCP Tool Catalog — the separate tool surface exposed via MCP, not via the AI pipeline.
Music Pipeline
From a !m play <query> command or an AI tool call to audio playing in
a voice channel. This page follows every step of the path so you can
add features, debug playback issues, or reason about what happens when
yt-dlp fails at 3 a.m.
For how users interact with music features, see Music.
Sequence
graph TB
User[!m play <query>] --> Cmd[commands::music::play]
AI[AI tool call: play_song] --> Exec[execute_music_tool]
Cmd --> Resolve[resolve_track / resolve_tracks]
Exec --> Resolve
Resolve --> Ytdlp[[yt-dlp --dump-json<br/>HTTP search or URL]]
Ytdlp --> Track[Track struct<br/>url, title, thumb]
Track --> Join[voice::join_channel<br/>songbird: deafen, 256k bitrate]
Join --> Play[voice::play_track<br/>YoutubeDl input via songbird]
Play --> Songbird[(songbird driver<br/>ffmpeg + Opus)]
Songbird --> Voice[Discord voice gateway]
Voice --> Channel[audio in voice channel]
Play --> Embed[now_playing_embed + controls]
Embed --> Msg[channel.send_message]
Play --> EndHook[TrackEndHandler<br/>registered via Event::Track]
EndHook --> Advance[GuildPlayer::advance<br/>respect loop_mode]
Advance -->|Some track| Play
Advance -->|None| Idle[start_idle_timer 5 min]
Idle -->|timeout| Leave[songbird leave]
Two entry points, one pipeline. The prefix command path and the AI tool
path both converge on resolve_track, then both use voice::play_track
to hand the URL to songbird. After that, track advancement is driven by
songbird’s TrackEvent::End hook, not by polling.
The MusicPlayer struct
Per-guild state lives in
GuildPlayer:
queue: VecDeque<Track>— upcoming tracks, bounded to 100 entries (MAX_QUEUE_LENGTH).current: Option<Track>— what’s playing right now, orNoneif the player is idle.loop_mode: LoopMode—Off,Track, orQueue. Cycles through those three values when the loop button is pressed.paused: bool— tracks paused state so the now-playing embed can show the right icon.skip_in_progress: Arc<AtomicBool>— see Skip race below.
The struct is plain data plus a handful of methods (enqueue,
enqueue_many, advance, skip_current, stop_all, remove,
shuffle, leave_empty). None of those methods touch the Tokio
runtime, do I/O, or know anything about songbird. That’s deliberate:
the player is a pure state machine, and the music pipeline wraps it in
an Arc<Mutex<GuildPlayer>> stored in Data::guild_players. Every
feature that reads or mutates the player takes the lock, works with
plain Rust data, and releases it. See
Concurrency Model for why this separation
matters.
The interesting method is advance. It implements loop semantics:
LoopMode::Trackreturns a clone of the current track (play it again).LoopMode::Queuepushes the current track back onto the queue’s tail, then pops the front.LoopMode::Offdrops the current track and pops the next one from the queue.
If the queue is empty after popping, advance returns None and the
caller uses that as the signal to leave the voice channel.
Track resolution
A raw user query — "sabrina carpenter espresso", a YouTube URL, a
playlist URL — becomes a Track via
src/music/track.rs.
The resolve_track and resolve_tracks helpers shell out to yt-dlp
with --dump-json --no-download and parse the NDJSON output into one
or more Track structs (URL, title, duration, thumbnail, requested-by
display name).
The yt-dlp invocation is a little unusual because YouTube’s age and region gates require a browser session. The bot passes several flags:
--cookies <path>— supplies acookies.txtfile exported from a logged-in browser. The path is thecookies.txtin the current working directory if it exists (so per-instance containers can mount their own).--js-runtimes node:<path>— tells yt-dlp to solve JavaScript challenges using a specific Node binary. The defaultnodepath isn’t always onPATHin service environments, so the code probes/home/webapps/.nvm/versions/node/v20.20.1/bin/nodefirst and falls back tonode.--remote-components ejs:github— lets yt-dlp pull JS extractor patches from its GitHub repo when the built-in ones are out of date.--no-playlistor--flat-playlistdepending on whether the caller wants one track or the whole URL’s contents.
If yt-dlp fails with output that looks like a cookie problem (“page
needs to be reloaded”, “sign in to confirm”, “this helps protect our
community”), the bot retries without the --cookies flag and, on
success, returns cookies_stale = true so the caller can warn the user
that cookies need refreshing. Non-cookie failures bubble up as errors.
Joining and playing
Once there’s a Track, the pipeline joins the user’s voice channel via
voice::join_channel.
This calls songbird’s manager.join(guild_id, channel_id), self-deafens
the bot (so it doesn’t waste bandwidth receiving audio), and sets the
voice bitrate to 256 kbps.
Playback happens through songbird’s YoutubeDl input source:
let source = YoutubeDl::new(http_client, url).user_args(ytdlp_user_args());
handler.play_input(source.into())
ytdlp_user_args() passes the same cookies/node-runtime/remote-components
flags as above. Songbird runs yt-dlp, reads its stdout, pipes it through
ffmpeg (internally), and feeds the Opus-encoded frames to Discord’s
voice UDP.
play_input returns a TrackHandle which the bot stores in
Data::track_handles keyed by guild ID, so pause/resume buttons and AI
tool calls can find the right handle to act on.
Track-end event and the idle timer
Songbird fires a TrackEvent::End when playback finishes. The bot
registers a custom
TrackEndHandler
on every track via track_handle.add_event(Event::Track(TrackEvent::End), handler).
When the event fires, the handler:
- Looks up the
GuildPlayerfor this guild. - Checks the per-guild
skip_in_progressflag and bails out if set (see Skip race). - Calls
advance()to figure out what plays next. - If there’s a next track, starts it with
play_next_from_contextand replaces the prior “Now Playing” message viareplace_now_playing_message(delete old + send new under one mutex hold). - If there isn’t, starts the idle timer.
Skip race: suppressing the spurious TrackEnd
handler.stop() causes songbird to fire TrackEvent::End for the
track being stopped. The end handler attached to that track would
then see “track ended naturally” and call advance() — which on a
!m skip would skip past the song the caller is about to play,
because the caller has already advanced the queue itself before
calling play_track. Songbird 0.6 has no way to detach an event
listener from a TrackHandle, so the bot can’t simply remove the
handler before the stop.
The fix is a per-guild skip_in_progress: Arc<AtomicBool> on
GuildPlayer. Right before any code path that calls
handler.stop() on an existing track and immediately starts a new
one, the caller sets the flag to true. When the stale TrackEnd
event arrives, TrackEndHandler::act swaps the flag back to false
with swap(false, Ordering::SeqCst) and, if it was true, returns
early without advancing. The next natural TrackEnd (after the
new track finishes) sees the flag as false and proceeds normally.
Because the flag is an AtomicBool, no lock is needed, and the
swap-on-read pattern guarantees exactly one of the two events
(skip-induced End vs. natural End) is consumed by the bail-out path.
NP message lifecycle
Three different code paths can replace the “Now Playing” embed:
the prefix command, the AI tool’s “play song” path, and the
TrackEndHandler advancing to the next track. Previously each path
sent its new NP message independently and only the track-end handler
remembered to delete the prior one, leaving orphan embeds with stale
buttons whenever a !m play or AI tool call replaced an existing
track.
All three paths now go through a single
replace_now_playing_message helper in
src/music/voice.rs.
The helper takes the per-guild Arc<Mutex<Option<MessageId>>> slot,
locks it for the whole sequence, deletes the prior message ID if
one is stored, sends the new embed (with optional component rows),
and stores the new message ID into the slot before releasing the
mutex. Holding the lock across delete-then-send is intentional: it
prevents two concurrent skip operations in the same guild from
racing each other into a partially-deleted, partially-orphaned
state. Failures to delete the prior message (the user could have
deleted it manually) are swallowed at debug level — the new message
still gets sent and recorded.
The idle timer is the mechanism that gets the bot out of the channel
politely when the queue runs dry. start_idle_timer spawns a task that
sleeps 5 minutes, then calls songbird.leave and cleans up the
per-guild maps. The task’s JoinHandle is stored in Data::idle_timers
so that new tracks (or explicit stops) can cancel it with .abort().
This two-step — store the handle in a per-guild Arc<Mutex<Option<..>>>,
cancel it before starting anything new — is why the idle_timers
DashMap exists. A new !m play call on an idle bot cancels the pending
leave timer before joining, preventing a race where the bot would leave
mid-song.
The voice-state-update handler in
src/events/voice_state.rs
is a separate trigger: when the user side of the voice channel goes
empty (all humans left), it short-circuits the idle timer and leaves
immediately.
Queue operations
User commands and AI tools both hit the same queue methods on
GuildPlayer:
- add —
enqueue(track)orenqueue_many(vec). The latter respects the 100-track cap and returns how many it actually added. - skip —
skip_current()returns the title for the user-facing confirmation; the caller then runsadvance()to decide what’s next and plays it. - remove —
remove(position)removes by 1-based index, so users can!m remove 3to drop the third track in the queue. - shuffle — drains the queue, shuffles with
rand::thread_rng(), refills. Returns the queue length so the response can say “Shuffled N songs.” - loop —
loop_mode.cycle()rotates throughOff → Track → Queue.
“Previous” isn’t supported. Once advance is called, the previous
track is dropped (or pushed to the back, in queue-loop mode). There’s
no history stack.
Button controls
The “Now Playing” embed ships with two rows of buttons, built by
music_controls:
- Row 1: Pause/Resume, Skip, Stop, Shuffle, Loop.
- Row 2: Queue (shows the current queue as an ephemeral reply).
Each button’s custom_id starts with music_
(music_pauseresume, music_skip, music_stop, music_shuffle,
music_loop, music_queue). The button handler sits in
handle_component_interaction
and does three checks before running any action:
- Voice presence: the clicker must be in a voice channel, and it must be the same channel the bot is in. Otherwise the interaction replies ephemerally with an error.
- DJ mode: if DJ mode is enabled on the guild, only admins and users with the DJ role can press buttons. Non-DJs get an ephemeral error.
- Active player: there must be a
GuildPlayerregistered for the guild. Otherwise the button replies “No active player.”
The music_queue button is read-only, so it bypasses the voice and DJ
checks — anyone can look at the queue even if they can’t control it.
Error and failure handling
yt-dlp crashes, ffmpeg hangs, voice gateway disconnects, cookies expire. The pipeline tries to handle each gracefully:
- yt-dlp exits non-zero.
resolve_trackschecks the stderr for cookie-error patterns. Cookie errors retry without cookies and warn the user. Non-cookie errors bubble up as"Couldn't find that song."plus a tracing log with the real stderr. - Join fails.
voice::join_channelreturns an error with songbird’s message; the caller replies “Failed to join voice: {error}” and aborts. No partial state is stored. - Playback fails.
voice::play_trackwraps songbird’s errors; failures log and the caller replies “Playback error: {error}.” - Track-end handler fails to start the next track. The handler
clears
current, drops the track handle, and starts the idle timer, so the bot doesn’t get stuck claiming it’s playing when it isn’t. - Voice disconnect mid-track. Songbird manages its own reconnect, and the bot doesn’t react explicitly. If the reconnect fails, playback simply ends and the track-end handler runs its normal path.
- Queue overflow.
is_full()is checked before enqueuing; the response tells the user the queue is full.
Cross-links
- Music — user-facing description of how music commands work.
- Command List — every music command.
- Concurrency Model — why per-guild state is
behind
Arc<Mutex<T>>inside aDashMap. - Data Flow — the wider event lifecycle that commands and buttons flow through.
MCP Gateway Routing
Each bot instance runs its own MCP server on port 9090. A small companion
crate, mcp-gateway, sits in front of those servers and routes incoming
MCP requests to the right backend. This page explains why the gateway
exists, how it picks a target, and how it stays synchronised with the
backends over time.
For the user-facing side of MCP — how to connect a client, how to call tools — see MCP Server and MCP Tool Catalog. For deploying it safely to a public host, see MCP Exposure.
Why the gateway exists
The Model Context Protocol is session-oriented and connection-oriented. A client (for example Claude Code) opens one session against one MCP endpoint and issues tool calls over that session. If you’re running two bot instances and want to send tool calls to both, the obvious approach — configure the client with two endpoints — has two problems:
- Every new instance breaks your client config. Adding a third bot
means editing the client’s
mcp.json, reloading the client, and hoping you didn’t typo the URL. - Every tool call has to pick an instance out of band. Your prompt has to say “on bot1, list the guilds”; there’s no way to say “list the guilds on the bot serving guild 1234” and let the system figure out which bot that is.
The gateway solves both. Clients point at one URL (the gateway). They
see a single tool catalog. Each tool acquires an optional instance
parameter, injected by the gateway, and they can also pass guild_id
and have the gateway figure out which instance serves that guild.
Adding a new bot is a docker-compose line plus a gateway restart —
clients don’t change anything.
Topology
graph TB
Client[MCP client<br/>Claude Code, CLI, etc.] -->|POST /mcp| Gateway
subgraph "mcp-gateway container"
Gateway[axum server :9100]
State[GatewayState<br/>Router + BackendClients]
Cache[tool_list_cache]
Gateway --- State
State --- Cache
end
subgraph "bot1 container"
B1MCP[MCP server :9090]
end
subgraph "bot2 container"
B2MCP[MCP server :9090]
end
State -->|session per backend| B1MCP
State -->|session per backend| B2MCP
The gateway is a standalone axum app on port 9100. It keeps one open MCP session to each backend and multiplexes requests from clients onto those persistent sessions. Clients do not know backends exist; backends do not know other backends exist.
Routing model
The gateway configuration is a single environment variable, INSTANCES,
formatted as comma-separated name=url pairs:
INSTANCES="bot1=http://bot1:9090,bot2=http://bot2:9090"
GatewayConfig::from_env
parses this into a Vec<Instance> at startup. Each name becomes a
routing key and each URL becomes a backend target. The gateway panics
if INSTANCES is missing — a misconfigured gateway is a hard failure.
Routing itself is a two-step decision in
mcp-gateway/src/routing.rs:
- Explicit instance wins. If the tool call’s arguments contain an
instancefield (injected by the gateway into every tool’s schema), the router looks up that name ininstances: HashMap<String, String>and routes there. Unknown instance names returnRouteError::InstanceNotFound. - Otherwise, match by
guild_id. If the arguments contain aguild_id, the router consults itsguild_map: Arc<RwLock<HashMap<String, String>>>, where the keys are guild IDs and the values are instance names. If the map has the ID, it routes to that instance. If it doesn’t, returnsRouteError::GuildNotFound. - Neither present returns
RouteError::NoTarget, which the server layer turns into a helpful “available instances: …” error.
The guild map is populated by calling each backend’s list_guilds tool
at startup and every 5 minutes thereafter (see “Lifecycle” below).
Session management
MCP’s Streamable HTTP transport uses Server-Sent Events (SSE) with a
session ID header (Mcp-Session-Id). The backend opens the SSE stream
on the initial POST, keeps it open, and sends JSON-RPC responses down
it indexed by request ID. Each subsequent POST carries the session
header so the backend knows which session the request belongs to.
The gateway maintains one BackendClient per configured instance, each
with its own persistent session. On startup, initialize_backends
calls initialize on every client — which does the MCP handshake
(initialize request, read response, send notifications/initialized,
start a background task that keeps the SSE stream open for future
responses). Once initialised, subsequent tool calls reuse that session.
When a client sends a request to the gateway, the flow is:
- Client → Gateway: single JSON-RPC POST to
/mcp. Bodies are capped at 64 KiB by aRequestBodyLimitLayerinmcp-gateway/src/main.rs— JSON-RPC envelopes are tiny, and the cap stops authenticated callers from saturating the gateway with multi-MiB bodies. - Gateway parses the body. A malformed JSON envelope returns the
spec-compliant JSON-RPC
-32700 Parse errorresponse instead of axum’s opaque 422, which keeps clients on the protocol’s own error model. - Gateway inspects
method. Fortools/list, the cached tool list is returned immediately. Fortools/call, the gateway extractsinstance,guild_id, and the tool arguments fromparams, picks a target via the router, and forwards the call to the chosen backend’sBackendClient::call_tool. BackendClient::call_toolposts to<backend>/mcp, reads the response from the POST’s own SSE stream, and returns the result. (The earlier in-process pending-request dispatcher map has been removed — the original prototype kept apending: HashMap<request_id, oneshot::Sender>and a background SSE reader, but in practice every backend response arrives on the same POST’s SSE stream, so the dispatcher was dead code. Removing it cut about 77 lines and eliminated a state machine that didn’t earn its keep.)- Gateway wraps the result in an
event: message\ndata: {...}\n\nSSE frame and sends it back to the client withMcp-Session-Id: gateway-session.
The gateway uses a single synthetic session ID (gateway-session) for
all client connections, because it doesn’t actually track per-client
state — every gateway request is a stateless proxy onto the backend’s
real session. This is simpler than forwarding real session IDs and
avoids the problem of tying a gateway restart to session IDs clients
still expect to see.
Session recovery
MCP sessions can expire. When the backend returns a 404 Not Found or
an error mentioning “Session not found”, handle_tool_call in
mcp-gateway/src/server.rs
re-initialises the dead backend client in place and retries the tool
call once. This is transparent to the client: a successful retry looks
exactly like a first-try success.
On top of the on-demand recovery, a background task spawned in
mcp-gateway/src/main.rs
runs every 5 minutes and does two things:
refresh_guild_map— health-checks every backend, re-initialises any unhealthy ones, and re-fetches each backend’s guild list to update the router’s guild map. Guild memberships change — a bot joins a new server, leaves an old one — and the 5-minute refresh keeps the map current without client action.refresh_tool_list— re-fetches the tool catalog from a backend and rebuilds the cachedtools/listresponse. Without this, a new tool added to a backend bot stays invisible to clients until the gateway itself is restarted, even though the bot already serves it correctly. The cached list is the same surface clients query, so freshness here matters as much as for the guild map.
Tool catalog
The gateway doesn’t define its own tools. On startup (and after
re-init), it picks one arbitrary backend, calls tools/list on it,
and caches the result. Every tool schema is mutated in flight to add
an instance property:
"instance": {
"type": "string",
"description": "Bot instance name to route to, matching a key in the INSTANCES env var (e.g., 'bot_a', 'bot_b'). If omitted, routes by guild_id."
}
The gateway also appends its own synthetic tool:
list_instances— returns a text blob listing every registered backend, its online/offline status, and the guilds it’s currently known to serve. Clients call this to discover the topology.
Because all instances run the same binary, their tool catalogs are identical, so asking one backend for tools is sufficient. If you ever run backends with mismatched tool sets, you’d need to change the catalog-building logic to union them.
Authentication
The gateway supports a single bearer token via the MCP_AUTH_TOKEN
environment variable, enforced by an axum auth_middleware in
server.rs. Every request must carry Authorization: Bearer <token>
or it’s rejected with 401.
Because the gateway always binds 0.0.0.0:GATEWAY_PORT (so sibling
containers on the Docker network can reach it), there is no loopback
escape hatch. Running it without a token would expose every
backend’s destructive Discord tools (ban, delete-channel,
send-message, …) to anyone with network reach. To make that
impossible to do by accident, mcp-gateway/src/main.rs panics at
startup if MCP_AUTH_TOKEN is missing or empty
(config.auth_token.is_none()), with a message naming the risk.
Local development inside the same compose network still works — the
operator just has to set a token, even if it’s a throwaway one.
The gateway uses a single shared-secret model: the same
MCP_AUTH_TOKEN the middleware verifies on incoming requests is
forwarded as Authorization: Bearer <token> on every outgoing
request to a backend (BackendClient::auth_token, set from
GatewayState::new). Backends in the bundled docker-compose deploy
bind 0.0.0.0:9090 so the gateway sidecar can reach them over
Docker DNS, and the bot-side strict guard therefore forces them to
require a token of their own. One secret both sides share — the
gateway verifies it inbound and forwards it outbound — keeps the
configuration to one value and matches what the backend’s
constant-time comparison expects.
One implementation detail worth knowing about: the gateway sends an
explicit Host: localhost:9090 header on every outgoing request,
overriding the Docker service name reqwest would otherwise use. The
backend’s rmcp::StreamableHttpService enforces an allowlist on the
incoming Host header as DNS-rebinding protection; the default
allowlist contains only loopback names. Without the override, the
backend would reject every gateway request with 403 Forbidden: Host header is not allowed.
Claude Code’s current MCP client prefers OAuth 2.1 over bearer tokens for remote servers, so running the gateway as a Claude Code remote-server target is more work than bearer auth suggests. Support for OAuth 2.1 in the gateway is tracked as future work.
Deployment topology
The gateway runs in its own Docker container from mcp-gateway/Dockerfile,
alongside the bot containers. Its compose service (in the project’s
top-level docker-compose.yml) declares depends_on: bot with
condition: service_healthy, so the gateway doesn’t start until at least
one backend is reachable. It binds its port to 127.0.0.1:9100 by
default, keeping it local; operators who want remote MCP access
typically front it with a reverse proxy that terminates TLS and adds
whatever authentication their environment needs.
The health-check side uses the backend’s curl on
http://localhost:9090/mcp to decide when the bot is ready to proxy
requests to — a simple 2xx check on the HTTP endpoint.
Future work
- OAuth 2.1 support. Bearer tokens are fine for scripts, but Claude Code’s remote-server transport really wants OAuth. Adding an OAuth 2.1 code-flow endpoint to the gateway is the main gap before it’s ready for general consumer use.
- Dynamic instance registration. Today
INSTANCESis read once at startup. An admin API to add/remove backends at runtime would avoid the restart cycle. - Per-client sessions. The gateway collapses every client onto one synthetic session. Real per-client sessions would allow tool-call cancellation and progress streaming.
- Streaming tool responses. The current proxy waits for the full result from the backend and then sends one SSE frame back. Real streaming would let backends emit progress events for long-running tools.
Cross-links
- MCP Server — the user-facing description of what MCP does in this project.
- MCP Tool Catalog — the full list of tools the gateway exposes.
- MCP Exposure — deployment patterns for running the gateway on a public host.
- Multi-Instance Model — the deployment model the gateway was built for.
Database Schema
The bot’s persistent state is small and simple. Every table is created
at startup by running sqlx::migrate! against versioned SQL files in
migrations/,
driven from
src/db/mod.rs.
All tables live inside one Postgres schema picked by the DB_SCHEMA
environment variable and are read or written through helper functions
in
src/db/queries.rs.
This page enumerates every table, its columns, who writes to it, and
how the schema is bootstrapped.
Schema isolation recap
As described in Multi-Instance Model, the
bot operates inside a Postgres schema whose name comes from DB_SCHEMA.
At startup, init_pool creates that schema if it doesn’t exist, then
configures the pool to run SET search_path TO "<schema>" on every new
connection. From that point on, every unqualified table reference in
the query layer resolves inside the instance’s own schema. One Postgres
server can host as many instances as you like without any table
collisions or per-query filtering.
ER diagram
erDiagram
tempbans {
SERIAL id PK
TEXT guild_id
TEXT user_id
TEXT moderator_id
TEXT reason
TIMESTAMPTZ banned_at
TIMESTAMPTZ expires_at
BOOLEAN unbanned
}
guild_settings {
TEXT guild_id PK
TEXT audit_log_channel_id
TEXT dj_role_id
BOOLEAN dj_mode_enabled
}
stock_portfolios {
TEXT guild_id PK
TEXT user_id PK
NUMERIC cash_balance
TIMESTAMPTZ created_at
}
stock_holdings {
SERIAL id PK
TEXT guild_id
TEXT user_id
TEXT symbol
NUMERIC quantity
NUMERIC avg_cost
}
stock_transactions {
SERIAL id PK
TEXT guild_id
TEXT user_id
TEXT symbol
TEXT action
NUMERIC quantity
NUMERIC price_per_share
NUMERIC total_amount
TIMESTAMPTZ created_at
}
stock_price_cache {
TEXT symbol PK
DOUBLE price
DOUBLE prev_close
DOUBLE change_pct
TIMESTAMPTZ fetched_at
}
member_activity {
TEXT guild_id PK
TEXT user_id PK
INTEGER message_count
TIMESTAMPTZ first_seen
BOOLEAN promoted
}
stock_portfolios ||--o{ stock_holdings : "owns"
stock_portfolios ||--o{ stock_transactions : "records"
Relationships shown in the diagram are conceptual: there are no actual
foreign keys in the schema. Every stock table carries (guild_id, user_id)
as a denormalised composite, and the bot enforces consistency at the
query layer (inside sqlx transactions for multi-table writes). This
decision keeps migrations simple and makes per-guild data easy to
delete in bulk.
Tables
tempbans
Tracks temporary bans so the unban worker can restore users when their
ban expires. Feature: moderation (!m ban, !m unban, !m banlist).
| Column | Type | Notes |
|---|---|---|
id | SERIAL PRIMARY KEY | Auto-incrementing row ID |
guild_id | TEXT NOT NULL | Discord guild ID as a string |
user_id | TEXT NOT NULL | Banned user’s Discord ID |
moderator_id | TEXT NOT NULL | Who issued the ban |
reason | TEXT | Optional, free-form |
banned_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | When the ban was issued |
expires_at | TIMESTAMPTZ NOT NULL | When the ban should be lifted |
unbanned | BOOLEAN NOT NULL DEFAULT FALSE | Set TRUE when the user has been unbanned (either by worker or manual) |
Index: idx_tempbans_active on (guild_id, expires_at) WHERE unbanned = FALSE.
This is a partial index — it only covers active bans — so the unban
worker’s WHERE unbanned = FALSE AND expires_at <= NOW() sweep reads
a small working set even in guilds with a long ban history.
Writers:
create_tempban
(from !m ban or the tempban AI tool),
mark_unbanned
(from !m unban),
mark_unbanned_by_id
(from the background unban worker in main.rs).
guild_settings
Per-guild configuration that’s mutable at runtime: audit log channel,
DJ role, DJ mode toggle. Feature: admin (!m setlog, !m djrole,
!m djmode) and moderation (uses the audit log channel).
| Column | Type | Notes |
|---|---|---|
guild_id | TEXT PRIMARY KEY | Discord guild ID |
audit_log_channel_id | TEXT | Where moderation actions get logged |
dj_role_id | TEXT | Role required when DJ mode is on |
dj_mode_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Restrict music commands to the DJ role |
Writers: the admin commands write via
upsert_guild_setting (string values) and upsert_guild_setting_bool
(boolean values). Both functions whitelist their column name against
an ALLOWED_COLUMNS list in Rust before constructing the SQL, which
is how the bot safely takes the column name as a parameter.
stock_portfolios
Virtual cash balance for the stock-trading game. One row per
(guild, user) pair. Feature: stocks.
| Column | Type | Notes |
|---|---|---|
guild_id | TEXT NOT NULL | Part of composite PK |
user_id | TEXT NOT NULL | Part of composite PK |
cash_balance | NUMERIC(18, 4) NOT NULL DEFAULT 1000.0000 | Everyone starts with $1000 virtual. Migrated from DOUBLE PRECISION in 20260414000001_stocks_decimal.sql so cents don’t drift over fractional-share trades |
created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | When the portfolio was created |
Primary key is (guild_id, user_id). The portfolio row is created on
demand by get_or_create_portfolio using INSERT ... ON CONFLICT DO NOTHING followed by a SELECT.
stock_holdings
Share ownership: which symbols a user holds, how many, at what average cost. Feature: stocks.
| Column | Type | Notes |
|---|---|---|
id | SERIAL PRIMARY KEY | Auto-incrementing row ID |
guild_id | TEXT NOT NULL | |
user_id | TEXT NOT NULL | |
symbol | TEXT NOT NULL | Ticker symbol, e.g. AAPL |
quantity | NUMERIC(18, 4) NOT NULL DEFAULT 0.0000 | Shares held (fractional allowed). NUMERIC for exact arithmetic — see migration 20260414000001_stocks_decimal.sql |
avg_cost | NUMERIC(18, 4) NOT NULL DEFAULT 0.0000 | Weighted average price paid. NUMERIC so the weighted-average upsert stays exact across many trades |
Unique constraint: UNIQUE (guild_id, user_id, symbol) — one row
per symbol per user per guild.
Index: idx_stock_holdings_user on (guild_id, user_id) for
portfolio lookups.
Writers: buy_stock uses an upsert that recalculates
avg_cost as a weighted average:
avg_cost = (old_avg * old_qty + new_price * new_qty) / (old_qty + new_qty)
sell_stock either reduces the quantity or deletes the row when the
remaining amount is exactly zero (Decimal::is_zero() — replaces the
old float-epsilon < 0.0001 guard now that arithmetic is exact).
All three mutating paths — buy_stock, sell_stock, and
reset_portfolio — run inside a sqlx transaction that begins with
a SELECT cash_balance FROM stock_portfolios WHERE ... FOR UPDATE
on the relevant (guild_id, user_id) portfolio row. That row-level
lock serialises every concurrent action against one user’s
portfolio, so a !m stock reset running at the same time as a
!m stock sell (or a parallel buy from the AI tool path) cannot
interleave their reads of cash_balance and produce divergent
totals. The cash update, holdings update, and transaction-log
insert all commit atomically with that lock held.
stock_transactions
Immutable audit log of every buy/sell. Feature: stocks.
| Column | Type | Notes |
|---|---|---|
id | SERIAL PRIMARY KEY | Row ID |
guild_id | TEXT NOT NULL | |
user_id | TEXT NOT NULL | |
symbol | TEXT NOT NULL | |
action | TEXT NOT NULL | 'BUY' or 'SELL' |
quantity | NUMERIC(18, 4) NOT NULL | NUMERIC since 20260414000001_stocks_decimal.sql |
price_per_share | NUMERIC(18, 4) NOT NULL | NUMERIC since 20260414000001_stocks_decimal.sql |
total_amount | NUMERIC(18, 4) NOT NULL | quantity * price_per_share, computed in Rust with rust_decimal::Decimal so the audit log matches the books exactly |
created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() |
Index: idx_stock_transactions_user on
(guild_id, user_id, created_at DESC) so !m stock history can
fetch the most recent N trades without scanning.
stock_price_cache
Short-lived price cache to avoid hammering the Finnhub API. Feature: stocks.
| Column | Type | Notes |
|---|---|---|
symbol | TEXT PRIMARY KEY | |
price | DOUBLE PRECISION NOT NULL | Last known price |
prev_close | DOUBLE PRECISION NOT NULL | Previous day’s close |
change_pct | DOUBLE PRECISION NOT NULL | Day-over-day change percentage |
fetched_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Cache insertion time |
TTL is enforced at query time: get_cached_price uses
WHERE fetched_at > NOW() - INTERVAL '60 seconds', so anything older
than 60 seconds is ignored and a fresh quote is fetched from the
upstream API. There’s no separate eviction job.
This table intentionally stays DOUBLE PRECISION even after the
stock_* Decimal migration — it’s a short-lived display cache, never
fed into portfolio arithmetic without first being converted to
Decimal at the API boundary in stocks::api::get_quote.
member_activity
Tracks messages sent per user per guild, for the auto-role promotion feature. Feature: auto-role.
| Column | Type | Notes |
|---|---|---|
guild_id | TEXT NOT NULL | Part of composite PK |
user_id | TEXT NOT NULL | Part of composite PK |
message_count | INTEGER NOT NULL DEFAULT 0 | Running total of messages sent |
first_seen | TIMESTAMPTZ NOT NULL DEFAULT NOW() | When this user first sent a message we counted |
promoted | BOOLEAN NOT NULL DEFAULT FALSE | Has the auto-role worker already promoted them |
Primary key is (guild_id, user_id). increment_message_count uses
INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING * so the caller
gets the latest counts in one round trip, which is what the message
handler uses to decide whether to queue a promotion attempt. See
Auto-Role for the thresholds.
The two promotion paths — the per-message scanner inside
handle_message and the 60-second background loop — would otherwise
race on the same user when they happen to fire close together. The
try_promote query closes that race with a single atomic claim:
UPDATE member_activity SET promoted = TRUE WHERE guild_id = $1 AND user_id = $2 AND promoted = FALSE RETURNING .... Only one of the
concurrent updates returns a row; the other returns nothing and
exits silently. The caller that wins the claim is the one that
performs the actual Discord role add, so a member is never
double-promoted and never sees two welcome reactions.
What’s not stored
A lot of state the bot manages lives entirely in memory and never
touches the database. Music queues, active Wordle and Connections
games, rate-limit counters, idle timers, and tempban cache are all
DashMap-based state on Data. Restarting the bot loses all of them,
and that’s intentional — persisting a music queue across restarts
would create more problems than it solves (stale URLs, replayed
commands, surprise audio when the bot rejoins). Games are re-started
by users on demand; rate limits reset cleanly; idle timers are
disposable.
The things in the database are the things that must survive a restart: tempbans (so the worker can still unban someone), portfolios and trades (real virtual money), auto-role counters (so promotions happen at the right time), and DJ-mode settings (so operators don’t have to reconfigure after every deploy).
Migrations
Migrations live in the top-level
migrations/
directory and are applied with the compile-time
sqlx::migrate!
macro. init_pool in src/db/mod.rs sets search_path on every
connection and then calls sqlx::migrate!("./migrations").run(&pool),
so each instance tracks its own migration history in a
_sqlx_migrations table inside its own schema. Migrations are
embedded in the binary at build time — no DATABASE_URL is needed at
build time, and there’s no sqlx-data.json to regenerate.
File naming. Each file is <timestamp>_<description>.sql. Use a
sortable UTC timestamp (YYYYMMDDHHMMSS) so sqlx applies them in the
right order. The bootstrap migration is
20260414000000_init.sql.
Adding a new migration. Drop a new file into migrations/ with a
later timestamp — for example,
20260501120000_add_user_timezone.sql — and include whatever DDL the
change needs (ALTER TABLE, CREATE TABLE, backfill UPDATEs, etc).
On the next startup, sqlx runs any unapplied migrations in order and
records each one in _sqlx_migrations. Do not edit an existing
migration file after it has been deployed — sqlx checksums the file
contents and a mismatch aborts startup.
Existing-database compatibility. The init migration keeps
IF NOT EXISTS on every CREATE so it is idempotent against the
pre-migration databases that already have all the tables (production
examplebot and secondbot). On those databases the init migration is a no-op
at the SQL level; sqlx still writes the _sqlx_migrations row
afterwards, so later migrations see a normal history.
Schema evolution (renaming a column, adding a NOT NULL default,
dropping a table) is now a new migration file rather than a manual
psql session. Destructive changes still deserve a release note in
CHANGELOG
and the Upgrading page.
Connection pool
The pool is a sqlx::PgPool built once in main.rs and handed to
Data::db. Every feature module holds an &PgPool (or clones the pool
— cloning is cheap because it’s just an Arc bump) and uses sqlx’s
async query methods directly. There’s no repository layer, no DAO, no
ORM — queries are SQL strings in src/db/queries.rs with typed
parameter binding and FromRow deserialisation.
Most queries use query_as::<_, Model> for typed reads and query
for writes. The compile-time-checked macros (query!, query_as!)
aren’t used here because they’d require sqlx-cli to generate
sqlx-data.json against a live database as part of the build, and
the project prefers a simpler Docker build.
Backups
Backup strategy is owned by the Postgres container, not the bot. See
PostgreSQL Setup for the
recommended approach. Because every instance’s data lives in one
schema, pg_dump --schema=<schema> gives you a clean per-instance
backup, and dropping or restoring one schema leaves the others
untouched.
Cross-links
- Multi-Instance Model — why the schema boundary is the multi-tenancy line.
- PostgreSQL Setup — deployment, tuning, backup.
- Data Flow — how commands reach the pool from event handlers.
Error Handling
This page describes how errors flow through the bot: where they start,
where they get turned into user-visible messages, and where they’re
logged and swallowed. The design is deliberately minimal — one error
type, one on_error hook, and a handful of rules about who panics and
who doesn’t.
The BotError type
Every fallible function in this codebase returns Result<T, BotError>,
where BotError is a plain hand-written enum defined in
src/error.rs:
#[derive(Debug)]
pub enum BotError {
Serenity(serenity::Error),
Sqlx(sqlx::Error),
Reqwest(reqwest::Error),
SerdeJson(serde_json::Error),
Other(String),
}
Each variant wraps one upstream error type. The fifth variant, Other,
is an escape hatch for ad-hoc string errors that don’t correspond to a
specific upstream — BotError::Other("Not in a guild".into()) is a
common pattern when a command can’t proceed because of a missing
argument or a precondition failure.
Conversions live right next to the definition as From impls:
impl From<serenity::Error> for BotError { ... }
impl From<sqlx::Error> for BotError { ... }
impl From<reqwest::Error> for BotError { ... }
impl From<serde_json::Error> for BotError { ... }
impl From<String> for BotError { ... }
These From impls are the reason command handlers can use ?
everywhere. let expires_at = create_tempban(...).await?; turns a
sqlx::Error into a BotError::Sqlx and bubbles up, without the
handler having to know what create_tempban can fail with. The enum
implements std::error::Error and Display, so errors also format
sensibly when logged.
There’s no thiserror, no anyhow, no derived From. The enum is
small enough that hand-writing the impls is cleaner than pulling in a
macro dependency, and the explicitness makes it obvious what kinds of
errors the bot actually handles.
Where errors are surfaced
Command errors. Poise ties every handler’s return value to its
framework error hook. A command returning Err(BotError::Sqlx(...))
or Err(BotError::Other("...".into())) raises a
FrameworkError::Command, which the hook turns into a user-facing
reply and a tracing log. That hook lives in main.rs and now uses
BotError::user_message() to keep operator-only details out of
chat:
on_error: |error| Box::pin(async move {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
// Full error (including upstream sqlx/reqwest text) goes
// to logs only.
tracing::error!("Command error: {error}");
// Sanitised, per-variant copy goes to the user.
let _ = ctx.say(error.user_message()).await;
}
other => {
tracing::error!("Framework error: {other}");
}
}
})
The split between Display and user_message() is deliberate:
Displaystill produces the verbose form ("Database error: <sqlx message>","HTTP error: <reqwest message>", …) and is what gets logged. It carries every byte of upstream context an operator might want to grep for.user_message()returns a fixed, generic per-variant string (“Something went wrong talking to the database. Please try again later.”, “Couldn’t reach an external service. Please try again.”, …) — except forOther(s), which is treated as already-curated copy and passed through verbatim. That last case is what makes short messages like"Not in a guild"and validation errors still surface naturally.
The user sees the short, friendly form. The operator sees the full upstream chain in the logs and can correlate by timestamp.
Other FrameworkError variants (permission denied, argument parse
failure, missing subcommand) are logged but not replied to. The
default poise behaviour is to post a short notice for some of these;
the current hook is deliberately minimal, because command errors are
rare and usually only interesting to operators.
Event handler errors. Event handlers (message handler, voice
state, component interactions, member join) don’t return Result in
the usual sense. The top-level event_handler function returns
Result<(), BotError>, but it never actually returns Err — every
sub-handler uses let _ = ... to swallow individual errors and
continues. This is because an event handler has no good place to post
an error: the “user” who triggered the event might be a raw gateway
event like a voice state update, not a chat message, so there’s
nothing to reply to.
Instead, event handlers emit tracing::error! or tracing::warn!
at the site of the failure. For example, the auto-role promotion
path inside handle_message spawns a task that logs via
tracing::warn!("Auto-role promotion failed for {}: {}", ...). The
user sees nothing; the operator sees the error in the logs.
Background task errors. main.rs spawns several long-running
tasks (rate-limiter cleanup, tempban unban sweep, auto-role
time-based check, donator sync). Each iteration body runs inside the
run_supervised helper, which wraps it in
AssertUnwindSafe(...).catch_unwind(). A panic inside one iteration
is caught and logged via tracing::error! with the task name and
panic payload, and the outer loop continues to the next iteration —
“a background task should never take the bot down” is now enforced
by the wrapper, not just by convention. Recoverable errors inside the
body still use the same tracing::warn! / tracing::error! pattern
and continue. See
Concurrency Model: background task supervision
for the JoinSet plumbing and graceful-shutdown story.
The AI pipeline. handle_mention doesn’t return a Result at
all. It uses pattern matching and explicit return statements to
exit on failure paths, and posts its own user-visible messages for
things like “Something went wrong talking to the AI.” This is by
design: the pipeline has too many recoverable states (classifier
failure, vision failure, censored response, search failure) for the
? operator to express naturally, so it handles each one explicitly.
The tool dispatch paths used to compose user-facing replies as
message.reply(format!("Database error: {e}")) (and similar for
reqwest/serde_json/MCP errors), which leaked the same internal
detail the command path now hides. Those reply sites have all been
swept to use the same generic copy as BotError::user_message(),
with the full upstream error logged via tracing::error! carrying
the failing tool name and guild ID for operator diagnostics.
Debug vs production
What gets logged and what gets shown to users is split on purpose:
- Logs (
tracing::error!,tracing::warn!): the fullDisplayform ofBotError, including every byte of upstream context. These go to stderr and whatever log aggregator the operator has set up.tracing_subscriber::fmt::init()inmain.rsis the default config — override withRUST_LOGto raise or lower the level. - User messages (
ctx.say(...),message.reply(...)): the output ofBotError::user_message(). A failed DB query becomes"Something went wrong talking to the database. Please try again later."; a flaky upstream API becomes"Couldn't reach an external service. Please try again.". The user knows something is broken and that retrying is reasonable, but no SQL fragment, hostname, or serde path leaks to chat.
The split matters because upstream error messages can contain
information that shouldn’t be in Discord — file paths, internal
hostnames, table names, partial stack traces from dependencies. The
old format!("Error: {e}") path leaked all of that whenever a
handler bubbled up a BotError::Sqlx or BotError::Reqwest; the
user_message() mapping closes that gap. The same swept the
BotError::Other(format!("...{e}")) pattern out of the AI tool
dispatch paths so a failing DeepSeek call can’t smuggle a raw HTTP
body into chat by way of Other.
Panics
Panics are reserved for one specific case: startup config validation.
The get_env_or_throw helper in
src/config.rs
panics if a required environment variable is missing or contains a
placeholder value:
fn get_env_or_throw(key: &str) -> String {
let val = env::var(key).unwrap_or_else(|_| panic!("{key} must be set in .env"));
if val.starts_with("your-") {
panic!("{key} has placeholder value — set it in .env");
}
val
}
This is used for DISCORD_TOKEN, CLIENT_ID, and GUILD_ID — the
three variables without which the bot literally cannot connect. A
missing value there is a deployment error that the operator needs to
see immediately, in the clearest possible way, before the process
starts doing real work. The panic message ends up in the process
output and the operator fixes it.
The database_url, MCP bind config, and AI API keys do not panic.
They fall back to defaults or stay unset, and the features that need
them either disable themselves or warn at first use.
Optional config is never a panic. When config.toml has a
feature enabled but its corresponding [feature_name] section is
missing, main.rs warns through tracing::warn!(...) and skips the
feature. For example:
let auto_role_config = if instance_cfg.features.auto_role {
match &instance_cfg.auto_role {
Some(cfg) => { /* log, enable */ Some(cfg.clone()) }
None => {
tracing::warn!("Auto-role feature enabled but [auto_role] config section missing");
None
}
}
} else {
None
};
The same pattern repeats for the minecraft donator-sync config, the chargeback config, the join-role config, and the welcome prompt file. Missing optional config is always a warning plus a disabled feature, never a crash. Operators can ship a bot with half its features half-configured and it’ll still start — the log just tells them what they missed.
Runtime panics elsewhere — inside a command handler, an event handler, or a background task — are considered bugs. If one happens, Tokio will catch the task panic and log it, and the rest of the runtime will keep going. The user whose command triggered the panic sees nothing, which is unpleasant but better than the process exiting.
Cross-links
- Data Flow — the shape of the call chain that produces these errors in the first place.
- Debugging — how to read the logs and track a failure back to its source.
- Environment Variables — the required variables whose absence produces a startup panic.
Concurrency Model
discord-bot-rs is a single-process async application. It handles many simultaneous guilds, commands, and background tasks on one Tokio runtime, without global locks. This page explains how it stays correct under concurrent load: which data structures it leans on, where mutex boundaries sit, and which patterns to copy when you’re adding a new feature.
The design rule is simple: locks are the last tool, not the first.
Where a feature can get away with a lock-free concurrent map, it does.
Where it needs serialised access within a single guild or channel, it
uses a narrow tokio::Mutex around the minimum amount of state. No
feature in the codebase holds a mutex across a network call, and there
is no global RwLock<HashMap>-style state anywhere.
Tokio runtime
main.rs starts the app with #[tokio::main], which gives it a
multi-threaded runtime using one worker thread per CPU by default.
Everything the bot does — gateway events, HTTP requests, DB queries,
yt-dlp subprocesses, MCP server, axum webhook router, background
workers — runs as async tasks on this single runtime. There are no
other runtimes, no threads spawned by std::thread::spawn, and no
blocking I/O outside of spawn_blocking (which isn’t currently used
anywhere).
The result is that scheduling decisions are centralised: Tokio can
starve a slow task without blocking the rest, and cargo run boots
into a fully functional bot without any threading ceremony.
The Data struct as shared state
Poise’s framework gives every command and event handler a reference to
a user-defined state object. In this project that’s Data, defined at
the top of
src/main.rs.
It’s built once at startup, wrapped in an Arc by poise, and handed
out to every handler via poise::Context. Cloning an Arc<Data> is
a single atomic refcount bump, so passing it into a spawned task is
free.
Inside Data, the read-only fields (db, http_client, config,
personality, bot_name, all the optional feature configs) are
accessed concurrently without any locking — they’re either Arc-
shared resources (the pool, the HTTP client) or immutable owned
strings. The interesting parts are the mutable per-guild and
per-channel maps.
DashMap for per-guild state
Six of Data’s fields are DashMap-based:
| Field | Shape | Feature |
|---|---|---|
guild_players | DashMap<GuildId, Arc<Mutex<GuildPlayer>>> | Music |
track_handles | DashMap<GuildId, TrackHandle> | Music |
now_playing_msgs | DashMap<GuildId, Arc<Mutex<Option<MessageId>>>> | Music |
idle_timers | DashMap<GuildId, Arc<Mutex<Option<JoinHandle<()>>>>> | Music |
connections_games | DashMap<ChannelId, Arc<Mutex<ConnectionsGame>>> | Games |
wordle_games | DashMap<ChannelId, Arc<Mutex<WordleGame>>> | Games |
DashMap is a sharded concurrent
hash map — internally it splits keys across a fixed number of shards,
each with its own RwLock. Lookups of different keys hit different
shards and don’t block each other. This is the shape of the workload
here: two guilds’ music commands land on different DashMap shards and
run concurrently; even two lookups inside the same guild’s DashMap
won’t block because the inner value is Arc<Mutex<T>> and the outer
map only holds the Arc.
Why not Arc<RwLock<HashMap<GuildId, T>>>? Because every write — a
user starts a song in guild A — would have to take the write lock on
the outer map, and every read from guild B would either have to wait
or hold a read lock that blocks guild C’s write. DashMap eliminates
that global contention by design.
Per-guild Mutex<T> inside DashMap
DashMap gives concurrent key-level access. Once a handler has its
guild’s value in hand, it needs a way to serialise access inside that
guild — because a music player is a single state machine and you
don’t want the skip button to race with the play command. The
pattern is to store Arc<Mutex<T>> as the value:
let player_arc = data.guild_players
.entry(guild_id)
.or_insert_with(|| Arc::new(Mutex::new(GuildPlayer::new(guild_id))))
.value()
.clone();
// Drop the DashMap entry guard before awaiting the inner mutex
let mut player = player_arc.lock().await;
player.enqueue(track);
Two important details:
- Release the DashMap guard before the await.
entry(...).value()returns a guard that holds the DashMap shard’s lock. Holding it while you.awaiton the inner mutex would hold up other handlers that need the same shard. The idiom is to clone the innerArcout and let the guard drop. - Use
tokio::sync::Mutex, notstd::sync::Mutex. Tokio mutexes are designed to be held across.awaitpoints; std mutexes are not. A handler that’s holding astd::sync::Mutexacross an.awaitcan deadlock the whole runtime if Tokio happens to schedule the task back onto the thread that’s blocked on the same mutex. Every mutex in this codebase is a tokio mutex.
This pattern gives fine-grained concurrency: two guilds can run music commands in parallel, two channels can run Wordle games in parallel, and within one guild the music player is still serialised. No feature module has to coordinate with another, because they use different DashMaps.
Idle timers
The music idle timer pattern in
src/music/voice.rs
is a good example of how to manage cancellable background work in this
style. When a track ends and the queue is empty, the track-end handler
calls start_idle_timer, which:
- Spawns a task that sleeps 300 seconds, then leaves the voice channel and cleans up.
- Stores the task’s
JoinHandleinsideData::idle_timersat the guild’s entry.
When the next track starts — or the user calls !m stop — the code
calls cancel_idle_timer, which takes the handle out of the mutex and
calls .abort() on it. Cancellation is atomic: either the task
already ran and there’s nothing to abort, or it was sleeping and the
.abort() drops its future.
The idle_timers DashMap’s value type is
Arc<Mutex<Option<JoinHandle<()>>>>. The Option is there because a
guild can be in the map without having an active timer (the slot
exists but is empty). The Mutex protects the slot from the “start a
new timer while the old one is being cancelled” race.
Rate limiting
src/util/ratelimit.rs
implements a sliding-window limiter using — unsurprisingly — a
DashMap:
pub struct SlidingWindowLimiter {
buckets: DashMap<String, Vec<Instant>>,
max_requests: usize,
window: Duration,
}
The key is arbitrary (in practice, user_id.to_string()). The value
is a vector of timestamps. When check is called, it prunes
timestamps older than the window, then either returns 0 (allowed,
append the current timestamp) or the seconds until the oldest
timestamp expires (rate limited).
Because DashMap::entry gives unique access to one slot, two
concurrent check calls for the same user serialise naturally
through the entry guard. Two calls for different users land on
different shards and don’t block each other.
Data::rate_limiters holds five of these, all enforced:
ai— 10 requests per 60 seconds, used by the AI chat pipeline.music— 15 requests per 30 seconds, enforced on every!mmusic command and every AI music tool call.moderation— 5 requests per 60 seconds. Enforced both by the AI pipeline’s moderation tool execution path and by the prefix!m ban/!m unban/!m nukecommands. (Discord-side permission checks still apply on top.)stocks— 10 requests per 30 seconds, enforced on every!m stockcommand and every AI stock tool call.welcome— 1 event per 5 seconds per joining user. Throttles the join flow so a fast-rejoining account can’t spam the welcome prompt or AI greeting.
Bucket cleanup
Every check call inserts a vector of timestamps into the limiter’s
DashMap entry, but nothing removes empty entries on its own. Without
periodic eviction, memory would grow with the unique-user count over
the lifetime of the process. To fix that, main.rs spawns a
rate_limiter_cleanup background task that calls
RateLimiters::cleanup_all() every 5 minutes. cleanup_all walks all
five limiters, prunes timestamps older than each window, and drops
entries that are now empty. This keeps the steady-state memory
footprint proportional to the active user count rather than the
all-time-unique user count.
Background task supervision
main.rs spawns several long-running loops (rate-limiter cleanup,
tempban unban sweep, auto-role time check, donator sync). They used to
be plain tokio::spawn calls with no panic recovery — a single panic
inside the loop body would silently kill the whole task and the
feature would simply stop working until the next restart, with nothing
in the logs to tell the operator what happened.
The current pattern has two layers:
- Per-iteration panic recovery. Every loop body runs inside the
run_supervised(task_name, || async { ... })helper defined at the top ofmain.rs. The helper wraps the body inAssertUnwindSafe(...).catch_unwind()so a panic inside one iteration is caught, logged viatracing::error!with the task name and panic payload, and then swallowed. The outerloop { ... sleep ... }continues to the next iteration. A bug in one tempban sweep doesn’t break tomorrow’s sweeps. - Task-level tracking via
JoinSet. Background tasks are spawned into aJoinSet<()>owned bymain(). A separate task awaitsjoin_nextin a loop and logs aterrorlevel if any supervised loop ever exits — which, with the panic-recovery wrapper in place, should never happen. If it does, the operator knows immediately rather than waiting to notice the missing behaviour.
Graceful shutdown
main.rs races client.start() against a shutdown_signal() future
inside tokio::select!. shutdown_signal() resolves on Ctrl-C, and
on unix it also resolves on SIGTERM (so docker stop and kill
are honoured). When the signal fires, shard_manager.shutdown_all()
is called before exit, which closes the gateway shards cleanly and
gives songbird a chance to tear down voice connections instead of
leaving them dangling on the Discord side.
Database pool concurrency
Sqlx’s PgPool is itself concurrent: it holds a bounded number of
connections, hands them out to tasks that need them, and queues
waiters when the pool is saturated. A handler that awaits a query
yields the task to Tokio until a connection is free; no thread is
blocked. That means running 50 simultaneous commands on 5 Postgres
connections is fine — 45 of them will be parked, waiting their turn,
while other tasks on the runtime continue unhindered.
Because the after_connect hook sets search_path per connection
(see Multi-Instance Model), every query
transparently lands in the right schema without per-query
parameterisation. There is no per-query lock; sqlx handles
concurrency through the pool.
Voice concurrency
Songbird runs its own audio processing inside the Tokio runtime. It
spawns internal tasks for gateway traffic, UDP packets, Opus
encoding, and track event dispatch. The bot’s main event handlers
only talk to songbird through its API: manager.join,
handler.play_input, handler.stop, and track_handle.add_event.
All of those are non-blocking control calls. The actual audio
pipeline runs on background tasks owned by songbird, so a slow
handler on the main runtime doesn’t stutter playback.
What NOT to do
A few patterns are actively avoided:
- Don’t use
std::sync::Mutexin async code. As explained above, holding one across an.awaitcan deadlock the runtime. The only place in the codebase that usesstd::syncprimitives isAtomicBool, which is lock-free. - Don’t hold a DashMap entry guard across an
.await. Clone theArcout and release the guard first. This keeps shard-level contention short and avoids mysterious stalls. - Don’t invent a global
RwLock<HashMap<GuildId, T>>. Use DashMap. If you find yourself wanting a global lock, reconsider the shape of your state: it probably should be keyed by guild or channel. - Don’t block in a handler. Anything that would normally block
(reading a file, running a subprocess, parsing a big input) should
either be async (
tokio::fs,tokio::process) or wrapped intokio::task::spawn_blocking. Theyt-dlpintegration goes throughtokio::process::Command, which is the right pattern. - Don’t share
!Sendstate across tasks. Every tokio task must beSend, so anything held across.awaitinside a task must be too. Tokio’s mutex guards satisfy this;RcandRefCelldo not.
Cross-links
- Data Flow — the lifecycle that these patterns are serving.
- Music Pipeline — the most elaborate example of the patterns on this page.
- AI Pipeline — the other heavy user of the rate limiter and per-user state.
- Multi-Instance Model — why none of this contention crosses instance boundaries.
Deployment Overview
This section is the operations manual for discord-bot-rs. It covers
how to get a bot running on a real server, how to add a second one
later, how to back up the database, when to expose the MCP port, and
how to keep the whole thing alive long-term.
If you have not got a bot up locally yet, start with Quickstart and come back here once you are ready to put something on a host that lives longer than your laptop.
What ships in the box
The repo includes everything you need to deploy a single instance:
- A multi-stage
Dockerfilethat builds the bot binary onrust:bookwormand ships adebian:bookworm-slimruntime image withffmpeg,yt-dlp, Node.js (foryt-dlp’s JS challenges), and the Opus / libsodium shared libraries the voice stack needs. - A separate
mcp-gateway/Dockerfilefor the gateway service. - A top-level
docker-compose.ymlthat wires the bot, apostgres:17container, and the gateway together with health checks and named volumes. - An
instances/example/directory used as a fully-documented reference forconfig.toml,.env.example, andpersonality.txt.
There are also pre-built images on GitHub Container Registry —
ghcr.io/mrmcepic/discord-bot-rs:0.5.0 and :latest, plus
ghcr.io/mrmcepic/discord-bot-rs-mcp-gateway — for hosts where you
do not want to build from source. They are amd64-only at the moment.
Recommended path: Docker Compose
Docker Compose is the path the repo is designed around and the path most operators should use. It gets you the bot, Postgres, and the MCP gateway with one command, with sensible defaults for restart policy, health checks, persistent volumes, and network isolation. Almost every other page in this section assumes you are running under Compose.
The defining choice in the Compose file is that the bot service is
generic — it points at a configurable INSTANCE_DIR. The
default is ./instances/example, but you select your own with:
INSTANCE_DIR=./instances/mybot docker compose up -d
That single switch lets the same Compose file run any instance you
have configured under instances/. To run more than one bot at a
time you copy the bot block in the Compose file, give it a unique
service name, and point it at a different directory — see
Multi-Instance Deployment for the
recipe.
Other deployment shapes
You are not locked into Compose. The bot binary and the gateway are both standalone executables, and you can run them however your infrastructure prefers:
- Plain Docker —
docker runthe published images directly, bring your own Postgres, manage networking yourself. - Kubernetes — wrap the same images in a Deployment and a StatefulSet for Postgres. There is no Helm chart in-tree, but the shape is straightforward enough that you can write one in an hour.
- Bare metal —
cargo build --release, installffmpeg,yt-dlp,libopus,libsodium, and Node.js, run the binary as a systemd unit, point it at a system Postgres. The build dependencies are listed in the Dockerfile.
The rest of this section is written against Compose because that is where the hardening, health-check, and upgrade workflows are best defined. If you are running under one of the alternatives, the configuration knobs and operational concerns are the same — only the mechanics of “restart this container” change.
Pages in this section
| Page | When to read |
|---|---|
| Docker Compose | Setting up your first deployment, or whenever you change the stack. |
| PostgreSQL Setup | Choosing bundled vs external Postgres, planning backups, migrations. |
| Multi-Instance Deployment | Adding a second bot to an existing host. |
| MCP Exposure | Connecting an MCP client from outside the host. |
| Upgrading | Pulling a new version, planning around breaking changes. |
| Monitoring | Health checks, log aggregation, what failure looks like. |
| Production Checklist | One-pass hardening sweep before you stop watching the logs. |
If something on a page surprises you, the architecture pages — especially Multi-Instance Model and MCP Gateway Routing — explain why the deployment shape looks the way it does.
Docker Compose Deployment
Docker Compose is the default deployment path. The repo ships a
top-level docker-compose.yml
that brings up the bot, a postgres:17 database, and the MCP gateway
as a single coordinated stack. This page covers everything in that
file: what each service does, what the environment variables mean,
how the health checks compose, how to use a custom instance
directory, what volumes persist, and what to look at first when
something is broken.
If you have not run the stack at all yet, work through Quickstart first. This page assumes you have done that and want a deeper look at the moving parts.
The three services
services:
postgres: # PostgreSQL 17, bundled
bot: # the discord-bot binary
mcp-gateway: # routes MCP requests to one or more bots
volumes:
pgdata: # persistent storage for postgres
postgres and bot are both required for a working deployment.
mcp-gateway is only needed if you want an MCP client (Claude Code,
Cursor, etc.) to be able to drive the bot programmatically — but
since the gateway is harmless when nobody connects to it, it is
included by default and you can ignore it until you need it.
The postgres service
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: discord_bot
POSTGRES_PASSWORD: discord_bot_pass
POSTGRES_DB: discord_bot
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U discord_bot"]
interval: 5s
timeout: 5s
retries: 5
The official postgres:17 image, started with the discord_bot user
and discord_bot_pass password, owning a discord_bot database. The
data lives in a named Docker volume called pgdata, which means
docker compose down does not wipe the database — only
docker compose down -v (or an explicit docker volume rm) does.
The health check uses pg_isready so the bot’s depends_on: condition: service_healthy clause actually waits for Postgres to be
accepting connections, not just for the container to be running.
restart: unless-stopped means the container comes back after
reboots and after crashes, but not after you have explicitly stopped
it with docker compose stop.
Whether you should keep the bundled Postgres or point at an external one is covered in PostgreSQL Setup. Short version: bundled is fine for a single host running a handful of bots; switch to external when you have other apps that need the same database server, or when you want managed backups.
The bot service
bot:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: ${INSTANCE_DIR:-./instances/example}/.env
environment:
CONFIG_DIR: /config
volumes:
- ${INSTANCE_DIR:-./instances/example}:/config
tmpfs:
- /tmp:size=500M
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null --connect-timeout 2 http://localhost:9090/mcp"]
interval: 10s
timeout: 5s
retries: 12
The interesting parts:
INSTANCE_DIR is the only deploy-time switch you need. Both
env_file and volumes interpolate ${INSTANCE_DIR:-./instances/example},
so a single environment variable on the host shell selects which
instance directory feeds this container. The whole point of the
generic bot service is that you can run any instance configured
under instances/ without editing the Compose file:
INSTANCE_DIR=./instances/mybot docker compose up -d
If INSTANCE_DIR is unset, the default points at the
fully-documented instances/example directory, which is intended as
a reference rather than a real bot but will boot if you fill in its
.env.
/config is where the bot reads its configuration. Inside the
container, CONFIG_DIR=/config and the instance directory is
mounted at /config, so the bot finds config.toml,
personality.txt, the optional welcome prompt, and any
cookies.txt for music there. The .env is loaded separately by
Compose’s env_file directive, not by the bot reading
/config/.env — that is why the env file path and the volume mount
both reference the same INSTANCE_DIR.
/tmp is a 500 MB tmpfs. yt-dlp and ffmpeg write transient
files here during music playback. Putting /tmp on tmpfs avoids
hammering the host disk and ensures it is wiped on container
restart.
The bot waits for Postgres. depends_on: condition: service_healthy is the strict version — the bot container will not
start its main process until the Postgres health check is passing.
This avoids the otherwise-common race where the bot tries to open a
connection before Postgres is accepting them and crashes.
The health check hits the embedded MCP server. The bot’s MCP
server starts on port 9090 inside the container as a side effect of
the bot reaching its run loop. If the health check fails, the bot is
either not started yet, deadlocked, or has the MCP feature disabled
on a non-default port. The health check is also what mcp-gateway
waits for before it starts.
The Dockerfile itself is multi-stage: a rust:bookworm builder
compiles a release binary, and the runtime image is
debian:bookworm-slim with ffmpeg, yt-dlp, Node.js (for
yt-dlp’s JS challenge solving), and the runtime libraries the
voice stack needs (libopus, libsodium, libssl3).
The mcp-gateway service
mcp-gateway:
build:
context: ./mcp-gateway
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "127.0.0.1:9100:9100"
environment:
GATEWAY_PORT: "9100"
INSTANCES: "${INSTANCES:-bot=http://bot:9090}"
MCP_AUTH_TOKEN: "${MCP_GATEWAY_AUTH_TOKEN:-}"
RUST_LOG: "info"
depends_on:
bot:
condition: service_healthy
The gateway is a tiny axum server that fronts every bot’s embedded MCP endpoint and presents a single tool catalog to clients. The full design is in MCP Gateway Routing; operationally, the things that matter:
It binds to 127.0.0.1:9100 on the host. The gateway port is
deliberately localhost-only by default. To make it reachable from
outside the host, see MCP Exposure — the safe
patterns are SSH tunnels, WireGuard / Tailscale, or a TLS-terminating
reverse proxy.
INSTANCES is the routing table. The format is comma-separated
name=url pairs. The default is bot=http://bot:9090 — one
backend, called bot, reached over the internal Compose network.
For multiple bots you override it on the host shell:
INSTANCES="bot1=http://bot1:9090,bot2=http://bot2:9090" docker compose up -d
The names here are also the routing keys clients use when calling tools that need to specify which bot to act on. See Multi-Instance Deployment for the end-to-end pattern.
MCP_GATEWAY_AUTH_TOKEN is the shared secret for the whole MCP
fabric. The gateway refuses to start if it is empty — there is no
loopback escape hatch, because the gateway’s whole job is to be
reachable from outside its own container. The same value is:
- checked against the
Authorization: Bearer <token>header on every inbound request from an MCP client, and - forwarded as
Authorization: Bearer <token>on every outbound request from the gateway to a backend bot.
For that to work, each bot’s MCP_AUTH_TOKEN must be set to the
same value as MCP_GATEWAY_AUTH_TOKEN. A mismatch shows up as the
gateway logging 401 Unauthorized from the backend at startup.
Generate one secret with openssl rand -hex 32 and use it in both
places.
It depends on the bot’s health check. depends_on: condition: service_healthy ensures the gateway never starts before there is at
least one backend to talk to. With multiple bot services you would
list each in the depends_on block — the gateway will fail to
register guilds for instances that are not up.
Common operations
# Start everything in the background
docker compose up -d
# Start with a specific instance directory
INSTANCE_DIR=./instances/mybot docker compose up -d
# Tail the bot logs
docker compose logs -f bot
# Tail everything
docker compose logs -f
# Restart just the bot (after editing config.toml or .env)
docker compose restart bot
# Stop everything but keep data
docker compose down
# Stop and wipe the database (destructive)
docker compose down -v
# Pull a new bot image and re-create the container
docker compose pull bot && docker compose up -d bot
# Force a rebuild after editing the source
docker compose build bot && docker compose up -d bot
# See container health
docker compose ps
Restarting the bot is cheap (a few seconds) and safe — the database holds all persistent state, and in-memory state like music queues and rate-limit counters is intentionally disposable. See Database Schema: What’s not stored if you are curious about that boundary.
Networking
Compose creates a default bridge network for the project. Inside it,
services reach each other by service name: the bot connects to
Postgres at postgres:5432, the gateway connects to bots at
http://bot:9090 (or http://bot1:9090, http://bot2:9090 in a
multi-instance setup). None of these names exist on the host’s DNS;
they only resolve inside the Compose network.
The only port published to the host is the gateway’s
127.0.0.1:9100. Postgres and the bots are network-isolated by
default. If you want host access to Postgres for backups or psql,
add a ports: ["127.0.0.1:5432:5432"] block to the postgres
service, but think twice before binding it to 0.0.0.0.
Volumes and persistence
There is one named volume: pgdata. Everything the bot considers
worth keeping across restarts goes through Postgres: tempbans,
guild settings, stock portfolios, member-activity counters. See
Database Schema for the full
list.
Things that are not persisted: music queues, active games,
rate-limit counters, idle timers. These live on Data in the bot’s
process and reset on every restart. That is by design — see
Database Schema: What’s not stored.
The instance directory itself (instances/mybot/) is bind-mounted
read-only-ish from the host. Anything you change in config.toml
or personality.txt takes effect after docker compose restart bot.
Configuration is not hot-reloaded.
Resource limits
The Compose file does not set explicit CPU or memory limits. A
single bot under normal load uses 50–150 MB of RAM and very little
CPU outside of music transcoding, which is bursty. If you want to
cap things, add a deploy.resources block per service — start with
512 MB for the bot and 256 MB for Postgres on a small VPS, raise
either if you see OOMs in docker compose ps.
Troubleshooting
bot exits immediately with <KEY> must be set in .env. A
required environment variable is missing. The bot validates
DISCORD_TOKEN, CLIENT_ID, and GUILD_ID at startup and panics
if any is empty or still has a your-... placeholder. Open
instances/<name>/.env and fill in the values. See
Environment Variables
for the full required list.
bot reports Failed to connect to database and restarts.
Postgres is not yet healthy — usually a transient race. With
depends_on: service_healthy this should not happen on a clean
boot, but if it does, check docker compose logs postgres for disk
space, volume permissions, or a corrupted data directory.
mcp-gateway logs InstanceNotFound: bot. The INSTANCES
variable is wrong. The default points at a service named bot; if
you renamed it for a multi-instance setup but did not update
INSTANCES, the gateway has no backends to talk to.
Health check is stuck on unhealthy for bot. The MCP server
inside the container is not responding on port 9090. Either the bot
process has not finished starting (give it 30 seconds), it is
listening on a different MCP_PORT, or it has crashed. docker compose logs bot will tell you which.
Music playback fails with node errors. The runtime image
includes Node.js because yt-dlp shells out to it for JavaScript
challenges. If you see node: command not found, you are running
an old image — pull or rebuild.
Cross-references
- Environment Variables —
every variable the bot reads from
.env. - Instance Config (config.toml) — what lives in the per-instance config file.
- PostgreSQL Setup — bundled vs external, backups.
- Multi-Instance Deployment — the copy-paste pattern for adding a second bot.
- Monitoring — the health check, log aggregation, alerting.
PostgreSQL Setup
The bot needs Postgres. The shipped docker-compose.yml includes a
postgres:17 sibling service so a default deployment is fully
self-contained, but you are not required to use it — pointing the
bot at any reachable Postgres instance works the same way. This
page covers both shapes, the schema-per-instance model the bot uses,
how to back the database up, and how migrations work.
The architectural side of the schema model lives in Multi-Instance Model and the table reference (plus the migration system) lives in Database Schema. This page is about the operations.
Bundled Postgres
The default. The Compose file declares:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: discord_bot
POSTGRES_PASSWORD: discord_bot_pass
POSTGRES_DB: discord_bot
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U discord_bot"]
A single discord_bot database lives inside a single Postgres
container. The data persists in a named Docker volume called
pgdata, which docker compose down does not delete — only
docker compose down -v does. The default credentials
(discord_bot / discord_bot_pass) are baked into the Compose
file and the example .env. Change them only if the database is
reachable from outside the host; on the internal Compose network
the credentials are not the threat.
This setup is suitable for any deployment where:
- Only the bot and its siblings need this Postgres.
- One host owns both the bot and the database.
- You are happy to handle backups with
docker compose execand a cron job.
It is not suitable when you already run a managed Postgres for other applications, when you want point-in-time recovery, or when you want to host the database off the bot’s box for resilience. For those cases, switch to external Postgres.
External Postgres
To point the bot at an existing Postgres instead of the bundled one:
- Create a database on the external server. Any name works — the bot does not care.
- Create a user with
CREATEandUSAGEprivileges on that database. The bot needs to be able to create schemas, create tables inside them, and read and write rows. - Set
DATABASE_URLin your instance’s.envto the new connection string:DATABASE_URL=postgresql://username:password@db.example.com:5432/discord_bot_db - Remove the
postgresservice fromdocker-compose.yml, or just leave it and ignore it. - Remove the
depends_on: postgresblock from thebotservice — it has no local Postgres to wait for. docker compose up -d.
The first time the bot connects, it runs CREATE SCHEMA IF NOT EXISTS "<DB_SCHEMA>" and then applies every migration in
migrations/ inside that schema via sqlx::migrate!. After that,
every connection in the pool is configured with SET search_path TO "<DB_SCHEMA>" so all queries — and the migration runner’s own
_sqlx_migrations tracking table — land there. There is no extra
setup step required on the external server beyond creating the
database and granting the user.
If you are adding a second instance later, give it its own
DB_SCHEMA and the bot will create a new schema in the same
database — see Multi-Instance Deployment.
The schema-per-instance model
Every instance’s data lives inside one Postgres schema, named by the
DB_SCHEMA environment variable. At startup,
init_pool
does three things in order:
- Opens a one-off connection and runs
CREATE SCHEMA IF NOT EXISTS "<schema>". - Builds a connection pool with an
after_connecthook that runsSET search_path TO "<schema>"on every new connection. - Calls
sqlx::migrate!("./migrations").run(&pool)to apply every migration that hasn’t been applied yet. Migration history is tracked per-instance in a_sqlx_migrationstable inside the schema.
The search_path hook is the magic. Postgres resolves unqualified
table names by walking search_path in order, so once it is
pinned to the instance’s schema, every SELECT * FROM tempbans
in the codebase implicitly becomes SELECT * FROM "<schema>".tempbans. No feature module has to know the schema
name; the multi-tenancy boundary is invisible above the pool.
What this means operationally: every instance gets its own
schema, every schema is independent. Drop one schema and the
others are untouched. Back up one schema with pg_dump --schema=
and restore it without affecting the others. Two instances cannot
read or write each other’s data even by accident — the connections
literally cannot see the other schema’s tables.
If DB_SCHEMA is unset, the bot falls back to public — but the
shipped instances/example/.env.example already sets it to example,
so a fresh quickstart user gets a properly-isolated example schema
out of the box. For any real deployment you should set this to a
unique value per instance, typically matching your instance directory
name. mybot1 and mybot2, not public and public.
Backups
Backups are owned by you, not the bot. The bundled postgres:17
container does not run any backup tool out of the box. Pick one of
these patterns.
pg_dump over docker compose exec
The simplest, single-host approach. From the host:
docker compose exec -T postgres pg_dump -U discord_bot \
--schema=mybot discord_bot > backups/mybot-$(date +%F).sql
--schema=mybot restricts the dump to a single instance’s data, so
you can take per-instance backups on different schedules. Drop the
flag for a full database dump.
Schedule it from cron on the host:
0 3 * * * cd /opt/discord-bot && docker compose exec -T postgres pg_dump -U discord_bot --schema=mybot discord_bot | gzip > /var/backups/discord-bot/mybot-$(date +\%F).sql.gz
Restoring a per-schema dump:
gunzip -c mybot-2024-01-01.sql.gz | docker compose exec -T postgres psql -U discord_bot discord_bot
If you are restoring on top of an existing schema, drop it first
(DROP SCHEMA "mybot" CASCADE;) — pg_dump output assumes the
target objects do not already exist.
Volume snapshots
For a host with a snapshotting filesystem (ZFS, btrfs, LVM) or a
cloud provider that snapshots block storage, take filesystem
snapshots of pgdata’s underlying volume. This captures the
database in a crash-consistent state, which Postgres can recover
from on restart. Snapshots are faster and cheaper than pg_dump
for large databases, but they restore the entire database, not a
single schema.
If you go this route, stop the bot first (docker compose stop bot mcp-gateway), let any in-flight writes settle for a few
seconds, take the snapshot, then restart. For most deployments the
bot’s write rate is so low that you can take live snapshots without
issue, but the safe order is bot-stopped-during-snapshot.
External Postgres backups
If you switched to external Postgres (managed RDS, your own
Postgres host, etc.), use whatever backup story that infrastructure
already provides. AWS RDS automated backups, Postgres native
streaming replication, pgbackrest, wal-g, take your pick. The
bot writes such a small amount of data that any of these are
overkill in absolute terms, and that is fine.
What to back up
Everything in the database is worth keeping. The full table list is
in Database Schema. The
tempbans, stock_*, and member_activity tables would be
genuinely painful to lose; guild_settings is one row per guild and
trivial to recreate; stock_price_cache is throwaway. There is no
“don’t bother backing this up” table — just take the whole schema.
The instance directory itself (config.toml, .env,
personality.txt) is not in the database. Back that up
through whatever you use for code or configuration — git is the
obvious answer.
Migrations
The bot uses sqlx::migrate! against a top-level migrations/
directory. Every migration file is <UTC-timestamp>_<description>.sql,
sqlx applies them in timestamp order on every startup, and each
applied migration is recorded in a _sqlx_migrations table inside the
instance’s schema. The migrations themselves are embedded in the
binary at build time, so no DATABASE_URL is required to compile.
What this means in practice:
- Adding a new table or index in a new bot release is transparent. The release ships a new migration file, the next startup applies it, no manual intervention.
- Renaming or dropping a column, changing a type, adding a NOT
NULL constraint is also transparent — it just goes in a new
migration file with the appropriate
ALTER TABLE(and any backfillUPDATEit needs). Only changes that require manual coordination with running instances (e.g. multi-step zero-downtime migrations) will be flagged in release notes. - Restoring a backup from an older version is safe: the
migration step on the next boot replays any migrations the older
dump was missing, in order. Because the init migration uses
IF NOT EXISTS, it also tolerates restores from snapshots that predate the migration system. - Pre-migration databases (anything that ran the bot before this
system was introduced) are handled by the init migration’s
IF NOT EXISTSguards: it is a no-op against a database that already has the bootstrapped tables, and sqlx writes the_sqlx_migrationsrow afterwards so future migrations have a clean history.
Do not edit a migration file once it has shipped — sqlx checksums the file contents and a mismatch on the next startup is a hard failure. Land schema fixes in a new file with a later timestamp.
When a release does require operator action beyond “restart the bot” (rare), it will be flagged in Upgrading and the CHANGELOG. The maintainer’s policy is to avoid breaking schema changes within a major version, but the project is young — read the release notes before upgrading any version where the minor or major number changed.
Tuning
For a single bot, Postgres needs no tuning. The default postgres:17
configuration handles dozens of bots writing tens of operations per
second without breaking a sweat.
For larger deployments, the ones to think about:
max_connections— the bot opens a small pool (a few connections) per instance. Default 100 is enough for ~30 bots without changes.shared_buffers— bumping from the 128 MB default to 25% of available RAM helps if the active dataset stops fitting in cache.work_mem— the bot does not run heavy joins or sorts; default is fine.autovacuum— leave on. Thetempbansandstock_*tables see a lot of updates, autovacuum keeps them tidy.
If you are running the bundled Postgres and want to pass tuning
flags, use command: in the Compose file:
postgres:
image: postgres:17
command: postgres -c max_connections=200 -c shared_buffers=512MB
Connection security
On the internal Compose network, Postgres is reachable only from
other services in the project — no host port is published. The
default discord_bot_pass password is fine for that scope.
If you publish the Postgres port to the host (ports: ["127.0.0.1:5432:5432"]),
the password is what stops a process on the host from connecting.
Change it. If you publish to a non-loopback host port, also enable
TLS (ssl=on in postgresql.conf) and consider a different
authentication method in pg_hba.conf — scram-sha-256 rather
than the default trust on the local socket.
Cross-references
- Environment Variables:
DATABASE_URLandDB_SCHEMA— the variables that point the bot at Postgres. - Multi-Instance Model — why every instance gets its own schema.
- Database Schema — the table-by-table reference.
- Multi-Instance Deployment — adding a second instance against the same Postgres.
- Upgrading — when manual migrations are needed.
Multi-Instance Deployment
discord-bot-rs is designed to run more than one bot side by side
on the same host: different Discord identities, different
personalities, different feature sets, sharing one Postgres server
and one MCP gateway. This page is the operational recipe for adding
a second instance to an already-working single-instance Compose
stack.
The architectural rationale lives in Multi-Instance Model. The gateway routing model is in MCP Gateway Routing. This page assumes both — it focuses on the steps and the gotchas.
Topology
graph TB
subgraph Host
subgraph "bot1 container"
B1[discord-bot<br/>CONFIG_DIR=/config]
end
subgraph "bot2 container"
B2[discord-bot<br/>CONFIG_DIR=/config]
end
subgraph "postgres container"
PG[(PostgreSQL 17)]
S1[schema: bot1]
S2[schema: bot2]
PG --- S1
PG --- S2
end
subgraph "mcp-gateway container"
GW[gateway :9100]
end
B1 -.-> S1
B2 -.-> S2
GW -->|http://bot1:9090| B1
GW -->|http://bot2:9090| B2
end
D1[Discord API<br/>bot1 token] <--> B1
D2[Discord API<br/>bot2 token] <--> B2
Client[MCP client] -->|127.0.0.1:9100| GW
Two bot containers, each with its own CONFIG_DIR and its own
Discord token, sharing one Postgres (each in its own schema), with
the gateway fronting both MCP endpoints. Adding a third bot is the
same pattern, repeated.
What you are about to do
- Create a new instance directory under
instances/. - Fill in its
.envandconfig.toml. - Add a second
botservice todocker-compose.yml. - Add the new instance to the gateway’s
INSTANCESenv var. - Restart the stack.
The whole thing is mechanical once you have done it once.
Step 1: Create the new instance directory
The example directory is the canonical reference. Copy it:
cp -r instances/example instances/bot2
cp instances/bot2/.env.example instances/bot2/.env
bot2 is just a label. Use whatever name you like — production,
staging, community, the bot’s actual name. You will refer to it
in three places (the directory name, the Compose service name, and
the gateway’s INSTANCES value), and they are easier to keep
straight if they all match.
Step 2: Fill in .env
Open instances/bot2/.env. The fields that must differ from
your existing instance:
DISCORD_TOKEN=<token for the new bot user>
CLIENT_ID=<application ID for the new bot>
GUILD_ID=<server ID for whatever guild this instance manages>
DB_SCHEMA=bot2
DB_SCHEMA is the critical one. Two instances pointing at the
same DB_SCHEMA will fight over the same tables — picture two
processes both running the unban worker against the same tempbans
rows. Pick a unique schema per instance. Matching the directory
name keeps it obvious.
DATABASE_URL stays the same — both bots are talking to the same
Postgres, just to different schemas. The bot creates the schema on
first boot if it does not exist.
If you want different AI keys per instance, you can vary
DEEPSEEK_API_KEY and GEMINI_API_KEY per .env. Most operators
use the same keys for both.
Step 3: Fill in config.toml
instances/bot2/config.toml is where per-instance behaviour lives:
the bot’s display name, the prefix, what features are on, etc. The
example file documents every field. The fields most likely to
differ between instances:
bot_name = "Bot Two"
command_prefix = "!"
[features]
minecraft = false
auto_role = false
welcome = false
Two bots in the same Discord server need different prefixes (otherwise they will both respond to every command). Two bots in different guilds can share the same prefix without conflict.
personality.txt is loaded at startup as the AI chat system
prompt. Edit it to give the new bot its own voice, or leave the
example default to start.
Step 4: Add the second bot service
Open docker-compose.yml. The single bot block currently looks
like:
bot:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: ${INSTANCE_DIR:-./instances/example}/.env
environment:
CONFIG_DIR: /config
volumes:
- ${INSTANCE_DIR:-./instances/example}:/config
tmpfs:
- /tmp:size=500M
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null --connect-timeout 2 http://localhost:9090/mcp"]
interval: 10s
timeout: 5s
retries: 12
Rename bot to bot1 and add a second block named bot2. Replace
the ${INSTANCE_DIR} interpolation in each block with the actual
hard-coded path — once you are running multiple instances, the
INSTANCE_DIR variable is no longer the right knob, since you want
both bots up at once:
bot1:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: ./instances/bot1/.env
environment:
CONFIG_DIR: /config
volumes:
- ./instances/bot1:/config
tmpfs:
- /tmp:size=500M
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null --connect-timeout 2 http://localhost:9090/mcp"]
interval: 10s
timeout: 5s
retries: 12
bot2:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: ./instances/bot2/.env
environment:
CONFIG_DIR: /config
volumes:
- ./instances/bot2:/config
tmpfs:
- /tmp:size=500M
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null --connect-timeout 2 http://localhost:9090/mcp"]
interval: 10s
timeout: 5s
retries: 12
You will also need to rename your existing instances/example (or
whatever your first instance was called) to instances/bot1, or
just point the bot1 block at wherever your first instance
already lives.
A few things you do not need to vary between the two services:
- The container’s MCP port. Both bots bind their internal MCP
server to
9090inside their own container. There is no port conflict because each container has its own network namespace —bot1:9090andbot2:9090are different addresses on the Compose network. The gateway reaches each by service name. - The Postgres credentials. They share one database; only the
schema differs (set in each instance’s
.env). - The
tmpfs,restart, and health check blocks. Identical across instances.
Step 5: Update the gateway’s INSTANCES
The gateway’s INSTANCES env var is the routing table. By default
the Compose file uses a fallback that points at a single backend
called bot:
INSTANCES: "${INSTANCES:-bot=http://bot:9090}"
For multiple bots, override it on the host shell:
INSTANCES="bot1=http://bot1:9090,bot2=http://bot2:9090" docker compose up -d
Or hard-code it in the Compose file:
mcp-gateway:
...
environment:
GATEWAY_PORT: "9100"
INSTANCES: "bot1=http://bot1:9090,bot2=http://bot2:9090"
...
The names on the left of = are the routing keys MCP clients use
when they want to address a specific bot. The URLs on the right
are how the gateway reaches each backend on the Compose network.
The names should match your service names exactly — the gateway
does not know about Compose, but the URLs (http://bot1:9090) are
resolved by Docker’s internal DNS using the service names.
You should also widen the depends_on block so the gateway waits
for both bots to be healthy:
mcp-gateway:
...
depends_on:
bot1:
condition: service_healthy
bot2:
condition: service_healthy
If a bot is unhealthy at gateway startup, the gateway will still
boot but it will log warnings about that backend being unreachable
and the relevant list_guilds call will fail until the bot
recovers. The 5-minute background refresh in
mcp-gateway/src/main.rs
re-attempts initialisation against any unhealthy backends.
Step 6: Bring it up
docker compose up -d
docker compose ps
docker compose logs -f
You should see both bot1 and bot2 reach the
Database initialized (schema: bot1) and
Database initialized (schema: bot2) log lines, then connect to
Discord. The gateway logs MCP Gateway starting with N instances
followed by one <name> -> <url> line per instance, then a
<name> serves N guild(s) line per backend after it polls each
bot’s list_guilds.
In Discord, both bots should appear as separate users with separate green dots, in whichever guilds their tokens permit.
Where things live across instances
| What | Per-instance | Shared |
|---|---|---|
| Discord token / identity | yes | — |
| Personality text | yes | — |
| Feature flags | yes | — |
| Postgres data | one schema each | one server |
| MCP catalog | one MCP server each | one gateway in front of all |
| Music / game / rate-limit state | yes (in-memory) | — |
| Host network / disk / CPU | — | shared host |
What this means in practice: you can drop bot2’s schema with
DROP SCHEMA "bot2" CASCADE; without touching bot1. You can
restart bot1 without affecting bot2. You can remove the
bot2 service from Compose and the rest of the stack keeps
working. There is no in-memory cross-talk between processes — each
bot is its own Tokio runtime.
What does not work, by design: there is no built-in way for one bot to send a message to a channel that only the other bot can post in, no shared music queue, no cross-instance rate limit. If you need any of that, you build it through the MCP gateway or an external message bus.
Adding instances three through N
The same recipe scales. For a third bot:
- Copy the directory:
cp -r instances/bot2 instances/bot3 - Update
.env(token, client ID, guild, schema) - Update
config.toml - Add a third service block to
docker-compose.yml, namedbot3 - Append
,bot3=http://bot3:9090toINSTANCES - Add
bot3: condition: service_healthyto the gateway’sdepends_on docker compose up -d
In practice, somewhere around 5–10 bots on one host you start wanting to template the Compose file (Helm, Jsonnet, Make, a small Python script — anything that turns the per-instance variation into data). The bot’s design tolerates it; the YAML repetition is just tedious.
Resource sharing
Each bot process uses 50–150 MB of RAM at idle and bursts during
music playback. CPU is mostly idle outside of voice transcoding.
Postgres handles everything in stride. On a 2 GB / 1 vCPU VPS you
can comfortably run 4–6 bot instances; the bottleneck is RAM, not
CPU. If you want to cap any individual bot’s resource use, add a
deploy.resources block to its service in Compose.
Cross-references
- Multi-Instance Model — the conceptual model the recipe implements.
- MCP Gateway Routing — how the gateway picks a backend and stays in sync.
- Multiple Instances — the configuration-side walkthrough.
- PostgreSQL Setup — schema isolation and per-schema backups.
- Docker Compose — what each service block means in detail.
MCP Exposure
The bot embeds a Model Context Protocol server, and the Compose stack ships with a gateway that fronts one or more of those servers. Both are powerful — they can ban members, delete channels, post messages, edit roles. This page is about deciding when and how to make those endpoints reachable from outside the host without handing administrator rights to the internet.
If you have not read MCP Server yet, do that first — it covers what the tools do and how a client connects. This page is about the network and authentication layer.
Two deploy shapes
There are two reasonable shapes for an MCP-exposing deployment, and the rest of this page assumes you have picked one:
Shape A — loopback-only bot, no gateway
A single bot, no mcp-gateway sidecar. The bot’s MCP server binds
127.0.0.1 and is reachable only from inside the bot’s container
(or from the host, if you publish the port to 127.0.0.1). This is
the right default if you are not using the gateway and you only
need MCP locally. MCP_AUTH_TOKEN is optional in this shape;
loopback binds are allowed to ship without a token.
Set in .env:
MCP_BIND_ADDR=127.0.0.1
MCP_AUTH_TOKEN= # optional on loopback
Shape B — bundled Compose with the gateway
The shape the shipped docker-compose.yml is built for. The
mcp-gateway sidecar publishes 127.0.0.1:9100 on the host and
reaches each bot at http://<bot-service>:9090 over the internal
Docker bridge network. Because the gateway is in a different
container from the bot, the bot must bind on a non-loopback
interface inside its own container (MCP_BIND_ADDR=0.0.0.0),
otherwise the gateway cannot reach it. The shipped
instances/example/.env.example already does this.
MCP_AUTH_TOKEN is mandatory in this shape. The bot refuses
to start if MCP_BIND_ADDR is non-loopback and MCP_AUTH_TOKEN
is empty — see “Hard auth requirements” below.
MCP_GATEWAY_AUTH_TOKEN is mandatory always; the gateway refuses
to start without it.
Set in .env:
MCP_BIND_ADDR=0.0.0.0
MCP_AUTH_TOKEN=$(openssl rand -hex 32) # required
MCP_GATEWAY_AUTH_TOKEN=$(openssl rand -hex 32) # required (gateway service)
Even in Shape B nothing is exposed to the internet by default —
the gateway publishes only on 127.0.0.1:9100 on the host, so
the MCP endpoint is reachable from a Claude Code on the same host
(http://localhost:9100/mcp), from anyone you ssh -L 9100:localhost:9100 to, and from nothing else. The “expose this
to a remote machine” patterns later on this page apply on top of
Shape B.
Hard auth requirements
The bot and the gateway both refuse to start in obviously unsafe configurations:
- Bot: if
MCP_AUTH_TOKENis empty andMCP_BIND_ADDRis not a loopback address, startup aborts with an error pointing atsrc/mcp/mod.rs. This is to prevent the easy mistake of switching to0.0.0.0(for a Compose deploy, or any cross-container reach) and forgetting to set a token. - Gateway: if
MCP_GATEWAY_AUTH_TOKENis empty, startup aborts unconditionally. The gateway’s whole job is to be reachable from outside its container, so there is no loopback escape hatch.
Bearer comparison on both is constant-time (via the subtle
crate), so a brute-force probe cannot distinguish “token wrong by
the first byte” from “token wrong by the last byte” through
timing.
Both servers also cap incoming request bodies at 64 KiB
(RequestBodyLimitLayer), so a misbehaving or malicious client
cannot tie up RAM with an oversized payload, and malformed JSON
returns a proper JSON-RPC parse-error response rather than
crashing the connection.
If that covers your use case, stop here. The defaults plus a token are the right answer for most operators. The rest of this page is about the cases where it is not.
When to expose MCP
You only need to think about exposing MCP when:
- Your MCP client (an AI agent, a CI runner, a remote operator’s Claude Code) lives on a machine that is not the bot host, and
- You cannot reasonably tunnel to the bot host first.
Case 1 is common — your laptop is not the bot host. Case 2 is rare; SSH port forwarding is usually the cheapest answer and you should reach for it first.
Pattern 1: SSH tunnel (recommended)
For an operator’s laptop reaching the gateway from anywhere:
ssh -L 9100:localhost:9100 user@bot-host
The gateway is now reachable as http://localhost:9100/mcp on the
laptop, encrypted over SSH, authenticated by SSH’s existing key
infrastructure. No changes to the Compose file, no exposed ports,
no token to manage. As long as you have SSH access to the host,
you have MCP access.
Configure your MCP client (e.g. Claude Code’s ~/.claude.json):
{
"mcpServers": {
"discord": {
"type": "http",
"url": "http://localhost:9100/mcp"
}
}
}
This is the right answer 90% of the time. Use it before you consider anything else.
Pattern 2: WireGuard / Tailscale
For a small group of operators or persistent automation that needs the gateway available without a tunnel running on demand:
-
Stand up WireGuard or Tailscale across the bot host and the client machines.
-
Bind the gateway to the VPN interface instead of
127.0.0.1. Edit themcp-gatewayservice’sports:block indocker-compose.yml:ports: - "10.0.0.1:9100:9100" # WireGuard interface IP, for example -
Set a bearer token for defence-in-depth — even though the VPN is the perimeter, you do not want one device on the VPN to be able to take over Discord. In the host shell:
MCP_GATEWAY_AUTH_TOKEN=$(openssl rand -hex 32)Add it to the gateway’s environment in Compose so it survives restarts. Distribute the token to clients that need access.
-
Configure clients with the gateway’s VPN IP and the bearer token:
{ "mcpServers": { "discord": { "type": "http", "url": "http://10.0.0.1:9100/mcp", "headers": { "Authorization": "Bearer <token>" } } } }
Tailscale’s MagicDNS makes this even easier — you can use the host’s Tailscale name in the URL.
Pattern 3: Reverse proxy with TLS
For when you really do need a public endpoint (a hosted AI agent, a multi-team SaaS context, etc.). This is the pattern with the most operational surface, and the one to think hardest about.
- Bind the gateway to
127.0.0.1:9100on the host (the default). Do not publish it on a public interface directly. - Run a reverse proxy (Caddy, nginx, Traefik) in front of it, terminating TLS with a real certificate.
- Set
MCP_GATEWAY_AUTH_TOKENto a long random string. - Configure the reverse proxy to pass the
Authorizationheader through unmodified.
Minimal Caddy example:
mcp.example.com {
reverse_proxy 127.0.0.1:9100
}
Minimal nginx example:
server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /etc/ssl/mcp.example.com.crt;
ssl_certificate_key /etc/ssl/mcp.example.com.key;
location / {
proxy_pass http://127.0.0.1:9100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
# Let SSE work
proxy_buffering off;
proxy_read_timeout 1h;
}
}
Two configuration details that bite people:
- MCP uses Server-Sent Events. The gateway returns long-lived
SSE responses for every request. nginx’s default
proxy_buffering onwill hold the whole response until it is finished, which breaks streaming. Turn it off (as in the example above), and raiseproxy_read_timeoutso it does not kill quiet streams. - Set the Host header. Some proxies default to a host the
gateway does not expect; pass through the original (
Host $hostin nginx, automatic in Caddy) so any future Host-based routing in the gateway keeps working.
You should also seriously consider an extra layer of authentication in front of the gateway — IP allow-listing, mTLS at the proxy, basic auth on top of the bearer token. The bearer token is a single secret; one leak (a chat log, a misplaced env file, a CI artifact) gives an attacker full Discord administrator access via the bot’s account. Defence in depth is appropriate here.
What MCP can actually do
The MCP catalog is administrative: list / create / delete channels and roles, ban / kick / timeout members, send messages. A misconfigured MCP endpoint is, in practical terms, a ban-everyone-from-your-Discord-and-delete-the-server endpoint.
There is no in-bot confirmation or “are you sure” gate on tool calls. The bot does whatever the client asks, as long as the bot’s Discord permissions allow it. This is intentional — the entire point of MCP is to let an AI agent execute server changes without clicking through prompts — but it means the network and auth layer is the only thing standing between an attacker and your server.
A few mitigations worth knowing about:
- The MCP tools are bounded by the bot’s Discord permissions.
The MCP server cannot do anything the bot itself cannot do. If
you give the bot only
Manage Roles, the MCP catalog can only manage roles. Audit the bot’s role permissions before you expose MCP — do not give the bot Administrator unless you really mean for the MCP endpoint to have Administrator. - Per-call API timeout is 10 seconds (
API_TIMEOUTinsrc/mcp/tools.rs). A misbehaving client cannot tie up the bot with a long-running request. - Rate limiting is Discord’s, not the bot’s. Bulk operations hit Discord’s rate limits and surface as errors in the tool output. This is not a security feature — it just means an attacker cannot bulk-ban 10,000 users in one second.
Authentication on the bot’s MCP, not just the gateway
In a Compose deployment the bot’s MCP server binds 0.0.0.0
inside its container so the gateway sidecar can reach it over the
Docker bridge network. That is a non-loopback bind, so the bot’s
own startup check requires MCP_AUTH_TOKEN to be set on every
bot.
The model is a single shared secret across the whole MCP
fabric: the gateway’s MCP_GATEWAY_AUTH_TOKEN and every bot’s
MCP_AUTH_TOKEN must hold the same value. The gateway uses it
for two things at once — it is the bearer it requires on
inbound requests from clients, and it is the bearer it sends
on outbound requests to each backend bot. Without that match the
backend’s Tier-1 auth check rejects the gateway with 401 Unauthorized and no client can ever reach it. Generate one
value with openssl rand -hex 32 and paste it everywhere.
If you ever bind a bot’s MCP server to a host port directly
(rare, and usually wrong — use the gateway), the same rule
applies: non-loopback means MCP_AUTH_TOKEN is required, and the
bot will refuse to start without one.
Token rotation
Generate a fresh token with openssl rand -hex 32 (or any
equivalently random source). Because the gateway and every bot
share the same secret, rotation is a flag-day update in three
places:
-
Update
MCP_GATEWAY_AUTH_TOKENin the host shell (or in the Compose file). -
Update
MCP_AUTH_TOKENin every bot’s.envto the same value. -
Restart the full stack so the new value takes on both ends:
docker compose up -d
Update every MCP client with the new token as well. There is no
built-in multi-token mechanism — rotation is atomic per stack.
Keep the value out of shell history (export MCP_GATEWAY_AUTH_TOKEN=$(...) rather than typing it inline), out
of git, and out of logs.
If you suspect a token leak, rotate immediately, then audit the bot’s recent Discord activity for unexpected actions.
What not to do
- Do not publish the gateway on
0.0.0.0:9100without a token. Even on a “private” network. Networks are less private than they look. - Do not put the gateway on the internet without TLS. Bearer tokens go in the clear over plain HTTP, and SSE responses leak the same token in connection logs along the way.
- Do not use one bearer token across staging and production. Treat them as separate trust domains.
- Do not skip the auth token because “it is behind a VPN.” Defence in depth costs you nothing here.
Cross-references
- MCP Server — what the tools do and how clients connect.
- MCP Gateway Routing — the gateway’s internal design.
- Environment Variables: MCP server — the variables that control bind, port, and auth.
- Production Checklist — the one-pass hardening sweep that includes MCP.
Upgrading
discord-bot-rs is shipped as Docker images on GitHub Container
Registry (ghcr.io/mrmcepic/discord-bot-rs and
ghcr.io/mrmcepic/discord-bot-rs-mcp-gateway) and as source on
GitHub. This page
covers how to move from one version to the next, what to expect
from the database when you do, and how breaking changes are
communicated.
Versioning
The project uses SemVer. The version is
visible in the Cargo.toml and on the ghcr.io image tags. Tag
suffixes:
:0.5.0— a specific version. Pin this in production.:latest— whatever the most recent release is. Convenient for development; do not use in production.
While the project is in 0.x, the minor version is the
breaking-change boundary. Going from 0.5.x to 0.5.y is always
safe; going from 0.5.x to 0.6.0 may require a manual step.
Once it reaches 1.0, the major version takes over that role.
The published images are currently amd64-only. If you are on
arm64 (Apple Silicon, Graviton, Ampere), build from source with
docker compose build.
Reading the release notes
Every release ships with a CHANGELOG entry that lists what changed. Before pulling, read the entries for every version between yours and the new one. The changelog distinguishes:
- Added — new features. Generally safe to pick up.
- Changed — behaviour changes. Read carefully.
- Fixed — bug fixes. Almost always wanted.
- Deprecated — features that still work but will be removed.
- Removed — features that are gone. Check whether you used them.
- Migration required — explicit flag for any release that needs a manual database step before the bot will boot.
If the release notes do not mention “Migration required”, the upgrade is the default flow below.
Default upgrade flow
For a deployment that uses pre-built images:
# Bring the new images down
docker compose pull
# Restart only the services whose images changed
docker compose up -d
Compose detects that the bot and gateway images now have new
digests and recreates the containers in place. Postgres is not
upgraded by this — it stays on whatever postgres: tag is in your
Compose file.
For a deployment that builds from source:
git fetch
git checkout v0.6.0 # or whatever release tag
docker compose build
docker compose up -d
This rebuilds the bot and gateway images locally, then recreates the containers.
For either path, the bot’s startup migration step
(sqlx::migrate!)
runs every boot against the files in
migrations/.
Any migration whose version is newer than the _sqlx_migrations
tracking row inside the instance’s schema is applied; older ones
are skipped. The initial migration is written with CREATE TABLE IF NOT EXISTS so it is idempotent against pre-existing databases.
Watching for problems on the first boot after upgrade
Tail the logs immediately after the upgrade:
docker compose logs -f bot mcp-gateway
Things you want to see:
Database initialized (schema: <yours>)— schema is in good shape.Instance config loaded: <name> (prefix: ...)— config still parses cleanly (a syntax change inconfig.tomlbetween versions would surface here).<botname> is connected!— Discord connection is up.<name> serves N guild(s)per bot in the gateway logs (afterMCP Gateway starting with N instancesand the<name> -> <url>registration lines).- Any health check transitioning to
healthyindocker compose ps.
Things that mean roll back:
- Any
panicfrom the bot during startup. The bot is in a hard crash loop. Failed to connect to database— the connection string broke or Postgres rejected the credentials.- A new required env var the upgrade introduced and your
.envis missing. The release notes will name it.
If you need to roll back, redeploy the previous image tag:
docker compose pull # implicit in `up -d` after editing image tag
# Edit docker-compose.yml: image: ghcr.io/mrmcepic/discord-bot-rs:0.5.0
docker compose up -d bot
For source builds, git checkout the previous tag and rebuild.
Database migrations
Migrations live in
migrations/
as timestamped .sql files. sqlx::migrate!("./migrations") runs
them at startup against each instance’s schema and records applied
versions in a _sqlx_migrations table inside that schema. Each
migration runs at most once per schema.
What this means for upgrades:
- Adding a new table or index in a release is transparent. The migration ships with the release; the next boot runs it.
- Renaming or dropping a column, changing a type, adding a NOT NULL constraint ships as a new migration file that the startup runner applies in order. Destructive migrations are called out in the release notes so you can schedule them against a backup.
- The bundled Postgres major version may change. If a release
bumps the
postgres:17image topostgres:18, thepgdatavolume needs to be migrated usingpg_upgradeor by dumping and reloading. The release notes will spell this out — Postgres major upgrades are not something to do casually.
A typical “Migration required” upgrade looks like:
# 1. Stop the bot so the schema is quiet
docker compose stop bot mcp-gateway
# 2. Take a backup
docker compose exec -T postgres pg_dump -U discord_bot \
--schema=mybot discord_bot > pre-upgrade-mybot.sql
# 3. Apply the SQL from the release notes
docker compose exec -T postgres psql -U discord_bot discord_bot < release-notes-migration.sql
# 4. Pull the new images
docker compose pull
# 5. Bring everything back up
docker compose up -d
The bot’s startup migrate step then handles any new-table
additions on top.
Multi-instance considerations
When you run multiple bot instances against one Postgres, every instance shares the same database but lives in its own schema. Migrations are per-schema. If a release adds a new table, the table is created inside the schema of whichever bot instance boots first, and again inside each other instance’s schema as they boot. There is no way for instance A to step on instance B’s tables.
You can also upgrade instances one at a time:
# Pull new images
docker compose pull
# Recreate just bot1 with the new image
docker compose up -d bot1
# bot2 keeps running on the old image until you choose to upgrade it
This is useful for canary upgrades, but be aware: if the new release introduces SQL that the old version rejects (a new column the old code does not know how to handle, or a column rename), a mixed-version deployment can be unstable. The safest path is to upgrade every instance together.
Upgrading the gateway
The gateway is upgraded the same way as the bot — pull the new
mcp-gateway image, docker compose up -d mcp-gateway. The
gateway has no persistent state of its own; restarting it loses
nothing. MCP clients reconnect automatically the next time they
make a request.
The gateway and the bots do not have to be on matching versions in the strictest sense, but you should aim to keep them in step. Tool schema changes in the bot are not picked up by the gateway until the gateway re-fetches the catalog (it does this on startup). If a bot release adds a new tool, restart the gateway after the bot upgrade to refresh the catalog.
Upgrading Postgres
Patch versions of postgres:17 (e.g. 17.0 to 17.4) are
handled by Docker pulling the new image; the data on pgdata is
forward-compatible within a major version.
Major-version Postgres upgrades (17 to 18, etc.) require
pg_upgrade or a dump-and-reload, because the on-disk format
changes. The simplest dump-and-reload:
# 1. Dump everything from old Postgres
docker compose exec -T postgres pg_dumpall -U discord_bot > pg-dump.sql
# 2. Stop everything
docker compose down
# 3. Move the old volume aside (do not delete yet)
docker volume rename discord-bot-rs_pgdata discord-bot-rs_pgdata_v17
# 4. Edit docker-compose.yml, change image: postgres:17 -> postgres:18
# 5. Bring up the new Postgres
docker compose up -d postgres
# 6. Restore
docker compose exec -T postgres psql -U discord_bot < pg-dump.sql
# 7. Start the rest
docker compose up -d
# 8. Once you have verified the bot works, drop the old volume
docker volume rm discord-bot-rs_pgdata_v17
The bot does not care which Postgres major version it is talking to as long as the connection works.
Rebuilding from source
If you contribute changes locally or want a custom build:
git pull
docker compose build
docker compose up -d
docker compose build rebuilds both the bot and mcp-gateway
images from the local Dockerfiles. The build leverages BuildKit’s
cargo cache mount, so incremental builds (small source changes)
take well under a minute. A clean build from a cold cache takes
3–8 minutes depending on the host.
Cross-references
- CHANGELOG — the canonical source for what changed in each release.
- PostgreSQL Setup — backups before upgrade.
- Docker Compose — the underlying stack.
- Production Checklist — what to verify after every upgrade.
Monitoring
The bot is small and quiet. There is no metrics endpoint, no Prometheus exporter, and no built-in alerting. What it gives you is structured logs, container health checks, and a database whose state you can query directly. This page is about how to make those things into a passable monitoring story.
Health checks
The Compose stack defines health checks on two services:
Postgres runs pg_isready every 5 seconds. The bot’s
depends_on: postgres: condition: service_healthy clause uses this
so the bot does not start until the database is accepting
connections.
Bot runs curl against its own embedded MCP server every 10
seconds:
healthcheck:
test: ["CMD-SHELL", "curl -s -o /dev/null --connect-timeout 2 http://localhost:9090/mcp"]
interval: 10s
timeout: 5s
retries: 12
This is a liveness check. The MCP server starts as a side effect of
the bot reaching its run loop, so if the check is passing, the bot
process is alive, has loaded its config, has connected to Postgres,
and is past startup. If the check is failing for 12 consecutive
intervals (2 minutes), Compose marks the container unhealthy and
the gateway’s depends_on clause stops it from being considered
ready.
The bot health check is also what the gateway depends on for its own startup ordering. A failed bot health check means the gateway will not (re-)route to that backend until it recovers.
There is no health check on mcp-gateway itself. It is stateless
and loud — if it is down, every MCP call fails immediately and
that is the signal.
The minimum viable monitoring is therefore docker compose ps:
NAME STATUS
discord-bot-rs-bot-1 Up 3 hours (healthy)
discord-bot-rs-postgres-1 Up 3 hours (healthy)
discord-bot-rs-mcp-gw-1 Up 3 hours
If bot or postgres shows unhealthy, something is broken. If
the gateway shows as Restarting, the bot is unhealthy and the
gateway crashed waiting for it.
For automated alerting, run docker compose ps --format json from
cron or a small script and alert when any service is anything
other than running and (where applicable) healthy.
Logs
The bot uses tracing with the
default tracing_subscriber::fmt::init() in main.rs. Output goes
to stderr, which Docker captures into the container log stream.
Common operational queries:
# Tail everything across the stack
docker compose logs -f
# Just the bot
docker compose logs -f bot
# The last 200 lines, then exit
docker compose logs --tail 200 bot
# Filter to errors and warnings
docker compose logs bot 2>&1 | grep -E ' (ERROR|WARN) '
# Logs from a specific time window
docker compose logs --since 1h --until 30m bot
Log levels
The default is INFO. Override with RUST_LOG:
# Set in the bot's .env
RUST_LOG=debug
RUST_LOG=debug is loud — useful when investigating a specific
incident, painful to leave on long-term. Per-module filters help:
RUST_LOG=info,discord_bot::music=debug,discord_bot::mcp=debug
This keeps everything else at INFO and only debugs music and MCP.
The module names follow the source tree
(src/music/, src/mcp/, etc.).
Log lines worth knowing
A few lines you will see often, with what they mean:
Database initialized (schema: <name>)— pool is up, migrations done. If you do not see this within a few seconds of boot, the database connection is broken.Instance config loaded: <name> (prefix: ...)—config.tomlparsed without errors.<botname> is connected!— Discord gateway is up. The bot is fully operational from this point.MCP server listening on 0.0.0.0:9090— embedded MCP server started.Tempban unban checker started (30s interval).— background worker spawned.Auto-role time checker started (60s interval).— auto-role background worker spawned (only if enabled).Donator sync checker started (<N>s interval).— Minecraft donator sync started (only if enabled).
WARN-level lines worth paying attention to:
<feature> enabled but [<section>] config section missing— a feature flag is on but its config section is absent. The feature is silently disabled until you fix the config.Welcome feature enabled but no AI API key (DEEPSEEK_API_KEY or GEMINI_API_KEY) configured— welcome messages need an AI provider; one is missing.Donator sync: failed to fetch donators— the Minecraft companion plugin is unreachable. Often transient (network blip, MC server restart); persistent failures mean MC_VERIFY_URL or MC_VERIFY_SECRET is wrong.Auto-role time promotion failed for <user>— Discord rejected a role grant. Usually a permissions issue; the bot’s role needs to be above the role it is granting.
ERROR-level lines should always be investigated:
Command error: ...— a user-facing command threw. The user also got anError: ...message in Discord. Often this is user input the command cannot handle (bad time format, missing permission), occasionally it is a bug.Framework error: ...— poise reported a framework-level problem.Client error: ...printed at the very bottom of the log right before the bot exits — Serenity has lost the connection and cannot recover. Compose’srestart: unless-stoppedwill bring the container back, but a recurring crash is worth digging into.
Log aggregation
For a single host running a single bot, docker compose logs and
grep is sufficient. As soon as you have multiple hosts or
multiple instances, you want logs in a central place.
The simplest option is to point the Docker daemon at a syslog endpoint, journald, or a log driver of your choice:
# In the bot service
logging:
driver: journald
options:
tag: "discord-bot"
journald gives you journalctl -u discord-bot -f and rotation for
free. Other drivers (gelf, awslogs, loki, fluentd, etc.)
are wired the same way — see the
Docker logging docs.
For a structured-log workflow, consider Loki + Grafana: it ingests the raw JSON-flavoured tracing output cleanly and lets you build dashboards on log fields (per-guild error rates, music command counts, etc.). The bot itself does not export metrics, so Loki + log-derived metrics is the path to graphs.
Common failure modes
The bot is offline and the container is restarting
Check docker compose logs bot --tail 100. The most common causes:
- A required env var is missing or has a placeholder. The bot
panics at startup with
<KEY> must be set in .envor<KEY> has placeholder value. - The Discord token is invalid. You will see a Serenity error
about authentication shortly after
Starting bot.... - Postgres is down. The pool fails to initialise and the bot
panics with
Failed to connect to database.
The bot is online but does not respond to commands
- Wrong prefix. Check
command_prefixinconfig.tomlmatches what you are typing. - Missing permissions. The bot needs Read Messages, Send Messages, and Read Message History in the channel.
- Missing intents. Discord requires you to enable Message Content Intent in the developer portal for the bot to read message text. Without it, prefix commands silently do nothing.
- The bot crashed mid-handler. Look for
Command error:in the logs.
Music does not play
- Check
docker compose logs bot | grep -E '(yt-dlp|ffmpeg|node)'. A broken yt-dlp or missing Node.js (it is needed for some JS challenges) will show up here. - If yt-dlp is failing on YouTube specifically, the bot may need cookies. See Music feature page.
- Voice-stack errors mention
songbirdoropus— typically a rare dependency mismatch in a custom build.
MCP calls fail
docker compose logs mcp-gatewayfirst. If the gateway is up but the bot’s MCP server is not responding, you will see health-check warnings.- 401 Unauthorized responses mean the bearer token is wrong or missing.
InstanceNotFoundorGuildNotFoundmeans the gateway’s routing table cannot resolve the request — see MCP Gateway Routing.
Donator sync stops working
Most often the MC companion plugin is unreachable or its endpoint
returns a non-200. The bot logs Donator sync error: and the next
poll retries — there is no escalation.
Auto-role does not promote
The auto-role worker logs Auto-role time promotion failed for
each failed grant. The bot needs its role to be above the role it
is granting in the Discord role hierarchy. Re-order roles in the
Discord server settings and the next sweep will succeed.
Database introspection
Sometimes the fastest debugging is a psql session:
docker compose exec postgres psql -U discord_bot discord_bot
Useful queries:
-- Active tempbans across instances
SELECT * FROM "<schema>".tempbans WHERE unbanned = FALSE ORDER BY expires_at;
-- Top message-senders for the auto-role feature
SELECT * FROM "<schema>".member_activity ORDER BY message_count DESC LIMIT 20;
-- Recent stock trades
SELECT * FROM "<schema>".stock_transactions ORDER BY created_at DESC LIMIT 20;
-- Per-guild settings
SELECT * FROM "<schema>".guild_settings;
Replace <schema> with each instance’s DB_SCHEMA. The
Database Schema page lists
every table.
What is intentionally not monitored
A few things the bot does not track and you should not try to:
- Per-command latency. The Discord gateway is the rate limiter; latency is dominated by Discord’s response time, not the bot’s.
- In-memory queues and caches. Music queues, game state, rate limiters all reset on restart by design — they are not state worth watching.
- The MCP gateway’s per-request status. It is a thin proxy; failures in it are visible as log lines.
Cross-references
- Docker Compose — service definitions and health-check syntax.
- Database Schema — what lives in each table.
- Production Checklist — sets up the monitoring you actually need before going live.
- Upgrading — log lines to watch for after a version change.
Production Checklist
A one-pass hardening sweep to do before you stop watching the logs. Each item is a yes/no — if the answer is “no” or “not sure”, read the linked page and decide. If you can answer “yes” to every item, the deployment is in reasonable shape.
The order is roughly secrets first, then network, then data, then operations.
Secrets
-
DISCORD_TOKENis unique to this bot user and is not committed anywhere. If it ever ended up ingit, in a chat message, or in a screenshot, regenerate it in the Discord developer portal. Tokens are full credentials. → Secrets Management -
.envfiles are not in git. The repo’s.gitignorealready excludesinstances/*/.env. Confirm withgit statusafter creating the file — it should not appear. -
No required env var is using its placeholder value. The bot rejects values starting with
your-at startup, but the check is best-effort. Open eachinstances/*/.envand confirm. → Environment Variables -
API keys (
DEEPSEEK_API_KEY,GEMINI_API_KEY,FINNHUB_API_KEY) are scoped to this deployment. Do not reuse the same DeepSeek key across staging and production — separate billing and rate-limit blast radius. -
MCP_AUTH_TOKENis set on every bot whoseMCP_BIND_ADDRis not loopback. This is now enforced at startup — the bot refuses to boot if the bind is non-loopback and the token is empty. The bundled Compose.env.exampleships withMCP_BIND_ADDR=0.0.0.0(so the gateway sidecar can reach it), so a Compose deploy without a token will fail to start. → MCP Exposure -
MCP_GATEWAY_AUTH_TOKENis set on the gateway service and matches every bot’sMCP_AUTH_TOKEN. The gateway refuses to start at all without it — there is no loopback escape hatch. The same value is used twice: it gates inbound requests from MCP clients and is forwarded as the bearer on outbound requests to each backend bot, so a mismatch with the bot’sMCP_AUTH_TOKENsurfaces as a401from the backend at startup. Generate one value withopenssl rand -hex 32and use it in both places. → MCP Exposure -
The Postgres password is not the default
discord_bot_passif Postgres is exposed beyond the Compose network. On the default localhost-only setup, the default is fine. If you bind Postgres to a host port or use external Postgres, rotate it. -
MC_VERIFY_SECRETmatches the value configured on the Minecraft companion plugin. A mismatch makes verification and donator sync silently fail.
Discord configuration
-
The bot’s role permissions are minimum-necessary. Audit the role’s permissions in the Discord server settings. Administrator is rarely required and turns the MCP endpoint into an “anything-goes” interface. Grant only the permissions the features you have enabled actually need.
-
The bot’s role is positioned correctly in the role hierarchy. It must be above any role it needs to assign, remove, or modify (auto-role, join role, donator sync). Drag it up if necessary.
-
Privileged intents are enabled in the Discord developer portal. Specifically, Server Members Intent and Message Content Intent. Without them the bot cannot read prefix commands or react to member joins.
-
The bot is in every guild whose
GUILD_IDyou have configured. AGUILD_IDfor a guild the bot is not in causes silent feature failure.
Network
-
The MCP gateway is bound to
127.0.0.1:9100on the host, not0.0.0.0. The default Compose file is correct; only change it if you have read MCP Exposure and are using one of the safe patterns. -
The Postgres port is not published unless you need it. The default Compose file does not publish it. Adding
ports: ["5432:5432"]exposes the database to the host and possibly the network. Only do it if a backup or admin tool needs it, and prefer127.0.0.1:5432:5432. -
Per-bot MCP ports are not published. The Compose file does not publish them by default; the gateway reaches them over the internal network. The only port published to the host should be the gateway’s.
-
External MCP access uses TLS or a tunnel. Plain HTTP on a public IP leaks bearer tokens. Use Tailscale / WireGuard / SSH tunnel / TLS-terminating reverse proxy. → MCP Exposure
-
The host firewall blocks anything you are not deliberately exposing. Even with Docker’s port bindings, having
ufwor equivalent in deny-by-default mode prevents accidents.
Database
-
DB_SCHEMAis set to a unique value per instance. Two instances on the sameDB_SCHEMAwill trample each other. Match it to the instance directory name. → PostgreSQL Setup -
The
pgdatavolume is on persistent storage. Default Docker named volumes live under/var/lib/docker/volumeson the host’s root disk. If your root disk is ephemeral (some cloud setups), bind-mount to persistent storage instead. -
Backups are scheduled. A
pg_dumpcron job, a filesystem snapshot policy, or an external Postgres with managed backups. Pick one and verify it runs. → PostgreSQL Setup: Backups -
You have tested a restore. A backup you have not restored is a wish. Restore into a throwaway database and check the bot can read its own data.
-
Backup retention matches your tolerance for lost data. Default the retention to “longer than you would notice a problem” — typically 30 days at minimum.
-
You know which schemas exist.
\dninpsqllists them. Stale schemas from removed instances waste space; drop them withDROP SCHEMA "<name>" CASCADE;once you are sure. -
You have read the migrations directory before upgrading. The bot now uses
sqlx::migrate!againstmigrations/, applied automatically on startup against each instance’s schema (tracked in a per-schema_sqlx_migrationstable). No operator action is required for ordinary releases — but a release that ships a destructive or long-running migration will be flagged in the CHANGELOG, and you should take a backup before applying it. → Database Schema: Migrations
Configuration hygiene
-
Each instance has its own directory under
instances/. One directory per Discord identity. No sharing of.envorconfig.tomlbetween bots. -
config.tomlreflects the features you actually use. Feature flags off for anything you do not want. Each enabled feature requires its config section ([auto_role],[minecraft], etc.) — the bot warns at startup if a flag is on but the section is missing. → Instance Config -
personality.txtreads how you want the bot to sound. The example default is functional but generic. Edit it for production bots. -
The
command_prefixdoes not collide with another bot in the same server. If two bots share!, both will respond to every!cmd.
Operations
-
restart: unless-stoppedis set on every service. The default Compose file already does this. Confirm if you hand-edited. -
The host has a reboot policy that brings Docker back up.
systemctl enable dockeron systemd hosts. Otherwiserestart: unless-stoppeddoes nothing on a host reboot. -
You have a documented upgrade process. Knowing whether you do
docker compose pull(image-based) orgit pull && docker compose build(source-based) saves panic later. Keep the bot’s image tag pinned to a specific version, not:latest. → Upgrading -
You read the CHANGELOG before pulling a new release. Releases occasionally need manual database migrations. The changelog flags them.
-
Disk space is monitored. Postgres data, container logs, and Docker images all grow.
df -h /var/lib/dockerand Postgres’spgdatavolume size should be on whatever monitoring you have. A full disk wedges everything. -
Log rotation is in place. Docker’s default JSON file driver has no rotation; logs grow indefinitely until they fill the disk. Either set
max-sizeandmax-fileon the logging driver, or usejournald(which rotates by default). -
Health checks have somewhere to alert from. A
cronjob that runsdocker compose ps --format jsonand pages on anything nothealthyis the minimum viable. Better: a proper monitoring agent (Healthchecks.io, Uptime Kuma, Datadog, etc.) hitting a wrapper script. → Monitoring -
Rate limiters need no operator action. All four per-user limiters (ai / music / moderation / stocks) are now wired into their respective command paths and clean up stale entries automatically — there is nothing to schedule or prune by hand. Previously only the AI limiter was enforced; the rest were defined but unused.
MCP-specific (if exposed)
-
MCP_GATEWAY_AUTH_TOKENis at least 32 random bytes.openssl rand -hex 32is the easiest way to generate one. Short or guessable tokens are not tokens. -
The bearer token is rotated when an operator leaves. There is no per-client revocation, so rotating the shared token and redistributing is the only mechanism.
-
MCP clients are configured with the production token, not a staging one. Rotating staging because it leaked into a test log should not affect production.
-
Your reverse proxy passes the
Authorizationheader through. Some proxies strip auth headers by default. -
Reverse proxy timeouts are long enough for SSE. MCP uses Server-Sent Events; default 60-second proxy timeouts kill streams. See MCP Exposure.
Multi-instance
-
Every instance has a distinct
DB_SCHEMA. Already mentioned but worth repeating — it is the most-common misconfiguration in multi-instance setups. -
Every instance has a distinct
DISCORD_TOKEN. Two bots on one token will conflict on the gateway connection. -
The gateway’s
INSTANCESlists every backend. Missing a backend means the gateway cannot route to it. → Multi-Instance Deployment -
The gateway’s
depends_onlists every backend. A missing backend means the gateway might start before that bot is ready. -
Each instance’s prefix is sensible. Two bots in the same Discord server need different prefixes.
Final smoke test
After every configuration change:
-
Startup logs are clean. No
panic, noFailed to ..., no unexpectedWARN. → Monitoring: Log lines worth knowing -
docker compose psshows everythinghealthy. -
The bot is online in Discord. Green dot, responds to
!m help. -
An end-to-end command works. Try a music command (
!m play test), a moderation command (!m banlist), or whatever your most-used feature is. If it returns a sensible response, the wiring is correct.
If anything on this list is unanswered or “no”, fix it before you walk away from the deployment. The defaults are reasonable; the defaults are not “production-grade with no thought required.”
Cross-references
- Docker Compose — the underlying stack.
- Environment Variables — every variable, what it does, what is required.
- Secrets Management — rotation and storage.
- PostgreSQL Setup — backups, schemas, external Postgres.
- MCP Exposure — the network and auth side of the MCP endpoint.
- Monitoring — logs, health checks, alerts.
- Upgrading — how to move forward without breaking things.
Development
This section is for people who want to read, extend, debug, or contribute to discord-bot-rs.
Where to start
- New to the codebase? Start with the Codebase Tour. It walks every module in
src/and explains responsibilities, key types, and entry points. ~3000 words; treat it as your map. - Want to run the bot without Docker? Building Locally covers the cargo workflow, system dependencies, and how to point the binary at a local PostgreSQL.
- Want to write code? Contributing Workflow covers fork-and-PR, the pre-PR checklist, what CI runs, and how reviews work. Pair it with the top-level CONTRIBUTING.md for dev setup and contribution terms.
- Stuck on a bug? Debugging covers
RUST_LOG, common failure modes, and where to look when the bot misbehaves.
How-to guides
When you have a specific change in mind:
- Adding a Command — every user-facing command in this bot is a prefix subcommand of the parent
mcommand. This guide walks through writing a new one and registering it correctly. The #1 gotcha is forgetting the entry insrc/commands/mod.rs. - Adding a Feature Module — the bigger version of “adding a command.” Covers creating a new top-level module under
src/, wiring its config intoInstanceConfig, hooking event handlers, and integrating with theDatastruct. - Adding an MCP Tool — the MCP server in
src/mcp/exposes Discord management tools to clients like Claude Code. This guide shows how to add a new tool, including the schema, handler, and#[tool]macro.
Testing
Testing describes the current state honestly: limited automated coverage today, with a clear path for adding more. The mcp-gateway crate has unit tests for routing; the main crate compiles in CI but has no test suite to speak of yet. Contributions of tests are very welcome.
Architecture context
These dev pages assume you’ve at least skimmed the Architecture Overview. If you haven’t, start there — it has the top-level component diagram, the Data struct’s role, and the multi-instance model. The architecture pages are reference material; the dev pages are how-to.
Tooling expectations
Every PR runs through CI: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo check, cargo test, and a docker build of both Dockerfiles. Run these locally before pushing and you’ll save yourself a round trip:
cargo fmt
cargo clippy --all-targets -- -D warnings
cargo test
cd mcp-gateway && cargo fmt && cargo clippy --all-targets -- -D warnings && cargo test
Rust formatting is hard tabs, width 4 (rustfmt.toml). Don’t fight the formatter.
Codebase Tour
This page is the contributor’s map. It walks the codebase module by module, tells you what each file is responsible for, and names the function or type you should open first if you’re trying to understand the area. Read it once before your first contribution; after that it becomes a lookup table.
If you want the higher-level picture of how the parts talk to each other, read the Architecture Overview first — this page assumes you already have that context and goes one level deeper.
Repository layout
discord-bot-rs/
├── src/ # the main bot crate
├── mcp-gateway/ # a second crate: the multi-instance MCP router
├── instances/ # per-instance config directories
│ └── example/ # config.toml + personality.txt starter
├── docs/ # this mdBook
├── theme/ # mdBook theme overrides
├── .github/ # CI workflows and issue/PR templates
├── Cargo.toml # workspace root for the main crate
├── Dockerfile # bot container build
├── docker-compose.yml # bot + postgres default stack
└── README.md
The two crates (src/ and mcp-gateway/) are built and tested
independently — CI runs format, clippy, build, and test on each. A
third top-level artifact is the mdBook under docs/, built by a
separate workflow and published to GitHub Pages.
The main crate (src/)
Every path below is relative to src/.
main.rs — entry point and shared state
src/main.rs
is the one file you have to read before anything else. It does four
things:
- Declares every top-level module — the
moddeclarations at the top are the ground truth for which directories undersrc/actually compile. - Defines the
Datastruct — this is the shared application state that poise hands to every command and event handler. It holds thesqlx::PgPool, areqwest::Client, the loadedConfig, thepersonalityandbot_namestrings, everyOption<T>feature config (auto_role_config,minecraft_config,join_role_config,welcome_config), per-guild stateDashMaps (guild_players,track_handles,now_playing_msgs,idle_timers,connections_games,wordle_games), aRateLimitersbundle, and one-shot startup flags (mcp_started,started_at). - Builds the poise framework in
main()— loads the instance config, constructs the parentmcommand (pushing the optionalverifysubcommand if Minecraft verify is enabled), registers the event handler, and wires the prefix frominstance_cfg.command_prefix. - Spawns long-running background tasks — the tempban unban checker
(30s loop), the auto-role time promotion checker (60s loop), and the
donator sync poller (interval from config). Each is a
tokio::spawnthat owns its own clones ofhttpand the DB pool.
When you add a new feature that needs startup state, this is the file
where you both extend Data and spawn the task, then pass the Data
reference through into the module that needs it.
config.rs — environment variables
src/config.rs
is a single Config struct and a Config::load() function. It reads
.env via dotenvy, panics fast on missing required vars
(DISCORD_TOKEN, CLIENT_ID, GUILD_ID), and exposes the optional
API keys (DEEPSEEK_API_KEY, GEMINI_API_KEY, FINNHUB_API_KEY,
MC_VERIFY_URL, MC_VERIFY_SECRET) as Option<String>. The MCP
settings (MCP_PORT, MCP_BIND_ADDR, MCP_AUTH_TOKEN) and the
database settings (DB_SCHEMA, DATABASE_URL) are plain String
fields with non-None defaults. The get_env_or_throw helper also
panics on your-... placeholder values, so the bot refuses to boot
with an unedited .env.example.
instance_config.rs — parsing config.toml
src/instance_config.rs
loads the per-instance config.toml — bot_name, command_prefix,
the features sub-table (feature flags), and optional typed config
sections (AutoRoleConfig, MinecraftConfig with its nested
DonatorSyncConfig and ChargebackConfig, JoinRoleConfig,
WelcomeConfig). It also resolves the instance directory via
CONFIG_DIR (default .) and loads personality.txt and the
optional welcome prompt from that directory. Everything here is
Deserialize + a small number of default_* functions for fields
that have sane defaults.
error.rs — the BotError enum
src/error.rs
defines BotError, the project-wide error type. Five variants —
Serenity, Sqlx, Reqwest, SerdeJson, Other(String) — with
From impls so every fallible call site can use ?. It implements
Display and std::error::Error, which is enough for poise to accept
it as the E in Context<'_, Data, E>. Commands that return
Err(...) surface through poise’s on_error handler in main.rs.
commands/ — the command tree
Every user-facing prefix command lives under
src/commands/.
The key file is
commands/mod.rs:
it declares the parent m command and lists every subcommand with
#[poise::command(prefix_command, subcommands(...))]. This is the one
place you register commands — main.rs only ever pushes the single
parent m into the framework’s commands vec. There are no slash
commands anywhere in this codebase. Every command is
prefix_command only, usually with a rename and short aliases.
The individual files group commands by area:
admin.rs—setlog,djrole,djmode. Server-admin settings that live in theguild_settingstable.moderation.rs—ban,unban,banlist,nuke. Tempbans go throughdb::queries::create_tempban, which returns the expiry timestamp; themain.rsbackground task later callshttp.remove_banwhen expired bans are found.music.rs—play,playlist,skip,stop,pause,resume,queue,nowplaying,remove,loop_cmd,shuffle. All of them call intomusic::voiceandmusic::player::GuildPlayerthroughData.connections.rs,wordle.rs— thin wrappers over the corresponding game module. Each creates aGamestruct, sends the initial embed + buttons, and inserts the game into the channel map onData.stocks.rs—stockparent withbuy,sell,portfolio,price,leaderboard,history,resetsubcommands. The module refuses to run withoutFINNHUB_API_KEYviarequire_finnhub_key.minecraft.rs— theverifysubcommand, which is only pushed into the parentmat startup whenfeatures.minecraftandminecraft.verifyare both enabled inconfig.toml.help.rs— renders the help embed, dynamically showing the moderation and admin sections only if the invoking member has the matching permissions.
If you’re adding a command, the Adding a Command page has a full worked example; start there rather than copying blindly from any of these files.
events/ — gateway event dispatcher
events/mod.rs
is the non-command event handler. It’s one big match over
poise::serenity_prelude::FullEvent:
Ready→ready::handle_readyplus a one-shot MCP server start guarded bydata.mcp_started.swap(true)so gateway reconnects don’t re-launch the HTTP server.VoiceStateUpdate→voice_state::handle_voice_state_update, which triggers the “auto-leave when the channel is empty” behaviour.Message→handle_message, the largest branch. It does three things in order: bumps themember_activityrow for auto-role, intercepts 5-letter messages as Wordle guesses in channels with an active game, and — if the message mentions the bot or replies to a bot message — hands off toai::deepseek::handle_mention.InteractionCreate(Component) → routes to one of the button handlers by prefix:music_*,game_*(Connections),cb_*(chargeback buttons). Music buttons enforce “you must be in the same voice channel” and the DJ mode check.GuildMemberAddition→member_join::handle_member_join, which applies the join role and (if enabled) generates a welcome message through the AI pipeline.
events/ready.rs is tiny (it just logs and sets a presence string).
events/voice_state.rs checks whether the bot is alone in its voice
channel and cancels playback / disconnects. events/member_join.rs
handles both static join roles and the AI-generated welcome flow with
its own rate limit (last_welcome).
When adding a new reactive behaviour — for example, a new button prefix — the routing lives here.
ai/ — the AI chat pipeline
src/ai/
is the @mention pipeline. It’s the single largest subtree in the
codebase and the one where you should start by reading the
AI Pipeline architecture page rather
than the files.
chat.rs— by far the biggest file in the project. It ownshandle_mention, the message-history builder, provider dispatch through theAiProvidertrait +ProviderRouter, and every tool implementation the bot exposes to the LLM. If a tool call resolves to playing music, creating a tempban, or starting a game, the dispatch lives here.tools.rs— JSON schema definitions for the tool set the bot advertises to DeepSeek/Gemini/Claude:web_search,play_song,skip,stop,pause,resume,show_queue,now_playing,shuffle,set_loop,remove_from_queue,tempban,unban,nuke,stock_buy,stock_sell,stock_price,stock_portfolio,stock_leaderboard,connections_start,wordle_start, and a few others. Predicate helpers (is_search_tool,is_moderation_tool, …) are used bychat.rsto route each tool call.dsml.rs— parses “DSML” (DeepSeek Markup Language) tool-call blocks embedded in model output, for models that emit structured tool calls in prose rather than in the OpenAI-styletool_callsarray.sanitize.rs— strips role markers, DeepSeek control tokens, and Llama-style[INST]/<<SYS>>markers from user input so users can’t trivially inject prompts.split.rs— splits responses over Discord’s 2000-character message limit without breaking code fences or multi-byte chars.search.rs— DuckDuckGo scraper, implemented viacurlrather thanreqwestbecause DDG returns a non-result page when it seesreqwest’s default headers.confirmation.rs— wraps moderation tool calls with a “react to confirm” button flow, with a 30-second timeout, so the AI can’t tempban someone without explicit human sign-off.
music/ — the music player
src/music/
has four files and is straightforward if you read them in order.
player.rs— theGuildPlayerstruct: aVecDeque<Track>, acurrent: Option<Track>, apaused: bool, aLoopMode, and aMAX_QUEUE_LENGTHof 100. All the queue operations (enqueue,advance,skip_current,shuffle,remove) live here. It has zero I/O; just data.track.rs— wrapsyt-dlpas a subprocess.resolve_trackandresolve_tracksspawn yt-dlp with the project’s cookie jar and node-runtime path, parse the JSON output, and returnTrackvalues. It also exposesytdlp_user_argswhichvoice.rspasses into songbird’sYoutubeDlinput source.voice.rs— the songbird wrapper.join_channel,play_track,stop_playback,leave_channel, and theTrackEndHandlerthat advances the queue when a track finishes.PlaybackContextis the cloned state bundle passed into event handlers.embeds.rs— builds the “now playing”, “added to queue”, and “queue” embeds, plus the two button rows (music_controls). Button IDs aremusic_pauseresume,music_skip,music_stop,music_shuffle,music_loop,music_queue; this is whatevents/mod.rsdispatches on.
wordle/, connections/, stocks/ — the games
Each game module has the same shape:
game.rs— the pure-Rust game state (guess list, selected words, mistakes remaining, game-over / won predicates).api.rs— the upstream fetch (NYT Wordle JSON, NYT Connections JSON, or Finnhub stock quotes, with a 60-secondstock_price_cachetable in front of the Finnhub calls).embeds.rs— the Discord rendering (emoji grid for Wordle, the 4×4 button grid for Connections, portfolio/leaderboard/transaction history for stocks).
Wordle lives in
src/wordle/
and also ships a words.txt wordlist compiled in via include_str!.
Connections is in
src/connections/.
Stocks is in
src/stocks/
and, unlike the word games, has no game.rs — it has no in-memory
session state because every holding is persisted to Postgres.
minecraft/ — the Minecraft integration
src/minecraft/
glues the bot to an external Minecraft server’s HTTP API. Three files:
api.rs— the wire types (VerifyRequest,VerifyResponse) and theverify()HTTP call that posts a code + Discord ID to the MC server’s verify endpoint.donator_sync.rs—fetch_donatorspulls the current donator list from the MC server;sync_rolesreconciles it against Discord role state, adding and removing supporter/premium roles. This is driven by thecheck_intervalloop spawned inmain.rs.chargeback.rs— anaxum::Routerthat listens for webhook posts from the MC server when a chargeback happens. The router verifies the secret, looks up the Discord user by UUID, posts a message to the staff channel with restrict/ignore buttons, and handles those button clicks (thecb_*custom ID prefix dispatched inevents/mod.rs). The router is optional and is only mounted under the MCP axum app whenminecraft.chargeback = true.
mcp/ — the embedded MCP server
src/mcp/
is the in-process Model Context Protocol server. Two files:
mod.rs— sets uprmcp’sStreamableHttpService, wraps it in an axum app with a bearer-token middleware, optionally mounts the chargeback webhook router under the same app, and binds onMCP_BIND_ADDR:MCP_PORT.tools.rs— theDiscordToolsstruct with aToolRouterand one#[tool]method per exposed capability: listing channels, reading messages, sending messages, managing roles, and so on. Every method wraps the serenity HTTP call with a 10-secondtimeoutand converts errors via a pair of helpers (api_err,timeout_err).
If you’re adding an MCP tool, Adding an MCP Tool walks through the macros and registration.
db/ — Postgres and queries
src/db/
has three files:
mod.rs—init_pool. Connects once, runsCREATE SCHEMA IF NOT EXISTS "{schema}", then creates aPgPoolOptionswith anafter_connectthat setssearch_pathon every new connection. This is how multi-instance schema isolation works: every instance uses the sameDATABASE_URLbut its ownDB_SCHEMA. After the pool is built,mod.rsruns everyCREATE TABLE IF NOT EXISTSstatement for the bot’s schema.models.rs— oneFromRowstruct per table:Tempban,GuildSettings,MemberActivity,StockPortfolio,StockHolding,StockTransaction,StockPriceCache.queries.rs— every query function.get_guild_settings,upsert_guild_setting,create_tempban,get_expired_bans,mark_unbanned,get_stock_portfolio,buy_stock,sell_stock, and so on. Queries use the rawsqlx::query*helpers withbindrather than the compile-time-checkedquery!/query_as!macros, so the crate builds without a live database at compile time.
autorole.rs — the role-promotion rule
src/autorole.rs
is a single small module: meets_criteria(activity, config) evaluates
age and message-count thresholds against an AutoRoleConfig, and
try_promote adds the target role, removes the source role, and marks
the row promoted = true in member_activity. It’s called from two
places: the background time-check loop in main.rs, and the
handle_message hot path in events/mod.rs so promotion happens
immediately on the message that tips the count over.
util/ — small helpers
util/duration.rs—parse_duration("3d","2h","30m", capped at 365 days) andformat_duration_ms/format_track_duration. Used by tempbans, auto-rolemin_age, and the music “now playing” line.util/ratelimit.rs— theSlidingWindowLimiter(aDashMap<String, Vec<Instant>>) andRateLimiters, which bundles four limiters:ai(10/60s),music(15/30s),moderation(5/60s), andstocks(10/30s). Every limiter is keyed by a stringified user ID.
The mcp-gateway crate
mcp-gateway/
is a small separate crate that sits in front of one or more running bot
instances and exposes a single MCP endpoint to an outside AI client.
Five files:
main.rs— buildsGatewayConfig, constructs oneBackendClientper configured instance, wires them into aGatewayState, mounts the axum router, binds, and spawns a 5-minute background task that refreshes the guild map.config.rs— readsGATEWAY_PORT(default9100), the optionalMCP_AUTH_TOKENbearer secret, and the requiredINSTANCESenv var of the formname1=url1,name2=url2into a list ofInstance { name, url }.backend.rs— the per-instance MCP client. Opens a Streamable HTTP SSE session to a bot’s MCP endpoint and exposesinitialize,list_tools,call_tool,list_guilds, andhealth_checkfor the gateway server to call.routing.rs— the guild-to-instance router. Keeps aHashMap<guild_id, instance_name>so that the gateway can look at a tool call’sguild_idand forward it to the right backend.server.rs— the axum handlers.POST /mcpaccepts a JSON-RPC envelope, authenticates with the optional bearer token, resolves the target backend via the router, forwards the call, and streams the response back. There’s also a cachedtools/listaggregation so the client sees the union of every backend’s tools, plus a gateway-onlylist_instancestool.
MCP Gateway Routing has the sequence diagram.
Cross-cutting patterns
A few conventions hold across the whole codebase. If you’re not sure how to do something, follow the pattern that already exists.
- Every command takes
Context<'_>— that’s the type alias forpoise::Context<'a, Data, BotError>at the bottom ofsrc/main.rs. You access shared state throughctx.data(), which hands you back a&Data. - Every DB query is in
db/queries.rs— commands do not build SQL inline. If you need a new query, add a function there and call it from the command. - Every long-running background task is a
tokio::spawninmain(), cloning theArc-friendly parts ofDatait needs (db.clone(),http.clone()). Tasks log viatracing::info!on startup andtracing::warn!/tracing::error!on failure, and they never panic — errors are logged and the loop keeps going. - Every optional feature is gated twice: once by the
features.<name>flag inconfig.toml, and once by the presence of the feature’sOption<Config>onData. The event handler and the command code both early-return when the feature is disabled. - Locking discipline is flat:
DashMap<GuildId, Arc<Mutex<T>>>for per-guild state. Look up the entry, clone theArc, acquire theMutex, do the work, drop the guard. Never hold aDashMapentry across an.await. - No
unwrap()in hot paths. Startup code inmain.rsis allowed to.expect()on missing env vars and pool creation; everything downstream returnsBotError.
Where to look for…
Use this table as a lookup when you can’t remember where something lives.
| Looking for | Start here |
|---|---|
| Adding a new command | commands/mod.rs and Adding a Command |
| Adding a new event handler branch | events/mod.rs |
| Adding a new AI tool | ai/tools.rs and the dispatch in ai/chat.rs |
| Adding a new MCP tool | mcp/tools.rs and Adding an MCP Tool |
| Adding a whole new feature module | Adding a Feature Module |
| Adding a DB table | db/mod.rs (CREATE TABLE), db/models.rs (struct), db/queries.rs (functions) |
| Changing rate limits | util/ratelimit.rs (RateLimiters::new) |
| Changing the default personality | The instance’s personality.txt, not the code |
| Adding a required env var | config.rs, then propagate through Data |
| Adding a per-instance TOML option | instance_config.rs, then main.rs wiring |
| Changing the music queue size | MAX_QUEUE_LENGTH in music/player.rs |
| Fixing Wordle validation | is_valid_word and words.txt in wordle/game.rs |
| Discord permission checks | ctx.author_member() + member_permissions — see commands/help.rs for the pattern |
Code style conventions
Things you’ll notice reading the code, and things reviewers will ask for on PRs:
- Hard tabs, width 4.
rustfmt.tomlenforces this. Runcargo fmtbefore opening a PR. ?overunwrapin fallible code.expectis only acceptable at startup inmain.rs.- Errors convert through
BotErrorviaFromimpls; commands and event handlers returnResult<(), BotError>. tracingoverprintln!. All logs go throughtracing::info!,warn!,error!so they’re structured and filterable viaRUST_LOG.- No
asyncclosures insideDashMapcallbacks. Look up, clone theArc, drop the entry guard, then.awaiton the clone. - Feature-gated startup is verbose by design —
main.rslogs each optional feature’s activation atinfo!and warns loudly if the TOML config is missing. Copy that pattern for new features.
Next steps
- Building Locally — run the crate with
cargooutside of Docker. - Adding a Command — the shortest path from “I want a new command” to “PR ready.”
- Adding a Feature Module — when a
feature is big enough to deserve its own directory under
src/. - Adding an MCP Tool — exposing a new capability to external MCP clients.
- Testing — what the test surface looks like today and where to add coverage.
Building Locally
This page is for the case where you want to skip Docker and run the bot
directly with cargo. That’s the right setup for active development —
the rebuild loop is faster, you get full IDE integration, and the
process is yours to attach a debugger to. If you just want a bot to talk
to in Discord, use the Quickstart
instead — Docker is the easier path.
By the end of this page you’ll have the main crate compiling, a
PostgreSQL database it can talk to, an instance directory pointing at
your bot, and cargo run producing a live Discord bot.
What you need installed
Three groups of dependencies: the Rust toolchain, the system libraries the bot links against, and the runtime tools it shells out to.
Rust toolchain
Install rustup and use a stable toolchain:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup default stable
The crate targets Rust 2021 edition and tracks the latest stable release. Anything from the last few stable versions should compile.
System libraries
The Discord client links against Opus and libsodium for voice, and the
build itself needs cmake for some transitive C dependencies. On
Debian/Ubuntu:
sudo apt-get install -y cmake libopus-dev libsodium-dev libssl-dev pkg-config
On macOS with Homebrew:
brew install cmake opus libsodium openssl pkg-config
If you forget one of these, cargo build fails partway through with a
linker error mentioning the missing library. The fix is always to
install it and run cargo build again — there’s no need to
cargo clean.
Runtime tools
The music feature shells out to ffmpeg and yt-dlp at runtime. They
don’t need to be present at build time, but the bot will fail to play
anything without them:
sudo apt-get install -y ffmpeg
pip install --user yt-dlp
Make sure both are on your PATH. which ffmpeg && which yt-dlp
should print two paths.
PostgreSQL
The bot needs a PostgreSQL instance to connect to. The easiest option is the bundled Compose service, even when the bot itself is not in Docker:
docker compose up -d postgres
This starts PostgreSQL 17 on localhost:5432 with the credentials the
default DATABASE_URL expects. Stop and remove it later with
docker compose down.
If you’d rather use a system-installed Postgres, create a database and
user that match your DATABASE_URL:
CREATE USER discord_bot WITH PASSWORD 'discord_bot_pass';
CREATE DATABASE discord_bot OWNER discord_bot;
The bot creates its own schema (CREATE SCHEMA IF NOT EXISTS "<name>")
on startup, so you don’t need to do that yourself. The user just needs
permission to create schemas in the database.
Discord application
You need a Discord bot token. If you don’t have one, follow the Prerequisites page to create the application, enable the Message Content Intent and Server Members Intent, and invite the bot to a test server. Save the token, the client ID, and the guild ID — you’ll paste all three into your environment in a moment.
Set up an instance directory
Each running bot needs an instance directory containing config.toml,
personality.txt, and a .env file. The instances/example/
directory is the canonical starter. For local work, copy it:
cp -r instances/example instances/local
cp instances/local/.env.example instances/local/.env
Edit instances/local/.env and fill in:
DISCORD_TOKEN=<your token>
CLIENT_ID=<your application id>
GUILD_ID=<your test server id>
DATABASE_URL=postgresql://discord_bot:discord_bot_pass@localhost:5432/discord_bot
DB_SCHEMA=local
If you want AI chat, add DEEPSEEK_API_KEY=... and/or
GEMINI_API_KEY=.... If you want stock trading, add
FINNHUB_API_KEY=.... Anything you leave unset just disables the
corresponding feature; the bot still boots.
config.toml ships with sensible defaults — change bot_name and
command_prefix if you want, and leave every feature flag in
[features] set to false for your first run.
Build and run
The bot reads its instance directory from the CONFIG_DIR environment
variable (default: the current directory). Point it at the directory
you just set up:
CONFIG_DIR=instances/local cargo run
The first build is slow — ten minutes or so on a laptop, longer on a
small VPS. cargo is downloading and compiling about 400
dependencies. Subsequent rebuilds with no changes take seconds; an
incremental change in one file usually takes 5–20 seconds to
rebuild.
When the build finishes you should see startup logs that end with something like:
INFO discord_bot::db: Database initialized (schema: local).
INFO discord_bot: Instance config loaded: Example Bot (prefix: !)
INFO discord_bot: Starting bot...
INFO discord_bot::events::ready: Example Bot is connected! (ID: ...)
The bot now appears online in your test server. !m help should
respond.
Press Ctrl+C to stop. There’s no graceful shutdown sequence — the
process exits immediately and the next start picks up from a clean
slate (with persisted Postgres state intact).
Speeding up the inner loop
A few things help once you’re iterating:
cargo checkis much faster thancargo buildand is enough to catch type errors. Use it while writing code, thencargo runwhen you’re ready to test in Discord.sccache(cargo install sccache && export RUSTC_WRAPPER=sccache) caches incremental builds acrosscargo cleans and across branches. It’s especially useful if you switch between branches with different dependency sets.moldis a faster linker than the default GNU ld. Install it, then add a.cargo/config.tomlsnippet pointing the linker atmold. On Linux this can cut link time from 10 seconds to under 1.- The dev profile is unoptimized. That’s deliberate — debug builds
are smaller and faster to produce. Don’t reach for
--releaseunless you’re profiling. The bot is plenty fast in debug.
Building the gateway crate
The mcp-gateway/ directory is a separate crate. It builds
independently and is not part of cargo run for the main crate. To
build or test it, either cd mcp-gateway and run cargo there, or use
--manifest-path from the repo root:
cargo check --manifest-path mcp-gateway/Cargo.toml
cargo test --manifest-path mcp-gateway/Cargo.toml
CI runs format, clippy, build, and test on both crates separately. Do the same locally before opening a PR — see Contributing Workflow.
Building the docs
The mdBook lives under docs/ and is built with
mdBook. Install once:
cargo install mdbook
Then build or live-preview from the repo root:
mdbook build # writes static HTML to ./book
mdbook serve # live-reloading preview on http://localhost:3000
The published site is generated by GitHub Actions from master; you
don’t need to commit book/.
Common build problems
error: linker 'cc' not found— installbuild-essential(or the equivalent toolchain on your platform). On macOS, install Xcode Command Line Tools (xcode-select --install).failed to find tool. Iscmakeinstalled?— exactly what it says: install cmake.could not find native library 'opus'— installlibopus-devon Debian, oropusvia Homebrew on macOS.Failed to connect to databaseat startup — check that PostgreSQL is running, the credentials matchDATABASE_URL, and that the user can connect from your host.psql "$DATABASE_URL"is the quickest way to confirm.<KEY> must be set in .envat startup — the loader couldn’t find a required variable. Either you didn’t set it, orcargo runisn’t picking up.env. The bot reads.envfrom the current working directory; running from the repo root withCONFIG_DIRpointed at your instance is the standard pattern.<KEY> has placeholder value— you copied.env.examplebut didn’t fill in real values. Edit.envand replace anyyour-...placeholders.
Next steps
- Adding a Command — write your first command
and watch it appear after the next
cargo run. - Adding a Feature Module — for changes bigger than a single command.
- Debugging — tracing,
RUST_LOG, and what to do when things hang. - Contributing Workflow — fork → PR → merge.
Adding a Command
This page walks through adding a new command to the bot from scratch.
By the end you’ll have a working !m echo <text> command, understand
where it’s registered, and know the handful of gotchas that catch
first-time contributors.
Before you start, read the Codebase Tour — at least
the sections on main.rs, commands/, and the Data struct. This
page assumes you have a local build working (see
Building Locally).
One thing you need to know up front
This bot has no slash commands. Every user-facing command is a
prefix command, and every prefix command is a subcommand of a single
parent command named m. So the user types !m play, !m wordle,
!m help, and so on, and the framework dispatches to a subcommand
registered on that parent.
There are two consequences:
- Your
#[poise::command(...)]attribute should useprefix_command, notslash_command. - “Register the command” means adding it to the
subcommands(...)list on the parentmcommand insrc/commands/mod.rs. It does not mean touchingsrc/main.rs.main.rsonly ever registers the single parentm; every real command is reached through it.
Forgetting step 2 is the single most common mistake: you write the
function, cargo check passes, the bot boots, and your command
silently doesn’t exist because nothing ever wired it into the parent.
Choose a file
Commands are grouped by area under
src/commands/.
The existing files are admin.rs, connections.rs, help.rs,
minecraft.rs, moderation.rs, music.rs, stocks.rs, and
wordle.rs.
If your command fits an existing category, add it to the matching file.
If it doesn’t — say, you’re adding a brand-new feature — create a new
file and add pub mod yourfeature; at the top of
src/commands/mod.rs
before the subcommands list.
For this walkthrough we’ll add an echo command. We’ll put it in a
new file, src/commands/echo.rs, so you can see the full wiring.
Write the function
Here’s the complete command:
// src/commands/echo.rs
use crate::error::BotError;
use crate::Context;
/// Echo a message back to the channel
#[poise::command(prefix_command, rename = "echo", aliases("say"))]
pub async fn echo(
ctx: Context<'_>,
#[description = "Text to echo"]
#[rest]
text: String,
) -> Result<(), BotError> {
if text.trim().is_empty() {
ctx.say("Give me something to echo.").await?;
return Ok(());
}
ctx.say(&text).await?;
Ok(())
}
A few things to notice:
- The
Context<'_>alias comes fromcrate::Context, defined at the bottom ofsrc/main.rsaspoise::Context<'_, Data, BotError>. Use the alias. prefix_commandtells poise this is a message-content command, not a slash command.rename = "echo"makes the invoked nameechoinstead of the function name. In this case they match, sorenameis redundant — but most commands in the codebase use it for clarity and to keep the Rust function name free of reserved words (for example,loop_cmdrenamed toloop).aliases("say")lets users type!m sayas an alternative. Aliases are short.#[description = "..."]on parameters is required for any command that might ever have a generated help page. Provide it for every parameter.#[rest]on aStringparameter tells poise to take the rest of the message as one argument instead of splitting on whitespace. Without#[rest],!m echo hello worldwould fail with “too many arguments.” With#[rest], it works.- Return
Result<(), BotError>— always. All errors convert throughBotError’sFromimpls, so?works on serenity, sqlx, reqwest, and serde_json errors.
Register the command
Open
src/commands/mod.rs.
It looks like this:
pub mod admin;
pub mod connections;
pub mod help;
pub mod minecraft;
pub mod moderation;
pub mod music;
pub mod stocks;
pub mod wordle;
use crate::error::BotError;
use crate::Data;
#[poise::command(
prefix_command,
subcommands(
"music::play",
"music::playlist",
// ... many more ...
"help::help",
)
)]
pub async fn m(_ctx: poise::Context<'_, Data, BotError>) -> Result<(), BotError> {
Ok(())
}
Make two edits. First, add your new module at the top of the file:
pub mod echo;
Second, add your command to the subcommands(...) list:
subcommands(
// ... existing entries ...
"echo::echo",
"help::help",
)
The string is "<module>::<function>". Order in the list doesn’t
affect functionality, but match the grouping of nearby commands if you
can — it keeps the help output tidy.
That’s it. cargo check, rebuild, restart the bot, and !m echo hello works.
Parameters
Poise supports most things you’d expect from an argument parser.
Optional parameters
Wrap the type in Option<T>:
pub async fn echo(
ctx: Context<'_>,
#[description = "Text to echo (defaults to a greeting)"]
text: Option<String>,
) -> Result<(), BotError> {
let text = text.unwrap_or_else(|| "hello!".into());
ctx.say(&text).await?;
Ok(())
}
Discord types
Poise auto-parses serenity model types: serenity::all::Member,
User, Role, Channel. See src/commands/moderation.rs — the
ban command takes target: serenity::all::Member directly:
pub async fn ban(
ctx: Context<'_>,
#[description = "User to ban"] target: serenity::all::Member,
#[description = "Duration (e.g. 3d, 2h, 1w)"] duration_str: String,
#[description = "Reason"]
#[rest]
reason: Option<String>,
) -> Result<(), BotError> { /* ... */ }
!m ban @user 3d flood gets parsed into three typed arguments.
Integers and booleans
i64, u64, bool all work out of the box. For enum inputs, define
an enum and derive poise::ChoiceParameter.
Permission gates
Use the required_permissions attribute to restrict the command:
#[poise::command(
prefix_command,
rename = "setlog",
required_permissions = "ADMINISTRATOR"
)]
See src/commands/admin.rs and src/commands/moderation.rs for the
pattern. Users who lack the permission get a clean “missing
permissions” error, and you don’t have to check in the function body.
Reading Data
Every command has access to the shared Data struct through
ctx.data(), which returns &Data. A few common patterns:
let db = &ctx.data().db; // sqlx::PgPool
let http = &ctx.data().http_client; // reqwest::Client
let bot_name = &ctx.data().bot_name;
let personality = &ctx.data().personality;
// Feature configs are Option<T>:
if let Some(cfg) = &ctx.data().auto_role_config {
// feature is enabled
}
For per-guild state (music players, active games), use the matching
DashMap on Data. See get_or_create_player in
src/commands/music.rs for the standard lookup-or-insert pattern.
Responding to the user
Poise gives you a few ways to reply:
ctx.say("...")— the simplest: send a text message to the current channel.ctx.reply("...")— likesay, but uses Discord’s reply feature so the message shows as a reply to the invoker.ctx.send(poise::CreateReply::default().embed(e).components(v))— the full builder. Use this when you want an embed, buttons, ephemeral flag, or anything beyond text.
For an ephemeral reply (only the invoker sees it):
ctx.send(
poise::CreateReply::default()
.content("This is only visible to you.")
.ephemeral(true),
).await?;
Note that ephemeral replies only work in certain contexts in prefix commands; if yours doesn’t render ephemerally, fall back to plain replies or DMs.
Slow commands: defer first
If your command takes more than about three seconds — say, it hits an
HTTP API or spawns a subprocess — call ctx.defer_or_broadcast()
before the slow work:
pub async fn play(
ctx: Context<'_>,
#[description = "Song name or URL"]
#[rest]
query: String,
) -> Result<(), BotError> {
// ... cheap checks ...
ctx.defer_or_broadcast().await?;
// ... resolve the track, join voice, start playback ...
}
For prefix commands, defer_or_broadcast sends a typing indicator so
the channel knows the bot is working on it. See the play command in
src/commands/music.rs for the pattern.
Feature-gating your command
If your command belongs to an optional feature, check the feature flag before doing work:
let cfg = match ctx.data().auto_role_config.as_ref() {
Some(c) => c,
None => {
ctx.say("Auto-role isn't enabled on this instance.").await?;
return Ok(());
}
};
If the feature is conditionally registered at startup — like the
Minecraft verify subcommand, which is only pushed into m when
features.minecraft and minecraft.verify are both true — follow the
same pattern as in src/main.rs:
let mut m_cmd = commands::m();
if instance_cfg.features.minecraft {
if let Some(ref mc) = instance_cfg.minecraft {
if mc.verify {
m_cmd.subcommands.push(commands::minecraft::verify());
}
}
}
This keeps the command completely absent from !m help on instances
where the feature is off.
Rate limiting
The bot has a RateLimiters bundle on Data with four sliding-window
limiters: ai, music, moderation, and stocks. If your command
should be rate limited, pick the closest bucket (or add a new one in
src/util/ratelimit.rs) and check it at the top of the function:
let cooldown = ctx
.data()
.rate_limiters
.music
.check(&ctx.author().id.to_string());
if cooldown > 0 {
ctx.say(format!("Rate limited — try again in {cooldown}s.")).await?;
return Ok(());
}
check returns 0 if the call is allowed and the number of seconds
until reset if not. See ai/chat.rs for real call sites.
Testing it
Rebuild and run the bot locally (see Building Locally). The fast loop is:
cargo run
# in another terminal, wait for "Starting bot..."
In Discord, test the command you just added. Prefix commands are immediate — there’s no global sync delay the way slash commands have, which is one reason this project uses them exclusively.
A manual test plan for !m echo:
!m echo hello world→ the bot replies withhello world.!m say hello(the alias) → same thing.!m echo(no text) → the bot replies with “Give me something to echo.”!m echowith multi-word text containing punctuation → parses correctly because of#[rest].
Before opening a PR, run:
cargo fmt
cargo clippy --all-targets -- -D warnings
cargo test
CI runs the same commands; getting them green locally saves a round trip.
Common gotchas
- Forgot the
subcommandsentry. Your command compiles, the bot boots clean, and!m echodoes nothing. Go back tosrc/commands/mod.rsand add"echo::echo"to the list. - Missed
#[rest]. Multi-word arguments fail with “too many arguments.” Add#[rest]to the lastStringparameter. - Used
slash_command. Slash commands aren’t enabled in this crate’s framework builder, so your command silently never registers. Useprefix_command. - Returned
anyhow::Erroror a custom type. Poise needs the error type to matchData’s error parameter — returnResult<(), BotError>, and let?convert from whatever you’re calling. - Held a
DashMapentry across an.await. This will deadlock or panic. Clone the innerArcout, drop the entry guard, then await. - Didn’t add a description. Commands without
#[description = "..."]on their parameters compile fine but look terrible in any generated help.
Worked examples in the codebase
When in doubt, copy from a command that already works:
- Simplest:
commands/help.rs— no parameters, renders an embed. - Parameters and permission gating: the
bancommand incommands/moderation.rs. - Defers for slow work: the
playcommand incommands/music.rs. - Own subcommands:
wordleincommands/wordle.rs—!m wordleplays today’s puzzle,!m wordle randompicks one. - Conditionally registered:
verifyincommands/minecraft.rs, pushed intomonly when enabled inconfig.toml.
Next steps
- Adding a Feature Module — if your
command is the tip of an iceberg and you need to create a whole new
src/<yourfeature>/directory with state, config, and event handlers, start there next. - Testing — how and where to add unit tests.
- Contributing Workflow — the end-to-end fork → PR → merge flow.
Adding a Feature Module
A “feature module” is what this project calls a top-level directory
under src/ — wordle/, music/, connections/, minecraft/, and
so on. Use this page when your change is bigger than a command: when
you’re introducing new state, new background work, new database
tables, or a meaningful new event handler. If all you want is a single
new command in an existing area, the Adding a Command
page is the shorter path.
This page walks through building a small feature module called
reminders — store reminders in the database, fire them via a
background task, and expose !m remind and !m reminders commands.
By the end you’ll have touched every part of the codebase a real
feature touches and you’ll know the conventions for each one.
Read the Codebase Tour first if you haven’t —
this page assumes you’re familiar with Data, BotError, and the
shape of commands/mod.rs.
What a feature module looks like
Open the wordle/ directory: mod.rs, game.rs, api.rs,
embeds.rs, plus a words.txt data file. That’s the canonical
shape:
mod.rsdeclares the public submodules and re-exports the types other modules need.game.rs(or whatever name fits) holds the pure-Rust state and logic — no I/O, no Discord types beyond what’s needed for IDs.api.rswraps any external HTTP calls.embeds.rsbuilds Discord embeds and component rows.
You don’t have to copy that exact split — the music module has
player.rs, track.rs, voice.rs, and embeds.rs instead — but
keep the same posture: separate the data, the I/O, and the rendering.
Step 1: Create the directory
mkdir src/reminders
touch src/reminders/{mod.rs,model.rs,scheduler.rs}
We’ll put the reminder data type in model.rs and the background
firing logic in scheduler.rs. Most features end up with three to
five files; resist the urge to make mod.rs itself long.
src/reminders/mod.rs is the single re-export and submodule
declaration:
pub mod model;
pub mod scheduler;
pub use model::Reminder;
Step 2: Wire the module into main.rs
Open src/main.rs. At the very top, with the other mod lines, add
yours in alphabetical order:
mod ai;
mod autorole;
mod commands;
mod config;
mod connections;
mod db;
mod error;
mod events;
mod instance_config;
mod mcp;
mod minecraft;
mod music;
mod reminders; // <-- new
mod stocks;
mod util;
mod wordle;
Until this line exists, nothing in src/reminders/ actually compiles.
Forgetting to add it is the most common mistake on a fresh module:
your IDE may show your code as fine, but cargo build reports the
files don’t exist as a module. The cure is one line.
Step 3: Decide what state you need on Data
Open the Data struct in src/main.rs. Anything your feature needs
to share across commands, events, and background tasks goes here.
Three kinds of state are common:
- Per-guild or per-channel state — use a
DashMap<GuildId, Arc<Mutex<T>>>(seewordle_games,connections_games,guild_players). - Loaded config — if your feature is opt-in via
config.toml, add anOption<YourConfig>field next toauto_role_config,minecraft_config, etc. - One-shot startup flags —
AtomicBools for things that should run once. The MCP server usesmcp_startedfor this.
For reminders, the firing logic is in a single background task and
the data lives in Postgres, so we don’t need any per-guild map. We do
want optional config — say, a maximum number of pending reminders per
user. So extend Data:
pub struct Data {
// ... existing fields ...
pub reminders_config: Option<instance_config::RemindersConfig>,
}
And construct it in setup:
Ok(Data {
// ... existing fields ...
reminders_config: instance_cfg.reminders.clone(),
})
Cheap to clone (it’s a small struct), cheap to read, no synchronisation
overhead. If you needed a DashMap for live state, you’d add it the
same way — initialised with Arc::new(DashMap::new()).
Step 4: Add a config.toml section (if needed)
If your feature is opt-in, instance_config.rs is where you teach the
bot to read it. Add a feature flag to the Features struct:
#[derive(Debug, Deserialize, Default)]
pub struct Features {
#[serde(default)]
pub minecraft: bool,
#[serde(default)]
pub auto_role: bool,
#[serde(default)]
pub join_role: bool,
#[serde(default)]
pub welcome: bool,
#[serde(default)]
pub reminders: bool, // <-- new
}
And the typed config struct alongside the others:
#[derive(Debug, Deserialize, Clone)]
pub struct RemindersConfig {
#[serde(default = "default_max_reminders")]
pub max_per_user: i64,
}
fn default_max_reminders() -> i64 {
20
}
Then add the optional field to InstanceConfig:
pub struct InstanceConfig {
// ... existing fields ...
pub reminders: Option<RemindersConfig>,
}
Update instances/example/config.toml with a commented-out example
block so users discover the option:
# [features]
# reminders = true
#
# [reminders]
# max_per_user = 20
In main.rs, follow the existing “feature gated twice” pattern when
loading the config:
let reminders_config = if instance_cfg.features.reminders {
match &instance_cfg.reminders {
Some(cfg) => {
tracing::info!("Reminders module enabled (max_per_user={})", cfg.max_per_user);
Some(cfg.clone())
}
None => {
tracing::warn!("Reminders feature enabled but [reminders] config section missing");
None
}
}
} else {
None
};
This is verbose by design — main.rs logs every feature’s activation
at info! and warns loudly when the config is half-set. Copy the
pattern; reviewers will ask for it.
Step 5: Add a database table
If your feature persists anything, the table belongs in src/db/.
Add a new migration file under migrations/ with a
<timestamp>_<name>.sql name (copy the format of the existing
files — the timestamp ordering is load-bearing). sqlx::migrate!
picks it up automatically at startup:
-- migrations/20260501000000_reminders.sql
CREATE TABLE IF NOT EXISTS reminders (
id SERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
message TEXT NOT NULL,
fire_at TIMESTAMPTZ NOT NULL,
fired BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_reminders_pending
ON reminders (fire_at) WHERE fired = FALSE;
IF NOT EXISTS keeps the migration idempotent against pre-existing
databases (production schemas that pre-date the sqlx migration
system already contain the tables). Each instance’s
_sqlx_migrations table tracks which versions have run.
In src/db/models.rs, add a FromRow struct:
#[derive(Debug, sqlx::FromRow)]
pub struct Reminder {
pub id: i32,
pub guild_id: String,
pub user_id: String,
pub channel_id: String,
pub message: String,
pub fire_at: chrono::DateTime<chrono::Utc>,
pub fired: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
In src/db/queries.rs, add the functions your feature needs:
pub async fn create_reminder(
pool: &PgPool,
guild_id: &str,
user_id: &str,
channel_id: &str,
message: &str,
fire_at: chrono::DateTime<chrono::Utc>,
) -> Result<i32, sqlx::Error> {
let row: (i32,) = sqlx::query_as(
"INSERT INTO reminders (guild_id, user_id, channel_id, message, fire_at)
VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(guild_id)
.bind(user_id)
.bind(channel_id)
.bind(message)
.bind(fire_at)
.fetch_one(pool)
.await?;
Ok(row.0)
}
pub async fn get_due_reminders(pool: &PgPool) -> Result<Vec<crate::db::models::Reminder>, sqlx::Error> {
sqlx::query_as("SELECT * FROM reminders WHERE fired = FALSE AND fire_at <= NOW()")
.fetch_all(pool)
.await
}
pub async fn mark_reminder_fired(pool: &PgPool, id: i32) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE reminders SET fired = TRUE WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
Notice the project uses the runtime query / query_as helpers with
bind, not the compile-time query! / query_as! macros. The
choice is deliberate: it lets the crate build without a live database
at compile time. Stick with it.
Step 6: Implement the feature logic
src/reminders/model.rs is just a small re-export and any helpers
that don’t need DB access:
pub use crate::db::models::Reminder;
impl Reminder {
pub fn human_when(&self) -> String {
crate::util::duration::format_duration_ms(
(self.fire_at - chrono::Utc::now()).num_milliseconds().max(0),
)
}
}
src/reminders/scheduler.rs holds the firing loop:
use serenity::all::*;
use std::sync::Arc;
use std::time::Duration;
use crate::db;
pub async fn fire_due_reminders(http: Arc<Http>, db: sqlx::PgPool) {
match db::queries::get_due_reminders(&db).await {
Ok(due) => {
for r in due {
let Ok(channel_id) = r.channel_id.parse::<u64>() else { continue };
let _ = ChannelId::new(channel_id)
.say(
&http,
format!("<@{}>: {}", r.user_id, r.message),
)
.await;
if let Err(e) = db::queries::mark_reminder_fired(&db, r.id).await {
tracing::warn!("Failed to mark reminder {} fired: {e}", r.id);
}
}
}
Err(e) => tracing::warn!("Reminder fetch failed: {e}"),
}
}
pub fn spawn_loop(http: Arc<Http>, db: sqlx::PgPool, interval: Duration) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
tracing::info!("Reminder scheduler started ({}s interval).", interval.as_secs());
loop {
fire_due_reminders(http.clone(), db.clone()).await;
tokio::time::sleep(interval).await;
}
});
}
Step 7: Spawn the background task
Open main() in src/main.rs. Below the existing background-task
spawns (tempban unban checker, auto-role checker, donator sync), add
yours, gated on the feature flag:
if instance_cfg.features.reminders {
reminders::scheduler::spawn_loop(
client.http.clone(),
db_clone.clone(),
std::time::Duration::from_secs(30),
);
}
Match the conventions of the existing tasks: clone the Arc-friendly
parts of state, log on startup, log warnings on per-iteration errors,
and never panic.
Step 8: Add commands
Create src/commands/reminders.rs with one or two prefix subcommands:
use crate::error::BotError;
use crate::{db, Context};
#[poise::command(prefix_command, rename = "remind")]
pub async fn remind(
ctx: Context<'_>,
#[description = "When (e.g. 30m, 2h, 1d)"] when: String,
#[description = "Reminder message"]
#[rest]
message: String,
) -> Result<(), BotError> {
let Some(ms) = crate::util::duration::parse_duration(&when) else {
ctx.say("Invalid duration. Try `30m`, `2h`, `1d`.").await?;
return Ok(());
};
let fire_at = chrono::Utc::now() + chrono::Duration::milliseconds(ms);
let guild_id = ctx.guild_id().map(|g| g.to_string()).unwrap_or_default();
let id = db::queries::create_reminder(
&ctx.data().db,
&guild_id,
&ctx.author().id.to_string(),
&ctx.channel_id().to_string(),
&message,
fire_at,
)
.await?;
ctx.say(format!("Reminder #{} set.", id)).await?;
Ok(())
}
Then register the command. Open src/commands/mod.rs and add the
module declaration plus the subcommand entry:
pub mod reminders;
// ... existing pub mod lines ...
#[poise::command(
prefix_command,
subcommands(
// ... existing entries ...
"reminders::remind",
"help::help",
)
)]
pub async fn m(_ctx: poise::Context<'_, Data, BotError>) -> Result<(), BotError> {
Ok(())
}
If your feature needs to be conditionally registered based on the
TOML config — like minecraft::verify — push it into m_cmd.subcommands
in main.rs after the parent is built:
let mut m_cmd = commands::m();
if instance_cfg.features.reminders {
m_cmd.subcommands.push(commands::reminders::remind());
}
This keeps the command absent from !m help on instances where the
feature is off.
Step 9: Hook into events (optional)
If your feature needs to react to gateway events — messages, member
joins, button clicks — open src/events/mod.rs. The handler is one
big match over FullEvent variants; add or extend the arm you need.
For a button-driven feature, define a custom-ID prefix
(e.g. reminder_dismiss_<id>) and route on it in the
InteractionCreate arm the same way music_*, game_*, and cb_*
are routed today.
For reminders the only reactive piece would be cancelling a fired reminder via a button — small enough that you can leave it for a follow-up PR.
Step 10: Document the feature
User-visible features get:
- A page under
docs/features/yourfeature.md. Match the shape of an existing feature page, likedocs/features/auto-role.md. - An entry in
docs/SUMMARY.mdunder the Features section. - An entry in
docs/configuration/instance-config.mdif you added aconfig.tomlsection. - A row in
docs/reference/command-list.mdif you added a command. - A
CHANGELOG.mdentry under[Unreleased]inAdded.
Documentation is a load-bearing part of the change. PRs that ship a feature without it tend to bounce in review.
Step 11: Test the loop
Locally:
CONFIG_DIR=instances/local cargo run
In Discord:
!m remind 1m helloshould respond withReminder #1 set.and fire a minute later.!m remind banana helloshould respond withInvalid duration.!m remind 1y helloshould respond withInvalid duration.(the unit must be one ofs,m,h,d,w—yisn’t supported).
Add automated coverage where it’s cheap. parse_duration is already
unit-tested; if your feature has pure logic — a scheduler that picks
which reminders to fire, a permission check, a duration formatter —
test it the same way. See Testing for the project’s
current posture and what kinds of tests are most welcome.
Common gotchas
- Forgot the
modline inmain.rs. Your code doesn’t compile, with a confusing error. Addmod reminders;at the top. - Forgot to register the command. The command compiles, the bot
boots, and
!m reminddoes nothing. Add"reminders::remind"to thesubcommands(...)list incommands/mod.rs. - Held a
DashMapentry across an.await. Will deadlock under load. Look up the entry, clone theArc, drop the guard, then await on the clone. - Panicked from a background task. Tasks must never panic — log
the error with
tracing::warn!ortracing::error!and continue the loop. The bot stays up. - Missed the feature-gate “verbose” log on startup. Reviewers
will ask for it. Copy the pattern from auto-role or minecraft in
main.rs. - Skipped the config struct because “the feature is small.” Once
the feature has any tunable, put it in
instance_config.rs. Hard-coded values become technical debt fast.
Next steps
- Adding a Command — for the next time you just need a new subcommand inside an existing area.
- Adding an MCP Tool — if your feature should also be reachable from an MCP client.
- Testing — where to add tests for the new logic.
- Contributing Workflow — fork → PR → merge end-to-end.
Adding an MCP Tool
This page walks through adding a new tool to the embedded MCP server,
the one bound on MCP_PORT (default 9090) inside the bot process.
By the end you’ll have an end-to-end example: a parameter struct, a
#[tool]-annotated handler, and a working tool call from an MCP
client like Claude Code or the bundled gateway.
If you’re confused about what MCP is or where it sits in the
architecture, read the MCP Server feature page
first. This page assumes you know that the bot exposes a JSON-RPC
endpoint, that tools live on the DiscordTools impl in
src/mcp/tools.rs, and that the
rmcp crate’s
#[tool] and #[tool_router] macros do the heavy lifting.
What “adding a tool” means
Three pieces, in this order:
- A parameter struct that derives
DeserializeandJsonSchemasormcpcan produce a JSON Schema for clients. - An async method on
impl DiscordToolsannotated with#[tool(description = "...")]. - Nothing else. Registration is automatic. The
#[tool_router(router = tool_router)]macro on the impl block discovers every method tagged with#[tool]and wires them into theToolRouter. There is no list to keep in sync. There is no second file to edit.
This is the single most important thing to internalise: write the
function, the macro registers it. If your new tool doesn’t show up
after a rebuild, it’s because the macro didn’t see it — and the cause
is almost always a missing #[tool(...)] attribute or putting the
function inside a separate impl block from the one tagged with
#[tool_router].
Open the file
Every tool in the codebase lives in
src/mcp/tools.rs.
Read the top of the file to see how it’s organised:
- The
useblock importsrmcptypes and theParameterswrapper. - A run of parameter structs at the top —
CreateChannelParams,EditRoleParams,BanParams, etc. Each one is its own#[derive(Debug, Deserialize, JsonSchema)] pub struct. - A few small helpers:
parse_id,channel_type_num,parse_duration_secs, thediscord_call!macro that wraps every Serenity HTTP call with a 10-second timeout. - The
ServerHandlerimpl that wireslist_toolsandcall_toolthrough the router. - The big
#[tool_router(router = tool_router)] impl DiscordToolsblock — every tool lives in this one block, with section dividers like// ===== CHANNELS =====.
Add your new tool to the bottom of an appropriate section, or create a new section if you’re starting a new area.
Step 1: Write the parameter struct
Worked example: a lookup_user_by_name tool that takes a username
substring and returns matching members. Define the params:
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LookupUserByNameParams {
/// Guild/server ID (optional, defaults to configured guild)
pub guild_id: Option<String>,
/// Substring to match against display name or username (case-insensitive)
pub query: String,
/// Max results (1-50, default 10)
pub limit: Option<u8>,
}
A few things to copy from the existing tools:
DeserializeandJsonSchemaare both required. The first parses incoming JSON, the second produces the schema MCP clients see intools/list.- Doc comments on every field become the schema’s per-property descriptions. A client like Claude reads them. Spend the extra thirty seconds on each one.
guild_idis always optional and always at the top when the tool can be run against a guild. Theresolve_guild()helper onDiscordToolsfalls back to the configured guild when it’sNone, which keeps the per-instance MCP setup ergonomic.- IDs are
String, notu64. Discord snowflakes are 64-bit, but JSON numbers aren’t reliably 64-bit on the client side, so the wire format is always strings. Parse tou64inside the handler with theparse_idhelper. - Use
Option<T>for any field that has a default, then apply the default inside the handler with.unwrap_or(...). Don’t fightserdewith#[serde(default = "...")]unless the default is expensive enough to matter.
Step 2: Write the handler
Add the method to the impl DiscordTools block tagged with
#[tool_router]. The body is your code; everything around it is
boilerplate copied from a neighbour:
#[tool(description = "Find members whose display name or username contains the substring (case-insensitive)")]
async fn lookup_user_by_name(
&self,
params: Parameters<LookupUserByNameParams>,
) -> Result<CallToolResult, McpError> {
let p = params.0;
let gid = self.resolve_guild(p.guild_id.as_deref())?;
let limit = p.limit.unwrap_or(10).clamp(1, 50);
let query = p.query.to_lowercase();
let members = discord_call!(self.http.get_guild_members(gid, Some(1000), None));
let matches: Vec<String> = members
.iter()
.filter(|m| {
m.display_name().to_lowercase().contains(&query)
|| m.user.name.to_lowercase().contains(&query)
})
.take(limit as usize)
.map(|m| {
format!(
"{} (@{}) | ID: {}",
m.display_name(),
m.user.name,
m.user.id
)
})
.collect();
let body = if matches.is_empty() {
format!("No members matching '{}'.", p.query)
} else {
format!("{} match(es):\n{}", matches.len(), matches.join("\n"))
};
Ok(CallToolResult::success(vec![Content::text(body)]))
}
Notes on the patterns this copies:
#[tool(description = "...")]is what makes the macro pick up the function. The description is what the MCP client sees in the tool list — write it for an LLM, not a person. State what the tool does and what kind of input it wants. If the tool is privileged (sends messages, deletes data, mutates state) say so in the description, the same waysend_messagedoes (“PRIVILEGED — recommend manual approval”). MCP clients generally surface that text in their approval UIs.params: Parameters<YourParams>is the universal signature for tools that take input. Pullp = params.0at the top.self.resolve_guild(...)is the helper for the optional guild-id pattern. Prefer it over readingparams.guild_iddirectly — it gives you the configured guild as a fallback and keeps the error type consistent.discord_call!(...)wraps every Serenity HTTP call in a 10-second timeout and converts errors intoMcpErrorviaapi_err. Don’t callself.http.<method>().await?directly; you lose the timeout guard and you have to write the error mapping by hand.- The return body is plain text in
Content::text(...). MCP supports structured content but this codebase keeps everything as human-readable text — the consumer is usually an LLM that handles prose just fine. If your tool returns a list, format it line by line; if it returns a count plus a list, prepend the count. Match the existing tools’ shape. - Validation errors use
McpError::invalid_params(...). Internal failures useMcpError::internal_error(...). The helpersapi_errandtimeout_errcover the common cases.
That’s the whole tool. Build:
CONFIG_DIR=instances/local cargo run
Then issue a tools/list against the MCP server (or restart your
Claude Code session and ask it to list discord-bot tools). Your tool
will be there, with the description and the JSON Schema you wrote.
Step 3: Test it from a client
The simplest end-to-end check is an HTTP call with curl:
curl -s -X POST http://127.0.0.1:9090/mcp \
-H 'content-type: application/json' \
-H 'authorization: Bearer YOUR_MCP_TOKEN' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "lookup_user_by_name",
"arguments": { "query": "alice", "limit": 5 }
}
}'
(Drop the authorization header if you didn’t set MCP_AUTH_TOKEN.)
A working response wraps the text body in MCP’s content envelope. A broken one tells you what’s wrong:
Method not found— your tool isn’t registered. The macro didn’t see it. Re-check the#[tool(description = "...")]attribute and that the function lives inside the same impl block as the#[tool_router]attribute.Invalid parameters— your params struct doesn’t match what you sent. Either the JSON Schema published intools/listdisagrees with the call, or a required field is missing. The error message names the field.Discord API error: ...— the underlying Serenity call failed. Most often a permission issue (the bot isn’t allowed to do what you asked) or a bad ID.Discord API request timed out— the call exceeded the 10-secondAPI_TIMEOUTconstant. This is rare for read calls and usually indicates an upstream Discord problem.
Patterns from the existing tools
Most of what you’d need is already in tools.rs. A few patterns worth
knowing about explicitly:
- Channel-scoped operations that don’t strictly need a
guild_idfor the API call still take it as an optional param for clarity. Seesend_message,delete_messages,edit_channel. They pulllet _gid = self.resolve_guild(p.guild_id.as_deref())?;to validate the param shape and discard the result. - Bulk vs single delete —
delete_messagescallsdelete_messageson the channel for >1 ID anddelete_messagefor exactly 1, because the bulk endpoint rejects single-message deletes. Discord API quirk; copy the pattern. - Permission overrides —
set_channel_permissionsaccepts permission bits as decimal strings (because JSON numbers can lose precision), parses them with.parse::<u64>().unwrap_or(0), and builds aPermissionOverwritestruct. Mirror this if you need to pass any 64-bit field over the wire. - Pagination —
list_membersuseslimitplusafter(the last user ID seen). MCP clients are expected to call repeatedly; don’t try to fetch unbounded data in one tool call. - Description tags for privileged operations —
send_messagecontains “PRIVILEGED — recommend manual approval” in its description. Add the same hint to any tool that mutates Discord state in a way you’d want a human to confirm.
When the tool needs more than a Parameters<T>
Most tools take one params struct. If yours takes none — for example,
list_guilds — drop the params argument entirely:
#[tool(description = "List all Discord servers (guilds) this bot is connected to")]
async fn list_guilds(&self) -> Result<CallToolResult, McpError> {
let guilds = discord_call!(self.http.get_guilds(None, None));
// ...
Ok(CallToolResult::success(vec![Content::text(/* ... */)]))
}
rmcp synthesises an empty schema for tools with no params.
If you want to access state that lives on Data (the bot’s main state
struct, not the MCP server’s), you’ll have to thread it through. The
current DiscordTools::new constructor takes only Arc<Http> and a
GuildId. To add database access, add a field for the pool, extend
new, and update the call site in src/mcp/mod.rs where
DiscordTools::new is invoked. There’s no example of this in the
codebase yet — every existing tool only needs Discord HTTP — but the
shape is straightforward: clone the Arc<sqlx::PgPool> into the
struct the same way http: Arc<Http> is cloned today.
Documentation
After your tool ships:
- Add a row to
docs/reference/mcp-tool-catalog.mddescribing the tool, its parameters, and an example call. - Update
CHANGELOG.mdunder[Unreleased]inAdded.
Common gotchas
- Forgot
#[tool(description = "...")]. The function compiles as a regular method, the macro ignores it, and the tool never appears intools/list. - Forgot
JsonSchemaon the params struct. You get a confusing trait-bound error from the macro expansion. Add the derive. - Used
u64for an ID. The wire format will accept it sometimes and lose precision other times. AlwaysStringin the params struct, parsed withparse_idinside the handler. - Skipped
discord_call!. Your tool can hang for minutes on a slow Discord response, blocking the MCP worker. Always wrap. - Returned a 10MB response. MCP clients don’t enjoy this any
more than humans do. Truncate large lists, paginate, or summarise.
list_memberscaps at 1000 per call. - Two
impl DiscordToolsblocks. Only one of them is the#[tool_router]block. Tools in any otherimplare invisible.
Next steps
- MCP Server feature page — what capabilities the bot already exposes and how external clients connect.
- MCP Gateway Routing — how tool calls find the right instance in a multi-bot deployment.
- Adding a Feature Module — when your MCP tool is part of a larger new area of the bot.
Testing
This page describes the test suite as it stands today: how it’s structured, how to run it, what it covers, and where the gaps still are. Coverage grew substantially during the v0.5.0 hardening cycle — the crate went from zero tests to a little over a hundred — so the tone of this page is no longer “we wish we had tests.” It’s “here’s how to run them and where to add more.”
Current coverage
A truthful inventory as of v0.5.0:
- Main crate unit tests — 92. Live alongside the code they cover
in
#[cfg(test)] mod testsblocks at the bottom of each file. Split acrosssrc/util/duration.rs,src/util/ratelimit.rs,src/ai/dsml.rs,src/ai/sanitize.rs,src/ai/split.rs,src/error.rs,src/wordle/game.rs,src/connections/game.rs,src/autorole.rs, and theparse_duration_secshelper on the MCP tool surface. mcp-gateway/unit tests — 10. Inmcp-gateway/src/routing.rs, covering theRouter::resolvedecision tree (explicit instance, guild lookup, unknown instance, guild-map updates, override semantics). The canonical example of the project’s test style for pure async logic.- Main crate integration tests — 18. Under
tests/as four files (db_stocks.rs,db_autorole.rs,db_moderation.rs,db_settings.rs) driven by#[sqlx::test]. They require a running Postgres — see below. - Doc tests — none worth mentioning.
Total: 120 automated tests. CI runs them all on every push and PR.
Two of the integration tests deserve their own call-out because they exist to pin specific regressions:
db_stocks::stocks_reset_sell_race_does_not_mint_money— ten iterations of a concurrentsell_stock+reset_portfoliorace. Confirms theFOR UPDATErow-lock fix (Tier 1.2) still holds; if the lock ever regresses, this test mints money and turns red.db_autorole— sixteen parallel tasks all trying to claim a role for the same user. Verifies the atomic-claim path (Tier 2.x) doesn’t double-assign.
If you touch the stock-trading SQL layer or autorole flow, run these tests before opening a PR.
How unit tests are structured
Every module that has pure logic worth testing carries its tests in
the same file, under #[cfg(test)] mod tests. That’s the whole
pattern — there’s no tests/ subdirectory inside src/, no separate
crate for fixtures, no shared helpers (yet). When you add a new
function worth testing, add the tests to the same file.
// src/util/duration.rs
pub fn parse_duration(input: &str) -> Option<i64> { /* ... */ }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_common_units() {
assert_eq!(parse_duration("30s"), Some(30_000));
assert_eq!(parse_duration("5m"), Some(300_000));
assert_eq!(parse_duration("2h"), Some(7_200_000));
}
#[test]
fn rejects_unknown_units() {
assert_eq!(parse_duration("3y"), None);
assert_eq!(parse_duration(""), None);
}
}
For async tests, use tokio::test the way the gateway’s routing
tests do:
#[tokio::test]
async fn resolve_explicit_instance() {
let router = test_router();
let result = router.resolve(Some("bot_b"), None).await.unwrap();
// ...
}
How integration tests work
The four files under tests/ use sqlx’s test macro:
#[sqlx::test(migrations = "./migrations")]
async fn buy_stock_decrements_cash_and_creates_holding(pool: PgPool) {
queries::get_or_create_portfolio(&pool, "test-guild", "test-user").await.unwrap();
let total = queries::buy_stock(&pool, "test-guild", "test-user", "AAPL", d("2"), d("100"))
.await
.unwrap();
assert_eq!(total, d("200"));
// ...
}
#[sqlx::test(migrations = "./migrations")] does three things per
test: clones a fresh database from the DATABASE_URL target, applies
every file under ./migrations/ into it, and passes the resulting
PgPool into the test function. Tests run in parallel against
independent databases, so there’s no ordering coupling or teardown
work to write.
The tests link against the bot’s own library crate — a minimal
src/lib.rs facade that exposes pub mod db; and pub mod stocks;.
The binary (src/main.rs) is unchanged; the library exists purely so
tests/*.rs can call discord_bot::db::queries::* without reaching
into private modules. If you need another module testable, add it to
src/lib.rs — but keep the surface narrow (no Discord context, no
Songbird, no MCP handlers).
Running tests locally
The unit tests don’t touch the database. The integration tests do. So there are two useful commands:
# Unit tests only, no Postgres needed:
cargo test --bins
# Full suite, requires a Postgres reachable at $DATABASE_URL:
cargo test
The easiest way to get a throwaway Postgres for the full suite:
docker run -d --rm --name dbrs-test-pg -p 5433:5432 \
-e POSTGRES_USER=test \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=test \
postgres:17
DATABASE_URL=postgres://test:test@localhost:5433/test cargo test
Stop it with docker stop dbrs-test-pg when done. #[sqlx::test]
creates a fresh per-test database, so the container can be reused
across cargo test invocations — nothing accumulates.
The gateway crate runs independently:
cargo test --manifest-path mcp-gateway/Cargo.toml
Other useful invocations:
cargo test util::duration # run tests matching a name
cargo test --test db_stocks # run one integration file
cargo test -- --nocapture # show println! output
How CI runs tests
ci.yml’s
check-main job stands up a postgres:17 service container with a
health check, then exports DATABASE_URL before running cargo test.
Both unit and integration tests run in one invocation. If the
container isn’t healthy when the test step starts, the job fails
outright — we don’t fall through to running unit tests only.
The check-gateway job runs cargo test inside mcp-gateway/ with
no services; the gateway’s tests are pure and don’t need a DB.
What’s tested
- Pure data transforms:
parse_duration/format_duration_ms/format_track_duration,parse_duration_secs(MCP-side), token bucket arithmetic inutil::ratelimit, DSML parsing, AI message splitting across the 2000-char boundary, prompt-injection scrub inai::sanitize,error::user_messagefallout. - Wordle game state: guess scoring (correct/present/absent), win/loss
detection,
is_valid_word. - Connections game state: selection validation, mistake counting, full-category detection.
- Autorole: both the pure
meets_criteriadecision and the atomic DB claim. - Stock trading SQL: buy, sell (partial and full), portfolio reset, transaction log, and the concurrency-sensitive reset/sell race.
- Moderation SQL: warnings, history queries, expiry sweeps.
- Instance-settings SQL: round-trip reads/writes of guild settings.
- Gateway routing: the
Router::resolvedecision tree.
What isn’t tested
Being honest about the gaps:
- Discord-context-dependent handlers. Anything that needs a
ContextorCommandInteractionfrom poise/Serenity. Mocking the framework is more code than the handler; the pattern is to extract the inner decision as a free function and test that instead. - The
songbirdvoice pipeline. Requires a real voice gateway or a fixture-heavy mock that doesn’t exist. - Live external API calls — DeepSeek, Gemini, Finnhub, NYT. These belong in manual smoke tests, not CI. The cost of flake is worse than the cost of a missed regression.
mcp-gatewaybackend.rs/server.rs. The router is tested; the request-parse andtools/listaggregation paths aren’t yet. Good first-PR territory.
Known quirks pinned by tests (not bugs, yet)
Several tests encode present behaviour that’s arguably wrong but hasn’t been changed to avoid bundling a fix into a “just add tests” PR. If you’re going to fix one of these, write the test-change and the code-change in the same PR so the intent is clear:
parse_duration("0s")returnsSome(0)— a zero-length duration. Consumers treat it as “no timeout,” which may not be what the user typing0smeant.parse_duration_secs(MCP tool helper) silently accepts negative values and can overflow on large inputs; the test pins the current saturating behaviour.sanitize_contentstrips role markers and prompt-injection attempts but does not scrub bot tokens or other high-entropy secrets that slip into AI context. The test suite documents the current threat model rather than an aspirational one.format_duration_msdoesn’t clamp negative inputs — it renders them with a leading minus. Fine for the display sites that guard against negatives upstream, dubious as a general-purpose helper.ConnectionsGame::AlreadyGuessedis dead-code today (no call path constructs it). A test asserts it exists so nobody deletes it during a cleanup before the feature that was going to produce it lands.submit_guesswith fewer than four tiles selected is a no-op rather than an error. Tests pin the no-op behaviour; change it deliberately if needed.
Adding tests
For pure logic, drop a #[cfg(test)] mod tests block at the
bottom of the file and add #[test] functions. If the code under
test is async, use #[tokio::test]. No ceremony.
For new SQL queries, add a file under tests/ named for the
module (e.g. tests/db_my_feature.rs). Pattern:
use sqlx::PgPool;
use discord_bot::db::queries;
#[sqlx::test(migrations = "./migrations")]
async fn my_query_does_the_thing(pool: PgPool) {
let result = queries::my_query(&pool, "guild", "user").await.unwrap();
assert_eq!(result, /* ... */);
}
If the module you want to test isn’t reachable through
discord_bot::… yet, add it to src/lib.rs. Keep the library
surface narrow: only modules that genuinely benefit from
Postgres-backed integration testing belong there.
For race tests, follow stocks_reset_sell_race_does_not_mint_money
as a template — set up the scenario, spawn two tokio::spawn tasks,
await both, then assert the invariant on the final state regardless
of which task won.
Test naming
snake_case names that say what’s expected, not what’s being called.
resolve_unknown_guild_fails beats test_resolve_3.
buy_stock_rejects_insufficient_funds beats test_buy_2. Your future
self reads test names when CI fails.
Manual testing
Automation still doesn’t cover most of the bot — anything that needs a live Discord connection, voice pipeline, or external API. The manual loop:
- Start a local instance with
CONFIG_DIR=instances/local cargo run. - Exercise the change in your test Discord server.
- Tail the logs (
RUST_LOG=discord_bot=debug,info cargo run) and confirm there’s no warning or error you didn’t expect.
The PR template’s Testing section asks you to list what you
manually verified. “Tested !m play and !m skip against a real
voice channel” is more useful than “tested music.”
Next steps
- Debugging — when a test fails and you don’t know why, start there.
- Contributing Workflow — the pre-PR
checklist tells you which
cargo testinvocation to run when.
Debugging
This page is the bag of tricks for the moments when the bot is doing
something you didn’t expect — silently failing a command, getting
stuck in voice, refusing to start, or crashing under specific load.
The tools are mostly the standard Rust ones (tracing, RUST_LOG,
the test harness, a profiler), but there are a few project-specific
patterns worth knowing.
Logging
The bot uses tracing end to end.
Every log call goes through one of tracing::info!, tracing::warn!,
tracing::error!, or tracing::debug!, and tracing_subscriber is
initialised in main.rs with tracing_subscriber::fmt::init(). There
is no println! in the codebase — if you find one, replace it.
tracing_subscriber::fmt::init() reads the RUST_LOG environment
variable to decide which spans and events get emitted. The default is
info for everything, which is the right level for production but
hides most of the detail you want when debugging.
Useful RUST_LOG settings
# Default: info from every crate (including serenity, sqlx, hyper).
cargo run
# Bot at debug, everything else at info — the typical dev setting.
RUST_LOG=discord_bot=debug,info cargo run
# Bot at trace (very loud), serenity quiet — useful when isolating
# bot logic from gateway noise.
RUST_LOG=discord_bot=trace,serenity=warn,info cargo run
# Just one module at debug.
RUST_LOG=discord_bot::ai=debug,info cargo run
# Music subsystem only.
RUST_LOG=discord_bot::music=debug,songbird=debug,info cargo run
# Database queries.
RUST_LOG=discord_bot::db=debug,sqlx=debug,info cargo run
The format is <crate>=<level> separated by commas, with a bare level
acting as the default for unmatched crates. info, debug, trace,
warn, and error are the levels — trace includes everything,
error only fatal stuff.
A common pattern when chasing a bug: start with
RUST_LOG=discord_bot=debug,info, reproduce, and grep for the
relevant module to see what fires.
What’s already logged
main.rs is verbose at startup — every feature flag’s activation,
the database init, the instance config name and prefix, and each
background task’s start are logged at info. If your bot doesn’t
boot, the last info line before silence is your strongest hint.
Each module logs its hot paths at debug:
ai/chat.rslogs the inbound message, tool calls and their results, and the final reply.music/voice.rslogs joins, leaves, track starts, and track-end events.db/mod.rslogs schema creation and migration progress.mcp/mod.rslogs the listen address.
warn is reserved for “unexpected but recoverable” — the donator
sync poll failed, an auto-role time check skipped a member, a
chargeback webhook arrived with a bad signature. error is reserved
for “this command failed and I’m reporting back to the user” plus the
single fallback in on_error for framework-level errors.
Reading logs in Docker
When the bot runs under Compose, every log line goes to stdout, which Docker captures:
docker compose logs -f bot # follow live
docker compose logs --since 10m bot # last 10 minutes
docker compose logs bot 2>&1 | grep WARN # filter
To raise the log level inside a Compose-deployed container, add
RUST_LOG to the bot service’s environment: block in
docker-compose.yml:
bot:
environment:
RUST_LOG: discord_bot=debug,info
Then docker compose up -d bot to restart. There’s no live reload
of RUST_LOG — the subscriber is initialised once at startup.
Common issues
A few classes of failure show up often enough to be named.
“The bot doesn’t come online.”
Usually one of three causes. In rough order of frequency:
- Bad token. Look for
Invalid TokenorWebSocket closein the logs near startup. Generate a new token in the Discord developer portal, paste it into.env, restart. - Privileged intents disabled. The bot needs Message Content
Intent (to read prefix commands) and Server Members Intent
(for member joins, auto-role, welcome). Both are toggled on the
Bot page in the developer portal. Logs say
Disallowed intents. - The process started but hung on database init. Watch for
Database initialized (schema: ...). If it never appears, Postgres is unreachable; checkDATABASE_URLand the network.
“A command silently does nothing.”
Two flavours:
- The command isn’t registered. You wrote a
#[poise::command]function but didn’t add"<module>::<function>"to thesubcommands(...)list insrc/commands/mod.rs. The command compiles, the bot boots, the user types it, nothing happens. Add the entry, restart. - The command panicked or returned an
Err. Poise’son_errorinmain.rswill replyError: <message>and logCommand error: <error>. If you see neither in the channel nor in the logs, you have a different bug — likely an earlyreturn Ok(())before any user-visible output, or a dropped future.
When in doubt: reproduce with RUST_LOG=discord_bot=debug,info.
“AI chat doesn’t reply.”
Mention the bot, get nothing. The pipeline is in src/ai/chat.rs
(look for handle_mention); the code logs at info when a request
comes in and at error when it fails. Possible causes:
- No API key.
DEEPSEEK_API_KEYandGEMINI_API_KEYare both unset. The pipeline silently returns. Set at least one. - Rate limit hit. The bot allows 10 AI calls per user per 60s. Eleventh call drops silently. Wait or restart.
- DeepSeek/Gemini outage. The logs will say so. The fallback path (DeepSeek → Gemini) only fires when DeepSeek returns an error response; if both are down, the bot is sad too.
- A tool call hung. Music searches via yt-dlp can stall when
YouTube changes; the AI may be waiting on the tool. Tail
discord_bot::music=debugand look for the offending track.
“Music doesn’t play.”
The music pipeline involves yt-dlp, ffmpeg, and songbird. Each
can fail independently:
yt-dlpnot onPATHor out of date. YouTube breaks yt-dlp every few weeks;pip install -U yt-dlpis the fix more often than not.ffmpegnot onPATH. The Docker image has it; bare-metal setups needapt install ffmpeg.- The bot can’t join voice. Check that the Voice channel
permissions allow the bot to Connect and Speak. Logs say
Failed to join voice channel. - The track resolves but never plays. Tail
RUST_LOG=discord_bot::music=debug,songbird=debug. Look for an ffmpeg subprocess error — usually a codec mismatch or a stream yt-dlp couldn’t extract.
“Database connection issues.”
Two patterns:
- Cold start.
Failed to connect to databaseat startup. Check Postgres is up andDATABASE_URLis correct.psql "$DATABASE_URL"is the fastest test. - Hot disconnect.
pool acquire timed outmid-run. The Postgres process restarted or the network blipped; sqlx will reconnect automatically on the next query.
“The bot is using a lot of CPU / memory.”
Voice playback dominates. A bot in three voice channels with three ffmpeg pipelines uses meaningfully more RAM than an idle bot. If you’re seeing growth without an obvious cause:
- Check
docker compose logs bot | grep "leaving voice"— make sure the auto-leave-on-empty logic is firing. If channels stay joined with nobody in them, that’s a leak. - The
airate limiter and the duration parser have unbounded internalVecs with sliding-window pruning. Pruning happens on next access, so if a user makes one call then disappears, their entries linger until they call again. Not a correctness issue — bounded by the number of distinct users who’ve called once. - For real heap profiling, see the Profiling section below.
“Multi-instance: one bot has the wrong data.”
Almost always DB_SCHEMA collision. Two instances with the same
DB_SCHEMA write to the same tables; their state intermixes. There
is no defensive check for this — the schemas just have to be
distinct. Fix the .env, restart both instances, and clean up the
mixed-up data manually.
Stuck or hung
When the bot stops responding entirely:
- Is the process alive?
ps aux | grep discord-botordocker compose ps bot. If exited, the logs will say why. - Is the gateway connected? Logs include heartbeats at
debuglevel. A long gap means the gateway link is dropped; serenity normally reconnects automatically. - Is the runtime stuck on a
.await? Most often a misuse ofDashMap: holding an entry across.await. The fix is “look up, clone the innerArc, drop the guard, await.” - Send
SIGQUITto dump a stack trace. On Linux,kill -QUIT <pid>produces a thread dump fromtokio-consoleif it’s running, or simply terminates the process otherwise.
Profiling
When you actually need numbers (you usually don’t), the Rust ecosystem has good tools:
cargo flamegraphfor CPU profiles. Install withcargo install flamegraph, run withcargo flamegraph --bin discord-bot. Produces an SVG you can open in a browser.tokio-consolefor runtime introspection. Addconsole-subscriberto dependencies, swaptracing_subscriber::fmt::init()forconsole_subscriber::init(), and runtokio-consolein another terminal. Lets you see live task counts, busy/idle times, and detect deadlocks.heaptrack(Linux) for memory growth. Run withheaptrack ./target/release/discord-bot, kill the process when done, open the resulting file inheaptrack_gui.
These are heavier than the RUST_LOG flow and overkill for most
debugging — reach for them when a slow query or a runaway allocation
is real, not just suspected.
Reproducing in the test harness
If you can extract the bug into a pure function — a duration parser
that returns None when it should return Some, a sanitiser that
keeps a marker it should strip — write a unit test that reproduces
it. The test stays in the repo as a regression guard. See
Testing for the project’s test posture.
Reporting bugs
If you’ve debugged something to the point of needing help, file a
bug report.
Include the version (or commit SHA), the deployment method (Docker
or bare metal), the RUST_LOG setting that produced your logs, and
the redacted log lines that show the failure. The template asks for
all of this; filling it out honestly speeds up triage by a factor of
ten.
Next steps
- Testing — the test posture and how to add a regression test for the bug you just fixed.
- Building Locally — when you need a fresh local build to reproduce a deploy-only issue.
- FAQ — the same questions, answered shorter.
Contributing Workflow
This page describes the end-to-end flow of contributing a change to discord-bot-rs, from the moment you have an idea to the moment your change ships in a release. It complements the top-level CONTRIBUTING.md, which is the canonical short version — read that first. This page adds the in-the-weeds detail you don’t want to bury in a root-level file.
Before you start
Read the ground rules. Be kind, keep PRs focused, and if you’re planning anything substantial — a new feature, a module reshuffle, a dependency bump with downstream impact — open an issue first so nobody spends a week on an approach that gets rejected at review.
Check existing issues and PRs before opening a duplicate. Search issues and pull requests.
Bugs go through the bug report template: version/commit, reproduction steps, redacted logs, deployment method. Filling it out honestly is worth more than any clever fix — half the time the steps you write reveal the bug.
Feature requests go through the feature template. Describe the problem before the proposal.
Security issues go through SECURITY.md — don’t open a public issue for anything that could be a vulnerability.
Fork and branch
The project follows the standard GitHub fork + feature-branch flow.
- Fork MrMcEpic/discord-bot-rs.
- Clone your fork locally.
- Add the upstream remote:
git remote add upstream https://github.com/MrMcEpic/discord-bot-rs.git - Create a feature branch off
master:git checkout -b fix/wordle-expiry-bug master
Branch names don’t have a strict format, but descriptive ones like
feat/stock-alerts, fix/music-skip-deadlock, or docs/add-mcp-guide
are easier to review than patch-1.
Local setup
Follow Building Locally to get a working
cargo run. The short version: install the Rust stable toolchain,
make sure Docker and Docker Compose are available for Postgres, and
run cargo check plus
cargo check --manifest-path mcp-gateway/Cargo.toml to pull down
dependencies and confirm the tree compiles.
If you don’t have a Discord application yet, follow the Prerequisites page; you’ll need it to test your changes live.
Make the change
Follow the posture of the file you’re editing — the existing code is the style guide. When in doubt:
- Add a test if reasonable. The crate ships with ~120 automated
tests (92 unit in main, 10 in the gateway, 18 Postgres-backed
integration tests under
tests/). Pure logic and SQL queries are well-covered; Discord-context handlers and the voice pipeline aren’t, so PRs that move the needle there are particularly welcome. For a bug fix, a test that reproduces the bug is the best comment on the diff. See Testing for the patterns. - Keep the PR focused. One logical change per PR. A refactor and a feature and a docs rewrite are three PRs, not one.
- Update the docs. If you changed a command, update the
command list. If you changed a
feature’s behaviour, update its page under
docs/features/. New config options go in instance-config.md. - Add a CHANGELOG entry. User-visible changes go under
[Unreleased]in CHANGELOG.md in one ofAdded,Changed,Fixed, orRemoved.
Commit style
The project leans toward conventional-style prefixes but doesn’t enforce them with a hook. Common prefixes:
feat:— a new user-visible feature or commandfix:— a bug fixdocs:— documentation onlychore:— build, CI, deps, or repo housekeepingrefactor:— code change with no behaviour changetest:— adding or fixing tests
One logical change per commit. If you’re tempted to write “and also”
in the commit message, split it with git reset or git add -p.
Don’t force-push to master on your fork — that’s fine on your own
feature branches, but never anywhere shared.
Pre-PR checklist
Before you push and open a PR, run through:
-
cargo fmt -
cargo fmt --check(same thing, but catches files you forgot to stage) -
cargo clippy --all-targets -- -D warnings -
cargo test --bins(minimum — unit tests, no Postgres needed) -
cargo testwith aDATABASE_URLpointing at a Postgres (full — runs the integration tests undertests/). Easy throwaway:docker run -d --rm -p 5433:5432 -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=test postgres:17, thenDATABASE_URL=postgres://test:test@localhost:5433/test cargo test. See Testing for the long version. - For changes touching the gateway crate, the same three commands
again with
--manifest-path mcp-gateway/Cargo.tomlor from insidemcp-gateway/(the gateway has no DB-backed tests, socargo testis enough) - Docs updated if behaviour changed
- CHANGELOG entry under
[Unreleased] - Manual test in a live Discord server
CI will run the first four for you, so skipping them locally just means you find out about failures from a bot instead of a shell. It’s faster to catch them yourself.
Open the pull request
Push your branch and open a PR against master:
git push -u origin fix/wordle-expiry-bug
Use the PR template. It asks for:
- Summary — one to three sentences on what and why.
- Changes — a bullet list of the main edits.
- Testing — the testing checkboxes (fmt, clippy, test, manual) and a short description of what you manually verified.
- Related issues —
Closes #123for issues this PR fully fixes,Refs #456for related context. - Breaking changes — default is
None. If yours breaks an existing config, command, or behaviour, describe the migration. - Checklist — four housekeeping items; tick them honestly.
Mark the PR as draft if it isn’t ready for review yet. Draft PRs still get CI, so you can push through a broken state until it’s green without fielding review comments prematurely.
CI checks
When you push, CI runs the
ci.yml
workflow, which has four jobs:
- check-main —
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo check --all-targets,cargo teston the main crate. The job stands up apostgres:17service container with a health check and exportsDATABASE_URLso the integration tests undertests/run for real against a live database. - check-gateway — the same four commands inside
mcp-gateway/(no Postgres service; the gateway’s tests are pure). - docker-main — builds the top-level
Dockerfile(no push). - docker-gateway — the same for
mcp-gateway/Dockerfile.
A red check fails the PR. Common failures:
cargo fmt --checkdiffers — runcargo fmtand push.- Clippy flags something — read the lint and fix it, or add a
targeted
#[allow(...)]with a comment if it’s a false positive. - Compile error on Linux that didn’t happen locally — usually a
missing system library.
check-maininstallscmake,libopus-dev, andlibsodium-dev. - Test flake — rare; push an empty commit or ask for a re-run.
Re-run a workflow by pushing any commit or re-opening the PR.
Review process
Reviews usually come within a few days. If a week goes by with no response, comment on the PR to bump it — it’s almost certainly been missed, not ignored.
During review:
- Respond by committing, not force-pushing. Follow-up commits make it easy for the reviewer to see exactly what changed between passes.
- Don’t squash your own history unless asked. The maintainer squashes at merge time.
- Mark resolved conversations once you’ve addressed them.
Merge
The default merge strategy is squash merge. Your feature branch
becomes one commit on master, titled after the PR title (and — since
the title follows a conventional prefix — grouped cleanly in the
changelog).
This means the shape of your individual commits matters less than the shape of the PR description and title. A messy WIP history is fine as long as the squashed commit message is tidy.
After the merge
Once your PR is merged:
git checkout master
git fetch upstream
git merge --ff-only upstream/master
git push origin master
git branch -d fix/wordle-expiry-bug
Then rebase any other in-flight feature branches onto the new master.
The merge UI also offers a button to delete the remote branch.
Release cadence
Releases are cut as-needed — usually every few weeks, sooner for security fixes. Every merged PR ends up in the next release’s CHANGELOG.md entry, and the release workflow publishes a tagged build. If your change is urgent, say so in the PR description.
Reference
- CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md
- Building Locally, Adding a Command, Testing
- CI workflow, PR template, issue templates
Command List
Every bot command supported by discord-bot-rs, grouped by source module.
All user-facing commands are prefix subcommands of a single parent command, m. The default prefix is ! (configurable per instance via command_prefix in config.toml), so you invoke them as !m <subcommand> [args]. None of the public commands are registered as slash commands — discord-bot-rs is a prefix-first bot. The parent m command itself does nothing on its own; it just routes to the subcommands listed below.
The single command registered with the poise framework lives in src/commands/mod.rs and is built in src/main.rs. The verify subcommand is added dynamically when the Minecraft verification module is enabled in config.toml.
Music
Defined in src/commands/music.rs. All music commands respect DJ mode: when DJ mode is enabled (!m djmode), only members with the configured DJ role (or administrators) may run them.
!m play <query>— Play a song. Joins your current voice channel and either starts playback or appends to the queue.query(string, required, rest) — Song name or URL (YouTube, etc.).- Aliases:
p.
!m playlist <url>— Queue an entire playlist at once.url(string, required, rest) — Playlist URL.- Aliases:
pl.
!m skip— Skip the current track and advance to the next item in the queue. Leaves voice if the queue becomes empty.- Aliases:
s.
- Aliases:
!m stop— Stop playback, clear the queue, and leave the voice channel.!m pause— Pause the current track.!m resume— Resume a paused track.- Aliases:
r.
- Aliases:
!m queue— Show the current queue as an embed.- Aliases:
q.
- Aliases:
!m nowplaying— Show what is currently playing, with playback controls.- Aliases:
np.
- Aliases:
!m remove <position>— Remove a single track from the queue by 1-based position.position(integer, required) — Queue position to remove.
!m loop [mode]— Toggle or set loop mode. With no argument, cycles throughoff→track→queue.mode(string, optional) — One ofoff/none,track/t, orqueue/q.- Aliases:
l.
!m shuffle— Shuffle the queued tracks (does not affect the currently playing track).
Moderation
Defined in src/commands/moderation.rs. All moderation commands write to the audit log channel set by !m setlog (if any).
!m ban <user> <duration> [reason]— Temporarily ban a user. The bot stores the expiration in the database and a background task auto-unbans when it elapses.user(member mention/ID, required) — Member to ban.duration(string, required) — Duration like30s,5m,2h,3d,1w.reason(string, optional, rest) — Audit-log reason.- Required permissions:
BAN_MEMBERS.
!m unban <user>— Unban a user early. Clears any matching tempban record.user(user mention/ID, required) — User to unban.- Required permissions:
BAN_MEMBERS.
!m banlist— Show all currently active tempbans for this guild.- Aliases:
bans. - Required permissions:
BAN_MEMBERS.
- Aliases:
!m nuke <count>— Bulk-delete the most recent messages in the current channel. Discord’s bulk-delete API rejects messages older than 14 days.count(integer 1–100, required) — Number of messages to delete.- Required permissions:
MANAGE_MESSAGES.
Admin
Defined in src/commands/admin.rs. These configure per-guild settings stored in the database.
!m setlog <channel>— Set the audit-log channel where moderation actions are reported.channel(channel mention/ID, required) — Target channel.- Required permissions:
ADMINISTRATOR.
!m djrole <role>— Set the DJ role used by DJ mode.role(role mention/ID, required) — Role to mark as DJ.- Required permissions:
ADMINISTRATOR.
!m djmode— Toggle DJ-only mode. When enabled, music commands require either administrator permission or the configured DJ role. Refuses to enable until a DJ role is set.- Required permissions:
ADMINISTRATOR.
- Required permissions:
Stocks
Defined in src/commands/stocks.rs. The stock parent command is itself a subcommand group with its own subcommands. All stock commands require FINNHUB_API_KEY to be configured.
!m stock— Bare invocation shows your current portfolio.- Aliases:
stocks,st.
- Aliases:
!m stock buy <symbol> <amount>— Buy shares. The amount may be a share count (5) or a dollar amount ($500).symbol(string, required) — Ticker symbol.amount(string, required, rest) — Quantity or$amount.- Aliases:
b.
!m stock sell <symbol> <amount>— Sell shares. The amount may be a quantity orall.symbol(string, required) — Ticker symbol.amount(string, required, rest) — Quantity orall.- Aliases:
s.
!m stock portfolio [user]— View a portfolio (yours by default).user(user mention/ID, optional) — Other user to inspect.- Aliases:
port,pf,p.
!m stock price <symbol>— Show the current quote for a stock.symbol(string, required, rest) — Ticker symbol.- Aliases:
quote,q.
!m stock leaderboard— Top 10 portfolios in this server, ranked by total value (cash + holdings).- Aliases:
lb,top.
- Aliases:
!m stock history— Show the user’s 10 most recent trades.- Aliases:
hist,h.
- Aliases:
!m stock reset [confirm]— Reset the user’s portfolio back to $1,000 cash and wipe holdings/history. Withoutconfirm, prints a confirmation prompt.confirmation(string, optional, rest) — Typeconfirmto actually reset.
Games: Connections
Defined in src/commands/connections.rs. NYT Connections puzzles.
!m connections— Start today’s NYT Connections puzzle in this channel. Replaces any existing game in the channel.- Aliases:
conn.
- Aliases:
!m connections random— Start a random Connections puzzle.- Aliases:
rand,r.
- Aliases:
!m connections date <YYYY-MM-DD>— Start the Connections puzzle from a specific date.date(string, required, rest) — Date inYYYY-MM-DDformat.- Aliases:
d.
Games: Wordle
Defined in src/commands/wordle.rs. NYT Wordle puzzles.
!m wordle— Start today’s Wordle puzzle in this channel.- Aliases:
w.
- Aliases:
!m wordle random— Start a random Wordle.- Aliases:
rand,r.
- Aliases:
!m wordle date <YYYY-MM-DD>— Start the Wordle from a specific date.date(string, required, rest) — Date inYYYY-MM-DDformat.- Aliases:
d.
Minecraft
Defined in src/commands/minecraft.rs. The verify subcommand is only registered when [features].minecraft = true and minecraft.verify = true in config.toml. It also requires MC_VERIFY_URL and MC_VERIFY_SECRET to be set.
!m verify <code>— Link your Discord account to a Minecraft username using a code generated by/verifyin-game.code(string, required, rest) — Verification code from Minecraft.
Help
Defined in src/commands/help.rs.
!m help— Show the embedded help message. Sections shown depend on the caller’s permissions: moderation/admin sections only appear for users with the relevant permissions.- Aliases:
h.
- Aliases:
MCP Tool Catalog
Complete catalog of MCP tools exposed by the embedded MCP server. discord-bot-rs ships with an in-process Model Context Protocol server that lets any MCP-compatible client (Claude, your editor, automation scripts) drive the bot’s Discord guild over HTTP. See MCP Server for a feature-level overview and MCP Exposure for connection details.
All tools live in src/mcp/tools.rs and are registered on the DiscordTools router via the rmcp #[tool] macro.
Conventions
guild_idis optional on every tool that accepts it. When omitted, the tool falls back to the bot instance’s configured guild (GUILD_ID). Pass an explicit guild ID only when calling a multi-guild bot.- IDs are passed as decimal strings (Discord snowflakes do not fit in JSON’s safe-integer range).
- Timeouts: every Discord API call is wrapped in a 10 s timeout; the tool returns an error result if Discord doesn’t respond in time.
- Return format: all tools return human-readable plain text inside a
Content::textblock. There is no machine-parseable JSON return type — these tools are designed for an LLM in the loop. - Permissions: the bot account itself must have permission to perform the underlying action; MCP tools do not bypass Discord’s permission model. The
send_messagetool is flagged as privileged in its description and the README recommends configuring your client to require manual approval for it.
Guilds
list_guilds
List every Discord server (guild) the bot is currently a member of.
Parameters: none.
Example:
{
"name": "list_guilds",
"arguments": {}
}
Returns: A line per guild in the form <name> | ID: <snowflake>, prefixed with the total count.
Server
get_guild_info
Get summary information about a server: name, owner, approximate member count, and channel/role counts.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
Example:
{
"name": "get_guild_info",
"arguments": {}
}
Returns: A multi-line text block with Server, ID, Owner, Approx Members, Channels, and Roles fields.
send_message
Send a plain-text message to a channel. Privileged — the source code marks this as something a client should require manual approval for.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Target channel snowflake. |
content | string | yes | Message body. |
Example:
{
"name": "send_message",
"arguments": {
"channel_id": "1234567890123456789",
"content": "Hello from MCP."
}
}
Returns: Message sent (ID: <snowflake>).
delete_messages
Bulk-delete the most recent messages from a channel (1–100). Falls back to a single-message delete if only one message is in scope. Subject to Discord’s 14-day bulk-delete restriction.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Target channel snowflake. |
count | integer (1–100) | yes | Number of recent messages to delete. Clamped server-side. |
Example:
{
"name": "delete_messages",
"arguments": {
"channel_id": "1234567890123456789",
"count": 25
}
}
Returns: Deleted N message(s).
get_recent_messages
Fetch recent messages from a channel, newest first. Each message is returned on its own line as [timestamp] author_name (author_id) [msg_id=...]: content followed by [+N attachment(s)] and [+N embed(s)] markers when present. Use the before parameter to paginate backward — pass the oldest msg_id from the previous response.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild; used to verify the channel belongs to that guild before reading. |
channel_id | string | yes | Target channel snowflake. |
limit | integer (1–100) | no | Number of messages to fetch. Defaults to 50, clamped server-side. |
before | string | no | Message snowflake. If set, only messages older than this ID are returned. |
Example:
{
"name": "get_recent_messages",
"arguments": {
"channel_id": "1234567890123456789",
"limit": 25
}
}
Returns: Newline-separated lines, one per message, or No messages found. if the channel is empty in the requested window.
search_messages
Search a channel for messages matching one or more filters. All filters compose: pass an author_id plus a date range to find what someone said in July, or content plus author_name for a substring match scoped to one user. The implementation pages backward from before (or “now”) in batches of 100, applying the filters client-side, and stops when limit matches are collected, the after boundary is reached, or max_pages (the safety cap on Discord API calls) is hit. The first line of the response is a summary stating how many messages were scanned and whether the search was truncated; subsequent lines are the matched messages in the same format as get_recent_messages.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild; used to verify the channel belongs to that guild. |
channel_id | string | yes | Target channel snowflake. |
author_id | string | no | Filter to messages from this user snowflake. |
author_name | string | no | Filter by case-insensitive substring of the author’s username. |
content | string | no | Filter by case-insensitive substring of the message body. |
after | string | no | Lower time bound. ISO 8601 date (2026-07-03 or 2026-07-03T12:00:00Z) or a Discord snowflake. Older messages are not returned. |
before | string | no | Upper time bound. Same format as after. Newer messages are not returned. |
limit | integer (1-1000) | no | Max matching messages to return. Default 100, clamped server-side. |
max_pages | integer (1-100) | no | Max API pages of 100 messages each to scan. Default 20 (= 2000 messages of search depth). Raise it for deep searches; the response says when the cap was hit. |
Examples:
All messages in a single day:
{
"name": "search_messages",
"arguments": {
"channel_id": "1234567890123456789",
"after": "2026-07-03",
"before": "2026-07-04",
"limit": 1000,
"max_pages": 50
}
}
All messages from one user in a window:
{
"name": "search_messages",
"arguments": {
"channel_id": "1234567890123456789",
"author_id": "9876543210987654321",
"after": "2026-07-01",
"before": "2026-08-01"
}
}
Find a phrase from a specific person:
{
"name": "search_messages",
"arguments": {
"channel_id": "1234567890123456789",
"author_name": "epic",
"content": "deployment"
}
}
Returns: A summary line followed by one line per matched message. The summary names the totals scanned and called out if the search was truncated by max_pages. To continue a truncated search, call again with before set to the oldest msg_id returned.
add_reaction
Add a reaction to a message. Useful for AI-driven moderation flows (“react with ✅ when handled”) or lightweight feedback signals.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild; used to verify the channel belongs to it. |
channel_id | string | yes | Channel containing the message. |
message_id | string | yes | Target message snowflake. |
emoji | string | yes | Unicode emoji (👍), Discord custom-emoji format (<:name:id> or <a:name:id> for animated), or a bare custom-emoji snowflake. |
Example:
{
"name": "add_reaction",
"arguments": {
"channel_id": "1234567890123456789",
"message_id": "1234567890123456790",
"emoji": "✅"
}
}
Returns: Reaction <emoji> added.
remove_reaction
Remove the bot’s own reaction from a message. Cannot be used to clear other users’ reactions.
Parameters: identical to add_reaction.
Example:
{
"name": "remove_reaction",
"arguments": {
"channel_id": "1234567890123456789",
"message_id": "1234567890123456790",
"emoji": "✅"
}
}
Returns: Reaction <emoji> removed.
Channels
list_channels
List every channel in the guild with ID, type, position, and parent category.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
Example:
{
"name": "list_channels",
"arguments": {}
}
Returns: Sorted lines like #general | ID: <snowflake> | Text | pos: 0 (in <parent>).
create_channel
Create a new channel. Supports text, voice, category, forum, and stage channels.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
name | string | yes | Channel name. |
channel_type | string | no (default text) | One of text, voice, category, forum, stage. |
category_id | string | no | Parent category snowflake. |
topic | string | no | Channel topic (text channels). |
nsfw | boolean | no | Mark channel NSFW. |
Example:
{
"name": "create_channel",
"arguments": {
"name": "announcements",
"channel_type": "text",
"topic": "One-way broadcasts only"
}
}
Returns: Created #<name> (ID: <snowflake>).
delete_channel
Permanently delete a channel.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Guild snowflake. Defaults to the instance’s configured guild; used to verify the channel belongs to that guild before deletion. |
channel_id | string | yes | Channel snowflake. |
Example:
{
"name": "delete_channel",
"arguments": {
"channel_id": "1234567890123456789"
}
}
Returns: Channel deleted.
edit_channel
Update channel metadata. Any omitted field is left unchanged.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Channel snowflake. |
name | string | no | New name. |
topic | string | no | New topic. |
nsfw | boolean | no | NSFW flag. |
slowmode_seconds | integer | no | Slowmode rate limit per user (in seconds). |
category_id | string | no | New parent category snowflake. |
Example:
{
"name": "edit_channel",
"arguments": {
"channel_id": "1234567890123456789",
"topic": "Updated topic",
"slowmode_seconds": 10
}
}
Returns: Channel updated.
move_channel
Move a channel to a new position (and optionally a new parent category).
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Channel snowflake. |
position | integer | yes | New position within its category/guild. |
category_id | string | no | New parent category. |
Example:
{
"name": "move_channel",
"arguments": {
"channel_id": "1234567890123456789",
"position": 3
}
}
Returns: Channel moved to position N.
create_voice_channel
Voice-specialised companion to create_channel. Creates a Discord voice channel with optional bitrate and user_limit.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
name | string | yes | Channel name. |
category_id | string | no | Snowflake of the parent category to nest under. |
bitrate | integer | no | Voice bitrate in bps. Default 64000; range 8000-96000 by default, up to 128000/256000/384000 on tier-1/2/3 boosted guilds. |
user_limit | integer (0-99) | no | Max simultaneous users. 0 = unlimited (default). |
Example:
{
"name": "create_voice_channel",
"arguments": {
"name": "Standup Room",
"user_limit": 10,
"bitrate": 96000
}
}
Returns: Created voice channel '<name>' (ID: <snowflake>).
create_stage_channel
Create a Discord stage channel — a voice channel with explicit speaker/audience separation, used for presentations and AMAs.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
name | string | yes | Channel name. |
category_id | string | no | Snowflake of the parent category to nest under. |
Example:
{
"name": "create_stage_channel",
"arguments": {
"name": "Q&A Session"
}
}
Returns: Created stage channel '<name>' (ID: <snowflake>).
edit_voice_channel
Voice-specialised companion to edit_channel. Edit the bitrate, user_limit, RTC region, name, or parent category of a voice channel.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Voice channel snowflake. |
name | string | no | New channel name. |
bitrate | integer | no | New bitrate in bps. |
user_limit | integer (0-99) | no | New max simultaneous users (0 = unlimited). |
category_id | string | no | Snowflake of a category to move the channel under. |
rtc_region | string | no | RTC region override (e.g. us-west, europe). Pass empty string to clear and let Discord auto-pick. |
At least one of name/bitrate/user_limit/category_id/rtc_region must be supplied.
Example:
{
"name": "edit_voice_channel",
"arguments": {
"channel_id": "1234567890123456789",
"bitrate": 128000,
"user_limit": 25
}
}
Returns: Voice channel '<name>' updated.
set_channel_permissions
Apply a permission overwrite (for a role or a member) on a single channel.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Channel snowflake. |
target_type | string | yes | role or member. |
target_id | string | yes | Role or user snowflake. |
allow | string | no | Decimal permission bits to grant. Defaults to 0. |
deny | string | no | Decimal permission bits to deny. Defaults to 0. |
Common bit values from the schema description: VIEW_CHANNEL=1024, SEND_MESSAGES=2048, MANAGE_CHANNELS=16, MANAGE_MESSAGES=8192, CONNECT=1048576, SPEAK=2097152.
Example:
{
"name": "set_channel_permissions",
"arguments": {
"channel_id": "1234567890123456789",
"target_type": "role",
"target_id": "9876543210987654321",
"deny": "2048"
}
}
Returns: Permissions set.
Roles
list_roles
List every role in the guild with name, ID, hex color, position, raw permission bits, and hoist flag.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
Example:
{
"name": "list_roles",
"arguments": {}
}
Returns: Lines like @Moderator | ID: <snowflake> | color: #5865F2 | pos: 5 | perms: 1071698660929 | hoist: true.
create_role
Create a new role.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
name | string | yes | Role name. |
color | integer | no | RGB color as a 24-bit integer (e.g. 5793266 for #5865F2). |
permissions | string | no | Decimal permission bitfield. |
hoist | boolean | no | Display the role separately in the member list. |
mentionable | boolean | no | Allow @<role> mentions. |
Example:
{
"name": "create_role",
"arguments": {
"name": "Trusted",
"color": 5793266,
"hoist": true,
"mentionable": true
}
}
Returns: Created @<name> (ID: <snowflake>).
delete_role
Permanently delete a role.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
role_id | string | yes | Role snowflake. |
Example:
{
"name": "delete_role",
"arguments": {
"role_id": "9876543210987654321"
}
}
Returns: Role deleted.
edit_role
Update an existing role. Any omitted field is left unchanged.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
role_id | string | yes | Role snowflake. |
name | string | no | New name. |
color | integer | no | New RGB color. |
permissions | string | no | New decimal permission bitfield. |
hoist | boolean | no | Hoist flag. |
mentionable | boolean | no | Mentionable flag. |
Example:
{
"name": "edit_role",
"arguments": {
"role_id": "9876543210987654321",
"name": "Trusted Member",
"mentionable": false
}
}
Returns: Role updated.
Members
list_members
List members in the guild. Paginated — each call fetches up to 1000 members, and the after parameter takes the last user ID from the previous page.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
limit | integer (1–1000) | no | Max members to return. Defaults to 100. |
after | string | no | User snowflake to paginate after. |
Example:
{
"name": "list_members",
"arguments": {
"limit": 200
}
}
Returns: Lines like <display_name> (ID: <snowflake>) | roles: [<role_id>, ...], prefixed with the total count.
get_member
Get detailed information about a single member: username, display name, roles, join date, and bot flag.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Member’s user snowflake. |
Example:
{
"name": "get_member",
"arguments": {
"user_id": "123456789012345678"
}
}
Returns: Multi-line block with User, Display, Roles, Joined, and Bot fields.
assign_role
Add a role to a member.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
role_id | string | yes | Role snowflake. |
Example:
{
"name": "assign_role",
"arguments": {
"user_id": "123456789012345678",
"role_id": "9876543210987654321"
}
}
Returns: Role assigned.
remove_role
Remove a role from a member.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
role_id | string | yes | Role snowflake. |
Example:
{
"name": "remove_role",
"arguments": {
"user_id": "123456789012345678",
"role_id": "9876543210987654321"
}
}
Returns: Role removed.
ban_member
Ban a user from the server.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
reason | string | no | Audit-log reason. |
delete_message_days | integer (0–7) | no | How many days of recent messages to delete. Defaults to 0; clamped server-side. |
Example:
{
"name": "ban_member",
"arguments": {
"user_id": "123456789012345678",
"reason": "spam",
"delete_message_days": 1
}
}
Returns: User banned.
unban_member
Lift an existing ban.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
Example:
{
"name": "unban_member",
"arguments": {
"user_id": "123456789012345678"
}
}
Returns: User unbanned.
kick_member
Kick a member from the server (does not ban them).
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
reason | string | no | Audit-log reason. |
Example:
{
"name": "kick_member",
"arguments": {
"user_id": "123456789012345678",
"reason": "inactivity"
}
}
Returns: User kicked.
timeout_member
Apply a Discord timeout (communication disable) to a member for a given duration.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
duration | string | yes | Duration like 30s, 30m, 1h, 7d. Bare numbers are interpreted as minutes. |
reason | string | no | Audit-log reason. Currently accepted by the schema but not threaded to Discord by the underlying call. |
Example:
{
"name": "timeout_member",
"arguments": {
"user_id": "123456789012345678",
"duration": "1h"
}
}
Returns: User timed out for <duration>.
remove_timeout
Lift an active timeout on a member. Inverse of timeout_member.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
Example:
{
"name": "remove_timeout",
"arguments": {
"user_id": "123456789012345678"
}
}
Returns: Timeout removed.
set_nickname
Set or clear a member’s nickname (1–32 characters). Pass an empty nickname (or omit it) to clear, which makes the member display their global Discord username.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
nickname | string | no | New nickname; omit or pass empty string to clear. |
Example:
{
"name": "set_nickname",
"arguments": {
"user_id": "123456789012345678",
"nickname": "Big Boss"
}
}
Returns: Nickname set to '<n>' or Nickname cleared.
get_bans
List active bans in the server, with each user’s id/name and the moderator-supplied reason if recorded. Paginate by passing the last user_id from the previous response as after.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
limit | integer (1-255) | no | Max bans per page. Default 100. |
after | string | no | Paginate forward — return bans whose user_id is greater than this snowflake. |
Example:
{
"name": "get_bans",
"arguments": {
"limit": 50
}
}
Returns: <count> ban(s): followed by one line per ban as <username> (<user_id>) — <reason>.
move_voice_member
Move a member to a different voice channel. The member must currently be in a voice channel in this guild — Discord rejects with 400 if they aren’t connected to voice.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
channel_id | string | yes | Voice channel to drop them into. |
Example:
{
"name": "move_voice_member",
"arguments": {
"user_id": "123456789012345678",
"channel_id": "1234567890123456790"
}
}
Returns: Moved user to voice channel <id>.
disconnect_voice_member
Disconnect a member from voice. The member must currently be in a voice channel in this guild for the disconnect to take effect.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
Example:
{
"name": "disconnect_voice_member",
"arguments": {
"user_id": "123456789012345678"
}
}
Returns: User disconnected from voice.
modify_voice_state
Server-mute or server-deafen a member when they’re in voice. Pass mute and/or deafen explicitly; omitted fields are left unchanged. At least one of the two must be provided.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
user_id | string | yes | Target user snowflake. |
mute | boolean | no | Server-mute (true) or unmute (false) when in voice. Omit to leave unchanged. |
deafen | boolean | no | Server-deafen (true) or undeafen (false) when in voice. Omit to leave unchanged. |
Example:
{
"name": "modify_voice_state",
"arguments": {
"user_id": "123456789012345678",
"mute": true
}
}
Returns: Voice state updated: <fields>.
Direct Messages
DM tools open (or reuse) a private channel between the bot and the target user, then operate on that channel like any other text channel. There’s no guild_id parameter and no cross-guild verification — DMs aren’t part of any guild. The underlying create_private_channel call is idempotent, so repeated calls don’t proliferate channels.
Permissions / setup notes:
- The bot doesn’t need a special Discord permission to send DMs, but the target user must allow DMs from server members and must share a guild with the bot. If they don’t, the send returns a 403 Forbidden which surfaces here as
Discord API error. - Reading DM history via
read_private_messagesuses the REST API (not the gateway), so theDIRECT_MESSAGESprivileged intent is not required for these tools to work. edit_private_messageanddelete_private_messageonly work on messages the bot itself sent — Discord won’t let any bot edit or delete another user’s DMs.
send_private_message
Send a direct message to a user. Opens the DM channel automatically. Privileged.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | Target user snowflake. The bot DMs them; Discord rejects with 403 if they have DMs disabled or don’t share a guild. |
content | string | yes | Message body. |
Example:
{
"name": "send_private_message",
"arguments": {
"user_id": "123456789012345678",
"content": "Following up on your moderation question — let me know if this resolves it."
}
}
Returns: Message sent.
read_private_messages
Read recent DMs between the bot and a user, newest first. Same output format as get_recent_messages but scoped to the DM channel.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | Target user snowflake. |
limit | integer (1-100) | no | Number of messages to fetch. Default 50. |
before | string | no | Message snowflake; only messages older than this are returned. |
Example:
{
"name": "read_private_messages",
"arguments": {
"user_id": "123456789012345678",
"limit": 20
}
}
Returns: Newline-separated lines, one per message, formatted as [timestamp] author_name (author_id) [msg_id=...]: content plus optional attachment/embed markers. No messages found. if the channel is empty.
edit_private_message
Edit one of the bot’s previously-sent DMs to a user.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | The DM partner (same as in the original send call). |
message_id | string | yes | Snowflake of the message to edit. |
content | string | yes | Replacement content. |
Example:
{
"name": "edit_private_message",
"arguments": {
"user_id": "123456789012345678",
"message_id": "1234567890123456790",
"content": "Updated: this answer was wrong; the correct procedure is …"
}
}
Returns: Message edited.
delete_private_message
Delete one of the bot’s previously-sent DMs to a user.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | The DM partner. |
message_id | string | yes | Snowflake of the message to delete. |
Example:
{
"name": "delete_private_message",
"arguments": {
"user_id": "123456789012345678",
"message_id": "1234567890123456790"
}
}
Returns: Message deleted.
Webhooks
Webhooks let an MCP client post as arbitrary identities (custom username + avatar per message) — the standard pattern for relays, persona bots, and cross-platform bridges. The bot uses its Manage Webhooks permission to create / delete / list webhooks; sending through one only needs the webhook id and token.
list_webhooks
List webhooks attached to a channel. Each entry includes id, name, and (when the bot has Manage Webhooks) the token, which send_webhook_message requires.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild; used to verify the channel. |
channel_id | string | yes | Target channel snowflake. |
Example:
{
"name": "list_webhooks",
"arguments": {
"channel_id": "1234567890123456789"
}
}
Returns: <count> webhook(s): followed by one line per webhook as <name> (id=<id>) — token=<token>. No webhooks in this channel. if empty.
create_webhook
Create a new webhook on a channel. Returns the webhook’s id and token; capture both — the token is required for send_webhook_message and is only returned at create time.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Target channel snowflake. |
name | string | yes | Webhook display name (1-80 chars). Discord rejects names containing the substring “discord” (case-insensitive). |
Example:
{
"name": "create_webhook",
"arguments": {
"channel_id": "1234567890123456789",
"name": "Daily Standup Bot"
}
}
Returns: Webhook created: id=<id> token=<token>.
delete_webhook
Delete a webhook by ID.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
webhook_id | string | yes | Webhook snowflake. |
Example:
{
"name": "delete_webhook",
"arguments": {
"webhook_id": "1234567890123456789"
}
}
Returns: Webhook deleted.
send_webhook_message
Send a message through a webhook. The optional username and avatar_url parameters override the webhook’s defaults for this message only — useful for relay/persona patterns where one webhook delivers messages on behalf of many identities. Privileged — webhooks bypass the bot’s role permissions.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
webhook_id | string | yes | Webhook snowflake. |
token | string | yes | Webhook token (returned by create_webhook / list_webhooks). |
content | string | yes | Message body. |
username | string | no | Override the webhook’s display name for this message. |
avatar_url | string | no | Override the webhook’s avatar (URL) for this message. |
Example:
{
"name": "send_webhook_message",
"arguments": {
"webhook_id": "1234567890123456789",
"token": "abcdef123456...",
"content": "[#general → Slack] Daisy: deploy is green",
"username": "Daisy (via Slack)",
"avatar_url": "https://example.com/daisy.png"
}
}
Returns: Webhook message sent.
Invites
list_invites
List active invites in the server. Each entry includes the invite code, target channel, inviter, and use count.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
Returns: <count> invite(s): followed by one line per invite as discord.gg/<code> → channel <id> (inviter: <name>, uses: N/M) (M is ∞ if unlimited).
create_invite
Create a new invite for a channel.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
channel_id | string | yes | Channel snowflake. |
max_age | integer | no | Lifetime in seconds. Default 86400 (24h); 0 = never expires. |
max_uses | integer | no | Max uses. Default 0 = unlimited. |
temporary | boolean | no | If true, members joining via this invite get kicked when they go offline. |
unique | boolean | no | If true, always create a new invite. If false (default), Discord may return an existing matching invite for this channel. |
Returns: Created invite discord.gg/<code> (max_age=N, max_uses=N, temporary=B).
delete_invite
Delete an invite by its code.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
code | string | yes | Invite code (the part after discord.gg/). |
Returns: Invite discord.gg/<code> deleted.
get_invite_details
Look up an invite (does not need bot to be in the target guild). Returns server name, channel name, member counts, and expiration.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
code | string | yes | Invite code. |
Returns: discord.gg/<code>: server '<name>' channel '<name>' members <online> online / <total> total, expires <timestamp or never>.
Custom Emoji
list_emojis
List all custom emoji in the server.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
Returns: <count> emoji: followed by one line per emoji as :<name>: (id=<id>, animated|static).
create_emoji
Create a custom emoji from a remote image URL. The bot fetches the URL, base64-encodes the bytes, and uploads.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
name | string | yes | Emoji name (2-32 chars, alphanumeric + underscore). |
image_url | string | yes | HTTPS URL to PNG/JPEG/GIF/WEBP. Discord caps at 256 KiB before base64 — bot rejects larger before upload with a clear error. |
Returns: Created emoji :<name>: (id=<id>).
edit_emoji
Rename a custom emoji.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
emoji_id | string | yes | Emoji snowflake. |
name | string | no | New name (2-32 chars). At least one editable field required. |
Returns: Emoji :<name>: updated.
delete_emoji
Delete a custom emoji.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
guild_id | string | no | Server ID. Defaults to the configured guild. |
emoji_id | string | yes | Emoji snowflake. |
Returns: Emoji deleted.
FAQ
The questions that come up most often, grouped by topic. Each answer points at the deeper page if you need the full story.
Getting it running
My bot doesn’t come online — there’s no green dot.
Three common causes, in order of frequency:
- The token is wrong. Logs show
Invalid Tokenor a WebSocket close. Generate a new token in the Discord Developer Portal under your application → Bot → Reset Token, paste it into your.env, restart the bot. - Privileged intents are off. The bot needs Message Content
Intent (to read prefix commands) and Server Members Intent
(for joins, auto-role, welcome). Both are toggles on the same Bot
page. Logs show
Disallowed intents. - The bot panicked at startup. Look for the line right before
the process exits. The
Config::load()panics with a clear message when a required env var is missing or still has its placeholderyour-...value.
See Quickstart Troubleshooting for the full list and Debugging for log-level controls.
!m help doesn’t get a reply, but the bot is online.
Two possibilities:
- Permissions in that channel. The bot needs View Channel, Send Messages, and Read Message History. Some channels inherit deny-by-default overrides that block bot replies. Check channel-level overrides for the bot’s role.
- The prefix is wrong. Default is
!, butcommand_prefixinconfig.tomlmay have been changed. The startup logs printInstance config loaded: <name> (prefix: <prefix>)— check that.
AI chat doesn’t reply when I @mention the bot.
AI chat needs at least one of DEEPSEEK_API_KEY or GEMINI_API_KEY.
If neither is set, mentions are silently ignored. Set one in your
.env, restart, and try again.
If you have a key set and it still doesn’t reply, tail the logs with
RUST_LOG=discord_bot::ai=debug,info — the pipeline logs every
inbound mention and every API call. Common downstream causes:
- Rate limit hit (10 calls per user per 60s). Wait a minute.
- DeepSeek/Gemini outage. The logs will show the upstream error.
- The bot lacks Send Messages in the channel you mentioned it in.
See AI Chat for the full feature page.
Music doesn’t play.
The pipeline is yt-dlp → ffmpeg → songbird. Each can fail:
yt-dlpoutdated — YouTube breaks yt-dlp every few weeks.pip install -U yt-dlpin the bot’s environment, restart. The Dockerfile pins topip install yt-dlp; rebuilding the image pulls the latest.ffmpegmissing onPATH— the Docker image bundles it; bare-metal needsapt install ffmpeg.- Bot can’t join voice — check the voice channel’s permissions for Connect and Speak on the bot’s role.
- DJ mode is on —
!m djmodetoggles a setting where only members with the configured DJ role can use music commands. Check with!m djmodeagain to see the current state.
Database connection issues.
Failed to connect to database at startup means the connection
string in .env is wrong or the database isn’t reachable. Quick
sanity check: psql "$DATABASE_URL" from the same host. If that
works and the bot still can’t connect, double-check the bot is
loading the right .env (CONFIG_DIR must point at the directory
holding it).
If you see pool acquire timed out mid-run, Postgres restarted or
the network blipped. sqlx reconnects on the next query — usually no
action needed, but check Postgres health if it keeps happening.
Multi-instance and operations
How do I run more than one bot on the same machine?
The model is “one process per bot, one schema per process, all
sharing one Postgres.” Concretely: copy instances/example/ to a
new directory, fill in a different DISCORD_TOKEN, CLIENT_ID,
GUILD_ID, and DB_SCHEMA, then add a second bot service to
docker-compose.yml pointing at the new directory.
The full recipe is in
Multiple Instances. The
key constraint: DB_SCHEMA must be unique per instance. There’s
no defensive check, and two instances on the same schema will
corrupt each other’s data.
How do I upgrade?
Pull the latest image (or rebuild from source), then restart the bot service:
git pull
docker compose pull bot
docker compose up -d bot
The bot is forward-compatible across schema migrations — migrate
runs CREATE TABLE IF NOT EXISTS for every table at startup. If a
release introduces a destructive migration (column rename, table
drop) the changelog and release notes will say so explicitly.
For multi-instance deployments, restart instances one at a time so you keep at least one bot online while you upgrade the others.
See Upgrading for the deeper version of this answer including rollback steps.
How do I back up the bot?
Two things to back up:
- The Postgres data volume. All persistent state — tempbans,
guild settings, stock portfolios, member activity, reminders if
you’ve added them — lives there.
pg_dumpagainst yourDATABASE_URL, store the dump somewhere safe, automate with cron or a managed backup service. Per-instance dumps arepg_dump --schema=<DB_SCHEMA>. - Your
instances/directories. They contain.env(with secrets),config.toml,personality.txt, and any prompt files you’ve added.tarthem up and store alongside the database backups. They’re small.
Source code is on GitHub; you don’t need to back that up.
How do I expose the MCP server externally?
Default behaviour is MCP_BIND_ADDR=127.0.0.1, which only the bot’s
own host can reach. To expose it:
- Set
MCP_AUTH_TOKEN=<long random value>— without auth on a public address, anyone with network access can drive the bot. - Set
MCP_BIND_ADDR=0.0.0.0(or your specific interface). - Open the port (
MCP_PORT, default9090) in your firewall. - If you’re running multiple instances, use the included
mcp-gatewayto route requests by instance name.
MCP Exposure walks through the threat model. Don’t skip the auth token.
How do I enable Minecraft features?
Three steps:
- Install the companion plugin on your Minecraft server (it owns the verification and donator-tier endpoints the bot calls).
- Set
MC_VERIFY_URLandMC_VERIFY_SECRETin the bot’s.env. - In
config.toml, setfeatures.minecraft = trueand toggle the sub-features you want under[minecraft]:verify,donator_sync,chargeback. Each sub-feature has its own[minecraft.*_config]table — seeinstances/example/config.tomlfor every option.
Restart the bot. The startup logs print which sub-features activated and which are enabled-but-misconfigured.
Minecraft Verify, Donator Sync, and Chargeback Alerts have the deeper details.
Design and conventions
Why prefix commands and no slash commands?
Three reasons, in order:
- Iteration speed. Prefix commands are immediate — change a handler, rebuild, type the command, see the result. Slash commands have global registration delays and per-guild quotas that turn the dev loop into “edit, push, wait, test.”
- No global state in Discord. Slash commands live as objects in Discord’s API, attached to your application. Prefix commands live entirely in your code. That makes the bot easier to fork, self-host, and run with custom variants.
- Subcommand parsing was already solved. Poise gives the bot a
parent
mcommand with a typed subcommand tree, which gives users an interface that reads like!m play,!m wordle,!m stocks portfolio— close enough to slash UX to be familiar without inheriting the registration pain.
The prefix is configurable per-instance (command_prefix in
config.toml); pick whatever you want.
What’s the license, really?
AGPL-3.0-or-later. The shape of it for a bot host:
- You can run this bot for any purpose, commercial or not.
- You can modify it for your own use without telling anyone.
- If you run a modified version that interacts with users over a network — which a Discord bot does by definition — the AGPL obligates you to make your modified source available to those users on request.
- You can’t take this code, modify it, run it as a service, and refuse to share the source. That’s the entire point of AGPL over GPL.
Contributing back is welcome but not required by the license; the license only requires that derivative network services share their own source. See CONTRIBUTING.md for the contribution terms.
Can I commercialize this?
Yes, with the AGPL constraint above. You can run a paid hosted version of this bot, charge money for support, or sell custom features built on top — the license imposes no fee, no royalty, and no commercial restriction. What it does require: anyone using your hosted version can request the source code of the version you’re running, and you have to provide it. If you’re building proprietary extensions that you don’t want to release, the AGPL probably isn’t compatible with your business model and you should look elsewhere.
How do I contribute?
The short version: fork, branch, change, test, PR. The long version
is in Contributing Workflow.
The pre-PR checklist is cargo fmt,
cargo clippy --all-targets -- -D warnings, cargo test, and a
manual run-through in a test Discord server.
If you’re new to the codebase, start with Codebase Tour and pick a small issue from GitHub Issues to land your first PR.
Misc
What models does the AI use?
The default is DeepSeek’s deepseek-v4-flash model when
DEEPSEEK_API_KEY is set, falling back to Google’s Gemini
(gemini-2.0-flash) when DeepSeek errors out and GEMINI_API_KEY
is set. Both are remote API calls; the bot has no local model.
DeepSeek is recommended as the primary because it’s the cheapest of
the supported providers.
Can I change the bot’s personality?
Yes — every instance has a personality.txt next to its
config.toml. The contents are used as the system prompt for AI
chat. Edit, restart the bot, talk to it. See
Personality Files for tips on
writing one that holds up.
Where does the music come from?
Anything yt-dlp can resolve — YouTube videos, playlists,
SoundCloud, Bandcamp, direct links to media files, and a few
hundred other sources. yt-dlp does the resolution; ffmpeg pipes the
output to songbird in OGG/Opus passthrough so the bot doesn’t
re-encode. The pipeline is in src/music/track.rs and voice.rs.
Why does the bot use so much disk space?
It doesn’t, unless logging is misconfigured. The bot itself stores
nothing on disk other than personality.txt and any cookies
yt-dlp keeps in its cache. All persistent state is in Postgres.
If your container is growing, check docker logs --no-color bot | wc -l — Docker keeps logs forever by default. Set a size limit in
docker-compose.yml under the bot service:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Still stuck?
Log a GitHub Issue. Bug report template asks for the version, reproduction steps, and redacted logs — those three together usually let someone help you on the first reply.
Glossary
Definitions for terms that show up across the codebase and the rest of the documentation. Skim this if you’ve seen a word twice and aren’t sure what it means here specifically.
AGPL
Short for GNU Affero General Public License, version 3 or later, the licence this project ships under. AGPL is GPL with one extra clause: software interacted with over a network is treated the same as distributed software for the purpose of source-availability obligations. A Discord bot inherently interacts with users over a network, so anyone running a modified version is required to make their source available to its users on request. See the FAQ for the practical shape.
DeepSeek
A Chinese AI provider whose deepseek-v4-flash model is the bot’s
primary backend for @mention AI chat when DEEPSEEK_API_KEY is set.
Cheap, fast, supports OpenAI-style tool calls. See the
AI Pipeline page.
Gemini
Google’s hosted LLM service. Used as the fallback path when DeepSeek
returns an error and GEMINI_API_KEY is set, or as the only
provider when DeepSeek isn’t configured.
Gateway (Discord)
The persistent WebSocket connection the bot maintains with Discord’s servers. Events (messages, member joins, voice updates) arrive over the gateway; the bot acknowledges them and replies via Discord’s HTTP API. Maintained by serenity.
Gateway (MCP)
A separate small Rust crate, mcp-gateway/,
that sits in front of one or more bot instances and exposes a single
external MCP endpoint. It routes incoming tool calls to the right
instance based on a configured guild-to-instance map or an explicit
instance parameter. See
MCP Gateway Routing.
ghcr.io
GitHub Container Registry, where the project publishes Docker images
when a release is tagged. The image reference looks like
ghcr.io/mrmcepic/discord-bot-rs:latest. The docker-compose.yml
in the repo can either build locally or pull from ghcr.io.
Hard tabs
The project’s indentation choice, enforced by rustfmt.toml. Every
indent level is one tab character, displayed as four columns. Don’t
expand tabs to spaces; cargo fmt will undo it.
Instance
One configured Discord bot identity. An instance is everything
contained in an instances/<name>/ directory: config.toml, .env
(with the Discord token, client ID, guild ID, database schema, and
optional API keys), personality.txt, and any prompt files. Each
running bot process serves exactly one instance. Two instances on
the same machine are two separate processes sharing the same
Postgres but writing to different schemas. See
Multi-Instance Model.
Intents
Discord’s gateway permissions system. The bot must declare which
classes of events it wants — GUILDS, GUILD_MESSAGES,
MESSAGE_CONTENT, GUILD_VOICE_STATES, GUILD_MEMBERS — and Discord
filters anything else out before sending. Two intents are
privileged and require an explicit toggle in the developer portal:
Message Content (needed to read prefix commands) and Server
Members (needed for joins, auto-role, welcome). The full list is
declared in main.rs.
mdBook
The static site generator the documentation is built with. Markdown
under docs/ plus book.toml plus a theme equals the site at
mrmcepic.github.io/discord-bot-rs.
mdbook serve gives you a live preview at localhost:3000. See
Building Locally.
Mermaid
A text-based diagramming tool. The architecture pages use Mermaid
graphs (in fenced ```mermaid blocks) for component diagrams and
sequence diagrams. mdBook renders them client-side via
mermaid.min.js.
OGG/Opus passthrough
The format the music pipeline uses for streaming audio to Discord. yt-dlp downloads source media; ffmpeg remuxes (without re-encoding) into an Opus-in-OGG container at 256 kbps; songbird sends the bytes straight to Discord. “Passthrough” means the bot never decodes and re-encodes — it just copies the Opus packets, which is fast and preserves quality. The downside is that source media that isn’t already Opus has to be transcoded by ffmpeg, which the pipeline handles transparently.
Personality
The contents of personality.txt in an instance directory, used as
the system prompt for the AI chat pipeline. Each instance has its
own; the loader panics if the file is missing or empty. See
Personality Files.
Poise
The command framework on top of serenity.
Provides typed Context<'_, Data, BotError>, a derive macro for
prefix commands, automatic argument parsing for Discord types, and a
subcommand tree. This project uses poise’s prefix commands
exclusively.
Prefix command
A Discord bot command invoked by a leading prefix character (default
! here, configurable per-instance via command_prefix) plus the
command name. Prefix commands use the MESSAGE_CONTENT intent
because the bot reads message text directly. Distinct from slash
commands, which Discord registers as application commands and
auto-completes for users — this project deliberately uses prefix
commands only. See the FAQ
for the reasoning.
Schema-per-instance
The database isolation strategy used in multi-instance deployments.
One PostgreSQL database hosts every instance; each instance has its
own DB_SCHEMA (e.g. bot1, bot2); on every connection the bot
runs SET search_path TO "<schema>". Tables, sequences, and indexes
all live inside the schema, so two instances pointed at the same
database but different schemas can’t see each other’s data. See
Multi-Instance Model and
init_pool in src/db/mod.rs.
Serenity, Poise, Songbird
The core Rust Discord stack the project is built on. Serenity owns the gateway connection, the typed Discord model, and the HTTP client. Poise is a command framework on top of serenity. Songbird is the voice driver — voice gateway, UDP transport, Opus packetisation. All three are maintained by the serenity-rs organisation.
Snowflake
Discord’s 64-bit ID format. Every guild, channel, user, role, and
message has one. They’re 64-bit integers but exposed in JSON as
strings (because JavaScript can’t safely represent 64-bit integers
as numbers). The bot follows the same convention internally —
parameter structs use String for IDs and parse to u64 on the way
in via the parse_id helper.
sqlx
The async PostgreSQL client the bot uses for every database call.
The project uses sqlx’s runtime helpers (query, query_as) with
bind rather than its compile-time query! / query_as! macros, so
the crate builds without a live database at compile time. See
src/db/queries.rs.
Tool (AI)
A function the bot exposes to the LLM through DeepSeek’s or
Gemini’s tool-call protocol. When the LLM emits a structured tool
call in its response, the bot dispatches it to the matching Rust
handler (e.g. web_search, play_song, start_wordle,
create_tempban) and feeds the result back into the conversation.
Defined in src/ai/tools.rs, dispatched in src/ai/chat.rs.
Tool (MCP)
A function the bot exposes to external Model Context Protocol
clients — Claude Code, the bundled mcp-gateway, anything else
speaking JSON-RPC against the bot’s /mcp endpoint. Each tool is a
method on the DiscordTools impl in src/mcp/tools.rs annotated
with #[tool(description = "...")]. The #[tool_router] macro on
the impl block discovers them automatically. Distinct from AI tools
above — different protocol, different consumer, different
registration mechanism. See
Adding an MCP Tool.
Webhook (chargeback)
The HTTP endpoint exposed by the bot when minecraft.chargeback = true, listening on the same port as the MCP server. The companion
Minecraft plugin POSTs to it when a chargeback is detected; the bot
verifies the signature, applies the configured restricted role, and
posts an interactive alert to the staff channel. See
Minecraft Chargeback.
See also
- Architecture Overview — how the components defined here fit together.
- FAQ — the same vocabulary in context.
- Codebase Tour — every term here mapped to a file.