Files
velox-bot/docs/architecture.md
FernandoJVideiraandClaude Sonnet 5.5 9839bba50e Beta: clean up servers that opted out while the bot was offline
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]>
2026-10-23 01:32:20 +01:00

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:

  1. Config: config.LoadConfig() reads the environment (and .env).
  2. Database: db.NewDB() opens a database/sql pool using the pgx driver.
  3. Repos: one repo per area (levelrepo, settingsrepo, ticketrepo, ...). A repo is the only place that writes SQL.
  4. Services: small structs holding the rules for one area (level, meeting, welcome, ...), built on top of repos, collected in services.Services.
  5. Singletons for the bigger modules: tickets and security get their repo with cmdtickets.Init(...) and security.Configure(...), and are reached later through tickets.Default() and security.Default().
  6. Bot: bot.NewBot() creates the Discord session and chooses the gateway intents.
  7. 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.
  8. bot.WatchBeta() reads the beta settings from the database and keeps them fresh every 30 seconds. See Beta features.
  9. The process waits for SIGINT or SIGTERM.

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

  1. Discord sends an interaction. discordgo calls commands.HandleInteraction (internal/commands/commands.go).
  2. For a slash command, the dispatcher looks the command's name up in the handlers map and calls the handler.
  3. The handler checks the context (server only? permissions?), finds the subcommand in i.ApplicationCommandData().Options, reads the options, calls a service, and responds with s.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 bot and the dashboard API do not talk to each other directly. They meet in PostgreSQL, in two ways:

  1. 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.
  2. 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 in music_commands, ticket_commands or security_commands and issues NOTIFY on that channel. The bot has a long-lived connection that runs LISTEN, 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