Docs: wiki-style documentation in docs/, README as the front page

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 01:07:17 +01:00
1 parent 1ee6280d91
commit fefa74e045
10 files changed
+1137 -130

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
# 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](beta-features.md).
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 |
| 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:
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](database.md).
## 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](features.md#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](database.md) |
| Environment variables | `internal/config/config.go` |
+51
View File
@@ -0,0 +1,51 @@
# Beta features
A beta feature is one that only a few chosen people can use while it is still being tried out. The same rule applies on the bot and on the dashboard: you can use beta features if you are **a member of the beta server and hold the Beta Tester role**.
## Who the testers are
- The **beta server** and the **Beta Tester role** are chosen by the bot's owner on the dashboard, under **Admin, Beta access**. Only the owner can see or change this page.
- Giving someone the role in Discord makes them a tester. Taking it away removes their access: within about 30 seconds on the bot, and within about 30 seconds on the dashboard API.
- Until a role is chosen, **nobody** has beta access.
- The settings are stored in the `beta_settings` table (one row). The API writes it, and the bot polls it every 30 seconds. There is nothing to put in `.env`.
Access **fails closed**. If the settings cannot be read, if the role or the server is not set, if a person is not in the server, if a command is used in a DM, or if Discord cannot be reached, access is denied.
## On the bot
Code: `internal/beta` (the gate), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `registerBetaCommands`), `internal/commands/betacmd` (`/beta`).
- Beta commands are registered **only in the beta server**, so nobody else sees them in the command menu.
- Each beta handler is wrapped in `beta.Gate`, which checks that the interaction came from the beta server and that the member has the role. If not, the user gets "This feature is in beta and isn't available to you yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
- If the owner changes the beta server on the dashboard, the bot moves the beta commands to the new server within 30 seconds and removes them from the old one. Commands in the beta server that are neither regular nor beta commands are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
- `/beta` shows the owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators.
### Add a beta command
Write the command like any other (see the [Development guide](development.md#add-a-simple-command)), but instead of adding it to `AllCommands` and `handlers`, register it from `commands.go`:
```go
func init() {
RegisterBeta(mything.MyThing, mything.MyThingHandler)
}
```
`RegisterBeta` adds the definition to `BetaCommands` and the handler, wrapped in `beta.Gate`, to the dispatcher. Buttons and forms that belong to a beta command need the same care: check `beta.Guard(s, i)` at the top of their handlers, because a custom ID can be reached from an old message.
### Release it
Move the definition into `AllCommands` and the handler into the `handlers` map, and delete the `RegisterBeta` call. On the next start the command appears everywhere, and it is removed from the beta server's own list because it is now a regular command.
## On the API and dashboard
These live in the other two repositories.
- **API**: put the feature's routes under `/api/v1/beta`. That route group sits behind a `betaOnly` middleware, which asks Discord whether the signed-in user holds the role in the beta server (with a 30-second memory) and answers `403` otherwise. `GET /users/me` also returns `is_beta`, which the dashboard uses to decide what to show.
- **Dashboard**: give a route `meta: { requiresBeta: true }` to keep its page away from non-testers, and use `is_beta` to hide its links. This is only about display. The API checks access on every request, so a page that was shown by mistake still cannot read or change anything.
## Why it works this way
- The role in Discord is the single list of testers, so there is no second list to keep in step with it.
- Access is decided by the server (bot and API), never by the browser.
- Beta commands do not exist outside the beta server, and the gate checks again when they run.
- The settings are in the database, not in the environment, so the owner can change them without a deploy.
+178
View File
@@ -0,0 +1,178 @@
# Command reference
Every slash command the bot registers. Options marked with `*` are required. "Needs" is what the handler checks, not just what Discord shows in its menu. Commands that need a server cannot be used in DMs.
This page is written by hand from the command definitions in `internal/commands/*/public`. If you change a command, update it here and in `/help` (`internal/commands/help/help.go`). The [Features](features.md) page explains the behaviour behind them.
- [Fun](#fun)
- [Help](#help)
- [Leveling](#leveling)
- [Music](#music)
- [Meeting rooms](#meeting-rooms)
- [Moderation](#moderation)
- [Timezone and scheduling](#timezone-and-scheduling)
- [Projects](#projects)
- [Server setup (`/config`)](#server-setup-config)
- [Tickets](#tickets)
- [Security](#security)
- [Beta](#beta)
## Fun
Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers only.
| Command | Options | What it does |
| --- | --- | --- |
| `/ping` | | Replies "Pong!" |
| `/joke` | `type` | A random joke, optionally of a type |
| `/coinflip` | | Heads or tails |
| `/dice` | `expr`* | Rolls a dice expression: `d20`, `2d20+1`, `2#d20+1` |
| `/rps` | `hand`* | Rock paper scissors against the bot |
| `/rpsstats` | | Your rock-paper-scissors record |
| `/rpsleaderboard` | | The server's leaderboard |
## Help
| Command | What it does |
| --- | --- |
| `/help` | DMs you a command reference |
## Leveling
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/slvl toggle` | | Manage Server | Turns leveling on or off |
| `/slvl set` | `user`*, `level`* | Manage Server, leveling on | Sets a member's level |
| `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go |
| `/slvl set-message` | `message`* | Manage Server, leveling on | The level-up message. Variables: `{user}`, `{level}` |
| `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level |
| `/rank` | `user` | Leveling on | A rank card for you or another member |
| `/leaderboard` | | Leveling on | Top 10 in the server |
| `/rewards` | | Leveling on | The role rewards |
## Music
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/play` | `query`* | **DJ** role | Plays a song by name or URL |
| `/queue` | | **DJ** role | Shows the queue |
| `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume |
| `/music toggle` | | Manage Server | Turns music on or off in this server |
Requires Lavalink. See [Getting started](getting-started.md#optional-music-with-lavalink).
## Meeting rooms
Every subcommand needs **Manage Server** and works in servers only.
| Command | Options | What it does |
| --- | --- | --- |
| `/meeting add-lobby` | `channel`* (voice) | Makes a voice channel a lobby (up to 10 per server) |
| `/meeting remove-lobby` | `channel`* (voice) | Stops using it as a lobby |
| `/meeting lobbies` | | Lists the lobbies |
| `/meeting lock` | | Locks the room you are in |
| `/meeting lock-private` | | Locks it and hides it |
| `/meeting unlock` | | Unlocks it |
| `/meeting invite` | `user`* | Lets a user into a locked room |
| `/meeting uninvite` | `user`* | Removes that access |
## Moderation
Each subcommand checks the Discord permission in its "Needs" column.
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/moderation purge` | `amount`* (1 to 100) | Manage Messages | Deletes recent messages in the channel |
| `/moderation timeout` | `user`*, `duration`* (`10m`, `1h`, `2h30m`, up to 28d), `reason` | Moderate Members | Times a member out |
| `/moderation untimeout` | `user`*, `reason` | Moderate Members | Removes a timeout |
| `/moderation kick` | `user`*, `reason` | Kick Members | Kicks a member |
| `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans a member |
| `/moderation unban` | `user_id`*, `reason` | Ban Members | Unbans by ID |
| `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off |
## Timezone and scheduling
Anyone can use these.
| Command | Options | What it does |
| --- | --- | --- |
| `/timezone set` | `zone`* (IANA name, such as `Europe/Lisbon`) | Saves your timezone |
| `/timezone show` | | Shows it |
| `/schedule create` | | Opens a form (date, time, description) to request a session with a member |
| `/schedule list` | | Your upcoming sessions |
| `/schedule reschedule` | `id`* (from `/schedule list`), `datetime`* (UTC, `2006-01-02 15:04`) | Proposes a new time |
## Projects
| Command | Needs | What it does |
| --- | --- | --- |
| `/projects list` | | Active projects with creators and helpers |
| `/projects create` | Manage Server | Opens a form to create a project |
| `/projects add-helper` | Manage Server | Adds a helper to a project |
| `/projects close` | Manage Server | Closes a project |
## Server setup (`/config`)
Every subcommand needs **Manage Server**.
| Command | Options | What it does |
| --- | --- | --- |
| `/config setwelcomechannel` | `channel`* | Where welcome messages go |
| `/config setwelcomemessage` | `message`* | Welcome template. `{user}` is a mention, `{server}` the server name |
| `/config setwelcomedm` | `message`* | The welcome DM template |
| `/config setwelcomegif` | `url`* | Image or GIF for the welcome embed |
| `/config setdefaultrole` | `role`* | Role given on join |
| `/config setlogchannel` | `channel`* | Where logs go |
| `/config enablelogging` | | Turns logging on |
| `/config disablelogging` | | Turns it off |
| `/config addstreamer` | `username`* | Adds a Twitch streamer to watch |
| `/config removestreamer` | `username`* | Removes one |
| `/config settwitchnotificationchannel` | `channel`* | Where live alerts go |
## Tickets
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/tickets setup` | `channel`*, `log-channel`, `staff-role` | Manage Server | Where tickets open and where closed ones are logged. `staff-role` creates a Support category if there are none yet |
| `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown |
| `/tickets category remove` | `name`* | Manage Server | Removes a category |
| `/tickets category list` | | Manage Server | Lists categories |
| `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories |
| `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket |
| `/ticket remove` | `user`* | Staff | Removes someone |
| `/ticket claim` | | Staff | Takes the ticket |
| `/ticket close` | `reason` | The opener or staff | Closes it and posts a transcript |
## Security
All of these are hidden from members without Manage Server (and `/lockdown` from those without Manage Channels), but **hiding is not the check**. The handlers check again, and some need the **server owner or someone on the trusted list** whatever Discord permissions a person has.
In this table "Manage Server" also lets Administrators and trusted members through.
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/security status` | | Manage Server | What is on, and who is trusted |
| `/security toggle` | `feature`* (Join Gate, Raid mode, Anti-nuke, Heat), `on`* | Manage Server to turn on; owner or trusted to turn off | Turns a protection on or off |
| `/security alerts` | `channel`*, `role` | Owner or trusted | Alert channel and the role to ping |
| `/security quarantine-role` | `role` | Owner or trusted | Role given to quarantined members (empty for none) |
| `/security trust add` | `user` or `role` | Owner only | Exempts a user or role from anti-nuke |
| `/security trust remove` | `user` or `role` | Owner only | Removes that exemption |
| `/security quarantined` | | Manage Server | Lists quarantined members |
| `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back |
| `/security end-raid` | | Owner or trusted | Ends raid mode now |
| `/security snapshot` | | Manage Server | Saves roles and channels |
| `/security restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot |
| `/lockdown channel` | `channel` (default: this one) | Manage Channels | Locks a channel |
| `/lockdown server` | | Manage Channels | Locks every channel |
| `/lockdown joins` | `action`* (Kick or Ban) | Manage Channels | Kicks or bans new members until unlocked |
| `/unlock channel` | `channel` | Owner or trusted | Unlocks a channel |
| `/unlock server` | | Owner or trusted | Unlocks every channel |
| `/unlock joins` | | Owner or trusted | Lets people join again |
## Beta
| Command | Needs | What it does |
| --- | --- | --- |
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only |
Beta features come and go. See [Beta features](beta-features.md).
+66
View File
@@ -0,0 +1,66 @@
# Database
The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL lives in repos under `internal/db/repos`.
## Who owns the schema
The bot and the dashboard API share one database, so two files describe it:
- **`schema.sql`** in this repository: the tables the bot reads and writes. It is safe to run more than once (`CREATE TABLE IF NOT EXISTS`). It is enough to run the bot **on its own**.
- **The API's migrations** (`velox-dashboard-api/cmd/db/migrations`): when the API is deployed, it creates and changes the tables, and runs its migrations on start. The bot never runs migrations.
Some tables are older and were created by the bot (levels, welcome, and so on). The API's migration `000005_bot_owned_tables` creates them for a fresh combined database. Newer modules (music, tickets, security, meeting lobbies, beta settings) were created by API migrations, and `schema.sql` carries a copy so the bot can run alone.
**The rule:** a table must have the same shape in `schema.sql` and in the API migrations. When you change one, change the other in the same release.
Two things only exist in the API's migrations, because the bot never inserts into those tables:
- the `NOTIFY` triggers on the three `*_commands` tables (see below),
- the migration history.
## Tables
| Area | Tables | Notes |
| --- | --- | --- |
| Leveling | `levels`, `levelsettings`, `levelrewards`, `levelup` | XP and level per member, settings, role rewards, the level-up channel |
| Welcome | `welcome`, `defaultrole` | Welcome channel, templates, GIF, default role |
| Logging | `logsettings` | Log channel and on/off |
| Twitch | `twitch`, `twitch_config` | Streamers and the notification channel |
| Fun | `rps` | Rock-paper-scissors stats |
| Meeting rooms | `meeting_lobbies`, `meeting_temp_channels`, `meeting_settings` | Lobbies (10 per server, enforced by a trigger), the temporary rooms and who created them. `meeting_settings.lobby_channel_id` is the old single lobby column and is no longer read |
| Projects | `projects`, `project_members` | |
| Scheduling | `schedules`, `schedule_messages`, `user_settings` | Sessions, the DM messages to update, and each member's timezone |
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard |
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | |
| Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests |
| Beta | `beta_settings` | One row: the beta server and the Beta Tester role. Written by the dashboard, read by the bot |
Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge.
## Dashboard to bot: settings and requests
There are two kinds of traffic from the dashboard, and the difference matters.
**Settings.** The dashboard writes a row, and the bot reads it when it next needs it. Nothing wakes the bot up. Hot paths cache settings for a short time. The bot's beta settings are polled every 30 seconds.
**Requests that need the bot to act.** `music_commands`, `ticket_commands` and `security_commands` work as a small queue:
1. The dashboard API inserts a row (`command_type`, a JSON `payload`, `requested_by`, `processed_at` empty).
2. A trigger in the database runs `pg_notify('<table>', id)`.
3. The bot holds one connection that ran `LISTEN <table>`. It wakes, loads **every** unprocessed row, carries each out, and sets `processed_at`.
Because the bot loads all unprocessed rows (also when it starts), nothing is lost if it was down when a request arrived. A command that fails is logged and marked processed anyway, since retrying would fail the same way.
Every listener **validates the request again**. The API checks that the requester is allowed, and the bot checks too, because it should not trust a row just because it exists. The listeners live in `internal/music/listener.go`, `internal/tickets/listener.go` and `internal/security/listener.go`, and each reconnects after 10 seconds if its connection drops.
## Changing the schema
1. Decide who owns the table (see above). New tables for the dashboard's modules are created by an API migration.
2. Write the API migration, **and** update `schema.sql` to match.
3. Add or change the repo method in `internal/db/repos/<area>repo`. Use `IF NOT EXISTS` and avoid destructive changes: the bot and the dashboard may be deployed a few minutes apart, so the old bot must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release.
4. If the dashboard must make the bot do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener.
5. Say in the release notes if the API, bot and dashboard have to deploy together.
## Connection settings
`internal/db/db.go` opens the pool with up to 100 open and 10 idle connections, and recycles them after an hour. The listeners use their own separate connections, because `LISTEN` needs one that stays open.
+62
View File
@@ -0,0 +1,62 @@
# Deployment
## Docker
The `Dockerfile` is a two-stage build: it compiles a static binary with Go, then copies it into a small Alpine image (with CA certificates and timezone data, which `/timezone` and scheduling need).
```bash
docker build -t velox-bot .
docker run --env-file .env velox-bot
```
The container reads its configuration from environment variables, exactly like a local run. See [Getting started](getting-started.md#2-configure-the-environment).
## Docker Compose
`docker-compose.yml` defines two services:
- `db`: PostgreSQL 16 with a named volume (`velox_pgdata`) and a health check.
- `bot`: the bot, built from this directory, started after the database is healthy. It takes `.env` through `env_file`, and defaults `DB_HOST` to the `db` service and `LAVALINK_HOST` to `ws://lavalink:2333` if you have not set them.
```bash
docker compose up -d # database and bot
docker compose logs -f bot
```
Compose does not include Lavalink. Run it separately (or add a service) if you want music.
Change the default database password (`velox_pwd`) before exposing PostgreSQL to anything other than your own machine, and remove the published `5432` port if only the bot needs it.
## Running next to the dashboard
If you also run `velox-dashboard-api` and `velox-dashboard-web`:
1. They all use the **same database**. Give each the same PostgreSQL, and point `DB_HOST` (bot) and the API's `DB_ADDR` at it.
2. The API runs the migrations when it starts. The bot does not run migrations.
3. **Deploy order for changes that touch the database:** API first (it applies the migration), then the bot and the dashboard.
Some changes need the API, the bot and the dashboard to go out together. For example, when a table the dashboard writes changes shape, an old dashboard talking to a new API fails. The release notes for such a change say so.
## Updating
```bash
git pull
docker compose build bot
docker compose up -d bot
```
Slash commands are re-registered on every start, so new and changed commands appear after a restart. Commands you removed from the code are not deleted from Discord automatically, except for the beta server (see [Beta features](beta-features.md)).
## Backups
All state is in PostgreSQL. Back it up with `pg_dump`:
```bash
pg_dump "postgres://velox:velox_pwd@localhost:5432/velox" > velox-backup.sql
```
Raid mode, quarantines, lockdowns and ticket state live in the database, so the bot picks them up again after a restart.
## Shutting down
Send `SIGINT` or `SIGTERM` (Docker does on `docker stop`). Slash commands stay registered in Discord while the bot is down, and are registered again (updated in place) on the next start.
+298
View File
@@ -0,0 +1,298 @@
# Development guide
How to change the bot and how to add to it. Read [Architecture](architecture.md) first if you have not: it explains the layers this page keeps referring to.
- [Daily workflow](#daily-workflow)
- [Add a simple command](#add-a-simple-command)
- [Add a subcommand](#add-a-subcommand)
- [Command options and permissions](#command-options-and-permissions)
- [Buttons, select menus and forms](#buttons-select-menus-and-forms)
- [Add a feature that stores settings](#add-a-feature-that-stores-settings)
- [React to a gateway event](#react-to-a-gateway-event)
- [Add a background job](#add-a-background-job)
- [Add an environment variable](#add-an-environment-variable)
- [Make a command beta](#make-a-command-beta)
- [Testing](#testing)
- [Style and safety rules](#style-and-safety-rules)
- [Checklist before a pull request](#checklist-before-a-pull-request)
## Daily workflow
```bash
docker compose up -d db # PostgreSQL
air # rebuild and restart on every change
```
Set `GUILD_ID` to a test server in `.env`, so commands update within seconds. Before pushing:
```bash
gofmt -l .
go build ./... && go vet ./... && go test ./...
```
Some older files are not gofmt-clean. Format the files you touch, and leave the others alone so reviews stay readable.
## Add a simple command
A command is a definition (what Discord shows) plus a handler (what runs). Each feature has a package under `internal/commands/<feature>`, with this shape:
```
internal/commands/fun/
registry.go re-exports the commands and handlers of the feature
public/ the definitions and handlers
ping.go
shared/ helpers used by several commands of the feature
```
Example: a `/hello` command in the `fun` feature.
**1. The definition and handler**, in `internal/commands/fun/public/hello.go`:
```go
package public
import "github.com/bwmarrin/discordgo"
var Hello = &discordgo.ApplicationCommand{
Name: "hello",
Description: "Say hello",
}
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: "Hello!"},
})
}
```
**2. Re-export it**, in `internal/commands/fun/registry.go`:
```go
var Hello *discordgo.ApplicationCommand = public.Hello
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HelloHandler(s, i) }
```
**3. Register it**, in `internal/commands/commands.go`: add `fun.Hello` to `AllCommands`, and `"hello": fun.HelloHandler` to the `handlers` map. The key is the command's name. A command that is in `AllCommands` but not in `handlers` is shown in Discord and then does nothing.
**4. Restart the bot.** It re-registers all commands on start.
**5. Document it:** add it to `/help` (`internal/commands/help/help.go`) and to the [Command reference](commands.md).
Names must be lowercase, up to 32 characters, and unique. Descriptions are required and limited to 100 characters.
For an entirely new feature, create a new package with the same shape, and import it in `commands.go`.
## Add a subcommand
A command with subcommands (like `/meeting`) lists them in `Options`, and its handler switches on the name of the first option. Add the subcommand to the definition:
```go
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "lobbies",
Description: "List this server's meeting lobbies",
},
```
and a case to the handler's switch:
```go
switch data.Options[0].Name {
case "lobbies":
handleListLobbies(s, i)
// ...
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
}
```
Keep one `handleX` function per subcommand. For a group (`/tickets category add`), the first option is the group and its first option is the subcommand.
## Command options and permissions
**Options** go in the definition's `Options` (or in a subcommand's). Types are `String`, `Integer`, `Boolean`, `User`, `Channel`, `Role`, `Number`, and others. A channel option can be limited with `ChannelTypes`. A string can offer fixed `Choices`.
Inside a subcommand, `shared.SubcommandOptionMap(i)` (in the meeting, level and other `shared` packages) turns the options into a map by name:
```go
opts := shared.SubcommandOptionMap(i)
channel := opts["channel"].ChannelValue(s) // *discordgo.Channel
```
**Where a command may be used.** `Contexts` lists where Discord offers it (`InteractionContextGuild`, `InteractionContextBotDM`, `InteractionContextPrivateChannel`). A server-only command should also refuse politely if `i.GuildID == ""`: the shared packages have `RequireGuild` for this.
**Who may use it.** There are two layers, and you need both:
1. `DefaultMemberPermissions` on the definition only decides who **sees** the command in Discord's menu. Server admins can change it in their settings, so it is not security.
2. The handler must check again, using `i.Member.Permissions`. The shared packages have helpers such as `RequireManageGuild`. Moderation uses `hasPerm(i, discordgo.PermissionKickMembers)` and so on.
For anything where a mistake hurts (security settings, anything destructive), check the **owner or trusted list** as well, as `internal/commands/security` does with `allowedOrTrusted`. Do not rely on Discord's permissions alone.
**Responses.**
- Answer within three seconds. If a command takes longer, respond with `InteractionResponseDeferredChannelMessageWithSource` first, then edit the response with `InteractionResponseEdit`.
- Use `Flags: discordgo.MessageFlagsEphemeral` for replies only the user should see (errors, settings). `shared.RespondEphemeral` does this.
- When the message contains user-controlled text, set `AllowedMentions: &discordgo.MessageAllowedMentions{}` so it cannot ping anyone.
## Buttons, select menus and forms
Components and modals carry a **custom ID**, and the dispatcher routes them by prefix (`internal/commands/commands.go`, `HandleInteraction`). To add one to a feature:
1. Use the feature's prefix when you build the component: `CustomID: "projects:close:" + id`. Put whatever you need in the ID after the prefix, split with `:`.
2. The dispatcher already sends `music:`, `schedule:`, `projects:`, `tickets:` and `security:` to that feature's `HandleComponent`, and `schedule:`, `projects:`, `tickets:` modals to its `HandleModalSubmit`.
3. In `HandleComponent`, parse the rest of the ID, check permissions **again** (the button may be clicked by anyone who can see the message), and respond.
For a **new prefix**, add a case to the `switch` in `HandleInteraction`, and write `HandleComponent` and/or `HandleModalSubmit` in your feature.
Custom IDs are limited to 100 characters, and a modal can have up to five inputs.
Never trust the contents of a custom ID. A user cannot edit it, but old messages stay around with old IDs, and IDs may refer to things that no longer exist.
## Add a feature that stores settings
The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (`/meeting add-lobby`).
**1. The table.** Add it to `schema.sql`, and to an API migration if the dashboard will show it (see [Database](database.md#changing-the-schema)).
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
```go
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at`, guildID)
// ...
}
```
Always pass values as parameters (`$1`), never build SQL with string formatting.
**3. The service**, in `internal/db/services/<area>/service.go`. It wraps the repo and holds the rules (limits, defaults, validation):
```go
type Service struct{ settings *settingsrepo.Repo }
func New(settings *settingsrepo.Repo) *Service { return &Service{settings: settings} }
```
**4. Wire it up.** Add a field to `services.Services` and the constructor in `internal/db/services/services.go`, and build the repo and service in `main.go` next to the others.
**5. Use it from the command**, through `services.Global`:
```go
if services.Global == nil || services.Global.Meeting == nil {
shared.RespondEphemeral(s, i, "Meeting service is not available.")
return
}
added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID)
```
**6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and the dashboard cannot disagree and two simultaneous requests cannot both slip through.
**7. Default to off.** A module that does something to a server without being asked (posts messages, moderates, grants roles) must start disabled and be turned on by an admin.
## React to a gateway event
1. Write the function in `internal/events/`, taking the session, the event and the services:
```go
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { ... }
```
2. Register it in `bot.Start()` (`internal/bot/bot.go`):
```go
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
events.HandleChannelDelete(s, c, b.Services)
})
```
3. Check that the **intent** for that event is enabled in `NewBot`. Events from an intent you did not ask for never arrive. Privileged intents must also be enabled in the developer portal.
Handlers run concurrently on goroutines. Do not share unsynchronised state, and do not block: slow work belongs in its own goroutine.
If the event is something security cares about (joins, messages, audit log), look at how `bot.go` calls the security engine **before** your handler.
## Add a background job
Follow `StartScheduleReminderLoop` in `internal/events/schedule_reminders.go`: a function that returns immediately and starts a goroutine with a ticker.
```go
func StartMyLoop(s *discordgo.Session, svc *services.Services) {
if svc == nil || svc.Thing == nil || s == nil {
return
}
go func() {
time.Sleep(10 * time.Second) // let the bot finish starting
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
// check, act, log errors, never panic
}
}()
}
```
Call it at the end of `bot.Start()`. Log errors instead of returning them, and make one failing server not stop the loop for the others.
## Add an environment variable
1. Add a field to `Config` and read it in `LoadConfig()` (`internal/config/config.go`). Return an error if the bot cannot work without it.
2. Document it in [Getting started](getting-started.md#2-configure-the-environment).
3. Pass it where it is needed from `main.go`.
Never log a secret, and never put one in a message to a user.
Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database and on the dashboard instead. That is how the beta settings work.
## Make a command beta
Register it with `commands.RegisterBeta` instead of adding it to `AllCommands` and `handlers`. See [Beta features](beta-features.md).
## Testing
```bash
go test ./... # unit tests, no database needed
go test -race ./... # recommended before a pull request
```
Where the code allows it, the Discord and database calls go through small interfaces, so flows can be tested with fakes. `internal/tickets` and `internal/security` are the models: look at their `fakes_test.go`.
Tests that need a real PostgreSQL are behind the `integration` build tag and skip themselves when their environment variable is missing:
| Package | Variable |
| --- | --- |
| `internal/db/repos/settingsrepo` | `TEST_DB` |
| `internal/db/repos/ticketrepo`, `internal/tickets` | `TICKETS_TEST_DB` |
| `internal/db/repos/securityrepo` | `SECURITY_TEST_DB` |
```bash
TEST_DB="postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" \
go test -tags integration ./internal/db/repos/settingsrepo
```
Use a throwaway database for these, not one with real data. The tables must already exist (load `schema.sql`).
Good things to test: permission checks (does the wrong person get refused?), limits, anything that parses user input, and "what if the service or the database fails".
## Style and safety rules
- **One feature per package, SQL only in repos, rules in services.** Commands never contain SQL.
- **Match the code around you**: naming, comment density, error handling. Comments explain *why*, not *what*.
- **Fail closed.** If you cannot tell whether someone is allowed, they are not.
- **Validate everything that comes from a user or the dashboard**: option values, custom IDs, `payload` JSON in command rows.
- **Escape user text** in messages, and set `AllowedMentions` so it cannot ping.
- **Bound everything per server**: caches, lists, loops. One server must not be able to use unlimited memory.
- **Do not leak internals** to users: reply with a short message, log the detail.
- **Idempotent writes**: use `ON CONFLICT` for "add" operations, so double clicks and retries are harmless.
- Handle the bot being **restarted at any time**. State that must survive (raid mode, quarantines) lives in the database.
## Checklist before a pull request
- [ ] `gofmt` on the files you touched, `go build ./...`, `go vet ./...`, `go test -race ./...`
- [ ] Commands: in `AllCommands` and `handlers`, permission check in the handler, ephemeral errors
- [ ] New table: in `schema.sql` **and** in an API migration, with a note about deploy order
- [ ] Module that acts on a server: off by default
- [ ] `/help`, [Command reference](commands.md) and [Features](features.md) updated
- [ ] No secrets, tokens or personal IDs in code, logs or tests
+120
View File
@@ -0,0 +1,120 @@
# Features
What each module does and how it behaves. For the exact options of every command, see the [Command reference](commands.md). To change how a module works, see the [Development guide](development.md).
Unless a section says otherwise, a module is **off** in a new server and a server admin turns it on.
## Security
Anti-raid and anti-nuke, modelled on Wick. Code: `internal/security`, commands in `internal/commands/security`.
**Join Gate** checks every new member: no avatar, account too new, invite links or blocked words in the name, unverified bots. Each check has its own action. It also removes bots added by anyone who is not trusted.
**Raid mode** starts when many members join in a short time. Raiders are timed out, kicked or banned, invites can be paused, staff are alerted, and raid mode ends after a quiet period or when staff end it with `/security end-raid`. It survives a bot restart: the quiet timer carries on and the summary still lists everyone caught.
**Anti-nuke** counts destructive actions per person from the audit log: deleting channels or roles, mass bans and kicks, creating webhooks, granting dangerous permissions. Someone who is not the owner or trusted and goes over a limit is **quarantined**: their roles are removed and saved, and they are timed out. `/security release` gives every role back.
**Lockdown** (`/lockdown`, `/unlock`) locks one channel, every channel, or joins. Unlocking puts back exactly the permission overwrite that was there before.
**Snapshots** save the server's roles and channels. They are taken every 6 hours while anti-nuke is on (the last three are kept) and on request with `/security snapshot`. `/security restore` recreates deleted roles and channels and reverts dangerous permission changes. It can also run automatically after a stopped nuke.
**Heat** is the anti-spam system. Each member has a score that rises with messages and cools over time. Spam, repeats, mass mentions and invite links max it, which deletes the message and times the member out (the timeout doubles for repeat offences). A wave of spammers turns on *heat panic* (newcomers are timed out on sight for 10 minutes), and a mention flood can lock the server. Members with Manage Messages and trusted members are exempt.
**Alerts** go to a channel you choose, and critical ones (raids, stopped nukes, restores, spam waves, removed bots) are also DMed to the owner and trusted users, because the alert channel may be the first thing a nuker deletes. One setting turns the DMs off.
Rules that hold everywhere in this module:
- Turning protection off, changing limits, releasing a quarantine and unlocking need the **server owner or someone on the trusted list**. Editing the trusted list needs the owner. Discord's own command permissions are never relied on, because admins can change them.
- The owner and the bot are never punished.
- Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned.
- Messages ping only the configured alert role, and names are escaped.
Thresholds, Join Gate filters and heat settings are set on the dashboard. The bot's role must be above the roles it protects against: it can only quarantine members whose highest role is below its own.
## Tickets
Support tickets in private threads. Code: `internal/tickets`, commands in `internal/commands/tickets`.
1. An admin runs `/tickets setup`, adds categories with `/tickets category add` (each with its own staff role), and posts the panel with `/tickets panel`.
2. The panel has a category dropdown and a **Create ticket** button. A short form asks what the ticket is about.
3. A **private thread** is created in the ticket channel. The opener and the category's staff role can see it.
4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`.
5. Closing locks and archives the thread and posts a **transcript** to the log channel.
The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
The flows go through small interfaces for Discord and the database, so they are tested without either. The slash commands and buttons only translate interactions into those calls.
## Moderation
`/moderation` with `purge`, `timeout`, `kick`, `ban`, `unban`, `untimeout` and `slowmode`. Each subcommand checks the matching Discord permission in the handler (Manage Messages, Moderate Members, Kick Members, Ban Members, Manage Channels). Durations accept `10m`, `1h`, `2h30m` and so on, up to 28 days. Actions are written to the log channel if logging is on.
## Logging
When enabled with `/config enablelogging` and a channel set with `/config setlogchannel`, the bot logs edited and deleted messages, joins and leaves, member and role updates, bans and unbans, and moderation actions. Code: `internal/events/logging.go` and `internal/modlog`.
## Leveling
Opt in per server with `/slvl toggle`. Code: `internal/db/services/level` (the rules) and `internal/events/event_handlers.go` (the message handler).
- Every message may give 1 to 3 XP. Below level 5 every message counts. From level 5 on, a message counts with a chance of 1 in `level / 4`, so leveling slows down.
- 100 XP makes a level, and XP resets to 0.
- A level-up posts the level-up message (set with `/slvl set-message`, variables `{user}` and `{level}`) in the channel chosen with `/slvl set-channel`, or in the channel of the message.
- `/slvl set-reward` gives a role at a level. `/rank` draws a rank card (`internal/rankcard`), `/leaderboard` shows the top 10 and `/rewards` lists role rewards.
## Music
Lavalink-powered playback. Code: `internal/music`, commands in `internal/commands/music`.
- `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**.
- The now-playing message has buttons for playback and repeat. Buttons also check for the DJ role.
- `/music toggle` (Manage Server) turns music on or off in a server.
- The dashboard shows the queue and can control playback. It does so through the `music_commands` table and a `NOTIFY` (see [Database](database.md)).
- Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not.
## Meeting rooms
Join a **lobby** voice channel and get your own temporary room. Code: `internal/events/meeting_voice.go`, commands in `internal/commands/meeting`.
- A server can have up to **10 lobbies** (`/meeting add-lobby`, `remove-lobby`, `lobbies`). The limit is enforced by a database trigger, so the bot and the dashboard obey the same rule.
- When someone joins a lobby, the bot creates a voice channel in the same category with the lobby's user limit and bitrate, named after the lobby and the time, and moves the member in.
- When the temporary room is empty, it is deleted.
- If a lobby channel is deleted in Discord, it is dropped from the lobbies.
- `/meeting lock`, `lock-private` (also hides the room), `unlock`, `invite` and `uninvite` act on the room you are in. Every `/meeting` subcommand, including these, currently needs **Manage Server**.
## Scheduling and timezones
1:1 sessions between two members. Code: `internal/commands/schedule`, `internal/db/services/schedule`, reminders in `internal/events/schedule_reminders.go`.
- Each member sets a timezone with `/timezone set` (an IANA name such as `Europe/Lisbon`).
- `/schedule create` opens a form (date, time in your timezone, optional description) and DMs the other member a request with **Accept**, **Decline** and **Propose different time** buttons.
- `/schedule list` shows your upcoming sessions, and `/schedule reschedule` proposes a new time (in UTC, `2006-01-02 15:04`). The other side can accept or decline the proposal.
- Every minute, a loop DMs the participants of sessions starting within 10 minutes.
## Projects
A simple list of ongoing projects. `/projects create` opens a form (name, optional description), `/projects list` shows active projects with creators and helpers, `/projects add-helper` and `/projects close` manage them. Creating, adding helpers and closing need Manage Server.
## Welcome messages and default role
Set up with `/config` (Manage Server). Code: `internal/events/welcome.go`.
- A welcome message in a channel, and an optional DM, both templates where `{user}` becomes a mention and `{server}` the server name. An optional GIF or image URL goes in the welcome embed.
- A default role is granted when a member joins.
- Bots are skipped, and members the security engine removes get nothing.
## Twitch alerts
`/config addstreamer`, `removestreamer` and `settwitchnotificationchannel` (Manage Server). Every 30 seconds a loop checks each configured streamer and posts in the notification channel when one goes live. Code: `internal/events/twitch_live.go`. It needs `TWITCH_CLIENT_ID`.
## Onboarding
When the bot is added to a server, it applies safe defaults (modules off) and DMs setup instructions to the person who invited it, falling back to the server owner. Code: `internal/events/onboarding.go`.
## Fun
`/ping`, `/joke [type]`, `/coinflip`, `/dice` (expressions like `d20`, `2d20+1`, `2#d20+1`, parsed in `internal/diceexpr`), and `/rps` with `/rpsstats` and `/rpsleaderboard`.
## Help
`/help` DMs a command reference. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command.
+118
View File
@@ -0,0 +1,118 @@
# Getting started
This page takes you from nothing to a bot running on your machine.
## Requirements
- **Go**: the version in `go.mod` (the Dockerfile builds with the same one).
- **PostgreSQL 16 or newer**: the included `docker-compose.yml` starts one for you.
- **A Discord application** with a bot user (see below).
- Optional: [Air](https://github.com/air-verse/air) for live reload, [Just](https://github.com/casey/just) for shortcuts, and a [Lavalink](https://github.com/lavalink-devs/Lavalink) server if you want music.
## 1. Create the Discord application
1. Open the [Discord developer portal](https://discord.com/developers/applications) and create an application. Copy its **Application ID**: this is `APP_ID`.
2. Under **Bot**, create the bot user and copy its token: this is `BOT_TOKEN`. Keep it secret.
3. Under **Bot**, switch on these **privileged gateway intents**: **Server Members Intent** (welcome messages, security) and **Message Content Intent** (leveling and spam detection). The bot also uses the non-privileged Guilds, Guild Moderation (bans and the audit log), Guild Messages, Guild Voice States and Direct Messages intents. You do not need to enable those by hand.
4. Invite the bot with the `bot` and `applications.commands` scopes. It needs these permissions, depending on which modules you use:
| Permission | Used by |
| --- | --- |
| View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts |
| Read Message History, Manage Messages | moderation purge, spam heat |
| Manage Roles | default role, level rewards, quarantine |
| Manage Channels | lockdown, slowmode, meeting rooms, snapshot restore |
| Manage Server | pausing invites during a raid |
| Kick Members, Ban Members, Moderate Members | moderation commands, Join Gate, raid mode, anti-nuke |
| View Audit Log | anti-nuke, unauthorised bot detection |
| Create Private Threads, Send Messages in Threads, Manage Threads | tickets |
| Connect, Speak, Move Members | music, meeting rooms |
The bot's role must sit **above** the roles it manages or protects against. Discord does not let a bot change members whose highest role is higher than its own.
## 2. Configure the environment
The bot reads environment variables, and loads a `.env` file from the working directory if there is one (`internal/config/config.go`).
| Variable | Required | Meaning |
| --- | --- | --- |
| `BOT_TOKEN` | yes | The bot token. |
| `APP_ID` | yes | The application ID. |
| `GUILD_ID` | no | If set, slash commands are registered in this server only, and appear within seconds. If empty they are registered globally, which Discord can take up to an hour to show. Use a test server while developing. |
| `DB_HOST` | yes | A pgx connection string, for example `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`. |
| `TWITCH_CLIENT_ID` | yes | A Twitch client ID, used to check who is live. The bot refuses to start without it, even if you never use Twitch alerts. Any non-empty value works if you do not use them. |
| `LAVALINK_HOST` | no | Lavalink address, for example `ws://localhost:2333`. Leave empty to run without music. |
| `LAVALINK_PASSWORD` | no | Lavalink password. |
A minimal `.env` for local development:
```bash
BOT_TOKEN=your-bot-token
APP_ID=123456789012345678
GUILD_ID=123456789012345678
DB_HOST=postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable
TWITCH_CLIENT_ID=unused
```
`.env` is ignored by git. Never commit it.
Who the beta testers are is **not** an environment variable. It is set on the dashboard and stored in the database. See [Beta features](beta-features.md).
## 3. Set up the database
Start PostgreSQL with Compose:
```bash
docker compose up -d db
```
Then load the schema:
```bash
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
```
`schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is a mirror of the tables the bot uses. If you run the dashboard API as well, the API's migrations create and change these tables, and you do not need `schema.sql` at all. Read [Database](database.md) before changing a table.
## 4. Run
```bash
go run .
```
You should see the bot connect, and the slash commands appear in your test server. Type `/ping`.
With live reload:
```bash
air
```
With Just (`Justfile`):
```bash
just up # start PostgreSQL
just start # start PostgreSQL and run Air
just down # stop PostgreSQL
```
## 5. Turn modules on
Nothing is on by default. In your server:
- `/security toggle` to enable Join Gate, raid mode, anti-nuke or heat.
- `/tickets setup`, then `/tickets category add` and `/tickets panel`.
- `/slvl toggle` for leveling.
- `/config setlogchannel` and `/config enablelogging` for logs.
- `/music toggle` for music (needs Lavalink).
The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option.
## Optional: music with Lavalink
Music needs a running Lavalink server and `LAVALINK_HOST` / `LAVALINK_PASSWORD`. Without them the bot starts normally and music simply does not work. Members need a role named exactly **DJ** to use `/play`, `/queue` and `/volume`.
## Next steps
- Running it for real: [Deployment](deployment.md).
- Changing or adding things: [Development guide](development.md).
+57
View File
@@ -0,0 +1,57 @@
# Troubleshooting
## The bot does not start
| Message | Cause |
| --- | --- |
| `BOT_TOKEN is not set`, `APP_ID is not set`, `DB_HOST is not set` | A required variable is missing. See [Getting started](getting-started.md#2-configure-the-environment). |
| `TWITCH_CLIENT_ID is not set` | It is required even if you do not use Twitch alerts. Set any non-empty value. |
| `Error creating database` | `DB_HOST` is wrong, or PostgreSQL is not running or not reachable. Try the connection string with `psql`. |
| `Error starting bot` | The token is wrong, or a privileged intent is not enabled in the developer portal. |
| `Disallowed intents` | Enable **Server Members Intent** and **Message Content Intent** in the developer portal, under Bot. |
## Slash commands do not appear
- With `GUILD_ID` set, commands appear in that server within seconds. If it is empty they are global and can take up to an hour.
- The bot must be invited with the `applications.commands` scope.
- If a command is defined but not in the `handlers` map in `internal/commands/commands.go`, it shows up and does nothing.
- If the bot logs `Cannot create '<name>' command`, read the error. A name or description that is too long, or an invalid option, makes Discord reject that one command.
- Commands that you removed from the code stay in Discord until you delete them (in the developer portal or through the API).
## A command says "You don't have permission"
Many commands need **Manage Server** (see the [Command reference](commands.md)), and the music commands need a role called exactly **DJ**. Security actions such as turning protection off need the **server owner or a trusted user**, whatever permissions you have.
## The bot cannot do something in a server
Most failures are about the bot's role:
- The bot's role must be **above** the roles it manages and the members it moderates or quarantines.
- It needs the permissions listed in [Getting started](getting-started.md#1-create-the-discord-application). Check the channel's own permission overrides too: a channel can deny what the server role allows.
- Security messages say "Check that Velox's role is high enough and has the permissions it needs" for this reason.
## Music does not play
- `LAVALINK_HOST` and `LAVALINK_PASSWORD` must match your Lavalink server, and Lavalink must be running and reachable from the bot (in Compose, use the service name, not `localhost`).
- You need the **DJ** role.
- A server admin can turn music off with `/music toggle`.
## Level-ups, logs or welcome messages do nothing
They are off until enabled: `/slvl toggle`, `/config enablelogging` with `/config setlogchannel`, and `/config setwelcomechannel`. Leveling and spam detection read message text, so the **Message Content Intent** must be on. Welcome messages and security need the **Server Members Intent**.
## Dashboard actions have no effect
The dashboard talks to the bot through the database (see [Database](database.md#dashboard-to-bot-settings-and-requests)).
- Check that the bot and the API use the **same database**.
- For a request such as skipping a song or ending a raid, look in the matching `*_commands` table. A row with an empty `processed_at` is waiting. If many are waiting, the bot's listener is down: look for `command listener stopped` in its log. It reconnects every 10 seconds.
- Settings are read when needed and may be cached briefly (the beta settings are polled every 30 seconds).
## Beta features are not available to a tester
Go through [Beta features](beta-features.md): is the Beta Tester role chosen on the dashboard? Is the person in the beta server, and do they have the role? Has 30 seconds passed? `/beta` (owner only) shows how access is set up.
## Getting more detail
The bot logs to standard output. Run `docker compose logs -f bot`, or just `go run .`, and reproduce the problem. Errors from Discord calls are logged with the server and channel IDs involved.