Compare commits

...
26 Commits
Author SHA1 Message Date
FernandoJVideira 5e6b4debbc Merge pull request 'v3.6.0 (music commands trimmed)' (#36) from dev into master
Reviewed-on: #36
2026-10-23 11:19:21 +00:00
FernandoJVideira 3eb1ce5b0a Merge pull request 'Music: only remove and move are commands' (#35) from fix/music-only-remove-move into dev
Reviewed-on: #35
2026-10-23 11:16:48 +00:00
FernandoJVideiraandClaude Sonnet 5.5 88fc915f3b Music: only /music remove and /music move are commands
Pause, skip, repeat and stop are already buttons on the now-playing message.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 12:16:20 +01:00
FernandoJVideira 4eed64c7bb Merge pull request 'v3.6.0' (#34) from dev into master
Reviewed-on: #34
2026-10-23 11:06:06 +00:00
FernandoJVideira 92c9f6b686 Merge pull request 'Help: fall back to a private message in the channel when DMs fail' (#33) from fix/help-dms-off into dev
Reviewed-on: #33
2026-10-23 11:05:20 +00:00
FernandoJVideira 12a3cadcf7 Merge pull request 'Help: wide and short' (#32) from ft/help-columns into dev
Reviewed-on: #32
2026-10-23 11:05:13 +00:00
FernandoJVideiraandClaude Sonnet 5.5 5bc2da4ad8 Help: log whether the bot is a member of the server when the DM fails
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:57:30 +01:00
FernandoJVideiraandClaude Sonnet 5.5 170e533990 Merge the compact help columns into the DM fallback
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:53:51 +01:00
FernandoJVideiraandClaude Sonnet 5.5 63f17365fc Help: show it in the channel when the DM can't be sent, and log where the command came from
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:50:44 +01:00
FernandoJVideiraandClaude Sonnet 5.5 694d262c8c Help: compact inline columns, wide instead of long, with a link to the docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:38:45 +01:00
FernandoJVideira e271f92da3 Merge pull request 'Release: open source, and a command for every setting' (#31) from dev into master
Reviewed-on: #31
2026-10-23 01:13:42 +00:00
FernandoJVideira 85bd89a3f6 Merge pull request 'Every dashboard feature is also a command' (#30) from ft/commands-parity into dev
Reviewed-on: #30
2026-10-23 01:12:37 +00:00
FernandoJVideiraandClaude Sonnet 5.5 30cbd9a798 Help and docs for the new commands
- /help is sent as a few messages (Discord caps an embed at 6000 characters), with a test for the limits
- command reference, features, self-hosting, beta and getting-started pages describe the commands; the SQL section is gone
- development and contributing: every setting needs a command

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:11:44 +01:00
FernandoJVideiraandClaude Sonnet 5.5 132a2cd986 Commands for everything a dashboard can do
- /music skip, pause, resume, stop, repeat, remove, move (DJ role)
- /config view, clear, toggletwitch, streamers, beta-features (server owner)
- /slvl status, remove-reward; /moderation bans, timeouts; /security history
- /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status
- betasettings service and repo methods, with integration tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:10:05 +01:00
FernandoJVideiraandClaude Sonnet 5.5 acdb5ead35 Tickets: settings, category edit and ticket list from Discord; NOTIFY triggers in schema.sql
- /tickets settings (on or off, panel title and text, open tickets per member), /tickets category edit, /tickets list
- schema.sql now creates the NOTIFY triggers, so the integration tests run on a database built from it, and two stale expectations (tickets start off) are fixed

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:05:45 +01:00
FernandoJVideiraandClaude Sonnet 5.5 d95ebf473e Security: tune the Join Gate, raid mode, anti-nuke and heat with commands
- /security settings, joingate check|account-age, words add|remove|list, raid, antinuke set|limit, heat, alert-dms
- Same rule as the rest of the configuration: owner or trusted, validated in the engine
- schema.sql was missing security_settings.alert_dm, so the bot couldn't read security settings on a database built from it. Fixed, with an ALTER for existing installs
- A test checks every command against Discord's limits

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:03:03 +01:00
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 d086d46fe5 Merge pull request 'v3.5.2 (beta in other servers)' (#28) from dev into master
Reviewed-on: #28
2026-10-23 00:34:24 +00: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
FernandoJVideira 67f0080d59 Merge pull request 'v3.5.2' (#26) from dev into master
Reviewed-on: #26
2026-10-23 00:09:47 +00:00
FernandoJVideira d64a532a81 Merge pull request 'v3.6.0' (#24) from dev into master
Reviewed-on: #24
2026-10-21 22:12:14 +00:00
FernandoJVideira e333f084b5 Merge pull request 'v3.5.0: ticket panel fixes' (#22) from dev into master
Reviewed-on: #22
2026-10-21 11:01:07 +00:00
FernandoJVideira 177c820821 Merge pull request 'v3.5.0' (#20) from dev into master
Reviewed-on: #20
2026-10-20 22:39:57 +00:00
FernandoJVideira ffb8b963fa Merge pull request 'v3.4.0' (#17) from dev into master
Reviewed-on: #17
2026-08-29 18:53:28 +00:00
51 changed files with 3385 additions and 310 deletions

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
# 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.
- **Every setting has a command.** The bot works fully without a dashboard, so a setting must be visible and changeable with slash commands.
- **Escape user text and set `AllowedMentions`** so a message can't ping people.
- **Validate everything that comes from users or from other programs writing to the database**: options, custom IDs, command payloads.
- **No secrets** in code, logs, tests or commit messages.
## Commit messages and pull requests
- Write the commit subject in the imperative and keep it short ("Add a limit of 10 meeting lobbies"). Use the body to explain why, when it isn't obvious.
- In the pull request, say what changed and why, how you tested it, and whether it needs a database change or a deploy in a particular order.
- Keep the pull request open to review comments. Small follow-up commits are fine, there is no need to squash them yourself.
## Behaviour
Be kind and be specific. Disagree with the code, not the person. Harassment, personal attacks and discrimination are not tolerated, and the maintainer may remove comments, close discussions or block people who do it.
## License
By contributing, you agree that your contribution is licensed under the [Apache License 2.0](LICENSE), the same as the project (section 5 of the license covers this). You keep the copyright on your own work.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Velox Bot
Copyright 2026 Fernando Videira
This product includes software developed by the authors of its dependencies,
each under its own license (see go.mod).
+19 -8
View File
@@ -2,12 +2,9 @@
Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on. Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on.
This repository is the bot. It has two companions that live in their own repositories: **This repository is the bot, and it is the only open source part of Velox.** The web dashboard and its API, which the hosted Velox uses, are separate proprietary software and are not part of this repository or its license. The bot does not need them. Every setting and every action is a slash command (see the [command reference](docs/commands.md)), with PostgreSQL as its only dependency.
- **velox-dashboard-api**: a Go REST API behind the web dashboard. It owns most of the database schema and runs the migrations. 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).
- **velox-dashboard-web**: the Vue dashboard and public website.
The bot and the API share one PostgreSQL database. They never call each other over HTTP. The dashboard writes settings and requests into tables, and the bot reads them (and is woken through Postgres `NOTIFY` where it has to react at once). See [Architecture](docs/architecture.md).
## Documentation ## Documentation
@@ -16,8 +13,9 @@ This README is the front page. The rest of the documentation lives in [`docs/`](
| Page | What is in it | | Page | What is in it |
| --- | --- | | --- | --- |
| [Getting started](docs/getting-started.md) | Requirements, creating the Discord application, configuration, database, running locally | | [Getting started](docs/getting-started.md) | Requirements, creating the Discord application, configuration, database, running locally |
| [Host your own bot](docs/self-hosting.md) | A step-by-step guide to running your own copy on a server, with Docker Compose |
| [Deployment](docs/deployment.md) | Docker, Compose, upgrade order, backups | | [Deployment](docs/deployment.md) | Docker, Compose, upgrade order, backups |
| [Architecture](docs/architecture.md) | How the code is laid out, how an interaction flows through it, background loops, the link to the dashboard | | [Architecture](docs/architecture.md) | How the code is laid out, how an interaction flows through it, background loops, how a dashboard talks to the bot |
| [Features](docs/features.md) | What each module does and how it behaves | | [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 | | [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 | | [Database](docs/database.md) | Who owns the schema, the tables, the `NOTIFY` channels, how to change them |
@@ -46,7 +44,7 @@ On start the bot registers its slash commands in the server named by `GUILD_ID`
- **Tickets**: a panel with categories, a private thread per ticket, claim and close, transcripts to a log channel. - **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. - **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. - **Leveling**: XP per message, level cards, leaderboard, role rewards.
- **Music**: Lavalink-powered playback with a queue, controlled from Discord or the dashboard. - **Music**: Lavalink-powered playback with a queue, controlled from Discord.
- **Meeting rooms**: join a lobby voice channel and get a temporary room you can lock and invite people to. - **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. - **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`. - **Twitch alerts, welcome messages, default role**: per-server setup through `/config`.
@@ -71,6 +69,19 @@ internal/modlog, rankcard small shared helpers
schema.sql mirror of the tables the bot reads and writes schema.sql mirror of the tables the bot reads and writes
``` ```
## Host your own bot
You can run your own copy under your own Discord application. [Host your own bot](docs/self-hosting.md) walks through it on a fresh server with Docker Compose: creating the application and the invite link, configuring the bot, loading the database, optional music and Twitch, backups and updates.
## Contributing ## Contributing
Start with the [Development guide](docs/development.md). In short: keep a command's definition and its handler in one package, keep SQL in a repo and rules in a service, and run `go build ./... && go vet ./... && go test ./...` before opening a pull request. Contributions are welcome: bug reports, fixes, documentation and new features.
- Read [CONTRIBUTING.md](CONTRIBUTING.md) first. It covers how to report a problem, how to propose a change, and what a good pull request looks like.
- The [Development guide](docs/development.md) shows how to add a command, a button, an event handler, a setting or a table, and how to test them.
- For anything big, open an issue to talk about it before you write the code.
- Pull requests go to the `dev` branch. Before opening one, run `gofmt` on the files you touched and `go build ./... && go vet ./... && go test -race ./...`.
## License
Velox Bot, the code in this repository, is released under the [Apache License 2.0](LICENSE). Copyright 2026 Fernando Videira. See [NOTICE](NOTICE). The license does not cover the proprietary Velox dashboard and API, or the Velox name and logo as used by the hosted service. The libraries the bot uses are under their own permissive licenses (see `go.mod`).
+6 -6
View File
@@ -19,7 +19,7 @@ This page explains how the bot is put together, so you know where to look and wh
| | | | | |
+----------- services (rules) <------ repos (SQL) -----+ +----------- services (rules) <------ repos (SQL) -----+
| |
PostgreSQL <----> velox-dashboard-api <----> dashboard PostgreSQL <----> a dashboard (the hosted Velox has a proprietary one)
``` ```
The bot is one Go process. It keeps one websocket connection to Discord and one connection pool to PostgreSQL. It has no HTTP server of its own. 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.
@@ -85,14 +85,14 @@ Some commands have a response that takes longer than three seconds (anything tha
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period | | 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) | | 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 | | Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are and which servers opted in, and adds or removes beta commands to match |
| Command listeners | music, tickets, security | on `NOTIFY` | Carry out dashboard requests | | Command listeners | music, tickets, security | on `NOTIFY` | Carry out requests written to the `*_commands` tables |
## The link to the dashboard ## How a dashboard talks to the bot
The bot and the dashboard API do not talk to each other directly. They meet in PostgreSQL, in two ways: The bot has no HTTP server and no API of its own. A web dashboard, such as the proprietary one the hosted Velox uses (not part of this repository), meets the bot in PostgreSQL, in two ways. This is the whole contract, so you can drive the bot the same way with SQL or write your own tool:
1. **Settings tables.** The dashboard writes settings (log channel, security thresholds, ticket categories, beta role). The bot reads them when it needs them. Hot paths cache them for a short time, so a raid of thousands of joins does not become thousands of queries. 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 the dashboard needs the bot to *do* something (skip a song, post the ticket panel, release a quarantined member), it inserts a row in `music_commands`, `ticket_commands` or `security_commands` and issues `NOTIFY` on that channel. The bot has a long-lived connection that runs `LISTEN`, picks up the row, validates it again and carries it out. Never trust a row just because it is in the table: the listeners re-check permissions themselves. 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). Both directions are described table by table in [Database](database.md).
+13 -16
View File
@@ -5,28 +5,28 @@ A beta feature is one that is still being tried out, so only chosen people can u
1. **The person is a beta tester**: a member of the beta server who holds the Beta Tester role. 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. 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 same rule applies on the bot and on the dashboard. 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 ## Who the testers are
- The **beta server** and the **Beta Tester role** are chosen by the bot's owner on the dashboard, under **Admin, Beta access**. Only the owner can see or change that page. - The **beta server** and the **Beta Tester role** are chosen by the bot's owner (the owner of the Discord application) with `/beta set`, in a direct message with the bot. They are stored in the `beta_settings` table, one row. `/beta status` shows them and `/beta off` clears the role, which turns beta features off for everyone.
- 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), and within about 30 seconds on the dashboard API. - 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. - 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. - 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 ## Which servers have beta features
- The beta server always has them. - The beta server always has them.
- Any other server gets them only when **its owner** switches on **Beta features** on that server's page of the dashboard. Beta features can act on a whole server (anti-raid behaviour, for example), so the people who answer for the server decide. A server's admins who are not its owner cannot switch it on. - Any other server gets them only when its **owner** runs `/config beta-features enabled:true` there. Beta features can act on a whole server (anti-raid behaviour, for example), so the people who answer for the server decide. A server's admins who are not its owner cannot switch it on.
- The bot's owner can take a server out again from **Admin, Beta access**, but cannot put one in. - The bot's owner can take a server out with `/beta remove-server` (and see them with `/beta servers`), but cannot put one in.
- The list of opted-in servers is the `beta_guilds` table. The bot polls it every 30 seconds, together with the beta settings. - The servers are the `beta_guilds` table. The bot polls it 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. 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 ## 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. - **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.
- **Dashboard:** beta pages and links show a **Beta** badge, and only to testers, and only for servers where beta features are on. - **On the hosted Velox dashboard** (proprietary, not in this repository): beta pages and links show a **Beta** badge, only to testers and only for servers where beta features are on.
## On the bot ## On the bot
@@ -35,8 +35,8 @@ Code: `internal/beta` (the rules), `internal/commands/commands.go` (`RegisterBet
- Beta commands are registered in each server where beta features are on, and only there. `/beta` is only in the beta server. - 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. - 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. - 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 owner changes the beta server on the dashboard, 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. - If the beta server changes, 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. - `/beta` is registered globally but works only in a direct message with the bot, so it is in no server's menu, and only the owner of the Discord application can use it.
- 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. - 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 ### Add a beta command
@@ -67,17 +67,14 @@ Do not use this for anything that should only be for testers as people. It means
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. Move the definition into `AllCommands` and the handler into the `handlers` map, and delete the `RegisterBeta` call. On the next start the command appears everywhere, and the beta servers drop it from their own lists because it is now a regular command.
## On the API and dashboard ## On the dashboard
These live in the other two repositories. 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.
- **API**: put the feature's routes under `/api/v1/beta` (not tied to a server), or add the `betaGuildOnly()` middleware to a route under `/api/v1/guilds/:guild_id/...` (tied to a server). The first asks Discord whether the signed-in user holds the role in the beta server (30-second memory); the second also checks that the server is the beta server or opted in. Both answer `403` otherwise. `GET /users/me` returns `is_beta`, and `GET /guilds/:guild_id/beta` tells the dashboard whether beta features are on for that server and whether the caller owns it.
- **Dashboard**: give a route `meta: { requiresBeta: true }` to keep its page away from non-testers, and mark navigation entries with `beta: true` so testers see them with a **Beta** badge (and nobody else sees them). This is only about display. The API checks access on every request, so a page that was shown by mistake still cannot read or change anything.
## Why it works this way ## 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. - 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. - Servers choose to take part, because a beta feature can affect everyone in them.
- Access is decided by the server (bot and API), never by the browser. - 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. - Beta commands do not exist outside the beta server, and the gate checks again when they run.
- The settings are in the database, not in the environment, so the owner can change them without a deploy. - The settings are in the database and changed with commands, not in the environment, so they change without a deploy.
+33 -6
View File
@@ -35,7 +35,7 @@ Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers
| Command | What it does | | Command | What it does |
| --- | --- | | --- | --- |
| `/help` | DMs you a command reference | | `/help` | DMs you a command reference, or shows it to you in the channel if DMs are off |
## Leveling ## Leveling
@@ -46,6 +46,8 @@ Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers
| `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go | | `/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-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 | | `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level |
| `/slvl remove-reward` | `level`* | Manage Server, leveling on | Removes a level's role reward |
| `/slvl status` | | Manage Server | Shows the leveling settings |
| `/rank` | `user` | Leveling on | A rank card for you or another member | | `/rank` | `user` | Leveling on | A rank card for you or another member |
| `/leaderboard` | | Leveling on | Top 10 in the server | | `/leaderboard` | | Leveling on | Top 10 in the server |
| `/rewards` | | Leveling on | The role rewards | | `/rewards` | | Leveling on | The role rewards |
@@ -58,8 +60,10 @@ Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers
| `/queue` | | **DJ** role | Shows the queue | | `/queue` | | **DJ** role | Shows the queue |
| `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume | | `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume |
| `/music toggle` | | Manage Server | Turns music on or off in this server | | `/music toggle` | | Manage Server | Turns music on or off in this server |
| `/music remove` | `position`* (its number in `/queue`) | **DJ** role | Removes a queued song |
| `/music move` | `from`*, `to`* | **DJ** role | Moves a queued song |
Requires Lavalink. See [Getting started](getting-started.md#optional-music-with-lavalink). Pause, skip, repeat and stop are buttons on the now-playing message. Requires Lavalink. See [Getting started](getting-started.md#optional-music-with-lavalink).
## Meeting rooms ## Meeting rooms
@@ -89,6 +93,8 @@ Each subcommand checks the Discord permission in its "Needs" column.
| `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans 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 unban` | `user_id`*, `reason` | Ban Members | Unbans by ID |
| `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off | | `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off |
| `/moderation bans` | | Ban Members | Lists banned users, with their IDs |
| `/moderation timeouts` | | Moderate Members | Lists members who are timed out now |
## Timezone and scheduling ## Timezone and scheduling
@@ -125,6 +131,11 @@ Every subcommand needs **Manage Server**.
| `/config setlogchannel` | `channel`* | Where logs go | | `/config setlogchannel` | `channel`* | Where logs go |
| `/config enablelogging` | | Turns logging on | | `/config enablelogging` | | Turns logging on |
| `/config disablelogging` | | Turns it off | | `/config disablelogging` | | Turns it off |
| `/config view` | | Shows the welcome, default role, logging and Twitch settings |
| `/config clear` | `setting`* (welcome-channel, welcome-message, welcome-dm, welcome-gif, default-role, log-channel) | Clears one |
| `/config toggletwitch` | `enabled`* | Turns Twitch live notifications on or off |
| `/config streamers` | | Lists the watched streamers |
| `/config beta-features` | `enabled` | Lets testers use beta features here. Only the **server owner** can change it. Leave out `enabled` to see the state |
| `/config addstreamer` | `username`* | Adds a Twitch streamer to watch | | `/config addstreamer` | `username`* | Adds a Twitch streamer to watch |
| `/config removestreamer` | `username`* | Removes one | | `/config removestreamer` | `username`* | Removes one |
| `/config settwitchnotificationchannel` | `channel`* | Where live alerts go | | `/config settwitchnotificationchannel` | `channel`* | Where live alerts go |
@@ -137,7 +148,10 @@ Every subcommand needs **Manage Server**.
| `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown | | `/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 remove` | `name`* | Manage Server | Removes a category |
| `/tickets category list` | | Manage Server | Lists categories | | `/tickets category list` | | Manage Server | Lists categories |
| `/tickets category edit` | `name`*, `new-name`, `staff-role`, `clear-staff-role`, `emoji`, `description` | Manage Server | Changes a category (`-` removes the emoji or description) |
| `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories | | `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories |
| `/tickets settings` | `enabled`, `panel-title`, `panel-description`, `max-open` | Manage Server | Shows the settings, or changes the ones you give |
| `/tickets list` | `status` (open or closed) | Manage Server | Lists recent tickets |
| `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket | | `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket |
| `/ticket remove` | `user`* | Staff | Removes someone | | `/ticket remove` | `user`* | Staff | Removes someone |
| `/ticket claim` | | Staff | Takes the ticket | | `/ticket claim` | | Staff | Takes the ticket |
@@ -160,6 +174,17 @@ In this table "Manage Server" also lets Administrators and trusted members throu
| `/security quarantined` | | Manage Server | Lists quarantined members | | `/security quarantined` | | Manage Server | Lists quarantined members |
| `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back | | `/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 end-raid` | | Owner or trusted | Ends raid mode now |
| `/security settings` | | Manage Server | Shows every setting |
| `/security history` | | Manage Server | Shows the latest security events |
| `/security joingate check` | `check`* (no-avatar, new-account, invite-in-name, blocked-names, unverified-bot, unauthorized-bot), `action`* | Owner or trusted | What happens when a check catches a new member |
| `/security joingate account-age` | `days`* (1 to 365) | Owner or trusted | How old an account must be |
| `/security words add`, `remove` | `list`* (username or chat), `word`* | Owner or trusted | Blocked words for new members' names (Join Gate) or messages (heat) |
| `/security words list` | `list`* | Manage Server | Shows a list |
| `/security raid` | `join-count`, `join-seconds`, `action`, `pause-invites`, `quiet-minutes` | Owner or trusted | Tunes raid mode |
| `/security antinuke set` | `window-seconds`, `lock-server`, `auto-restore` | Owner or trusted | Tunes anti-nuke |
| `/security antinuke limit` | `action`*, `count`* (0 to 100) | Owner or trusted | How many times someone may do an action in the window (0: never) |
| `/security heat` | `strictness`, `action`, `timeout-minutes`, `escalate`, `panic-members`, `mention-lockdown` | Owner or trusted | Tunes the spam heat system |
| `/security alert-dms` | `on`* | Owner or trusted | DM critical alerts to the owner and trusted users |
| `/security snapshot` | | Manage Server | Saves roles and channels | | `/security snapshot` | | Manage Server | Saves roles and channels |
| `/security restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot | | `/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 channel` | `channel` (default: this one) | Manage Channels | Locks a channel |
@@ -173,8 +198,10 @@ In this table "Manage Server" also lets Administrators and trusted members throu
| Command | Needs | What it does | | Command | Needs | What it does |
| --- | --- | --- | | --- | --- | --- |
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only | | `/beta status` | The bot's owner, **in a DM with the bot** | Shows how beta access is set up |
| `/beta set` | The bot's owner, in a DM | `server-id`* and `role-id`*: chooses the beta server and the Beta Tester role |
| `/beta off` | The bot's owner, in a DM | Turns beta features off for everyone |
| `/beta servers` | The bot's owner, in a DM | Lists the servers that opted in |
| `/beta remove-server` | The bot's owner, in a DM | `server-id`*: takes a server out of the beta |
Other beta commands appear only in the beta server and in servers whose owner opted in, and only testers can run them. `/beta` is registered globally but only works in a direct message with the bot, so it is in no server's menu. A server opts itself in with `/config beta-features` (its owner). Other beta commands appear only in the beta server and in servers that opted in, and only testers can run them. See [Beta features](beta-features.md).
Beta features come and go. See [Beta features](beta-features.md).
+12 -21
View File
@@ -4,19 +4,11 @@ The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL live
## Who owns the schema ## Who owns the schema
The bot and the dashboard API share one database, so two files describe it: **`schema.sql`** in this repository is the schema. It describes every table the bot reads and writes, and it is enough to run the bot. It is safe to run more than once (`CREATE TABLE IF NOT EXISTS`). The bot never runs migrations itself.
- **`schema.sql`** in this repository: the tables the bot reads and writes. It is safe to run more than once (`CREATE TABLE IF NOT EXISTS`). It is enough to run the bot **on its own**. The 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.
- **The API's migrations** (`velox-dashboard-api/cmd/db/migrations`): when the API is deployed, it creates and changes the tables, and runs its migrations on start. The bot never runs migrations.
Some tables are older and were created by the bot (levels, welcome, and so on). The API's migration `000005_bot_owned_tables` creates them for a fresh combined database. Newer modules (music, tickets, security, meeting lobbies, beta settings) were created by API migrations, and `schema.sql` carries a copy so the bot can run alone. `schema.sql` also creates the `NOTIFY` triggers on the three `*_commands` tables (see [below](#dashboard-to-bot-settings-and-requests)), so anything that inserts a request wakes the bot, with no `NOTIFY` of its own.
**The rule:** a table must have the same shape in `schema.sql` and in the API migrations. When you change one, change the other in the same release.
Two things only exist in the API's migrations, because the bot never inserts into those tables:
- the `NOTIFY` triggers on the three `*_commands` tables (see below),
- the migration history.
## Tables ## Tables
@@ -30,7 +22,7 @@ Two things only exist in the API's migrations, because the bot never inserts int
| Meeting rooms | `meeting_lobbies`, `meeting_temp_channels`, `meeting_settings` | Lobbies (10 per server, enforced by a trigger), the temporary rooms and who created them. `meeting_settings.lobby_channel_id` is the old single lobby column and is no longer read | | 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` | | | Projects | `projects`, `project_members` | |
| Scheduling | `schedules`, `schedule_messages`, `user_settings` | Sessions, the DM messages to update, and each member's timezone | | Scheduling | `schedules`, `schedule_messages`, `user_settings` | Sessions, the DM messages to update, and each member's timezone |
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard | | Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State a dashboard can show, plus requests to the bot |
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | | | 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 | | 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 | | 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 |
@@ -39,27 +31,26 @@ Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Di
## Dashboard to bot: settings and requests ## Dashboard to bot: settings and requests
There are two kinds of traffic from the dashboard, and the difference matters. There are two kinds of traffic from a dashboard to the bot, and the difference matters.
**Settings.** The dashboard writes a row, and the bot reads it when it next needs it. Nothing wakes the bot up. Hot paths cache settings for a short time. The bot's beta settings are polled every 30 seconds. **Settings.** A dashboard writes a row, and the bot reads it when it next needs it. Nothing wakes the bot up. Hot paths cache settings for a short time. The bot's beta settings are polled every 30 seconds.
**Requests that need the bot to act.** `music_commands`, `ticket_commands` and `security_commands` work as a small queue: **Requests that need the bot to act.** `music_commands`, `ticket_commands` and `security_commands` work as a small queue:
1. The dashboard API inserts a row (`command_type`, a JSON `payload`, `requested_by`, `processed_at` empty). 1. A dashboard inserts a row (`command_type`, a JSON `payload`, `requested_by`, `processed_at` empty).
2. A trigger in the database runs `pg_notify('<table>', id)`. 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`. 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. Because the bot loads all unprocessed rows (also when it starts), nothing is lost if it was down when a request arrived. A command that fails is logged and marked processed anyway, since retrying would fail the same way.
Every listener **validates the request again**. The API checks that the requester is allowed, and the bot checks too, because it should not trust a row just because it exists. The listeners live in `internal/music/listener.go`, `internal/tickets/listener.go` and `internal/security/listener.go`, and each reconnects after 10 seconds if its connection drops. Every listener **validates the request again**. The dashboard should check that the requester is allowed, and the bot checks too, because it should not trust a row just because it exists. The listeners live in `internal/music/listener.go`, `internal/tickets/listener.go` and `internal/security/listener.go`, and each reconnects after 10 seconds if its connection drops.
## Changing the schema ## Changing the schema
1. Decide who owns the table (see above). New tables for the dashboard's modules are created by an API migration. 1. Update `schema.sql`. Use `CREATE TABLE IF NOT EXISTS`, and for a new column on an existing table also add `ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...` right after it, so servers set up earlier get the column when they run the file again. 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. Write the API migration, **and** update `schema.sql` to match. 2. Add or change the repo method in `internal/db/repos/<area>repo`.
3. Add or change the repo method in `internal/db/repos/<area>repo`. Use `IF NOT EXISTS` and avoid destructive changes: the bot and the dashboard may be deployed a few minutes apart, so the old bot must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release. 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. If the dashboard must make the bot do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener. 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.
5. Say in the release notes if the API, bot and dashboard have to deploy together.
## Connection settings ## Connection settings
+2 -8
View File
@@ -27,15 +27,9 @@ Compose does not include Lavalink. Run it separately (or add a service) if you w
Change the default database password (`velox_pwd`) before exposing PostgreSQL to anything other than your own machine, and remove the published `5432` port if only the bot needs it. Change the default database password (`velox_pwd`) before exposing PostgreSQL to anything other than your own machine, and remove the published `5432` port if only the bot needs it.
## Running next to the dashboard ## Database changes
If you also run `velox-dashboard-api` and `velox-dashboard-web`: 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.
1. They all use the **same database**. Give each the same PostgreSQL, and point `DB_HOST` (bot) and the API's `DB_ADDR` at it.
2. The API runs the migrations when it starts. The bot does not run migrations.
3. **Deploy order for changes that touch the database:** API first (it applies the migration), then the bot and the dashboard.
Some changes need the API, the bot and the dashboard to go out together. For example, when a table the dashboard writes changes shape, an old dashboard talking to a new API fails. The release notes for such a change say so.
## Updating ## Updating
+8 -6
View File
@@ -154,7 +154,7 @@ Never trust the contents of a custom ID. A user cannot edit it, but old messages
The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (`/meeting add-lobby`). The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (`/meeting add-lobby`).
**1. The table.** Add it to `schema.sql`, and to an API migration if the dashboard will show it (see [Database](database.md#changing-the-schema)). **1. The table.** Add it to `schema.sql` (see [Database](database.md#changing-the-schema)).
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values: **2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
@@ -188,9 +188,11 @@ if services.Global == nil || services.Global.Meeting == nil {
added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID) added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID)
``` ```
**6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and the dashboard cannot disagree and two simultaneous requests cannot both slip through. **6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and anything else writing to the table cannot disagree and two simultaneous requests cannot both slip through.
**7. Default to off.** A module that does something to a server without being asked (posts messages, moderates, grants roles) must start disabled and be turned on by an admin. **7. Give it commands.** Every setting must be visible and changeable from Discord (a slash command to set it, and a way to see it). The bot has to work completely without a dashboard, so never add a setting that only a dashboard or SQL can change. Put the options in the command definition, and `internal/commands/limits_test.go` will tell you if it goes over Discord's limits.
**8. 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 ## React to a gateway event
@@ -244,7 +246,7 @@ Call it at the end of `bot.Start()`. Log errors instead of returning them, and m
Never log a secret, and never put one in a message to a user. Never log a secret, and never put one in a message to a user.
Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database and on the dashboard instead. That is how the beta settings work. Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database instead (set with a command, or by a dashboard). That is how the beta settings work.
## Make a command beta ## Make a command beta
@@ -281,7 +283,7 @@ Good things to test: permission checks (does the wrong person get refused?), lim
- **One feature per package, SQL only in repos, rules in services.** Commands never contain SQL. - **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*. - **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. - **Fail closed.** If you cannot tell whether someone is allowed, they are not.
- **Validate everything that comes from a user or the dashboard**: option values, custom IDs, `payload` JSON in command rows. - **Validate everything that comes from a user or from another program writing to the database**: option values, custom IDs, `payload` JSON in command rows.
- **Escape user text** in messages, and set `AllowedMentions` so it cannot ping. - **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. - **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. - **Do not leak internals** to users: reply with a short message, log the detail.
@@ -292,7 +294,7 @@ Good things to test: permission checks (does the wrong person get refused?), lim
- [ ] `gofmt` on the files you touched, `go build ./...`, `go vet ./...`, `go test -race ./...` - [ ] `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 - [ ] Commands: in `AllCommands` and `handlers`, permission check in the handler, ephemeral errors
- [ ] New table: in `schema.sql` **and** in an API migration, with a note about deploy order - [ ] New table or column: in `schema.sql`, and mentioned in the pull request
- [ ] Module that acts on a server: off by default - [ ] Module that acts on a server: off by default
- [ ] `/help`, [Command reference](commands.md) and [Features](features.md) updated - [ ] `/help`, [Command reference](commands.md) and [Features](features.md) updated
- [ ] No secrets, tokens or personal IDs in code, logs or tests - [ ] No secrets, tokens or personal IDs in code, logs or tests
+5 -5
View File
@@ -29,13 +29,13 @@ Rules that hold everywhere in this module:
- Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned. - 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. - Messages ping only the configured alert role, and names are escaped.
Thresholds, Join Gate filters and heat settings are set on the dashboard. The bot's role must be above the roles it protects against: it can only quarantine members whose highest role is below its own. Every threshold, filter and action is a command: `/security settings` shows them all, and `/security joingate`, `words`, `raid`, `antinuke`, `heat` and `alert-dms` change them (owner or trusted users only). `/security history` shows what the system has done. 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 ## Tickets
Support tickets in private threads. Code: `internal/tickets`, commands in `internal/commands/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`. 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`. `/tickets settings` changes the panel text and the open ticket limit and turns tickets on or off, `/tickets category edit` changes a category, and `/tickets list` shows tickets.
2. The panel has a category dropdown and a **Create ticket** button. A short form asks what the ticket is about. 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. 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]`. 4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`.
@@ -68,8 +68,8 @@ Lavalink-powered playback. Code: `internal/music`, commands in `internal/command
- `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**. - `/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. - 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. - `/music toggle` (Manage Server) turns music on or off in a server. `/music remove` and `/music move` edit the queue (DJ role).
- The dashboard shows the queue and can control playback. It does so through the `music_commands` table and a `NOTIFY` (see [Database](database.md)). - The queue and now-playing state are kept in the database, and playback can also be controlled by writing to the `music_commands` table. The hosted Velox dashboard uses this (see [Database](database.md)); you never need it, the commands above do the same.
- Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not. - Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not.
## Meeting rooms ## Meeting rooms
@@ -117,4 +117,4 @@ When the bot is added to a server, it applies safe defaults (modules off) and DM
## Help ## 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. `/help` DMs a command reference. If the member has DMs off, or Discord refuses the DM, it shows the same help as a message only they can see. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command.
+6 -2
View File
@@ -20,6 +20,7 @@ This page takes you from nothing to a bot running on your machine.
| --- | --- | | --- | --- |
| View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts | | View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts |
| Read Message History, Manage Messages | moderation purge, spam heat | | 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 Roles | default role, level rewards, quarantine |
| Manage Channels | lockdown, slowmode, meeting rooms, snapshot restore | | Manage Channels | lockdown, slowmode, meeting rooms, snapshot restore |
| Manage Server | pausing invites during a raid | | Manage Server | pausing invites during a raid |
@@ -56,7 +57,7 @@ TWITCH_CLIENT_ID=unused
`.env` is ignored by git. Never commit it. `.env` is ignored by git. Never commit it.
Who the beta testers are is **not** an environment variable. It is set on the dashboard and stored in the database. See [Beta features](beta-features.md). Who the beta testers are is **not** an environment variable. It is stored in the database. See [Beta features](beta-features.md).
## 3. Set up the database ## 3. Set up the database
@@ -72,7 +73,7 @@ Then load the schema:
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
``` ```
`schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is a mirror of the tables the bot uses. If you run the dashboard API as well, the API's migrations create and change these tables, and you do not need `schema.sql` at all. Read [Database](database.md) before changing a table. `schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is the schema of every table the bot uses. Read [Database](database.md) before changing a table.
## 4. Run ## 4. Run
@@ -105,6 +106,9 @@ Nothing is on by default. In your server:
- `/slvl toggle` for leveling. - `/slvl toggle` for leveling.
- `/config setlogchannel` and `/config enablelogging` for logs. - `/config setlogchannel` and `/config enablelogging` for logs.
- `/music toggle` for music (needs Lavalink). - `/music toggle` for music (needs Lavalink).
- `/config toggletwitch` and `/config addstreamer` for Twitch alerts.
`/security settings`, `/tickets settings`, `/slvl status` and `/config view` show what is set.
The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option. The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option.
+184
View File
@@ -0,0 +1,184 @@
# 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, and you do not need it: everything it does is also a command. See [Everything is a command](#everything-is-a-command).
## 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.
## Everything is a command
The bot is complete on its own: every setting and every action is a slash command, and nothing needs a web dashboard. The [Command reference](commands.md) lists them all. The ones you will want first:
- **Modules:** `/security toggle`, `/tickets setup`, `/slvl toggle`, `/music toggle`, `/config enablelogging`, `/config toggletwitch`.
- **Seeing what is set:** `/security settings`, `/tickets settings`, `/slvl status`, `/config view`.
- **Tuning security:** `/security joingate`, `words`, `raid`, `antinuke` and `heat` change every threshold, filter and action (owner or trusted users only).
- **The beta program:** the bot's owner sets it up in a direct message with the bot, see below.
### Setting up beta features
Beta features are off until you choose a beta server and a Beta Tester role. As the owner of the Discord application, open a **direct message** with your bot and run:
```
/beta set server-id:<the server's ID> role-id:<the role's ID>
```
(In Discord, turn on Developer Mode, then right-click the server or role and choose Copy ID.) `/beta status` shows the result, `/beta off` turns the beta off, `/beta servers` lists the servers that opted in. A server's owner opts the server in with `/config beta-features`. The bot reads the change within 30 seconds. See [Beta features](beta-features.md).
## Making it your own
- Change the bot's name and avatar in the developer portal.
- `/help` is hand-written text in `internal/commands/help/help.go`. Its author link points at the original project, so change it to your own repository.
- The name "Velox" in this code is just a name. You may rename it, and under the license you must keep the copyright notices (`LICENSE`, `NOTICE`) and say what you changed. Please do not present your fork as the original project.
## Something went wrong
[Troubleshooting](troubleshooting.md) covers the usual problems: the bot not starting, commands not appearing, permissions, and music.
+4 -4
View File
@@ -40,17 +40,17 @@ Most failures are about the bot's role:
They are off until enabled: `/slvl toggle`, `/config enablelogging` with `/config setlogchannel`, and `/config setwelcomechannel`. Leveling and spam detection read message text, so the **Message Content Intent** must be on. Welcome messages and security need the **Server Members Intent**. They are off until enabled: `/slvl toggle`, `/config enablelogging` with `/config setlogchannel`, and `/config setwelcomechannel`. Leveling and spam detection read message text, so the **Message Content Intent** must be on. Welcome messages and security need the **Server Members Intent**.
## Dashboard actions have no effect ## A dashboard or tool's requests have no effect
The dashboard talks to the bot through the database (see [Database](database.md#dashboard-to-bot-settings-and-requests)). A dashboard (the hosted Velox has a proprietary one) or any tool of yours talks to the bot through the database (see [Database](database.md#dashboard-to-bot-settings-and-requests)).
- Check that the bot and the API use the **same database**. - Check that the tool and the bot use the **same database**.
- For a request such as skipping a song or ending a raid, look in the matching `*_commands` table. A row with an empty `processed_at` is waiting. If many are waiting, the bot's listener is down: look for `command listener stopped` in its log. It reconnects every 10 seconds. - 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). - 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 ## Beta features are not available to a tester
Go through [Beta features](beta-features.md): is the Beta Tester role chosen on the dashboard? Is the person in the **beta server**, and do they have the role there? Is the server where they use it the beta server, or has **its owner** switched on Beta features on the dashboard? Has 30 seconds passed? `/beta` (owner only) shows how access is set up. Go through [Beta features](beta-features.md): has the bot's owner set the Beta Tester role (`/beta status` in a DM with the bot shows it)? 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 has its owner run `/config beta-features enabled:true`? Has 30 seconds passed?
## Getting more detail ## Getting more detail
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
const ( const (
betaGuild = "1170934850157547600" betaGuild = "700000000000000001"
otherGuild = "999000000000000001" otherGuild = "999000000000000001"
testerRol = "555000000000000001" testerRol = "555000000000000001"
testerID = "777000000000000001" testerID = "777000000000000001"
+22 -3
View File
@@ -20,7 +20,7 @@ import (
const ( const (
appID = "100" appID = "100"
betaServer = "1170934850157547600" betaServer = "700000000000000001"
optedIn = "222" optedIn = "222"
leftServer = "333" leftServer = "333"
) )
@@ -171,8 +171,8 @@ func TestBetaCommandsFollowTheServers(t *testing.T) {
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got) t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
} }
got := d.names(betaServer) got := d.names(betaServer)
if !contains(got, "beta") || !contains(got, "betatest") || contains(got, "oldthing") { if !contains(got, "betatest") || contains(got, "oldthing") || contains(got, "beta") {
t.Errorf("the beta server gets /beta and the beta commands, and loses stale ones: %v", got) t.Errorf("the beta server gets the beta commands (not /beta, which lives in DMs) and loses stale ones: %v", got)
} }
if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] { if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("the database remembers where the commands are now: %v", store.registered) t.Errorf("the database remembers where the commands are now: %v", store.registered)
@@ -219,3 +219,22 @@ func TestBetaCleanupIsRetriedAndGivesUpWhenTheBotIsGone(t *testing.T) {
t.Error("a server the bot can't reach any more is forgotten") 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)
}
}
+13 -15
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"velox-bot/internal/beta" "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"
@@ -25,7 +24,6 @@ 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
@@ -114,14 +112,19 @@ 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)
}
}
// The owner's commands live in direct messages with the bot, not in a server.
for _, cmd := range commands.OwnerCommands {
if _, err := b.Session.ApplicationCommandCreate(b.AppID, "", cmd); 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)
@@ -360,9 +363,6 @@ func (b *Bot) addBetaCommands(guildID string) bool {
} }
ok := true ok := true
for _, cmd := range commands.BetaCommands { for _, cmd := range commands.BetaCommands {
if cmd.Name == betacmd.Beta.Name && !isBetaServer {
continue
}
keep[cmd.Name] = true keep[cmd.Name] = true
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil { 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) log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
@@ -428,11 +428,9 @@ func (b *Bot) removeBetaCommands(guildID string) bool {
return clean 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()
} }
+187 -34
View File
@@ -1,31 +1,59 @@
// Package betacmd is the /beta command: it shows how beta access is set up. // Package betacmd is /beta, the bot owner's control over the beta program.
// It exists only in the beta server (see commands.BetaCommands), is hidden // It is a command you use in a direct message with the bot, so it exists in no
// from everyone but administrators in Discord's command picker, and only the // server's command menu, and only the owner of the Discord application can run
// bot's owner can actually run it (the owner is looked up from the Discord // it (the owner is looked up from the application, so there is nothing to
// application, so there's nothing to configure). // configure). It chooses the beta server and the Beta Tester role, and
// removes servers from the beta; a server's own owner opts it in with
// /config beta-features. Nothing about the beta needs a dashboard.
package betacmd package betacmd
import ( import (
"context"
"fmt" "fmt"
"log"
"regexp"
"strconv"
"strings"
"sync" "sync"
"time"
"velox-bot/internal/beta" "velox-bot/internal/beta"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
var ( var (
noDM = false
noPerms = int64(0) // hides the command from everyone but administrators
ownerIDs sync.Map // user ID -> struct{}, filled once from the application ownerIDs sync.Map // user ID -> struct{}, filled once from the application
loadOnce sync.Mutex loadOnce sync.Mutex
loaded bool loaded bool
snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
dmOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextBotDM}
) )
var Beta = &discordgo.ApplicationCommand{ var Beta = &discordgo.ApplicationCommand{
Name: "beta", Name: "beta",
Description: "Show how beta access is set up (bot owner only)", Description: "Manage the beta program (bot owner, in a DM with the bot)",
DMPermission: &noDM, Contexts: dmOnly,
DefaultMemberPermissions: &noPerms, Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show how beta access is set up"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "set", Description: "Choose the beta server and the Beta Tester role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "server-id", Description: "The beta server's ID (the bot must be in it)", Required: true, MaxLength: 20},
{Type: discordgo.ApplicationCommandOptionString, Name: "role-id", Description: "The Beta Tester role's ID in that server", Required: true, MaxLength: 20},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "off", Description: "Turn beta features off for everyone (clears the role)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "servers", Description: "List the servers that opted in to beta features"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove-server", Description: "Take a server out of the beta",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "server-id", Description: "The server's ID", Required: true, MaxLength: 20},
},
},
},
} }
// isOwner reports whether the user owns the bot's Discord application (or is // isOwner reports whether the user owns the bot's Discord application (or is
@@ -57,31 +85,156 @@ func isOwner(s *discordgo.Session, userID string) bool {
return ok return ok
} }
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) { func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
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{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{Content: msg, AllowedMentions: &discordgo.MessageAllowedMentions{}},
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
AllowedMentions: &discordgo.MessageAllowedMentions{},
},
}) })
} }
func guildName(s *discordgo.Session, id string) string {
if g, err := s.State.Guild(id); err == nil && g != nil && g.Name != "" {
return g.Name
}
if g, err := s.Guild(id); err == nil && g != nil {
return g.Name
}
return "unknown server"
}
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
userID := ""
switch {
case i.User != nil:
userID = i.User.ID
case i.Member != nil && i.Member.User != nil:
userID = i.Member.User.ID
}
if !isOwner(s, userID) {
reply(s, i, "Only the bot's owner can use this command.")
return
}
if i.GuildID != "" {
reply(s, i, "Use this command in a direct message with me, not in a server.")
return
}
svc := services.Global
if svc == nil || svc.BetaSettings == nil {
reply(s, i, "Beta settings aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
fail := func(err error) {
reply(s, i, "Something went wrong. Try again in a moment.")
log.Printf("beta %s: %v", sub.Name, err)
}
switch sub.Name {
case "status":
guildID, roleID, err := svc.BetaSettings.Get(ctx)
if err != nil {
fail(err)
return
}
opted, _ := svc.BetaSettings.OptedIn(ctx)
var b strings.Builder
switch {
case guildID == "":
b.WriteString("No beta server is set. Choose one with `/beta set`.\n")
case roleID == "":
fmt.Fprintf(&b, "Beta server: **%s** (`%s`).\nNo Beta Tester role is set, so beta features are locked for everyone. Set one with `/beta set`.\n", guildName(s, guildID), guildID)
default:
fmt.Fprintf(&b, "Beta server: **%s** (`%s`).\nBeta Tester role: `%s`. Members of the beta server who have it are testers.\n", guildName(s, guildID), guildID, roleID)
}
fmt.Fprintf(&b, "%d other server(s) opted in. The bot is using: server `%s`, %d opted-in server(s).", len(opted), beta.GuildID(), len(beta.Servers()))
reply(s, i, b.String())
case "set":
guildArg, roleArg := strings.TrimSpace(args["server-id"].StringValue()), strings.TrimSpace(args["role-id"].StringValue())
if !snowflake.MatchString(guildArg) || !snowflake.MatchString(roleArg) {
reply(s, i, "Both IDs must be numbers. In Discord, turn on Developer Mode, then right-click the server or role and choose Copy ID.")
return
}
roles, err := s.GuildRoles(guildArg)
if err != nil {
reply(s, i, "I couldn't read that server. Check the ID, and that I'm in it.")
return
}
found := false
for _, r := range roles {
if r.ID == roleArg && r.ID != guildArg && !r.Managed {
found = true
}
}
if !found {
reply(s, i, "That role isn't a normal role of that server.")
return
}
g, _ := strconv.ParseInt(guildArg, 10, 64)
r, _ := strconv.ParseInt(roleArg, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetServer(ctx, g, r, by); err != nil {
fail(err)
return
}
reply(s, i, fmt.Sprintf("Saved. **%s** is the beta server, and members with the role `%s` are beta testers. The bot picks this up within 30 seconds.", guildName(s, guildArg), roleArg))
case "off":
guildID, _, err := svc.BetaSettings.Get(ctx)
if err != nil {
fail(err)
return
}
if guildID == "" {
reply(s, i, "No beta server is set, so beta features are already off.")
return
}
g, _ := strconv.ParseInt(guildID, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetServer(ctx, g, 0, by); err != nil {
fail(err)
return
}
reply(s, i, "Beta features are off for everyone. Choose a role again with `/beta set` to turn them back on.")
case "servers":
opted, err := svc.BetaSettings.OptedIn(ctx)
if err != nil {
fail(err)
return
}
if len(opted) == 0 {
reply(s, i, "No other server has opted in. A server's owner does that with `/config beta-features`.")
return
}
var b strings.Builder
for _, id := range opted {
fmt.Fprintf(&b, "• %s (`%s`)\n", guildName(s, id), id)
}
out := b.String()
if r := []rune(out); len(r) > 1900 {
out = string(r[:1899]) + "…"
}
reply(s, i, "**Servers in the beta**\n"+out)
case "remove-server":
id := strings.TrimSpace(args["server-id"].StringValue())
if !snowflake.MatchString(id) {
reply(s, i, "The ID must be a number.")
return
}
g, _ := strconv.ParseInt(id, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetOptedIn(ctx, g, by, false); err != nil {
fail(err)
return
}
reply(s, i, "Removed. Its beta commands disappear within a minute. Only that server's owner can put it back.")
}
}
+6 -2
View File
@@ -86,10 +86,14 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
// wrapped so it runs only for members with the Beta Tester role (see the beta // 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 // package). To put a feature in beta, add it with RegisterBeta instead of to
// AllCommands and handlers; to release it, move it there. // AllCommands and handlers; to release it, move it there.
var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta} var BetaCommands = []*discordgo.ApplicationCommand{}
// OwnerCommands are registered globally but can only be used in a direct
// message with the bot, so they are in no server's menu. /beta is the bot
// owner's control over the beta program (see betacmd).
var OwnerCommands = []*discordgo.ApplicationCommand{betacmd.Beta}
func init() { func init() {
// /beta itself is open to the whole beta server (see betacmd).
handlers[betacmd.Beta.Name] = betacmd.Handler handlers[betacmd.Beta.Name] = betacmd.Handler
} }
+6 -2
View File
@@ -14,7 +14,7 @@ import (
var Config = &discordgo.ApplicationCommand{ var Config = &discordgo.ApplicationCommand{
Name: "config", Name: "config",
Description: "Server configuration", Description: "Server configuration",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "addstreamer", Name: "addstreamer",
@@ -145,7 +145,7 @@ var Config = &discordgo.ApplicationCommand{
Name: "disablelogging", Name: "disablelogging",
Description: "Disable server logging events", Description: "Disable server logging events",
}, },
}, }, extraOptions()...),
} }
func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -168,6 +168,10 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
if handleExtra(s, i, data.Options[0]) {
return
}
switch data.Options[0].Name { switch data.Options[0].Name {
case "addstreamer": case "addstreamer":
log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
+214
View File
@@ -0,0 +1,214 @@
package public
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// Everything else /config needs so that no setting depends on a dashboard:
// seeing the settings, clearing one, switching Twitch alerts on and off,
// listing streamers, and the server owner's beta features switch.
var clearChoices = []string{"welcome-channel", "welcome-message", "welcome-dm", "welcome-gif", "default-role", "log-channel"}
func extraOptions() []*discordgo.ApplicationCommandOption {
clear := &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString, Name: "setting", Description: "What to clear", Required: true,
}
for _, c := range clearChoices {
clear.Choices = append(clear.Choices, &discordgo.ApplicationCommandOptionChoice{Name: c, Value: c})
}
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "view", Description: "Show the welcome, role, logging and Twitch settings"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "clear", Description: "Clear a welcome, role or log setting", Options: []*discordgo.ApplicationCommandOption{clear}},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggletwitch", Description: "Turn Twitch live notifications on or off",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "On or off", Required: true}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "streamers", Description: "List the Twitch streamers being watched"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "beta-features",
Description: "Let testers use beta features here. Only the server owner can change it",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "On or off. Leave out to see the state"}},
},
}
}
func guildInt(i *discordgo.InteractionCreate) int64 {
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
return n
}
func ownerOf(s *discordgo.Session, guildID string) (string, error) {
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
return g.OwnerID, nil
}
g, err := s.Guild(guildID)
if err != nil {
return "", err
}
return g.OwnerID, nil
}
// handleExtra runs the subcommands above. It reports whether it handled name.
func handleExtra(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) bool {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
g := guildInt(i)
svc := services.Global
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
fail := func(err error) {
respondEphemeral(s, i, "Something went wrong. Try again in a moment.")
log.Printf("config %s: %v", sub.Name, err)
}
switch sub.Name {
case "view":
var b strings.Builder
if w, err := svc.Welcome.GetSettings(ctx, g); err == nil {
fmt.Fprintf(&b, "**Welcome**\nChannel: %s\nMessage: %s\nDM: %s\nGIF: %s\n", mention("#", w.ChannelID), textOr(w.WelcomeMessage), textOr(w.WelcomeDM), textOr(w.WelcomeGIFURL))
}
if r, err := svc.DefaultRole.GetRole(ctx, g); err == nil {
fmt.Fprintf(&b, "**Default role:** %s\n", mention("@&", r))
}
if l, err := svc.LogSettings.GetSettings(ctx, g); err == nil && l != nil {
state := "off"
if l.IsEnabled {
state = "on"
}
fmt.Fprintf(&b, "**Logging:** %s, channel %s\n", state, mention("#", l.ChannelID))
} else {
b.WriteString("**Logging:** off\n")
}
if on, err := svc.Twitch.IsEnabled(ctx, g); err == nil {
ch, _, _ := svc.Twitch.GetNotificationChannel(ctx, g)
users, _ := svc.Twitch.ListUsersForGuild(ctx, g)
state := "off"
if on {
state = "on"
}
fmt.Fprintf(&b, "**Twitch alerts:** %s, channel %s, %d streamer(s)\n", state, mention("#", ch), len(users))
}
respondEphemeral(s, i, clipConfig(b.String(), 1900))
case "clear":
var err error
setting := args["setting"].StringValue()
switch setting {
case "welcome-channel":
err = svc.Welcome.SetChannel(ctx, g, 0)
case "welcome-message":
err = svc.Welcome.SetMessage(ctx, g, "")
case "welcome-dm":
err = svc.Welcome.SetDM(ctx, g, "")
case "welcome-gif":
err = svc.Welcome.SetGIF(ctx, g, "")
case "default-role":
err = svc.DefaultRole.SetRole(ctx, g, 0)
case "log-channel":
err = svc.LogSettings.SetChannel(ctx, g, 0)
default:
respondEphemeral(s, i, "Unknown setting.")
return true
}
if err != nil {
fail(err)
return true
}
respondEphemeral(s, i, "Cleared **"+setting+"**.")
case "toggletwitch":
on := args["enabled"].BoolValue()
if err := svc.Twitch.SetEnabled(ctx, g, on); err != nil {
fail(err)
return true
}
if on {
if ch, ok, _ := svc.Twitch.GetNotificationChannel(ctx, g); !ok || ch == 0 {
respondEphemeral(s, i, "Twitch alerts are **on**, but there is no channel yet. Set one with `/config settwitchnotificationchannel`.")
return true
}
respondEphemeral(s, i, "Twitch alerts are **on**.")
} else {
respondEphemeral(s, i, "Twitch alerts are **off**.")
}
case "streamers":
users, err := svc.Twitch.ListUsersForGuild(ctx, g)
if err != nil {
fail(err)
return true
}
if len(users) == 0 {
respondEphemeral(s, i, "No streamers yet. Add one with `/config addstreamer`.")
return true
}
respondEphemeral(s, i, clipConfig("Watching: "+strings.Join(users, ", "), 1900))
case "beta-features":
on, err := svc.BetaSettings.IsOptedIn(ctx, g)
if err != nil {
fail(err)
return true
}
state := "off"
if on {
state = "on"
}
want, ok := args["enabled"]
if !ok {
respondEphemeral(s, i, "Beta features are **"+state+"** in this server. Testers can use them here when this is on. Only the server owner can change it.")
return true
}
owner, err := ownerOf(s, i.GuildID)
if err != nil {
fail(err)
return true
}
if i.Member == nil || i.Member.User == nil || i.Member.User.ID != owner {
respondEphemeral(s, i, "Only the server owner can change this. Beta features can act on the whole server.")
return true
}
by, _ := strconv.ParseInt(owner, 10, 64)
if err := svc.BetaSettings.SetOptedIn(ctx, g, by, want.BoolValue()); err != nil {
fail(err)
return true
}
if want.BoolValue() {
respondEphemeral(s, i, "Beta features are **on**. Beta commands appear here within a minute, for testers only.")
} else {
respondEphemeral(s, i, "Beta features are **off**. Beta commands disappear from this server within a minute.")
}
default:
return false
}
return true
}
func mention(prefix string, id int64) string {
if id == 0 {
return "not set"
}
return fmt.Sprintf("<%s%d>", prefix, id)
}
func textOr(v string) string {
if strings.TrimSpace(v) == "" {
return "not set"
}
return "`" + clipConfig(v, 120) + "`"
}
func clipConfig(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
+156 -147
View File
@@ -1,7 +1,9 @@
package help package help
import ( import (
"fmt"
"log" "log"
"unicode/utf8"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -11,163 +13,170 @@ var Help = &discordgo.ApplicationCommand{
Description: "Get help with the bot", Description: "Get help with the bot",
} }
func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { // A message's embeds share a limit of 6000 characters, and one embed holds at
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ // most 25 fields of 1024 characters. The reference is longer than that, so it
Type: discordgo.InteractionResponseChannelMessageWithSource, // is sent as a few messages.
Data: &discordgo.InteractionResponseData{ const (
Content: "Check your DMs for help.", maxEmbedChars = 5000
Flags: discordgo.MessageFlagsEphemeral, maxFieldChars = 1024
)
// docsURL is where each command is explained in full, with its options and
// who may use it.
const docsURL = "https://velox.fernandovideira.com/docs/commands"
// col is one short column of the help: a list of commands, no descriptions.
// Fields are inline, so Discord lays three of them side by side and the help
// is wide and short instead of narrow and long.
func col(name string, lines ...string) *discordgo.MessageEmbedField {
v := ""
for i, l := range lines {
if i > 0 {
v += "\n"
}
v += l
}
return &discordgo.MessageEmbedField{Name: name, Value: v, Inline: true}
}
func fields() []*discordgo.MessageEmbedField {
return []*discordgo.MessageEmbedField{
col("Fun",
"`/ping` `/joke` `/coinflip`", "`/dice` `/rps` `/rpsstats`", "`/rpsleaderboard`"),
col("Music (DJ role)",
"`/play` `/queue` `/volume`", "`/music remove` `move`", "`/music toggle` (Manage Server)"),
col("Sessions and time",
"`/schedule create`", "`/schedule list`", "`/schedule reschedule`", "`/timezone set` `show`"),
col("Tickets",
"Open one from the panel.", "`/ticket add` `close`", "`/ticket claim` `remove` (staff)", "`/tickets setup` `settings`", "`/tickets category` `panel` `list`"),
col("Security",
"`/security status` `settings` `history`", "`/security toggle`", "`/security joingate` `words` `raid`", "`/security antinuke` `heat` `alert-dms`", "`/security trust` `release` `end-raid`", "`/security alerts` `quarantine-role`", "`/security snapshot` `restore`", "`/lockdown` `/unlock`"),
col("Moderation",
"`/moderation purge`", "`/moderation timeout` `untimeout`", "`/moderation kick` `ban` `unban`", "`/moderation slowmode`", "`/moderation bans` `timeouts`"),
col("Meeting rooms",
"`/meeting add-lobby`", "`/meeting remove-lobby` `lobbies`", "`/meeting lock` `lock-private`", "`/meeting unlock`", "`/meeting invite` `uninvite`"),
col("Leveling",
"`/rank` `/leaderboard` `/rewards`", "`/slvl toggle` `status`", "`/slvl set` `set-channel`", "`/slvl set-message`", "`/slvl set-reward` `remove-reward`"),
col("Projects",
"`/projects list`", "`/projects create`", "`/projects add-helper`", "`/projects close`"),
col("Welcome and roles",
"`/config setwelcomechannel`", "`/config setwelcomemessage`", "`/config setwelcomedm`", "`/config setwelcomegif`", "`/config setdefaultrole`"),
col("Logging and Twitch",
"`/config setlogchannel`", "`/config enablelogging` `disablelogging`", "`/config addstreamer` `removestreamer`", "`/config streamers`", "`/config toggletwitch`", "`/config settwitchnotificationchannel`"),
col("Settings and beta",
"`/config view` `clear`", "`/config beta-features` (server owner)", "Bot owner, in a DM with me:", "`/beta set` `off` `status`"),
{
Name: "Everything in detail",
Value: "Options, permissions and what each command does: " + docsURL + "\nMost setup commands need **Manage Server**. `/help` shows this list again.",
}, },
})
userID := ""
// check if command is run in a guild
if i.Member != nil {
userID = i.Member.User.ID
} else {
userID = i.Interaction.User.ID
} }
}
dmChannel, err := s.UserChannelCreate(userID) func fieldSize(f *discordgo.MessageEmbedField) int {
if err != nil { return utf8.RuneCountInString(f.Name) + utf8.RuneCountInString(f.Value)
log.Printf("Error creating DM channel: %v", err) }
return
// embeds splits the reference into embeds that fit Discord's limits.
func embeds(author *discordgo.MessageEmbedAuthor) []*discordgo.MessageEmbed {
var out []*discordgo.MessageEmbed
cur := &discordgo.MessageEmbed{
Title: "Velox Bot Help",
Description: "Every command, by area. Some need server permissions or a role.",
Color: 0xFFA500,
Author: author,
} }
size := utf8.RuneCountInString(cur.Title) + utf8.RuneCountInString(cur.Description) + utf8.RuneCountInString(author.Name)
for _, f := range fields() {
if size+fieldSize(f) > maxEmbedChars || len(cur.Fields) >= 25 {
out = append(out, cur)
cur = &discordgo.MessageEmbed{Title: "Velox Bot Help (continued)", Color: 0xFFA500}
size = utf8.RuneCountInString(cur.Title)
}
cur.Fields = append(cur.Fields, f)
size += fieldSize(f)
}
return append(out, cur)
}
// HelpHandler sends the help by direct message. If that isn't possible (the
// member has DMs off, or shares no server with the bot), it shows the help to
// them in the channel instead, as a message only they can see.
func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
author := discordgo.MessageEmbedAuthor{ author := discordgo.MessageEmbedAuthor{
Name: "Velox Bot", Name: "Velox Bot",
URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot", URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot",
} }
list := embeds(&author)
embed := &discordgo.MessageEmbed{ // Used in a DM with the bot, there is nowhere else to send it.
Title: "Velox Bot Help", if i.GuildID == "" {
Description: "Command reference. Some commands require server permissions or roles.", _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Color: 0xFFA500, Type: discordgo.InteractionResponseChannelMessageWithSource,
Fields: []*discordgo.MessageEmbedField{ Data: &discordgo.InteractionResponseData{Embeds: list},
{ })
Name: "Fun", return
Value: "" +
"`/ping` — Pong!\n" +
"`/joke [type]` — Random joke (optional category)\n" +
"`/coinflip` — Flip a coin\n" +
"`/dice <expr>` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" +
"`/rps <hand>` — Rock Paper Scissors (server only)\n" +
"`/rpsstats` — Your RPS score (server only)\n" +
"`/rpsleaderboard` — Top RPS scores (server only)",
},
{
Name: "Music (requires **DJ** role)",
Value: "" +
"`/play <query>` — Play a song (search or URL)\n" +
"`/queue` — Show the current queue\n" +
"`/volume <0-150>` — Set playback volume",
},
{
Name: "Scheduling (1:1 sessions)",
Value: "" +
"`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" +
"`/schedule list` — List your upcoming sessions\n" +
"`/schedule reschedule id:<id> datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" +
"Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.",
},
{
Name: "Timezone",
Value: "" +
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
"`/timezone show` — Show your timezone",
},
{
Name: "Projects",
Value: "" +
"`/projects list` — List active projects\n" +
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
},
{
Name: "Tickets",
Value: "" +
"Open a ticket from the panel: you get a private thread with the staff.\n" +
"`/ticket add user:@someone` — Add someone to your ticket\n" +
"`/ticket close [reason]` — Close the ticket (opener or staff)\n" +
"`/ticket claim`, `/ticket remove user:@someone` — Staff only\n" +
"`/tickets setup`, `/tickets category add|remove|list`, `/tickets panel` — Set up (**Manage Server**)",
},
{
Name: "Security (anti-raid & anti-nuke)",
Value: "" +
"`/security status` — What's on and who is trusted\n" +
"`/security toggle` — Join Gate, raid mode, anti-nuke on/off (off: owner or trusted)\n" +
"`/security alerts`, `/security quarantine-role`, `/security release`, `/security end-raid` — Owner or trusted\n" +
"`/security trust add|remove` — Owner only\n" +
"`/security snapshot` — Save roles and channels; `/security restore` — Rebuild what a nuke deleted (owner or trusted)\n" +
"`/lockdown channel|server|joins`, `/unlock …` (unlock: owner or trusted)",
},
{
Name: "Meetings (**Manage Server**)",
Value: "" +
"`/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-private` — Lock + hide your meeting room\n" +
"`/meeting unlock` — Unlock your meeting room\n" +
"`/meeting invite user:@someone` — Allow a user to join\n" +
"`/meeting uninvite user:@someone` — Remove a user's permission",
},
{
Name: "Moderation (focused tools)",
Value: "" +
"`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" +
"`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" +
"`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" +
"`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" +
"`/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)\n" +
"`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" +
"`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)",
},
{
Name: "Leveling",
Value: "" +
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
"`/slvl set user:@someone level:<n>` — Set user level (**Manage Server**)\n" +
"`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" +
"`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" +
"`/slvl set-reward level:<n> role:@role` — Role rewards (**Manage Server**)\n" +
"`/rank [user]` — Your rank card (server only)\n" +
"`/leaderboard` — Top levels (server only)\n" +
"`/rewards` — List configured rewards (server only)",
},
{
Name: "Twitch live notifications (**Manage Server**)",
Value: "" +
"`/config addstreamer username:<twitch-name>` — Add a streamer\n" +
"`/config removestreamer username:<twitch-name>` — Remove a streamer\n" +
"`/config settwitchnotificationchannel channel:#channel` — Set notification channel",
},
{
Name: "Welcome / onboarding (**Manage Server**)",
Value: "" +
"`/config setwelcomechannel channel:#channel` — Channel for welcome messages\n" +
"`/config setwelcomemessage message:\"...\"` — Welcome text (supports `{user}`, `{server}`)\n" +
"`/config setwelcomedm message:\"...\"` — DM template (supports `{user}`, `{server}`)\n" +
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
"`/config setdefaultrole role:@role` — Role granted to new members",
},
{
Name: "Logging (**Manage Server**)",
Value: "" +
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
"`/config enablelogging` — Enable event/moderation logging\n" +
"`/config disablelogging` — Disable event/moderation logging",
},
{
Name: "Other",
Value: "`/help` — Show this help in DMs",
},
},
Author: &author,
} }
s.ChannelMessageSendEmbed(dmChannel.ID, embed) // Answer first (sending the DM can take a moment), and edit the answer
// once we know where the help went.
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
}); err != nil {
log.Printf("help: cannot answer: %v", err)
return
}
userID := ""
if i.Member != nil && i.Member.User != nil {
userID = i.Member.User.ID
} else if i.User != nil {
userID = i.User.ID
}
if err := sendByDM(s, userID, list); err != nil {
// Typically "Cannot send messages to this user": DMs are off.
// The context says where the command came from, which is what to look at
// when "no mutual guilds" appears for someone who is in a server with
// the bot: a command run through a user install, or in a server the bot
// isn't actually a member of, has no shared server for Discord to see.
log.Printf("help: DM not possible, showing it here (guild %q, context %d, installs %v, %s): %v",
i.GuildID, i.Context, i.AuthorizingIntegrationOwners, botMembership(s, i.GuildID), err)
note := "I couldn't DM you (your DMs may be off), so here it is."
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &note, Embeds: &list})
return
}
done := "Check your DMs for help."
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &done})
}
func sendByDM(s *discordgo.Session, userID string, list []*discordgo.MessageEmbed) error {
dm, err := s.UserChannelCreate(userID)
if err != nil {
return err
}
for _, e := range list {
if _, err := s.ChannelMessageSendEmbed(dm.ID, e); err != nil {
return err
}
}
return nil
}
// botMembership says whether the bot is really a member of the server, which
// is what Discord means by "mutual guilds". A server where the app was added
// with only the applications.commands scope runs slash commands fine but has
// no bot user in it.
func botMembership(s *discordgo.Session, guildID string) string {
if s == nil || s.State == nil || s.State.User == nil {
return "bot user unknown"
}
inCache := false
if g, err := s.State.Guild(guildID); err == nil && g != nil {
inCache = true
}
if _, err := s.GuildMember(guildID, s.State.User.ID); err != nil {
return fmt.Sprintf("bot is NOT a member of that server (in gateway cache: %v): %v", inCache, err)
}
return fmt.Sprintf("bot is a member of that server (in gateway cache: %v)", inCache)
} }
+132
View File
@@ -0,0 +1,132 @@
package help
import (
"io"
"net/http"
"strings"
"testing"
"unicode/utf8"
"github.com/bwmarrin/discordgo"
)
// Discord refuses an embed that is over its limits, and then /help would send
// nothing. This catches it when a command is added to the text.
func TestHelpFitsDiscordsLimits(t *testing.T) {
list := embeds(&discordgo.MessageEmbedAuthor{Name: "Velox Bot"})
if len(list) == 0 {
t.Fatal("no help")
}
seen := 0
for n, e := range list {
total := utf8.RuneCountInString(e.Title) + utf8.RuneCountInString(e.Description)
if len(e.Fields) > 25 {
t.Errorf("embed %d has %d fields, at most 25", n, len(e.Fields))
}
for _, f := range e.Fields {
seen++
if l := utf8.RuneCountInString(f.Value); l == 0 || l > maxFieldChars {
t.Errorf("field %q has %d characters, must be 1 to %d", f.Name, l, maxFieldChars)
}
if l := utf8.RuneCountInString(f.Name); l == 0 || l > 256 {
t.Errorf("field name %q has %d characters, at most 256", f.Name, l)
}
total += fieldSize(f)
}
if total > 6000 {
t.Errorf("embed %d has %d characters, Discord allows 6000", n, total)
}
}
if seen != len(fields()) {
t.Errorf("%d fields shown of %d", seen, len(fields()))
}
}
// fakeDiscord answers the calls /help makes and records what the member was
// shown. dm decides whether the direct message works.
type fakeDiscord struct {
dm bool
edited []byte
callbacks int
sent int
}
func (f *fakeDiscord) RoundTrip(r *http.Request) (*http.Response, error) {
reply := func(code int, body string) (*http.Response, error) {
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
}
switch {
case strings.HasSuffix(r.URL.Path, "/callback"):
f.callbacks++
return reply(204, "")
case strings.HasSuffix(r.URL.Path, "/users/@me/channels"):
if !f.dm {
return reply(403, `{"message":"Cannot send messages to this user","code":50007}`)
}
return reply(200, `{"id":"900","type":1}`)
case strings.Contains(r.URL.Path, "/channels/900/messages"):
f.sent++
return reply(200, `{"id":"1"}`)
case strings.Contains(r.URL.Path, "/messages/@original"):
f.edited, _ = io.ReadAll(r.Body)
return reply(200, `{"id":"2"}`)
}
return reply(404, `{"message":"unexpected `+r.URL.Path+`"}`)
}
func run(t *testing.T, f *fakeDiscord, guild string) {
t.Helper()
s, err := discordgo.New("Bot x")
if err != nil {
t.Fatal(err)
}
s.Client = &http.Client{Transport: f}
s.MaxRestRetries = 0
i := &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{
ID: "1", Token: "tok", AppID: "100", GuildID: guild,
Member: &discordgo.Member{User: &discordgo.User{ID: "42"}},
}}
HelpHandler(s, i)
}
func TestHelpGoesToDMs(t *testing.T) {
f := &fakeDiscord{dm: true}
run(t, f, "5")
if f.sent == 0 || !strings.Contains(string(f.edited), "Check your DMs") || strings.Contains(string(f.edited), `"embeds"`) {
t.Errorf("sent=%d edited=%s", f.sent, f.edited)
}
}
func TestHelpFallsBackToTheChannelWhenDMsAreOff(t *testing.T) {
f := &fakeDiscord{dm: false}
run(t, f, "5")
got := string(f.edited)
if f.sent != 0 || !strings.Contains(got, "couldn't DM you") || !strings.Contains(got, `"embeds"`) || !strings.Contains(got, "Velox Bot Help") {
t.Errorf("the help should be shown in the reply: sent=%d edited=%s", f.sent, got)
}
}
func TestHelpInADMIsShownDirectly(t *testing.T) {
f := &fakeDiscord{dm: true}
run(t, f, "")
if f.callbacks != 1 || f.sent != 0 || f.edited != nil {
t.Errorf("one direct answer, no DM: %+v", f)
}
}
// The help is meant to be wide and short: inline fields, so Discord puts
// three side by side, and it should fit in one message.
func TestHelpIsCompactAndInline(t *testing.T) {
if n := len(embeds(&discordgo.MessageEmbedAuthor{Name: "Velox Bot"})); n != 1 {
t.Errorf("the help should fit in one message, it takes %d", n)
}
inline := 0
for _, f := range fields() {
if f.Inline {
inline++
}
}
if inline < len(fields())-2 {
t.Errorf("only %d of %d fields are inline", inline, len(fields()))
}
}
@@ -15,6 +15,8 @@ var Slvl = &discordgo.ApplicationCommand{
slvlSetChannelOption(), slvlSetChannelOption(),
slvlSetMessageOption(), slvlSetMessageOption(),
slvlSetRewardOption(), slvlSetRewardOption(),
slvlRemoveRewardOption(),
slvlStatusOption(),
}, },
} }
@@ -34,6 +36,10 @@ func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
slvlSetMessageHandler(s, i) slvlSetMessageHandler(s, i)
case "set-reward": case "set-reward":
slvlSetRewardHandler(s, i) slvlSetRewardHandler(s, i)
case "remove-reward":
slvlRemoveRewardHandler(s, i)
case "status":
slvlStatusHandler(s, i)
default: default:
shared.RespondEphemeral(s, i, "Invalid subcommand.") shared.RespondEphemeral(s, i, "Invalid subcommand.")
} }
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"context"
"fmt"
"strconv"
"strings"
"velox-bot/internal/commands/level/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// Looking at the leveling settings and removing a role reward, so nothing
// about leveling needs a dashboard.
func slvlRemoveRewardOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove-reward", Description: "Remove the role reward of a level",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionInteger, Name: "level", Description: "The level", Required: true},
},
}
}
func slvlStatusOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show the leveling settings",
}
}
func slvlRemoveRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) || !shared.RequireLevelSettingsService(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
level := int(shared.SubcommandOptionMap(i)["level"].IntValue())
removed, err := services.Global.LevelSettings.DeleteRoleRewardForLevel(context.Background(), guildID, level)
switch {
case err != nil:
shared.RespondEphemeral(s, i, "Failed to remove the reward.")
case !removed:
shared.RespondEphemeral(s, i, "Level "+strconv.Itoa(level)+" has no role reward.")
default:
shared.RespondEphemeral(s, i, "Removed the reward for level "+strconv.Itoa(level)+".")
}
}
func slvlStatusHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSettingsService(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
ctx := context.Background()
ls := services.Global.LevelSettings
enabled, err := ls.IsLevelSystemEnabled(ctx, guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load the settings.")
return
}
state := "off"
if enabled {
state = "on"
}
var b strings.Builder
fmt.Fprintf(&b, "**Leveling:** %s\n", state)
if ch, ok, _ := ls.GetLevelUpChannelID(ctx, guildID); ok && ch != 0 {
fmt.Fprintf(&b, "**Level-up channel:** <#%d>\n", ch)
} else {
b.WriteString("**Level-up channel:** the channel of the message\n")
}
if msg, err := ls.GetLevelUpMessage(ctx, guildID); err == nil {
fmt.Fprintf(&b, "**Level-up message:** %s\n", msg)
}
rewards, _ := ls.ListRoleRewards(ctx, guildID, 25)
if len(rewards) == 0 {
b.WriteString("**Role rewards:** none")
} else {
b.WriteString("**Role rewards:** ")
for n, r := range rewards {
if n > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "level %d → <@&%d>", r.Level, r.RoleID)
}
}
out := b.String()
if r := []rune(out); len(r) > 1900 {
out = string(r[:1899]) + "…"
}
shared.RespondEphemeral(s, i, out)
}
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"regexp"
"testing"
"unicode/utf8"
"github.com/bwmarrin/discordgo"
)
// Discord refuses a command that is over its limits, and then the whole
// command silently doesn't exist. These checks catch that in a test instead
// of after a deploy. See
// https://discord.com/developers/docs/interactions/application-commands
var commandName = regexp.MustCompile(`^[-_\p{L}\p{N}]{1,32}$`)
func checkOptions(t *testing.T, path string, options []*discordgo.ApplicationCommandOption, size *int) {
t.Helper()
if len(options) > 25 {
t.Errorf("%s: %d options, at most 25 allowed", path, len(options))
}
seen := map[string]bool{}
sawOptional := false
for _, o := range options {
where := path + " " + o.Name
if !commandName.MatchString(o.Name) || o.Name != lower(o.Name) {
t.Errorf("%s: option names are 1 to 32 lowercase letters, digits, - or _", where)
}
if seen[o.Name] {
t.Errorf("%s: duplicate option name", where)
}
seen[o.Name] = true
if n := utf8.RuneCountInString(o.Description); n < 1 || n > 100 {
t.Errorf("%s: description is %d characters, must be 1 to 100", where, n)
}
*size += utf8.RuneCountInString(o.Name) + utf8.RuneCountInString(o.Description)
if len(o.Choices) > 25 {
t.Errorf("%s: %d choices, at most 25", where, len(o.Choices))
}
for _, c := range o.Choices {
*size += utf8.RuneCountInString(c.Name)
if s, ok := c.Value.(string); ok {
*size += utf8.RuneCountInString(s)
if utf8.RuneCountInString(s) > 100 || utf8.RuneCountInString(c.Name) > 100 {
t.Errorf("%s: a choice is over 100 characters", where)
}
}
}
switch o.Type {
case discordgo.ApplicationCommandOptionSubCommand, discordgo.ApplicationCommandOptionSubCommandGroup:
if len(o.Options) == 0 && o.Type == discordgo.ApplicationCommandOptionSubCommandGroup {
t.Errorf("%s: a group needs subcommands", where)
}
checkOptions(t, where, o.Options, size)
default:
// Required options come before optional ones.
if o.Required && sawOptional {
t.Errorf("%s: a required option after an optional one", where)
}
if !o.Required {
sawOptional = true
}
}
}
}
func lower(s string) string {
out := []rune(s)
for i, r := range out {
if r >= 'A' && r <= 'Z' {
out[i] = r + 32
}
}
return string(out)
}
func TestCommandsStayWithinDiscordLimits(t *testing.T) {
all := append(append(append([]*discordgo.ApplicationCommand{}, AllCommands...), BetaCommands...), OwnerCommands...)
names := map[string]bool{}
for _, c := range all {
if names[c.Name] {
t.Errorf("/%s is defined twice", c.Name)
}
names[c.Name] = true
if !commandName.MatchString(c.Name) || c.Name != lower(c.Name) {
t.Errorf("/%s: names are 1 to 32 lowercase letters, digits, - or _", c.Name)
}
if n := utf8.RuneCountInString(c.Description); n < 1 || n > 100 {
t.Errorf("/%s: description is %d characters, must be 1 to 100", c.Name, n)
}
size := utf8.RuneCountInString(c.Name) + utf8.RuneCountInString(c.Description)
checkOptions(t, "/"+c.Name, c.Options, &size)
if size > 8000 {
t.Errorf("/%s: %d characters in total, Discord allows 8000", c.Name, size)
}
if _, ok := handlers[c.Name]; !ok {
t.Errorf("/%s has no handler", c.Name)
}
}
for name := range handlers {
if !names[name] {
t.Errorf("a handler for /%s exists but the command is never registered", name)
}
}
}
@@ -0,0 +1,106 @@
package public
import (
"context"
"fmt"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
// Looking at who is banned and who is timed out, so unbanning and ending a
// timeout never needs a dashboard to find the person first.
func listOptions() []*discordgo.ApplicationCommandOption {
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "bans", Description: "List banned users, to use with /moderation unban"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "timeouts", Description: "List members who are timed out right now"},
}
}
const listShown = 20
func handleBans(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
bans, err := s.GuildBans(i.GuildID, 1000, "", "")
if err != nil {
respondEphemeral(s, i, "I couldn't read the ban list. I need the **Ban Members** permission.")
return
}
if len(bans) == 0 {
respondEphemeral(s, i, "Nobody is banned.")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Banned users (%d)**\n", len(bans))
for n, ban := range bans {
if n == listShown {
fmt.Fprintf(&b, "…and %d more.\n", len(bans)-listShown)
break
}
name := "unknown"
id := ""
if ban.User != nil {
name, id = ban.User.Username, ban.User.ID
}
reason := strings.TrimSpace(ban.Reason)
if reason == "" {
reason = "no reason"
}
fmt.Fprintf(&b, "• %s (`%s`): %s\n", escape(name), id, escape(clip(reason, 80)))
}
b.WriteString("Unban with `/moderation unban user_id:<id>`.")
respondEphemeral(s, i, clip(b.String(), 1950))
}
func handleTimeouts(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
_, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
members, err := s.GuildMembers(i.GuildID, "", 1000)
if err != nil {
respondEphemeral(s, i, "I couldn't read the member list.")
return
}
now := time.Now()
var out []*discordgo.Member
for _, m := range members {
if m.CommunicationDisabledUntil != nil && m.CommunicationDisabledUntil.After(now) {
out = append(out, m)
}
}
if len(out) == 0 {
respondEphemeral(s, i, "Nobody is timed out (among the first 1000 members).")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Timed out (%d)**\n", len(out))
for n, m := range out {
if n == listShown {
fmt.Fprintf(&b, "…and %d more.\n", len(out)-listShown)
break
}
fmt.Fprintf(&b, "• <@%s> until <t:%d:f>\n", m.User.ID, m.CommunicationDisabledUntil.Unix())
}
b.WriteString("End one with `/moderation untimeout`.")
respondEphemeral(s, i, clip(b.String(), 1950))
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// escape keeps names and reasons from becoming markdown or mentions.
func escape(s string) string {
return strings.NewReplacer("`", "'", "*", "", "_", " ", "~", "", "@", "@​", "|", "/").Replace(s)
}
@@ -14,7 +14,7 @@ import (
var Moderation = &discordgo.ApplicationCommand{ var Moderation = &discordgo.ApplicationCommand{
Name: "moderation", Name: "moderation",
Description: "High-value moderation tools", Description: "High-value moderation tools",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "purge", Name: "purge",
@@ -155,7 +155,7 @@ var Moderation = &discordgo.ApplicationCommand{
}, },
}, },
}, },
}, }, listOptions()...),
} }
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -185,6 +185,10 @@ func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleUntimeout(s, i) handleUntimeout(s, i)
case "slowmode": case "slowmode":
handleSlowmode(s, i) handleSlowmode(s, i)
case "bans":
handleBans(s, i)
case "timeouts":
handleTimeouts(s, i)
default: default:
respondEphemeral(s, i, "Unknown subcommand.") respondEphemeral(s, i, "Unknown subcommand.")
} }
+92
View File
@@ -0,0 +1,92 @@
package public
import (
"fmt"
"strings"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
)
// Queue editing. Pausing, skipping, repeating and stopping are buttons on the
// now-playing message, so the only queue controls that need commands are
// removing a song and moving one. They need the DJ role, like /play, /queue
// and /volume. Turning music on or off needs Manage Server.
func positionOption(name, desc string) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionInteger, Name: name, Description: desc,
Required: true, MinValue: ptrFloat(1), MaxValue: 1000,
}
}
func playerOptions() []*discordgo.ApplicationCommandOption {
sub := func(name, desc string, o ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: name, Description: desc, Options: o}
}
return []*discordgo.ApplicationCommandOption{
sub("remove", "Remove a song from the queue (DJ)", positionOption("position", "Its number in /queue")),
sub("move", "Move a song to another place in the queue (DJ)",
positionOption("from", "Its number in /queue"), positionOption("to", "The number it should have")),
}
}
// queueEntry returns the queued song at position n (1 is the first one after
// the song that is playing), as /queue numbers them.
func queueEntry(guildID string, n int) (*music.TrackEntry, int) {
_, rest := music.GetQueue(guildID)
if n < 1 || n > len(rest) {
return nil, len(rest)
}
e := rest[n-1]
return &e, len(rest)
}
func title(e *music.TrackEntry) string {
return strings.TrimSpace(e.Track.Info.Title)
}
// handlePlayer runs a queue subcommand. The caller has checked that music is
// on and that the member has the DJ role. Pause, skip, repeat and stop are
// buttons on the now-playing message, so only what has no button is a command.
func handlePlayer(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) {
g := i.GuildID
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
var err error
var msg string
switch sub.Name {
case "remove":
n := int(args["position"].IntValue())
e, size := queueEntry(g, n)
if e == nil {
shared.RespondEphemeral(s, i, fmt.Sprintf("There is no song number %d in the queue (it has %d).", n, size))
return
}
err, msg = music.RemoveFromQueue(g, e.QueueID), fmt.Sprintf("Removed **%s**.", title(e))
case "move":
from, to := int(args["from"].IntValue()), int(args["to"].IntValue())
e, size := queueEntry(g, from)
if e == nil {
shared.RespondEphemeral(s, i, fmt.Sprintf("There is no song number %d in the queue (it has %d).", from, size))
return
}
if to > size {
to = size
}
err, msg = music.Reorder(g, e.QueueID, to), fmt.Sprintf("Moved **%s** to number %d.", title(e), to)
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
return
}
if err != nil {
shared.RespondEphemeral(s, i, "That didn't work: "+err.Error())
return
}
shared.RespondEphemeral(s, i, msg)
}
+22 -10
View File
@@ -10,14 +10,14 @@ import (
var Music = &discordgo.ApplicationCommand{ var Music = &discordgo.ApplicationCommand{
Name: "music", Name: "music",
Description: "Music settings", Description: "Music settings and player controls",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "toggle", Name: "toggle",
Description: "Turn the music player on or off for this server", Description: "Turn the music player on or off for this server (Manage Server)",
},
}, },
}, playerOptions()...),
} }
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -25,9 +25,6 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
shared.RespondEphemeral(s, i, "This command can only be used in a server.") shared.RespondEphemeral(s, i, "This command can only be used in a server.")
return return
} }
if !shared.RequireManageGuild(s, i) {
return
}
if !shared.RequireMusicService(s, i) { if !shared.RequireMusicService(s, i) {
return return
} }
@@ -37,11 +34,26 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
shared.RespondEphemeral(s, i, "Missing subcommand.") shared.RespondEphemeral(s, i, "Missing subcommand.")
return return
} }
sub := data.Options[0]
switch data.Options[0].Name { if sub.Name == "toggle" {
case "toggle": if !shared.RequireManageGuild(s, i) {
handleToggle(s, i) return
} }
handleToggle(s, i)
return
}
// Every other subcommand controls the player: music has to be on and the
// member needs the DJ role, like /play, /queue and /volume.
if !shared.RequireMusicEnabled(s, i) {
return
}
if !memberHasDJRole(s, i.GuildID, i.Member) {
shared.RespondEphemeral(s, i, "You need the **DJ** role to use music commands.")
return
}
handlePlayer(s, i, sub)
} }
func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -43,7 +43,7 @@ var Security = &discordgo.ApplicationCommand{
Description: "Anti-raid and anti-nuke protection", Description: "Anti-raid and anti-nuke protection",
DefaultMemberPermissions: &manageGuild, DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly, Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"},
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off", Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off",
@@ -94,7 +94,7 @@ var Security = &discordgo.ApplicationCommand{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
}, }, tuneOptions()...),
} }
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption { func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
@@ -211,6 +211,10 @@ func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout) ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel() defer cancel()
if handleTune(ctx, s, i, e, sub) {
return
}
switch sub.Name { switch sub.Name {
case "status": case "status":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) { if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
@@ -434,7 +438,7 @@ func status(ctx context.Context, e *security.Engine, guildID string) string {
fmt.Fprintf(&b, ", <@%d>", t.TargetID) fmt.Fprintf(&b, ", <@%d>", t.TargetID)
} }
} }
b.WriteString("\nFine-tune filters, limits and actions on the dashboard.") b.WriteString("\nSee every setting with `/security settings`, and change them with `/security joingate`, `words`, `raid`, `antinuke` and `heat`.")
return b.String() return b.String()
} }
+377
View File
@@ -0,0 +1,377 @@
package public
import (
"context"
"fmt"
"sort"
"strings"
"velox-bot/internal/db/repos/securityrepo"
"velox-bot/internal/security"
"github.com/bwmarrin/discordgo"
)
// The commands that tune the Join Gate, raid mode, anti-nuke and heat. They
// reach the same settings a dashboard would, so a server never needs one.
// Changing them needs the server owner or someone on the trusted list; that is
// enforced by the security engine, not by Discord's command permissions.
func intOption(name, desc string, min, max int) *discordgo.ApplicationCommandOption {
lo := float64(min)
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionInteger, Name: name, Description: desc,
MinValue: &lo, MaxValue: float64(max),
}
}
func boolOption(name, desc string) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionBoolean, Name: name, Description: desc}
}
func choiceOption(name, desc string, required bool, choices ...string) *discordgo.ApplicationCommandOption {
o := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString, Name: name, Description: desc, Required: required}
for _, c := range choices {
o.Choices = append(o.Choices, &discordgo.ApplicationCommandOptionChoice{Name: c, Value: c})
}
return o
}
func sub(name, desc string, options ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: name, Description: desc, Options: options}
}
func group(name, desc string, subs ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: name, Description: desc, Options: subs}
}
func required(o *discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
o.Required = true
return o
}
var nukeLimitChoices = []string{
securityrepo.LimitChannelDelete, securityrepo.LimitChannelCreate, securityrepo.LimitRoleDelete, securityrepo.LimitRoleCreate,
securityrepo.LimitBan, securityrepo.LimitKick, securityrepo.LimitWebhookCreate,
}
// Which Join Gate check an option name stands for.
var joinGateChecks = []string{"no-avatar", "new-account", "invite-in-name", "blocked-names", "unverified-bot", "unauthorized-bot"}
// tuneOptions are the subcommands added to /security.
func tuneOptions() []*discordgo.ApplicationCommandOption {
acts := []string{securityrepo.ActionOff, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan}
lists := []string{string(security.UsernameWords), string(security.ChatWords)}
listOpt := func() *discordgo.ApplicationCommandOption {
return choiceOption("list", "username: new members' names (Join Gate). chat: messages (heat)", true, lists...)
}
wordOpt := func() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString, Name: "word", Description: "A word or phrase, matched as plain text", Required: true, MaxLength: 50}
}
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings", Description: "Show every security setting"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "history", Description: "Show the latest security events (raids, quarantines, setting changes)"},
group("joingate", "Tune the Join Gate (owner or trusted)",
sub("check", "Choose what happens when a check catches a new member",
choiceOption("check", "Which check", true, joinGateChecks...),
choiceOption("action", "What to do. Bot checks accept off, kick or ban", true, acts...)),
sub("account-age", "How old an account must be to pass the new-account check",
required(intOption("days", "Days, 1 to 365", 1, 365)))),
group("words", "Blocked words (owner or trusted)",
sub("add", "Block a word", listOpt(), wordOpt()),
sub("remove", "Stop blocking a word", listOpt(), wordOpt()),
sub("list", "Show the blocked words", listOpt())),
sub("raid", "Tune raid mode (owner or trusted). Leave out what you don't change",
intOption("join-count", "Joins that start a raid, 3 to 100", 3, 100),
intOption("join-seconds", "Within this many seconds, 5 to 300", 5, 300),
choiceOption("action", "What happens to raiders", false, securityrepo.ActionAlert, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan),
boolOption("pause-invites", "Pause server invites during a raid"),
intOption("quiet-minutes", "Minutes without a join before the raid ends, 1 to 120", 1, 120)),
group("antinuke", "Tune anti-nuke (owner or trusted)",
sub("set", "Leave out what you don't change",
intOption("window-seconds", "Actions are counted within this many seconds, 5 to 300", 5, 300),
boolOption("lock-server", "Also lock every channel when a nuke is stopped"),
boolOption("auto-restore", "Restore deleted roles and channels after a stopped nuke")),
sub("limit", "How many times someone may do an action within the window (0 means never)",
choiceOption("action", "Which action", true, nukeLimitChoices...),
required(intOption("count", "0 to 100", 0, 100)))),
sub("heat", "Tune the spam heat system (owner or trusted). Leave out what you don't change",
choiceOption("strictness", "How fast heat builds", false, "low", "medium", "high"),
choiceOption("action", "What happens at maximum heat", false, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan),
intOption("timeout-minutes", "Timeout length, 1 to 10080", 1, 10080),
boolOption("escalate", "Double the timeout for repeat offences"),
intOption("panic-members", "Members maxing out in a minute that start heat panic, 0 (off) to 50", 0, 50),
intOption("mention-lockdown", "Mentions in 30 seconds that lock the server, 0 (off) or 5 to 1000", 0, 1000)),
sub("alert-dms", "Send critical alerts to the owner and trusted users too (owner or trusted)",
required(boolOption("on", "On or off"))),
}
}
func intArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *int {
if a, ok := args[name]; ok {
v := int(a.IntValue())
return &v
}
return nil
}
func boolArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *bool {
if a, ok := args[name]; ok {
v := a.BoolValue()
return &v
}
return nil
}
func strArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *string {
if a, ok := args[name]; ok {
v := a.StringValue()
return &v
}
return nil
}
// handleTune runs the tuning subcommands. It reports whether it handled name.
func handleTune(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, subcmd *discordgo.ApplicationCommandInteractionDataOption) bool {
args := opts(subcmd.Options)
switch subcmd.Name {
case "settings":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security settings.")
return true
}
st, _, err := e.Settings(ctx, i.GuildID)
if err != nil {
reply(s, i, message("settings", err))
return true
}
reply(s, i, describeSettings(st))
case "history":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security history.")
return true
}
events, err := e.Events(ctx, i.GuildID, 15)
if err != nil {
reply(s, i, message("history", err))
return true
}
if len(events) == 0 {
reply(s, i, "Nothing has happened yet.")
return true
}
var b strings.Builder
for _, ev := range events {
fmt.Fprintf(&b, "<t:%d:R> **%s**", ev.CreatedAt.Unix(), ev.Kind)
if ev.ActorID != 0 {
fmt.Fprintf(&b, " by <@%d>", ev.ActorID)
}
if ev.TargetID != 0 {
fmt.Fprintf(&b, " on <@%d>", ev.TargetID)
}
if ev.Detail != "" {
fmt.Fprintf(&b, ": %s", clip(ev.Detail, 120))
}
b.WriteString("\n")
}
reply(s, i, clip(b.String(), 1950))
case "joingate":
if len(subcmd.Options) == 0 {
return true
}
inner := subcmd.Options[0]
innerArgs := opts(inner.Options)
t := securityrepo.Tuning{}
var summary, done string
switch inner.Name {
case "check":
check, action := innerArgs["check"].StringValue(), innerArgs["action"].StringValue()
switch check {
case "no-avatar":
t.NoAvatar = &action
case "new-account":
t.NewAccount = &action
case "invite-in-name":
t.AdvertisingName = &action
case "blocked-names":
t.UsernameFilter = &action
case "unverified-bot":
t.UnverifiedBot = &action
case "unauthorized-bot":
t.UnauthorizedBot = &action
}
summary = "joingate " + check + " " + action
done = fmt.Sprintf("The **%s** check is now **%s**. The Join Gate itself is switched with `/security toggle`.", check, action)
case "account-age":
days := int(innerArgs["days"].IntValue())
t.MinAccountAgeDays = &days
summary = fmt.Sprintf("joingate minimum account age %d days", days)
done = fmt.Sprintf("Accounts younger than **%d days** are caught by the new-account check.", days)
default:
return true
}
finishTune(ctx, s, i, e, t, summary, done)
case "words":
if len(subcmd.Options) == 0 {
return true
}
handleWords(ctx, s, i, e, subcmd.Options[0])
case "raid":
t := securityrepo.Tuning{
RaidJoinCount: intArg(args, "join-count"), RaidJoinSeconds: intArg(args, "join-seconds"), RaidAction: strArg(args, "action"),
RaidPauseInvites: boolArg(args, "pause-invites"), RaidQuietMinutes: intArg(args, "quiet-minutes"),
}
if len(args) == 0 {
reply(s, i, "Say what to change: join-count, join-seconds, action, pause-invites or quiet-minutes.")
return true
}
finishTune(ctx, s, i, e, t, "raid settings changed", "Raid settings saved.\n"+raidLine(ctx, e, i.GuildID, t))
case "antinuke":
if len(subcmd.Options) == 0 {
return true
}
inner := subcmd.Options[0]
innerArgs := opts(inner.Options)
t := securityrepo.Tuning{}
var summary, done string
switch inner.Name {
case "set":
if len(innerArgs) == 0 {
reply(s, i, "Say what to change: window-seconds, lock-server or auto-restore.")
return true
}
t.AntiNukeWindowSeconds, t.AntiNukeLockServer, t.AntiNukeAutoRestore = intArg(innerArgs, "window-seconds"), boolArg(innerArgs, "lock-server"), boolArg(innerArgs, "auto-restore")
summary, done = "antinuke settings changed", "Anti-nuke settings saved. See them with `/security settings`."
case "limit":
action, count := innerArgs["action"].StringValue(), int(innerArgs["count"].IntValue())
t.AntiNukeLimits = map[string]int{action: count}
summary = fmt.Sprintf("antinuke limit %s = %d", action, count)
if count == 0 {
done = fmt.Sprintf("**%s** is never allowed for anyone who isn't the owner or trusted.", action)
} else {
done = fmt.Sprintf("**%s** is limited to **%d** per window.", action, count)
}
default:
return true
}
finishTune(ctx, s, i, e, t, summary, done)
case "heat":
if len(args) == 0 {
reply(s, i, "Say what to change: strictness, action, timeout-minutes, escalate, panic-members or mention-lockdown.")
return true
}
t := securityrepo.Tuning{
HeatStrictness: strArg(args, "strictness"), HeatAction: strArg(args, "action"), HeatTimeoutMinutes: intArg(args, "timeout-minutes"),
HeatEscalate: boolArg(args, "escalate"), HeatPanicMembers: intArg(args, "panic-members"), HeatLockdownMentions: intArg(args, "mention-lockdown"),
}
finishTune(ctx, s, i, e, t, "heat settings changed", "Heat settings saved. See them with `/security settings`.")
case "alert-dms":
on := args["on"].BoolValue()
state := "off"
if on {
state = "on"
}
finishTune(ctx, s, i, e, securityrepo.Tuning{AlertDM: &on}, "alert DMs "+state, "Critical alerts by DM are now **"+state+"**.")
default:
return false
}
return true
}
func finishTune(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, t securityrepo.Tuning, summary, done string) {
if err := e.Tune(ctx, i.GuildID, i.Member, t, summary); err != nil {
reply(s, i, message("tune", err))
return
}
reply(s, i, done)
}
func raidLine(ctx context.Context, e *security.Engine, guildID string, _ securityrepo.Tuning) string {
st, _, err := e.Settings(ctx, guildID)
if err != nil {
return ""
}
return fmt.Sprintf("%d joins within %ds start a raid, raiders get **%s**, it ends after %d quiet minutes (invites %s).",
st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction, st.RaidQuietMinutes, pausedText(st.RaidPauseInvites))
}
func pausedText(on bool) string {
if on {
return "are paused"
}
return "stay open"
}
func handleWords(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, inner *discordgo.ApplicationCommandInteractionDataOption) {
args := opts(inner.Options)
list := security.WordList(args["list"].StringValue())
what := map[security.WordList]string{security.UsernameWords: "new members' names", security.ChatWords: "messages"}[list]
switch inner.Name {
case "list":
st, _, err := e.Settings(ctx, i.GuildID)
if err != nil {
reply(s, i, message("words", err))
return
}
words := st.UsernamePatterns
if list == security.ChatWords {
words = st.HeatBlockedWords
}
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the blocked words.")
return
}
if len(words) == 0 {
reply(s, i, "No words are blocked in "+what+".")
return
}
reply(s, i, fmt.Sprintf("Blocked in %s (%d): %s", what, len(words), clip("`"+strings.Join(words, "`, `")+"`", 1700)))
case "add", "remove":
word := args["word"].StringValue()
words, err := e.EditWords(ctx, i.GuildID, i.Member, list, inner.Name == "add", word)
if err != nil {
reply(s, i, message("words", err))
return
}
verb := "is now blocked in"
if inner.Name == "remove" {
verb = "is no longer blocked in"
}
reply(s, i, fmt.Sprintf("`%s` %s %s. %d word(s) on the list.", strings.ToLower(strings.TrimSpace(word)), verb, what, len(words)))
}
}
// describeSettings lists every setting, for /security settings.
func describeSettings(st *securityrepo.Settings) string {
var b strings.Builder
fmt.Fprintf(&b, "**Join Gate** %s\n", onOff(st.JoinGateEnabled))
fmt.Fprintf(&b, "No avatar: %s · New account (under %d days): %s · Invite in name: %s · Blocked names: %s (%d words)\n",
st.NoAvatar, st.MinAccountAgeDays, st.NewAccount, st.AdvertisingName, st.UsernameFilter, len(st.UsernamePatterns))
fmt.Fprintf(&b, "Unverified bots: %s · Bots added by someone untrusted: %s\n\n", st.UnverifiedBot, st.UnauthorizedBot)
fmt.Fprintf(&b, "**Raid mode** %s\n%d joins in %ds → %s · ends after %d quiet minutes · invites %s\n\n",
onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction, st.RaidQuietMinutes, pausedText(st.RaidPauseInvites))
fmt.Fprintf(&b, "**Anti-nuke** %s\nWindow %ds · lock server on a nuke: %s · auto restore: %s\n",
onOff(st.AntiNukeEnabled), st.AntiNukeWindowSeconds, onOff(st.AntiNukeLockServer), onOff(st.AntiNukeAutoRestore))
keys := make([]string, 0, len(st.AntiNukeLimits))
for k := range st.AntiNukeLimits {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s %d", k, st.AntiNukeLimits[k]))
}
fmt.Fprintf(&b, "Limits: %s\n\n", strings.Join(parts, " · "))
mention := "off"
if st.HeatLockdownMentions > 0 {
mention = fmt.Sprintf("%d mentions", st.HeatLockdownMentions)
}
panicMembers := "off"
if st.HeatPanicMembers > 0 {
panicMembers = fmt.Sprintf("%d members", st.HeatPanicMembers)
}
fmt.Fprintf(&b, "**Heat** %s\nStrictness %s · action %s (%d min timeout, escalating %s) · panic at %s · mention flood lockdown at %s · %d blocked words\n\n",
onOff(st.HeatEnabled), st.HeatStrictness, st.HeatAction, st.HeatTimeoutMinutes, onOff(st.HeatEscalate), panicMembers, mention, len(st.HeatBlockedWords))
fmt.Fprintf(&b, "**Alerts** by DM to the owner and trusted: %s", onOff(st.AlertDM))
return clip(b.String(), 1990)
}
@@ -0,0 +1,247 @@
package public
import (
"context"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// The rest of the ticket configuration: the on/off switch, the panel's text,
// the open ticket limit, editing a category, and looking at tickets. All of it
// is available from Discord, so a dashboard is never needed.
func ticketSettingsOptions() []*discordgo.ApplicationCommandOption {
lo, hi := 1.0, 10.0
return []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings",
Description: "Show the ticket settings, or change them. Leave out what you don't change",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "Turn tickets on or off"},
{Type: discordgo.ApplicationCommandOptionString, Name: "panel-title", Description: "The panel's title", MaxLength: 256},
{Type: discordgo.ApplicationCommandOptionString, Name: "panel-description", Description: "The panel's text", MaxLength: 4000},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "max-open", Description: "Open tickets one member can have, 1 to 10", MinValue: &lo, MaxValue: hi},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list",
Description: "List recent tickets",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString, Name: "status", Description: "Open or closed (default: open)",
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "open", Value: ticketrepo.StatusOpen}, {Name: "closed", Value: ticketrepo.StatusClosed}},
},
},
},
}
}
func categoryEditOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "edit",
Description: "Change a category. Leave out what you don't change",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category to change", Required: true},
{Type: discordgo.ApplicationCommandOptionString, Name: "new-name", Description: "A new name", MaxLength: 40},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "clear-staff-role", Description: "Remove the staff role"},
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button. Use - to remove", MaxLength: 64},
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel. Use - to remove", MaxLength: 200},
},
}
}
func onOff(b bool) string {
if b {
return "on"
}
return "off"
}
func handleSettings(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
if len(args) == 0 {
st, err := Repo.GetSettings(ctx, g)
if err != nil {
reply(s, i, message("settings", err))
return
}
where := "not set (use `/tickets setup`)"
if st.ChannelID != 0 {
where = fmt.Sprintf("<#%d>", st.ChannelID)
}
logs := "none"
if st.LogChannelID != 0 {
logs = fmt.Sprintf("<#%d>", st.LogChannelID)
}
reply(s, i, fmt.Sprintf("**Tickets:** %s\n**Channel:** %s · **Log channel:** %s\n**Open tickets per member:** %d\n**Panel title:** %s\n**Panel text:** %s",
onOff(st.Enabled), where, logs, st.MaxOpenPerUser, clipText(st.PanelTitle, 200), clipText(st.PanelDescription, 600)))
return
}
var p ticketrepo.SettingsPatch
if a, ok := args["enabled"]; ok {
v := a.BoolValue()
p.Enabled = &v
}
if a, ok := args["panel-title"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "" || utf8.RuneCountInString(v) > 256 {
reply(s, i, "The panel title must be between 1 and 256 characters.")
return
}
p.PanelTitle = &v
}
if a, ok := args["panel-description"]; ok {
v := strings.TrimSpace(a.StringValue())
if utf8.RuneCountInString(v) > 4000 {
reply(s, i, "The panel text can be at most 4000 characters.")
return
}
p.PanelDescription = &v
}
if a, ok := args["max-open"]; ok {
v := int(a.IntValue())
if v < 1 || v > 10 {
reply(s, i, "Open tickets per member must be between 1 and 10.")
return
}
p.MaxOpenPerUser = &v
}
if err := Repo.UpdateSettings(ctx, g, p); err != nil {
reply(s, i, message("settings", err))
return
}
msg := "Saved."
if p.PanelTitle != nil || p.PanelDescription != nil {
msg += " Run `/tickets panel` to update the panel with the new text."
}
if p.Enabled != nil {
msg += " Tickets are now **" + onOff(*p.Enabled) + "**."
}
reply(s, i, msg)
}
func clipText(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
func handleCategoryEdit(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
name := strings.TrimSpace(args["name"].StringValue())
cats, err := Repo.ListCategories(ctx, g)
if err != nil {
reply(s, i, message("category edit", err))
return
}
var target *ticketrepo.Category
for idx := range cats {
if strings.EqualFold(cats[idx].Name, name) {
target = &cats[idx]
}
}
if target == nil {
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
return
}
var p ticketrepo.CategoryPatch
if a, ok := args["new-name"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "" || utf8.RuneCountInString(v) > 40 {
reply(s, i, "The category name must be between 1 and 40 characters.")
return
}
for _, c := range cats {
if c.ID != target.ID && strings.EqualFold(c.Name, v) {
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
return
}
}
p.Name = &v
}
if a, ok := args["staff-role"]; ok {
id, _ := strconv.ParseInt(a.RoleValue(nil, i.GuildID).ID, 10, 64)
p.StaffRoleID = &id
}
if a, ok := args["clear-staff-role"]; ok && a.BoolValue() {
if p.StaffRoleID != nil {
reply(s, i, "Choose either a staff role or clearing it, not both.")
return
}
zero := int64(0)
p.StaffRoleID = &zero
}
if a, ok := args["emoji"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "-" {
v = ""
}
p.Emoji = &v
}
if a, ok := args["description"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "-" {
v = ""
}
if utf8.RuneCountInString(v) > 200 {
reply(s, i, "The category description can be at most 200 characters.")
return
}
p.Description = &v
}
if p == (ticketrepo.CategoryPatch{}) {
reply(s, i, "Say what to change: new-name, staff-role, clear-staff-role, emoji or description.")
return
}
if _, err := Repo.UpdateCategory(ctx, g, target.ID, p); err != nil {
reply(s, i, message("category edit", err))
return
}
reply(s, i, fmt.Sprintf("Updated **%s**. Run `/tickets panel` to update the panel.", target.Name))
}
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
status := ticketrepo.StatusOpen
if a, ok := opts(o)["status"]; ok {
status = a.StringValue()
}
ts, err := Repo.ListTickets(ctx, guildID(i), status, 15)
if err != nil {
reply(s, i, message("list", err))
return
}
if len(ts) == 0 {
reply(s, i, "No "+status+" tickets.")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Latest %s tickets**\n", status)
for _, t := range ts {
fmt.Fprintf(&b, "• #%d <#%d> %s, by <@%d>", t.Number, t.ThreadID, clipText(t.CategoryName, 30), t.OpenerID)
if t.ClaimedBy != 0 {
fmt.Fprintf(&b, ", claimed by <@%d>", t.ClaimedBy)
}
b.WriteString("\n")
}
reply(s, i, clipText(b.String(), 1900))
}
+9 -2
View File
@@ -41,7 +41,7 @@ var Tickets = &discordgo.ApplicationCommand{
Description: "Set up support tickets", Description: "Set up support tickets",
DefaultMemberPermissions: &manageGuild, DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly, Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setup", Name: "setup",
@@ -77,6 +77,7 @@ var Tickets = &discordgo.ApplicationCommand{
}, },
}, },
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
categoryEditOption(),
}, },
}, },
{ {
@@ -84,7 +85,7 @@ var Tickets = &discordgo.ApplicationCommand{
Name: "panel", Name: "panel",
Description: "Post the ticket panel, or update it after changing categories", Description: "Post the ticket panel, or update it after changing categories",
}, },
}, }, ticketSettingsOptions()...),
} }
// Ticket is used inside a ticket thread. // Ticket is used inside a ticket thread.
@@ -166,6 +167,10 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleSetup(s, i, sub.Options) handleSetup(s, i, sub.Options)
case "panel": case "panel":
handlePanel(s, i) handlePanel(s, i)
case "settings":
handleSettings(s, i, sub.Options)
case "list":
handleList(s, i, sub.Options)
case "category": case "category":
if len(sub.Options) == 0 { if len(sub.Options) == 0 {
return return
@@ -177,6 +182,8 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleCategoryRemove(s, i, sub.Options[0].Options) handleCategoryRemove(s, i, sub.Options[0].Options)
case "list": case "list":
handleCategoryList(s, i) handleCategoryList(s, i)
case "edit":
handleCategoryEdit(s, i, sub.Options[0].Options)
} }
} }
} }
+158
View File
@@ -8,6 +8,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"strconv"
"strings"
"time" "time"
) )
@@ -197,6 +199,138 @@ func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) erro
return r.update(ctx, guildID, `join_lock = $2`, mode) return r.update(ctx, guildID, `join_lock = $2`, mode)
} }
// Tuning is a partial update of a server's thresholds, filters and actions.
// A nil field is left as it is. The security package validates it before it
// gets here; the column constraints are the last line of defence.
type Tuning struct {
AlertDM *bool
NoAvatar *string
NewAccount *string
MinAccountAgeDays *int
AdvertisingName *string
UsernameFilter *string
UsernamePatterns *[]string
UnverifiedBot *string
UnauthorizedBot *string
RaidJoinCount *int
RaidJoinSeconds *int
RaidAction *string
RaidPauseInvites *bool
RaidQuietMinutes *int
AntiNukeWindowSeconds *int
AntiNukeLimits map[string]int // only the keys present change
AntiNukeLockServer *bool
AntiNukeAutoRestore *bool
HeatStrictness *string
HeatAction *string
HeatTimeoutMinutes *int
HeatEscalate *bool
HeatBlockedWords *[]string
HeatPanicMembers *int
HeatLockdownMentions *int
}
// UpdateTuning applies the fields that are set, in one statement.
func (r *Repo) UpdateTuning(ctx context.Context, guildID int64, t Tuning) error {
var sets []string
args := []any{}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)+1)) // $1 is the guild
}
addList := func(col string, v []string) {
b, _ := json.Marshal(v)
args = append(args, string(b))
sets = append(sets, col+` = ARRAY(SELECT jsonb_array_elements_text($`+strconv.Itoa(len(args)+1)+`::jsonb))`)
}
if t.AlertDM != nil {
add("alert_dm", *t.AlertDM)
}
if t.NoAvatar != nil {
add("joingate_no_avatar", *t.NoAvatar)
}
if t.NewAccount != nil {
add("joingate_new_account", *t.NewAccount)
}
if t.MinAccountAgeDays != nil {
add("joingate_min_account_age_days", *t.MinAccountAgeDays)
}
if t.AdvertisingName != nil {
add("joingate_advertising_name", *t.AdvertisingName)
}
if t.UsernameFilter != nil {
add("joingate_username_filter", *t.UsernameFilter)
}
if t.UsernamePatterns != nil {
addList("joingate_username_patterns", *t.UsernamePatterns)
}
if t.UnverifiedBot != nil {
add("joingate_unverified_bot", *t.UnverifiedBot)
}
if t.UnauthorizedBot != nil {
add("joingate_unauthorized_bot", *t.UnauthorizedBot)
}
if t.RaidJoinCount != nil {
add("raid_join_count", *t.RaidJoinCount)
}
if t.RaidJoinSeconds != nil {
add("raid_join_seconds", *t.RaidJoinSeconds)
}
if t.RaidAction != nil {
add("raid_action", *t.RaidAction)
}
if t.RaidPauseInvites != nil {
add("raid_pause_invites", *t.RaidPauseInvites)
}
if t.RaidQuietMinutes != nil {
add("raid_quiet_minutes", *t.RaidQuietMinutes)
}
if t.AntiNukeWindowSeconds != nil {
add("antinuke_window_seconds", *t.AntiNukeWindowSeconds)
}
if len(t.AntiNukeLimits) > 0 {
b, _ := json.Marshal(t.AntiNukeLimits)
args = append(args, string(b))
// Merge into what is saved, so setting one limit keeps the others.
sets = append(sets, `antinuke_limits = antinuke_limits || $`+strconv.Itoa(len(args)+1)+`::jsonb`)
}
if t.AntiNukeLockServer != nil {
add("antinuke_lock_server", *t.AntiNukeLockServer)
}
if t.AntiNukeAutoRestore != nil {
add("antinuke_auto_restore", *t.AntiNukeAutoRestore)
}
if t.HeatStrictness != nil {
add("heat_strictness", *t.HeatStrictness)
}
if t.HeatAction != nil {
add("heat_action", *t.HeatAction)
}
if t.HeatTimeoutMinutes != nil {
add("heat_timeout_minutes", *t.HeatTimeoutMinutes)
}
if t.HeatEscalate != nil {
add("heat_escalate", *t.HeatEscalate)
}
if t.HeatBlockedWords != nil {
addList("heat_blocked_words", *t.HeatBlockedWords)
}
if t.HeatPanicMembers != nil {
add("heat_panic_members", *t.HeatPanicMembers)
}
if t.HeatLockdownMentions != nil {
add("heat_lockdown_mentions", *t.HeatLockdownMentions)
}
if len(sets) == 0 {
return nil
}
return r.update(ctx, guildID, strings.Join(sets, ", "), args...)
}
// ---- Trusted ---- // ---- Trusted ----
type Trusted struct { type Trusted struct {
@@ -380,6 +514,7 @@ type Event struct {
ActorID int64 ActorID int64
TargetID int64 TargetID int64
Detail string Detail string
CreatedAt time.Time // set when read
} }
// Event kinds. // Event kinds.
@@ -582,3 +717,26 @@ func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) (
} }
return out, rows.Err() return out, rows.Err()
} }
// ListEvents returns a server's most recent security events, newest first.
func (r *Repo) ListEvents(ctx context.Context, guildID int64, limit int) ([]Event, error) {
if limit < 1 || limit > 100 {
limit = 20
}
rows, err := r.db.QueryContext(ctx, `
SELECT kind, COALESCE(actor_id, 0), COALESCE(target_id, 0), detail, created_at
FROM security_events WHERE guild_id = $1 ORDER BY id DESC LIMIT $2`, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Event
for rows.Next() {
e := Event{GuildID: guildID}
if err := rows.Scan(&e.Kind, &e.ActorID, &e.TargetID, &e.Detail, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
@@ -229,3 +229,27 @@ func TestRaidsSnapshotsAndHeatSettings(t *testing.T) {
t.Error("with a zero max age every anti-nuke guild is due") t.Error("with a zero max age every anti-nuke guild is due")
} }
} }
func TestListEvents(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
for _, kind := range []string{"settings", "raid_started", "quarantine"} {
if err := r.AddEvent(ctx, Event{GuildID: g, Kind: kind, ActorID: 5, Detail: "d"}); err != nil {
t.Fatal(err)
}
}
if err := r.AddEvent(ctx, Event{GuildID: g + 1, Kind: "other server"}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = r.db.Exec(`DELETE FROM security_events WHERE guild_id = $1`, g+1) })
got, err := r.ListEvents(ctx, g, 2)
if err != nil || len(got) != 2 || got[0].Kind != "quarantine" || got[1].Kind != "raid_started" {
t.Fatalf("newest first, limited: %+v %v", got, err)
}
if got[0].CreatedAt.IsZero() || got[0].ActorID != 5 || got[0].TargetID != 0 {
t.Errorf("fields: %+v", got[0])
}
if all, _ := r.ListEvents(ctx, g, 0); len(all) != 3 {
t.Errorf("only this server's events: %v", all)
}
}
@@ -153,7 +153,7 @@ func TestGetBetaSettings(t *testing.T) {
t.Fatalf("role = %q, want empty", role) t.Fatalf("role = %q, want empty", role)
} }
_, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 1170934850157547600)`) _, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 700000000000000001)`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -161,8 +161,8 @@ func TestGetBetaSettings(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if g != "1170934850157547600" { if g != "700000000000000001" {
t.Fatalf("g = %q, want %q", g, "1170934850157547600") t.Fatalf("g = %q, want %q", g, "700000000000000001")
} }
if role != "" { if role != "" {
t.Fatalf("role = %q, want empty", role) t.Fatalf("role = %q, want empty", role)
@@ -256,3 +256,66 @@ func TestBetaRegistered(t *testing.T) {
t.Error("a bad ID is refused") t.Error("a bad ID is refused")
} }
} }
func TestBetaSettingsFromCommands(t *testing.T) {
r, _ := testRepo(t)
ctx := context.Background()
var wasG, wasR sql.NullInt64
hadRow := r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil
t.Cleanup(func() {
if hadRow {
_, _ = r.db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR)
} else {
_, _ = r.db.ExecContext(ctx, `DELETE FROM beta_settings`)
}
_ = r.SetBetaGuild(ctx, 8800000000000007171, 1, false)
})
if err := r.SetBetaServer(ctx, 700000000000000001, 800000000000000002, 5); err != nil {
t.Fatal(err)
}
g, role, err := r.GetBetaSettings(ctx)
if err != nil || g != "700000000000000001" || role != "800000000000000002" {
t.Fatalf("settings: %q %q %v", g, role, err)
}
if err := r.SetBetaServer(ctx, 700000000000000001, 0, 5); err != nil {
t.Fatal(err)
}
if g, role, _ = r.GetBetaSettings(ctx); g != "700000000000000001" || role != "" {
t.Errorf("a zero role clears the role and keeps the server: %q %q", g, role)
}
const guild = 8800000000000007171
for i := 0; i < 2; i++ {
if err := r.SetBetaGuild(ctx, guild, 9, true); err != nil {
t.Fatal(err)
}
}
if on, _ := r.IsBetaGuild(ctx, guild); !on {
t.Error("opted in")
}
if err := r.SetBetaGuild(ctx, guild, 9, false); err != nil {
t.Fatal(err)
}
if on, _ := r.IsBetaGuild(ctx, guild); on {
t.Error("opted out")
}
}
func TestDeleteRoleReward(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if err := r.SetRoleRewardForLevel(ctx, g, 5, 77); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = r.db.ExecContext(ctx, `DELETE FROM levelrewards WHERE guild_id = $1`, g) })
if removed, err := r.DeleteRoleRewardForLevel(ctx, g, 6); err != nil || removed {
t.Errorf("nothing at level 6: %v %v", removed, err)
}
if removed, err := r.DeleteRoleRewardForLevel(ctx, g, 5); err != nil || !removed {
t.Errorf("remove level 5: %v %v", removed, err)
}
if _, ok, _ := r.GetRoleRewardForLevel(ctx, g, 5); ok {
t.Error("the reward is gone")
}
}
+40
View File
@@ -475,3 +475,43 @@ func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered
} }
return err return err
} }
// SetBetaServer saves the beta server and the Beta Tester role (0 clears the
// role, which turns beta features off for everyone).
func (r *Repo) SetBetaServer(ctx context.Context, guildID, roleID, updatedBy int64) error {
const q = `
INSERT INTO beta_settings (id, guild_id, role_id, updated_by, updated_at)
VALUES (TRUE, $1, $2, $3, NOW())
ON CONFLICT (id) DO UPDATE SET guild_id = EXCLUDED.guild_id, role_id = EXCLUDED.role_id,
updated_by = EXCLUDED.updated_by, updated_at = NOW()`
_, err := r.db.ExecContext(ctx, q, guildID, sql.NullInt64{Int64: roleID, Valid: roleID != 0}, updatedBy)
return err
}
// SetBetaGuild opts a server in or out of beta features.
func (r *Repo) SetBetaGuild(ctx context.Context, guildID, enabledBy int64, enabled bool) error {
if enabled {
_, err := r.db.ExecContext(ctx, `INSERT INTO beta_guilds (guild_id, enabled_by) VALUES ($1, $2) ON CONFLICT (guild_id) DO NOTHING`, guildID, enabledBy)
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, guildID)
return err
}
// IsBetaGuild reports whether a server opted in to beta features.
func (r *Repo) IsBetaGuild(ctx context.Context, guildID int64) (bool, error) {
var on bool
err := r.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM beta_guilds WHERE guild_id = $1)`, guildID).Scan(&on)
return on, err
}
// DeleteRoleRewardForLevel removes the role reward of a level and reports
// whether there was one.
func (r *Repo) DeleteRoleRewardForLevel(ctx context.Context, guildID int64, level int) (bool, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM levelrewards WHERE guild_id = $1 AND levelreq = $2`, guildID, level)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
+109
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"strconv"
"strings"
"time" "time"
) )
@@ -301,3 +303,110 @@ func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id) _, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
return err return err
} }
// SettingsPatch changes ticket settings. A nil field is left as it is.
type SettingsPatch struct {
Enabled *bool
PanelTitle *string
PanelDescription *string
MaxOpenPerUser *int
}
// UpdateSettings applies the fields that are set. The server gets a settings
// row if it has none yet, with everything else at its default and the system
// still off, until /tickets setup or this turns it on.
func (r *Repo) UpdateSettings(ctx context.Context, guildID int64, p SettingsPatch) error {
if _, err := r.db.ExecContext(ctx, `INSERT INTO ticket_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID); err != nil {
return err
}
var sets []string
args := []any{guildID}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)))
}
if p.Enabled != nil {
add("enabled", *p.Enabled)
}
if p.PanelTitle != nil {
add("panel_title", *p.PanelTitle)
}
if p.PanelDescription != nil {
add("panel_description", *p.PanelDescription)
}
if p.MaxOpenPerUser != nil {
add("max_open_per_user", *p.MaxOpenPerUser)
}
if len(sets) == 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `UPDATE ticket_settings SET `+strings.Join(sets, ", ")+` WHERE guild_id = $1`, args...)
return err
}
// CategoryPatch changes a category. A nil field is left as it is; an empty
// emoji or description clears it, and a zero StaffRoleID clears the role.
type CategoryPatch struct {
Name *string
Emoji *string
Description *string
StaffRoleID *int64
}
// UpdateCategory applies the fields that are set and returns the category as
// saved, or nil when there is none with that ID in the guild.
func (r *Repo) UpdateCategory(ctx context.Context, guildID, id int64, p CategoryPatch) (*Category, error) {
var sets []string
args := []any{guildID, id}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)))
}
if p.Name != nil {
add("name", *p.Name)
}
if p.Emoji != nil {
add("emoji", nullText(*p.Emoji))
}
if p.Description != nil {
add("description", *p.Description)
}
if p.StaffRoleID != nil {
add("staff_role_id", nullID(*p.StaffRoleID))
}
if len(sets) == 0 {
return r.GetCategory(ctx, guildID, id)
}
q := `UPDATE ticket_categories SET ` + strings.Join(sets, ", ") + ` WHERE guild_id = $1 AND id = $2 RETURNING ` + categoryCols
c, err := scanCategory(r.db.QueryRowContext(ctx, q, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// ListTickets returns a guild's most recent tickets with the given status
// ("open" or "closed"), newest first.
func (r *Repo) ListTickets(ctx context.Context, guildID int64, status string, limit int) ([]Ticket, error) {
if limit < 1 || limit > 100 {
limit = 25
}
q := `SELECT ` + ticketCols + ` FROM tickets WHERE guild_id = $1 AND status = $2 ORDER BY number DESC LIMIT $3`
rows, err := r.db.QueryContext(ctx, q, guildID, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
@@ -1,6 +1,6 @@
//go:build integration //go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied: // Runs against a real Postgres with schema.sql loaded:
// //
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo // TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
package ticketrepo package ticketrepo
@@ -46,7 +46,7 @@ func TestSettingsDefaultsAndChannels(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s, err := r.GetSettings(ctx, g) s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 { if err != nil || s.Configured || s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
t.Fatalf("defaults wrong: %+v %v", s, err) t.Fatalf("defaults wrong: %+v %v", s, err)
} }
if err := r.SetChannels(ctx, g, 111, 222); err != nil { if err := r.SetChannels(ctx, g, 111, 222); err != nil {
@@ -204,3 +204,84 @@ func TestCommands(t *testing.T) {
} }
} }
} }
func TestUpdateSettingsCategoriesAndList(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
str := func(v string) *string { return &v }
num := func(v int) *int { return &v }
on := true
// Changing the text of a server that never set tickets up creates its row
// and leaves the system off.
if err := r.UpdateSettings(ctx, g, SettingsPatch{PanelTitle: str("Help desk"), MaxOpenPerUser: num(3)}); err != nil {
t.Fatal(err)
}
st, _ := r.GetSettings(ctx, g)
if st.Enabled || st.PanelTitle != "Help desk" || st.MaxOpenPerUser != 3 || st.PanelDescription != DefaultPanelDescription {
t.Errorf("settings: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{Enabled: &on}); err != nil {
t.Fatal(err)
}
if st, _ = r.GetSettings(ctx, g); !st.Enabled || st.PanelTitle != "Help desk" {
t.Errorf("enabling keeps the rest: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{MaxOpenPerUser: num(11)}); err == nil {
t.Error("the database refuses more than 10 open tickets per member")
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{}); err != nil {
t.Errorf("an empty patch is fine: %v", err)
}
cat, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🎫", Description: "Questions", StaffRoleID: 99})
if err != nil {
t.Fatal(err)
}
other, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Report"})
if err != nil {
t.Fatal(err)
}
zero := int64(0)
empty := ""
got, err := r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{Name: str("Help"), Emoji: &empty, StaffRoleID: &zero})
if err != nil || got == nil {
t.Fatalf("update: %v %v", got, err)
}
if got.Name != "Help" || got.Emoji != "" || got.StaffRoleID != 0 || got.Description != "Questions" {
t.Errorf("only the named fields change, and empty clears: %+v", got)
}
if got, _ = r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{StaffRoleID: func() *int64 { v := int64(5); return &v }()}); got.StaffRoleID != 5 {
t.Errorf("setting the role: %+v", got)
}
if gone, err := r.UpdateCategory(ctx, g+1, cat.ID, CategoryPatch{Name: str("x")}); err != nil || gone != nil {
t.Errorf("another server's category can't be edited: %v %v", gone, err)
}
if _, err := r.UpdateCategory(ctx, g, other.ID, CategoryPatch{Name: str("")}); err == nil {
t.Error("the database refuses an empty name")
}
for n, status := range []string{"open", "open", "closed"} {
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: n + 1, CategoryID: cat.ID, CategoryName: "Help", ThreadID: int64(9100 + n), OpenerID: 7, Subject: "x"})
if err != nil {
t.Fatal(err)
}
if status == "closed" {
if _, err := r.Close(ctx, int64(9100+n), 7, "done"); err != nil {
t.Fatal(err)
}
}
_ = id
}
open, err := r.ListTickets(ctx, g, StatusOpen, 10)
if err != nil || len(open) != 2 || open[0].Number != 2 {
t.Errorf("open tickets, newest first: %v %v", open, err)
}
closed, _ := r.ListTickets(ctx, g, StatusClosed, 10)
if len(closed) != 1 {
t.Errorf("closed tickets: %v", closed)
}
if other, _ := r.ListTickets(ctx, g+1, StatusOpen, 10); len(other) != 0 {
t.Error("another server's tickets must not show")
}
}
@@ -0,0 +1,39 @@
// Package betasettings is where the beta server, the Beta Tester role and the
// servers that opted in to beta features are read and changed. The bot polls
// the same tables, so a change here reaches the beta gate within 30 seconds.
package betasettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
// Get returns the beta server and role as ID strings ("" when not set).
func (s *Service) Get(ctx context.Context) (guildID, roleID string, err error) {
return s.settings.GetBetaSettings(ctx)
}
func (s *Service) SetServer(ctx context.Context, guildID, roleID, updatedBy int64) error {
return s.settings.SetBetaServer(ctx, guildID, roleID, updatedBy)
}
func (s *Service) OptedIn(ctx context.Context) ([]string, error) {
return s.settings.ListBetaGuilds(ctx)
}
func (s *Service) IsOptedIn(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsBetaGuild(ctx, guildID)
}
func (s *Service) SetOptedIn(ctx context.Context, guildID, by int64, on bool) error {
return s.settings.SetBetaGuild(ctx, guildID, by, on)
}
@@ -55,3 +55,11 @@ func (s *Service) ListRoleRewards(ctx context.Context, guildID int64, limit int)
func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) { func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsLevelSystemEnabled(ctx, guildID) return s.settings.IsLevelSystemEnabled(ctx, guildID)
} }
func (s *Service) DeleteRoleRewardForLevel(ctx context.Context, guildID int64, level int) (bool, error) {
return s.settings.DeleteRoleRewardForLevel(ctx, guildID, level)
}
func (s *Service) GetLevelUpChannelID(ctx context.Context, guildID int64) (int64, bool, error) {
return s.settings.GetLevelupChannelID(ctx, guildID)
}
+4 -1
View File
@@ -1,6 +1,7 @@
package services package services
import ( import (
"velox-bot/internal/db/services/betasettings"
"velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
@@ -28,11 +29,12 @@ type Services struct {
DefaultRole *defaultrole.Service DefaultRole *defaultrole.Service
LogSettings *logsettings.Service LogSettings *logsettings.Service
MusicSettings *musicsettings.Service MusicSettings *musicsettings.Service
BetaSettings *betasettings.Service
} }
var Global *Services var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service) *Services { func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service, betaSvc *betasettings.Service) *Services {
s := &Services{ s := &Services{
Level: level, Level: level,
LevelSettings: levelSettings, LevelSettings: levelSettings,
@@ -46,6 +48,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
DefaultRole: defaultRoleSvc, DefaultRole: defaultRoleSvc,
LogSettings: logSettingsSvc, LogSettings: logSettingsSvc,
MusicSettings: musicSvc, MusicSettings: musicSvc,
BetaSettings: betaSvc,
} }
Global = s Global = s
return s return s
+66
View File
@@ -153,6 +153,61 @@ func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
f.set(g).JoinLock = mode f.set(g).JoinLock = mode
return nil return nil
} }
func (f *fakeStore) UpdateTuning(_ context.Context, g int64, t securityrepo.Tuning) error {
s := f.set(g)
str := func(dst *string, v *string) {
if v != nil {
*dst = *v
}
}
num := func(dst *int, v *int) {
if v != nil {
*dst = *v
}
}
flag := func(dst *bool, v *bool) {
if v != nil {
*dst = *v
}
}
if t.AlertDM != nil {
s.AlertDM = *t.AlertDM
}
str(&s.NoAvatar, t.NoAvatar)
str(&s.NewAccount, t.NewAccount)
num(&s.MinAccountAgeDays, t.MinAccountAgeDays)
str(&s.AdvertisingName, t.AdvertisingName)
str(&s.UsernameFilter, t.UsernameFilter)
if t.UsernamePatterns != nil {
s.UsernamePatterns = *t.UsernamePatterns
}
str(&s.UnverifiedBot, t.UnverifiedBot)
str(&s.UnauthorizedBot, t.UnauthorizedBot)
num(&s.RaidJoinCount, t.RaidJoinCount)
num(&s.RaidJoinSeconds, t.RaidJoinSeconds)
str(&s.RaidAction, t.RaidAction)
flag(&s.RaidPauseInvites, t.RaidPauseInvites)
num(&s.RaidQuietMinutes, t.RaidQuietMinutes)
num(&s.AntiNukeWindowSeconds, t.AntiNukeWindowSeconds)
if s.AntiNukeLimits == nil {
s.AntiNukeLimits = securityrepo.DefaultLimits()
}
for k, v := range t.AntiNukeLimits {
s.AntiNukeLimits[k] = v
}
flag(&s.AntiNukeLockServer, t.AntiNukeLockServer)
flag(&s.AntiNukeAutoRestore, t.AntiNukeAutoRestore)
str(&s.HeatStrictness, t.HeatStrictness)
str(&s.HeatAction, t.HeatAction)
num(&s.HeatTimeoutMinutes, t.HeatTimeoutMinutes)
flag(&s.HeatEscalate, t.HeatEscalate)
if t.HeatBlockedWords != nil {
s.HeatBlockedWords = *t.HeatBlockedWords
}
num(&s.HeatPanicMembers, t.HeatPanicMembers)
num(&s.HeatLockdownMentions, t.HeatLockdownMentions)
return nil
}
func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) { func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -252,6 +307,17 @@ func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error {
f.events = append(f.events, e) f.events = append(f.events, e)
return nil return nil
} }
func (f *fakeStore) ListEvents(_ context.Context, g int64, limit int) ([]securityrepo.Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Event
for k := len(f.events) - 1; k >= 0 && len(out) < limit; k-- {
if f.events[k].GuildID == g {
out = append(out, f.events[k])
}
}
return out, nil
}
func (f *fakeStore) eventKinds() []string { func (f *fakeStore) eventKinds() []string {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+5
View File
@@ -182,6 +182,11 @@ func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error
} }
// Quarantines lists who is quarantined in a guild. // Quarantines lists who is quarantined in a guild.
// Events returns the server's recent security history, newest first.
func (e *Engine) Events(ctx context.Context, guildID string, limit int) ([]securityrepo.Event, error) {
return e.st.ListEvents(ctx, parseID(guildID), limit)
}
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) { func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
return e.st.ListQuarantines(ctx, parseID(guildID)) return e.st.ListQuarantines(ctx, parseID(guildID))
} }
+2
View File
@@ -48,6 +48,7 @@ type Store interface {
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
SetJoinLock(ctx context.Context, guildID int64, mode string) error SetJoinLock(ctx context.Context, guildID int64, mode string) error
UpdateTuning(ctx context.Context, guildID int64, t securityrepo.Tuning) error
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error) ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
@@ -60,6 +61,7 @@ type Store interface {
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
DeleteLockdown(ctx context.Context, guildID, channelID int64) error DeleteLockdown(ctx context.Context, guildID, channelID int64) error
AddEvent(ctx context.Context, e securityrepo.Event) error AddEvent(ctx context.Context, e securityrepo.Event) error
ListEvents(ctx context.Context, guildID int64, limit int) ([]securityrepo.Event, error)
ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error) ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error)
MarkCommandProcessed(ctx context.Context, id int64) error MarkCommandProcessed(ctx context.Context, id int64) error
SaveRaid(ctx context.Context, x securityrepo.Raid) error SaveRaid(ctx context.Context, x securityrepo.Raid) error
+217
View File
@@ -0,0 +1,217 @@
package security
import (
"context"
"fmt"
"sort"
"strings"
"unicode"
"unicode/utf8"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Tuning the thresholds, filters and actions. Everything the Join Gate, raid
// mode, anti-nuke and heat do can be adjusted from Discord with these calls;
// a dashboard is never needed. They follow the same rules as the rest of the
// configuration: the server owner or someone on the trusted list, checked
// here and not left to Discord's command permissions.
const (
maxWords = 50 // blocked words per list
maxWordLength = 50
)
var (
filterActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
botActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
raidActions = map[string]bool{securityrepo.ActionAlert: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
heatActions = map[string]bool{securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
strictnessSet = map[string]bool{"low": true, "medium": true, "high": true}
nukeLimitKeys = map[string]bool{
securityrepo.LimitChannelDelete: true, securityrepo.LimitChannelCreate: true, securityrepo.LimitRoleDelete: true,
securityrepo.LimitRoleCreate: true, securityrepo.LimitBan: true, securityrepo.LimitKick: true, securityrepo.LimitWebhookCreate: true,
}
)
// CleanWords trims, lowercases and de-duplicates a list of blocked words and
// rejects anything oversized or containing control characters. They are
// matched as plain text, never as regular expressions.
func CleanWords(in []string) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, w := range in {
w = strings.ToLower(strings.TrimSpace(w))
if w == "" || seen[w] {
continue
}
if utf8.RuneCountInString(w) > maxWordLength {
return nil, UserError("Each blocked word can be at most 50 characters.")
}
for _, r := range w {
if unicode.IsControl(r) {
return nil, UserError("Blocked words can't contain control characters.")
}
}
seen[w] = true
out = append(out, w)
}
if len(out) > maxWords {
return nil, UserError("You can block at most 50 words.")
}
sort.Strings(out)
return out, nil
}
func inRange(v *int, lo, hi int) bool { return v == nil || (*v >= lo && *v <= hi) }
func oneOf(v *string, allowed map[string]bool) bool { return v == nil || allowed[*v] }
// ValidateTuning returns a message for the first invalid field, or "". It
// also cleans the word lists in place.
func ValidateTuning(t *securityrepo.Tuning) error {
for name, v := range map[string]*string{
"No avatar": t.NoAvatar, "New account": t.NewAccount, "Invite in name": t.AdvertisingName, "Blocked words": t.UsernameFilter,
} {
if !oneOf(v, filterActions) {
return UserError(name + " can be off, timeout, kick or ban.")
}
}
if !oneOf(t.UnverifiedBot, botActions) || !oneOf(t.UnauthorizedBot, botActions) {
return UserError("Bot filters can only be off, kick or ban.")
}
if !oneOf(t.RaidAction, raidActions) {
return UserError("The raid action can be alert, timeout, kick or ban.")
}
if !oneOf(t.HeatStrictness, strictnessSet) {
return UserError("Heat strictness must be low, medium or high.")
}
if !oneOf(t.HeatAction, heatActions) {
return UserError("The heat action can be timeout, kick or ban.")
}
switch {
case !inRange(t.MinAccountAgeDays, 1, 365):
return UserError("Minimum account age must be between 1 and 365 days.")
case !inRange(t.RaidJoinCount, 3, 100):
return UserError("The raid join count must be between 3 and 100.")
case !inRange(t.RaidJoinSeconds, 5, 300):
return UserError("The raid window must be between 5 and 300 seconds.")
case !inRange(t.RaidQuietMinutes, 1, 120):
return UserError("The raid quiet period must be between 1 and 120 minutes.")
case !inRange(t.AntiNukeWindowSeconds, 5, 300):
return UserError("The anti-nuke window must be between 5 and 300 seconds.")
case !inRange(t.HeatTimeoutMinutes, 1, 10080):
return UserError("The heat timeout must be between 1 minute and 7 days (10080 minutes).")
case !inRange(t.HeatPanicMembers, 0, 50):
return UserError("Heat panic must be between 0 (off) and 50 members.")
case t.HeatLockdownMentions != nil && *t.HeatLockdownMentions != 0 && (*t.HeatLockdownMentions < 5 || *t.HeatLockdownMentions > 1000):
return UserError("The mention flood lockdown must be 0 (off) or between 5 and 1000 mentions.")
}
for k, v := range t.AntiNukeLimits {
if !nukeLimitKeys[k] {
return UserError("Unknown anti-nuke limit " + fmt.Sprintf("%q", k) + ".")
}
if v < 0 || v > 100 {
return UserError("Anti-nuke limits must be between 0 and 100 (0 means never allowed).")
}
}
if t.UsernamePatterns != nil {
words, err := CleanWords(*t.UsernamePatterns)
if err != nil {
return err
}
t.UsernamePatterns = &words
}
if t.HeatBlockedWords != nil {
words, err := CleanWords(*t.HeatBlockedWords)
if err != nil {
return err
}
t.HeatBlockedWords = &words
}
return nil
}
// Tune changes thresholds, filters and actions. Only the server owner or
// someone on the trusted list may.
func (e *Engine) Tune(ctx context.Context, guildID string, actor *discordgo.Member, t securityrepo.Tuning, summary string) error {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return err
}
if err := ValidateTuning(&t); err != nil {
return err
}
if err := e.st.UpdateTuning(ctx, parseID(guildID), t); err != nil {
return err
}
e.Invalidate(guildID)
e.event(ctx, guildID, "settings", actor.User.ID, "", summary)
return nil
}
// WordList names one of the two blocked word lists.
type WordList string
const (
UsernameWords WordList = "username" // checked against new members' names by the Join Gate
ChatWords WordList = "chat" // checked against messages by heat
)
// EditWords adds or removes one blocked word and returns the list as saved.
func (e *Engine) EditWords(ctx context.Context, guildID string, actor *discordgo.Member, list WordList, add bool, word string) ([]string, error) {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return nil, err
}
e.Invalidate(guildID) // read what is saved now, not what was cached
cur, _, err := e.load(ctx, guildID)
if err != nil {
return nil, err
}
var words []string
switch list {
case UsernameWords:
words = append(words, cur.UsernamePatterns...)
case ChatWords:
words = append(words, cur.HeatBlockedWords...)
default:
return nil, UserError("Unknown list.")
}
word = strings.ToLower(strings.TrimSpace(word))
if word == "" {
return nil, UserError("Give a word to block.")
}
if add {
words = append(words, word)
} else {
kept := words[:0]
for _, w := range words {
if w != word {
kept = append(kept, w)
}
}
if len(kept) == len(words) {
return nil, UserError("That word isn't on the list.")
}
words = kept
}
clean, err := CleanWords(words)
if err != nil {
return nil, err
}
t := securityrepo.Tuning{}
if list == UsernameWords {
t.UsernamePatterns = &clean
} else {
t.HeatBlockedWords = &clean
}
verb := "removed"
if add {
verb = "added"
}
if err := e.Tune(ctx, guildID, actor, t, fmt.Sprintf("%s word list: %s %q", list, verb, word)); err != nil {
return nil, err
}
return clean, nil
}
+147
View File
@@ -0,0 +1,147 @@
package security
import (
"context"
"errors"
"strings"
"testing"
"velox-bot/internal/db/repos/securityrepo"
)
func ptr[T any](v T) *T { return &v }
func TestValidateTuning(t *testing.T) {
cases := []struct {
name string
t securityrepo.Tuning
ok bool
}{
{"nothing to change is fine", securityrepo.Tuning{}, true},
{"a filter action", securityrepo.Tuning{NoAvatar: ptr("timeout")}, true},
{"an unknown filter action", securityrepo.Tuning{NoAvatar: ptr("nuke")}, false},
{"bots can't be timed out", securityrepo.Tuning{UnverifiedBot: ptr("timeout")}, false},
{"bots can be kicked", securityrepo.Tuning{UnauthorizedBot: ptr("kick")}, true},
{"raid alert only", securityrepo.Tuning{RaidAction: ptr("alert")}, true},
{"heat can't just alert", securityrepo.Tuning{HeatAction: ptr("alert")}, false},
{"strictness", securityrepo.Tuning{HeatStrictness: ptr("extreme")}, false},
{"account age low", securityrepo.Tuning{MinAccountAgeDays: ptr(0)}, false},
{"account age high", securityrepo.Tuning{MinAccountAgeDays: ptr(366)}, false},
{"raid join count edges", securityrepo.Tuning{RaidJoinCount: ptr(3)}, true},
{"raid join count too low", securityrepo.Tuning{RaidJoinCount: ptr(2)}, false},
{"raid window too long", securityrepo.Tuning{RaidJoinSeconds: ptr(301)}, false},
{"quiet minutes", securityrepo.Tuning{RaidQuietMinutes: ptr(121)}, false},
{"nuke window", securityrepo.Tuning{AntiNukeWindowSeconds: ptr(4)}, false},
{"heat timeout max", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10080)}, true},
{"heat timeout over", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10081)}, false},
{"panic off", securityrepo.Tuning{HeatPanicMembers: ptr(0)}, true},
{"panic too many", securityrepo.Tuning{HeatPanicMembers: ptr(51)}, false},
{"mention flood off", securityrepo.Tuning{HeatLockdownMentions: ptr(0)}, true},
{"mention flood too small", securityrepo.Tuning{HeatLockdownMentions: ptr(4)}, false},
{"mention flood max", securityrepo.Tuning{HeatLockdownMentions: ptr(1000)}, true},
{"a known limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 0}}, true},
{"an unknown limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban_everyone": 1}}, false},
{"a negative limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": -1}}, false},
{"a huge limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 101}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateTuning(&tc.t)
if (err == nil) != tc.ok {
t.Errorf("ValidateTuning = %v, want ok=%v", err, tc.ok)
}
var ue UserError
if err != nil && !errors.As(err, &ue) {
t.Errorf("a refusal must be a UserError the command can show: %T", err)
}
})
}
}
func TestCleanWords(t *testing.T) {
got, err := CleanWords([]string{" Spam ", "spam", "", "ADS", "ads "})
if err != nil || len(got) != 2 || got[0] != "ads" || got[1] != "spam" {
t.Errorf("trimmed, lowercased, de-duplicated and sorted: %v, %v", got, err)
}
if _, err := CleanWords([]string{strings.Repeat("x", 51)}); err == nil {
t.Error("an oversized word is refused")
}
if _, err := CleanWords([]string{"bad\x00word"}); err == nil {
t.Error("control characters are refused")
}
many := make([]string, 51)
for i := range many {
many[i] = strings.Repeat("a", i+1)
}
if _, err := CleanWords(many); err == nil {
t.Error("more than 50 words is refused")
}
}
func TestTuneNeedsTheOwnerOrTrusted(t *testing.T) {
e, d, st, _ := testEngine(t)
ctx := context.Background()
owner := d.members["1"]
admin := d.addMember("50")
trustedUser := d.addMember("60")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
refresh(e)
tun := securityrepo.Tuning{RaidJoinCount: ptr(20)}
if err := e.Tune(ctx, guild, admin, tun, "x"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("an admin who isn't trusted must not tune: %v", err)
}
if err := e.Tune(ctx, guild, nil, tun, "x"); err == nil {
t.Error("no actor, no change")
}
if st.set(100).RaidJoinCount == 20 {
t.Fatal("nothing may be saved for a refused change")
}
if err := e.Tune(ctx, guild, owner, tun, "raid join count 20"); err != nil {
t.Fatalf("the owner may: %v", err)
}
if st.set(100).RaidJoinCount != 20 {
t.Error("the owner's change is saved")
}
if err := e.Tune(ctx, guild, trustedUser, securityrepo.Tuning{RaidJoinCount: ptr(2)}, "x"); err == nil {
t.Error("an invalid value is refused even for the owner or trusted")
}
if st.set(100).RaidJoinCount != 20 {
t.Error("an invalid change must leave the old value")
}
if len(st.events) == 0 || st.events[len(st.events)-1].Kind != "settings" {
t.Errorf("the change is written to the history: %v", st.events)
}
}
func TestEditWords(t *testing.T) {
e, d, st, _ := testEngine(t)
ctx := context.Background()
owner := d.members["1"]
admin := d.addMember("50")
if _, err := e.EditWords(ctx, guild, admin, UsernameWords, true, "scam"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("untrusted: %v", err)
}
words, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " SCAM ")
if err != nil || len(words) != 1 || words[0] != "scam" {
t.Fatalf("add: %v, %v", words, err)
}
if _, err := e.EditWords(ctx, guild, owner, ChatWords, true, "free nitro"); err != nil {
t.Fatalf("the chat list is separate: %v", err)
}
s := st.set(100)
if len(s.UsernamePatterns) != 1 || len(s.HeatBlockedWords) != 1 {
t.Errorf("each list keeps its own words: %v %v", s.UsernamePatterns, s.HeatBlockedWords)
}
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, false, "nothing"); err == nil {
t.Error("removing a word that isn't there says so")
}
words, err = e.EditWords(ctx, guild, owner, UsernameWords, false, "scam")
if err != nil || len(words) != 0 {
t.Errorf("remove: %v, %v", words, err)
}
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " "); err == nil {
t.Error("a blank word is refused")
}
}
+2 -1
View File
@@ -25,6 +25,7 @@ import (
"velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/welcomerepo" "velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/db/services/betasettings"
"velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
@@ -78,7 +79,7 @@ func main() {
welcomeService := welcome.New(welcomeRepo) welcomeService := welcome.New(welcomeRepo)
defaultRoleService := defaultrole.New(defaultRoleRepo) defaultRoleService := defaultrole.New(defaultRoleRepo)
musicSettingsService := musicsettings.New(settingsRepo) musicSettingsService := musicsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService) services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService, betasettings.New(settingsRepo))
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost)
if err != nil { if err != nil {
+39
View File
@@ -288,6 +288,8 @@ CREATE TABLE IF NOT EXISTS security_settings (
-- Where alerts go, and who gets pinged for raids and nukes. -- Where alerts go, and who gets pinged for raids and nukes.
alert_channel_id BIGINT, alert_channel_id BIGINT,
alert_role_id BIGINT, alert_role_id BIGINT,
-- Also DM critical alerts to the owner and trusted users.
alert_dm BOOLEAN NOT NULL DEFAULT TRUE,
-- Join Gate: checks run on every join, each with its own action. -- Join Gate: checks run on every join, each with its own action.
joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE, joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE,
@@ -348,6 +350,9 @@ CREATE TABLE IF NOT EXISTS security_settings (
-- Join lockdown (/lockdown joins): every new member is kicked or banned. -- Join lockdown (/lockdown joins): every new member is kicked or banned.
join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban')) join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban'))
); );
-- Servers set up before alert_dm existed get it here (CREATE TABLE IF NOT EXISTS
-- doesn't change an existing table).
ALTER TABLE security_settings ADD COLUMN IF NOT EXISTS alert_dm BOOLEAN NOT NULL DEFAULT TRUE;
-- Users and roles exempt from anti-nuke, who may also manage its settings. -- Users and roles exempt from anti-nuke, who may also manage its settings.
-- The server owner is always trusted and never listed here. -- The server owner is always trusted and never listed here.
@@ -440,3 +445,37 @@ CREATE TABLE IF NOT EXISTS security_commands (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ processed_at TIMESTAMPTZ
); );
-- Wake the bot when a request is added. The bot also processes everything
-- waiting when it starts, so a request is never lost; this only makes it
-- immediate. Whatever writes to these tables (a dashboard, a script) needs no
-- NOTIFY of its own.
CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('music_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS music_commands_notify ON music_commands;
CREATE TRIGGER music_commands_notify AFTER INSERT ON music_commands
FOR EACH ROW EXECUTE FUNCTION notify_music_command();
CREATE OR REPLACE FUNCTION notify_ticket_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('ticket_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_commands_notify ON ticket_commands;
CREATE TRIGGER ticket_commands_notify AFTER INSERT ON ticket_commands
FOR EACH ROW EXECUTE FUNCTION notify_ticket_command();
CREATE OR REPLACE FUNCTION notify_security_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('security_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS security_commands_notify ON security_commands;
CREATE TRIGGER security_commands_notify AFTER INSERT ON security_commands
FOR EACH ROW EXECUTE FUNCTION notify_security_command();