Compare commits

..
16 Commits
Author SHA1 Message Date
FernandoJVideira 5657d0789a Merge pull request 'Prepare for open source: license, contributing, self-hosting guide, fix Close()' (#29) from ft/open-source into dev
Reviewed-on: #29
2026-10-23 00:54:45 +00:00
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
FernandoJVideira e2fe30b524 Merge pull request 'v3.5.2: multiple meeting lobbies and beta features' (#25) from ft/lobbies-and-beta into dev
Reviewed-on: #25
2026-10-23 00:08:28 +00:00
FernandoJVideiraandClaude Sonnet 5.5 fefa74e045 Docs: wiki-style documentation in docs/, README as the front page
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 01:07:17 +01:00
FernandoJVideiraandClaude Sonnet 5.5 1ee6280d91 Beta server and role come from the dashboard
The owner sets them on the admin page; the bot polls the shared beta_settings
row (30s) and moves the beta commands if the server changes. BETA_* env vars
are gone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 00:55:09 +01:00
FernandoJVideiraandClaude Sonnet 5.5 a364435bab Multiple meeting lobbies and beta-gated features (3.5.2)
- /meeting add-lobby, remove-lobby, lobbies: up to 10 lobbies per server, capped in the database
- Deleted voice channels are dropped from the lobbies
- Beta features: registered only in the beta server and usable only with the Beta Tester role (BETA_GUILD_ID, BETA_ROLE_ID); fails closed
- /beta (bot owner only) shows how beta access is set up

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 00:38:48 +01:00
FernandoJVideira 136377d81a Merge pull request 'Start Twitch alerts and tickets off on new servers' (#23) from fix/modules-default-off into dev
Reviewed-on: #23
2026-10-21 22:10:37 +00:00
FernandoJVideiraandClaude Opus 5.5 0218676e2d Start Twitch alerts and tickets off on new servers
Every module that can be switched on or off now starts off. A server
with no settings yet reads as off; setting the Twitch alert channel or
running /tickets setup for the first time turns that module on, and
existing settings are left as they are. schema.sql mirrors the API's
migration 000019.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:56:26 +01:00
FernandoJVideira f285367511 Merge pull request 'Ticket panel: dropdown, Create ticket button, orange' (#21) from fix/ticket-panel into dev
Reviewed-on: #21
2026-10-21 10:58:52 +00:00
FernandoJVideiraandClaude Opus 5.5 e10e1f7a86 Use a dropdown and a Create ticket button on the ticket panel, in orange
The panel is now one category dropdown and a Create ticket button
instead of a button per category. Discord doesn't send the dropdown's
value with a button click, so each member's pick is remembered in
memory for 15 minutes (per guild and member, capped). Panels posted
before keep working until they're reposted. Every ticket embed is
orange, closed tickets included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:58:19 +01:00
FernandoJVideira e453a9ba94 Merge pull request 'Add anti-raid and anti-nuke protection' (#19) from ft/antiraid into dev
Reviewed-on: #19
2026-10-20 21:04:12 +00:00
FernandoJVideira 95072fcd53 Merge pull request 'Add support tickets with private threads' (#18) from ft/tickets into dev
Reviewed-on: #18
2026-10-20 21:03:04 +00:00
37 changed files with 3477 additions and 247 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).
+64 -126
View File
@@ -1,149 +1,87 @@
# Velox Bot # Velox Bot
A Discord bot written in Go with a PostgreSQL backing store. 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.
It currently includes: **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.
- Fun commands (`/ping`, `/joke`, `/dice`, `/rps`, …) 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).
- Leveling system (opt-in per server via `/slvl toggle`)
- Meeting rooms (voice lobby → auto-created temporary voice channels)
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
- Projects (list/create projects + helpers)
- Support tickets (a panel with categories, a private thread per ticket, claim/close, transcripts to a log channel)
- Anti-raid and anti-nuke (Join Gate, raid mode, quarantine of anyone who mass-deletes or mass-bans, lockdowns), modelled on Wick
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
- Twitch live notifications (per-guild `/config` setup)
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions)
When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions. ## Documentation
## Requirements This README is the front page. The rest of the documentation lives in [`docs/`](docs/), organised like a wiki:
- Go (see `go.mod`) | Page | What is in it |
- PostgreSQL 16+ (or use the provided `docker-compose.yml`) | --- | --- |
- (Optional) [Air](https://github.com/air-verse/air) for live reload during development | [Getting started](docs/getting-started.md) | Requirements, creating the Discord application, configuration, database, running locally |
- (Optional) Lavalink if you want music commands | [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, 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 |
| [Development guide](docs/development.md) | How to add commands, subcommands, buttons, events, background jobs, settings and tables; testing; code style |
| [Beta features](docs/beta-features.md) | How features are tried by a few testers before release |
| [Troubleshooting](docs/troubleshooting.md) | Common problems and what they mean |
## Configuration ## Quick start
The app loads environment variables from `.env` (see `internal/config/config.go`). You need Go (the version in `go.mod`), PostgreSQL 16 or newer, and a Discord application with a bot token.
Required:
- `BOT_TOKEN`: Discord bot token
- `APP_ID`: Discord application ID
- `GUILD_ID`: Target guild/server ID (commands are registered per-guild)
- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`
Optional (music):
- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup)
- `LAVALINK_PASSWORD`: Lavalink password
Optional (Twitch live notifications):
- `TWITCH_CLIENT_ID`: Twitch Client ID used to query live status
## Database setup
If you use the compose file:
```bash
docker compose up -d
```
Then apply the schema:
```bash ```bash
git clone <this repository>
cd velox-bot
# create .env with BOT_TOKEN, APP_ID, GUILD_ID, DB_HOST and TWITCH_CLIENT_ID (see docs/getting-started.md)
docker compose up -d db # PostgreSQL on localhost:5432
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
```
## Run
Plain Go:
```bash
go run . go run .
``` ```
With live reload (Air): On start the bot registers its slash commands in the server named by `GUILD_ID` (or globally if that is empty). The full walkthrough, including the Discord side, is in [Getting started](docs/getting-started.md).
```bash ## What it does
air
- **Security**: Join Gate, raid mode, anti-nuke with quarantine, lockdowns, snapshots and restore, and a spam "heat" system, modelled on Wick.
- **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.
- **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`.
- **Fun**: `/ping`, `/joke`, `/coinflip`, `/dice`, `/rps` with stats and a leaderboard.
When the bot joins a server for the first time, toggleable systems start **disabled** and the person who invited it gets a DM with setup instructions.
## Repository layout
```
main.go wiring: config, database, repos, services, bot
internal/config environment variables
internal/bot Discord session, intents, event registration, startup
internal/commands slash commands, one package per feature, plus the dispatcher
internal/events handlers for gateway events and background loops
internal/db connection, repos (SQL), services (rules)
internal/security anti-raid and anti-nuke engine
internal/tickets ticket flows
internal/music Lavalink player, queue, buttons
internal/beta beta access gate
internal/modlog, rankcard small shared helpers
schema.sql mirror of the tables the bot reads and writes
``` ```
If you use `just`, a small workflow is included: ## Host your own bot
```bash 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.
just up # start postgres
just start # start postgres + run air
just down # stop postgres
```
## Commands (high-level) ## Contributing
- **Fun** Contributions are welcome: bug reports, fixes, documentation and new features.
- `/ping`, `/joke [type]`, `/coinflip`, `/dice <expr>`
- `/rps <hand>`, `/rpsstats`, `/rpsleaderboard` (server only)
- **Help**
- `/help` (sends a DM with command reference)
- **Leveling**
- `/slvl ...` admin/config
- `/rank`, `/leaderboard`, `/rewards`
- **Meetings** (**Manage Server**)
- `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite`
- **Timezone**
- `/timezone set`, `/timezone show`
- **Scheduling**
- `/schedule create`, `/schedule list`, `/schedule reschedule`
- **Projects**
- `/projects list`, `/projects create`, `/projects add-helper`
- **Tickets**
- `/tickets setup channel:#tickets [log-channel] [staff-role]` — Where tickets open and closed ones are logged (**Manage Server**)
- `/tickets category add|remove|list` — One panel button per category, each with its own staff role (**Manage Server**)
- `/tickets panel` — Post or refresh the panel (**Manage Server**)
- `/ticket add`, `/ticket close [reason]` — Opener or staff; `/ticket claim`, `/ticket remove` — staff
- The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
- **Security** (anti-raid and anti-nuke; everything is off until turned on)
- `/security status`, `/security toggle feature on`, `/security alerts channel [role]`
- `/security trust add|remove user|role` — owner only; trusted members are exempt from anti-nuke and may change security settings
- `/security release user`, `/security quarantined`, `/security end-raid`, `/security quarantine-role`
- `/lockdown channel|server|joins`, `/unlock channel|server|joins` — unlocking needs the owner or a trusted member
- `/security snapshot`, `/security restore` — snapshots of roles and channels are also taken every 6 hours while anti-nuke is on (last three kept); restore recreates deleted roles and channels and reverts dangerous permission changes, and can run automatically after a stopped nuke
- Heat (anti-spam): messages build a per-member score that cools over time; spam, repeats, mass mentions and invite links max it, which deletes the message and times the member out (doubling for repeat offences). A wave of spammers turns on heat panic (newcomers timed out on sight for 10 minutes); a mention flood can lock the server. Moderators (Manage Messages) and trusted members are exempt.
- Raid mode survives a bot restart.
- Critical alerts (raids, stopped nukes, restores, spam waves, removed bots) are also DMed to the owner and trusted users (up to 10; trusted roles aren't expanded), since the alert channel may be the first thing a nuker deletes. One setting turns this off.
- Join Gate filters, raid thresholds, anti-nuke limits and heat settings are set on the dashboard.
- The bot needs View Audit Log, Manage Roles, Manage Channels, Manage Server (to pause invites), Moderate Members, Kick Members and Ban Members, and its role must be above the roles it protects against: it can only quarantine members whose highest role is below its own.
- Turning things off, unlocking and releasing always need the owner or someone on the trusted list, whatever Discord's command permissions say.
- **Music** (requires **DJ** role)
- `/play`, `/queue`, `/volume`
- **Moderation** (focused + practical)
- `/moderation purge amount:<1-100>` — Fast recent message cleanup (**Manage Messages**)
- `/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Temporary member timeout (**Moderate Members**)
- `/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)
- `/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)
- `/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)
- `/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)
- `/moderation slowmode channel:#channel seconds:<0-21600>` — Control channel flood (**Manage Channels**)
- **Twitch live notifications** (**Manage Server**)
- `/config addstreamer username:<twitch-name>` — Add a streamer for notifications
- `/config removestreamer username:<twitch-name>` — Remove a streamer
- `/config settwitchnotificationchannel channel:#channel` — Channel for live pings
- **Welcome / onboarding** (**Manage Server**)
- `/config setwelcomechannel channel:#channel` — Channel for welcome messages
- `/config setwelcomemessage message:"..."` — Server welcome template (supports `{user}`, `{server}`)
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
- `/config setdefaultrole role:@role` — Role automatically granted on join
- **Logging** (**Manage Server**)
- `/config setlogchannel channel:#channel` — Set where logs are sent
- `/config enablelogging` — Enable event/moderation logs
- `/config disablelogging` — Disable event/moderation logs
## Notes - 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 ./...`.
- Slash commands are registered to the configured `GUILD_ID` on startup. ## License
- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music).
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`).
+131
View File
@@ -0,0 +1,131 @@
# Architecture
This page explains how the bot is put together, so you know where to look and where to make a change.
## The big picture
```
Discord gateway (websocket)
|
discordgo session
|
+--------------------------+---------------------------+
| | |
interactions gateway events background loops
(slash commands, (messages, joins, bans, (Twitch, reminders,
buttons, modals) voice, audit log, ...) raid watch, snapshots)
| | |
internal/commands internal/events internal/events, security
| | |
+----------- services (rules) <------ repos (SQL) -----+
|
PostgreSQL <----> 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.
## Startup
`main.go` wires everything in this order:
1. **Config**: `config.LoadConfig()` reads the environment (and `.env`).
2. **Database**: `db.NewDB()` opens a `database/sql` pool using the pgx driver.
3. **Repos**: one repo per area (`levelrepo`, `settingsrepo`, `ticketrepo`, ...). A repo is the only place that writes SQL.
4. **Services**: small structs holding the rules for one area (`level`, `meeting`, `welcome`, ...), built on top of repos, collected in `services.Services`.
5. **Singletons for the bigger modules**: tickets and security get their repo with `cmdtickets.Init(...)` and `security.Configure(...)`, and are reached later through `tickets.Default()` and `security.Default()`.
6. **Bot**: `bot.NewBot()` creates the Discord session and chooses the gateway intents.
7. **`bot.Start()`** opens the connection and then:
- starts the music player (if Lavalink is configured) and its command listener,
- starts the ticket and security engines and their command listeners (each reconnects every 10 seconds if its database connection drops),
- registers every slash command in `GUILD_ID` (or globally),
- registers the gateway event handlers,
- starts the Twitch and schedule-reminder loops.
8. **`bot.WatchBeta()`** reads the beta settings from the database and keeps them fresh every 30 seconds. See [Beta features](beta-features.md).
9. The process waits for `SIGINT` or `SIGTERM`.
## Layers
The code follows one direction: commands and events call services, services call repos, repos run SQL.
| Layer | Where | Responsibility |
| --- | --- | --- |
| Commands | `internal/commands/<feature>` | Describe the slash command, check who is allowed, read the options, answer the user. No SQL. |
| Events | `internal/events` | React to gateway events and run background loops. |
| Engines | `internal/security`, `internal/tickets`, `internal/music` | The bigger modules, with their own state and flows. |
| Services | `internal/db/services/<area>` | Rules and orchestration (for example the XP rules in `level`). |
| Repos | `internal/db/repos/<area>repo` | SQL, and nothing else. |
Commands reach services through the package-level `services.Global`, set by `services.NewServices()`. A shared helper in each command package (for example `RequireMeetingService`) answers "service not available" if it is nil. Event handlers receive the services as an argument.
## How a slash command runs
1. Discord sends an interaction. discordgo calls `commands.HandleInteraction` (`internal/commands/commands.go`).
2. For a slash command, the dispatcher looks the command's name up in the `handlers` map and calls the handler.
3. The handler checks the context (server only? permissions?), finds the subcommand in `i.ApplicationCommandData().Options`, reads the options, calls a service, and responds with `s.InteractionRespond`.
Buttons, select menus and modals do not have a command name. They carry a **custom ID**, and the dispatcher sends them to a feature by its **prefix**: `music:`, `schedule:`, `projects:`, `tickets:` or `security:`. A feature owns everything after its prefix.
Some commands have a response that takes longer than three seconds (anything that calls Discord several times). Those answer with a deferred response first, then edit it when done. Search the code for `InteractionResponseDeferredChannelMessageWithSource` for examples.
## How a gateway event runs
`bot.Start()` registers one handler per event with `Session.AddHandler`. Most of them hand the event to a function in `internal/events`, together with the services. A few go to the security engine first, because it must decide before anything else happens:
- **Member joins**: the security engine runs first. If it removes the member (join lock, Join Gate, raid mode) there is no welcome message or default role. Otherwise the welcome, default role and join log run.
- **Messages**: the security engine's heat check runs, then the leveling handler.
- **Audit log entries**: only the security engine (anti-nuke, unauthorised bots).
- **Voice state changes**: meeting rooms and the music player.
## Background loops
| Loop | Where | Interval | What it does |
| --- | --- | --- | --- |
| Twitch live check | `events/twitch_live.go` | 30 s | Checks configured streamers and posts when one goes live |
| Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session |
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period |
| Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) |
| Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are 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 |
## How a dashboard talks to the bot
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.** 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).
## Gateway intents
Set in `internal/bot/bot.go`:
| Intent | Why |
| --- | --- |
| Guilds | slash commands, channel and role events |
| Guild Moderation (bans, audit log) | ban logs, anti-nuke |
| Guild Members (privileged) | joins, leaves, member updates |
| Guild Messages and Message Content (privileged) | leveling, spam heat, message logs |
| Guild Voice States | meeting rooms, music |
| Direct Messages | direct messages sent to the bot |
If you add a feature that needs another event, add its intent here too, and enable it in the developer portal if it is privileged.
## Concurrency and safety notes
- discordgo calls event handlers on goroutines, so handlers must not share unsynchronised state. The engines use mutexes, and per-server memory is bounded.
- Security-sensitive actions do not rely on Discord's command permissions, because server admins can change those. The handlers check the owner and the trusted list themselves (see the [Security](features.md#security) section).
- User-controlled text (usernames, reasons) is escaped or kept out of mentions. Messages only ping roles that are configured for it.
- Settings that decide who has access (the beta role) fail closed: if they cannot be read, nobody gets access.
## Where things live
| I want to find... | Look in |
| --- | --- |
| The definition and handler of `/foo` | `internal/commands/foo/public/` (the `registry.go` next to it re-exports it) |
| The list of all commands and the dispatcher | `internal/commands/commands.go` |
| What happens on a gateway event | `internal/bot/bot.go`, then `internal/events/` |
| The SQL behind a feature | `internal/db/repos/<area>repo/repo.go` |
| The rules behind a feature | `internal/db/services/<area>/` |
| The table definitions | `schema.sql` and [Database](database.md) |
| Environment variables | `internal/config/config.go` |
+79
View File
@@ -0,0 +1,79 @@
# Beta features
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** 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.
- 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.
## 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 rules), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `syncBetaCommands`), `internal/commands/betacmd` (`/beta`).
- 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
Write the command like any other (see the [Development guide](development.md#add-a-simple-command)), but instead of adding it to `AllCommands` and `handlers`, register it from `commands.go`:
```go
func init() {
RegisterBeta(mything.MyThing, mything.MyThingHandler)
}
```
`RegisterBeta` adds the definition to `BetaCommands` and the handler, wrapped in `beta.Gate`, to the dispatcher. Buttons and forms that belong to a beta command need the same care: 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 the beta servers drop it from their own lists because it is now a regular command.
## On the dashboard
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.
- 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 they can change without a deploy.
+180
View File
@@ -0,0 +1,180 @@
# Command reference
Every slash command the bot registers. Options marked with `*` are required. "Needs" is what the handler checks, not just what Discord shows in its menu. Commands that need a server cannot be used in DMs.
This page is written by hand from the command definitions in `internal/commands/*/public`. If you change a command, update it here and in `/help` (`internal/commands/help/help.go`). The [Features](features.md) page explains the behaviour behind them.
- [Fun](#fun)
- [Help](#help)
- [Leveling](#leveling)
- [Music](#music)
- [Meeting rooms](#meeting-rooms)
- [Moderation](#moderation)
- [Timezone and scheduling](#timezone-and-scheduling)
- [Projects](#projects)
- [Server setup (`/config`)](#server-setup-config)
- [Tickets](#tickets)
- [Security](#security)
- [Beta](#beta)
## Fun
Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers only.
| Command | Options | What it does |
| --- | --- | --- |
| `/ping` | | Replies "Pong!" |
| `/joke` | `type` | A random joke, optionally of a type |
| `/coinflip` | | Heads or tails |
| `/dice` | `expr`* | Rolls a dice expression: `d20`, `2d20+1`, `2#d20+1` |
| `/rps` | `hand`* | Rock paper scissors against the bot |
| `/rpsstats` | | Your rock-paper-scissors record |
| `/rpsleaderboard` | | The server's leaderboard |
## Help
| Command | What it does |
| --- | --- |
| `/help` | DMs you a command reference |
## Leveling
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/slvl toggle` | | Manage Server | Turns leveling on or off |
| `/slvl set` | `user`*, `level`* | Manage Server, leveling on | Sets a member's level |
| `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go |
| `/slvl set-message` | `message`* | Manage Server, leveling on | The level-up message. Variables: `{user}`, `{level}` |
| `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level |
| `/rank` | `user` | Leveling on | A rank card for you or another member |
| `/leaderboard` | | Leveling on | Top 10 in the server |
| `/rewards` | | Leveling on | The role rewards |
## Music
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/play` | `query`* | **DJ** role | Plays a song by name or URL |
| `/queue` | | **DJ** role | Shows the queue |
| `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume |
| `/music toggle` | | Manage Server | Turns music on or off in this server |
Requires Lavalink. See [Getting started](getting-started.md#optional-music-with-lavalink).
## Meeting rooms
Every subcommand needs **Manage Server** and works in servers only.
| Command | Options | What it does |
| --- | --- | --- |
| `/meeting add-lobby` | `channel`* (voice) | Makes a voice channel a lobby (up to 10 per server) |
| `/meeting remove-lobby` | `channel`* (voice) | Stops using it as a lobby |
| `/meeting lobbies` | | Lists the lobbies |
| `/meeting lock` | | Locks the room you are in |
| `/meeting lock-private` | | Locks it and hides it |
| `/meeting unlock` | | Unlocks it |
| `/meeting invite` | `user`* | Lets a user into a locked room |
| `/meeting uninvite` | `user`* | Removes that access |
## Moderation
Each subcommand checks the Discord permission in its "Needs" column.
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/moderation purge` | `amount`* (1 to 100) | Manage Messages | Deletes recent messages in the channel |
| `/moderation timeout` | `user`*, `duration`* (`10m`, `1h`, `2h30m`, up to 28d), `reason` | Moderate Members | Times a member out |
| `/moderation untimeout` | `user`*, `reason` | Moderate Members | Removes a timeout |
| `/moderation kick` | `user`*, `reason` | Kick Members | Kicks a member |
| `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans a member |
| `/moderation unban` | `user_id`*, `reason` | Ban Members | Unbans by ID |
| `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off |
## Timezone and scheduling
Anyone can use these.
| Command | Options | What it does |
| --- | --- | --- |
| `/timezone set` | `zone`* (IANA name, such as `Europe/Lisbon`) | Saves your timezone |
| `/timezone show` | | Shows it |
| `/schedule create` | | Opens a form (date, time, description) to request a session with a member |
| `/schedule list` | | Your upcoming sessions |
| `/schedule reschedule` | `id`* (from `/schedule list`), `datetime`* (UTC, `2006-01-02 15:04`) | Proposes a new time |
## Projects
| Command | Needs | What it does |
| --- | --- | --- |
| `/projects list` | | Active projects with creators and helpers |
| `/projects create` | Manage Server | Opens a form to create a project |
| `/projects add-helper` | Manage Server | Adds a helper to a project |
| `/projects close` | Manage Server | Closes a project |
## Server setup (`/config`)
Every subcommand needs **Manage Server**.
| Command | Options | What it does |
| --- | --- | --- |
| `/config setwelcomechannel` | `channel`* | Where welcome messages go |
| `/config setwelcomemessage` | `message`* | Welcome template. `{user}` is a mention, `{server}` the server name |
| `/config setwelcomedm` | `message`* | The welcome DM template |
| `/config setwelcomegif` | `url`* | Image or GIF for the welcome embed |
| `/config setdefaultrole` | `role`* | Role given on join |
| `/config setlogchannel` | `channel`* | Where logs go |
| `/config enablelogging` | | Turns logging on |
| `/config disablelogging` | | Turns it off |
| `/config addstreamer` | `username`* | Adds a Twitch streamer to watch |
| `/config removestreamer` | `username`* | Removes one |
| `/config settwitchnotificationchannel` | `channel`* | Where live alerts go |
## Tickets
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/tickets setup` | `channel`*, `log-channel`, `staff-role` | Manage Server | Where tickets open and where closed ones are logged. `staff-role` creates a Support category if there are none yet |
| `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown |
| `/tickets category remove` | `name`* | Manage Server | Removes a category |
| `/tickets category list` | | Manage Server | Lists categories |
| `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories |
| `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket |
| `/ticket remove` | `user`* | Staff | Removes someone |
| `/ticket claim` | | Staff | Takes the ticket |
| `/ticket close` | `reason` | The opener or staff | Closes it and posts a transcript |
## Security
All of these are hidden from members without Manage Server (and `/lockdown` from those without Manage Channels), but **hiding is not the check**. The handlers check again, and some need the **server owner or someone on the trusted list** whatever Discord permissions a person has.
In this table "Manage Server" also lets Administrators and trusted members through.
| Command | Options | Needs | What it does |
| --- | --- | --- | --- |
| `/security status` | | Manage Server | What is on, and who is trusted |
| `/security toggle` | `feature`* (Join Gate, Raid mode, Anti-nuke, Heat), `on`* | Manage Server to turn on; owner or trusted to turn off | Turns a protection on or off |
| `/security alerts` | `channel`*, `role` | Owner or trusted | Alert channel and the role to ping |
| `/security quarantine-role` | `role` | Owner or trusted | Role given to quarantined members (empty for none) |
| `/security trust add` | `user` or `role` | Owner only | Exempts a user or role from anti-nuke |
| `/security trust remove` | `user` or `role` | Owner only | Removes that exemption |
| `/security quarantined` | | Manage Server | Lists quarantined members |
| `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back |
| `/security end-raid` | | Owner or trusted | Ends raid mode now |
| `/security snapshot` | | Manage Server | Saves roles and channels |
| `/security restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot |
| `/lockdown channel` | `channel` (default: this one) | Manage Channels | Locks a channel |
| `/lockdown server` | | Manage Channels | Locks every channel |
| `/lockdown joins` | `action`* (Kick or Ban) | Manage Channels | Kicks or bans new members until unlocked |
| `/unlock channel` | `channel` | Owner or trusted | Unlocks a channel |
| `/unlock server` | | Owner or trusted | Unlocks every channel |
| `/unlock joins` | | Owner or trusted | Lets people join again |
## Beta
| Command | Needs | What it does |
| --- | --- | --- |
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only |
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).
+72
View File
@@ -0,0 +1,72 @@
# Database
The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL lives in repos under `internal/db/repos`.
## Who owns the schema
**`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.
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.
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:
```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;
CREATE TRIGGER music_commands_notify
AFTER INSERT ON music_commands
FOR EACH ROW EXECUTE FUNCTION notify_music_command();
```
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
| Area | Tables | Notes |
| --- | --- | --- |
| Leveling | `levels`, `levelsettings`, `levelrewards`, `levelup` | XP and level per member, settings, role rewards, the level-up channel |
| Welcome | `welcome`, `defaultrole` | Welcome channel, templates, GIF, default role |
| Logging | `logsettings` | Log channel and on/off |
| Twitch | `twitch`, `twitch_config` | Streamers and the notification channel |
| Fun | `rps` | Rock-paper-scissors stats |
| Meeting rooms | `meeting_lobbies`, `meeting_temp_channels`, `meeting_settings` | Lobbies (10 per server, enforced by a trigger), the temporary rooms and who created them. `meeting_settings.lobby_channel_id` is the old single lobby column and is no longer read |
| Projects | `projects`, `project_members` | |
| Scheduling | `schedules`, `schedule_messages`, `user_settings` | Sessions, the DM messages to update, and each member's timezone |
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State 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`, `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 a dashboard to the bot, and the difference matters.
**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. 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 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. 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
`internal/db/db.go` opens the pool with up to 100 open and 10 idle connections, and recycles them after an hour. The listeners use their own separate connections, because `LISTEN` needs one that stays open.
+56
View File
@@ -0,0 +1,56 @@
# Deployment
## Docker
The `Dockerfile` is a two-stage build: it compiles a static binary with Go, then copies it into a small Alpine image (with CA certificates and timezone data, which `/timezone` and scheduling need).
```bash
docker build -t velox-bot .
docker run --env-file .env velox-bot
```
The container reads its configuration from environment variables, exactly like a local run. See [Getting started](getting-started.md#2-configure-the-environment).
## Docker Compose
`docker-compose.yml` defines two services:
- `db`: PostgreSQL 16 with a named volume (`velox_pgdata`) and a health check.
- `bot`: the bot, built from this directory, started after the database is healthy. It takes `.env` through `env_file`, and defaults `DB_HOST` to the `db` service and `LAVALINK_HOST` to `ws://lavalink:2333` if you have not set them.
```bash
docker compose up -d # database and bot
docker compose logs -f bot
```
Compose does not include Lavalink. Run it separately (or add a service) if you want music.
Change the default database password (`velox_pwd`) before exposing PostgreSQL to anything other than your own machine, and remove the published `5432` port if only the bot needs it.
## Database changes
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
```bash
git pull
docker compose build bot
docker compose up -d bot
```
Slash commands are re-registered on every start, so new and changed commands appear after a restart. Commands you removed from the code are not deleted from Discord automatically, except for the beta server (see [Beta features](beta-features.md)).
## Backups
All state is in PostgreSQL. Back it up with `pg_dump`:
```bash
pg_dump "postgres://velox:velox_pwd@localhost:5432/velox" > velox-backup.sql
```
Raid mode, quarantines, lockdowns and ticket state live in the database, so the bot picks them up again after a restart.
## Shutting down
Send `SIGINT` or `SIGTERM` (Docker does on `docker stop`). Slash commands stay registered in Discord while the bot is down, and are registered again (updated in place) on the next start.
+298
View File
@@ -0,0 +1,298 @@
# Development guide
How to change the bot and how to add to it. Read [Architecture](architecture.md) first if you have not: it explains the layers this page keeps referring to.
- [Daily workflow](#daily-workflow)
- [Add a simple command](#add-a-simple-command)
- [Add a subcommand](#add-a-subcommand)
- [Command options and permissions](#command-options-and-permissions)
- [Buttons, select menus and forms](#buttons-select-menus-and-forms)
- [Add a feature that stores settings](#add-a-feature-that-stores-settings)
- [React to a gateway event](#react-to-a-gateway-event)
- [Add a background job](#add-a-background-job)
- [Add an environment variable](#add-an-environment-variable)
- [Make a command beta](#make-a-command-beta)
- [Testing](#testing)
- [Style and safety rules](#style-and-safety-rules)
- [Checklist before a pull request](#checklist-before-a-pull-request)
## Daily workflow
```bash
docker compose up -d db # PostgreSQL
air # rebuild and restart on every change
```
Set `GUILD_ID` to a test server in `.env`, so commands update within seconds. Before pushing:
```bash
gofmt -l .
go build ./... && go vet ./... && go test ./...
```
Some older files are not gofmt-clean. Format the files you touch, and leave the others alone so reviews stay readable.
## Add a simple command
A command is a definition (what Discord shows) plus a handler (what runs). Each feature has a package under `internal/commands/<feature>`, with this shape:
```
internal/commands/fun/
registry.go re-exports the commands and handlers of the feature
public/ the definitions and handlers
ping.go
shared/ helpers used by several commands of the feature
```
Example: a `/hello` command in the `fun` feature.
**1. The definition and handler**, in `internal/commands/fun/public/hello.go`:
```go
package public
import "github.com/bwmarrin/discordgo"
var Hello = &discordgo.ApplicationCommand{
Name: "hello",
Description: "Say hello",
}
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: "Hello!"},
})
}
```
**2. Re-export it**, in `internal/commands/fun/registry.go`:
```go
var Hello *discordgo.ApplicationCommand = public.Hello
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HelloHandler(s, i) }
```
**3. Register it**, in `internal/commands/commands.go`: add `fun.Hello` to `AllCommands`, and `"hello": fun.HelloHandler` to the `handlers` map. The key is the command's name. A command that is in `AllCommands` but not in `handlers` is shown in Discord and then does nothing.
**4. Restart the bot.** It re-registers all commands on start.
**5. Document it:** add it to `/help` (`internal/commands/help/help.go`) and to the [Command reference](commands.md).
Names must be lowercase, up to 32 characters, and unique. Descriptions are required and limited to 100 characters.
For an entirely new feature, create a new package with the same shape, and import it in `commands.go`.
## Add a subcommand
A command with subcommands (like `/meeting`) lists them in `Options`, and its handler switches on the name of the first option. Add the subcommand to the definition:
```go
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "lobbies",
Description: "List this server's meeting lobbies",
},
```
and a case to the handler's switch:
```go
switch data.Options[0].Name {
case "lobbies":
handleListLobbies(s, i)
// ...
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
}
```
Keep one `handleX` function per subcommand. For a group (`/tickets category add`), the first option is the group and its first option is the subcommand.
## Command options and permissions
**Options** go in the definition's `Options` (or in a subcommand's). Types are `String`, `Integer`, `Boolean`, `User`, `Channel`, `Role`, `Number`, and others. A channel option can be limited with `ChannelTypes`. A string can offer fixed `Choices`.
Inside a subcommand, `shared.SubcommandOptionMap(i)` (in the meeting, level and other `shared` packages) turns the options into a map by name:
```go
opts := shared.SubcommandOptionMap(i)
channel := opts["channel"].ChannelValue(s) // *discordgo.Channel
```
**Where a command may be used.** `Contexts` lists where Discord offers it (`InteractionContextGuild`, `InteractionContextBotDM`, `InteractionContextPrivateChannel`). A server-only command should also refuse politely if `i.GuildID == ""`: the shared packages have `RequireGuild` for this.
**Who may use it.** There are two layers, and you need both:
1. `DefaultMemberPermissions` on the definition only decides who **sees** the command in Discord's menu. Server admins can change it in their settings, so it is not security.
2. The handler must check again, using `i.Member.Permissions`. The shared packages have helpers such as `RequireManageGuild`. Moderation uses `hasPerm(i, discordgo.PermissionKickMembers)` and so on.
For anything where a mistake hurts (security settings, anything destructive), check the **owner or trusted list** as well, as `internal/commands/security` does with `allowedOrTrusted`. Do not rely on Discord's permissions alone.
**Responses.**
- Answer within three seconds. If a command takes longer, respond with `InteractionResponseDeferredChannelMessageWithSource` first, then edit the response with `InteractionResponseEdit`.
- Use `Flags: discordgo.MessageFlagsEphemeral` for replies only the user should see (errors, settings). `shared.RespondEphemeral` does this.
- When the message contains user-controlled text, set `AllowedMentions: &discordgo.MessageAllowedMentions{}` so it cannot ping anyone.
## Buttons, select menus and forms
Components and modals carry a **custom ID**, and the dispatcher routes them by prefix (`internal/commands/commands.go`, `HandleInteraction`). To add one to a feature:
1. Use the feature's prefix when you build the component: `CustomID: "projects:close:" + id`. Put whatever you need in the ID after the prefix, split with `:`.
2. The dispatcher already sends `music:`, `schedule:`, `projects:`, `tickets:` and `security:` to that feature's `HandleComponent`, and `schedule:`, `projects:`, `tickets:` modals to its `HandleModalSubmit`.
3. In `HandleComponent`, parse the rest of the ID, check permissions **again** (the button may be clicked by anyone who can see the message), and respond.
For a **new prefix**, add a case to the `switch` in `HandleInteraction`, and write `HandleComponent` and/or `HandleModalSubmit` in your feature.
Custom IDs are limited to 100 characters, and a modal can have up to five inputs.
Never trust the contents of a custom ID. A user cannot edit it, but old messages stay around with old IDs, and IDs may refer to things that no longer exist.
## Add a feature that stores settings
The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (`/meeting add-lobby`).
**1. The table.** Add it to `schema.sql` (see [Database](database.md#changing-the-schema)).
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
```go
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at`, guildID)
// ...
}
```
Always pass values as parameters (`$1`), never build SQL with string formatting.
**3. The service**, in `internal/db/services/<area>/service.go`. It wraps the repo and holds the rules (limits, defaults, validation):
```go
type Service struct{ settings *settingsrepo.Repo }
func New(settings *settingsrepo.Repo) *Service { return &Service{settings: settings} }
```
**4. Wire it up.** Add a field to `services.Services` and the constructor in `internal/db/services/services.go`, and build the repo and service in `main.go` next to the others.
**5. Use it from the command**, through `services.Global`:
```go
if services.Global == nil || services.Global.Meeting == nil {
shared.RespondEphemeral(s, i, "Meeting service is not available.")
return
}
added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID)
```
**6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and 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.
## React to a gateway event
1. Write the function in `internal/events/`, taking the session, the event and the services:
```go
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { ... }
```
2. Register it in `bot.Start()` (`internal/bot/bot.go`):
```go
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
events.HandleChannelDelete(s, c, b.Services)
})
```
3. Check that the **intent** for that event is enabled in `NewBot`. Events from an intent you did not ask for never arrive. Privileged intents must also be enabled in the developer portal.
Handlers run concurrently on goroutines. Do not share unsynchronised state, and do not block: slow work belongs in its own goroutine.
If the event is something security cares about (joins, messages, audit log), look at how `bot.go` calls the security engine **before** your handler.
## Add a background job
Follow `StartScheduleReminderLoop` in `internal/events/schedule_reminders.go`: a function that returns immediately and starts a goroutine with a ticker.
```go
func StartMyLoop(s *discordgo.Session, svc *services.Services) {
if svc == nil || svc.Thing == nil || s == nil {
return
}
go func() {
time.Sleep(10 * time.Second) // let the bot finish starting
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
// check, act, log errors, never panic
}
}()
}
```
Call it at the end of `bot.Start()`. Log errors instead of returning them, and make one failing server not stop the loop for the others.
## Add an environment variable
1. Add a field to `Config` and read it in `LoadConfig()` (`internal/config/config.go`). Return an error if the bot cannot work without it.
2. Document it in [Getting started](getting-started.md#2-configure-the-environment).
3. Pass it where it is needed from `main.go`.
Never log a secret, and never put one in a message to a user.
Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database instead (set with a command, or by a dashboard). That is how the beta settings work.
## Make a command beta
Register it with `commands.RegisterBeta` instead of adding it to `AllCommands` and `handlers`. See [Beta features](beta-features.md).
## Testing
```bash
go test ./... # unit tests, no database needed
go test -race ./... # recommended before a pull request
```
Where the code allows it, the Discord and database calls go through small interfaces, so flows can be tested with fakes. `internal/tickets` and `internal/security` are the models: look at their `fakes_test.go`.
Tests that need a real PostgreSQL are behind the `integration` build tag and skip themselves when their environment variable is missing:
| Package | Variable |
| --- | --- |
| `internal/db/repos/settingsrepo` | `TEST_DB` |
| `internal/db/repos/ticketrepo`, `internal/tickets` | `TICKETS_TEST_DB` |
| `internal/db/repos/securityrepo` | `SECURITY_TEST_DB` |
```bash
TEST_DB="postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" \
go test -tags integration ./internal/db/repos/settingsrepo
```
Use a throwaway database for these, not one with real data. The tables must already exist (load `schema.sql`).
Good things to test: permission checks (does the wrong person get refused?), limits, anything that parses user input, and "what if the service or the database fails".
## Style and safety rules
- **One feature per package, SQL only in repos, rules in services.** Commands never contain SQL.
- **Match the code around you**: naming, comment density, error handling. Comments explain *why*, not *what*.
- **Fail closed.** If you cannot tell whether someone is allowed, they are not.
- **Validate everything that comes from a user or 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.
- **Idempotent writes**: use `ON CONFLICT` for "add" operations, so double clicks and retries are harmless.
- Handle the bot being **restarted at any time**. State that must survive (raid mode, quarantines) lives in the database.
## Checklist before a pull request
- [ ] `gofmt` on the files you touched, `go build ./...`, `go vet ./...`, `go test -race ./...`
- [ ] Commands: in `AllCommands` and `handlers`, permission check in the handler, ephemeral errors
- [ ] New table 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
+120
View File
@@ -0,0 +1,120 @@
# Features
What each module does and how it behaves. For the exact options of every command, see the [Command reference](commands.md). To change how a module works, see the [Development guide](development.md).
Unless a section says otherwise, a module is **off** in a new server and a server admin turns it on.
## Security
Anti-raid and anti-nuke, modelled on Wick. Code: `internal/security`, commands in `internal/commands/security`.
**Join Gate** checks every new member: no avatar, account too new, invite links or blocked words in the name, unverified bots. Each check has its own action. It also removes bots added by anyone who is not trusted.
**Raid mode** starts when many members join in a short time. Raiders are timed out, kicked or banned, invites can be paused, staff are alerted, and raid mode ends after a quiet period or when staff end it with `/security end-raid`. It survives a bot restart: the quiet timer carries on and the summary still lists everyone caught.
**Anti-nuke** counts destructive actions per person from the audit log: deleting channels or roles, mass bans and kicks, creating webhooks, granting dangerous permissions. Someone who is not the owner or trusted and goes over a limit is **quarantined**: their roles are removed and saved, and they are timed out. `/security release` gives every role back.
**Lockdown** (`/lockdown`, `/unlock`) locks one channel, every channel, or joins. Unlocking puts back exactly the permission overwrite that was there before.
**Snapshots** save the server's roles and channels. They are taken every 6 hours while anti-nuke is on (the last three are kept) and on request with `/security snapshot`. `/security restore` recreates deleted roles and channels and reverts dangerous permission changes. It can also run automatically after a stopped nuke.
**Heat** is the anti-spam system. Each member has a score that rises with messages and cools over time. Spam, repeats, mass mentions and invite links max it, which deletes the message and times the member out (the timeout doubles for repeat offences). A wave of spammers turns on *heat panic* (newcomers are timed out on sight for 10 minutes), and a mention flood can lock the server. Members with Manage Messages and trusted members are exempt.
**Alerts** go to a channel you choose, and critical ones (raids, stopped nukes, restores, spam waves, removed bots) are also DMed to the owner and trusted users, because the alert channel may be the first thing a nuker deletes. One setting turns the DMs off.
Rules that hold everywhere in this module:
- Turning protection off, changing limits, releasing a quarantine and unlocking need the **server owner or someone on the trusted list**. Editing the trusted list needs the owner. Discord's own command permissions are never relied on, because admins can change them.
- The owner and the bot are never punished.
- Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned.
- Messages ping only the configured alert role, and names are escaped.
Thresholds, Join Gate filters and heat settings are 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
Support tickets in private threads. Code: `internal/tickets`, commands in `internal/commands/tickets`.
1. An admin runs `/tickets setup`, adds categories with `/tickets category add` (each with its own staff role), and posts the panel with `/tickets panel`.
2. The panel has a category dropdown and a **Create ticket** button. A short form asks what the ticket is about.
3. A **private thread** is created in the ticket channel. The opener and the category's staff role can see it.
4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`.
5. Closing locks and archives the thread and posts a **transcript** to the log channel.
The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
The flows go through small interfaces for Discord and the database, so they are tested without either. The slash commands and buttons only translate interactions into those calls.
## Moderation
`/moderation` with `purge`, `timeout`, `kick`, `ban`, `unban`, `untimeout` and `slowmode`. Each subcommand checks the matching Discord permission in the handler (Manage Messages, Moderate Members, Kick Members, Ban Members, Manage Channels). Durations accept `10m`, `1h`, `2h30m` and so on, up to 28 days. Actions are written to the log channel if logging is on.
## Logging
When enabled with `/config enablelogging` and a channel set with `/config setlogchannel`, the bot logs edited and deleted messages, joins and leaves, member and role updates, bans and unbans, and moderation actions. Code: `internal/events/logging.go` and `internal/modlog`.
## Leveling
Opt in per server with `/slvl toggle`. Code: `internal/db/services/level` (the rules) and `internal/events/event_handlers.go` (the message handler).
- Every message may give 1 to 3 XP. Below level 5 every message counts. From level 5 on, a message counts with a chance of 1 in `level / 4`, so leveling slows down.
- 100 XP makes a level, and XP resets to 0.
- A level-up posts the level-up message (set with `/slvl set-message`, variables `{user}` and `{level}`) in the channel chosen with `/slvl set-channel`, or in the channel of the message.
- `/slvl set-reward` gives a role at a level. `/rank` draws a rank card (`internal/rankcard`), `/leaderboard` shows the top 10 and `/rewards` lists role rewards.
## Music
Lavalink-powered playback. Code: `internal/music`, commands in `internal/commands/music`.
- `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**.
- The now-playing message has buttons for playback and repeat. Buttons also check for the DJ role.
- `/music toggle` (Manage Server) turns music on or off in a server.
- The 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
Join a **lobby** voice channel and get your own temporary room. Code: `internal/events/meeting_voice.go`, commands in `internal/commands/meeting`.
- A server can have up to **10 lobbies** (`/meeting add-lobby`, `remove-lobby`, `lobbies`). The limit is enforced by a database trigger, so the bot and the dashboard obey the same rule.
- When someone joins a lobby, the bot creates a voice channel in the same category with the lobby's user limit and bitrate, named after the lobby and the time, and moves the member in.
- When the temporary room is empty, it is deleted.
- If a lobby channel is deleted in Discord, it is dropped from the lobbies.
- `/meeting lock`, `lock-private` (also hides the room), `unlock`, `invite` and `uninvite` act on the room you are in. Every `/meeting` subcommand, including these, currently needs **Manage Server**.
## Scheduling and timezones
1:1 sessions between two members. Code: `internal/commands/schedule`, `internal/db/services/schedule`, reminders in `internal/events/schedule_reminders.go`.
- Each member sets a timezone with `/timezone set` (an IANA name such as `Europe/Lisbon`).
- `/schedule create` opens a form (date, time in your timezone, optional description) and DMs the other member a request with **Accept**, **Decline** and **Propose different time** buttons.
- `/schedule list` shows your upcoming sessions, and `/schedule reschedule` proposes a new time (in UTC, `2006-01-02 15:04`). The other side can accept or decline the proposal.
- Every minute, a loop DMs the participants of sessions starting within 10 minutes.
## Projects
A simple list of ongoing projects. `/projects create` opens a form (name, optional description), `/projects list` shows active projects with creators and helpers, `/projects add-helper` and `/projects close` manage them. Creating, adding helpers and closing need Manage Server.
## Welcome messages and default role
Set up with `/config` (Manage Server). Code: `internal/events/welcome.go`.
- A welcome message in a channel, and an optional DM, both templates where `{user}` becomes a mention and `{server}` the server name. An optional GIF or image URL goes in the welcome embed.
- A default role is granted when a member joins.
- Bots are skipped, and members the security engine removes get nothing.
## Twitch alerts
`/config addstreamer`, `removestreamer` and `settwitchnotificationchannel` (Manage Server). Every 30 seconds a loop checks each configured streamer and posts in the notification channel when one goes live. Code: `internal/events/twitch_live.go`. It needs `TWITCH_CLIENT_ID`.
## Onboarding
When the bot is added to a server, it applies safe defaults (modules off) and DMs setup instructions to the person who invited it, falling back to the server owner. Code: `internal/events/onboarding.go`.
## Fun
`/ping`, `/joke [type]`, `/coinflip`, `/dice` (expressions like `d20`, `2d20+1`, `2#d20+1`, parsed in `internal/diceexpr`), and `/rps` with `/rpsstats` and `/rpsleaderboard`.
## Help
`/help` DMs a command reference. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command.
+119
View File
@@ -0,0 +1,119 @@
# Getting started
This page takes you from nothing to a bot running on your machine.
## Requirements
- **Go**: the version in `go.mod` (the Dockerfile builds with the same one).
- **PostgreSQL 16 or newer**: the included `docker-compose.yml` starts one for you.
- **A Discord application** with a bot user (see below).
- Optional: [Air](https://github.com/air-verse/air) for live reload, [Just](https://github.com/casey/just) for shortcuts, and a [Lavalink](https://github.com/lavalink-devs/Lavalink) server if you want music.
## 1. Create the Discord application
1. Open the [Discord developer portal](https://discord.com/developers/applications) and create an application. Copy its **Application ID**: this is `APP_ID`.
2. Under **Bot**, create the bot user and copy its token: this is `BOT_TOKEN`. Keep it secret.
3. Under **Bot**, switch on these **privileged gateway intents**: **Server Members Intent** (welcome messages, security) and **Message Content Intent** (leveling and spam detection). The bot also uses the non-privileged Guilds, Guild Moderation (bans and the audit log), Guild Messages, Guild Voice States and Direct Messages intents. You do not need to enable those by hand.
4. Invite the bot with the `bot` and `applications.commands` scopes. It needs these permissions, depending on which modules you use:
| Permission | Used by |
| --- | --- |
| View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts |
| Read Message History, Manage Messages | moderation purge, spam heat |
| 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 |
| Kick Members, Ban Members, Moderate Members | moderation commands, Join Gate, raid mode, anti-nuke |
| View Audit Log | anti-nuke, unauthorised bot detection |
| Create Private Threads, Send Messages in Threads, Manage Threads | tickets |
| Connect, Speak, Move Members | music, meeting rooms |
The bot's role must sit **above** the roles it manages or protects against. Discord does not let a bot change members whose highest role is higher than its own.
## 2. Configure the environment
The bot reads environment variables, and loads a `.env` file from the working directory if there is one (`internal/config/config.go`).
| Variable | Required | Meaning |
| --- | --- | --- |
| `BOT_TOKEN` | yes | The bot token. |
| `APP_ID` | yes | The application ID. |
| `GUILD_ID` | no | If set, slash commands are registered in this server only, and appear within seconds. If empty they are registered globally, which Discord can take up to an hour to show. Use a test server while developing. |
| `DB_HOST` | yes | A pgx connection string, for example `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`. |
| `TWITCH_CLIENT_ID` | yes | A Twitch client ID, used to check who is live. The bot refuses to start without it, even if you never use Twitch alerts. Any non-empty value works if you do not use them. |
| `LAVALINK_HOST` | no | Lavalink address, for example `ws://localhost:2333`. Leave empty to run without music. |
| `LAVALINK_PASSWORD` | no | Lavalink password. |
A minimal `.env` for local development:
```bash
BOT_TOKEN=your-bot-token
APP_ID=123456789012345678
GUILD_ID=123456789012345678
DB_HOST=postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable
TWITCH_CLIENT_ID=unused
```
`.env` is ignored by git. Never commit it.
Who the beta testers are is **not** an environment variable. It is stored in the database. See [Beta features](beta-features.md).
## 3. Set up the database
Start PostgreSQL with Compose:
```bash
docker compose up -d db
```
Then load the schema:
```bash
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
```
`schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is the schema of every table the bot uses. Read [Database](database.md) before changing a table.
## 4. Run
```bash
go run .
```
You should see the bot connect, and the slash commands appear in your test server. Type `/ping`.
With live reload:
```bash
air
```
With Just (`Justfile`):
```bash
just up # start PostgreSQL
just start # start PostgreSQL and run Air
just down # stop PostgreSQL
```
## 5. Turn modules on
Nothing is on by default. In your server:
- `/security toggle` to enable Join Gate, raid mode, anti-nuke or heat.
- `/tickets setup`, then `/tickets category add` and `/tickets panel`.
- `/slvl toggle` for leveling.
- `/config setlogchannel` and `/config enablelogging` for logs.
- `/music toggle` for music (needs Lavalink).
The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option.
## Optional: music with Lavalink
Music needs a running Lavalink server and `LAVALINK_HOST` / `LAVALINK_PASSWORD`. Without them the bot starts normally and music simply does not work. Members need a role named exactly **DJ** to use `/play`, `/queue` and `/volume`.
## Next steps
- Running it for real: [Deployment](deployment.md).
- Changing or adding things: [Development guide](development.md).
+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.
+57
View File
@@ -0,0 +1,57 @@
# Troubleshooting
## The bot does not start
| Message | Cause |
| --- | --- |
| `BOT_TOKEN is not set`, `APP_ID is not set`, `DB_HOST is not set` | A required variable is missing. See [Getting started](getting-started.md#2-configure-the-environment). |
| `TWITCH_CLIENT_ID is not set` | It is required even if you do not use Twitch alerts. Set any non-empty value. |
| `Error creating database` | `DB_HOST` is wrong, or PostgreSQL is not running or not reachable. Try the connection string with `psql`. |
| `Error starting bot` | The token is wrong, or a privileged intent is not enabled in the developer portal. |
| `Disallowed intents` | Enable **Server Members Intent** and **Message Content Intent** in the developer portal, under Bot. |
## Slash commands do not appear
- With `GUILD_ID` set, commands appear in that server within seconds. If it is empty they are global and can take up to an hour.
- The bot must be invited with the `applications.commands` scope.
- If a command is defined but not in the `handlers` map in `internal/commands/commands.go`, it shows up and does nothing.
- If the bot logs `Cannot create '<name>' command`, read the error. A name or description that is too long, or an invalid option, makes Discord reject that one command.
- Commands that you removed from the code stay in Discord until you delete them (in the developer portal or through the API).
## A command says "You don't have permission"
Many commands need **Manage Server** (see the [Command reference](commands.md)), and the music commands need a role called exactly **DJ**. Security actions such as turning protection off need the **server owner or a trusted user**, whatever permissions you have.
## The bot cannot do something in a server
Most failures are about the bot's role:
- The bot's role must be **above** the roles it manages and the members it moderates or quarantines.
- It needs the permissions listed in [Getting started](getting-started.md#1-create-the-discord-application). Check the channel's own permission overrides too: a channel can deny what the server role allows.
- Security messages say "Check that Velox's role is high enough and has the permissions it needs" for this reason.
## Music does not play
- `LAVALINK_HOST` and `LAVALINK_PASSWORD` must match your Lavalink server, and Lavalink must be running and reachable from the bot (in Compose, use the service name, not `localhost`).
- You need the **DJ** role.
- A server admin can turn music off with `/music toggle`.
## Level-ups, logs or welcome messages do nothing
They are off until enabled: `/slvl toggle`, `/config enablelogging` with `/config setlogchannel`, and `/config setwelcomechannel`. Leveling and spam detection read message text, so the **Message Content Intent** must be on. Welcome messages and security need the **Server Members Intent**.
## A dashboard or tool's requests have no effect
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 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 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
The bot logs to standard output. Run `docker compose logs -f bot`, or just `go run .`, and reproduce the problem. Errors from Discord calls are logged with the server and channel IDs involved.
+215
View File
@@ -0,0 +1,215 @@
// Package beta gates features that are still being tried out.
//
// 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"
"github.com/bwmarrin/discordgo"
)
// Handler is a slash command handler.
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
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 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; ignoring it", name, v)
return ""
}
return v
}
// 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 server", guildID),
roleID: idOrEmpty("beta role", roleID),
servers: set,
})
}
func get() settings {
if s := current.Load(); s != nil {
return *s
}
return settings{}
}
// 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 {
ServerEnabled bool // beta features are on in this server
RoleConfigured bool // the owner has set a Beta Tester role
HasRole bool // the member is a tester
}
// 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{
ServerEnabled: ServerEnabled(guildID),
RoleConfigured: c.roleID != "",
}
if st.ServerEnabled && st.RoleConfigured && member != nil {
st.HasRole = isTester(c, guildID, member)
}
return st
}
// 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.ServerEnabled && st.RoleConfigured && st.HasRole
}
// AllowedInteraction is Allowed for an interaction. A DM has no member, so it
// 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)
}
// respond is how a refusal is sent; tests replace it.
var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
if i.Type == discordgo.InteractionApplicationCommandAutocomplete {
// Nothing to suggest, and a message isn't a valid reply here.
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{Choices: []*discordgo.ApplicationCommandOptionChoice{}},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
AllowedMentions: &discordgo.MessageAllowedMentions{},
},
})
}
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
// those). It answers the user and returns false when they aren't allowed:
//
// if !beta.Guard(s, i) { return }
func Guard(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if AllowedInteraction(i) {
return true
}
respond(s, i, denied)
return false
}
// Gate wraps a command handler so it only runs for beta testers.
func Gate(h Handler) Handler {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !Guard(s, i) {
return
}
h(s, i)
}
}
+240
View File
@@ -0,0 +1,240 @@
package beta
import (
"errors"
"testing"
"github.com/bwmarrin/discordgo"
)
const (
betaGuild = "700000000000000001"
otherGuild = "999000000000000001"
testerRol = "555000000000000001"
testerID = "777000000000000001"
)
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}}
}
// 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
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},
{"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},
{"no beta server configured: nobody", "", testerRol, betaGuild, member(testerRol), false, false, false},
{"a role ID that isn't a number is dropped", betaGuild, "tester-role", betaGuild, member("tester-role"), false, true, false},
{"a server ID that isn't a number is dropped", "my-server", testerRol, "my-server", member(testerRol), false, false, false},
{"a role that only contains the ID isn't a match", betaGuild, testerRol, betaGuild, member(testerRol + "9"), false, true, false},
{"stray spaces in the configuration are ignored", " " + betaGuild + " ", " " + testerRol + "\n", betaGuild, member(testerRol), true, true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
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)
}
if got := AllowedInteraction(interaction(tc.inGuild, tc.member)); got != tc.want {
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
}
st := StatusOf(tc.inGuild, tc.member)
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() != "" || len(Servers()) != 0 {
t.Error("before Configure nothing is allowed and there's no beta server")
}
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
t.Error("a missing interaction is never allowed")
}
}
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
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) {
answered = append(answered, msg)
}
t.Cleanup(func() { respond = orig })
ran := 0
h := Gate(func(*discordgo.Session, *discordgo.InteractionCreate) { ran++ })
h(nil, interaction(betaGuild, member(testerRol)))
if ran != 1 || len(answered) != 0 {
t.Fatalf("a tester runs the handler without being refused: ran=%d answered=%v", ran, answered)
}
for name, i := range map[string]*discordgo.InteractionCreate{
"no role": interaction(betaGuild, member("1")),
"opted-in, not a tester": interaction(otherGuild, member(testerRol)),
"server not opted in": interaction("123000000000000001", member(testerRol)),
"DM": interaction("", nil),
} {
before := ran
h(nil, i)
if ran != before {
t.Errorf("%s: the handler must not run", name)
}
}
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, nil)
n := 0
orig := respond
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
t.Cleanup(func() { respond = orig })
if !Guard(nil, interaction(betaGuild, member(testerRol))) || n != 0 {
t.Error("a tester passes without a reply")
}
if Guard(nil, interaction(betaGuild, member())) || n != 1 {
t.Error("someone else is refused with a reply")
}
}
+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)
}
}
+239 -12
View File
@@ -2,15 +2,20 @@ package bot
import ( import (
"context" "context"
"errors"
"log" "log"
"net/http"
"sync"
"time" "time"
"velox-bot/internal/beta"
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/commands/betacmd"
"velox-bot/internal/db/repos/musicrepo" "velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/events" "velox-bot/internal/events"
"velox-bot/internal/music" "velox-bot/internal/music"
"velox-bot/internal/tickets"
"velox-bot/internal/security" "velox-bot/internal/security"
"velox-bot/internal/tickets"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -20,12 +25,15 @@ type Bot struct {
AppID string AppID string
GuildID string GuildID string
Commands []*discordgo.ApplicationCommand Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand
Services *services.Services Services *services.Services
MusicRepo *musicrepo.Repo MusicRepo *musicrepo.Repo
DBHost string DBHost string
LavalinkHost string LavalinkHost string
LavalinkPass string LavalinkPass string
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) { func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
@@ -48,6 +56,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
discordgo.IntentsDirectMessages discordgo.IntentsDirectMessages
return &Bot{ return &Bot{
betaRegistered: map[string]bool{},
betaKnown: map[string]bool{},
Session: session, Session: session,
AppID: appID, AppID: appID,
GuildID: guildID, GuildID: guildID,
@@ -103,18 +113,20 @@ 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 { for _, cmd := range b.Commands {
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd) if _, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd); err != nil {
if err != nil {
log.Printf("Cannot create '%v' command: %v", cmd.Name, err) log.Printf("Cannot create '%v' command: %v", cmd.Name, err)
continue
} }
b.registeredCommands = append(b.registeredCommands, created)
} }
b.Session.AddHandler(commands.HandleInteraction) b.Session.AddHandler(commands.HandleInteraction)
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
events.HandleChannelDelete(s, c, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if eng := security.Default(); eng != nil { if eng := security.Default(); eng != nil {
eng.OnMessage(context.Background(), m) eng.OnMessage(context.Background(), m)
@@ -196,11 +208,226 @@ func (b *Bot) Start() error {
return nil return nil
} }
// betaPollEvery is how often the bot looks for a change to the beta settings
// made on the dashboard.
const betaPollEvery = 30 * time.Second
// 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 := store.GetBetaSettings(ctx)
if err != nil {
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
return
}
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() {
t := time.NewTicker(betaPollEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sync()
}
}
}()
}
type cachedRoles struct {
roles []string
at time.Time
}
// 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
}
// 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 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 ok
}
for _, cmd := range existing {
if keep[cmd.Name] {
continue
}
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil {
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 { 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() return b.Session.Close()
} }
+87
View File
@@ -0,0 +1,87 @@
// Package betacmd is the /beta command: it shows how beta access is set up.
// It exists only in the beta server (see commands.BetaCommands), is hidden
// from everyone but administrators in Discord's command picker, and only the
// bot's owner can actually run it (the owner is looked up from the Discord
// application, so there's nothing to configure).
package betacmd
import (
"fmt"
"sync"
"velox-bot/internal/beta"
"github.com/bwmarrin/discordgo"
)
var (
noDM = false
noPerms = int64(0) // hides the command from everyone but administrators
ownerIDs sync.Map // user ID -> struct{}, filled once from the application
loadOnce sync.Mutex
loaded bool
)
var Beta = &discordgo.ApplicationCommand{
Name: "beta",
Description: "Show how beta access is set up (bot owner only)",
DMPermission: &noDM,
DefaultMemberPermissions: &noPerms,
}
// isOwner reports whether the user owns the bot's Discord application (or is
// on its team). If the application can't be read, nobody is the owner.
func isOwner(s *discordgo.Session, userID string) bool {
if userID == "" {
return false
}
loadOnce.Lock()
if !loaded {
app, err := s.Application("@me")
if err == nil && app != nil {
if app.Owner != nil {
ownerIDs.Store(app.Owner.ID, struct{}{})
}
if app.Team != nil {
ownerIDs.Store(app.Team.OwnerID, struct{}{})
for _, m := range app.Team.Members {
if m != nil && m.User != nil {
ownerIDs.Store(m.User.ID, struct{}{})
}
}
}
loaded = true
}
}
loadOnce.Unlock()
_, ok := ownerIDs.Load(userID)
return ok
}
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
var userID string
if i.Member != nil && i.Member.User != nil {
userID = i.Member.User.ID
}
var msg string
st := beta.StatusOf(i.GuildID, i.Member)
switch {
case !isOwner(s, userID):
msg = "Only the bot's owner can use this command."
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 = 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."
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
AllowedMentions: &discordgo.MessageAllowedMentions{},
},
})
}
+21 -1
View File
@@ -2,6 +2,8 @@ package commands
import ( import (
"strings" "strings"
"velox-bot/internal/beta"
"velox-bot/internal/commands/betacmd"
"velox-bot/internal/commands/config" "velox-bot/internal/commands/config"
"velox-bot/internal/commands/fun" "velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help" "velox-bot/internal/commands/help"
@@ -11,8 +13,8 @@ import (
cmdmusic "velox-bot/internal/commands/music" cmdmusic "velox-bot/internal/commands/music"
"velox-bot/internal/commands/projects" "velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule" "velox-bot/internal/commands/schedule"
cmdtickets "velox-bot/internal/commands/tickets"
cmdsecurity "velox-bot/internal/commands/security" cmdsecurity "velox-bot/internal/commands/security"
cmdtickets "velox-bot/internal/commands/tickets"
"velox-bot/internal/commands/timezone" "velox-bot/internal/commands/timezone"
"velox-bot/internal/music" "velox-bot/internal/music"
@@ -79,6 +81,24 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"unlock": cmdsecurity.UnlockHandler, "unlock": cmdsecurity.UnlockHandler,
} }
// BetaCommands are the beta features' slash commands. They're registered in
// the beta server only, so nobody else even sees them, and each handler is
// wrapped so it runs only for members with the Beta Tester role (see the beta
// package). To put a feature in beta, add it with RegisterBeta instead of to
// AllCommands and handlers; to release it, move it there.
var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta}
func init() {
// /beta itself is open to the whole beta server (see betacmd).
handlers[betacmd.Beta.Name] = betacmd.Handler
}
// RegisterBeta adds a slash command that only beta testers can use.
func RegisterBeta(cmd *discordgo.ApplicationCommand, h func(s *discordgo.Session, i *discordgo.InteractionCreate)) {
BetaCommands = append(BetaCommands, cmd)
handlers[cmd.Name] = beta.Gate(h)
}
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type { switch i.Type {
case discordgo.InteractionApplicationCommand: case discordgo.InteractionApplicationCommand:
+3 -1
View File
@@ -106,7 +106,9 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
{ {
Name: "Meetings (**Manage Server**)", Name: "Meetings (**Manage Server**)",
Value: "" + Value: "" +
"`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\n" + "`/meeting add-lobby channel:#voice` — Add a voice lobby that creates meetings (up to 10)\n" +
"`/meeting remove-lobby channel:#voice` — Stop using a voice lobby\n" +
"`/meeting lobbies` — List the voice lobbies\n" +
"`/meeting lock` — Lock your meeting room\n" + "`/meeting lock` — Lock your meeting room\n" +
"`/meeting lock-private` — Lock + hide your meeting room\n" + "`/meeting lock-private` — Lock + hide your meeting room\n" +
"`/meeting unlock` — Unlock your meeting room\n" + "`/meeting unlock` — Unlock your meeting room\n" +
+102 -16
View File
@@ -2,11 +2,14 @@ package public
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"velox-bot/internal/commands/meeting/shared" "velox-bot/internal/commands/meeting/shared"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
meetingsvc "velox-bot/internal/db/services/meeting"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -17,18 +20,37 @@ var Meeting = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "set-lobby", Name: "add-lobby",
Description: "Set the voice lobby channel used to create meetings", Description: "Add a voice lobby: joining it creates a meeting room",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionChannel, Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel", Name: "channel",
Description: "Voice channel lobby", Description: "Voice channel to use as a lobby",
Required: true, Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
}, },
}, },
}, },
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "remove-lobby",
Description: "Stop using a voice channel as a lobby",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel",
Description: "The lobby to remove",
Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "lobbies",
Description: "List this server's meeting lobbies",
},
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "lock", Name: "lock",
@@ -85,8 +107,12 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
switch data.Options[0].Name { switch data.Options[0].Name {
case "set-lobby": case "add-lobby":
handleSetLobby(s, i) handleAddLobby(s, i)
case "remove-lobby":
handleRemoveLobby(s, i)
case "lobbies":
handleListLobbies(s, i)
case "lock": case "lock":
handleLock(s, i) handleLock(s, i)
case "lock-private": case "lock-private":
@@ -102,37 +128,97 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
} }
func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { // lobbyChannelOption reads the "channel" option of add-lobby and
guildID, ok := shared.ParseGuildID(s, i) // remove-lobby, answering the user itself if it's missing or not a voice
if !ok { // channel.
return func lobbyChannelOption(s *discordgo.Session, i *discordgo.InteractionCreate) (*discordgo.Channel, int64, bool) {
}
optMap := shared.SubcommandOptionMap(i) optMap := shared.SubcommandOptionMap(i)
channelOpt, ok := optMap["channel"] channelOpt, ok := optMap["channel"]
if !ok { if !ok {
shared.RespondEphemeral(s, i, "Missing channel option.") shared.RespondEphemeral(s, i, "Missing channel option.")
return return nil, 0, false
} }
ch := channelOpt.ChannelValue(s) ch := channelOpt.ChannelValue(s)
if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice { if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice {
shared.RespondEphemeral(s, i, "Invalid voice channel.") shared.RespondEphemeral(s, i, "Invalid voice channel.")
return return nil, 0, false
} }
channelID, err := strconv.ParseInt(ch.ID, 10, 64) channelID, err := strconv.ParseInt(ch.ID, 10, 64)
if err != nil { if err != nil {
shared.RespondEphemeral(s, i, "Invalid channel ID.") shared.RespondEphemeral(s, i, "Invalid channel ID.")
return nil, 0, false
}
return ch, channelID, true
}
func handleAddLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
ch, channelID, ok := lobbyChannelOption(s, i)
if !ok {
return return
} }
if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil { added, err := services.Global.Meeting.AddLobby(context.Background(), guildID, channelID)
shared.RespondEphemeral(s, i, "Failed to set meeting lobby.") switch {
case errors.Is(err, meetingsvc.ErrLobbyLimit):
shared.RespondEphemeral(s, i, fmt.Sprintf("This server already has %d lobbies. Remove one with `/meeting remove-lobby` first.", meetingsvc.MaxLobbies))
case err != nil:
shared.RespondEphemeral(s, i, "Failed to add the meeting lobby.")
case !added:
shared.RespondEphemeral(s, i, ch.Mention()+" is already a meeting lobby.")
default:
shared.RespondEphemeral(s, i, ch.Mention()+" is now a meeting lobby.")
}
}
func handleRemoveLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
ch, channelID, ok := lobbyChannelOption(s, i)
if !ok {
return return
} }
shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".") removed, err := services.Global.Meeting.RemoveLobby(context.Background(), guildID, channelID)
switch {
case err != nil:
shared.RespondEphemeral(s, i, "Failed to remove the meeting lobby.")
case !removed:
shared.RespondEphemeral(s, i, ch.Mention()+" isn't a meeting lobby.")
default:
shared.RespondEphemeral(s, i, ch.Mention()+" is no longer a meeting lobby.")
}
}
func handleListLobbies(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
ids, err := services.Global.Meeting.ListLobbies(context.Background(), guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to list the meeting lobbies.")
return
}
if len(ids) == 0 {
shared.RespondEphemeral(s, i, "No meeting lobbies yet. Add one with `/meeting add-lobby`.")
return
}
var b strings.Builder
fmt.Fprintf(&b, "Meeting lobbies (%d of %d):\n", len(ids), meetingsvc.MaxLobbies)
for _, id := range ids {
fmt.Fprintf(&b, "<#%d>\n", id)
}
shared.RespondEphemeral(s, i, b.String())
} }
func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) {
+31 -3
View File
@@ -60,7 +60,7 @@ var Tickets = &discordgo.ApplicationCommand{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add", Name: "add",
Description: "Add a category (one button on the panel)", Description: "Add a category (an option in the panel's dropdown)",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40}, {Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"}, {Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
@@ -424,7 +424,7 @@ func closeTicket(s *discordgo.Session, i *discordgo.InteractionCreate, reason st
edit(s, i, "Ticket closed.") edit(s, i, "Ticket closed.")
} }
// HandleComponent handles the panel buttons and dropdown and the Claim and // HandleComponent handles the panel dropdown and Create ticket button and the Claim and
// Close buttons in a ticket. // Close buttons in a ticket.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil { if Repo == nil {
@@ -434,9 +434,12 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.MessageComponentData() data := i.MessageComponentData()
switch { switch {
case strings.HasPrefix(data.CustomID, "tickets:open:"): case strings.HasPrefix(data.CustomID, "tickets:open:"):
// Panels posted before the dropdown had one button per category.
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:")) showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
case data.CustomID == "tickets:select" && len(data.Values) > 0: case data.CustomID == "tickets:select" && len(data.Values) > 0:
showOpenForm(s, i, data.Values[0]) rememberPick(s, i, data.Values[0])
case data.CustomID == "tickets:create":
createFromPick(s, i)
case data.CustomID == "tickets:claim": case data.CustomID == "tickets:claim":
claim(s, i) claim(s, i)
case data.CustomID == "tickets:close": case data.CustomID == "tickets:close":
@@ -450,6 +453,31 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
} }
// rememberPick stores the dropdown choice for the Create ticket button and
// acknowledges it without changing the panel, so the choice stays visible.
func rememberPick(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
catID, err := strconv.ParseInt(rawCategory, 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
tickets.RememberPick(guildID(i), userID, catID)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseDeferredMessageUpdate})
}
func createFromPick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
catID, ok := tickets.PickedCategory(guildID(i), userID)
if !ok {
reply(s, i, "Pick a category from the menu first, then press **Create ticket**.")
return
}
showOpenForm(s, i, strconv.FormatInt(catID, 10))
}
func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) { func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
catID, err := strconv.ParseInt(rawCategory, 10, 64) catID, err := strconv.ParseInt(rawCategory, 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil { if err != nil || i.Member == nil || i.Member.User == nil {
@@ -0,0 +1,258 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied
// (the lobby limit is a trigger from migration 000020):
//
// TEST_DB=postgres://... go test -tags integration ./internal/db/repos/settingsrepo
package settingsrepo
import (
"context"
"database/sql"
"errors"
"os"
"sync"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testRepo(t *testing.T) (*Repo, int64) {
t.Helper()
dsn := os.Getenv("TEST_DB")
if dsn == "" {
t.Skip("TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
const guild = 4242
if _, err := db.Exec(`DELETE FROM meeting_lobbies WHERE guild_id IN ($1, $2)`, guild, guild+1); err != nil {
t.Fatal(err)
}
return NewRepo(db), guild
}
func TestMeetingLobbies(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if ids, err := r.ListMeetingLobbies(ctx, g); err != nil || len(ids) != 0 {
t.Fatalf("a fresh server has no lobbies: %v %v", ids, err)
}
for ch := int64(1); ch <= MaxMeetingLobbies; ch++ {
added, err := r.AddMeetingLobby(ctx, g, ch)
if err != nil || !added {
t.Fatalf("adding lobby %d: added=%v err=%v", ch, added, err)
}
}
if _, err := r.AddMeetingLobby(ctx, g, 99); !errors.Is(err, ErrMeetingLobbyLimit) {
t.Errorf("an 11th lobby must hit the limit, got %v", err)
}
if added, err := r.AddMeetingLobby(ctx, g, 3); err != nil || added {
t.Errorf("adding an existing lobby is a no-op even at the limit: added=%v err=%v", added, err)
}
if added, err := r.AddMeetingLobby(ctx, g+1, 99); err != nil || !added {
t.Errorf("the limit is per server: added=%v err=%v", added, err)
}
if ok, _ := r.IsMeetingLobby(ctx, g, 7); !ok {
t.Error("7 is a lobby")
}
if ok, _ := r.IsMeetingLobby(ctx, g, 99); ok {
t.Error("99 isn't a lobby of this server (it is one of the other server's)")
}
if removed, err := r.RemoveMeetingLobby(ctx, g, 7); err != nil || !removed {
t.Errorf("removing 7: removed=%v err=%v", removed, err)
}
if removed, _ := r.RemoveMeetingLobby(ctx, g, 7); removed {
t.Error("7 was already removed")
}
if ok, _ := r.IsMeetingLobby(ctx, g, 7); ok {
t.Error("7 is no longer a lobby")
}
if added, err := r.AddMeetingLobby(ctx, g, 99); err != nil || !added {
t.Errorf("removing one makes room: added=%v err=%v", added, err)
}
ids, err := r.ListMeetingLobbies(ctx, g)
if err != nil || len(ids) != MaxMeetingLobbies || ids[len(ids)-1] != 99 {
t.Errorf("oldest first, newest last: %v %v", ids, err)
}
}
// Two people adding a lobby at the same moment must not both fit under the limit.
func TestMeetingLobbiesConcurrent(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
for ch := int64(1); ch < MaxMeetingLobbies; ch++ {
if _, err := r.AddMeetingLobby(ctx, g, ch); err != nil {
t.Fatal(err)
}
}
var wg sync.WaitGroup
var mu sync.Mutex
succeeded := 0
for i := 0; i < 8; i++ {
wg.Add(1)
go func(ch int64) {
defer wg.Done()
if added, err := r.AddMeetingLobby(ctx, g, ch); err == nil && added {
mu.Lock()
succeeded++
mu.Unlock()
}
}(int64(100 + i))
}
wg.Wait()
if succeeded != 1 {
t.Errorf("exactly one of the concurrent adds fits, %d did", succeeded)
}
if ids, _ := r.ListMeetingLobbies(ctx, g); len(ids) != MaxMeetingLobbies {
t.Errorf("have %d lobbies, want %d", len(ids), MaxMeetingLobbies)
}
}
func TestGetBetaSettings(t *testing.T) {
r, _ := testRepo(t)
db := r.db
ctx := context.Background()
_, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_settings (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), guild_id BIGINT, role_id BIGINT, updated_by BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
if err != nil {
t.Fatal(err)
}
var wasG, wasR sql.NullInt64
hadRow := db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil
t.Cleanup(func() {
if hadRow {
_, _ = db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR)
} else {
_, _ = db.ExecContext(ctx, `DELETE FROM beta_settings`)
}
})
_, err = db.ExecContext(ctx, `DELETE FROM beta_settings`)
if err != nil {
t.Fatal(err)
}
g, role, err := r.GetBetaSettings(ctx)
if err != nil {
t.Fatal(err)
}
if g != "" {
t.Fatalf("g = %q, want empty", g)
}
if role != "" {
t.Fatalf("role = %q, want empty", role)
}
_, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 700000000000000001)`)
if err != nil {
t.Fatal(err)
}
g, role, err = r.GetBetaSettings(ctx)
if err != nil {
t.Fatal(err)
}
if g != "700000000000000001" {
t.Fatalf("g = %q, want %q", g, "700000000000000001")
}
if role != "" {
t.Fatalf("role = %q, want empty", role)
}
_, err = db.ExecContext(ctx, `UPDATE beta_settings SET role_id = 42`)
if err != nil {
t.Fatal(err)
}
_, role, err = r.GetBetaSettings(ctx)
if err != nil {
t.Fatal(err)
}
if role != "42" {
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")
}
}
+139 -23
View File
@@ -3,6 +3,10 @@ package settingsrepo
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"strconv"
"github.com/jackc/pgx/v5/pgconn"
) )
type Repo struct { type Repo struct {
@@ -25,36 +29,75 @@ func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db} return &Repo{db: db}
} }
func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { // MaxMeetingLobbies is how many lobby channels one server can have. The
// database enforces it too (a trigger from the API's migration 000020), so
// the command and the dashboard can't disagree.
const MaxMeetingLobbies = 10
// ErrMeetingLobbyLimit means the server already has MaxMeetingLobbies.
var ErrMeetingLobbyLimit = errors.New("meeting lobby limit reached")
// pgLimitExceeded is the error code the database raises for one lobby too many.
const pgLimitExceeded = "54000"
// AddMeetingLobby makes channelID a lobby. added is false when it already
// was one; going over the limit is ErrMeetingLobbyLimit.
func (r *Repo) AddMeetingLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) {
const q = ` const q = `
INSERT INTO meeting_settings (guild_id, lobby_channel_id) INSERT INTO meeting_lobbies (guild_id, channel_id)
VALUES ($1, $2) VALUES ($1, $2)
ON CONFLICT (guild_id) ON CONFLICT DO NOTHING
DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id
` `
_, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID) res, err := r.db.ExecContext(ctx, q, guildID, channelID)
return err var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == pgLimitExceeded {
return false, ErrMeetingLobbyLimit
}
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
} }
func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { // RemoveMeetingLobby takes a channel off the lobby list; removed is false
const q = ` // when it wasn't one.
SELECT lobby_channel_id func (r *Repo) RemoveMeetingLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
FROM meeting_settings const q = `DELETE FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2`
WHERE guild_id = $1 res, err := r.db.ExecContext(ctx, q, guildID, channelID)
` if err != nil {
row := r.db.QueryRowContext(ctx, q, guildID) return false, err
var ch sql.NullInt64
switch err := row.Scan(&ch); err {
case nil:
if !ch.Valid {
return 0, false, nil
} }
return ch.Int64, true, nil n, err := res.RowsAffected()
case sql.ErrNoRows: return n > 0, err
return 0, false, nil
default:
return 0, false, err
} }
// ListMeetingLobbies returns a server's lobbies, oldest first.
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
const q = `SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at, channel_id`
rows, err := r.db.QueryContext(ctx, q, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// IsMeetingLobby reports whether channelID is one of the server's lobbies.
// It runs whenever someone joins a voice channel, so it's one indexed lookup.
func (r *Repo) IsMeetingLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
const q = `SELECT EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2)`
var ok bool
err := r.db.QueryRowContext(ctx, q, guildID, channelID).Scan(&ok)
return ok, err
} }
func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
@@ -359,3 +402,76 @@ func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool)
_, err := r.db.ExecContext(ctx, q, guildID, enabled) _, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err return err
} }
// GetBetaSettings returns the beta server and Beta Tester role as set on the
// dashboard. Either is "" when not set (or when there's no row yet).
func (r *Repo) GetBetaSettings(ctx context.Context) (guildID, roleID string, err error) {
var g, role sql.NullInt64
err = r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id = TRUE`).Scan(&g, &role)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", err
}
if g.Valid {
guildID = strconv.FormatInt(g.Int64, 10)
}
if role.Valid {
roleID = strconv.FormatInt(role.Int64, 10)
}
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
}
+6 -4
View File
@@ -93,9 +93,10 @@ func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser) err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
switch { switch {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
// Modules start off until the server sets them up.
return &Settings{ return &Settings{
GuildID: guildID, GuildID: guildID,
Enabled: true, Enabled: false,
PanelTitle: DefaultPanelTitle, PanelTitle: DefaultPanelTitle,
PanelDescription: DefaultPanelDescription, PanelDescription: DefaultPanelDescription,
MaxOpenPerUser: DefaultMaxOpenPerUser, MaxOpenPerUser: DefaultMaxOpenPerUser,
@@ -109,11 +110,12 @@ func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error
} }
// SetChannels saves where tickets live and where transcripts go. A zero log // SetChannels saves where tickets live and where transcripts go. A zero log
// channel clears it. // channel clears it. Setting tickets up for the first time turns them on
// (they start off); after that, the on/off switch is left alone.
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error { func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
const q = ` const q = `
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id) INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id, enabled)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, TRUE)
ON CONFLICT (guild_id) DO UPDATE SET ON CONFLICT (guild_id) DO UPDATE SET
channel_id = EXCLUDED.channel_id, channel_id = EXCLUDED.channel_id,
log_channel_id = EXCLUDED.log_channel_id` log_channel_id = EXCLUDED.log_channel_id`
+8 -6
View File
@@ -102,10 +102,13 @@ func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64
return chID.Int64, true, nil return chID.Int64, true, nil
} }
// SetNotificationChannel sets where live alerts go. Setting it for the first
// time turns alerts on (they start off); after that, the on/off switch is
// left alone.
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error { func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
const q = ` const q = `
INSERT INTO twitch_config (guild_id, twitch_channel_id) INSERT INTO twitch_config (guild_id, twitch_channel_id, enabled)
VALUES ($1, $2) VALUES ($1, $2, TRUE)
ON CONFLICT (guild_id) ON CONFLICT (guild_id)
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
` `
@@ -114,15 +117,14 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in
} }
// IsEnabled reports whether Twitch notifications are enabled for guildID. // IsEnabled reports whether Twitch notifications are enabled for guildID.
// A guild with no twitch_config row yet defaults to enabled, matching the // A guild with no twitch_config row yet is off: modules start off until
// column's own DEFAULT TRUE - "never configured" and "explicitly on" are // the server sets them up.
// the same state until someone actually flips it off.
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) { func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1` const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
var enabled bool var enabled bool
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled) err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return true, nil return false, nil
} }
if err != nil { if err != nil {
return false, err return false, err
+18 -4
View File
@@ -14,12 +14,26 @@ func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings} return &Service{settings: settings}
} }
func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { // MaxLobbies is how many lobbies a server can have.
return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID) const MaxLobbies = settingsrepo.MaxMeetingLobbies
// ErrLobbyLimit means the server already has MaxLobbies.
var ErrLobbyLimit = settingsrepo.ErrMeetingLobbyLimit
func (s *Service) AddLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) {
return s.settings.AddMeetingLobby(ctx, guildID, channelID)
} }
func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { func (s *Service) RemoveLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
return s.settings.GetMeetingLobbyChannel(ctx, guildID) return s.settings.RemoveMeetingLobby(ctx, guildID, channelID)
}
func (s *Service) ListLobbies(ctx context.Context, guildID int64) ([]int64, error) {
return s.settings.ListMeetingLobbies(ctx, guildID)
}
func (s *Service) IsLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
return s.settings.IsMeetingLobby(ctx, guildID, channelID)
} }
func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
+23 -4
View File
@@ -13,7 +13,7 @@ import (
) )
// HandleVoiceStateUpdate implements the "meeting lobby" behavior: // HandleVoiceStateUpdate implements the "meeting lobby" behavior:
// - When a user joins the configured lobby voice channel, create a temporary voice channel // - When a user joins one of the server's lobby voice channels, create a temporary voice channel
// with the same UserLimit and move the user into it. // with the same UserLimit and move the user into it.
// - When a temporary channel becomes empty, delete it. // - When a temporary channel becomes empty, delete it.
func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) { func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) {
@@ -40,11 +40,12 @@ func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate
return return
} }
lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64) joinedID, err := strconv.ParseInt(vs.ChannelID, 10, 64)
if err != nil || !ok || lobbyID == 0 { if err != nil {
return return
} }
if vs.ChannelID != strconv.FormatInt(lobbyID, 10) { isLobby, err := svc.Meeting.IsLobby(ctx, guildID64, joinedID)
if err != nil || !isLobby {
return return
} }
@@ -114,3 +115,21 @@ func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *servi
_ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64) _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64)
} }
// HandleChannelDelete drops a deleted voice channel from the meeting lobbies,
// so it doesn't keep using up one of the server's slots.
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) {
if svc == nil || svc.Meeting == nil || c == nil || c.Channel == nil || c.GuildID == "" {
return
}
if c.Type != discordgo.ChannelTypeGuildVoice {
return
}
guildID, err1 := strconv.ParseInt(c.GuildID, 10, 64)
channelID, err2 := strconv.ParseInt(c.ID, 10, 64)
if err1 != nil || err2 != nil {
return
}
if _, err := svc.Meeting.RemoveLobby(context.Background(), guildID, channelID); err != nil {
log.Printf("meeting: removing deleted lobby guild=%s channel=%s err=%v", c.GuildID, c.ID, err)
}
}
+1 -1
View File
@@ -182,7 +182,7 @@ func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, tra
embed := &discordgo.MessageEmbed{ embed := &discordgo.MessageEmbed{
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)), Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
Description: "**" + clip(t.Subject, maxSubject) + "**", Description: "**" + clip(t.Subject, maxSubject) + "**",
Color: 0x6b7280, Color: accent,
Fields: []*discordgo.MessageEmbedField{ Fields: []*discordgo.MessageEmbedField{
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true}, {Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
{Name: "Claimed by", Value: claimed, Inline: true}, {Name: "Claimed by", Value: claimed, Inline: true},
-1
View File
@@ -86,7 +86,6 @@ func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."}, Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
} }
if t.Status == ticketrepo.StatusClosed { if t.Status == ticketrepo.StatusClosed {
e.Color = 0x6b7280
e.Footer.Text = "This ticket is closed." e.Footer.Text = "This ticket is closed."
} }
return e return e
+16 -27
View File
@@ -15,8 +15,9 @@ import (
// Custom IDs. Everything starts with "tickets:" so the interaction router can // Custom IDs. Everything starts with "tickets:" so the interaction router can
// hand it to this package. // hand it to this package.
const ( const (
idOpenPrefix = "tickets:open:" // panel button, + category ID idOpenPrefix = "tickets:open:" // older panels: one button per category, + category ID
idSelect = "tickets:select" // panel dropdown (many categories) idSelect = "tickets:select" // panel dropdown
idCreate = "tickets:create" // panel "Create ticket" button
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
idClaim = "tickets:claim" idClaim = "tickets:claim"
idClose = "tickets:close" idClose = "tickets:close"
@@ -42,8 +43,8 @@ func parseEmoji(s string) *discordgo.ComponentEmoji {
} }
// BuildPanel is the message members open tickets from: an embed with the // BuildPanel is the message members open tickets from: an embed with the
// guild's text and one button per category, or a dropdown once there are // guild's text and each category's description, a dropdown to pick the
// more categories than fit comfortably in a row. // category, and a Create ticket button that opens the form for that pick.
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) { func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
embed := &discordgo.MessageEmbed{ embed := &discordgo.MessageEmbed{
Title: clip(s.PanelTitle, 256), Title: clip(s.PanelTitle, 256),
@@ -54,27 +55,7 @@ func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.
cats = cats[:maxCategories] cats = cats[:maxCategories]
} }
if len(cats) <= maxPanelButtons { opts := make([]discordgo.SelectMenuOption, 0, len(cats))
var buttons []discordgo.MessageComponent
for _, c := range cats {
buttons = append(buttons, discordgo.Button{
Label: clip(c.Name, 80),
Style: discordgo.PrimaryButton,
CustomID: idOpenPrefix + id(c.ID),
Emoji: parseEmoji(c.Emoji),
})
}
// With few categories their descriptions go in the embed, since a
// button has no room for one.
for _, c := range cats {
if c.Description != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
}
}
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: buttons}}
}
var opts []discordgo.SelectMenuOption
for _, c := range cats { for _, c := range cats {
opts = append(opts, discordgo.SelectMenuOption{ opts = append(opts, discordgo.SelectMenuOption{
Label: clip(c.Name, 100), Label: clip(c.Name, 100),
@@ -82,10 +63,18 @@ func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.
Description: clip(c.Description, 100), Description: clip(c.Description, 100),
Emoji: parseEmoji(c.Emoji), Emoji: parseEmoji(c.Emoji),
}) })
if c.Description != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
} }
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{ }
return embed, []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts}, discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
}}} }},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Create ticket", Style: discordgo.PrimaryButton, CustomID: idCreate, Emoji: &discordgo.ComponentEmoji{Name: "🎫"}},
}},
}
} }
// PostPanel posts the panel in the ticket channel, or updates the one it // PostPanel posts the panel in the ticket channel, or updates the one it
+59
View File
@@ -0,0 +1,59 @@
package tickets
import (
"sync"
"time"
)
// Discord doesn't send a dropdown's value along with a button click, so the
// panel's category pick is remembered here between the two interactions:
// per guild and member, for a short while, in memory. A bot restart forgets
// it, which only means picking the category again.
const (
pickTTL = 15 * time.Minute
maxPicks = 10000 // a ceiling, so a flood of picks can't grow this forever
)
type pickKey struct{ guild, user int64 }
type pick struct {
category int64
at time.Time
}
var (
picksMu sync.Mutex
picks = map[pickKey]pick{}
now = time.Now
)
// RememberPick records the category a member chose in the panel dropdown.
func RememberPick(guildID, userID, categoryID int64) {
picksMu.Lock()
defer picksMu.Unlock()
t := now()
k := pickKey{guildID, userID}
if _, ok := picks[k]; !ok && len(picks) >= maxPicks {
for k, p := range picks {
if t.Sub(p.at) > pickTTL {
delete(picks, k)
}
}
if len(picks) >= maxPicks {
return
}
}
picks[k] = pick{categoryID, t}
}
// PickedCategory returns the category a member chose, if they did recently.
func PickedCategory(guildID, userID int64) (int64, bool) {
picksMu.Lock()
defer picksMu.Unlock()
p, ok := picks[pickKey{guildID, userID}]
if !ok || now().Sub(p.at) > pickTTL {
delete(picks, pickKey{guildID, userID})
return 0, false
}
return p.category, true
}
+8 -6
View File
@@ -1,7 +1,8 @@
// Package tickets is the support ticket system: a panel message with one // Package tickets is the support ticket system: a panel message with a
// button per category, a short form, and a private thread per ticket that // category dropdown and a Create ticket button, a short form, and a private
// staff can claim and anyone involved can close. Closing locks and archives // thread per ticket that staff can claim and anyone involved can close.
// the thread and posts a transcript to the log channel. // Closing locks and archives the thread and posts a transcript to the log
// channel.
// //
// The Discord and database calls go through small interfaces so the flows // The Discord and database calls go through small interfaces so the flows
// can be tested without either; the slash command and button handlers in // can be tested without either; the slash command and button handlers in
@@ -67,7 +68,9 @@ const (
var ( var (
defaultStore Store defaultStore Store
accent = 0x2b7bfa // Every ticket embed (panel, ticket, log) is orange, so support stands
// apart from the bot's other messages.
accent = 0xf97316
) )
func Init(s Store) { defaultStore = s } func Init(s Store) { defaultStore = s }
@@ -80,7 +83,6 @@ const (
maxSubject = 100 maxSubject = 100
maxDetails = 1000 maxDetails = 1000
maxReason = 300 maxReason = 300
maxPanelButtons = 5 // more categories than this switch the panel to a dropdown
maxCategories = 25 maxCategories = 25
autoArchiveWeek = 10080 autoArchiveWeek = 10080
) )
+61 -10
View File
@@ -74,26 +74,77 @@ func TestBuildPanel(t *testing.T) {
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"}, {ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
} }
embed, comps := BuildPanel(s, cats) embed, comps := BuildPanel(s, cats)
row := comps[0].(discordgo.ActionsRow) if embed.Title != "Help" || embed.Color != 0xf97316 {
b1, b2 := row.Components[0].(discordgo.Button), row.Components[1].(discordgo.Button) t.Errorf("panel embed wrong: %+v", embed)
if embed.Title != "Help" || b1.CustomID != "tickets:open:1" || b1.Emoji.Name != "🛟" {
t.Errorf("first button wrong: %+v", b1)
} }
if b2.Emoji.ID != "123" || b2.Emoji.Name != "party" || !b2.Emoji.Animated { if len(comps) != 2 {
t.Errorf("custom emoji not parsed: %+v", b2.Emoji) t.Fatalf("want a dropdown row and a button row, got %d rows", len(comps))
}
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
if menu.CustomID != "tickets:select" || len(menu.Options) != 2 || menu.Options[0].Value != "1" || menu.Options[0].Emoji.Name != "🛟" {
t.Errorf("dropdown wrong: %+v", menu)
}
if e := menu.Options[1].Emoji; e.ID != "123" || e.Name != "party" || !e.Animated {
t.Errorf("custom emoji not parsed: %+v", e)
}
button := comps[1].(discordgo.ActionsRow).Components[0].(discordgo.Button)
if button.CustomID != "tickets:create" || button.Label != "Create ticket" {
t.Errorf("create button wrong: %+v", button)
} }
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" { if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
t.Errorf("descriptions belong in the embed: %+v", embed.Fields) t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
} }
var many []ticketrepo.Category var many []ticketrepo.Category
for i := 1; i <= 7; i++ { for i := 1; i <= 30; i++ {
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)}) many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
} }
_, comps = BuildPanel(s, many) _, comps = BuildPanel(s, many)
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu) if n := len(comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu).Options); n != 25 {
if menu.CustomID != "tickets:select" || len(menu.Options) != 7 || menu.Options[6].Value != "7" { t.Errorf("a dropdown holds at most 25 options, got %d", n)
t.Errorf("more than 5 categories should use a dropdown: %+v", menu) }
}
func TestPicks(t *testing.T) {
clock := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return clock }
t.Cleanup(func() { now = time.Now; picks = map[pickKey]pick{} })
if _, ok := PickedCategory(guild, 5); ok {
t.Error("nothing picked yet")
}
RememberPick(guild, 5, 3)
RememberPick(guild, 5, 4)
RememberPick(guild, 6, 9)
if c, ok := PickedCategory(guild, 5); !ok || c != 4 {
t.Errorf("the latest pick wins: %d %v", c, ok)
}
if _, ok := PickedCategory(guild+1, 5); ok {
t.Error("picks are per guild")
}
clock = clock.Add(pickTTL + time.Second)
if _, ok := PickedCategory(guild, 5); ok {
t.Error("a pick expires")
}
// Full: new members can't add more, but expired ones make room.
picks = map[pickKey]pick{}
for i := 0; i < maxPicks; i++ {
picks[pickKey{guild + 100, int64(i)}] = pick{1, clock}
}
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); ok {
t.Error("a full map takes no new members")
}
RememberPick(guild+100, 0, 2)
if c, _ := PickedCategory(guild+100, 0); c != 2 {
t.Error("someone already in the map can still change their pick")
}
clock = clock.Add(pickTTL + time.Second)
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); !ok {
t.Error("expired picks should be cleared to make room")
} }
} }
+4
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -92,6 +93,9 @@ func main() {
defer bot.Close() defer bot.Close()
// Who the beta testers are is set on the dashboard; follow it.
bot.WatchBeta(context.Background(), settingsRepo)
fmt.Println("Bot is running...") fmt.Println("Bot is running...")
sc := make(chan os.Signal, 1) sc := make(chan os.Signal, 1)
+55 -2
View File
@@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS levelup (
CREATE TABLE IF NOT EXISTS twitch_config ( CREATE TABLE IF NOT EXISTS twitch_config (
guild_id BIGINT PRIMARY KEY, guild_id BIGINT PRIMARY KEY,
twitch_channel_id BIGINT, twitch_channel_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE enabled BOOLEAN NOT NULL DEFAULT FALSE
); );
CREATE TABLE IF NOT EXISTS rps ( CREATE TABLE IF NOT EXISTS rps (
@@ -73,6 +73,59 @@ CREATE TABLE IF NOT EXISTS meeting_settings (
lobby_channel_id BIGINT lobby_channel_id BIGINT
); );
-- Who counts as a beta tester: holders of role_id in guild_id. One row, set by
-- the owner on the dashboard's admin page (the API creates and writes it, see
-- its migration 000021); the bot only reads it.
CREATE TABLE IF NOT EXISTS beta_settings (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
guild_id BIGINT,
role_id BIGINT,
updated_by BIGINT,
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.
CREATE TABLE IF NOT EXISTS meeting_lobbies (
guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, channel_id)
);
CREATE OR REPLACE FUNCTION meeting_lobbies_limit() RETURNS trigger AS $$
BEGIN
PERFORM pg_advisory_xact_lock(NEW.guild_id);
IF EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = NEW.guild_id AND channel_id = NEW.channel_id) THEN
RETURN NEW;
END IF;
IF (SELECT COUNT(*) FROM meeting_lobbies WHERE guild_id = NEW.guild_id) >= 10 THEN
RAISE EXCEPTION 'a server can have at most 10 lobby channels' USING ERRCODE = '54000';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS meeting_lobbies_limit ON meeting_lobbies;
CREATE TRIGGER meeting_lobbies_limit BEFORE INSERT ON meeting_lobbies
FOR EACH ROW EXECUTE FUNCTION meeting_lobbies_limit();
CREATE TABLE IF NOT EXISTS meeting_temp_channels ( CREATE TABLE IF NOT EXISTS meeting_temp_channels (
guild_id BIGINT NOT NULL, guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL, channel_id BIGINT NOT NULL,
@@ -180,7 +233,7 @@ CREATE TABLE IF NOT EXISTS music_commands (
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets) -- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
CREATE TABLE IF NOT EXISTS ticket_settings ( CREATE TABLE IF NOT EXISTS ticket_settings (
guild_id BIGINT PRIMARY KEY, guild_id BIGINT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT FALSE,
channel_id BIGINT, channel_id BIGINT,
log_channel_id BIGINT, log_channel_id BIGINT,
panel_message_id BIGINT, panel_message_id BIGINT,