Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Open https://discord.com/developers/applications and sign in.
  2. Click New Application in the top right.
  3. 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.
  4. 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

  1. In the left sidebar, click Bot.
  2. Scroll to the Token section. If a token is already displayed, copy it. If not, click Reset Token and copy the new value.
  3. 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.

  1. In the left sidebar, click Installation (newer portals) or OAuth2 → URL Generator (older portals).
  2. Under Scopes, check bot only. (The bot has no slash commands, so applications.commands isn’t required — checking it is harmless.)
  3. 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.
  4. Copy the generated URL at the bottom of the page.
  5. 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.”

  1. In Discord, open User Settings → Advanced and turn on Developer Mode.
  2. In your server list, right-click the server you just invited the bot to and choose Copy Server ID.
  3. 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-plugin or docker-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.yml includes 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:

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 help with 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.

  1. Open https://discord.com/developers/applications and log in with your normal Discord account.
  2. Click New Application in the top right.
  3. 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.

  1. Click Bot in the left sidebar.
  2. 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.

  1. Click Installation in the sidebar (or OAuth2 → URL Generator on older portals).
  2. Under Scopes, check bot. (applications.commands isn’t needed — the bot has no slash commands — but checking it is harmless if your workflow already includes it.)
  3. 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.
  4. 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

  1. In Discord, open User Settings → Advanced and turn on Developer Mode.
  2. Right-click your server icon and choose Copy Server ID.
  3. 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:

  1. Postgres starting up and reporting it is ready to accept connections.
  2. The bot connecting to Postgres and creating its schema (“Database initialized (schema: mybot)”).
  3. The bot loading its instance config (“Instance config loaded: My Bot (prefix: !)”).
  4. The bot connecting to Discord (“Starting bot…” → shard running → “My Bot is connected! (ID: …)”).
  5. 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.txt and put whatever you want the bot’s voice to be. Restart with docker 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 skip skips, !m queue shows the queue.
  • Try Wordle. Run !m wordle in 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, and postgres and bot report healthy.
  • 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 help replies 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:

  • .env holds secrets and runtime connection details: the Discord token, the database URL, AI provider API keys, and the schema name. It is loaded by dotenvy at startup and exists only on the host (and inside the container at runtime).
  • config.toml holds 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.txt holds 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

WhatFileExample
Discord bot token.envDISCORD_TOKEN=MTxxxxxxxxxxxxxxxx...
Database URL.envDATABASE_URL=postgresql://user:pass@host:5432/db
Postgres schema name.envDB_SCHEMA=mybot
AI provider API keys.envDEEPSEEK_API_KEY=sk-...
Bot display nameconfig.tomlbot_name = "My Bot"
Command prefixconfig.tomlcommand_prefix = "!"
Feature flagsconfig.toml[features] auto_role = true
Role and channel IDsconfig.toml[auto_role] from_role = "123456789012345678"
AI personalitypersonality.txtYou are a friendly assistant on this Discord server.

How the files are loaded

At startup the bot does the following, in order:

  1. Calls dotenvy::dotenv() to read the .env file from the current working directory, exporting each key into the process environment. This populates DISCORD_TOKEN, DATABASE_URL, and friends.
  2. Determines CONFIG_DIR from 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.
  3. Reads config.toml from CONFIG_DIR and parses it into the InstanceConfig struct. Failures here panic with a clear error pointing at the file.
  4. Reads personality.txt (or whatever personality_file is set to) from CONFIG_DIR. An empty or missing file panics; the AI chat module needs a non-empty system prompt.
  5. 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

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:

  1. A .env file in the current working directory, parsed at startup by dotenvy. The file is plain KEY=VALUE lines; comments start with #. Empty values are treated as if the variable were unset for every optional key here, so DEEPSEEK_API_KEY= means “no DeepSeek key.”
  2. The process environment itself. Anything Docker Compose passes via env_file or an environment: 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)

NameRequiredDefaultDescription
DISCORD_TOKENyesBot token from the Discord Developer Portal
CLIENT_IDyesApplication (client) ID for the bot user
GUILD_IDyesSnowflake 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)

NameRequiredDefaultDescription
DATABASE_URLyespostgresql://discord_bot:discord_bot_pass@localhost:5432/discord_botPostgreSQL connection string
DB_SCHEMAyespublicPostgres 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)

NameRequiredDefaultDescription
DEEPSEEK_API_KEYnounsetAPI key for DeepSeek chat completions
GEMINI_API_KEYnounsetAPI key for Google Gemini chat completions
GROK_API_KEYnounsetAPI 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)

NameRequiredDefaultDescription
FINNHUB_API_KEYnounsetAPI 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)

NameRequiredDefaultDescription
MC_VERIFY_URLwhen any minecraft sub-feature is onunsetBase URL of the companion plugin’s HTTP API
MC_VERIFY_SECRETwhen any minecraft sub-feature is onunsetShared 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.

NameRequiredDefaultDescription
MCP_PORTno9090TCP port the MCP server listens on
MCP_BIND_ADDRno127.0.0.1Bind address for the MCP server
MCP_AUTH_TOKENwhen bind is non-loopbackemptyBearer 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 the mcp-gateway sidecar reach the bot over the Docker bridge network at http://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.0 from 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 with MCP_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)

NameRequiredDefaultDescription
MCP_GATEWAY_AUTH_TOKENgateway serviceunsetBearer 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

FieldTypeRequiredDefaultDescription
bot_namestringyesDisplay name shown in help output and logs
command_prefixstringyesPrefix for text-based commands
command_rootstringno"m"Parent command name; "" for flat commands
personality_filestringno"personality.txt"Path to the personality file, relative to config.toml
timezonestringno(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:

ValueEffect
"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 default command_prefix and m is the default command_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.

FieldTypeDefaultGates
minecraftboolfalseThe Minecraft module (verify, donator_sync, chargeback)
auto_roleboolfalsePeriodic auto-role promotion based on member age and activity
join_roleboolfalseAssigning a role to every new member on join
welcomeboolfalseAI-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.

FieldTypeRequiredDefaultDescription
from_rolestringyesSnowflake of the role to remove
to_rolestringyesSnowflake of the role to grant
min_agestringno"3d"Minimum time in the guild before promotion
min_messagesintno20Minimum messages sent in the guild before promotion
require_allboolnofalseIf 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:

SuffixUnitExample
sseconds30s
mminutes15m
hhours2h
ddays3d (the default)
wweeks1w

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.

FieldTypeRequiredDefaultDescription
rolestringyesSnowflake 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.

FieldTypeRequiredDefaultDescription
channelstringyesSnowflake of the channel to post into
prompt_filestringno"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.

FieldTypeRequiredDefaultDescription
verifyboolnotrueEnable the !m verify linking command
donator_syncboolnofalsePoll the MC server and sync donator-tier roles
chargebackboolnofalseReceive 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.

FieldTypeRequiredDefaultDescription
supporter_rolestringyesSnowflake of the supporter-tier role
premium_rolestringyesSnowflake of the premium-tier role
check_intervalintno300Seconds 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.

FieldTypeRequiredDefaultDescription
staff_channelstringyesSnowflake of the channel to post alerts in
restricted_rolestringyesSnowflake of the role to apply to offenders
staff_roleslist of stringno[]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_name or command_prefix is 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 = true with no [minecraft] section logs a warning and disables the Minecraft module. The bot still starts.
  • features.auto_role = true with no [auto_role] section logs a warning and disables auto-role. The bot still starts.
  • features.join_role = true with no [join_role] section logs a warning and disables join-role. The bot still starts.
  • features.welcome = true with no [welcome] section, no prompt file, or an empty prompt file logs a warning and disables welcomes. The bot still starts.
  • minecraft.donator_sync = true with no [minecraft.donator_sync_config] logs a warning and disables donator sync.
  • minecraft.chargeback = true with 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:

NameURLModelEnv varmax_tokenstimeoutvisiontoolsreasoner
deepseek_chathttps://api.deepseek.com/chat/completionsdeepseek-v4-flashDEEPSEEK_API_KEY819230noyesno
deepseek_reasonerhttps://api.deepseek.com/chat/completionsdeepseek-v4-proDEEPSEEK_API_KEY65536300nonoyes
gemini_flashhttps://generativelanguage.googleapis.com/v1beta/openai/chat/completionsgemini-3-flash-previewGEMINI_API_KEY1638430yesyesno
grokhttps://api.x.ai/v1/chat/completionsgrok-3GROK_API_KEY1638430noyesno

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:

RoleIf setIf omitted
chatMust resolve to a configured provider; otherwise the bot panics at startupRequired — bot panics at startup
visionMust resolveImage-bearing requests fall through to chat with a warning log
reasonerMust resolveClassifier 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 reads std::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 (default 30) — HTTP timeout for chat-completions calls. DeepSeek Reasoner needs 300 (5 minutes); fast chat models are fine with the default.
  • supports_vision (default false) — whether the model accepts image content parts. Today only the Gemini default registry entry sets this to true.
  • supports_tools (default true) — whether the model accepts a tools array. DeepSeek Reasoner is the standout exception (set to false).
  • is_reasoner (default false) — flags a slow reasoning model. The orchestration layer uses this signal alongside the longer timeout_secs budget 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:

AliasResolves toAdded in
geminigemini_flash0.14.0
deepseekdeepseek_chat0.14.0
deepseek-chatdeepseek_chat0.14.0
deepseek-v4deepseek_chat0.18.0
deepseek-v4-flashdeepseek_chat0.18.0
deepseek-v4-prodeepseek_reasoner0.18.0
deepseek-reasonerdeepseek_reasoner0.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)

  • headersHashMap<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 system field on the request body (not a role: "system" message in the array)
  • Image content parts → base64 source blocks with correct media_type
  • Tool definitions → flat {name, description, input_schema} shape
  • Tool call responses → tool_use content blocks are extracted into the same flat ToolCall { id, name, arguments } shape the bot uses internally
  • Tool result messages → wrapped in user-content tool_result blocks

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_control ephemeral 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:

CaseBehaviour
[ai.routing] chat unset OR points at unknown provider namePanic
[ai.routing] vision / reasoner set to unknown provider namePanic
[ai.routing] section present without chatPanic
Provider name contains whitespacePanic
Provider with spec = "anthropic"Fully supported as of 0.16.0 — see Anthropic spec
Provider’s api_key_env resolves to unset env varProvider marked unavailable
Routing or fallback references an unavailable providerWarn at startup
[ai.routing] vision references provider with supports_vision = falseWarn at startup
[ai.routing] reasoner references provider with is_reasoner = falseWarn at startup
[ai.fallback] on_censored references defined-but-unavailable providerWarn at startup; cascade_for skips it at request time
[ai.fallback] on_censored references completely unknown nameWarn 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

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:

  1. The root .env (used in some legacy local-dev flows).
  2. Any per-instance .env under instances/<name>/.
  3. The negation !instances/*/.env.example keeps the documented templates checked in, so the docs and onboarding still work.
  4. 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 → BotReset 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>';), update DATABASE_URL in .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:

  1. 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.
  2. Update .env with the new value.
  3. Restart the bot. Confirm it comes back up cleanly with the new credentials.
  4. Revoke the old secret in the provider dashboard. For Discord, Reset Token invalidates the previous one automatically.
  5. Audit logs for the window the secret was live. Look for unexpected message activity, role changes, channel creations, or API calls.
  6. If the secret was committed to git, the value is in history and is effectively public. Use git filter-repo to 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_bot user but rely on the per-schema search_path for isolation.
  • Set tight permissions on .env. chmod 600 instances/yourbot/.env so 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 Config struct.
  • MCP_AUTH_TOKEN is mandatory on any non-loopback bind. The bot now refuses to start if MCP_BIND_ADDR is anything other than a loopback address and MCP_AUTH_TOKEN is empty — this used to be a “you really should” recommendation; it is now enforced at startup. The bundled Compose .env.example ships with MCP_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. The mcp-gateway service is stricter still and refuses to start at all without MCP_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 update and rebuild on a regular cadence; security advisories for transitive crates show up via cargo audit if 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:

  1. Copy the example directory.

    cp -r instances/example instances/bot2
    cp instances/bot2/.env.example instances/bot2/.env
    
  2. Edit instances/bot2/.env. At minimum, change:

    • DISCORD_TOKEN — the new bot’s token from the Discord Developer Portal
    • CLIENT_ID — the new application’s client ID
    • GUILD_ID — the snowflake of the new bot’s home server
    • DB_SCHEMA=bot2 — must be unique across instances

    Leave DATABASE_URL pointing 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.

  3. Edit instances/bot2/config.toml. Set bot_name to something distinct (it shows up in logs and helps you tell which instance is which when tailing) and adjust command_prefix, feature flags, and feature sub-sections to match what you want this bot to do.

  4. 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.

  5. Duplicate the bot service in docker-compose.yml. Copy the existing bot service block and rename the copy to bot2. Update its env_file to point at instances/bot2/.env, update its volume mount so instances/bot2 becomes /config inside the new container, and change the container name. The image, depends_on: postgres, and restart settings can stay identical — both containers run the same image with different config mounted.

  6. Update the mcp-gateway service. Add the new instance to the gateway’s INSTANCES env var so it knows where to route requests for bot2. The gateway reads this list at startup; restarting the gateway picks up new entries.

  7. 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_SCHEMA in the instance’s .env and applied via SET search_path on every new connection (see src/db/mod.rs). Tables, sequences, and migrations all live inside the schema, so two instances with DB_SCHEMA=bot1 and DB_SCHEMA=bot2 cannot 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_SCHEMA must 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 bot service block without renaming the container_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:9090 inside 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

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_KEY or GEMINI_API_KEY the bot just never replies to mentions. Music is similarly always-on but needs yt-dlp and ffmpeg on the PATH to actually play anything.
  • Opt-in — the feature is gated behind a boolean in the [features] section of config.toml. If the flag is false (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

FeatureConfig FlagRequired Env VarsRequired ConfigDocs
AI Chatalways-on (activates if AI key set)DEEPSEEK_API_KEY and/or GEMINI_API_KEYpersonality.txtai-chat.md
Musicalways-onnoneyt-dlp + ffmpeg on PATH; optional cookies.txtmusic.md
Wordlealways-onnonenonegames-wordle.md
Connectionsalways-onnonenonegames-connections.md
Stocksalways-on (inert without key)FINNHUB_API_KEYnonegames-stocks.md
Moderationalways-onnonenonemoderation.md
Auto-Role Promotionfeatures.auto_role = truenone[auto_role] sectionauto-role.md
Join Rolefeatures.join_role = truenone[join_role] sectionjoin-features.md
Welcome Messagesfeatures.welcome = trueDEEPSEEK_API_KEY and/or GEMINI_API_KEY[welcome] section + welcome_prompt.txtjoin-features.md
Minecraft: Verifyfeatures.minecraft = true, minecraft.verify = trueMC_VERIFY_URL, MC_VERIFY_SECRET[minecraft] sectionminecraft-verify.md
Minecraft: Donator Syncfeatures.minecraft = true, minecraft.donator_sync = trueMC_VERIFY_URL, MC_VERIFY_SECRET[minecraft.donator_sync_config]minecraft-donator-sync.md
Minecraft: Chargeback Alertsfeatures.minecraft = true, minecraft.chargeback = trueMC_VERIFY_URL, MC_VERIFY_SECRET[minecraft.chargeback_config]minecraft-chargeback.md
MCP Serveralways-onnone (MCP_* optional)nonemcp-server.md

A few things worth calling out:

  • The flags in the table match the field names on the Features struct in src/instance_config.rs. There is no auto_role, join_role, welcome, or minecraft flag in [features] if you don’t put it there — they all default to false.
  • When you set a features.* flag to true but 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 editing config.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 stock commands are always registered, but every command will return “Finnhub API key not configured” if FINNHUB_API_KEY is missing.

Enabling a feature at runtime

Features are loaded once at startup. There is no live reload. To change what is enabled:

  1. Edit config.toml (or .env for env-var-driven features).
  2. Restart the bot. With Docker Compose that’s docker compose restart <service-name>.
  3. Check the startup logs to confirm the feature was picked up — every opt-in feature emits an enabled log line on boot.

For deployment-level guidance on how to roll restarts safely, see Upgrading.

Cross-references

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 to deepseek-v4-pro for 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:

  1. The message contains a direct mention of the bot’s user (the bot is in message.mentions).
  2. 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:

  1. DEEPSEEK_API_KEY in .env. Optional in the strict sense, but without it you get no text replies. Get one from platform.deepseek.com.
  2. GEMINI_API_KEY in .env. Optional. Required only if you want image understanding or text-fallback when DeepSeek is unreachable.
  3. personality.txt in the instance config directory (the directory pointed at by CONFIG_DIR, defaults to the working directory). This is loaded at startup by InstanceConfig::load_personality and 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.txt overrides 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:

  • Musicplay_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.
  • Moderationtempban, unban, nuke. Privileged. Every moderation tool call goes through a confirmation embed (see below) before it actually runs.
  • Web searchweb_search, used for current-events questions and fact-checking. Up to three rounds of search are allowed per request (the MAX_SEARCH_ROUNDS constant in src/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.
  • Stocksstock_buy, stock_sell, stock_price, stock_portfolio, stock_leaderboard. Bound to the virtual portfolio system.
  • Gamesconnections_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:

  1. The AI emits a moderation tool call.
  2. The bot posts an embed showing exactly what is about to happen (“Tempban @user for 3d — repeated spam”), with Approve and Cancel buttons.
  3. Only the original requesting user can press a button.
  4. If the user lacks the corresponding permission (BAN_MEMBERS for tempban/unban, MANAGE_MESSAGES for nuke), the bot refuses up front.
  5. 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 that DEEPSEEK_API_KEY is set in the running environment (Docker users: confirm .env is being passed in), check the bot’s logs for API request failed messages, and verify the bot has the Message Content gateway intent enabled in the Discord developer portal.
  • Bot replies but the personality is wrongpersonality.txt was 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 Risk error 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:

Cross-references

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_LENGTH in src/music/player.rs).
  • Plays anything yt-dlp can 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 !.

CommandAliasesDescription
!m play <url-or-query>!m pPlay 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 plResolve 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 sSkip 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 stopStop playback, clear the queue, leave the voice channel. The opposite of !m play.
!m pausePause the current track. The voice connection stays up.
!m resume!m rResume a paused track.
!m queue!m qShow the queue: now-playing, the next 15 tracks (with a “+ N more” line if longer), and total duration.
!m nowplaying!m npShow 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 lSet the loop mode. With no argument, cycles through the modes. track repeats the current song; queue re-enqueues finished tracks at the back.
!m shuffleRandomize 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:

  1. The user pressing them must be in the same voice channel as the bot.
  2. 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 play on a playlist URL only takes the first video, by design (--no-playlist is set on the single-track path).
  • SoundCloud, Bandcamp, Mixcloud, Vimeo, Twitch VODs, direct media URLs — anything in the yt-dlp extractor 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:

  1. Install a browser extension such as “Get cookies.txt LOCALLY” (Firefox or Chrome).
  2. Log into YouTube in the browser session you control.
  3. Use the extension to export cookies for youtube.com.
  4. Save the file as cookies.txt in the instance config directory next to config.toml.

Use a throwaway YouTube account for this. The bot is going to make API calls with whatever account you log in as.

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:

  1. Run yt-dlp with --cookies cookies.txt.
  2. If the call succeeds, return the result.
  3. 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 --cookies flag at all.
  4. If the second attempt succeeds, return the result and tell the caller to flag the cookies as stale.
  5. 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.txt plus a tunneled connection might work, but that’s outside the bot’s scope.
  • Bot joins the channel but no audio playsffmpeg is 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 on PATH.
  • Audio is choppy or stutters — rare with passthrough, since CPU is barely involved, but possible if the host has heavy disk/network contention. Check htop and 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_LENGTH is 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-dlp startup 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

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 !.

CommandAliasesDescription
!m wordle!m wStart today’s puzzle in this channel.
!m wordle random!m w rand, !m w rStart 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

  1. Start the puzzle with one of the commands above. The bot posts an embed showing six empty rows and a Wordle — YYYY-MM-DD title.
  2. 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.
  3. 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.
  4. 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 date argument 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 before 2021-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_start as a tool, so users can say “@bot start a Wordle” without remembering the command.
  • Instance Configcommand_prefix if 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.

CommandAliasesDescription
!m connections!m connStart today’s puzzle in this channel.
!m connections random!m conn rand, !m conn rStart 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

  1. Run !m connections (or one of the variants above). The bot posts an embed with the title Connections — YYYY-MM-DD, a “Mistakes remaining: ⬛⬛⬛⬛” line, and four rows of word buttons.
  2. 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.
  3. Click Deselect to clear your current selection, Shuffle to randomize the remaining words on the board, or Submit to commit the four-word guess.
  4. 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.
  5. 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:

  1. Solved groups at the top, in difficulty order (yellow → purple), each line showing the colour emoji, the category title, and the four words.
  2. Mistakes remaining as a row of four squares: for each remaining mistake, ✖️ for each used.
  3. 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 date argument 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 before 2023-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_start as 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 /quote endpoint, using your free or paid FINNHUB_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:

  1. A Finnhub account. The free tier is generous (60 calls/minute) and covers all US equities.
  2. The key in your bot’s .env as FINNHUB_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.

CommandAliasesDescription
!m stock!m stocks, !m stBare command; shows your portfolio.
!m stock buy <ticker> <qty|$amount>!m stock bBuy 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 sSell shares. all sells your full position.
!m stock portfolio [@user]!m stock port, !m stock pf, !m stock pShow your own portfolio, or somebody else’s if you mention them.
!m stock price <ticker>!m stock quote, !m stock qLook up the current quote. No portfolio needed.
!m stock leaderboard!m stock lb, !m stock topTop 10 portfolios by total value.
!m stock history!m stock hist, !m stock hYour last 10 trades.
!m stock resetWipe 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 equitiesAAPL, 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:

  1. Checks the database price cache for that symbol.
  2. If a fresh entry exists, returns it immediately. Otherwise hits https://finnhub.io/api/v1/quote?symbol=<TICKER> with the API key in the X-Finnhub-Token header.
  3. 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_price shares. 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,000 baseline, 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_KEY is missing or empty. Set it and restart.
  • “Could not find stock symbol X — Finnhub returned 0 for 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

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.

CommandRequired PermissionDescription
!m ban <@user> <duration> [reason]BAN_MEMBERSTempban a user. Duration is a short string (30s, 5m, 2h, 3d, 1w); reason is #[rest], so spaces are fine.
!m unban <@user>BAN_MEMBERSLift any ban on the user. Marks an active tempban resolved if one exists.
!m banlist (alias !m bans)BAN_MEMBERSShow all active tempbans in this guild with expiry timestamps.
!m nuke <count>MANAGE_MESSAGESBulk-delete the last count messages in the channel. count must be 1-100.
!m setlog <#channel>ADMINISTRATORSet 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 seconds
  • 15m — 15 minutes
  • 2h — 2 hours
  • 3d — 3 days
  • 1w — 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

  1. Parses the duration; bad input gets Invalid duration. Use: 30s, 5m, 2h, 3d, 1w.
  2. Inserts a row into the tempbans table with the guild, user, moderator, expiry timestamp, and reason.
  3. Calls Discord’s ban_with_reason with the audit-log reason Tempban by <moderator> (<duration>): <reason>.
  4. Replies in the invocation channel with a relative-timestamp footer.
  5. 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 count if 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), title Mod 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 banlist empty 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 setlog never 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 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:

  1. 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.
  2. 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

FieldTypeRequiredDefaultDescription
from_rolestring (snowflake)yesThe role the member starts with. Removed on promotion.
to_rolestring (snowflake)yesThe role the member ends up with. Added on promotion.
min_ageduration stringno"3d"Minimum time in the guild before promotion.
min_messagesintegerno20Minimum messages in the guild before promotion.
require_allboolnofalseIf 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:

  1. Adds to_role to the member.
  2. Removes from_role.
  3. Sets promoted = TRUE in 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_role and to_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 enabled and Auto-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 lost MANAGE_ROLES. Check the logs around the promotion attempt.
  • Existing members aren’t being promoted — expected. The scan only sees members who have a member_activity row, 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 promoted back to FALSE on their member_activity row in the database; the next tick re-promotes.
  • Invalid from_role ID warning — value isn’t a numeric snowflake. Copy the role’s ID, not its name.

Cross-references

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

FieldTypeRequiredDescription
rolestring (snowflake)yesThe 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_ROLES or 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 GuildMemberAddition events. Existing roleless members need to be batched manually.
  • Invalid join role ID warning — the configured role value 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:

  1. features.welcome = true in config.toml.
  2. A [welcome] section pointing at the channel and an optional custom prompt file.
  3. At least one AI provider key — DEEPSEEK_API_KEY or GEMINI_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:

  1. Rate-limits per user. Each joining user gets their own 5-second budget through the shared RateLimiters infrastructure (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.
  2. Picks a provider. If DEEPSEEK_API_KEY is 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.
  3. Builds the system prompt. Concatenates the bot’s full personality file with the welcome prompt under a ## Welcome Message Instructions heading.
  4. Sends one user message. It tells the model the new member’s display name and mention string and asks it to write a welcome.
  5. 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

FieldTypeRequiredDefaultDescription
channelstring (snowflake)yesChannel ID to post the welcome in.
prompt_filestringno"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, either features.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, verify DEEPSEEK_API_KEY or GEMINI_API_KEY is 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.txt or welcome_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].channel is for the channel you actually meant. No mention syntax; just the raw ID as a string.

Cross-references

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:

  1. A player joins your Minecraft server and runs an in-game /verify command. 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.
  2. The player runs !m verify <code> in any Discord channel where the bot can read messages.
  3. The bot POSTs the code plus the player’s Discord ID to the Minecraft server’s /api/verify endpoint.
  4. The Minecraft server validates the code, links the Discord ID to the player’s Mojang UUID, and responds with success + username.
  5. 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

CommandDescription
!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 /verify in 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_URL in .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_URL or MC_VERIFY_SECRET is missing from the running environment. Make sure your .env is being passed in (Docker users: env_file: .env in compose.yaml).
  • “Could not reach the MC server” — the bot can’t contact the URL. Check that MC_VERIFY_URL is reachable from the bot’s network (try curl from 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 Unauthorized in bot logsMC_VERIFY_SECRET doesn’t match the value the plugin expects. Make sure both sides have the exact same string (no trailing whitespace).
  • !m verify says “command not found”features.minecraft or minecraft.verify is false, 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

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

FieldTypeRequiredDefaultDescription
supporter_rolestring (snowflake)yesRole ID granted to users with the supporter tier.
premium_rolestring (snowflake)yesRole ID granted to users with the premium tier.
check_intervalintegerno300Seconds 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:

  1. Fetch. Call <MC_VERIFY_URL>/api/donators with the shared secret. Response shape:

    {
      "donators": [
        { "discord_id": "987654321098765432", "tier": "supporter" },
        { "discord_id": "234567890123456789", "tier": "premium" }
      ]
    }
    
  2. Build target sets. Walk the response and produce two sets: should_have_supporter and should_have_premium. Unknown tiers (anything that isn’t supporter or premium) are logged as warnings and ignored.

  3. Snapshot current state. Call Discord’s get_guild_members (paginated, 1000 per page) and build three sets: current_supporters, current_premium, and restricted_users (anyone with the chargeback-restricted role, if chargeback is also configured).

  4. Add missing roles. For each user who should have a tier but doesn’t, call add_member_role with the audit-log reason `Donator sync: {tier} tier`. Restricted users are skipped.

  5. 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_role and premium_role in 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 startupdonator_sync = true but either the config sub-section or MC_VERIFY_* is missing. Check the warning log lines around startup.
  • Sync runs but no roles change — the MC server’s /api/donators returned an empty or malformed response. Check the bot’s logs for MC server returned status … or Invalid donator response. Try curl -H "Authorization: Bearer $MC_VERIFY_SECRET" $MC_VERIFY_URL/api/donators from 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 ID warning — one of the snowflake fields isn’t a numeric string. Snowflakes are quoted strings in TOML; make sure you have supporter_role = "123…" and not supporter_role = 123… (TOML integers can’t represent Discord snowflakes accurately).
  • Sync feels slowcheck_interval is 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_interval tick. There’s no event hook for faster propagation.

Cross-references

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:

  1. Authenticates the request via Authorization: Bearer <MC_VERIFY_SECRET>. Mismatched or missing tokens get a 401 Unauthorized and no further processing.
  2. Strips and restricts. If the chargeback payload includes a linked discord_id, the bot calls edit_member on Discord with a fresh roles list of just [restricted_role] — wiping every other role the user had — and an audit-log reason Chargeback: roles stripped, user restricted.
  3. 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.
  4. 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

FieldTypeRequiredDescription
staff_channelstring (snowflake)yesChannel ID where alerts are posted.
restricted_rolestring (snowflake)yesRole ID applied to the offender (and used as the only remaining role on their account after roles are stripped).
staff_roleslist of string (snowflakes)noRoles 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:

  1. Discord side. If the embed shows a linked discord_id, the bot extracts it and calls ban_user with audit-log reason Chargeback ban by <staff>. Failures are logged but don’t block MC side.
  2. MC side. The bot POSTs to <MC_VERIFY_URL>/api/ban with the player’s UUID and a reason identifying the staff member.
  3. 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) and BAN_MEMBERS (for the Ban button).
  • Have SEND_MESSAGES and EMBED_LINKS in the configured staff_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, check staff_channel and bot permissions there.
  • 401 Unauthorized on the webhookMC_VERIFY_SECRET doesn’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_roles in [minecraft.chargeback_config] and restart the bot. If staff_roles is empty (the default), no one can press the buttons.
  • MC ban failed — the /api/ban endpoint 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

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

ToolWhat it does
list_guildsList every Discord server the bot is a member of, with names and IDs.

Server

ToolWhat it does
get_guild_infoName, owner, approximate member count, channel/role counts.
send_messagePost a message to a channel. Privileged.
delete_messagesBulk-delete the most recent N messages from a channel (1–100).
get_recent_messagesRead recent messages from a channel, newest first; supports pagination via before.
search_messagesSearch a channel by author, content substring, and time range (ISO date or snowflake). Filters compose.
add_reactionAdd a reaction (unicode or custom emoji) to a specific message.
remove_reactionRemove the bot’s own reaction from a specific message.

Channels

ToolWhat it does
list_channelsAll channels in the server with IDs, types, and positions.
create_channelCreate a text, voice, category, forum, or stage channel.
delete_channelDelete a channel.
edit_channelEdit name, topic, NSFW flag, slowmode, parent category.
move_channelMove a channel to a new position or category.
set_channel_permissionsApply permission overrides for a role or member.
create_voice_channelCreate a voice channel with optional bitrate / user_limit.
create_stage_channelCreate a stage channel (speaker/audience-separated voice).
edit_voice_channelEdit voice-specific channel fields (bitrate, user_limit, region).

Roles

ToolWhat it does
list_rolesAll roles with IDs, colours, positions, permissions.
create_roleCreate a new role.
delete_roleDelete a role.
edit_roleEdit name, colour, permissions, hoist, mentionable.

Members

ToolWhat it does
list_membersList server members (max 1000 per call, paginate with after).
get_memberDetailed info about one member.
assign_roleAdd a role to a member.
remove_roleRemove a role from a member.
ban_memberBan a user, optionally with a reason and message-history purge.
unban_memberUnban a user.
kick_memberKick a member.
timeout_memberTime out a member for a duration like 1h, 30m, 7d.
remove_timeoutLift an active timeout (inverse of timeout_member).
set_nicknameSet or clear a member’s server nickname (1–32 chars).
get_bansList active bans with id/name/reason; paginate with after.
move_voice_memberMove a member to a different voice channel.
disconnect_voice_memberDisconnect a member from voice.
modify_voice_stateServer-mute / server-deafen a member when in voice.

Direct Messages

ToolWhat it does
send_private_messageDM a user. Opens the DM channel automatically. Privileged.
read_private_messagesRead recent DMs between the bot and a user, newest first.
edit_private_messageEdit one of the bot’s previously-sent DMs.
delete_private_messageDelete one of the bot’s previously-sent DMs.

Webhooks

ToolWhat it does
list_webhooksList webhooks on a channel (id, name, token).
create_webhookCreate a webhook on a channel; returns id + token.
delete_webhookDelete a webhook by ID.
send_webhook_messageSend through a webhook with optional username/avatar overrides. Privileged.

Invites

ToolWhat it does
list_invitesList active server invites (code, channel, inviter, uses).
create_inviteCreate a new invite with optional max_age, max_uses, temporary, unique.
delete_inviteDelete an invite by code.
get_invite_detailsLook up an invite (no need for bot to be in the target guild).

Custom Emoji

ToolWhat it does
list_emojisList custom emoji in the server.
create_emojiCreate a custom emoji from an HTTPS image URL (bot fetches + base64).
edit_emojiRename a custom emoji.
delete_emojiDelete 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):

VariableDefaultNotes
MCP_PORT9090TCP port the server listens on.
MCP_BIND_ADDR127.0.0.1Interface 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:

VariableDefaultNotes
MCP_AUTH_TOKENempty (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_TOKEN is empty, every request is allowed through.
  • If it is set, every request must carry a matching Authorization: Bearer <token> header, otherwise it is rejected with 401 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_TOKEN is required unless bound to loopback. The bot enforces this at startup: if MCP_BIND_ADDR is anything other than a loopback address and the token is unset, the process refuses to boot. Generate the token with openssl rand -hex 32 or similar.
  • Default to 127.0.0.1. The default MCP_BIND_ADDR is 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_TIMEOUT in src/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 smaller limit values.
  • 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_member tool’s reason argument 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-2022 category that hasn’t had a message this year and delete them.” The AI calls list_channels, filters mentally, and invokes delete_channel for each one.
  • Role audits. “List every member with the @Donor role and cross-check against the active payment list.” Pair list_members with whatever data source you point the AI at.
  • Permissions sweep. “Make sure every channel under the #mods-only category has the @Moderator role allowed and @everyone denied.” set_channel_permissions per channel.
  • Bulk message cleanup. “Delete the last 50 messages in #spam.” One call to delete_messages.
  • Server bootstrapping. “Create a category ‘Beta’, three text channels under it, and a role with permissions limited to that category.” Several create_channel and create_role calls 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 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: String and bot_name: String — per-instance identity.
  • Optional feature configsauto_role_config, minecraft_config, join_role_config, welcome_config, etc. Each is Option<T> so that disabled features simply hold None.
  • Per-guild state maps: guild_players, track_handles, now_playing_msgs, idle_timers, connections_games, wordle_games. All are Arc<DashMap<key, value>>, giving lock-free guild lookup. Most values are wrapped in Arc<Mutex<T>> for serialised access inside one guild; track_handles is the exception — it stores TrackHandle directly 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 in main.rs evicts empty buckets every 5 minutes. See Concurrency Model.
  • mcp_started: AtomicBool and started_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

ModuleResponsibility
src/main.rsEntry point, Data struct, framework init, background task spawning
src/config.rsEnvironment variable loading
src/instance_config.rsconfig.toml parsing for per-instance feature flags
src/error.rsBotError 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.rsTime/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 .env at startup. Modern maintained fork of the classic dotenv crate.
  • 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::Mutex patterns and why locks are the last tool, not the first.
  • Error Handling for how BotError reaches 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:

  1. One Discord bot identity — its own DISCORD_TOKEN, CLIENT_ID, and GUILD_ID.
  2. One config directory — a path on disk containing config.toml, personality.txt, an .env file, and whatever optional feature files (welcome prompt, cookies, etc.) that instance uses.
  3. One Postgres schema — selected by the DB_SCHEMA environment variable. All of the instance’s persistent state lives inside it.
  4. One Linux process — in practice, one container running the discord-bot-rs binary. Each process has its own Tokio runtime, its own Data struct, 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.

  1. 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.
  2. Config. CONFIG_DIR points at a per-instance directory. The bot reads config.toml, personality.txt, the optional welcome prompt, and (for music) cookies.txt from that path. Two instances can ship completely different config.toml feature flags and the code will ignore them independently.
  3. Database. DB_SCHEMA selects a Postgres schema. See below for how sqlx threads this through the pool. Bot A can migrate its schema without affecting Bot B, and you can drop one schema without touching the other.
  4. Personality. Each instance has its own personality.txt loaded into Data::personality at startup. The AI system prompt interpolates this string, so the two bots have different voices even if they share every other config value.
  5. 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:

  1. Opens a one-off connection and runs CREATE SCHEMA IF NOT EXISTS "<schema>".
  2. Builds a PgPoolOptions with an after_connect hook that runs SET search_path TO "<schema>" on every new connection the pool hands out.
  3. Runs the migration SQL (currently a set of CREATE TABLE IF NOT EXISTS statements) 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-gateway reaches http://bot1:9090 by 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.yml and restarting docker compose. There’s no admin API to register a new bot at runtime.
  • MCP gateway routing is static. The gateway reads INSTANCES from 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 an AtomicBool so the server doesn’t bind its port twice.
  • Messagehandle_message runs auto-role bookkeeping, checks for active Wordle games in the channel, and dispatches to ai::deepseek::handle_mention if 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_update cleans up the player, cancels the idle timer, and leaves the channel.
  • InteractionCreate — a component button click. The dispatcher looks at the custom_id prefix (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 its on_error hook, which is wired in main.rs to log the full error via tracing::error! and post error.user_message() — a short, sanitised, per-variant string — in the channel. See Error Handling for the full picture.
  • DB query fails. sqlx::Error converts into BotError::Sqlx via a From impl in src/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 via tracing::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 Ready handler re-fires. The mcp_started guard 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.

  • Error Handling — what happens when any of the above fails, and how errors reach users (or get quietly logged).
  • Concurrency Model — why Data uses DashMap the way it does, and how background tasks coexist with event handlers.
  • AI Pipeline — the most elaborate event path: an @mention becomes 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:

  1. The message is from a non-bot author in a guild channel.
  2. The message either contains a direct mention of the bot user ID, or is a Discord reply to a message the bot itself sent.
  3. At least one of DEEPSEEK_API_KEY or GEMINI_API_KEY is set on Data::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 via BAD_ASSISTANT_PATTERNS and 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-pro is 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-preview handles 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_search today. The model asks for a search, the bot runs it, the result goes back to the model as a role: "tool" message, and the model gets another turn to decide whether to search again or answer. Up to MAX_SEARCH_ROUNDS rounds (currently 3), after which the pipeline forces a final answer with tools disabled. The same MAX_SEARCH_ROUNDS constant in src/ai/chat.rs is 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 ```lang in 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 as BotError::user_message() (“Something went wrong talking to the database. Please try again later.”, etc.). Operators still see the full upstream error via tracing::error! with the failing tool name and guild ID, but raw sqlx/reqwest/serde_json strings 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.permissions is often None for messages fetched via the API. This is a serenity quirk, not a design choice.
  • rmcp session auth. See MCP Gateway Routing for the current state of MCP authentication.

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, or None if the player is idle.
  • loop_mode: LoopModeOff, Track, or Queue. 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::Track returns a clone of the current track (play it again).
  • LoopMode::Queue pushes the current track back onto the queue’s tail, then pops the front.
  • LoopMode::Off drops 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 a cookies.txt file exported from a logged-in browser. The path is the cookies.txt in 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 default node path isn’t always on PATH in service environments, so the code probes /home/webapps/.nvm/versions/node/v20.20.1/bin/node first and falls back to node.
  • --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-playlist or --flat-playlist depending 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:

  1. Looks up the GuildPlayer for this guild.
  2. Checks the per-guild skip_in_progress flag and bails out if set (see Skip race).
  3. Calls advance() to figure out what plays next.
  4. If there’s a next track, starts it with play_next_from_context and replaces the prior “Now Playing” message via replace_now_playing_message (delete old + send new under one mutex hold).
  5. 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:

  • addenqueue(track) or enqueue_many(vec). The latter respects the 100-track cap and returns how many it actually added.
  • skipskip_current() returns the title for the user-facing confirmation; the caller then runs advance() to decide what’s next and plays it.
  • removeremove(position) removes by 1-based index, so users can !m remove 3 to 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.”
  • looploop_mode.cycle() rotates through Off → 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:

  1. 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.
  2. 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.
  3. Active player: there must be a GuildPlayer registered 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_tracks checks 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_channel returns 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_track wraps 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.
  • 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 a DashMap.
  • 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:

  1. 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.
  2. 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:

  1. Explicit instance wins. If the tool call’s arguments contain an instance field (injected by the gateway into every tool’s schema), the router looks up that name in instances: HashMap<String, String> and routes there. Unknown instance names return RouteError::InstanceNotFound.
  2. Otherwise, match by guild_id. If the arguments contain a guild_id, the router consults its guild_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, returns RouteError::GuildNotFound.
  3. 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:

  1. Client → Gateway: single JSON-RPC POST to /mcp. Bodies are capped at 64 KiB by a RequestBodyLimitLayer in mcp-gateway/src/main.rs — JSON-RPC envelopes are tiny, and the cap stops authenticated callers from saturating the gateway with multi-MiB bodies.
  2. Gateway parses the body. A malformed JSON envelope returns the spec-compliant JSON-RPC -32700 Parse error response instead of axum’s opaque 422, which keeps clients on the protocol’s own error model.
  3. Gateway inspects method. For tools/list, the cached tool list is returned immediately. For tools/call, the gateway extracts instance, guild_id, and the tool arguments from params, picks a target via the router, and forwards the call to the chosen backend’s BackendClient::call_tool.
  4. BackendClient::call_tool posts 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 a pending: 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.)
  5. Gateway wraps the result in an event: message\ndata: {...}\n\n SSE frame and sends it back to the client with Mcp-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:

  1. 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.
  2. refresh_tool_list — re-fetches the tool catalog from a backend and rebuilds the cached tools/list response. 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 INSTANCES is 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.
  • 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).

ColumnTypeNotes
idSERIAL PRIMARY KEYAuto-incrementing row ID
guild_idTEXT NOT NULLDiscord guild ID as a string
user_idTEXT NOT NULLBanned user’s Discord ID
moderator_idTEXT NOT NULLWho issued the ban
reasonTEXTOptional, free-form
banned_atTIMESTAMPTZ NOT NULL DEFAULT NOW()When the ban was issued
expires_atTIMESTAMPTZ NOT NULLWhen the ban should be lifted
unbannedBOOLEAN NOT NULL DEFAULT FALSESet 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).

ColumnTypeNotes
guild_idTEXT PRIMARY KEYDiscord guild ID
audit_log_channel_idTEXTWhere moderation actions get logged
dj_role_idTEXTRole required when DJ mode is on
dj_mode_enabledBOOLEAN NOT NULL DEFAULT FALSERestrict 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.

ColumnTypeNotes
guild_idTEXT NOT NULLPart of composite PK
user_idTEXT NOT NULLPart of composite PK
cash_balanceNUMERIC(18, 4) NOT NULL DEFAULT 1000.0000Everyone starts with $1000 virtual. Migrated from DOUBLE PRECISION in 20260414000001_stocks_decimal.sql so cents don’t drift over fractional-share trades
created_atTIMESTAMPTZ 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.

ColumnTypeNotes
idSERIAL PRIMARY KEYAuto-incrementing row ID
guild_idTEXT NOT NULL
user_idTEXT NOT NULL
symbolTEXT NOT NULLTicker symbol, e.g. AAPL
quantityNUMERIC(18, 4) NOT NULL DEFAULT 0.0000Shares held (fractional allowed). NUMERIC for exact arithmetic — see migration 20260414000001_stocks_decimal.sql
avg_costNUMERIC(18, 4) NOT NULL DEFAULT 0.0000Weighted 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.

ColumnTypeNotes
idSERIAL PRIMARY KEYRow ID
guild_idTEXT NOT NULL
user_idTEXT NOT NULL
symbolTEXT NOT NULL
actionTEXT NOT NULL'BUY' or 'SELL'
quantityNUMERIC(18, 4) NOT NULLNUMERIC since 20260414000001_stocks_decimal.sql
price_per_shareNUMERIC(18, 4) NOT NULLNUMERIC since 20260414000001_stocks_decimal.sql
total_amountNUMERIC(18, 4) NOT NULLquantity * price_per_share, computed in Rust with rust_decimal::Decimal so the audit log matches the books exactly
created_atTIMESTAMPTZ 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.

ColumnTypeNotes
symbolTEXT PRIMARY KEY
priceDOUBLE PRECISION NOT NULLLast known price
prev_closeDOUBLE PRECISION NOT NULLPrevious day’s close
change_pctDOUBLE PRECISION NOT NULLDay-over-day change percentage
fetched_atTIMESTAMPTZ 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.

ColumnTypeNotes
guild_idTEXT NOT NULLPart of composite PK
user_idTEXT NOT NULLPart of composite PK
message_countINTEGER NOT NULL DEFAULT 0Running total of messages sent
first_seenTIMESTAMPTZ NOT NULL DEFAULT NOW()When this user first sent a message we counted
promotedBOOLEAN NOT NULL DEFAULT FALSEHas 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.

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:

  • Display still 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 for Other(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 full Display form of BotError, including every byte of upstream context. These go to stderr and whatever log aggregator the operator has set up. tracing_subscriber::fmt::init() in main.rs is the default config — override with RUST_LOG to raise or lower the level.
  • User messages (ctx.say(...), message.reply(...)): the output of BotError::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.

  • 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:

FieldShapeFeature
guild_playersDashMap<GuildId, Arc<Mutex<GuildPlayer>>>Music
track_handlesDashMap<GuildId, TrackHandle>Music
now_playing_msgsDashMap<GuildId, Arc<Mutex<Option<MessageId>>>>Music
idle_timersDashMap<GuildId, Arc<Mutex<Option<JoinHandle<()>>>>>Music
connections_gamesDashMap<ChannelId, Arc<Mutex<ConnectionsGame>>>Games
wordle_gamesDashMap<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:

  1. Release the DashMap guard before the await. entry(...).value() returns a guard that holds the DashMap shard’s lock. Holding it while you .await on the inner mutex would hold up other handlers that need the same shard. The idiom is to clone the inner Arc out and let the guard drop.
  2. Use tokio::sync::Mutex, not std::sync::Mutex. Tokio mutexes are designed to be held across .await points; std mutexes are not. A handler that’s holding a std::sync::Mutex across an .await can 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:

  1. Spawns a task that sleeps 300 seconds, then leaves the voice channel and cleans up.
  2. Stores the task’s JoinHandle inside Data::idle_timers at 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 !m music 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 nuke commands. (Discord-side permission checks still apply on top.)
  • stocks — 10 requests per 30 seconds, enforced on every !m stock command 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:

  1. Per-iteration panic recovery. Every loop body runs inside the run_supervised(task_name, || async { ... }) helper defined at the top of main.rs. The helper wraps the body in AssertUnwindSafe(...).catch_unwind() so a panic inside one iteration is caught, logged via tracing::error! with the task name and panic payload, and then swallowed. The outer loop { ... sleep ... } continues to the next iteration. A bug in one tempban sweep doesn’t break tomorrow’s sweeps.
  2. Task-level tracking via JoinSet. Background tasks are spawned into a JoinSet<()> owned by main(). A separate task awaits join_next in a loop and logs at error level 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::Mutex in async code. As explained above, holding one across an .await can deadlock the runtime. The only place in the codebase that uses std::sync primitives is AtomicBool, which is lock-free.
  • Don’t hold a DashMap entry guard across an .await. Clone the Arc out 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 in tokio::task::spawn_blocking. The yt-dlp integration goes through tokio::process::Command, which is the right pattern.
  • Don’t share !Send state across tasks. Every tokio task must be Send, so anything held across .await inside a task must be too. Tokio’s mutex guards satisfy this; Rc and RefCell do not.
  • 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 Dockerfile that builds the bot binary on rust:bookworm and ships a debian:bookworm-slim runtime image with ffmpeg, yt-dlp, Node.js (for yt-dlp’s JS challenges), and the Opus / libsodium shared libraries the voice stack needs.
  • A separate mcp-gateway/Dockerfile for the gateway service.
  • A top-level docker-compose.yml that wires the bot, a postgres:17 container, and the gateway together with health checks and named volumes.
  • An instances/example/ directory used as a fully-documented reference for config.toml, .env.example, and personality.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.

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 Dockerdocker run the 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 metalcargo build --release, install ffmpeg, 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

PageWhen to read
Docker ComposeSetting up your first deployment, or whenever you change the stack.
PostgreSQL SetupChoosing bundled vs external Postgres, planning backups, migrations.
Multi-Instance DeploymentAdding a second bot to an existing host.
MCP ExposureConnecting an MCP client from outside the host.
UpgradingPulling a new version, planning around breaking changes.
MonitoringHealth checks, log aggregation, what failure looks like.
Production ChecklistOne-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

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 exec and 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:

  1. Create a database on the external server. Any name works — the bot does not care.
  2. Create a user with CREATE and USAGE privileges on that database. The bot needs to be able to create schemas, create tables inside them, and read and write rows.
  3. Set DATABASE_URL in your instance’s .env to the new connection string:
    DATABASE_URL=postgresql://username:password@db.example.com:5432/discord_bot_db
    
  4. Remove the postgres service from docker-compose.yml, or just leave it and ignore it.
  5. Remove the depends_on: postgres block from the bot service — it has no local Postgres to wait for.
  6. 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:

  1. Opens a one-off connection and runs CREATE SCHEMA IF NOT EXISTS "<schema>".
  2. Builds a connection pool with an after_connect hook that runs SET search_path TO "<schema>" on every new connection.
  3. 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_migrations table 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 backfill UPDATE it 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 EXISTS guards: it is a no-op against a database that already has the bootstrapped tables, and sqlx writes the _sqlx_migrations row 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. The tempbans and stock_* 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.confscram-sha-256 rather than the default trust on the local socket.

Cross-references

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

  1. Create a new instance directory under instances/.
  2. Fill in its .env and config.toml.
  3. Add a second bot service to docker-compose.yml.
  4. Add the new instance to the gateway’s INSTANCES env var.
  5. 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 9090 inside their own container. There is no port conflict because each container has its own network namespace — bot1:9090 and bot2:9090 are 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

WhatPer-instanceShared
Discord token / identityyes
Personality textyes
Feature flagsyes
Postgres dataone schema eachone server
MCP catalogone MCP server eachone gateway in front of all
Music / game / rate-limit stateyes (in-memory)
Host network / disk / CPUshared 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:

  1. Copy the directory: cp -r instances/bot2 instances/bot3
  2. Update .env (token, client ID, guild, schema)
  3. Update config.toml
  4. Add a third service block to docker-compose.yml, named bot3
  5. Append ,bot3=http://bot3:9090 to INSTANCES
  6. Add bot3: condition: service_healthy to the gateway’s depends_on
  7. 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

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_TOKEN is empty and MCP_BIND_ADDR is not a loopback address, startup aborts with an error pointing at src/mcp/mod.rs. This is to prevent the easy mistake of switching to 0.0.0.0 (for a Compose deploy, or any cross-container reach) and forgetting to set a token.
  • Gateway: if MCP_GATEWAY_AUTH_TOKEN is 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:

  1. 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
  2. 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.

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:

  1. Stand up WireGuard or Tailscale across the bot host and the client machines.

  2. Bind the gateway to the VPN interface instead of 127.0.0.1. Edit the mcp-gateway service’s ports: block in docker-compose.yml:

    ports:
      - "10.0.0.1:9100:9100"   # WireGuard interface IP, for example
    
  3. 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.

  4. 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.

  1. Bind the gateway to 127.0.0.1:9100 on the host (the default). Do not publish it on a public interface directly.
  2. Run a reverse proxy (Caddy, nginx, Traefik) in front of it, terminating TLS with a real certificate.
  3. Set MCP_GATEWAY_AUTH_TOKEN to a long random string.
  4. Configure the reverse proxy to pass the Authorization header 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 on will hold the whole response until it is finished, which breaks streaming. Turn it off (as in the example above), and raise proxy_read_timeout so 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 $host in 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_TIMEOUT in src/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:

  1. Update MCP_GATEWAY_AUTH_TOKEN in the host shell (or in the Compose file).

  2. Update MCP_AUTH_TOKEN in every bot’s .env to the same value.

  3. 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:9100 without 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

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 in config.toml between versions would surface here).
  • <botname> is connected! — Discord connection is up.
  • <name> serves N guild(s) per bot in the gateway logs (after MCP Gateway starting with N instances and the <name> -> <url> registration lines).
  • Any health check transitioning to healthy in docker compose ps.

Things that mean roll back:

  • Any panic from 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 .env is 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:17 image to postgres:18, the pgdata volume needs to be migrated using pg_upgrade or 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

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.toml parsed 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 an Error: ... 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’s restart: unless-stopped will 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 .env or <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_prefix in config.toml matches 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 songbird or opus — typically a rare dependency mismatch in a custom build.

MCP calls fail

  • docker compose logs mcp-gateway first. 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.
  • InstanceNotFound or GuildNotFound means 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

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_TOKEN is unique to this bot user and is not committed anywhere. If it ever ended up in git, in a chat message, or in a screenshot, regenerate it in the Discord developer portal. Tokens are full credentials. → Secrets Management

  • .env files are not in git. The repo’s .gitignore already excludes instances/*/.env. Confirm with git status after 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 each instances/*/.env and 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_TOKEN is set on every bot whose MCP_BIND_ADDR is 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.example ships with MCP_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_TOKEN is set on the gateway service and matches every bot’s MCP_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’s MCP_AUTH_TOKEN surfaces as a 401 from the backend at startup. Generate one value with openssl rand -hex 32 and use it in both places. → MCP Exposure

  • The Postgres password is not the default discord_bot_pass if 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_SECRET matches 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_ID you have configured. A GUILD_ID for a guild the bot is not in causes silent feature failure.

Network

  • The MCP gateway is bound to 127.0.0.1:9100 on the host, not 0.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 prefer 127.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 ufw or equivalent in deny-by-default mode prevents accidents.

Database

  • DB_SCHEMA is set to a unique value per instance. Two instances on the same DB_SCHEMA will trample each other. Match it to the instance directory name. → PostgreSQL Setup

  • The pgdata volume is on persistent storage. Default Docker named volumes live under /var/lib/docker/volumes on 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_dump cron 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. \dn in psql lists them. Stale schemas from removed instances waste space; drop them with DROP SCHEMA "<name>" CASCADE; once you are sure.

  • You have read the migrations directory before upgrading. The bot now uses sqlx::migrate! against migrations/, applied automatically on startup against each instance’s schema (tracked in a per-schema _sqlx_migrations table). 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 .env or config.toml between bots.

  • config.toml reflects 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.txt reads how you want the bot to sound. The example default is functional but generic. Edit it for production bots.

  • The command_prefix does not collide with another bot in the same server. If two bots share !, both will respond to every !cmd.

Operations

  • restart: unless-stopped is 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 docker on systemd hosts. Otherwise restart: unless-stopped does nothing on a host reboot.

  • You have a documented upgrade process. Knowing whether you do docker compose pull (image-based) or git 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/docker and Postgres’s pgdata volume 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-size and max-file on the logging driver, or use journald (which rotates by default).

  • Health checks have somewhere to alert from. A cron job that runs docker compose ps --format json and pages on anything not healthy is 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_TOKEN is at least 32 random bytes. openssl rand -hex 32 is 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 Authorization header 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 INSTANCES lists every backend. Missing a backend means the gateway cannot route to it. → Multi-Instance Deployment

  • The gateway’s depends_on lists 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, no Failed to ..., no unexpected WARN. → Monitoring: Log lines worth knowing

  • docker compose ps shows everything healthy.

  • 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

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 m command. This guide walks through writing a new one and registering it correctly. The #1 gotcha is forgetting the entry in src/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 into InstanceConfig, hooking event handlers, and integrating with the Data struct.
  • 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:

  1. Declares every top-level module — the mod declarations at the top are the ground truth for which directories under src/ actually compile.
  2. Defines the Data struct — this is the shared application state that poise hands to every command and event handler. It holds the sqlx::PgPool, a reqwest::Client, the loaded Config, the personality and bot_name strings, every Option<T> feature config (auto_role_config, minecraft_config, join_role_config, welcome_config), per-guild state DashMaps (guild_players, track_handles, now_playing_msgs, idle_timers, connections_games, wordle_games), a RateLimiters bundle, and one-shot startup flags (mcp_started, started_at).
  3. Builds the poise framework in main() — loads the instance config, constructs the parent m command (pushing the optional verify subcommand if Minecraft verify is enabled), registers the event handler, and wires the prefix from instance_cfg.command_prefix.
  4. 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::spawn that owns its own clones of http and 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.tomlbot_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.rssetlog, djrole, djmode. Server-admin settings that live in the guild_settings table.
  • moderation.rsban, unban, banlist, nuke. Tempbans go through db::queries::create_tempban, which returns the expiry timestamp; the main.rs background task later calls http.remove_ban when expired bans are found.
  • music.rsplay, playlist, skip, stop, pause, resume, queue, nowplaying, remove, loop_cmd, shuffle. All of them call into music::voice and music::player::GuildPlayer through Data.
  • connections.rs, wordle.rs — thin wrappers over the corresponding game module. Each creates a Game struct, sends the initial embed + buttons, and inserts the game into the channel map on Data.
  • stocks.rsstock parent with buy, sell, portfolio, price, leaderboard, history, reset subcommands. The module refuses to run without FINNHUB_API_KEY via require_finnhub_key.
  • minecraft.rs — the verify subcommand, which is only pushed into the parent m at startup when features.minecraft and minecraft.verify are both enabled in config.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:

  • Readyready::handle_ready plus a one-shot MCP server start guarded by data.mcp_started.swap(true) so gateway reconnects don’t re-launch the HTTP server.
  • VoiceStateUpdatevoice_state::handle_voice_state_update, which triggers the “auto-leave when the channel is empty” behaviour.
  • Messagehandle_message, the largest branch. It does three things in order: bumps the member_activity row 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 to ai::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.
  • GuildMemberAdditionmember_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 owns handle_mention, the message-history builder, provider dispatch through the AiProvider trait + 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 by chat.rs to 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-style tool_calls array.
  • 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 via curl rather than reqwest because DDG returns a non-result page when it sees reqwest’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 — the GuildPlayer struct: a VecDeque<Track>, a current: Option<Track>, a paused: bool, a LoopMode, and a MAX_QUEUE_LENGTH of 100. All the queue operations (enqueue, advance, skip_current, shuffle, remove) live here. It has zero I/O; just data.
  • track.rs — wraps yt-dlp as a subprocess. resolve_track and resolve_tracks spawn yt-dlp with the project’s cookie jar and node-runtime path, parse the JSON output, and return Track values. It also exposes ytdlp_user_args which voice.rs passes into songbird’s YoutubeDl input source.
  • voice.rs — the songbird wrapper. join_channel, play_track, stop_playback, leave_channel, and the TrackEndHandler that advances the queue when a track finishes. PlaybackContext is 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 are music_pauseresume, music_skip, music_stop, music_shuffle, music_loop, music_queue; this is what events/mod.rs dispatches 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-second stock_price_cache table 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 the verify() HTTP call that posts a code + Discord ID to the MC server’s verify endpoint.
  • donator_sync.rsfetch_donators pulls the current donator list from the MC server; sync_roles reconciles it against Discord role state, adding and removing supporter/premium roles. This is driven by the check_interval loop spawned in main.rs.
  • chargeback.rs — an axum::Router that 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 (the cb_* custom ID prefix dispatched in events/mod.rs). The router is optional and is only mounted under the MCP axum app when minecraft.chargeback = true.

mcp/ — the embedded MCP server

src/mcp/ is the in-process Model Context Protocol server. Two files:

  • mod.rs — sets up rmcp’s StreamableHttpService, wraps it in an axum app with a bearer-token middleware, optionally mounts the chargeback webhook router under the same app, and binds on MCP_BIND_ADDR:MCP_PORT.
  • tools.rs — the DiscordTools struct with a ToolRouter and 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-second timeout and 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.rsinit_pool. Connects once, runs CREATE SCHEMA IF NOT EXISTS "{schema}", then creates a PgPoolOptions with an after_connect that sets search_path on every new connection. This is how multi-instance schema isolation works: every instance uses the same DATABASE_URL but its own DB_SCHEMA. After the pool is built, mod.rs runs every CREATE TABLE IF NOT EXISTS statement for the bot’s schema.
  • models.rs — one FromRow struct 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 raw sqlx::query* helpers with bind rather than the compile-time-checked query! / 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.rsparse_duration ("3d", "2h", "30m", capped at 365 days) and format_duration_ms / format_track_duration. Used by tempbans, auto-role min_age, and the music “now playing” line.
  • util/ratelimit.rs — the SlidingWindowLimiter (a DashMap<String, Vec<Instant>>) and RateLimiters, which bundles four limiters: ai (10/60s), music (15/30s), moderation (5/60s), and stocks (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 — builds GatewayConfig, constructs one BackendClient per configured instance, wires them into a GatewayState, mounts the axum router, binds, and spawns a 5-minute background task that refreshes the guild map.
  • config.rs — reads GATEWAY_PORT (default 9100), the optional MCP_AUTH_TOKEN bearer secret, and the required INSTANCES env var of the form name1=url1,name2=url2 into a list of Instance { name, url }.
  • backend.rs — the per-instance MCP client. Opens a Streamable HTTP SSE session to a bot’s MCP endpoint and exposes initialize, list_tools, call_tool, list_guilds, and health_check for the gateway server to call.
  • routing.rs — the guild-to-instance router. Keeps a HashMap<guild_id, instance_name> so that the gateway can look at a tool call’s guild_id and forward it to the right backend.
  • server.rs — the axum handlers. POST /mcp accepts 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 cached tools/list aggregation so the client sees the union of every backend’s tools, plus a gateway-only list_instances tool.

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 for poise::Context<'a, Data, BotError> at the bottom of src/main.rs. You access shared state through ctx.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::spawn in main(), cloning the Arc-friendly parts of Data it needs (db.clone(), http.clone()). Tasks log via tracing::info! on startup and tracing::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 in config.toml, and once by the presence of the feature’s Option<Config> on Data. 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 the Arc, acquire the Mutex, do the work, drop the guard. Never hold a DashMap entry across an .await.
  • No unwrap() in hot paths. Startup code in main.rs is allowed to .expect() on missing env vars and pool creation; everything downstream returns BotError.

Where to look for…

Use this table as a lookup when you can’t remember where something lives.

Looking forStart here
Adding a new commandcommands/mod.rs and Adding a Command
Adding a new event handler branchevents/mod.rs
Adding a new AI toolai/tools.rs and the dispatch in ai/chat.rs
Adding a new MCP toolmcp/tools.rs and Adding an MCP Tool
Adding a whole new feature moduleAdding a Feature Module
Adding a DB tabledb/mod.rs (CREATE TABLE), db/models.rs (struct), db/queries.rs (functions)
Changing rate limitsutil/ratelimit.rs (RateLimiters::new)
Changing the default personalityThe instance’s personality.txt, not the code
Adding a required env varconfig.rs, then propagate through Data
Adding a per-instance TOML optioninstance_config.rs, then main.rs wiring
Changing the music queue sizeMAX_QUEUE_LENGTH in music/player.rs
Fixing Wordle validationis_valid_word and words.txt in wordle/game.rs
Discord permission checksctx.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.toml enforces this. Run cargo fmt before opening a PR.
  • ? over unwrap in fallible code. expect is only acceptable at startup in main.rs.
  • Errors convert through BotError via From impls; commands and event handlers return Result<(), BotError>.
  • tracing over println!. All logs go through tracing::info!, warn!, error! so they’re structured and filterable via RUST_LOG.
  • No async closures inside DashMap callbacks. Look up, clone the Arc, drop the entry guard, then .await on the clone.
  • Feature-gated startup is verbose by designmain.rs logs each optional feature’s activation at info! and warns loudly if the TOML config is missing. Copy that pattern for new features.

Next steps

  • Building Locally — run the crate with cargo outside 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 check is much faster than cargo build and is enough to catch type errors. Use it while writing code, then cargo run when you’re ready to test in Discord.
  • sccache (cargo install sccache && export RUSTC_WRAPPER=sccache) caches incremental builds across cargo cleans and across branches. It’s especially useful if you switch between branches with different dependency sets.
  • mold is a faster linker than the default GNU ld. Install it, then add a .cargo/config.toml snippet pointing the linker at mold. 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 --release unless 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 — install build-essential (or the equivalent toolchain on your platform). On macOS, install Xcode Command Line Tools (xcode-select --install).
  • failed to find tool. Is cmake installed? — exactly what it says: install cmake.
  • could not find native library 'opus' — install libopus-dev on Debian, or opus via Homebrew on macOS.
  • Failed to connect to database at startup — check that PostgreSQL is running, the credentials match DATABASE_URL, and that the user can connect from your host. psql "$DATABASE_URL" is the quickest way to confirm.
  • <KEY> must be set in .env at startup — the loader couldn’t find a required variable. Either you didn’t set it, or cargo run isn’t picking up .env. The bot reads .env from the current working directory; running from the repo root with CONFIG_DIR pointed at your instance is the standard pattern.
  • <KEY> has placeholder value — you copied .env.example but didn’t fill in real values. Edit .env and replace any your-... placeholders.

Next steps

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:

  1. Your #[poise::command(...)] attribute should use prefix_command, not slash_command.
  2. “Register the command” means adding it to the subcommands(...) list on the parent m command in src/commands/mod.rs. It does not mean touching src/main.rs. main.rs only ever registers the single parent m; 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 from crate::Context, defined at the bottom of src/main.rs as poise::Context<'_, Data, BotError>. Use the alias.
  • prefix_command tells poise this is a message-content command, not a slash command.
  • rename = "echo" makes the invoked name echo instead of the function name. In this case they match, so rename is 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_cmd renamed to loop).
  • aliases("say") lets users type !m say as 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 a String parameter tells poise to take the rest of the message as one argument instead of splitting on whitespace. Without #[rest], !m echo hello world would fail with “too many arguments.” With #[rest], it works.
  • Return Result<(), BotError> — always. All errors convert through BotError’s From impls, 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("...") — like say, 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 with hello world.
  • !m say hello (the alias) → same thing.
  • !m echo (no text) → the bot replies with “Give me something to echo.”
  • !m echo with 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 subcommands entry. Your command compiles, the bot boots clean, and !m echo does nothing. Go back to src/commands/mod.rs and add "echo::echo" to the list.
  • Missed #[rest]. Multi-word arguments fail with “too many arguments.” Add #[rest] to the last String parameter.
  • Used slash_command. Slash commands aren’t enabled in this crate’s framework builder, so your command silently never registers. Use prefix_command.
  • Returned anyhow::Error or a custom type. Poise needs the error type to match Data’s error parameter — return Result<(), BotError>, and let ? convert from whatever you’re calling.
  • Held a DashMap entry across an .await. This will deadlock or panic. Clone the inner Arc out, 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:

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.rs declares 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.rs wraps any external HTTP calls.
  • embeds.rs builds 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>>> (see wordle_games, connections_games, guild_players).
  • Loaded config — if your feature is opt-in via config.toml, add an Option<YourConfig> field next to auto_role_config, minecraft_config, etc.
  • One-shot startup flagsAtomicBools for things that should run once. The MCP server uses mcp_started for 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, like docs/features/auto-role.md.
  • An entry in docs/SUMMARY.md under the Features section.
  • An entry in docs/configuration/instance-config.md if you added a config.toml section.
  • A row in docs/reference/command-list.md if you added a command.
  • A CHANGELOG.md entry under [Unreleased] in Added.

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 hello should respond with Reminder #1 set. and fire a minute later.
  • !m remind banana hello should respond with Invalid duration.
  • !m remind 1y hello should respond with Invalid duration. (the unit must be one of s, m, h, d, wy isn’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 mod line in main.rs. Your code doesn’t compile, with a confusing error. Add mod reminders; at the top.
  • Forgot to register the command. The command compiles, the bot boots, and !m remind does nothing. Add "reminders::remind" to the subcommands(...) list in commands/mod.rs.
  • Held a DashMap entry across an .await. Will deadlock under load. Look up the entry, clone the Arc, drop the guard, then await on the clone.
  • Panicked from a background task. Tasks must never panic — log the error with tracing::warn! or tracing::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 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:

  1. A parameter struct that derives Deserialize and JsonSchema so rmcp can produce a JSON Schema for clients.
  2. An async method on impl DiscordTools annotated with #[tool(description = "...")].
  3. 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 the ToolRouter. 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 use block imports rmcp types and the Parameters wrapper.
  • 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, the discord_call! macro that wraps every Serenity HTTP call with a 10-second timeout.
  • The ServerHandler impl that wires list_tools and call_tool through the router.
  • The big #[tool_router(router = tool_router)] impl DiscordTools block — 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:

  • Deserialize and JsonSchema are both required. The first parses incoming JSON, the second produces the schema MCP clients see in tools/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_id is always optional and always at the top when the tool can be run against a guild. The resolve_guild() helper on DiscordTools falls back to the configured guild when it’s None, which keeps the per-instance MCP setup ergonomic.
  • IDs are String, not u64. 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 to u64 inside the handler with the parse_id helper.
  • Use Option<T> for any field that has a default, then apply the default inside the handler with .unwrap_or(...). Don’t fight serde with #[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 way send_message does (“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. Pull p = params.0 at the top.
  • self.resolve_guild(...) is the helper for the optional guild-id pattern. Prefer it over reading params.guild_id directly — 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 into McpError via api_err. Don’t call self.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 use McpError::internal_error(...). The helpers api_err and timeout_err cover 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 in tools/list disagrees 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-second API_TIMEOUT constant. 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_id for the API call still take it as an optional param for clarity. See send_message, delete_messages, edit_channel. They pull let _gid = self.resolve_guild(p.guild_id.as_deref())?; to validate the param shape and discard the result.
  • Bulk vs single deletedelete_messages calls delete_messages on the channel for >1 ID and delete_message for exactly 1, because the bulk endpoint rejects single-message deletes. Discord API quirk; copy the pattern.
  • Permission overridesset_channel_permissions accepts permission bits as decimal strings (because JSON numbers can lose precision), parses them with .parse::<u64>().unwrap_or(0), and builds a PermissionOverwrite struct. Mirror this if you need to pass any 64-bit field over the wire.
  • Paginationlist_members uses limit plus after (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_message contains “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.md describing the tool, its parameters, and an example call.
  • Update CHANGELOG.md under [Unreleased] in Added.

Common gotchas

  • Forgot #[tool(description = "...")]. The function compiles as a regular method, the macro ignores it, and the tool never appears in tools/list.
  • Forgot JsonSchema on the params struct. You get a confusing trait-bound error from the macro expansion. Add the derive.
  • Used u64 for an ID. The wire format will accept it sometimes and lose precision other times. Always String in the params struct, parsed with parse_id inside 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_members caps at 1000 per call.
  • Two impl DiscordTools blocks. Only one of them is the #[tool_router] block. Tools in any other impl are invisible.

Next steps

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 tests blocks at the bottom of each file. Split across src/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 the parse_duration_secs helper on the MCP tool surface.
  • mcp-gateway/ unit tests — 10. In mcp-gateway/src/routing.rs, covering the Router::resolve decision 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 concurrent sell_stock + reset_portfolio race. Confirms the FOR UPDATE row-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 in util::ratelimit, DSML parsing, AI message splitting across the 2000-char boundary, prompt-injection scrub in ai::sanitize, error::user_message fallout.
  • 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_criteria decision 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::resolve decision tree.

What isn’t tested

Being honest about the gaps:

  • Discord-context-dependent handlers. Anything that needs a Context or CommandInteraction from 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 songbird voice 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-gateway backend.rs / server.rs. The router is tested; the request-parse and tools/list aggregation 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") returns Some(0) — a zero-length duration. Consumers treat it as “no timeout,” which may not be what the user typing 0s meant.
  • parse_duration_secs (MCP tool helper) silently accepts negative values and can overflow on large inputs; the test pins the current saturating behaviour.
  • sanitize_content strips 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_ms doesn’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::AlreadyGuessed is 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_guess with 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:

  1. Start a local instance with CONFIG_DIR=instances/local cargo run.
  2. Exercise the change in your test Discord server.
  3. 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 test invocation 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.rs logs the inbound message, tool calls and their results, and the final reply.
  • music/voice.rs logs joins, leaves, track starts, and track-end events.
  • db/mod.rs logs schema creation and migration progress.
  • mcp/mod.rs logs 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:

  1. Bad token. Look for Invalid Token or WebSocket close in the logs near startup. Generate a new token in the Discord developer portal, paste it into .env, restart.
  2. 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.
  3. The process started but hung on database init. Watch for Database initialized (schema: ...). If it never appears, Postgres is unreachable; check DATABASE_URL and 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 the subcommands(...) list in src/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’s on_error in main.rs will reply Error: <message> and log Command error: <error>. If you see neither in the channel nor in the logs, you have a different bug — likely an early return 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_KEY and GEMINI_API_KEY are 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=debug and look for the offending track.

“Music doesn’t play.”

The music pipeline involves yt-dlp, ffmpeg, and songbird. Each can fail independently:

  • yt-dlp not on PATH or out of date. YouTube breaks yt-dlp every few weeks; pip install -U yt-dlp is the fix more often than not.
  • ffmpeg not on PATH. The Docker image has it; bare-metal setups need apt 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 database at startup. Check Postgres is up and DATABASE_URL is correct. psql "$DATABASE_URL" is the fastest test.
  • Hot disconnect. pool acquire timed out mid-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 ai rate limiter and the duration parser have unbounded internal Vecs 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:

  1. Is the process alive? ps aux | grep discord-bot or docker compose ps bot. If exited, the logs will say why.
  2. Is the gateway connected? Logs include heartbeats at debug level. A long gap means the gateway link is dropped; serenity normally reconnects automatically.
  3. Is the runtime stuck on a .await? Most often a misuse of DashMap: holding an entry across .await. The fix is “look up, clone the inner Arc, drop the guard, await.”
  4. Send SIGQUIT to dump a stack trace. On Linux, kill -QUIT <pid> produces a thread dump from tokio-console if 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 flamegraph for CPU profiles. Install with cargo install flamegraph, run with cargo flamegraph --bin discord-bot. Produces an SVG you can open in a browser.
  • tokio-console for runtime introspection. Add console-subscriber to dependencies, swap tracing_subscriber::fmt::init() for console_subscriber::init(), and run tokio-console in another terminal. Lets you see live task counts, busy/idle times, and detect deadlocks.
  • heaptrack (Linux) for memory growth. Run with heaptrack ./target/release/discord-bot, kill the process when done, open the resulting file in heaptrack_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.

  1. Fork MrMcEpic/discord-bot-rs.
  2. Clone your fork locally.
  3. Add the upstream remote:
    git remote add upstream https://github.com/MrMcEpic/discord-bot-rs.git
    
  4. 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 of Added, Changed, Fixed, or Removed.

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 command
  • fix: — a bug fix
  • docs: — documentation only
  • chore: — build, CI, deps, or repo housekeeping
  • refactor: — code change with no behaviour change
  • test: — 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 test with a DATABASE_URL pointing at a Postgres (full — runs the integration tests under tests/). Easy throwaway: docker run -d --rm -p 5433:5432 -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=test postgres:17, then DATABASE_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.toml or from inside mcp-gateway/ (the gateway has no DB-backed tests, so cargo test is 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 issuesCloses #123 for issues this PR fully fixes, Refs #456 for 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-maincargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo check --all-targets, cargo test on the main crate. The job stands up a postgres:17 service container with a health check and exports DATABASE_URL so the integration tests under tests/ 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 --check differs — run cargo fmt and 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-main installs cmake, libopus-dev, and libsodium-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

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.
  • !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.
  • !m queue — Show the current queue as an embed.
    • Aliases: q.
  • !m nowplaying — Show what is currently playing, with playback controls.
    • Aliases: np.
  • !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 through offtrackqueue.
    • mode (string, optional) — One of off/none, track/t, or queue/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 like 30s, 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.
  • !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.

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.
  • !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 or all.
    • symbol (string, required) — Ticker symbol.
    • amount (string, required, rest) — Quantity or all.
    • 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.
  • !m stock history — Show the user’s 10 most recent trades.
    • Aliases: hist, h.
  • !m stock reset [confirm] — Reset the user’s portfolio back to $1,000 cash and wipe holdings/history. Without confirm, prints a confirmation prompt.
    • confirmation (string, optional, rest) — Type confirm to 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.
  • !m connections random — Start a random Connections puzzle.
    • Aliases: rand, r.
  • !m connections date <YYYY-MM-DD> — Start the Connections puzzle from a specific date.
    • date (string, required, rest) — Date in YYYY-MM-DD format.
    • 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.
  • !m wordle random — Start a random Wordle.
    • Aliases: rand, r.
  • !m wordle date <YYYY-MM-DD> — Start the Wordle from a specific date.
    • date (string, required, rest) — Date in YYYY-MM-DD format.
    • 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 /verify in-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.

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_id is 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::text block. 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_message tool 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:

NameTypeRequiredDescription
guild_idstringnoServer 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesTarget channel snowflake.
contentstringyesMessage 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesTarget channel snowflake.
countinteger (1–100)yesNumber 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild; used to verify the channel belongs to that guild before reading.
channel_idstringyesTarget channel snowflake.
limitinteger (1–100)noNumber of messages to fetch. Defaults to 50, clamped server-side.
beforestringnoMessage 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild; used to verify the channel belongs to that guild.
channel_idstringyesTarget channel snowflake.
author_idstringnoFilter to messages from this user snowflake.
author_namestringnoFilter by case-insensitive substring of the author’s username.
contentstringnoFilter by case-insensitive substring of the message body.
afterstringnoLower time bound. ISO 8601 date (2026-07-03 or 2026-07-03T12:00:00Z) or a Discord snowflake. Older messages are not returned.
beforestringnoUpper time bound. Same format as after. Newer messages are not returned.
limitinteger (1-1000)noMax matching messages to return. Default 100, clamped server-side.
max_pagesinteger (1-100)noMax 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild; used to verify the channel belongs to it.
channel_idstringyesChannel containing the message.
message_idstringyesTarget message snowflake.
emojistringyesUnicode 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:

NameTypeRequiredDescription
guild_idstringnoServer 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
namestringyesChannel name.
channel_typestringno (default text)One of text, voice, category, forum, stage.
category_idstringnoParent category snowflake.
topicstringnoChannel topic (text channels).
nsfwbooleannoMark 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:

NameTypeRequiredDescription
guild_idstringnoGuild snowflake. Defaults to the instance’s configured guild; used to verify the channel belongs to that guild before deletion.
channel_idstringyesChannel snowflake.

Example:

{
  "name": "delete_channel",
  "arguments": {
    "channel_id": "1234567890123456789"
  }
}

Returns: Channel deleted.

edit_channel

Update channel metadata. Any omitted field is left unchanged.

Parameters:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesChannel snowflake.
namestringnoNew name.
topicstringnoNew topic.
nsfwbooleannoNSFW flag.
slowmode_secondsintegernoSlowmode rate limit per user (in seconds).
category_idstringnoNew 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesChannel snowflake.
positionintegeryesNew position within its category/guild.
category_idstringnoNew 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
namestringyesChannel name.
category_idstringnoSnowflake of the parent category to nest under.
bitrateintegernoVoice bitrate in bps. Default 64000; range 8000-96000 by default, up to 128000/256000/384000 on tier-1/2/3 boosted guilds.
user_limitinteger (0-99)noMax 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
namestringyesChannel name.
category_idstringnoSnowflake 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesVoice channel snowflake.
namestringnoNew channel name.
bitrateintegernoNew bitrate in bps.
user_limitinteger (0-99)noNew max simultaneous users (0 = unlimited).
category_idstringnoSnowflake of a category to move the channel under.
rtc_regionstringnoRTC 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesChannel snowflake.
target_typestringyesrole or member.
target_idstringyesRole or user snowflake.
allowstringnoDecimal permission bits to grant. Defaults to 0.
denystringnoDecimal 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:

NameTypeRequiredDescription
guild_idstringnoServer 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
namestringyesRole name.
colorintegernoRGB color as a 24-bit integer (e.g. 5793266 for #5865F2).
permissionsstringnoDecimal permission bitfield.
hoistbooleannoDisplay the role separately in the member list.
mentionablebooleannoAllow @<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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
role_idstringyesRole 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
role_idstringyesRole snowflake.
namestringnoNew name.
colorintegernoNew RGB color.
permissionsstringnoNew decimal permission bitfield.
hoistbooleannoHoist flag.
mentionablebooleannoMentionable 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
limitinteger (1–1000)noMax members to return. Defaults to 100.
afterstringnoUser 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesMember’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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
role_idstringyesRole snowflake.

Example:

{
  "name": "assign_role",
  "arguments": {
    "user_id": "123456789012345678",
    "role_id": "9876543210987654321"
  }
}

Returns: Role assigned.

remove_role

Remove a role from a member.

Parameters:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
role_idstringyesRole snowflake.

Example:

{
  "name": "remove_role",
  "arguments": {
    "user_id": "123456789012345678",
    "role_id": "9876543210987654321"
  }
}

Returns: Role removed.

ban_member

Ban a user from the server.

Parameters:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
reasonstringnoAudit-log reason.
delete_message_daysinteger (0–7)noHow 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
reasonstringnoAudit-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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
durationstringyesDuration like 30s, 30m, 1h, 7d. Bare numbers are interpreted as minutes.
reasonstringnoAudit-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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
nicknamestringnoNew 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
limitinteger (1-255)noMax bans per page. Default 100.
afterstringnoPaginate 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
channel_idstringyesVoice 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
user_idstringyesTarget user snowflake.
mutebooleannoServer-mute (true) or unmute (false) when in voice. Omit to leave unchanged.
deafenbooleannoServer-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_messages uses the REST API (not the gateway), so the DIRECT_MESSAGES privileged intent is not required for these tools to work.
  • edit_private_message and delete_private_message only 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:

NameTypeRequiredDescription
user_idstringyesTarget user snowflake. The bot DMs them; Discord rejects with 403 if they have DMs disabled or don’t share a guild.
contentstringyesMessage 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:

NameTypeRequiredDescription
user_idstringyesTarget user snowflake.
limitinteger (1-100)noNumber of messages to fetch. Default 50.
beforestringnoMessage 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:

NameTypeRequiredDescription
user_idstringyesThe DM partner (same as in the original send call).
message_idstringyesSnowflake of the message to edit.
contentstringyesReplacement 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:

NameTypeRequiredDescription
user_idstringyesThe DM partner.
message_idstringyesSnowflake 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild; used to verify the channel.
channel_idstringyesTarget 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesTarget channel snowflake.
namestringyesWebhook 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:

NameTypeRequiredDescription
webhook_idstringyesWebhook 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:

NameTypeRequiredDescription
webhook_idstringyesWebhook snowflake.
tokenstringyesWebhook token (returned by create_webhook / list_webhooks).
contentstringyesMessage body.
usernamestringnoOverride the webhook’s display name for this message.
avatar_urlstringnoOverride 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:

NameTypeRequiredDescription
guild_idstringnoServer 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
channel_idstringyesChannel snowflake.
max_ageintegernoLifetime in seconds. Default 86400 (24h); 0 = never expires.
max_usesintegernoMax uses. Default 0 = unlimited.
temporarybooleannoIf true, members joining via this invite get kicked when they go offline.
uniquebooleannoIf 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:

NameTypeRequiredDescription
codestringyesInvite 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:

NameTypeRequiredDescription
codestringyesInvite 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:

NameTypeRequiredDescription
guild_idstringnoServer 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
namestringyesEmoji name (2-32 chars, alphanumeric + underscore).
image_urlstringyesHTTPS 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:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
emoji_idstringyesEmoji snowflake.
namestringnoNew name (2-32 chars). At least one editable field required.

Returns: Emoji :<name>: updated.

delete_emoji

Delete a custom emoji.

Parameters:

NameTypeRequiredDescription
guild_idstringnoServer ID. Defaults to the configured guild.
emoji_idstringyesEmoji 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:

  1. The token is wrong. Logs show Invalid Token or 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.
  2. 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.
  3. 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 placeholder your-... 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 !, but command_prefix in config.toml may have been changed. The startup logs print Instance 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-dlpffmpegsongbird. Each can fail:

  • yt-dlp outdated — YouTube breaks yt-dlp every few weeks. pip install -U yt-dlp in the bot’s environment, restart. The Dockerfile pins to pip install yt-dlp; rebuilding the image pulls the latest.
  • ffmpeg missing on PATH — the Docker image bundles it; bare-metal needs apt 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 djmode toggles a setting where only members with the configured DJ role can use music commands. Check with !m djmode again 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:

  1. The Postgres data volume. All persistent state — tempbans, guild settings, stock portfolios, member activity, reminders if you’ve added them — lives there. pg_dump against your DATABASE_URL, store the dump somewhere safe, automate with cron or a managed backup service. Per-instance dumps are pg_dump --schema=<DB_SCHEMA>.
  2. Your instances/ directories. They contain .env (with secrets), config.toml, personality.txt, and any prompt files you’ve added. tar them 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:

  1. Set MCP_AUTH_TOKEN=<long random value> — without auth on a public address, anyone with network access can drive the bot.
  2. Set MCP_BIND_ADDR=0.0.0.0 (or your specific interface).
  3. Open the port (MCP_PORT, default 9090) in your firewall.
  4. If you’re running multiple instances, use the included mcp-gateway to 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:

  1. Install the companion plugin on your Minecraft server (it owns the verification and donator-tier endpoints the bot calls).
  2. Set MC_VERIFY_URL and MC_VERIFY_SECRET in the bot’s .env.
  3. In config.toml, set features.minecraft = true and toggle the sub-features you want under [minecraft]: verify, donator_sync, chargeback. Each sub-feature has its own [minecraft.*_config] table — see instances/example/config.toml for 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:

  1. 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.”
  2. 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.
  3. Subcommand parsing was already solved. Poise gives the bot a parent m command 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