Codebase Tour
This page is the contributor’s map. It walks the codebase module by module, tells you what each file is responsible for, and names the function or type you should open first if you’re trying to understand the area. Read it once before your first contribution; after that it becomes a lookup table.
If you want the higher-level picture of how the parts talk to each other, read the Architecture Overview first — this page assumes you already have that context and goes one level deeper.
Repository layout
discord-bot-rs/
├── src/ # the main bot crate
├── mcp-gateway/ # a second crate: the multi-instance MCP router
├── instances/ # per-instance config directories
│ └── example/ # config.toml + personality.txt starter
├── docs/ # this mdBook
├── theme/ # mdBook theme overrides
├── .github/ # CI workflows and issue/PR templates
├── Cargo.toml # workspace root for the main crate
├── Dockerfile # bot container build
├── docker-compose.yml # bot + postgres default stack
└── README.md
The two crates (src/ and mcp-gateway/) are built and tested
independently — CI runs format, clippy, build, and test on each. A
third top-level artifact is the mdBook under docs/, built by a
separate workflow and published to GitHub Pages.
The main crate (src/)
Every path below is relative to src/.
main.rs — entry point and shared state
src/main.rs
is the one file you have to read before anything else. It does four
things:
- Declares every top-level module — the
moddeclarations at the top are the ground truth for which directories undersrc/actually compile. - Defines the
Datastruct — this is the shared application state that poise hands to every command and event handler. It holds thesqlx::PgPool, areqwest::Client, the loadedConfig, thepersonalityandbot_namestrings, everyOption<T>feature config (auto_role_config,minecraft_config,join_role_config,welcome_config), per-guild stateDashMaps (guild_players,track_handles,now_playing_msgs,idle_timers,connections_games,wordle_games), aRateLimitersbundle, and one-shot startup flags (mcp_started,started_at). - Builds the poise framework in
main()— loads the instance config, constructs the parentmcommand (pushing the optionalverifysubcommand if Minecraft verify is enabled), registers the event handler, and wires the prefix frominstance_cfg.command_prefix. - Spawns long-running background tasks — the tempban unban checker
(30s loop), the auto-role time promotion checker (60s loop), and the
donator sync poller (interval from config). Each is a
tokio::spawnthat owns its own clones ofhttpand the DB pool.
When you add a new feature that needs startup state, this is the file
where you both extend Data and spawn the task, then pass the Data
reference through into the module that needs it.
config.rs — environment variables
src/config.rs
is a single Config struct and a Config::load() function. It reads
.env via dotenvy, panics fast on missing required vars
(DISCORD_TOKEN, CLIENT_ID, GUILD_ID), and exposes the optional
API keys (DEEPSEEK_API_KEY, GEMINI_API_KEY, FINNHUB_API_KEY,
MC_VERIFY_URL, MC_VERIFY_SECRET) as Option<String>. The MCP
settings (MCP_PORT, MCP_BIND_ADDR, MCP_AUTH_TOKEN) and the
database settings (DB_SCHEMA, DATABASE_URL) are plain String
fields with non-None defaults. The get_env_or_throw helper also
panics on your-... placeholder values, so the bot refuses to boot
with an unedited .env.example.
instance_config.rs — parsing config.toml
src/instance_config.rs
loads the per-instance config.toml — bot_name, command_prefix,
the features sub-table (feature flags), and optional typed config
sections (AutoRoleConfig, MinecraftConfig with its nested
DonatorSyncConfig and ChargebackConfig, JoinRoleConfig,
WelcomeConfig). It also resolves the instance directory via
CONFIG_DIR (default .) and loads personality.txt and the
optional welcome prompt from that directory. Everything here is
Deserialize + a small number of default_* functions for fields
that have sane defaults.
error.rs — the BotError enum
src/error.rs
defines BotError, the project-wide error type. Five variants —
Serenity, Sqlx, Reqwest, SerdeJson, Other(String) — with
From impls so every fallible call site can use ?. It implements
Display and std::error::Error, which is enough for poise to accept
it as the E in Context<'_, Data, E>. Commands that return
Err(...) surface through poise’s on_error handler in main.rs.
commands/ — the command tree
Every user-facing prefix command lives under
src/commands/.
The key file is
commands/mod.rs:
it declares the parent m command and lists every subcommand with
#[poise::command(prefix_command, subcommands(...))]. This is the one
place you register commands — main.rs only ever pushes the single
parent m into the framework’s commands vec. There are no slash
commands anywhere in this codebase. Every command is
prefix_command only, usually with a rename and short aliases.
The individual files group commands by area:
admin.rs—setlog,djrole,djmode. Server-admin settings that live in theguild_settingstable.moderation.rs—ban,unban,banlist,nuke. Tempbans go throughdb::queries::create_tempban, which returns the expiry timestamp; themain.rsbackground task later callshttp.remove_banwhen expired bans are found.music.rs—play,playlist,skip,stop,pause,resume,queue,nowplaying,remove,loop_cmd,shuffle. All of them call intomusic::voiceandmusic::player::GuildPlayerthroughData.connections.rs,wordle.rs— thin wrappers over the corresponding game module. Each creates aGamestruct, sends the initial embed + buttons, and inserts the game into the channel map onData.stocks.rs—stockparent withbuy,sell,portfolio,price,leaderboard,history,resetsubcommands. The module refuses to run withoutFINNHUB_API_KEYviarequire_finnhub_key.minecraft.rs— theverifysubcommand, which is only pushed into the parentmat startup whenfeatures.minecraftandminecraft.verifyare both enabled inconfig.toml.help.rs— renders the help embed, dynamically showing the moderation and admin sections only if the invoking member has the matching permissions.
If you’re adding a command, the Adding a Command page has a full worked example; start there rather than copying blindly from any of these files.
events/ — gateway event dispatcher
events/mod.rs
is the non-command event handler. It’s one big match over
poise::serenity_prelude::FullEvent:
Ready→ready::handle_readyplus a one-shot MCP server start guarded bydata.mcp_started.swap(true)so gateway reconnects don’t re-launch the HTTP server.VoiceStateUpdate→voice_state::handle_voice_state_update, which triggers the “auto-leave when the channel is empty” behaviour.Message→handle_message, the largest branch. It does three things in order: bumps themember_activityrow for auto-role, intercepts 5-letter messages as Wordle guesses in channels with an active game, and — if the message mentions the bot or replies to a bot message — hands off toai::deepseek::handle_mention.InteractionCreate(Component) → routes to one of the button handlers by prefix:music_*,game_*(Connections),cb_*(chargeback buttons). Music buttons enforce “you must be in the same voice channel” and the DJ mode check.GuildMemberAddition→member_join::handle_member_join, which applies the join role and (if enabled) generates a welcome message through the AI pipeline.
events/ready.rs is tiny (it just logs and sets a presence string).
events/voice_state.rs checks whether the bot is alone in its voice
channel and cancels playback / disconnects. events/member_join.rs
handles both static join roles and the AI-generated welcome flow with
its own rate limit (last_welcome).
When adding a new reactive behaviour — for example, a new button prefix — the routing lives here.
ai/ — the AI chat pipeline
src/ai/
is the @mention pipeline. It’s the single largest subtree in the
codebase and the one where you should start by reading the
AI Pipeline architecture page rather
than the files.
chat.rs— by far the biggest file in the project. It ownshandle_mention, the message-history builder, provider dispatch through theAiProvidertrait +ProviderRouter, and every tool implementation the bot exposes to the LLM. If a tool call resolves to playing music, creating a tempban, or starting a game, the dispatch lives here.tools.rs— JSON schema definitions for the tool set the bot advertises to DeepSeek/Gemini/Claude:web_search,play_song,skip,stop,pause,resume,show_queue,now_playing,shuffle,set_loop,remove_from_queue,tempban,unban,nuke,stock_buy,stock_sell,stock_price,stock_portfolio,stock_leaderboard,connections_start,wordle_start, and a few others. Predicate helpers (is_search_tool,is_moderation_tool, …) are used bychat.rsto route each tool call.dsml.rs— parses “DSML” (DeepSeek Markup Language) tool-call blocks embedded in model output, for models that emit structured tool calls in prose rather than in the OpenAI-styletool_callsarray.sanitize.rs— strips role markers, DeepSeek control tokens, and Llama-style[INST]/<<SYS>>markers from user input so users can’t trivially inject prompts.split.rs— splits responses over Discord’s 2000-character message limit without breaking code fences or multi-byte chars.search.rs— DuckDuckGo scraper, implemented viacurlrather thanreqwestbecause DDG returns a non-result page when it seesreqwest’s default headers.confirmation.rs— wraps moderation tool calls with a “react to confirm” button flow, with a 30-second timeout, so the AI can’t tempban someone without explicit human sign-off.
music/ — the music player
src/music/
has four files and is straightforward if you read them in order.
player.rs— theGuildPlayerstruct: aVecDeque<Track>, acurrent: Option<Track>, apaused: bool, aLoopMode, and aMAX_QUEUE_LENGTHof 100. All the queue operations (enqueue,advance,skip_current,shuffle,remove) live here. It has zero I/O; just data.track.rs— wrapsyt-dlpas a subprocess.resolve_trackandresolve_tracksspawn yt-dlp with the project’s cookie jar and node-runtime path, parse the JSON output, and returnTrackvalues. It also exposesytdlp_user_argswhichvoice.rspasses into songbird’sYoutubeDlinput source.voice.rs— the songbird wrapper.join_channel,play_track,stop_playback,leave_channel, and theTrackEndHandlerthat advances the queue when a track finishes.PlaybackContextis the cloned state bundle passed into event handlers.embeds.rs— builds the “now playing”, “added to queue”, and “queue” embeds, plus the two button rows (music_controls). Button IDs aremusic_pauseresume,music_skip,music_stop,music_shuffle,music_loop,music_queue; this is whatevents/mod.rsdispatches on.
wordle/, connections/, stocks/ — the games
Each game module has the same shape:
game.rs— the pure-Rust game state (guess list, selected words, mistakes remaining, game-over / won predicates).api.rs— the upstream fetch (NYT Wordle JSON, NYT Connections JSON, or Finnhub stock quotes, with a 60-secondstock_price_cachetable in front of the Finnhub calls).embeds.rs— the Discord rendering (emoji grid for Wordle, the 4×4 button grid for Connections, portfolio/leaderboard/transaction history for stocks).
Wordle lives in
src/wordle/
and also ships a words.txt wordlist compiled in via include_str!.
Connections is in
src/connections/.
Stocks is in
src/stocks/
and, unlike the word games, has no game.rs — it has no in-memory
session state because every holding is persisted to Postgres.
minecraft/ — the Minecraft integration
src/minecraft/
glues the bot to an external Minecraft server’s HTTP API. Three files:
api.rs— the wire types (VerifyRequest,VerifyResponse) and theverify()HTTP call that posts a code + Discord ID to the MC server’s verify endpoint.donator_sync.rs—fetch_donatorspulls the current donator list from the MC server;sync_rolesreconciles it against Discord role state, adding and removing supporter/premium roles. This is driven by thecheck_intervalloop spawned inmain.rs.chargeback.rs— anaxum::Routerthat listens for webhook posts from the MC server when a chargeback happens. The router verifies the secret, looks up the Discord user by UUID, posts a message to the staff channel with restrict/ignore buttons, and handles those button clicks (thecb_*custom ID prefix dispatched inevents/mod.rs). The router is optional and is only mounted under the MCP axum app whenminecraft.chargeback = true.
mcp/ — the embedded MCP server
src/mcp/
is the in-process Model Context Protocol server. Two files:
mod.rs— sets uprmcp’sStreamableHttpService, wraps it in an axum app with a bearer-token middleware, optionally mounts the chargeback webhook router under the same app, and binds onMCP_BIND_ADDR:MCP_PORT.tools.rs— theDiscordToolsstruct with aToolRouterand one#[tool]method per exposed capability: listing channels, reading messages, sending messages, managing roles, and so on. Every method wraps the serenity HTTP call with a 10-secondtimeoutand converts errors via a pair of helpers (api_err,timeout_err).
If you’re adding an MCP tool, Adding an MCP Tool walks through the macros and registration.
db/ — Postgres and queries
src/db/
has three files:
mod.rs—init_pool. Connects once, runsCREATE SCHEMA IF NOT EXISTS "{schema}", then creates aPgPoolOptionswith anafter_connectthat setssearch_pathon every new connection. This is how multi-instance schema isolation works: every instance uses the sameDATABASE_URLbut its ownDB_SCHEMA. After the pool is built,mod.rsruns everyCREATE TABLE IF NOT EXISTSstatement for the bot’s schema.models.rs— oneFromRowstruct per table:Tempban,GuildSettings,MemberActivity,StockPortfolio,StockHolding,StockTransaction,StockPriceCache.queries.rs— every query function.get_guild_settings,upsert_guild_setting,create_tempban,get_expired_bans,mark_unbanned,get_stock_portfolio,buy_stock,sell_stock, and so on. Queries use the rawsqlx::query*helpers withbindrather than the compile-time-checkedquery!/query_as!macros, so the crate builds without a live database at compile time.
autorole.rs — the role-promotion rule
src/autorole.rs
is a single small module: meets_criteria(activity, config) evaluates
age and message-count thresholds against an AutoRoleConfig, and
try_promote adds the target role, removes the source role, and marks
the row promoted = true in member_activity. It’s called from two
places: the background time-check loop in main.rs, and the
handle_message hot path in events/mod.rs so promotion happens
immediately on the message that tips the count over.
util/ — small helpers
util/duration.rs—parse_duration("3d","2h","30m", capped at 365 days) andformat_duration_ms/format_track_duration. Used by tempbans, auto-rolemin_age, and the music “now playing” line.util/ratelimit.rs— theSlidingWindowLimiter(aDashMap<String, Vec<Instant>>) andRateLimiters, which bundles four limiters:ai(10/60s),music(15/30s),moderation(5/60s), andstocks(10/30s). Every limiter is keyed by a stringified user ID.
The mcp-gateway crate
mcp-gateway/
is a small separate crate that sits in front of one or more running bot
instances and exposes a single MCP endpoint to an outside AI client.
Five files:
main.rs— buildsGatewayConfig, constructs oneBackendClientper configured instance, wires them into aGatewayState, mounts the axum router, binds, and spawns a 5-minute background task that refreshes the guild map.config.rs— readsGATEWAY_PORT(default9100), the optionalMCP_AUTH_TOKENbearer secret, and the requiredINSTANCESenv var of the formname1=url1,name2=url2into a list ofInstance { name, url }.backend.rs— the per-instance MCP client. Opens a Streamable HTTP SSE session to a bot’s MCP endpoint and exposesinitialize,list_tools,call_tool,list_guilds, andhealth_checkfor the gateway server to call.routing.rs— the guild-to-instance router. Keeps aHashMap<guild_id, instance_name>so that the gateway can look at a tool call’sguild_idand forward it to the right backend.server.rs— the axum handlers.POST /mcpaccepts a JSON-RPC envelope, authenticates with the optional bearer token, resolves the target backend via the router, forwards the call, and streams the response back. There’s also a cachedtools/listaggregation so the client sees the union of every backend’s tools, plus a gateway-onlylist_instancestool.
MCP Gateway Routing has the sequence diagram.
Cross-cutting patterns
A few conventions hold across the whole codebase. If you’re not sure how to do something, follow the pattern that already exists.
- Every command takes
Context<'_>— that’s the type alias forpoise::Context<'a, Data, BotError>at the bottom ofsrc/main.rs. You access shared state throughctx.data(), which hands you back a&Data. - Every DB query is in
db/queries.rs— commands do not build SQL inline. If you need a new query, add a function there and call it from the command. - Every long-running background task is a
tokio::spawninmain(), cloning theArc-friendly parts ofDatait needs (db.clone(),http.clone()). Tasks log viatracing::info!on startup andtracing::warn!/tracing::error!on failure, and they never panic — errors are logged and the loop keeps going. - Every optional feature is gated twice: once by the
features.<name>flag inconfig.toml, and once by the presence of the feature’sOption<Config>onData. The event handler and the command code both early-return when the feature is disabled. - Locking discipline is flat:
DashMap<GuildId, Arc<Mutex<T>>>for per-guild state. Look up the entry, clone theArc, acquire theMutex, do the work, drop the guard. Never hold aDashMapentry across an.await. - No
unwrap()in hot paths. Startup code inmain.rsis allowed to.expect()on missing env vars and pool creation; everything downstream returnsBotError.
Where to look for…
Use this table as a lookup when you can’t remember where something lives.
| Looking for | Start here |
|---|---|
| Adding a new command | commands/mod.rs and Adding a Command |
| Adding a new event handler branch | events/mod.rs |
| Adding a new AI tool | ai/tools.rs and the dispatch in ai/chat.rs |
| Adding a new MCP tool | mcp/tools.rs and Adding an MCP Tool |
| Adding a whole new feature module | Adding a Feature Module |
| Adding a DB table | db/mod.rs (CREATE TABLE), db/models.rs (struct), db/queries.rs (functions) |
| Changing rate limits | util/ratelimit.rs (RateLimiters::new) |
| Changing the default personality | The instance’s personality.txt, not the code |
| Adding a required env var | config.rs, then propagate through Data |
| Adding a per-instance TOML option | instance_config.rs, then main.rs wiring |
| Changing the music queue size | MAX_QUEUE_LENGTH in music/player.rs |
| Fixing Wordle validation | is_valid_word and words.txt in wordle/game.rs |
| Discord permission checks | ctx.author_member() + member_permissions — see commands/help.rs for the pattern |
Code style conventions
Things you’ll notice reading the code, and things reviewers will ask for on PRs:
- Hard tabs, width 4.
rustfmt.tomlenforces this. Runcargo fmtbefore opening a PR. ?overunwrapin fallible code.expectis only acceptable at startup inmain.rs.- Errors convert through
BotErrorviaFromimpls; commands and event handlers returnResult<(), BotError>. tracingoverprintln!. All logs go throughtracing::info!,warn!,error!so they’re structured and filterable viaRUST_LOG.- No
asyncclosures insideDashMapcallbacks. Look up, clone theArc, drop the entry guard, then.awaiton the clone. - Feature-gated startup is verbose by design —
main.rslogs each optional feature’s activation atinfo!and warns loudly if the TOML config is missing. Copy that pattern for new features.
Next steps
- Building Locally — run the crate with
cargooutside of Docker. - Adding a Command — the shortest path from “I want a new command” to “PR ready.”
- Adding a Feature Module — when a
feature is big enough to deserve its own directory under
src/. - Adding an MCP Tool — exposing a new capability to external MCP clients.
- Testing — what the test surface looks like today and where to add coverage.