How it works#
A plugin per feature#
Each feature is its own file in src/sidequest_bot/plugins/, called a cog in discord.py terms:
a class that groups a feature’s commands, buttons and event listeners together. For example,
events.py handles event threads and sign-up buttons, leveling.py handles chat XP, and
moderation.py handles warns, timeouts, kicks and bans.
Talking to the API#
src/sidequest_bot/api_client.py is the only code in the bot that calls the website’s API. It
sends the shared X-API-Key header on every request. Bot code catches its APIError, not a raw
validation error, so a bad reply from the API fails predictably instead of crashing a plugin.
About once a minute the bot asks the API what changed (new events, sign-ups, who should hold which role) and reports back what happened in Discord (button clicks, who’s in which voice channel, name and role changes). A change made on either side reaches the other within about a minute.
Role safety#
src/sidequest_bot/lib/roles.py guards every role grant and revoke. The bot only hands out a
role if it sits below the bot’s own role in the server’s role list, carries no moderator or admin
permissions, and isn’t managed by another app (such as a role Discord itself creates for a
booster). It checks this before every single grant, so a role that later gains permissions stops
being handed out automatically. The same rule applies to moderation commands: nobody, including
an officer, can moderate the server owner, the bot, themselves, or a member whose top role is at
or above their own.
Slash command limits#
A command name can be at most 32 characters, and a description must be 1 to 100 characters. A
command outside those limits makes the bot crash on startup in a loop. test_app_command_limits.py
checks every command for this before it can be merged; see Test and check.
The sidequest-common pin#
The bot gets the shape of the API’s requests and replies from the website’s sidequest-common
package. It isn’t published anywhere else, so pyproject.toml pins it (and, for tests,
sidequest-api) to one commit of the website repo:
[tool.uv.sources]
sidequest-common = { git = "https://github.com/SideQuest-Guild/website", rev = "<sha>", subdirectory = "packages/sidequest-common" }
sidequest-api = { git = "https://github.com/SideQuest-Guild/website", rev = "<sha>", subdirectory = "api" }
To move to a newer website commit: put its SHA in both rev fields, run uv lock, then make ci.
Rolling deploys#
The bot and the website’s API deploy independently of each other. A new bot talking to an old API, or an old bot talking to a new API, has to tolerate the other side returning 404 for a route it doesn’t know about yet. Never assume the other side has already deployed; if the bot needs a new route, deploy the website first.