Compare commits

...
Author SHA1 Message Date
FernandoJVideiraandClaude Sonnet 5.5 02b7db1813 Docs: say plainly that only the bot is open source; the dashboard and API are proprietary
- README, license section and architecture no longer point to the dashboard repositories
- schema.sql is the schema for contributors; database changes only need schema.sql and a note in the PR
- self-hosting: settings without a dashboard (SQL for security thresholds, beta settings, opted-in servers)
- beta features, deployment and troubleshooting reworded for people who don't have the dashboard

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 01:54:08 +01:00
FernandoJVideiraandClaude Sonnet 5.5 1fbc38ca56 Prepare for open source: Apache-2.0 license, contributing guide, self-hosting guide, fix Close()
- LICENSE (Apache-2.0), NOTICE, CONTRIBUTING.md
- docs/self-hosting.md and README sections for hosting and contributing
- Close() no longer crashes: it only disconnects and leaves the slash commands registered, so they keep their IDs across restarts
- Tests use a neutral server ID

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 01:47:24 +01:00
FernandoJVideira 279607183a Merge pull request 'Beta in other servers (still 3.5.2)' (#27) from ft/beta-servers into dev
Reviewed-on: #27
2026-10-23 00:32:50 +00:00
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
FernandoJVideiraandClaude Sonnet 5.5 fcf4f3ccbd Beta servers: testers can use beta features in servers whose owner opted in
- beta.Configure takes the opted-in servers; testers are looked up in the beta server
- beta commands registered per opted-in server and removed when it opts out
- beta.ServerEnabled for behaviour that no person triggers
- docs updated

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 01:25:50 +01:00
23 changed files with 1393 additions and 172 deletions

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
# Contributing to Velox Bot
Thanks for wanting to help. This page is the short version of how to do it well. The longer technical material is in the [Development guide](docs/development.md).
## Ways to help
- **Report a bug.** Say what you did, what you expected and what happened. Include the bot's log lines around the problem and whether it happens in more than one server. Never paste a bot token, a database password or a `.env` file.
- **Suggest a feature.** Explain the problem it solves for a server, not only the command you imagine.
- **Improve the documentation.** Wrong or missing docs are bugs. A fix to `docs/` is always welcome.
- **Fix something or build something.** Pick an issue, or open one first (see below).
## Security problems
If you find a way to get around a permission check, make the bot act for someone who shouldn't be able to ask, or leak a token, **do not open a public issue**. Contact the maintainer privately, using the contact details on the repository's profile, and give them time to fix it before you share it.
## Before you write code
1. **Open an issue for anything big**: a new module, a change to how permissions or security work, a change to the database. It saves you from writing something that can't be merged.
2. Read [Architecture](docs/architecture.md) so you know the layers, and the [Development guide](docs/development.md) for the recipe that matches your change.
3. For anything that touches the database, read [Database](docs/database.md#changing-the-schema). A schema change has to keep a bot that is one version behind working for a short while.
## Making the change
1. Fork the repository and create a branch **from `dev`**: `git checkout -b my-change dev`. `master` only holds releases.
2. Keep the change focused. One pull request, one purpose. Unrelated cleanup goes in its own pull request.
3. Follow the code around you: naming, comment density, error handling. Comments explain why, not what.
4. Add tests. At minimum, test the permission check (does the wrong person get refused?), limits, and anything that parses input. See [Testing](docs/development.md#testing).
5. Update the documentation that your change makes wrong: `/help` (`internal/commands/help/help.go`), [Command reference](docs/commands.md) and [Features](docs/features.md).
Before you push:
```bash
gofmt -l . # format the files you touched
go build ./... && go vet ./... && go test -race ./...
```
Some older files are not `gofmt`-clean. Format the files you change, and leave the rest alone so the diff stays readable.
## The rules that matter most
These are the things a reviewer will check first. The reasons are in the [Development guide](docs/development.md#style-and-safety-rules).
- **Fail closed.** If you can't tell whether someone is allowed, they are not.
- **Check permissions in the handler.** `DefaultMemberPermissions` only hides a command from the menu.
- **No SQL outside repos.** Always use query parameters (`$1`), never string formatting.
- **A module that acts on a server starts off.** An admin turns it on.
- **Escape user text and set `AllowedMentions`** so a message can't ping people.
- **Validate everything that comes from users or from other programs writing to the database**: options, custom IDs, command payloads.
- **No secrets** in code, logs, tests or commit messages.
## Commit messages and pull requests
- Write the commit subject in the imperative and keep it short ("Add a limit of 10 meeting lobbies"). Use the body to explain why, when it isn't obvious.
- In the pull request, say what changed and why, how you tested it, and whether it needs a database change or a deploy in a particular order.
- Keep the pull request open to review comments. Small follow-up commits are fine, there is no need to squash them yourself.
## Behaviour
Be kind and be specific. Disagree with the code, not the person. Harassment, personal attacks and discrimination are not tolerated, and the maintainer may remove comments, close discussions or block people who do it.
## License
By contributing, you agree that your contribution is licensed under the [Apache License 2.0](LICENSE), the same as the project (section 5 of the license covers this). You keep the copyright on your own work.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Velox Bot
Copyright 2026 Fernando Videira
This product includes software developed by the authors of its dependencies,
each under its own license (see go.mod).
+19 -8
View File
@@ -2,12 +2,9 @@
Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on.
This repository is the bot. It has two companions that live in their own repositories:
**This repository is the bot, and it is the only open source part of Velox.** The web dashboard and its API, which the hosted Velox uses, are separate proprietary software and are not part of this repository or its license. The bot does not need them: everything in the [command reference](docs/commands.md) works on its own, with PostgreSQL as its only dependency.
- **velox-dashboard-api**: a Go REST API behind the web dashboard. It owns most of the database schema and runs the migrations.
- **velox-dashboard-web**: the Vue dashboard and public website.
The bot and the API share one PostgreSQL database. They never call each other over HTTP. The dashboard writes settings and requests into tables, and the bot reads them (and is woken through Postgres `NOTIFY` where it has to react at once). See [Architecture](docs/architecture.md).
The dashboard and the bot meet in the database. The dashboard writes settings and requests into tables, and the bot reads them (and is woken through Postgres `NOTIFY` where it has to react at once). Those tables are documented, so you can change the same settings with SQL, or build your own tool on top. See [Architecture](docs/architecture.md) and [Database](docs/database.md).
## Documentation
@@ -16,8 +13,9 @@ This README is the front page. The rest of the documentation lives in [`docs/`](
| Page | What is in it |
| --- | --- |
| [Getting started](docs/getting-started.md) | Requirements, creating the Discord application, configuration, database, running locally |
| [Host your own bot](docs/self-hosting.md) | A step-by-step guide to running your own copy on a server, with Docker Compose |
| [Deployment](docs/deployment.md) | Docker, Compose, upgrade order, backups |
| [Architecture](docs/architecture.md) | How the code is laid out, how an interaction flows through it, background loops, the link to the dashboard |
| [Architecture](docs/architecture.md) | How the code is laid out, how an interaction flows through it, background loops, how a dashboard talks to the bot |
| [Features](docs/features.md) | What each module does and how it behaves |
| [Command reference](docs/commands.md) | Every slash command, its options and who may run it |
| [Database](docs/database.md) | Who owns the schema, the tables, the `NOTIFY` channels, how to change them |
@@ -46,7 +44,7 @@ On start the bot registers its slash commands in the server named by `GUILD_ID`
- **Tickets**: a panel with categories, a private thread per ticket, claim and close, transcripts to a log channel.
- **Moderation**: purge, timeout, kick, ban, unban, slowmode, plus server logs for edits, deletes, joins, leaves, role changes, bans and mod actions.
- **Leveling**: XP per message, level cards, leaderboard, role rewards.
- **Music**: Lavalink-powered playback with a queue, controlled from Discord or the dashboard.
- **Music**: Lavalink-powered playback with a queue, controlled from Discord.
- **Meeting rooms**: join a lobby voice channel and get a temporary room you can lock and invite people to.
- **Scheduling and projects**: 1:1 sessions with DM invites and reminders, and a simple project list.
- **Twitch alerts, welcome messages, default role**: per-server setup through `/config`.
@@ -71,6 +69,19 @@ internal/modlog, rankcard small shared helpers
schema.sql mirror of the tables the bot reads and writes
```
## Host your own bot
You can run your own copy under your own Discord application. [Host your own bot](docs/self-hosting.md) walks through it on a fresh server with Docker Compose: creating the application and the invite link, configuring the bot, loading the database, optional music and Twitch, backups and updates.
## Contributing
Start with the [Development guide](docs/development.md). In short: keep a command's definition and its handler in one package, keep SQL in a repo and rules in a service, and run `go build ./... && go vet ./... && go test ./...` before opening a pull request.
Contributions are welcome: bug reports, fixes, documentation and new features.
- Read [CONTRIBUTING.md](CONTRIBUTING.md) first. It covers how to report a problem, how to propose a change, and what a good pull request looks like.
- The [Development guide](docs/development.md) shows how to add a command, a button, an event handler, a setting or a table, and how to test them.
- For anything big, open an issue to talk about it before you write the code.
- Pull requests go to the `dev` branch. Before opening one, run `gofmt` on the files you touched and `go build ./... && go vet ./... && go test -race ./...`.
## License
Velox Bot, the code in this repository, is released under the [Apache License 2.0](LICENSE). Copyright 2026 Fernando Videira. See [NOTICE](NOTICE). The license does not cover the proprietary Velox dashboard and API, or the Velox name and logo as used by the hosted service. The libraries the bot uses are under their own permissive licenses (see `go.mod`).
+7 -7
View File
@@ -19,7 +19,7 @@ This page explains how the bot is put together, so you know where to look and wh
| | |
+----------- services (rules) <------ repos (SQL) -----+
|
PostgreSQL <----> velox-dashboard-api <----> dashboard
PostgreSQL <----> a dashboard (the hosted Velox has a proprietary one)
```
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.
@@ -84,15 +84,15 @@ Some commands have a response that takes longer than three seconds (anything tha
| 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 |
| 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 requests written to the `*_commands` tables |
## The link to the dashboard
## How a dashboard talks to the bot
The bot and the dashboard API do not talk to each other directly. They meet in PostgreSQL, in two ways:
The bot has no HTTP server and no API of its own. A web dashboard, such as the proprietary one the hosted Velox uses (not part of this repository), meets the bot in PostgreSQL, in two ways. This is the whole contract, so you can drive the bot the same way with SQL or write your own tool:
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.
1. **Settings tables.** A 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 a 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).
+47 -19
View File
@@ -1,24 +1,42 @@
# 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**.
A beta feature is one that is still being tried out, so only chosen people can use it, and only where the server agreed. Two things must both be true:
1. **The person is a beta tester**: a member of the beta server who holds the Beta Tester role.
2. **Beta features are on in the server they use it in**: the beta server itself, or any other server whose owner opted in.
The rule is the same on the bot and on the hosted Velox dashboard. The dashboard is proprietary and not part of this repository, so this page is about the bot, and mentions the dashboard only where it explains how the settings get into the database.
## 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.
- The **beta server** and the **Beta Tester role** live in the `beta_settings` table, one row. On the hosted Velox the owner sets them on the dashboard. If you host the bot yourself, set them with SQL (see [Settings without a dashboard](self-hosting.md#settings-without-a-dashboard)).
- Giving someone the role in Discord makes them a tester. Taking it away removes their access, within about 30 seconds on the bot (a few seconds if the bot has them cached).
- 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`.
- Being a tester is about the person, so it is always checked in the **beta server**, also when they use a feature somewhere else. Having a role with the same ID in another server means nothing.
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.
## Which servers have beta features
- The beta server always has them.
- Any other server gets them only when it is in the `beta_guilds` table. Beta features can act on a whole server (anti-raid behaviour, for example), so a server should only be listed with its owner's agreement. On the hosted Velox, only the server's own owner can switch it on, from the dashboard. If you host the bot yourself, you add rows yourself.
- The bot polls the table every 30 seconds, together with the beta settings.
Beta never replaces a feature's own permission checks. A beta command that needs Manage Server still needs it, a music command still needs the DJ role, and anything for the owner or trusted users still needs that.
## What a tester sees
- **Bot:** beta commands appear only in the beta server and in opted-in servers, and run only for testers. When a server opts in, the commands show up within about 30 seconds. When it opts out, they are removed.
- **On the hosted Velox dashboard:** beta pages and links show a **Beta** badge, only to testers and only for servers where beta features are on.
## On the bot
Code: `internal/beta` (the gate), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `registerBetaCommands`), `internal/commands/betacmd` (`/beta`).
Code: `internal/beta` (the rules), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `syncBetaCommands`), `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.
- Beta commands are registered in each server where beta features are on, and only there. `/beta` is only in the beta server.
- Each beta handler is wrapped in `beta.Gate`. It checks that beta is on in the server of the interaction and that the person is a tester, and otherwise answers "This feature is in beta and isn't available to you or this server yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
- For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no.
- If the beta server changes in `beta_settings`, the beta commands move within 30 seconds. In the beta server, commands this bot left there that no longer exist are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
- `/beta` shows the bot's owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators.
- The bot remembers the servers where it registered beta commands (`beta_registered` table). A server that opted out while the bot was **offline** is cleaned up on the next start, and a cleanup that fails is retried every 30 seconds. A server the bot has left is forgotten.
### Add a beta command
@@ -30,22 +48,32 @@ func init() {
}
```
`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.
`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: call `beta.Guard(s, i)` at the top of their handlers, because a custom ID can be reached from an old message.
### Add beta behaviour that no one runs
Event handlers and background jobs have no person to check. Gate them on the server only:
```go
if !beta.ServerEnabled(guildID) {
return // beta features are off here
}
```
Do not use this for anything that should only be for testers as people. It means "this server agreed to try beta features", and every member of the server is affected.
### 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.
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 the beta servers drop it from their own lists because it is now a regular command.
## On the API and dashboard
## On the 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.
The hosted Velox has a proprietary dashboard and API that apply the same rule to their own pages: a beta page is only shown, and its API routes only answer, for a tester in a server where beta features are on. Their code is not part of this repository. If you build your own tool on the same tables, apply the rule on your server side too: never rely on hiding a page.
## 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.
- Servers choose to take part, because a beta feature can affect everyone in them.
- Access is decided on the server, never in 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.
- The settings are in the database, not in the environment, so they can change without a deploy.
+2
View File
@@ -175,4 +175,6 @@ In this table "Manage Server" also lets Administrators and trusted members throu
| --- | --- | --- |
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only |
Other beta commands appear only in the beta server and in servers whose owner opted in, and only testers can run them.
Beta features come and go. See [Beta features](beta-features.md).
+25 -19
View File
@@ -4,19 +4,26 @@ The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL live
## Who owns the schema
The bot and the dashboard API share one database, so two files describe it:
**`schema.sql`** in this repository is the schema. It describes every table the bot reads and writes, and it is enough to run the bot. It is safe to run more than once (`CREATE TABLE IF NOT EXISTS`). The bot never runs migrations itself.
- **`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.
The hosted Velox also has a proprietary dashboard API that keeps its own migrations for the same tables. That is the maintainer's concern: if your change adds or alters a table, update `schema.sql` and say so in your pull request, and the maintainer ports it. You never need the API to develop or run the bot.
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.
Two things are not in `schema.sql`, because only a dashboard needs them: the `NOTIFY` triggers on the three `*_commands` tables (see below), and a migration history. If you write a tool that inserts command rows, add the trigger yourself:
**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.
```sql
CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('music_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Two things only exist in the API's migrations, because the bot never inserts into those tables:
CREATE TRIGGER music_commands_notify
AFTER INSERT ON music_commands
FOR EACH ROW EXECUTE FUNCTION notify_music_command();
```
- the `NOTIFY` triggers on the three `*_commands` tables (see below),
- the migration history.
The same works for `ticket_commands` and `security_commands`. Without the trigger the bot still picks the rows up, but only the next time it starts or is notified for another reason.
## Tables
@@ -30,36 +37,35 @@ Two things only exist in the API's migrations, because the bot never inserts int
| 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 |
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State a dashboard can show, plus requests to the bot |
| 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 |
| Beta | `beta_settings`, `beta_guilds` | `beta_settings` is one row: the beta server and the Beta Tester role. `beta_guilds` lists servers whose owner opted in to beta features. Both are written by the dashboard and read by the bot. `beta_registered` is the bot's own note of where it registered beta commands |
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.
There are two kinds of traffic from a dashboard to the bot, 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.
**Settings.** A 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).
1. A dashboard 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.
Every listener **validates the request again**. The dashboard should check 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.
1. Update `schema.sql`. Use `CREATE TABLE IF NOT EXISTS`, and avoid destructive changes: a bot that is a version behind (or a tool reading the same tables) must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release.
2. Add or change the repo method in `internal/db/repos/<area>repo`.
3. If something outside the bot must make it do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener.
4. In your pull request, say that the schema changed and whether an existing table is affected, so the maintainer can port the change to the dashboard's migrations.
## Connection settings
+2 -8
View File
@@ -27,15 +27,9 @@ Compose does not include Lavalink. Run it separately (or add a service) if you w
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
## Database changes
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.
The bot does not run migrations. When a release adds tables, load `schema.sql` again before restarting the bot (it only creates what is missing, and is safe to repeat). A release that changes an existing table says so in its notes.
## Updating
+5 -5
View File
@@ -154,7 +154,7 @@ Never trust the contents of a custom ID. A user cannot edit it, but old messages
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)).
**1. The table.** Add it to `schema.sql` (see [Database](database.md#changing-the-schema)).
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
@@ -188,7 +188,7 @@ if services.Global == nil || services.Global.Meeting == nil {
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.
**6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and anything else writing to the table 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.
@@ -244,7 +244,7 @@ Call it at the end of `bot.Start()`. Log errors instead of returning them, and m
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.
Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database instead (set with a command, or by a dashboard). That is how the beta settings work.
## Make a command beta
@@ -281,7 +281,7 @@ Good things to test: permission checks (does the wrong person get refused?), lim
- **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.
- **Validate everything that comes from a user or from another program writing to the database**: 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.
@@ -292,7 +292,7 @@ Good things to test: permission checks (does the wrong person get refused?), lim
- [ ] `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
- [ ] New table or column: in `schema.sql`, and mentioned in the pull request
- [ ] 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
+2 -2
View File
@@ -29,7 +29,7 @@ Rules that hold everywhere in this module:
- 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.
Thresholds, Join Gate filters and heat settings are rows in the `security_settings` table (the hosted Velox edits them on its dashboard; see [Settings without a dashboard](self-hosting.md#settings-without-a-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
@@ -69,7 +69,7 @@ Lavalink-powered playback. Code: `internal/music`, commands in `internal/command
- `/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)).
- The queue and now-playing state are kept in the database, and playback can be controlled by writing to the `music_commands` table. The hosted Velox dashboard uses this (see [Database](database.md)).
- Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not.
## Meeting rooms
+3 -2
View File
@@ -20,6 +20,7 @@ This page takes you from nothing to a bot running on your machine.
| --- | --- |
| View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts |
| Read Message History, Manage Messages | moderation purge, spam heat |
| Use External Emojis | embeds that use custom emojis |
| Manage Roles | default role, level rewards, quarantine |
| Manage Channels | lockdown, slowmode, meeting rooms, snapshot restore |
| Manage Server | pausing invites during a raid |
@@ -56,7 +57,7 @@ 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).
Who the beta testers are is **not** an environment variable. It is stored in the database. See [Beta features](beta-features.md).
## 3. Set up the database
@@ -72,7 +73,7 @@ Then load the schema:
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.
`schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is the schema of every table the bot uses. Read [Database](database.md) before changing a table.
## 4. Run
+198
View File
@@ -0,0 +1,198 @@
# Host your own bot
This guide takes you from a clean server to your own Velox running under your own Discord application. It uses Docker Compose, which is the simplest way. For the Discord side in more detail, see [Getting started](getting-started.md). For a short run on your own machine while developing, see that page too.
You will run:
- **The bot** (this repository). It needs PostgreSQL.
- **PostgreSQL**, started by the same Compose file.
- Optionally **Lavalink**, if you want music.
The hosted Velox also has a web dashboard. It is proprietary and not part of this repository, so it is not something you can host. You do not need it. See [Settings without a dashboard](#settings-without-a-dashboard).
## Before you start
You need:
- A Linux server (or any machine that is always on) with [Docker and Docker Compose](https://docs.docker.com/engine/install/). The bot itself is a small static Go binary and needs little. Lavalink is a Java program and needs noticeably more memory, so size the server for it if you want music.
- A Discord account that can create an application.
- A way to reach the server over SSH, and a firewall that does not expose PostgreSQL.
You do not need a domain name or an open port. The bot only makes outgoing connections to Discord (and to Lavalink and Twitch if you use them).
## 1. Create your Discord application
Follow [step 1 of Getting started](getting-started.md#1-create-the-discord-application). You end up with:
- an **application ID** (`APP_ID`),
- a **bot token** (`BOT_TOKEN`), which is a password: never commit it or paste it anywhere public,
- the **Server Members** and **Message Content** privileged intents switched on.
### The invite link
Invite your bot with this link, replacing `YOUR_APP_ID`:
```
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot%20applications.commands&permissions=1460577627318
```
The number is the set of permissions the bot uses across all its modules: View Channels, Send Messages, Embed Links, Attach Files, Read Message History, Use External Emojis, Manage Messages, Manage Roles, Manage Channels, Manage Server, Kick Members, Ban Members, Moderate Members, View Audit Log, Create Private Threads, Send Messages in Threads, Manage Threads, Connect, Speak and Move Members. If you never use a module you can leave its permissions out. The [permission table](getting-started.md#1-create-the-discord-application) says which module needs which.
After inviting, drag the bot's role **above** the roles it should manage and the members it should moderate. Discord does not let a bot change anyone whose highest role is above its own.
## 2. Get the code
```bash
git clone <this repository> velox-bot
cd velox-bot
```
## 3. Configure
Create a file called `.env` next to `docker-compose.yml`:
```bash
BOT_TOKEN=paste-your-bot-token
APP_ID=paste-your-application-id
# Register commands in one server (appear at once). Leave empty to register
# them globally, which Discord can take up to an hour to show.
GUILD_ID=paste-a-server-id
# Used for Twitch live alerts. Required to start, even if you never use them
# (any value works then). See "Twitch" below.
TWITCH_CLIENT_ID=unused
```
You do not set `DB_HOST` here: the Compose file points the bot at its own database. **Change the database password**, though. In `docker-compose.yml`, replace `velox_pwd` in two places (the `db` service's `POSTGRES_PASSWORD` and the default `DB_HOST` of the `bot` service), or set `DB_HOST` in `.env` yourself:
```bash
DB_HOST=postgres://velox:A-LONG-RANDOM-PASSWORD@db:5432/velox?sslmode=disable
```
and set the same password for `POSTGRES_PASSWORD`. Then remove the `ports: - "5432:5432"` lines of the `db` service so the database is only reachable from the bot.
`.env` holds secrets. Keep it out of version control (it is already in `.gitignore`) and readable only by you: `chmod 600 .env`.
## 4. Start the database and load the schema
```bash
docker compose up -d db
docker compose exec -T db psql -U velox -d velox < schema.sql
```
The schema file can be run again at any time, it only creates what is missing.
## 5. Start the bot
```bash
docker compose up -d --build bot
docker compose logs -f bot
```
You should see the bot connect and no errors. In your server, type `/ping`. If the commands do not appear, see [Troubleshooting](troubleshooting.md#slash-commands-do-not-appear).
The Compose file restarts the bot automatically (`restart: unless-stopped`), including after a reboot of the server, as long as Docker starts at boot (`sudo systemctl enable docker`).
## 6. Turn modules on
Nothing is on by default. Follow [Turn modules on](getting-started.md#5-turn-modules-on).
## Music (optional)
Music needs a [Lavalink](https://github.com/lavalink-devs/Lavalink) server. Add it to `docker-compose.yml`:
```yaml
lavalink:
image: ghcr.io/lavalink-devs/lavalink:4
container_name: velox-lavalink
restart: unless-stopped
environment:
LAVALINK_SERVER_PASSWORD: ${LAVALINK_PASSWORD}
# Not published: only the bot, on the same Compose network, needs it.
```
and put the password in `.env`:
```bash
LAVALINK_PASSWORD=another-long-random-password
```
The Compose file already defaults `LAVALINK_HOST` to `ws://lavalink:2333` for the bot, so the two find each other. Restart with `docker compose up -d`.
Two things to know:
- Lavalink 4 does not play from every source out of the box. Playing from YouTube needs a source plugin configured in Lavalink. See Lavalink's own documentation for what is current, since it changes.
- Members need a role named exactly **DJ** to use the music commands.
## Twitch alerts (optional)
Twitch live alerts check streamers every 30 seconds, using Twitch's web GraphQL endpoint with the client ID you set in `TWITCH_CLIENT_ID`. That is not Twitch's official API and Twitch can change or block it without notice. If you do not use alerts, leave the value as is and never set a notification channel.
## Keeping it running
**Updates.**
```bash
git pull
docker compose up -d --build bot
```
Read the release notes first. Releases that add tables need `schema.sql` run again (it only creates what is missing, and is safe to repeat) before the bot restarts, and a release that changes an existing table says so in its notes. Slash commands update themselves when the bot starts.
**Backups.** Everything the bot stores is in PostgreSQL:
```bash
docker compose exec -T db pg_dump -U velox velox > velox-backup-$(date +%F).sql
```
Run it on a schedule and keep copies off the server. To restore into an empty database, load the file with `psql`.
**Logs.** `docker compose logs -f bot`. Docker keeps logs without limit by default. Set a log size limit for the `bot` service (`logging` options in Compose) if the server is small.
**Rotating the token.** If the bot token ever leaks, reset it in the developer portal at once, put the new one in `.env` and run `docker compose up -d bot`.
## Growing past 100 servers
Discord requires you to **verify** your application once the bot is in 100 servers, and to be approved for the privileged intents (Server Members, Message Content). Plan for that if you intend to run a public bot. For a bot in a few servers you manage yourself, nothing is needed.
## Settings without a dashboard
The bot works fully on its own, and every command in the [Command reference](commands.md) does. Most things are set with slash commands. A few settings that the hosted Velox sets on its dashboard have no command, so you set them in the database. They are plain rows, described in `schema.sql`.
**Security thresholds and filters.** The defaults are in `security_settings`. To change a server's settings, insert or update its row (the columns are documented in `schema.sql`, and invalid values are refused):
```sql
INSERT INTO security_settings (guild_id, raid_join_count, raid_join_seconds)
VALUES (123456789012345678, 15, 10)
ON CONFLICT (guild_id) DO UPDATE
SET raid_join_count = EXCLUDED.raid_join_count, raid_join_seconds = EXCLUDED.raid_join_seconds;
```
Protections are switched on with `/security toggle`, not with SQL.
**Beta features.** Beta features are off until you choose a beta server and a Beta Tester role. Run this once, with your own IDs (the bot reads it every 30 seconds):
```sql
INSERT INTO beta_settings (id, guild_id, role_id) VALUES (TRUE, 123456789012345678, 234567890123456789)
ON CONFLICT (id) DO UPDATE SET guild_id = EXCLUDED.guild_id, role_id = EXCLUDED.role_id;
```
To let another server's beta features on, add it (its owner's agreement is yours to arrange):
```sql
INSERT INTO beta_guilds (guild_id, enabled_by) VALUES (345678901234567890, 123456789012345678)
ON CONFLICT DO NOTHING;
```
See [Beta features](beta-features.md) for what this does.
**Queue and panel requests.** The `*_commands` tables let another program ask the bot to skip a song, post the ticket panel or end a raid. See [Database](database.md#dashboard-to-bot-settings-and-requests) if you want to build something on them.
## Making it your own
- Change the bot's name and avatar in the developer portal.
- `/help` is hand-written text in `internal/commands/help/help.go`. Its author link points at the original project, so change it to your own repository.
- The name "Velox" in this code is just a name. You may rename it, and under the license you must keep the copyright notices (`LICENSE`, `NOTICE`) and say what you changed. Please do not present your fork as the original project.
## Something went wrong
[Troubleshooting](troubleshooting.md) covers the usual problems: the bot not starting, commands not appearing, permissions, and music.
+4 -4
View File
@@ -40,17 +40,17 @@ Most failures are about the bot's role:
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
## A dashboard or tool's requests have no effect
The dashboard talks to the bot through the database (see [Database](database.md#dashboard-to-bot-settings-and-requests)).
A dashboard (the hosted Velox has a proprietary one) or any tool of yours 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**.
- Check that the tool and the bot 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.
Go through [Beta features](beta-features.md): is the Beta Tester role set in `beta_settings`? Is the person in the **beta server**, and do they have the role there? Is the server where they use it the beta server, or is it listed in `beta_guilds`? Has 30 seconds passed? `/beta` (owner only) shows how access is set up.
## Getting more detail
+105 -35
View File
@@ -1,16 +1,30 @@
// Package beta gates features that are still being tried out. A beta feature
// works only for members of the beta server who have the Beta Tester role:
// the server comes from BETA_GUILD_ID and the role from BETA_ROLE_ID, both set
// by the bot's owner in its environment, so nobody can change them from
// Discord.
// Package beta gates features that are still being tried out.
//
// Everything fails closed. With no role configured, or an ID that isn't a
// Discord ID, nobody gets access.
// A beta feature works for a beta tester, in a server where beta features are
// on:
//
// - A beta tester is a member of the beta server who has the Beta Tester
// role. Both are chosen by the bot's owner on the dashboard.
// - Beta features are on in the beta server itself, and in every other
// server whose owner opted in on the dashboard. A feature that can act on
// a whole server never reaches a server that didn't ask for it.
//
// Being a tester is about the person, so it is looked up in the beta server
// even when they use a feature somewhere else (see SetRolesLookup). Beta
// never replaces a feature's own permission checks: a beta command that needs
// Manage Server still needs it.
//
// Features that act without anyone running a command (an event handler, a
// background job) have no person to check, so they use ServerEnabled only.
//
// Everything fails closed. With no role configured, an ID that isn't a
// Discord ID, or a lookup that fails, nobody gets access.
package beta
import (
"log"
"regexp"
"slices"
"strings"
"sync/atomic"
@@ -20,32 +34,43 @@ import (
// Handler is a slash command handler.
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
type settings struct{ guildID, roleID string }
type settings struct {
guildID, roleID string
servers map[string]bool // opted-in servers, besides the beta server
}
var current atomic.Pointer[settings]
var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
// idOrEmpty keeps a Discord ID and drops anything else, so a typo in the
// environment turns beta features off instead of matching something odd.
// idOrEmpty keeps a Discord ID and drops anything else, so a bad value turns
// beta features off instead of matching something odd.
func idOrEmpty(name, v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if !snowflake.MatchString(v) {
log.Printf("beta: %s %q isn't a Discord ID; beta features stay locked", name, v)
log.Printf("beta: %s %q isn't a Discord ID; ignoring it", name, v)
return ""
}
return v
}
// Configure sets the beta server and the Beta Tester role. Call it once at
// startup.
func Configure(guildID, roleID string) {
// Configure sets the beta server, the Beta Tester role and the servers that
// opted in. It replaces everything set before, so call it with the full
// picture each time.
func Configure(guildID, roleID string, servers []string) {
set := make(map[string]bool, len(servers))
for _, id := range servers {
if id = idOrEmpty("beta server", id); id != "" {
set[id] = true
}
}
current.Store(&settings{
guildID: idOrEmpty("BETA_GUILD_ID", guildID),
roleID: idOrEmpty("BETA_ROLE_ID", roleID),
guildID: idOrEmpty("beta server", guildID),
roleID: idOrEmpty("beta role", roleID),
servers: set,
})
}
@@ -56,45 +81,90 @@ func get() settings {
return settings{}
}
// GuildID is the beta server, or "" when beta features are off.
// RolesFunc returns a member's role IDs in a server, as Discord has them now,
// or an error (including "not in that server").
type RolesFunc func(guildID, userID string) ([]string, error)
var lookup atomic.Pointer[RolesFunc]
// SetRolesLookup tells the package how to find someone's roles in the beta
// server when they use a feature in another server. Without it, testers only
// qualify in the beta server itself.
func SetRolesLookup(f RolesFunc) { lookup.Store(&f) }
// GuildID is the beta server, or "" when it isn't set.
func GuildID() string { return get().guildID }
// Servers lists the servers that opted in, besides the beta server, in a
// stable order.
func Servers() []string {
c := get()
out := make([]string, 0, len(c.servers))
for id := range c.servers {
if id != c.guildID {
out = append(out, id)
}
}
slices.Sort(out)
return out
}
// ServerEnabled reports whether beta features are on in a server: the beta
// server, or one whose owner opted in. Use it alone for anything no person
// triggers.
func ServerEnabled(guildID string) bool {
c := get()
return guildID != "" && (guildID == c.guildID || c.servers[guildID])
}
// Status describes where a member stands, for the /beta command.
type Status struct {
InBetaGuild bool // the interaction came from the beta server
ServerEnabled bool // beta features are on in this server
RoleConfigured bool // the owner has set a Beta Tester role
HasRole bool // the member has it
HasRole bool // the member is a tester
}
// StatusOf reports a member's standing in the guild they're acting in.
// StatusOf reports a member's standing in the server they're acting in.
func StatusOf(guildID string, member *discordgo.Member) Status {
c := get()
st := Status{
InBetaGuild: c.guildID != "" && guildID == c.guildID,
ServerEnabled: ServerEnabled(guildID),
RoleConfigured: c.roleID != "",
}
if st.InBetaGuild && st.RoleConfigured && member != nil {
for _, r := range member.Roles {
if r == c.roleID {
st.HasRole = true
break
}
}
if st.ServerEnabled && st.RoleConfigured && member != nil {
st.HasRole = isTester(c, guildID, member)
}
return st
}
// Allowed reports whether a member may use beta features in guildID: it has
// to be the beta server, and the member has to have the Beta Tester role.
// Use it for anything that isn't a command, such as an event handler.
// isTester checks the Beta Tester role in the beta server. In the beta server
// the roles come with the interaction. Anywhere else they are looked up.
func isTester(c settings, guildID string, member *discordgo.Member) bool {
if guildID == c.guildID {
return slices.Contains(member.Roles, c.roleID)
}
f := lookup.Load()
if f == nil || *f == nil || member.User == nil || member.User.ID == "" {
return false
}
roles, err := (*f)(c.guildID, member.User.ID)
if err != nil {
return false
}
return slices.Contains(roles, c.roleID)
}
// Allowed reports whether a member may use beta features in guildID: beta
// features have to be on there, and the member has to be a tester. Use it for
// anything a person triggers that isn't a command, such as a button.
func Allowed(guildID string, member *discordgo.Member) bool {
st := StatusOf(guildID, member)
return st.InBetaGuild && st.RoleConfigured && st.HasRole
return st.ServerEnabled && st.RoleConfigured && st.HasRole
}
// AllowedInteraction is Allowed for an interaction. A DM has no member, so it
// never qualifies. The member and their roles come from Discord with the
// interaction, not from anything the user typed.
// never qualifies. The member comes from Discord with the interaction, not
// from anything the user typed.
func AllowedInteraction(i *discordgo.InteractionCreate) bool {
return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member)
}
@@ -119,7 +189,7 @@ var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg str
})
}
const denied = "This feature is in beta and isn't available to you yet."
const denied = "This feature is in beta and isn't available to you or this server yet."
// Guard is for the start of a beta feature's handler, and for its component,
// modal and autocomplete handlers too (the command's gate doesn't cover
+138 -13
View File
@@ -1,35 +1,54 @@
package beta
import (
"errors"
"testing"
"github.com/bwmarrin/discordgo"
)
const (
betaGuild = "1170934850157547600"
betaGuild = "700000000000000001"
otherGuild = "999000000000000001"
testerRol = "555000000000000001"
testerID = "777000000000000001"
)
func member(roles ...string) *discordgo.Member { return &discordgo.Member{Roles: roles} }
func member(roles ...string) *discordgo.Member {
return &discordgo.Member{Roles: roles, User: &discordgo.User{ID: testerID}}
}
func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate {
return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}}
}
func TestAllowed(t *testing.T) {
// useLookup makes the beta server say who holds which roles, as Discord would.
func useLookup(t *testing.T, rolesByUser map[string][]string, err error) *int {
t.Helper()
calls := 0
SetRolesLookup(func(guildID, userID string) ([]string, error) {
calls++
if guildID != betaGuild {
t.Errorf("the lookup must ask the beta server, asked %q", guildID)
}
return rolesByUser[userID], err
})
t.Cleanup(func() { lookup.Store(nil) })
return &calls
}
func TestAllowedInTheBetaServer(t *testing.T) {
cases := []struct {
name string
guild, role string // configuration
inGuild string
member *discordgo.Member
want bool
inBeta, hasRole bool
on, hasRole bool
}{
{"tester in the beta server", betaGuild, testerRol, betaGuild, member("1", testerRol), true, true, true},
{"member without the role", betaGuild, testerRol, betaGuild, member("1", "2"), false, true, false},
{"member with no roles at all", betaGuild, testerRol, betaGuild, member(), false, true, false},
{"the role in another server doesn't count", betaGuild, testerRol, "999000000000000001", member(testerRol), false, false, false},
{"a DM has no member", betaGuild, testerRol, "", nil, false, false, false},
{"a nil member in the beta server", betaGuild, testerRol, betaGuild, nil, false, true, false},
{"no role configured: nobody", betaGuild, "", betaGuild, member(testerRol, ""), false, true, false},
@@ -41,7 +60,7 @@ func TestAllowed(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Configure(tc.guild, tc.role)
Configure(tc.guild, tc.role, nil)
if got := Allowed(tc.inGuild, tc.member); got != tc.want {
t.Errorf("Allowed = %v, want %v", got, tc.want)
}
@@ -49,16 +68,113 @@ func TestAllowed(t *testing.T) {
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
}
st := StatusOf(tc.inGuild, tc.member)
if st.InBetaGuild != tc.inBeta || st.HasRole != tc.hasRole {
t.Errorf("StatusOf = %+v, want in beta server %v, has role %v", st, tc.inBeta, tc.hasRole)
if st.ServerEnabled != tc.on || st.HasRole != tc.hasRole {
t.Errorf("StatusOf = %+v, want server on %v, tester %v", st, tc.on, tc.hasRole)
}
})
}
}
func TestOptedInServers(t *testing.T) {
roles := map[string][]string{testerID: {"1", testerRol}}
t.Run("a tester in an opted-in server is allowed", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
calls := useLookup(t, roles, nil)
if !Allowed(otherGuild, member()) { // roles in this server don't matter
t.Error("tester in an opted-in server should be allowed")
}
if *calls != 1 {
t.Errorf("the beta server should be asked once, was asked %d times", *calls)
}
})
t.Run("the role in the other server doesn't count", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
useLookup(t, map[string][]string{}, nil)
if Allowed(otherGuild, member(testerRol)) {
t.Error("holding a role with that ID in another server must not make anyone a tester")
}
})
t.Run("a server that didn't opt in is refused, and nothing is looked up", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
calls := useLookup(t, roles, nil)
if Allowed("123000000000000001", member()) {
t.Error("a server that didn't opt in must be refused")
}
if *calls != 0 {
t.Error("no lookup for a server where beta is off")
}
})
t.Run("a failing lookup fails closed", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
useLookup(t, roles, errors.New("Unknown Member"))
if Allowed(otherGuild, member()) {
t.Error("an error from Discord must mean no")
}
})
t.Run("no lookup set: testers only qualify in the beta server", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
lookup.Store(nil)
if Allowed(otherGuild, member()) {
t.Error("without a lookup there is no way to know")
}
if !Allowed(betaGuild, member(testerRol)) {
t.Error("the beta server doesn't need the lookup")
}
})
t.Run("a user without an ID can't be looked up", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
useLookup(t, roles, nil)
if Allowed(otherGuild, &discordgo.Member{}) || Allowed(otherGuild, &discordgo.Member{User: &discordgo.User{}}) {
t.Error("no user, no access")
}
})
t.Run("no role configured: nobody, even when the server opted in", func(t *testing.T) {
Configure(betaGuild, "", []string{otherGuild})
useLookup(t, map[string][]string{testerID: {""}}, nil)
if Allowed(otherGuild, member()) {
t.Error("without a role there are no testers")
}
})
t.Run("bad IDs in the list are dropped", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild, "not-an-id", "", " 5 "})
got := Servers()
if len(got) != 2 || got[0] != "5" || got[1] != otherGuild {
t.Errorf("Servers = %v", got)
}
})
t.Run("the beta server isn't listed twice", func(t *testing.T) {
Configure(betaGuild, testerRol, []string{betaGuild, otherGuild})
if got := Servers(); len(got) != 1 || got[0] != otherGuild {
t.Errorf("Servers = %v", got)
}
})
}
func TestServerEnabledNeedsNoPerson(t *testing.T) {
Configure(betaGuild, testerRol, []string{otherGuild})
for guild, want := range map[string]bool{betaGuild: true, otherGuild: true, "123": false, "": false} {
if got := ServerEnabled(guild); got != want {
t.Errorf("ServerEnabled(%q) = %v, want %v", guild, got, want)
}
}
current.Store(nil)
if ServerEnabled(betaGuild) {
t.Error("before Configure nothing is on")
}
}
func TestNotConfiguredAtAll(t *testing.T) {
current.Store(nil)
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" {
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" || len(Servers()) != 0 {
t.Error("before Configure nothing is allowed and there's no beta server")
}
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
@@ -67,7 +183,8 @@ func TestNotConfiguredAtAll(t *testing.T) {
}
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
Configure(betaGuild, testerRol)
Configure(betaGuild, testerRol, []string{otherGuild})
useLookup(t, map[string][]string{}, nil) // nobody holds the role in the beta server
var answered []string
orig := respond
respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) {
@@ -85,7 +202,8 @@ func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
for name, i := range map[string]*discordgo.InteractionCreate{
"no role": interaction(betaGuild, member("1")),
"wrong server": interaction("999000000000000001", member(testerRol)),
"opted-in, not a tester": interaction(otherGuild, member(testerRol)),
"server not opted in": interaction("123000000000000001", member(testerRol)),
"DM": interaction("", nil),
} {
before := ran
@@ -94,13 +212,20 @@ func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
t.Errorf("%s: the handler must not run", name)
}
}
if len(answered) != 3 || answered[0] != denied {
if len(answered) != 4 || answered[0] != denied {
t.Errorf("each refusal is answered with the beta message: %v", answered)
}
useLookup(t, map[string][]string{testerID: {testerRol}}, nil)
before := ran
h(nil, interaction(otherGuild, member()))
if ran != before+1 {
t.Error("a tester in an opted-in server runs the handler")
}
}
func TestGuardOnItsOwn(t *testing.T) {
Configure(betaGuild, testerRol)
Configure(betaGuild, testerRol, nil)
n := 0
orig := respond
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
+240
View File
@@ -0,0 +1,240 @@
package bot
import (
"context"
"encoding/json"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"velox-bot/internal/beta"
"velox-bot/internal/commands"
"github.com/bwmarrin/discordgo"
)
const (
appID = "100"
betaServer = "700000000000000001"
optedIn = "222"
leftServer = "333"
)
// fakeDiscord answers the application-command calls the beta sync makes, from
// memory, and records what it was asked to change.
var registerOnce sync.Once
type fakeDiscord struct {
mu sync.Mutex
cmds map[string][]*discordgo.ApplicationCommand // guild -> commands
failures map[string]int // guild -> status for every call
nextID int
created []string // "guild/name"
deleted []string // "guild/name"
}
var cmdPath = regexp.MustCompile(`/applications/` + appID + `/guilds/(\d+)/commands(?:/(\d+))?$`)
func (f *fakeDiscord) RoundTrip(r *http.Request) (*http.Response, error) {
f.mu.Lock()
defer f.mu.Unlock()
reply := func(code int, v any) (*http.Response, error) {
b, _ := json.Marshal(v)
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
}
m := cmdPath.FindStringSubmatch(r.URL.Path)
if m == nil {
return reply(404, map[string]any{"message": "unknown route"})
}
guild, id := m[1], m[2]
if code := f.failures[guild]; code != 0 {
return reply(code, map[string]any{"message": "failure", "code": 0})
}
switch r.Method {
case http.MethodGet:
return reply(200, f.cmds[guild])
case http.MethodPost:
var c discordgo.ApplicationCommand
_ = json.NewDecoder(r.Body).Decode(&c)
f.nextID++
c.ID = strconv.Itoa(1000 + f.nextID)
f.cmds[guild] = append(f.cmds[guild], &c)
f.created = append(f.created, guild+"/"+c.Name)
return reply(200, c)
case http.MethodDelete:
kept := f.cmds[guild][:0]
for _, c := range f.cmds[guild] {
if c.ID == id {
f.deleted = append(f.deleted, guild+"/"+c.Name)
continue
}
kept = append(kept, c)
}
f.cmds[guild] = kept
return reply(204, nil)
}
return reply(405, nil)
}
func (f *fakeDiscord) names(guild string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.cmds[guild] {
out = append(out, c.Name)
}
sort.Strings(out)
return out
}
type fakeStore struct {
registered map[string]bool
servers []string
failWrite bool
}
func (s *fakeStore) GetBetaSettings(context.Context) (string, string, error) {
return betaServer, "555", nil
}
func (s *fakeStore) ListBetaGuilds(context.Context) ([]string, error) { return s.servers, nil }
func (s *fakeStore) ListBetaRegistered(context.Context) ([]string, error) {
var out []string
for id := range s.registered {
out = append(out, id)
}
return out, nil
}
func (s *fakeStore) SetBetaRegistered(_ context.Context, id string, on bool) error {
if s.failWrite {
return io.ErrUnexpectedEOF
}
if on {
s.registered[id] = true
} else {
delete(s.registered, id)
}
return nil
}
func newTestBot(t *testing.T, d *fakeDiscord) *Bot {
t.Helper()
sess, err := discordgo.New("Bot x")
if err != nil {
t.Fatal(err)
}
sess.Client = &http.Client{Transport: d}
sess.MaxRestRetries = 0
return &Bot{
Session: sess,
AppID: appID,
betaRegistered: map[string]bool{},
betaKnown: map[string]bool{},
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
func TestBetaCommandsFollowTheServers(t *testing.T) {
registerOnce.Do(func() {
commands.RegisterBeta(&discordgo.ApplicationCommand{Name: "betatest", Description: "test"}, func(*discordgo.Session, *discordgo.InteractionCreate) {})
})
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
// A server that opted out while the bot was offline still has them,
// next to a command that isn't a beta one.
leftServer: {{ID: "1", Name: "betatest"}, {ID: "2", Name: "ping"}},
// A stale command in the beta server, which the bot cleans up.
betaServer: {{ID: "3", Name: "oldthing"}},
}, failures: map[string]int{}}
store := &fakeStore{registered: map[string]bool{leftServer: true}, servers: []string{optedIn}}
b := newTestBot(t, d)
ctx := context.Background()
beta.Configure(betaServer, "555", store.servers)
b.syncBetaCommands(ctx, store)
if got := d.names(leftServer); len(got) != 1 || got[0] != "ping" {
t.Errorf("the beta command is removed from the server that left, nothing else: %v", got)
}
if got := d.names(optedIn); len(got) != 1 || got[0] != "betatest" {
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
}
got := d.names(betaServer)
if !contains(got, "beta") || !contains(got, "betatest") || contains(got, "oldthing") {
t.Errorf("the beta server gets /beta and the beta commands, and loses stale ones: %v", got)
}
if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("the database remembers where the commands are now: %v", store.registered)
}
// The server opts out.
store.servers = nil
beta.Configure(betaServer, "555", store.servers)
b.syncBetaCommands(ctx, store)
if got := d.names(optedIn); len(got) != 0 {
t.Errorf("an opted-out server loses them within one round: %v", got)
}
if store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("only the beta server stays remembered: %v", store.registered)
}
}
func TestBetaCleanupIsRetriedAndGivesUpWhenTheBotIsGone(t *testing.T) {
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
leftServer: {{ID: "1", Name: "betatest"}},
}, failures: map[string]int{leftServer: 500}}
store := &fakeStore{registered: map[string]bool{leftServer: true}}
b := newTestBot(t, d)
ctx := context.Background()
beta.Configure(betaServer, "555", nil)
b.syncBetaCommands(ctx, store)
if !store.registered[leftServer] {
t.Fatal("a failed cleanup is remembered so it is tried again")
}
d.failures[leftServer] = 0
b.syncBetaCommands(ctx, store)
if store.registered[leftServer] || len(d.names(leftServer)) != 0 {
t.Errorf("the retry cleans up: %v, %v", store.registered, d.names(leftServer))
}
// The bot was removed from a server: nothing to clean, don't try forever.
store.registered["444"] = true
b.betaKnown["444"] = true
d.failures["444"] = 403
b.syncBetaCommands(ctx, store)
if store.registered["444"] {
t.Error("a server the bot can't reach any more is forgotten")
}
}
// Close used to loop over a list that started with empty entries and read
// their IDs, so shutting down crashed. It must just disconnect, and must not
// touch the registered commands.
func TestCloseDoesNotTouchCommands(t *testing.T) {
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
"1": {{ID: "7", Name: "ping"}},
}, failures: map[string]int{}}
b := newTestBot(t, d)
b.GuildID = "1"
b.Commands = []*discordgo.ApplicationCommand{{Name: "ping"}}
if err := b.Close(); err != nil {
t.Fatalf("Close on a bot that never connected: %v", err)
}
if len(d.deleted) != 0 {
t.Errorf("Close must not delete commands: %v", d.deleted)
}
}
+175 -43
View File
@@ -2,10 +2,14 @@ package bot
import (
"context"
"errors"
"log"
"net/http"
"sync"
"time"
"velox-bot/internal/beta"
"velox-bot/internal/commands"
"velox-bot/internal/commands/betacmd"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
@@ -21,13 +25,15 @@ type Bot struct {
AppID string
GuildID string
Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand
Services *services.Services
MusicRepo *musicrepo.Repo
DBHost string
LavalinkHost string
LavalinkPass string
betaGuild string // where beta commands are registered right now
betaRegistered map[string]bool // servers where this run registered the beta commands
betaKnown map[string]bool // servers where they were registered, now or in an earlier run
betaLoaded bool
betaRoles sync.Map // "guild:user" -> cachedRoles
}
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
@@ -50,6 +56,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
discordgo.IntentsDirectMessages
return &Bot{
betaRegistered: map[string]bool{},
betaKnown: map[string]bool{},
Session: session,
AppID: appID,
GuildID: guildID,
@@ -105,14 +113,12 @@ func (b *Bot) Start() error {
}()
}
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
// Registering is an upsert by name: existing commands are updated in
// place and keep their IDs (links like </help:ID> keep working).
for _, cmd := range b.Commands {
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
if err != nil {
if _, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd); err != nil {
log.Printf("Cannot create '%v' command: %v", cmd.Name, err)
continue
}
b.registeredCommands = append(b.registeredCommands, created)
}
b.Session.AddHandler(commands.HandleInteraction)
@@ -206,22 +212,37 @@ func (b *Bot) Start() error {
// made on the dashboard.
const betaPollEvery = 30 * time.Second
// WatchBeta keeps the bot's idea of who the beta testers are in step with the
// settings the owner saves on the dashboard. When the beta server changes, the
// beta commands move with it. If the settings can't be read, the last known
// ones stay in force (and before the first read, beta stays locked).
func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, err error)) {
// BetaStore is where the bot reads the beta settings the dashboard writes,
// and remembers which servers it registered beta commands in.
type BetaStore interface {
GetBetaSettings(ctx context.Context) (guildID, roleID string, err error)
ListBetaGuilds(ctx context.Context) ([]string, error)
ListBetaRegistered(ctx context.Context) ([]string, error)
SetBetaRegistered(ctx context.Context, guildID string, registered bool) error
}
// WatchBeta keeps the bot's idea of who the beta testers are, and which
// servers opted in, in step with the dashboard. When the list of servers
// changes, the beta commands follow: they appear in servers that opted in and
// are removed from servers that opted out, also if that happened while the bot
// was offline (it remembers where it registered them). If the settings can't
// be read, the last known ones stay in force (and before the first read, beta
// stays off).
func (b *Bot) WatchBeta(ctx context.Context, store BetaStore) {
beta.SetRolesLookup(b.betaMemberRoles)
sync := func() {
guildID, roleID, err := load(ctx)
guildID, roleID, err := store.GetBetaSettings(ctx)
if err != nil {
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
return
}
beta.Configure(guildID, roleID)
if now := beta.GuildID(); now != b.betaGuild {
b.registerBetaCommands(b.betaGuild, now)
b.betaGuild = now
servers, err := store.ListBetaGuilds(ctx)
if err != nil {
log.Printf("beta: cannot read the opted-in servers, keeping the last ones: %v", err)
return
}
beta.Configure(guildID, roleID, servers)
b.syncBetaCommands(ctx, store)
}
sync()
go func() {
@@ -238,48 +259,120 @@ func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID
}()
}
// registerBetaCommands puts the beta commands in the beta server, and only
// there, so nobody else sees them. Commands this bot left there that are no
// longer beta (or no longer exist) are removed. When the beta server is also
// the bot's main server, its regular commands are kept. If the beta server
// changed, the beta commands are taken out of the old one.
func (b *Bot) registerBetaCommands(previous, guildID string) {
if previous != "" && previous != guildID {
isBeta := map[string]bool{}
for _, cmd := range commands.BetaCommands {
isBeta[cmd.Name] = true
type cachedRoles struct {
roles []string
at time.Time
}
if old, err := b.Session.ApplicationCommands(b.AppID, previous); err != nil {
log.Printf("beta: cannot list commands in the previous server: %v", err)
} else {
for _, cmd := range old {
if isBeta[cmd.Name] {
if err := b.Session.ApplicationCommandDelete(b.AppID, previous, cmd.ID); err != nil {
log.Printf("beta: cannot remove '%v' from the previous server: %v", cmd.Name, err)
// betaRolesTTL is how long a member's roles in the beta server are remembered
// when they had to be fetched from Discord.
const betaRolesTTL = 30 * time.Second
// betaMemberRoles answers beta.SetRolesLookup: someone's roles in the beta
// server. The gateway cache is used when it has them (it is kept current by
// member updates), otherwise Discord is asked, at most once per
// betaRolesTTL per person.
func (b *Bot) betaMemberRoles(guildID, userID string) ([]string, error) {
if m, err := b.Session.State.Member(guildID, userID); err == nil && m != nil {
return m.Roles, nil
}
key := guildID + ":" + userID
if v, ok := b.betaRoles.Load(key); ok {
if c := v.(cachedRoles); time.Since(c.at) < betaRolesTTL {
return c.roles, nil
}
}
m, err := b.Session.GuildMember(guildID, userID)
if err != nil {
return nil, err
}
b.betaRoles.Store(key, cachedRoles{roles: m.Roles, at: time.Now()})
return m.Roles, nil
}
}
if guildID == "" {
// syncBetaCommands makes the beta commands exist in exactly the servers where
// beta features are on (the beta server and the ones that opted in), and
// nowhere else. The servers it registered them in are remembered in the
// database, so a server that opted out while the bot was offline is cleaned up
// at the next start. A step that failed is retried on the next round.
func (b *Bot) syncBetaCommands(ctx context.Context, store BetaStore) {
if !b.betaLoaded {
known, err := store.ListBetaRegistered(ctx)
if err != nil {
log.Printf("beta: cannot read where beta commands were registered: %v", err)
return
}
for _, id := range known {
b.betaKnown[id] = true
}
b.betaLoaded = true
}
want := map[string]bool{}
if g := beta.GuildID(); g != "" {
want[g] = true
}
for _, id := range beta.Servers() {
want[id] = true
}
for id := range b.betaKnown {
if want[id] {
continue
}
if !b.removeBetaCommands(id) {
continue
}
if err := store.SetBetaRegistered(ctx, id, false); err != nil {
log.Printf("beta: cannot forget server %s: %v", id, err)
continue
}
delete(b.betaKnown, id)
delete(b.betaRegistered, id)
}
for id := range want {
if b.betaRegistered[id] || !b.addBetaCommands(id) {
continue
}
b.betaRegistered[id] = true
if err := store.SetBetaRegistered(ctx, id, true); err != nil {
log.Printf("beta: cannot remember server %s: %v", id, err)
continue
}
b.betaKnown[id] = true
}
}
// addBetaCommands registers the beta commands in a server. In the beta server
// it also removes commands this bot left there that no longer exist (when the
// beta server is also the bot's main server, its regular commands are kept).
// /beta is for the bot's owner and is only registered in the beta server. It
// reports whether everything was registered.
func (b *Bot) addBetaCommands(guildID string) bool {
isBetaServer := guildID == beta.GuildID()
keep := map[string]bool{}
for _, cmd := range b.Commands {
if b.GuildID == guildID {
keep[cmd.Name] = true
}
}
ok := true
for _, cmd := range commands.BetaCommands {
if cmd.Name == betacmd.Beta.Name && !isBetaServer {
continue
}
keep[cmd.Name] = true
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
log.Printf("beta: cannot create '%v' command: %v", cmd.Name, err)
log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
ok = false
}
}
if !isBetaServer {
return ok
}
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
if err != nil {
log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err)
return
return ok
}
for _, cmd := range existing {
if keep[cmd.Name] {
@@ -289,13 +382,52 @@ func (b *Bot) registerBetaCommands(previous, guildID string) {
log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err)
}
}
return ok
}
// goneFromServer reports whether an error from Discord means the bot isn't in
// that server any more (or it no longer exists), so there is nothing left to
// clean up there.
func goneFromServer(err error) bool {
var rest *discordgo.RESTError
if errors.As(err, &rest) && rest.Response != nil {
return rest.Response.StatusCode == http.StatusForbidden || rest.Response.StatusCode == http.StatusNotFound
}
return false
}
// removeBetaCommands takes the beta commands out of a server that is no longer
// part of the beta, and leaves everything else there alone. It reports whether
// the server is clean (or out of reach for good).
func (b *Bot) removeBetaCommands(guildID string) bool {
isBeta := map[string]bool{}
for _, cmd := range commands.BetaCommands {
isBeta[cmd.Name] = true
}
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
if err != nil {
if goneFromServer(err) {
return true
}
log.Printf("beta: cannot list commands in %s: %v", guildID, err)
return false
}
clean := true
for _, cmd := range existing {
if !isBeta[cmd.Name] {
continue
}
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil && !goneFromServer(err) {
log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err)
clean = false
}
}
return clean
}
// Close disconnects from Discord. The slash commands are left registered on
// purpose: deleting them on every shutdown would make them disappear during a
// restart, and registering them again would give them new IDs.
func (b *Bot) Close() error {
for _, cmd := range b.registeredCommands {
if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil {
log.Printf("cannot delete '%s' command: %v", cmd.Name, err)
}
}
return b.Session.Close()
}
+4 -3
View File
@@ -6,6 +6,7 @@
package betacmd
import (
"fmt"
"sync"
"velox-bot/internal/beta"
@@ -66,12 +67,12 @@ func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch {
case !isOwner(s, userID):
msg = "Only the bot's owner can use this command."
case !st.InBetaGuild:
msg = "Beta features aren't available here."
case !st.ServerEnabled:
msg = "Beta features are off in this server."
case !st.RoleConfigured:
msg = "No Beta Tester role is set up, so beta features are locked for everyone."
case st.HasRole:
msg = "You're a beta tester: beta features are unlocked for you in this server."
msg = fmt.Sprintf("You're a beta tester. Beta features are on in this server, and in %d other server(s) that opted in.", len(beta.Servers()))
default:
msg = "You don't have the Beta Tester role, so beta features are locked for you."
}
@@ -153,7 +153,7 @@ func TestGetBetaSettings(t *testing.T) {
t.Fatalf("role = %q, want empty", role)
}
_, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 1170934850157547600)`)
_, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 700000000000000001)`)
if err != nil {
t.Fatal(err)
}
@@ -161,8 +161,8 @@ func TestGetBetaSettings(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if g != "1170934850157547600" {
t.Fatalf("g = %q, want %q", g, "1170934850157547600")
if g != "700000000000000001" {
t.Fatalf("g = %q, want %q", g, "700000000000000001")
}
if role != "" {
t.Fatalf("role = %q, want empty", role)
@@ -180,3 +180,79 @@ func TestGetBetaSettings(t *testing.T) {
t.Fatalf("role = %q, want %q", role, "42")
}
}
func TestListBetaGuilds(t *testing.T) {
r, _ := testRepo(t)
db := r.db
ctx := context.Background()
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_guilds (
guild_id BIGINT PRIMARY KEY, enabled_by BIGINT NOT NULL, enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`); err != nil {
t.Fatal(err)
}
const g = 8800000000000004242
_, _ = db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, g)
t.Cleanup(func() { _, _ = db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, g) })
has := func() bool {
ids, err := r.ListBetaGuilds(ctx)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if id == "8800000000000004242" {
return true
}
}
return false
}
if has() {
t.Fatal("not listed before opting in")
}
if _, err := db.ExecContext(ctx, `INSERT INTO beta_guilds (guild_id, enabled_by) VALUES ($1, 1)`, g); err != nil {
t.Fatal(err)
}
if !has() {
t.Fatal("listed after opting in")
}
}
func TestBetaRegistered(t *testing.T) {
r, _ := testRepo(t)
ctx := context.Background()
if _, err := r.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_registered (guild_id BIGINT PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
const g = "8800000000000005151"
_ = r.SetBetaRegistered(ctx, g, false)
t.Cleanup(func() { _ = r.SetBetaRegistered(ctx, g, false) })
has := func() bool {
ids, err := r.ListBetaRegistered(ctx)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if id == g {
return true
}
}
return false
}
if has() {
t.Fatal("not remembered at first")
}
for i := 0; i < 2; i++ { // twice: harmless
if err := r.SetBetaRegistered(ctx, g, true); err != nil {
t.Fatal(err)
}
}
if !has() {
t.Fatal("remembered after registering")
}
if err := r.SetBetaRegistered(ctx, g, false); err != nil || has() {
t.Fatalf("forgotten after removing: %v", err)
}
if err := r.SetBetaRegistered(ctx, "not-a-number", true); err == nil {
t.Error("a bad ID is refused")
}
}
+53
View File
@@ -422,3 +422,56 @@ func (r *Repo) GetBetaSettings(ctx context.Context) (guildID, roleID string, err
}
return guildID, roleID, nil
}
// ListBetaGuilds returns the servers whose owner opted in to beta features
// (set on the dashboard), as ID strings.
func (r *Repo) ListBetaGuilds(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_guilds ORDER BY enabled_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, strconv.FormatInt(id, 10))
}
return out, rows.Err()
}
// ListBetaRegistered returns the servers where the bot registered beta
// commands, so it can clean up after itself even if a server opted out while
// it was offline.
func (r *Repo) ListBetaRegistered(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_registered`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, strconv.FormatInt(id, 10))
}
return out, rows.Err()
}
// SetBetaRegistered records (or forgets) that beta commands exist in a server.
func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered bool) error {
id, err := strconv.ParseInt(guildID, 10, 64)
if err != nil {
return err
}
if registered {
_, err = r.db.ExecContext(ctx, `INSERT INTO beta_registered (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, id)
} else {
_, err = r.db.ExecContext(ctx, `DELETE FROM beta_registered WHERE guild_id = $1`, id)
}
return err
}
+1 -1
View File
@@ -94,7 +94,7 @@ func main() {
defer bot.Close()
// Who the beta testers are is set on the dashboard; follow it.
bot.WatchBeta(context.Background(), settingsRepo.GetBetaSettings)
bot.WatchBeta(context.Background(), settingsRepo)
fmt.Println("Bot is running...")
+15
View File
@@ -84,6 +84,21 @@ CREATE TABLE IF NOT EXISTS beta_settings (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Servers whose owner opted in to beta features (API migration 000022). The
-- beta server itself is always eligible and isn't listed. The bot only reads it.
CREATE TABLE IF NOT EXISTS beta_guilds (
guild_id BIGINT PRIMARY KEY,
enabled_by BIGINT NOT NULL,
enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Servers where the bot registered beta commands, so it can remove them even
-- if the server opted out while the bot was offline. Bot-owned (the API's
-- migration 000022 creates it for a combined database).
CREATE TABLE IF NOT EXISTS beta_registered (
guild_id BIGINT PRIMARY KEY
);
-- A server can have several lobbies (at most 10; the trigger enforces it, and
-- is the same as the API's migration 000020). meeting_settings.lobby_channel_id
-- is the old single-lobby column, no longer read.