Architecture Overview
discord-bot-rs is built from small, well-isolated modules communicating
through a central shared Data
struct and a handful of Discord event handlers. The process is a single
Tokio runtime that hosts a Discord client, a Postgres connection pool, and
an embedded MCP server, plus whichever feature modules you’ve turned on in
config.toml. Everything else — music players, game state, rate limiters,
the AI pipeline — hangs off Data and is accessed async-safely through
per-guild or per-channel maps. This page sketches the high-level shape;
the rest of the architecture section drills into each piece.
Components
graph TB
subgraph "Bot Process"
Gateway[Discord Gateway<br/>serenity shard]
Handler[Event + Command Handler<br/>poise]
Commands[Commands<br/>src/commands/]
AI[AI Pipeline<br/>src/ai/]
Music[Music Player<br/>src/music/]
Games[Games<br/>wordle, connections, stocks]
DB[(PostgreSQL<br/>sqlx pool)]
MCP[MCP Server<br/>src/mcp + axum]
end
Discord[Discord API] <--> Gateway
Gateway --> Handler
Handler --> Commands
Handler --> AI
Handler --> Music
Commands --> DB
AI --> DB
Games --> DB
Handler --> Games
Claude[MCP client<br/>e.g. Claude Code] --> MCP
MCP --> Handler
One Tokio process hosts a serenity shard (the WebSocket to Discord’s
gateway), a poise dispatcher wrapped around it, and an axum-based MCP
server bound on a local port. Gateway events flow into the event handler,
which either fires a command (via poise) or routes the event directly to a
feature module — the AI pipeline for @mentions and replies, the music
player for voice state updates, the games module for interaction buttons.
Every module talks to the Postgres pool through sqlx. The MCP server is
a separate ingress point: it exposes tools like list_guilds and
send_message to outside clients, and its handlers reach into the same
shared state the event handler uses.
The Data struct
Poise gives every handler a typed reference to a user-defined state
struct. In this project that struct is
Data,
defined at the top of src/main.rs. It holds:
db: sqlx::PgPool— the shared Postgres connection pool.http_client: reqwest::Client— a preconfigured HTTP client for yt-dlp, DeepSeek, Gemini, Finnhub, DuckDuckGo scraping, and any other outbound HTTP work a feature needs.config: Config— loaded from environment variables at startup.personality: Stringandbot_name: String— per-instance identity.- Optional feature configs —
auto_role_config,minecraft_config,join_role_config,welcome_config, etc. Each isOption<T>so that disabled features simply holdNone. - Per-guild state maps:
guild_players,track_handles,now_playing_msgs,idle_timers,connections_games,wordle_games. All areArc<DashMap<key, value>>, giving lock-free guild lookup. Most values are wrapped inArc<Mutex<T>>for serialised access inside one guild;track_handlesis the exception — it storesTrackHandledirectly because songbird’s handle is already cheap to clone and internally synchronised. rate_limiters: RateLimiters— sliding-window limiters for AI, music, moderation, stock tools, and the welcome/join flow, keyed by user ID. All five are enforced; a periodic cleanup task inmain.rsevicts empty buckets every 5 minutes. See Concurrency Model.mcp_started: AtomicBoolandstarted_at: DateTime<Utc>— one-shot flags used to guard the MCP server against gateway reconnects and to let the AI history builder ignore messages from previous bot lifetimes.
A single Arc<Data> is cloned into every command context, event handler
future, and background task. Cheap to clone, shared everywhere, no global
state — see Concurrency Model for why this shape
works without contention.
Per-instance model
Each running bot is a separate Linux process with its own Data, its own
Discord token, its own Postgres schema, and its own instance config
directory. “Multi-tenant” here means “run two containers with two
.env files,” not “one process with guild-scoped data.” The
Multi-Instance Model page explains the
boundaries and why the project chose a schema-per-instance approach over
the alternatives.
How events flow
Discord pushes an event down the gateway. serenity parses it into a
typed variant and hands it to poise’s event dispatcher, which either
matches a prefix command (the !m family) and runs the command handler,
or calls the plain event_handler
for everything else. The event handler is one big match over
FullEvent variants — ready, message create, voice state update,
interaction create, member add — and each arm dispatches to the
corresponding feature module. Responses go back to Discord via serenity’s
HTTP client. The full lifecycle is in Data Flow.
Major modules
| Module | Responsibility |
|---|---|
src/main.rs | Entry point, Data struct, framework init, background task spawning |
src/config.rs | Environment variable loading |
src/instance_config.rs | config.toml parsing for per-instance feature flags |
src/error.rs | BotError enum and From conversions |
src/commands/ | Every prefix command, all parented under !m |
src/events/ | Gateway event dispatcher, message handler, voice-state handler |
src/ai/ | DeepSeek/Gemini pipeline, tool execution, response sanitising |
src/music/ | Per-guild player, yt-dlp + songbird pipeline, voice handling |
src/wordle/ | Wordle game state and puzzle fetching |
src/connections/ | NYT Connections game state and puzzle fetching |
src/stocks/ | Virtual stock trading, Finnhub integration |
src/minecraft/ | Minecraft link verification, donator sync, chargeback webhooks |
src/autorole.rs | Time/message-based role promotion |
src/mcp/ | Embedded MCP server and tool definitions |
src/db/ | Connection pool, models, query helpers |
src/util/ | Rate limiters, duration parsing |
Tech choices
- serenity — Rust’s mature Discord library, the foundation for everything else. Chosen for its stable gateway handling and typed model objects.
- poise — a command framework
built on serenity. Used for its prefix-command parsing, subcommand
tree, and typed
Context<'_, Data, BotError>. Saves hundreds of lines of boilerplate compared to raw serenity. - songbird — the voice driver. Handles voice gateway, UDP, and Opus packet assembly so this project only has to feed it audio bytes.
- sqlx — async Postgres client with compile-time-checked queries. Chosen over an ORM for explicitness and because the schema is small enough not to need one.
- dotenvy — reads
.envat startup. Modern maintained fork of the classicdotenvcrate. - rmcp — the official Rust SDK for the Model Context Protocol; used for the embedded MCP server.
- axum — HTTP server. The MCP server and (optionally) chargeback webhook router run on axum inside the same Tokio runtime as the Discord client.
- dashmap — a lock-free concurrent hash map. Used for every per-guild and per-channel state map so that work in one guild never blocks work in another.
Where to go next
- Multi-Instance Model for the process and schema layout when you run more than one bot against one Postgres.
- Data Flow for the step-by-step lifecycle of a single Discord event.
- AI Pipeline for how
@mention→ response actually works, including tool-use loops. - Music Pipeline for the yt-dlp + songbird path.
- Concurrency Model for
DashMap+tokio::Mutexpatterns and why locks are the last tool, not the first. - Error Handling for how
BotErrorreaches users. - Database Schema for every table and what owns it.