The bot remembers where it registered beta commands (beta_registered) and removes them from servers that are no longer in the beta at the next start. Failed cleanups are retried; a server the bot has left is forgotten. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
8.7 KiB
Architecture
This page explains how the bot is put together, so you know where to look and where to make a change.
The big picture
Discord gateway (websocket)
|
discordgo session
|
+--------------------------+---------------------------+
| | |
interactions gateway events background loops
(slash commands, (messages, joins, bans, (Twitch, reminders,
buttons, modals) voice, audit log, ...) raid watch, snapshots)
| | |
internal/commands internal/events internal/events, security
| | |
+----------- services (rules) <------ repos (SQL) -----+
|
PostgreSQL <----> velox-dashboard-api <----> dashboard
The bot is one Go process. It keeps one websocket connection to Discord and one connection pool to PostgreSQL. It has no HTTP server of its own.
Startup
main.go wires everything in this order:
- Config:
config.LoadConfig()reads the environment (and.env). - Database:
db.NewDB()opens adatabase/sqlpool using the pgx driver. - Repos: one repo per area (
levelrepo,settingsrepo,ticketrepo, ...). A repo is the only place that writes SQL. - Services: small structs holding the rules for one area (
level,meeting,welcome, ...), built on top of repos, collected inservices.Services. - Singletons for the bigger modules: tickets and security get their repo with
cmdtickets.Init(...)andsecurity.Configure(...), and are reached later throughtickets.Default()andsecurity.Default(). - Bot:
bot.NewBot()creates the Discord session and chooses the gateway intents. bot.Start()opens the connection and then:- starts the music player (if Lavalink is configured) and its command listener,
- starts the ticket and security engines and their command listeners (each reconnects every 10 seconds if its database connection drops),
- registers every slash command in
GUILD_ID(or globally), - registers the gateway event handlers,
- starts the Twitch and schedule-reminder loops.
bot.WatchBeta()reads the beta settings from the database and keeps them fresh every 30 seconds. See Beta features.- The process waits for
SIGINTorSIGTERM.
Layers
The code follows one direction: commands and events call services, services call repos, repos run SQL.
| Layer | Where | Responsibility |
|---|---|---|
| Commands | internal/commands/<feature> |
Describe the slash command, check who is allowed, read the options, answer the user. No SQL. |
| Events | internal/events |
React to gateway events and run background loops. |
| Engines | internal/security, internal/tickets, internal/music |
The bigger modules, with their own state and flows. |
| Services | internal/db/services/<area> |
Rules and orchestration (for example the XP rules in level). |
| Repos | internal/db/repos/<area>repo |
SQL, and nothing else. |
Commands reach services through the package-level services.Global, set by services.NewServices(). A shared helper in each command package (for example RequireMeetingService) answers "service not available" if it is nil. Event handlers receive the services as an argument.
How a slash command runs
- Discord sends an interaction. discordgo calls
commands.HandleInteraction(internal/commands/commands.go). - For a slash command, the dispatcher looks the command's name up in the
handlersmap and calls the handler. - The handler checks the context (server only? permissions?), finds the subcommand in
i.ApplicationCommandData().Options, reads the options, calls a service, and responds withs.InteractionRespond.
Buttons, select menus and modals do not have a command name. They carry a custom ID, and the dispatcher sends them to a feature by its prefix: music:, schedule:, projects:, tickets: or security:. A feature owns everything after its prefix.
Some commands have a response that takes longer than three seconds (anything that calls Discord several times). Those answer with a deferred response first, then edit it when done. Search the code for InteractionResponseDeferredChannelMessageWithSource for examples.
How a gateway event runs
bot.Start() registers one handler per event with Session.AddHandler. Most of them hand the event to a function in internal/events, together with the services. A few go to the security engine first, because it must decide before anything else happens:
- Member joins: the security engine runs first. If it removes the member (join lock, Join Gate, raid mode) there is no welcome message or default role. Otherwise the welcome, default role and join log run.
- Messages: the security engine's heat check runs, then the leveling handler.
- Audit log entries: only the security engine (anti-nuke, unauthorised bots).
- Voice state changes: meeting rooms and the music player.
Background loops
| Loop | Where | Interval | What it does |
|---|---|---|---|
| Twitch live check | events/twitch_live.go |
30 s | Checks configured streamers and posts when one goes live |
| Schedule reminders | events/schedule_reminders.go |
1 min | DMs participants 10 minutes before a session |
| Raid watch | security |
30 s | Ends raid mode after a quiet period |
| Snapshots | security |
6 h | Saves roles and channels while anti-nuke is on (keeps three) |
| Beta settings | bot/bot.go (WatchBeta) |
30 s | Re-reads who the beta testers are and which servers opted in, and adds or removes beta commands to match |
| Command listeners | music, tickets, security | on NOTIFY |
Carry out dashboard requests |
The link to the dashboard
The bot and the dashboard API do not talk to each other directly. They meet in PostgreSQL, in two ways:
- Settings tables. The dashboard writes settings (log channel, security thresholds, ticket categories, beta role). The bot reads them when it needs them. Hot paths cache them for a short time, so a raid of thousands of joins does not become thousands of queries.
- Command tables with
NOTIFY. When the dashboard needs the bot to do something (skip a song, post the ticket panel, release a quarantined member), it inserts a row inmusic_commands,ticket_commandsorsecurity_commandsand issuesNOTIFYon that channel. The bot has a long-lived connection that runsLISTEN, picks up the row, validates it again and carries it out. Never trust a row just because it is in the table: the listeners re-check permissions themselves.
Both directions are described table by table in Database.
Gateway intents
Set in internal/bot/bot.go:
| Intent | Why |
|---|---|
| Guilds | slash commands, channel and role events |
| Guild Moderation (bans, audit log) | ban logs, anti-nuke |
| Guild Members (privileged) | joins, leaves, member updates |
| Guild Messages and Message Content (privileged) | leveling, spam heat, message logs |
| Guild Voice States | meeting rooms, music |
| Direct Messages | direct messages sent to the bot |
If you add a feature that needs another event, add its intent here too, and enable it in the developer portal if it is privileged.
Concurrency and safety notes
- discordgo calls event handlers on goroutines, so handlers must not share unsynchronised state. The engines use mutexes, and per-server memory is bounded.
- Security-sensitive actions do not rely on Discord's command permissions, because server admins can change those. The handlers check the owner and the trusted list themselves (see the Security section).
- User-controlled text (usernames, reasons) is escaped or kept out of mentions. Messages only ping roles that are configured for it.
- Settings that decide who has access (the beta role) fail closed: if they cannot be read, nobody gets access.
Where things live
| I want to find... | Look in |
|---|---|
The definition and handler of /foo |
internal/commands/foo/public/ (the registry.go next to it re-exports it) |
| The list of all commands and the dispatcher | internal/commands/commands.go |
| What happens on a gateway event | internal/bot/bot.go, then internal/events/ |
| The SQL behind a feature | internal/db/repos/<area>repo/repo.go |
| The rules behind a feature | internal/db/services/<area>/ |
| The table definitions | schema.sql and Database |
| Environment variables | internal/config/config.go |