Compare commits
30
Commits
No files matched your search
@@ -1,3 +1,10 @@
|
|||||||
*.env
|
*.env
|
||||||
.cursor
|
.cursor
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Local /tldr benchmark: chat dumps and results never get committed.
|
||||||
|
bench-out/
|
||||||
|
internal/tldr/tldrbench/private/
|
||||||
|
|
||||||
|
# Real chat dumps used by the benchmark never get committed.
|
||||||
|
internal/tldr/tldrbench/testdata/private/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -1,129 +1,87 @@
|
|||||||
# Velox Bot
|
# Velox Bot
|
||||||
|
|
||||||
A Discord bot written in Go with a PostgreSQL backing store.
|
Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on.
|
||||||
|
|
||||||
It currently includes:
|
**This repository is the bot, and it is the only open source part of Velox.** The web dashboard and its API, which the hosted Velox uses, are separate proprietary software and are not part of this repository or its license. The bot does not need them. Every setting and every action is a slash command (see the [command reference](docs/commands.md)), with PostgreSQL as its only dependency.
|
||||||
|
|
||||||
- Fun commands (`/ping`, `/joke`, `/dice`, `/rps`, …)
|
The dashboard and the bot meet in the database. The dashboard writes settings and requests into tables, and the bot reads them (and is woken through Postgres `NOTIFY` where it has to react at once). Those tables are documented, so you can change the same settings with SQL, or build your own tool on top. See [Architecture](docs/architecture.md) and [Database](docs/database.md).
|
||||||
- Leveling system (opt-in per server via `/slvl toggle`)
|
|
||||||
- Meeting rooms (voice lobby → auto-created temporary voice channels)
|
|
||||||
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
|
|
||||||
- Projects (list/create projects + helpers)
|
|
||||||
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
|
|
||||||
- Twitch live notifications (per-guild `/config` setup)
|
|
||||||
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
|
|
||||||
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
|
|
||||||
- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions)
|
|
||||||
|
|
||||||
When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions.
|
## Documentation
|
||||||
|
|
||||||
## Requirements
|
This README is the front page. The rest of the documentation lives in [`docs/`](docs/), organised like a wiki:
|
||||||
|
|
||||||
- Go (see `go.mod`)
|
| Page | What is in it |
|
||||||
- PostgreSQL 16+ (or use the provided `docker-compose.yml`)
|
| --- | --- |
|
||||||
- (Optional) [Air](https://github.com/air-verse/air) for live reload during development
|
| [Getting started](docs/getting-started.md) | Requirements, creating the Discord application, configuration, database, running locally |
|
||||||
- (Optional) Lavalink if you want music commands
|
| [Host your own bot](docs/self-hosting.md) | A step-by-step guide to running your own copy on a server, with Docker Compose |
|
||||||
|
| [Deployment](docs/deployment.md) | Docker, Compose, upgrade order, backups |
|
||||||
|
| [Architecture](docs/architecture.md) | How the code is laid out, how an interaction flows through it, background loops, how a dashboard talks to the bot |
|
||||||
|
| [Features](docs/features.md) | What each module does and how it behaves |
|
||||||
|
| [Command reference](docs/commands.md) | Every slash command, its options and who may run it |
|
||||||
|
| [Database](docs/database.md) | Who owns the schema, the tables, the `NOTIFY` channels, how to change them |
|
||||||
|
| [Development guide](docs/development.md) | How to add commands, subcommands, buttons, events, background jobs, settings and tables; testing; code style |
|
||||||
|
| [Beta features](docs/beta-features.md) | How features are tried by a few testers before release |
|
||||||
|
| [Troubleshooting](docs/troubleshooting.md) | Common problems and what they mean |
|
||||||
|
|
||||||
## Configuration
|
## Quick start
|
||||||
|
|
||||||
The app loads environment variables from `.env` (see `internal/config/config.go`).
|
You need Go (the version in `go.mod`), PostgreSQL 16 or newer, and a Discord application with a bot token.
|
||||||
|
|
||||||
Required:
|
|
||||||
|
|
||||||
- `BOT_TOKEN`: Discord bot token
|
|
||||||
- `APP_ID`: Discord application ID
|
|
||||||
- `GUILD_ID`: Target guild/server ID (commands are registered per-guild)
|
|
||||||
- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`
|
|
||||||
|
|
||||||
Optional (music):
|
|
||||||
|
|
||||||
- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup)
|
|
||||||
- `LAVALINK_PASSWORD`: Lavalink password
|
|
||||||
|
|
||||||
Optional (Twitch live notifications):
|
|
||||||
|
|
||||||
- `TWITCH_CLIENT_ID`: Twitch Client ID used to query live status
|
|
||||||
|
|
||||||
## Database setup
|
|
||||||
|
|
||||||
If you use the compose file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Then apply the schema:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone <this repository>
|
||||||
|
cd velox-bot
|
||||||
|
# create .env with BOT_TOKEN, APP_ID, GUILD_ID, DB_HOST and TWITCH_CLIENT_ID (see docs/getting-started.md)
|
||||||
|
docker compose up -d db # PostgreSQL on localhost:5432
|
||||||
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
||||||
```
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
Plain Go:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
With live reload (Air):
|
On start the bot registers its slash commands in the server named by `GUILD_ID` (or globally if that is empty). The full walkthrough, including the Discord side, is in [Getting started](docs/getting-started.md).
|
||||||
|
|
||||||
```bash
|
## What it does
|
||||||
air
|
|
||||||
|
- **Security**: Join Gate, raid mode, anti-nuke with quarantine, lockdowns, snapshots and restore, and a spam "heat" system, modelled on Wick.
|
||||||
|
- **Tickets**: a panel with categories, a private thread per ticket, claim and close, transcripts to a log channel.
|
||||||
|
- **Moderation**: purge, timeout, kick, ban, unban, slowmode, plus server logs for edits, deletes, joins, leaves, role changes, bans and mod actions.
|
||||||
|
- **Leveling**: XP per message, level cards, leaderboard, role rewards.
|
||||||
|
- **Music**: Lavalink-powered playback with a queue, controlled from Discord.
|
||||||
|
- **Meeting rooms**: join a lobby voice channel and get a temporary room you can lock and invite people to.
|
||||||
|
- **Scheduling and projects**: 1:1 sessions with DM invites and reminders, and a simple project list.
|
||||||
|
- **Twitch alerts, welcome messages, default role**: per-server setup through `/config`.
|
||||||
|
- **Fun**: `/ping`, `/joke`, `/coinflip`, `/dice`, `/rps` with stats and a leaderboard.
|
||||||
|
|
||||||
|
When the bot joins a server for the first time, toggleable systems start **disabled** and the person who invited it gets a DM with setup instructions.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
main.go wiring: config, database, repos, services, bot
|
||||||
|
internal/config environment variables
|
||||||
|
internal/bot Discord session, intents, event registration, startup
|
||||||
|
internal/commands slash commands, one package per feature, plus the dispatcher
|
||||||
|
internal/events handlers for gateway events and background loops
|
||||||
|
internal/db connection, repos (SQL), services (rules)
|
||||||
|
internal/security anti-raid and anti-nuke engine
|
||||||
|
internal/tickets ticket flows
|
||||||
|
internal/music Lavalink player, queue, buttons
|
||||||
|
internal/beta beta access gate
|
||||||
|
internal/modlog, rankcard small shared helpers
|
||||||
|
schema.sql mirror of the tables the bot reads and writes
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use `just`, a small workflow is included:
|
## Host your own bot
|
||||||
|
|
||||||
```bash
|
You can run your own copy under your own Discord application. [Host your own bot](docs/self-hosting.md) walks through it on a fresh server with Docker Compose: creating the application and the invite link, configuring the bot, loading the database, optional music and Twitch, backups and updates.
|
||||||
just up # start postgres
|
|
||||||
just start # start postgres + run air
|
|
||||||
just down # stop postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands (high-level)
|
## Contributing
|
||||||
|
|
||||||
- **Fun**
|
Contributions are welcome: bug reports, fixes, documentation and new features.
|
||||||
- `/ping`, `/joke [type]`, `/coinflip`, `/dice <expr>`
|
|
||||||
- `/rps <hand>`, `/rpsstats`, `/rpsleaderboard` (server only)
|
|
||||||
- **Help**
|
|
||||||
- `/help` (sends a DM with command reference)
|
|
||||||
- **Leveling**
|
|
||||||
- `/slvl ...` admin/config
|
|
||||||
- `/rank`, `/leaderboard`, `/rewards`
|
|
||||||
- **Meetings** (**Manage Server**)
|
|
||||||
- `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite`
|
|
||||||
- **Timezone**
|
|
||||||
- `/timezone set`, `/timezone show`
|
|
||||||
- **Scheduling**
|
|
||||||
- `/schedule create`, `/schedule list`, `/schedule reschedule`
|
|
||||||
- **Projects**
|
|
||||||
- `/projects list`, `/projects create`, `/projects add-helper`
|
|
||||||
- **Music** (requires **DJ** role)
|
|
||||||
- `/play`, `/queue`, `/volume`
|
|
||||||
- **Moderation** (focused + practical)
|
|
||||||
- `/moderation purge amount:<1-100>` — Fast recent message cleanup (**Manage Messages**)
|
|
||||||
- `/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Temporary member timeout (**Moderate Members**)
|
|
||||||
- `/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)
|
|
||||||
- `/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)
|
|
||||||
- `/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)
|
|
||||||
- `/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)
|
|
||||||
- `/moderation slowmode channel:#channel seconds:<0-21600>` — Control channel flood (**Manage Channels**)
|
|
||||||
- **Twitch live notifications** (**Manage Server**)
|
|
||||||
- `/config addstreamer username:<twitch-name>` — Add a streamer for notifications
|
|
||||||
- `/config removestreamer username:<twitch-name>` — Remove a streamer
|
|
||||||
- `/config settwitchnotificationchannel channel:#channel` — Channel for live pings
|
|
||||||
- **Welcome / onboarding** (**Manage Server**)
|
|
||||||
- `/config setwelcomechannel channel:#channel` — Channel for welcome messages
|
|
||||||
- `/config setwelcomemessage message:"..."` — Server welcome template (supports `{user}`, `{server}`)
|
|
||||||
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
|
|
||||||
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
|
|
||||||
- `/config setdefaultrole role:@role` — Role automatically granted on join
|
|
||||||
- **Logging** (**Manage Server**)
|
|
||||||
- `/config setlogchannel channel:#channel` — Set where logs are sent
|
|
||||||
- `/config enablelogging` — Enable event/moderation logs
|
|
||||||
- `/config disablelogging` — Disable event/moderation logs
|
|
||||||
|
|
||||||
## Notes
|
- Read [CONTRIBUTING.md](CONTRIBUTING.md) first. It covers how to report a problem, how to propose a change, and what a good pull request looks like.
|
||||||
|
- The [Development guide](docs/development.md) shows how to add a command, a button, an event handler, a setting or a table, and how to test them.
|
||||||
|
- For anything big, open an issue to talk about it before you write the code.
|
||||||
|
- Pull requests go to the `dev` branch. Before opening one, run `gofmt` on the files you touched and `go build ./... && go vet ./... && go test -race ./...`.
|
||||||
|
|
||||||
- Slash commands are registered to the configured `GUILD_ID` on startup.
|
## License
|
||||||
- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music).
|
|
||||||
|
Velox Bot, the code in this repository, is released under the [Apache License 2.0](LICENSE). Copyright 2026 Fernando Videira. See [NOTICE](NOTICE). The license does not cover the proprietary Velox dashboard and API, or the Velox name and logo as used by the hosted service. The libraries the bot uses are under their own permissive licenses (see `go.mod`).
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This page explains how the bot is put together, so you know where to look and where to make a change.
|
||||||
|
|
||||||
|
## The big picture
|
||||||
|
|
||||||
|
```
|
||||||
|
Discord gateway (websocket)
|
||||||
|
|
|
||||||
|
discordgo session
|
||||||
|
|
|
||||||
|
+--------------------------+---------------------------+
|
||||||
|
| | |
|
||||||
|
interactions gateway events background loops
|
||||||
|
(slash commands, (messages, joins, bans, (Twitch, reminders,
|
||||||
|
buttons, modals) voice, audit log, ...) raid watch, snapshots)
|
||||||
|
| | |
|
||||||
|
internal/commands internal/events internal/events, security
|
||||||
|
| | |
|
||||||
|
+----------- services (rules) <------ repos (SQL) -----+
|
||||||
|
|
|
||||||
|
PostgreSQL <----> a dashboard (the hosted Velox has a proprietary one)
|
||||||
|
```
|
||||||
|
|
||||||
|
The bot is one Go process. It keeps one websocket connection to Discord and one connection pool to PostgreSQL. It has no HTTP server of its own.
|
||||||
|
|
||||||
|
## Startup
|
||||||
|
|
||||||
|
`main.go` wires everything in this order:
|
||||||
|
|
||||||
|
1. **Config**: `config.LoadConfig()` reads the environment (and `.env`).
|
||||||
|
2. **Database**: `db.NewDB()` opens a `database/sql` pool using the pgx driver.
|
||||||
|
3. **Repos**: one repo per area (`levelrepo`, `settingsrepo`, `ticketrepo`, ...). A repo is the only place that writes SQL.
|
||||||
|
4. **Services**: small structs holding the rules for one area (`level`, `meeting`, `welcome`, ...), built on top of repos, collected in `services.Services`.
|
||||||
|
5. **Singletons for the bigger modules**: tickets and security get their repo with `cmdtickets.Init(...)` and `security.Configure(...)`, and are reached later through `tickets.Default()` and `security.Default()`.
|
||||||
|
6. **Bot**: `bot.NewBot()` creates the Discord session and chooses the gateway intents.
|
||||||
|
7. **`bot.Start()`** opens the connection and then:
|
||||||
|
- starts the music player (if Lavalink is configured) and its command listener,
|
||||||
|
- starts the ticket and security engines and their command listeners (each reconnects every 10 seconds if its database connection drops),
|
||||||
|
- registers every slash command in `GUILD_ID` (or globally),
|
||||||
|
- registers the gateway event handlers,
|
||||||
|
- starts the Twitch and schedule-reminder loops.
|
||||||
|
8. **`bot.WatchBeta()`** reads the beta settings from the database and keeps them fresh every 30 seconds. See [Beta features](beta-features.md).
|
||||||
|
9. The process waits for `SIGINT` or `SIGTERM`.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
The code follows one direction: commands and events call services, services call repos, repos run SQL.
|
||||||
|
|
||||||
|
| Layer | Where | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Commands | `internal/commands/<feature>` | Describe the slash command, check who is allowed, read the options, answer the user. No SQL. |
|
||||||
|
| Events | `internal/events` | React to gateway events and run background loops. |
|
||||||
|
| Engines | `internal/security`, `internal/tickets`, `internal/music` | The bigger modules, with their own state and flows. |
|
||||||
|
| Services | `internal/db/services/<area>` | Rules and orchestration (for example the XP rules in `level`). |
|
||||||
|
| Repos | `internal/db/repos/<area>repo` | SQL, and nothing else. |
|
||||||
|
|
||||||
|
Commands reach services through the package-level `services.Global`, set by `services.NewServices()`. A shared helper in each command package (for example `RequireMeetingService`) answers "service not available" if it is nil. Event handlers receive the services as an argument.
|
||||||
|
|
||||||
|
## How a slash command runs
|
||||||
|
|
||||||
|
1. Discord sends an interaction. discordgo calls `commands.HandleInteraction` (`internal/commands/commands.go`).
|
||||||
|
2. For a slash command, the dispatcher looks the command's name up in the `handlers` map and calls the handler.
|
||||||
|
3. The handler checks the context (server only? permissions?), finds the subcommand in `i.ApplicationCommandData().Options`, reads the options, calls a service, and responds with `s.InteractionRespond`.
|
||||||
|
|
||||||
|
Buttons, select menus and modals do not have a command name. They carry a **custom ID**, and the dispatcher sends them to a feature by its **prefix**: `music:`, `schedule:`, `projects:`, `tickets:` or `security:`. A feature owns everything after its prefix.
|
||||||
|
|
||||||
|
Some commands have a response that takes longer than three seconds (anything that calls Discord several times). Those answer with a deferred response first, then edit it when done. Search the code for `InteractionResponseDeferredChannelMessageWithSource` for examples.
|
||||||
|
|
||||||
|
## How a gateway event runs
|
||||||
|
|
||||||
|
`bot.Start()` registers one handler per event with `Session.AddHandler`. Most of them hand the event to a function in `internal/events`, together with the services. A few go to the security engine first, because it must decide before anything else happens:
|
||||||
|
|
||||||
|
- **Member joins**: the security engine runs first. If it removes the member (join lock, Join Gate, raid mode) there is no welcome message or default role. Otherwise the welcome, default role and join log run.
|
||||||
|
- **Messages**: the security engine's heat check runs, then the leveling handler.
|
||||||
|
- **Audit log entries**: only the security engine (anti-nuke, unauthorised bots).
|
||||||
|
- **Voice state changes**: meeting rooms and the music player.
|
||||||
|
|
||||||
|
## Background loops
|
||||||
|
|
||||||
|
| Loop | Where | Interval | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Twitch live check | `events/twitch_live.go` | 30 s | Checks configured streamers and posts when one goes live |
|
||||||
|
| Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session |
|
||||||
|
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period |
|
||||||
|
| Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) |
|
||||||
|
| Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are and which servers opted in, and adds or removes beta commands to match |
|
||||||
|
| Command listeners | music, tickets, security | on `NOTIFY` | Carry out requests written to the `*_commands` tables |
|
||||||
|
|
||||||
|
## How a dashboard talks to the bot
|
||||||
|
|
||||||
|
The bot has no HTTP server and no API of its own. A web dashboard, such as the proprietary one the hosted Velox uses (not part of this repository), meets the bot in PostgreSQL, in two ways. This is the whole contract, so you can drive the bot the same way with SQL or write your own tool:
|
||||||
|
|
||||||
|
1. **Settings tables.** A dashboard writes settings (log channel, security thresholds, ticket categories, beta role). The bot reads them when it needs them. Hot paths cache them for a short time, so a raid of thousands of joins does not become thousands of queries.
|
||||||
|
2. **Command tables with `NOTIFY`.** When a dashboard needs the bot to *do* something (skip a song, post the ticket panel, release a quarantined member), it inserts a row in `music_commands`, `ticket_commands` or `security_commands` and issues `NOTIFY` on that channel. The bot has a long-lived connection that runs `LISTEN`, picks up the row, validates it again and carries it out. Never trust a row just because it is in the table: the listeners re-check permissions themselves.
|
||||||
|
|
||||||
|
Both directions are described table by table in [Database](database.md).
|
||||||
|
|
||||||
|
## Gateway intents
|
||||||
|
|
||||||
|
Set in `internal/bot/bot.go`:
|
||||||
|
|
||||||
|
| Intent | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| Guilds | slash commands, channel and role events |
|
||||||
|
| Guild Moderation (bans, audit log) | ban logs, anti-nuke |
|
||||||
|
| Guild Members (privileged) | joins, leaves, member updates |
|
||||||
|
| Guild Messages and Message Content (privileged) | leveling, spam heat, message logs |
|
||||||
|
| Guild Voice States | meeting rooms, music |
|
||||||
|
| Direct Messages | direct messages sent to the bot |
|
||||||
|
|
||||||
|
If you add a feature that needs another event, add its intent here too, and enable it in the developer portal if it is privileged.
|
||||||
|
|
||||||
|
## Concurrency and safety notes
|
||||||
|
|
||||||
|
- discordgo calls event handlers on goroutines, so handlers must not share unsynchronised state. The engines use mutexes, and per-server memory is bounded.
|
||||||
|
- Security-sensitive actions do not rely on Discord's command permissions, because server admins can change those. The handlers check the owner and the trusted list themselves (see the [Security](features.md#security) section).
|
||||||
|
- User-controlled text (usernames, reasons) is escaped or kept out of mentions. Messages only ping roles that are configured for it.
|
||||||
|
- Settings that decide who has access (the beta role) fail closed: if they cannot be read, nobody gets access.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| I want to find... | Look in |
|
||||||
|
| --- | --- |
|
||||||
|
| The definition and handler of `/foo` | `internal/commands/foo/public/` (the `registry.go` next to it re-exports it) |
|
||||||
|
| The list of all commands and the dispatcher | `internal/commands/commands.go` |
|
||||||
|
| What happens on a gateway event | `internal/bot/bot.go`, then `internal/events/` |
|
||||||
|
| The SQL behind a feature | `internal/db/repos/<area>repo/repo.go` |
|
||||||
|
| The rules behind a feature | `internal/db/services/<area>/` |
|
||||||
|
| The table definitions | `schema.sql` and [Database](database.md) |
|
||||||
|
| Environment variables | `internal/config/config.go` |
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Beta features
|
||||||
|
|
||||||
|
A beta feature is one that is still being tried out, so only chosen people can use it, and only where the server agreed. Two things must both be true:
|
||||||
|
|
||||||
|
1. **The person is a beta tester**: a member of the beta server who holds the Beta Tester role.
|
||||||
|
2. **Beta features are on in the server they use it in**: the beta server itself, or any other server whose owner opted in.
|
||||||
|
|
||||||
|
The rule is the same on the bot and on the hosted Velox dashboard. The dashboard is proprietary and not part of this repository, so this page is about the bot, and mentions the dashboard only where it explains how the settings get into the database.
|
||||||
|
|
||||||
|
## Who the testers are
|
||||||
|
|
||||||
|
- The **beta server** and the **Beta Tester role** 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).
|
||||||
|
- Until a role is chosen, **nobody** has beta access.
|
||||||
|
- Being a tester is about the person, so it is always checked in the **beta server**, also when they use a feature somewhere else. Having a role with the same ID in another server means nothing.
|
||||||
|
|
||||||
|
## Which servers have beta features
|
||||||
|
|
||||||
|
- The beta server always has them.
|
||||||
|
- Any other server gets them only when 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 with `/beta remove-server` (and see them with `/beta servers`), but cannot put one in.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## What a tester sees
|
||||||
|
|
||||||
|
- **Bot:** beta commands appear only in the beta server and in opted-in servers, and run only for testers. When a server opts in, the commands show up within about 30 seconds. When it opts out, they are removed.
|
||||||
|
- **On the hosted Velox dashboard** (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
|
||||||
|
|
||||||
|
Code: `internal/beta` (the rules), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `syncBetaCommands`), `internal/commands/betacmd` (`/beta`).
|
||||||
|
|
||||||
|
- Beta commands are registered in each server where beta features are on, and only there. `/beta` is only in the beta server.
|
||||||
|
- Each beta handler is wrapped in `beta.Gate`. It checks that beta is on in the server of the interaction and that the person is a tester, and otherwise answers "This feature is in beta and isn't available to you or this server yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
|
||||||
|
- For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no.
|
||||||
|
- If the beta server changes, 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` 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.
|
||||||
|
|
||||||
|
### Add a beta command
|
||||||
|
|
||||||
|
Write the command like any other (see the [Development guide](development.md#add-a-simple-command)), but instead of adding it to `AllCommands` and `handlers`, register it from `commands.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func init() {
|
||||||
|
RegisterBeta(mything.MyThing, mything.MyThingHandler)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RegisterBeta` adds the definition to `BetaCommands` and the handler, wrapped in `beta.Gate`, to the dispatcher. Buttons and forms that belong to a beta command need the same care: call `beta.Guard(s, i)` at the top of their handlers, because a custom ID can be reached from an old message.
|
||||||
|
|
||||||
|
### Add beta behaviour that no one runs
|
||||||
|
|
||||||
|
Event handlers and background jobs have no person to check. Gate them on the server only:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if !beta.ServerEnabled(guildID) {
|
||||||
|
return // beta features are off here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use this for anything that should only be for testers as people. It means "this server agreed to try beta features", and every member of the server is affected.
|
||||||
|
|
||||||
|
### Release it
|
||||||
|
|
||||||
|
Move the definition into `AllCommands` and the handler into the `handlers` map, and delete the `RegisterBeta` call. On the next start the command appears everywhere, and the beta servers drop it from their own lists because it is now a regular command.
|
||||||
|
|
||||||
|
## On the dashboard
|
||||||
|
|
||||||
|
The hosted Velox has a proprietary dashboard and API that apply the same rule to their own pages: a beta page is only shown, and its API routes only answer, for a tester in a server where beta features are on. Their code is not part of this repository. If you build your own tool on the same tables, apply the rule on your server side too: never rely on hiding a page.
|
||||||
|
|
||||||
|
## Why it works this way
|
||||||
|
|
||||||
|
- The role in Discord is the single list of testers, so there is no second list to keep in step with it.
|
||||||
|
- Servers choose to take part, because a beta feature can affect everyone in them.
|
||||||
|
- Access is decided on the server, never in the browser.
|
||||||
|
- Beta commands do not exist outside the beta server, and the gate checks again when they run.
|
||||||
|
- The settings are in the database and changed with commands, not in the environment, so they change without a deploy.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Command reference
|
||||||
|
|
||||||
|
Every slash command the bot registers. Options marked with `*` are required. "Needs" is what the handler checks, not just what Discord shows in its menu. Commands that need a server cannot be used in DMs.
|
||||||
|
|
||||||
|
This page is written by hand from the command definitions in `internal/commands/*/public`. If you change a command, update it here and in `/help` (`internal/commands/help/help.go`). The [Features](features.md) page explains the behaviour behind them.
|
||||||
|
|
||||||
|
- [Fun](#fun)
|
||||||
|
- [Help](#help)
|
||||||
|
- [Leveling](#leveling)
|
||||||
|
- [Music](#music)
|
||||||
|
- [Meeting rooms](#meeting-rooms)
|
||||||
|
- [Moderation](#moderation)
|
||||||
|
- [Timezone and scheduling](#timezone-and-scheduling)
|
||||||
|
- [Projects](#projects)
|
||||||
|
- [Server setup (`/config`)](#server-setup-config)
|
||||||
|
- [Tickets](#tickets)
|
||||||
|
- [Security](#security)
|
||||||
|
- [Beta](#beta)
|
||||||
|
|
||||||
|
## Fun
|
||||||
|
|
||||||
|
Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers only.
|
||||||
|
|
||||||
|
| Command | Options | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/ping` | | Replies "Pong!" |
|
||||||
|
| `/joke` | `type` | A random joke, optionally of a type |
|
||||||
|
| `/coinflip` | | Heads or tails |
|
||||||
|
| `/dice` | `expr`* | Rolls a dice expression: `d20`, `2d20+1`, `2#d20+1` |
|
||||||
|
| `/rps` | `hand`* | Rock paper scissors against the bot |
|
||||||
|
| `/rpsstats` | | Your rock-paper-scissors record |
|
||||||
|
| `/rpsleaderboard` | | The server's leaderboard |
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `/help` | DMs you a command reference |
|
||||||
|
|
||||||
|
## Leveling
|
||||||
|
|
||||||
|
| Command | Options | Needs | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/slvl toggle` | | Manage Server | Turns leveling on or off |
|
||||||
|
| `/slvl set` | `user`*, `level`* | Manage Server, leveling on | Sets a member's level |
|
||||||
|
| `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go |
|
||||||
|
| `/slvl set-message` | `message`* | Manage Server, leveling on | The level-up message. Variables: `{user}`, `{level}` |
|
||||||
|
| `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level |
|
||||||
|
| `/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 |
|
||||||
|
| `/leaderboard` | | Leveling on | Top 10 in the server |
|
||||||
|
| `/rewards` | | Leveling on | The role rewards |
|
||||||
|
|
||||||
|
## Music
|
||||||
|
|
||||||
|
| Command | Options | Needs | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/play` | `query`* | **DJ** role | Plays a song by name or URL |
|
||||||
|
| `/queue` | | **DJ** role | Shows the queue |
|
||||||
|
| `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume |
|
||||||
|
| `/music toggle` | | Manage Server | Turns music on or off in this server |
|
||||||
|
| `/music skip` | | **DJ** role | Skips the current song |
|
||||||
|
| `/music pause`, `/music resume` | | **DJ** role | Pauses or resumes |
|
||||||
|
| `/music stop` | | **DJ** role | Stops and clears the queue |
|
||||||
|
| `/music repeat` | `mode`* (off, song, queue) | **DJ** role | Repeats the song, the queue, or nothing |
|
||||||
|
| `/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).
|
||||||
|
|
||||||
|
## Meeting rooms
|
||||||
|
|
||||||
|
Every subcommand needs **Manage Server** and works in servers only.
|
||||||
|
|
||||||
|
| Command | Options | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/meeting add-lobby` | `channel`* (voice) | Makes a voice channel a lobby (up to 10 per server) |
|
||||||
|
| `/meeting remove-lobby` | `channel`* (voice) | Stops using it as a lobby |
|
||||||
|
| `/meeting lobbies` | | Lists the lobbies |
|
||||||
|
| `/meeting lock` | | Locks the room you are in |
|
||||||
|
| `/meeting lock-private` | | Locks it and hides it |
|
||||||
|
| `/meeting unlock` | | Unlocks it |
|
||||||
|
| `/meeting invite` | `user`* | Lets a user into a locked room |
|
||||||
|
| `/meeting uninvite` | `user`* | Removes that access |
|
||||||
|
|
||||||
|
## Moderation
|
||||||
|
|
||||||
|
Each subcommand checks the Discord permission in its "Needs" column.
|
||||||
|
|
||||||
|
| Command | Options | Needs | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/moderation purge` | `amount`* (1 to 100) | Manage Messages | Deletes recent messages in the channel |
|
||||||
|
| `/moderation timeout` | `user`*, `duration`* (`10m`, `1h`, `2h30m`, up to 28d), `reason` | Moderate Members | Times a member out |
|
||||||
|
| `/moderation untimeout` | `user`*, `reason` | Moderate Members | Removes a timeout |
|
||||||
|
| `/moderation kick` | `user`*, `reason` | Kick Members | Kicks a member |
|
||||||
|
| `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans a member |
|
||||||
|
| `/moderation unban` | `user_id`*, `reason` | Ban Members | Unbans by ID |
|
||||||
|
| `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off |
|
||||||
|
| `/moderation bans` | | Ban Members | Lists banned users, with their IDs |
|
||||||
|
| `/moderation timeouts` | | Moderate Members | Lists members who are timed out now |
|
||||||
|
|
||||||
|
## Timezone and scheduling
|
||||||
|
|
||||||
|
Anyone can use these.
|
||||||
|
|
||||||
|
| Command | Options | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/timezone set` | `zone`* (IANA name, such as `Europe/Lisbon`) | Saves your timezone |
|
||||||
|
| `/timezone show` | | Shows it |
|
||||||
|
| `/schedule create` | | Opens a form (date, time, description) to request a session with a member |
|
||||||
|
| `/schedule list` | | Your upcoming sessions |
|
||||||
|
| `/schedule reschedule` | `id`* (from `/schedule list`), `datetime`* (UTC, `2006-01-02 15:04`) | Proposes a new time |
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
| Command | Needs | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/projects list` | | Active projects with creators and helpers |
|
||||||
|
| `/projects create` | Manage Server | Opens a form to create a project |
|
||||||
|
| `/projects add-helper` | Manage Server | Adds a helper to a project |
|
||||||
|
| `/projects close` | Manage Server | Closes a project |
|
||||||
|
|
||||||
|
## Server setup (`/config`)
|
||||||
|
|
||||||
|
Every subcommand needs **Manage Server**.
|
||||||
|
|
||||||
|
| Command | Options | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/config setwelcomechannel` | `channel`* | Where welcome messages go |
|
||||||
|
| `/config setwelcomemessage` | `message`* | Welcome template. `{user}` is a mention, `{server}` the server name |
|
||||||
|
| `/config setwelcomedm` | `message`* | The welcome DM template |
|
||||||
|
| `/config setwelcomegif` | `url`* | Image or GIF for the welcome embed |
|
||||||
|
| `/config setdefaultrole` | `role`* | Role given on join |
|
||||||
|
| `/config setlogchannel` | `channel`* | Where logs go |
|
||||||
|
| `/config enablelogging` | | Turns logging on |
|
||||||
|
| `/config disablelogging` | | Turns it off |
|
||||||
|
| `/config 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 removestreamer` | `username`* | Removes one |
|
||||||
|
| `/config settwitchnotificationchannel` | `channel`* | Where live alerts go |
|
||||||
|
|
||||||
|
## Tickets
|
||||||
|
|
||||||
|
| Command | Options | Needs | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/tickets setup` | `channel`*, `log-channel`, `staff-role` | Manage Server | Where tickets open and where closed ones are logged. `staff-role` creates a Support category if there are none yet |
|
||||||
|
| `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown |
|
||||||
|
| `/tickets category remove` | `name`* | Manage Server | Removes a category |
|
||||||
|
| `/tickets category list` | | Manage Server | Lists categories |
|
||||||
|
| `/tickets 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 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 remove` | `user`* | Staff | Removes someone |
|
||||||
|
| `/ticket claim` | | Staff | Takes the ticket |
|
||||||
|
| `/ticket close` | `reason` | The opener or staff | Closes it and posts a transcript |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
All of these are hidden from members without Manage Server (and `/lockdown` from those without Manage Channels), but **hiding is not the check**. The handlers check again, and some need the **server owner or someone on the trusted list** whatever Discord permissions a person has.
|
||||||
|
|
||||||
|
In this table "Manage Server" also lets Administrators and trusted members through.
|
||||||
|
|
||||||
|
| Command | Options | Needs | What it does |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/security status` | | Manage Server | What is on, and who is trusted |
|
||||||
|
| `/security toggle` | `feature`* (Join Gate, Raid mode, Anti-nuke, Heat), `on`* | Manage Server to turn on; owner or trusted to turn off | Turns a protection on or off |
|
||||||
|
| `/security alerts` | `channel`*, `role` | Owner or trusted | Alert channel and the role to ping |
|
||||||
|
| `/security quarantine-role` | `role` | Owner or trusted | Role given to quarantined members (empty for none) |
|
||||||
|
| `/security trust add` | `user` or `role` | Owner only | Exempts a user or role from anti-nuke |
|
||||||
|
| `/security trust remove` | `user` or `role` | Owner only | Removes that exemption |
|
||||||
|
| `/security quarantined` | | Manage Server | Lists quarantined members |
|
||||||
|
| `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back |
|
||||||
|
| `/security end-raid` | | Owner or trusted | Ends raid mode now |
|
||||||
|
| `/security 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 restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot |
|
||||||
|
| `/lockdown channel` | `channel` (default: this one) | Manage Channels | Locks a channel |
|
||||||
|
| `/lockdown server` | | Manage Channels | Locks every channel |
|
||||||
|
| `/lockdown joins` | `action`* (Kick or Ban) | Manage Channels | Kicks or bans new members until unlocked |
|
||||||
|
| `/unlock channel` | `channel` | Owner or trusted | Unlocks a channel |
|
||||||
|
| `/unlock server` | | Owner or trusted | Unlocks every channel |
|
||||||
|
| `/unlock joins` | | Owner or trusted | Lets people join again |
|
||||||
|
|
||||||
|
## Beta
|
||||||
|
|
||||||
|
| Command | Needs | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/beta 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 |
|
||||||
|
|
||||||
|
`/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).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Database
|
||||||
|
|
||||||
|
The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL lives in repos under `internal/db/repos`.
|
||||||
|
|
||||||
|
## Who owns the schema
|
||||||
|
|
||||||
|
**`schema.sql`** in this repository is the schema. It describes every table the bot reads and writes, and it is enough to run the bot. It is safe to run more than once (`CREATE TABLE IF NOT EXISTS`). The bot never runs migrations itself.
|
||||||
|
|
||||||
|
The hosted Velox also has a proprietary dashboard API that keeps its own migrations for the same tables. That is the maintainer's concern: if your change adds or alters a table, update `schema.sql` and say so in your pull request, and the maintainer ports it. You never need the API to develop or run the bot.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
| Area | Tables | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Leveling | `levels`, `levelsettings`, `levelrewards`, `levelup` | XP and level per member, settings, role rewards, the level-up channel |
|
||||||
|
| Welcome | `welcome`, `defaultrole` | Welcome channel, templates, GIF, default role |
|
||||||
|
| Logging | `logsettings` | Log channel and on/off |
|
||||||
|
| Twitch | `twitch`, `twitch_config` | Streamers and the notification channel |
|
||||||
|
| Fun | `rps` | Rock-paper-scissors stats |
|
||||||
|
| Meeting rooms | `meeting_lobbies`, `meeting_temp_channels`, `meeting_settings` | Lobbies (10 per server, enforced by a trigger), the temporary rooms and who created them. `meeting_settings.lobby_channel_id` is the old single lobby column and is no longer read |
|
||||||
|
| Projects | `projects`, `project_members` | |
|
||||||
|
| Scheduling | `schedules`, `schedule_messages`, `user_settings` | Sessions, the DM messages to update, and each member's timezone |
|
||||||
|
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State a dashboard can show, plus requests to the bot |
|
||||||
|
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | |
|
||||||
|
| Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests |
|
||||||
|
| Beta | `beta_settings`, `beta_guilds` | `beta_settings` is one row: the beta server and the Beta Tester role. `beta_guilds` lists servers whose owner opted in to beta features. Both are written by the dashboard and read by the bot. `beta_registered` is the bot's own note of where it registered beta commands |
|
||||||
|
|
||||||
|
Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge.
|
||||||
|
|
||||||
|
## Dashboard to bot: settings and requests
|
||||||
|
|
||||||
|
There are two kinds of traffic from a dashboard to the bot, and the difference matters.
|
||||||
|
|
||||||
|
**Settings.** A dashboard writes a row, and the bot reads it when it next needs it. Nothing wakes the bot up. Hot paths cache settings for a short time. The bot's beta settings are polled every 30 seconds.
|
||||||
|
|
||||||
|
**Requests that need the bot to act.** `music_commands`, `ticket_commands` and `security_commands` work as a small queue:
|
||||||
|
|
||||||
|
1. A dashboard inserts a row (`command_type`, a JSON `payload`, `requested_by`, `processed_at` empty).
|
||||||
|
2. A trigger in the database runs `pg_notify('<table>', id)`.
|
||||||
|
3. The bot holds one connection that ran `LISTEN <table>`. It wakes, loads **every** unprocessed row, carries each out, and sets `processed_at`.
|
||||||
|
|
||||||
|
Because the bot loads all unprocessed rows (also when it starts), nothing is lost if it was down when a request arrived. A command that fails is logged and marked processed anyway, since retrying would fail the same way.
|
||||||
|
|
||||||
|
Every listener **validates the request again**. The dashboard should check that the requester is allowed, and the bot checks too, because it should not trust a row just because it exists. The listeners live in `internal/music/listener.go`, `internal/tickets/listener.go` and `internal/security/listener.go`, and each reconnects after 10 seconds if its connection drops.
|
||||||
|
|
||||||
|
## Changing the schema
|
||||||
|
|
||||||
|
1. Update `schema.sql`. Use `CREATE TABLE IF NOT EXISTS`, and 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. Add or change the repo method in `internal/db/repos/<area>repo`.
|
||||||
|
3. If something outside the bot must make it do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener.
|
||||||
|
4. In your pull request, say that the schema changed and whether an existing table is affected, so the maintainer can port the change to the dashboard's migrations.
|
||||||
|
|
||||||
|
## Connection settings
|
||||||
|
|
||||||
|
`internal/db/db.go` opens the pool with up to 100 open and 10 idle connections, and recycles them after an hour. The listeners use their own separate connections, because `LISTEN` needs one that stays open.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
The `Dockerfile` is a two-stage build: it compiles a static binary with Go, then copies it into a small Alpine image (with CA certificates and timezone data, which `/timezone` and scheduling need).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t velox-bot .
|
||||||
|
docker run --env-file .env velox-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
The container reads its configuration from environment variables, exactly like a local run. See [Getting started](getting-started.md#2-configure-the-environment).
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
`docker-compose.yml` defines two services:
|
||||||
|
|
||||||
|
- `db`: PostgreSQL 16 with a named volume (`velox_pgdata`) and a health check.
|
||||||
|
- `bot`: the bot, built from this directory, started after the database is healthy. It takes `.env` through `env_file`, and defaults `DB_HOST` to the `db` service and `LAVALINK_HOST` to `ws://lavalink:2333` if you have not set them.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # database and bot
|
||||||
|
docker compose logs -f bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose does not include Lavalink. Run it separately (or add a service) if you want music.
|
||||||
|
|
||||||
|
Change the default database password (`velox_pwd`) before exposing PostgreSQL to anything other than your own machine, and remove the published `5432` port if only the bot needs it.
|
||||||
|
|
||||||
|
## Database changes
|
||||||
|
|
||||||
|
The bot does not run migrations. When a release adds tables, load `schema.sql` again before restarting the bot (it only creates what is missing, and is safe to repeat). A release that changes an existing table says so in its notes.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose build bot
|
||||||
|
docker compose up -d bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Slash commands are re-registered on every start, so new and changed commands appear after a restart. Commands you removed from the code are not deleted from Discord automatically, except for the beta server (see [Beta features](beta-features.md)).
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
All state is in PostgreSQL. Back it up with `pg_dump`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump "postgres://velox:velox_pwd@localhost:5432/velox" > velox-backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Raid mode, quarantines, lockdowns and ticket state live in the database, so the bot picks them up again after a restart.
|
||||||
|
|
||||||
|
## Shutting down
|
||||||
|
|
||||||
|
Send `SIGINT` or `SIGTERM` (Docker does on `docker stop`). Slash commands stay registered in Discord while the bot is down, and are registered again (updated in place) on the next start.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Development guide
|
||||||
|
|
||||||
|
How to change the bot and how to add to it. Read [Architecture](architecture.md) first if you have not: it explains the layers this page keeps referring to.
|
||||||
|
|
||||||
|
- [Daily workflow](#daily-workflow)
|
||||||
|
- [Add a simple command](#add-a-simple-command)
|
||||||
|
- [Add a subcommand](#add-a-subcommand)
|
||||||
|
- [Command options and permissions](#command-options-and-permissions)
|
||||||
|
- [Buttons, select menus and forms](#buttons-select-menus-and-forms)
|
||||||
|
- [Add a feature that stores settings](#add-a-feature-that-stores-settings)
|
||||||
|
- [React to a gateway event](#react-to-a-gateway-event)
|
||||||
|
- [Add a background job](#add-a-background-job)
|
||||||
|
- [Add an environment variable](#add-an-environment-variable)
|
||||||
|
- [Make a command beta](#make-a-command-beta)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Style and safety rules](#style-and-safety-rules)
|
||||||
|
- [Checklist before a pull request](#checklist-before-a-pull-request)
|
||||||
|
|
||||||
|
## Daily workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d db # PostgreSQL
|
||||||
|
air # rebuild and restart on every change
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `GUILD_ID` to a test server in `.env`, so commands update within seconds. Before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -l .
|
||||||
|
go build ./... && go vet ./... && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Some older files are not gofmt-clean. Format the files you touch, and leave the others alone so reviews stay readable.
|
||||||
|
|
||||||
|
## Add a simple command
|
||||||
|
|
||||||
|
A command is a definition (what Discord shows) plus a handler (what runs). Each feature has a package under `internal/commands/<feature>`, with this shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/commands/fun/
|
||||||
|
registry.go re-exports the commands and handlers of the feature
|
||||||
|
public/ the definitions and handlers
|
||||||
|
ping.go
|
||||||
|
shared/ helpers used by several commands of the feature
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: a `/hello` command in the `fun` feature.
|
||||||
|
|
||||||
|
**1. The definition and handler**, in `internal/commands/fun/public/hello.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package public
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
var Hello = &discordgo.ApplicationCommand{
|
||||||
|
Name: "hello",
|
||||||
|
Description: "Say hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{Content: "Hello!"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Re-export it**, in `internal/commands/fun/registry.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var Hello *discordgo.ApplicationCommand = public.Hello
|
||||||
|
|
||||||
|
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HelloHandler(s, i) }
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Register it**, in `internal/commands/commands.go`: add `fun.Hello` to `AllCommands`, and `"hello": fun.HelloHandler` to the `handlers` map. The key is the command's name. A command that is in `AllCommands` but not in `handlers` is shown in Discord and then does nothing.
|
||||||
|
|
||||||
|
**4. Restart the bot.** It re-registers all commands on start.
|
||||||
|
|
||||||
|
**5. Document it:** add it to `/help` (`internal/commands/help/help.go`) and to the [Command reference](commands.md).
|
||||||
|
|
||||||
|
Names must be lowercase, up to 32 characters, and unique. Descriptions are required and limited to 100 characters.
|
||||||
|
|
||||||
|
For an entirely new feature, create a new package with the same shape, and import it in `commands.go`.
|
||||||
|
|
||||||
|
## Add a subcommand
|
||||||
|
|
||||||
|
A command with subcommands (like `/meeting`) lists them in `Options`, and its handler switches on the name of the first option. Add the subcommand to the definition:
|
||||||
|
|
||||||
|
```go
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "lobbies",
|
||||||
|
Description: "List this server's meeting lobbies",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
and a case to the handler's switch:
|
||||||
|
|
||||||
|
```go
|
||||||
|
switch data.Options[0].Name {
|
||||||
|
case "lobbies":
|
||||||
|
handleListLobbies(s, i)
|
||||||
|
// ...
|
||||||
|
default:
|
||||||
|
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep one `handleX` function per subcommand. For a group (`/tickets category add`), the first option is the group and its first option is the subcommand.
|
||||||
|
|
||||||
|
## Command options and permissions
|
||||||
|
|
||||||
|
**Options** go in the definition's `Options` (or in a subcommand's). Types are `String`, `Integer`, `Boolean`, `User`, `Channel`, `Role`, `Number`, and others. A channel option can be limited with `ChannelTypes`. A string can offer fixed `Choices`.
|
||||||
|
|
||||||
|
Inside a subcommand, `shared.SubcommandOptionMap(i)` (in the meeting, level and other `shared` packages) turns the options into a map by name:
|
||||||
|
|
||||||
|
```go
|
||||||
|
opts := shared.SubcommandOptionMap(i)
|
||||||
|
channel := opts["channel"].ChannelValue(s) // *discordgo.Channel
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where a command may be used.** `Contexts` lists where Discord offers it (`InteractionContextGuild`, `InteractionContextBotDM`, `InteractionContextPrivateChannel`). A server-only command should also refuse politely if `i.GuildID == ""`: the shared packages have `RequireGuild` for this.
|
||||||
|
|
||||||
|
**Who may use it.** There are two layers, and you need both:
|
||||||
|
|
||||||
|
1. `DefaultMemberPermissions` on the definition only decides who **sees** the command in Discord's menu. Server admins can change it in their settings, so it is not security.
|
||||||
|
2. The handler must check again, using `i.Member.Permissions`. The shared packages have helpers such as `RequireManageGuild`. Moderation uses `hasPerm(i, discordgo.PermissionKickMembers)` and so on.
|
||||||
|
|
||||||
|
For anything where a mistake hurts (security settings, anything destructive), check the **owner or trusted list** as well, as `internal/commands/security` does with `allowedOrTrusted`. Do not rely on Discord's permissions alone.
|
||||||
|
|
||||||
|
**Responses.**
|
||||||
|
|
||||||
|
- Answer within three seconds. If a command takes longer, respond with `InteractionResponseDeferredChannelMessageWithSource` first, then edit the response with `InteractionResponseEdit`.
|
||||||
|
- Use `Flags: discordgo.MessageFlagsEphemeral` for replies only the user should see (errors, settings). `shared.RespondEphemeral` does this.
|
||||||
|
- When the message contains user-controlled text, set `AllowedMentions: &discordgo.MessageAllowedMentions{}` so it cannot ping anyone.
|
||||||
|
|
||||||
|
## Buttons, select menus and forms
|
||||||
|
|
||||||
|
Components and modals carry a **custom ID**, and the dispatcher routes them by prefix (`internal/commands/commands.go`, `HandleInteraction`). To add one to a feature:
|
||||||
|
|
||||||
|
1. Use the feature's prefix when you build the component: `CustomID: "projects:close:" + id`. Put whatever you need in the ID after the prefix, split with `:`.
|
||||||
|
2. The dispatcher already sends `music:`, `schedule:`, `projects:`, `tickets:` and `security:` to that feature's `HandleComponent`, and `schedule:`, `projects:`, `tickets:` modals to its `HandleModalSubmit`.
|
||||||
|
3. In `HandleComponent`, parse the rest of the ID, check permissions **again** (the button may be clicked by anyone who can see the message), and respond.
|
||||||
|
|
||||||
|
For a **new prefix**, add a case to the `switch` in `HandleInteraction`, and write `HandleComponent` and/or `HandleModalSubmit` in your feature.
|
||||||
|
|
||||||
|
Custom IDs are limited to 100 characters, and a modal can have up to five inputs.
|
||||||
|
|
||||||
|
Never trust the contents of a custom ID. A user cannot edit it, but old messages stay around with old IDs, and IDs may refer to things that no longer exist.
|
||||||
|
|
||||||
|
## Add a feature that stores settings
|
||||||
|
|
||||||
|
The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (`/meeting add-lobby`).
|
||||||
|
|
||||||
|
**1. The table.** Add it to `schema.sql` (see [Database](database.md#changing-the-schema)).
|
||||||
|
|
||||||
|
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at`, guildID)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always pass values as parameters (`$1`), never build SQL with string formatting.
|
||||||
|
|
||||||
|
**3. The service**, in `internal/db/services/<area>/service.go`. It wraps the repo and holds the rules (limits, defaults, validation):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Service struct{ settings *settingsrepo.Repo }
|
||||||
|
|
||||||
|
func New(settings *settingsrepo.Repo) *Service { return &Service{settings: settings} }
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Wire it up.** Add a field to `services.Services` and the constructor in `internal/db/services/services.go`, and build the repo and service in `main.go` next to the others.
|
||||||
|
|
||||||
|
**5. Use it from the command**, through `services.Global`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if services.Global == nil || services.Global.Meeting == nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Meeting service is not available.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID)
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and anything else writing to the table cannot disagree and two simultaneous requests cannot both slip through.
|
||||||
|
|
||||||
|
**7. 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
|
||||||
|
|
||||||
|
1. Write the function in `internal/events/`, taking the session, the event and the services:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register it in `bot.Start()` (`internal/bot/bot.go`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
|
||||||
|
events.HandleChannelDelete(s, c, b.Services)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check that the **intent** for that event is enabled in `NewBot`. Events from an intent you did not ask for never arrive. Privileged intents must also be enabled in the developer portal.
|
||||||
|
|
||||||
|
Handlers run concurrently on goroutines. Do not share unsynchronised state, and do not block: slow work belongs in its own goroutine.
|
||||||
|
|
||||||
|
If the event is something security cares about (joins, messages, audit log), look at how `bot.go` calls the security engine **before** your handler.
|
||||||
|
|
||||||
|
## Add a background job
|
||||||
|
|
||||||
|
Follow `StartScheduleReminderLoop` in `internal/events/schedule_reminders.go`: a function that returns immediately and starts a goroutine with a ticker.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func StartMyLoop(s *discordgo.Session, svc *services.Services) {
|
||||||
|
if svc == nil || svc.Thing == nil || s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Second) // let the bot finish starting
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
// check, act, log errors, never panic
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call it at the end of `bot.Start()`. Log errors instead of returning them, and make one failing server not stop the loop for the others.
|
||||||
|
|
||||||
|
## Add an environment variable
|
||||||
|
|
||||||
|
1. Add a field to `Config` and read it in `LoadConfig()` (`internal/config/config.go`). Return an error if the bot cannot work without it.
|
||||||
|
2. Document it in [Getting started](getting-started.md#2-configure-the-environment).
|
||||||
|
3. Pass it where it is needed from `main.go`.
|
||||||
|
|
||||||
|
Never log a secret, and never put one in a message to a user.
|
||||||
|
|
||||||
|
Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database instead (set with a command, or by a dashboard). That is how the beta settings work.
|
||||||
|
|
||||||
|
## Make a command beta
|
||||||
|
|
||||||
|
Register it with `commands.RegisterBeta` instead of adding it to `AllCommands` and `handlers`. See [Beta features](beta-features.md).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... # unit tests, no database needed
|
||||||
|
go test -race ./... # recommended before a pull request
|
||||||
|
```
|
||||||
|
|
||||||
|
Where the code allows it, the Discord and database calls go through small interfaces, so flows can be tested with fakes. `internal/tickets` and `internal/security` are the models: look at their `fakes_test.go`.
|
||||||
|
|
||||||
|
Tests that need a real PostgreSQL are behind the `integration` build tag and skip themselves when their environment variable is missing:
|
||||||
|
|
||||||
|
| Package | Variable |
|
||||||
|
| --- | --- |
|
||||||
|
| `internal/db/repos/settingsrepo` | `TEST_DB` |
|
||||||
|
| `internal/db/repos/ticketrepo`, `internal/tickets` | `TICKETS_TEST_DB` |
|
||||||
|
| `internal/db/repos/securityrepo` | `SECURITY_TEST_DB` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEST_DB="postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" \
|
||||||
|
go test -tags integration ./internal/db/repos/settingsrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a throwaway database for these, not one with real data. The tables must already exist (load `schema.sql`).
|
||||||
|
|
||||||
|
Good things to test: permission checks (does the wrong person get refused?), limits, anything that parses user input, and "what if the service or the database fails".
|
||||||
|
|
||||||
|
## Style and safety rules
|
||||||
|
|
||||||
|
- **One feature per package, SQL only in repos, rules in services.** Commands never contain SQL.
|
||||||
|
- **Match the code around you**: naming, comment density, error handling. Comments explain *why*, not *what*.
|
||||||
|
- **Fail closed.** If you cannot tell whether someone is allowed, they are not.
|
||||||
|
- **Validate everything that comes from a user or from another program writing to the database**: option values, custom IDs, `payload` JSON in command rows.
|
||||||
|
- **Escape user text** in messages, and set `AllowedMentions` so it cannot ping.
|
||||||
|
- **Bound everything per server**: caches, lists, loops. One server must not be able to use unlimited memory.
|
||||||
|
- **Do not leak internals** to users: reply with a short message, log the detail.
|
||||||
|
- **Idempotent writes**: use `ON CONFLICT` for "add" operations, so double clicks and retries are harmless.
|
||||||
|
- Handle the bot being **restarted at any time**. State that must survive (raid mode, quarantines) lives in the database.
|
||||||
|
|
||||||
|
## Checklist before a pull request
|
||||||
|
|
||||||
|
- [ ] `gofmt` on the files you touched, `go build ./...`, `go vet ./...`, `go test -race ./...`
|
||||||
|
- [ ] Commands: in `AllCommands` and `handlers`, permission check in the handler, ephemeral errors
|
||||||
|
- [ ] New table or column: in `schema.sql`, and mentioned in the pull request
|
||||||
|
- [ ] Module that acts on a server: off by default
|
||||||
|
- [ ] `/help`, [Command reference](commands.md) and [Features](features.md) updated
|
||||||
|
- [ ] No secrets, tokens or personal IDs in code, logs or tests
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
What each module does and how it behaves. For the exact options of every command, see the [Command reference](commands.md). To change how a module works, see the [Development guide](development.md).
|
||||||
|
|
||||||
|
Unless a section says otherwise, a module is **off** in a new server and a server admin turns it on.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Anti-raid and anti-nuke, modelled on Wick. Code: `internal/security`, commands in `internal/commands/security`.
|
||||||
|
|
||||||
|
**Join Gate** checks every new member: no avatar, account too new, invite links or blocked words in the name, unverified bots. Each check has its own action. It also removes bots added by anyone who is not trusted.
|
||||||
|
|
||||||
|
**Raid mode** starts when many members join in a short time. Raiders are timed out, kicked or banned, invites can be paused, staff are alerted, and raid mode ends after a quiet period or when staff end it with `/security end-raid`. It survives a bot restart: the quiet timer carries on and the summary still lists everyone caught.
|
||||||
|
|
||||||
|
**Anti-nuke** counts destructive actions per person from the audit log: deleting channels or roles, mass bans and kicks, creating webhooks, granting dangerous permissions. Someone who is not the owner or trusted and goes over a limit is **quarantined**: their roles are removed and saved, and they are timed out. `/security release` gives every role back.
|
||||||
|
|
||||||
|
**Lockdown** (`/lockdown`, `/unlock`) locks one channel, every channel, or joins. Unlocking puts back exactly the permission overwrite that was there before.
|
||||||
|
|
||||||
|
**Snapshots** save the server's roles and channels. They are taken every 6 hours while anti-nuke is on (the last three are kept) and on request with `/security snapshot`. `/security restore` recreates deleted roles and channels and reverts dangerous permission changes. It can also run automatically after a stopped nuke.
|
||||||
|
|
||||||
|
**Heat** is the anti-spam system. Each member has a score that rises with messages and cools over time. Spam, repeats, mass mentions and invite links max it, which deletes the message and times the member out (the timeout doubles for repeat offences). A wave of spammers turns on *heat panic* (newcomers are timed out on sight for 10 minutes), and a mention flood can lock the server. Members with Manage Messages and trusted members are exempt.
|
||||||
|
|
||||||
|
**Alerts** go to a channel you choose, and critical ones (raids, stopped nukes, restores, spam waves, removed bots) are also DMed to the owner and trusted users, because the alert channel may be the first thing a nuker deletes. One setting turns the DMs off.
|
||||||
|
|
||||||
|
Rules that hold everywhere in this module:
|
||||||
|
|
||||||
|
- Turning protection off, changing limits, releasing a quarantine and unlocking need the **server owner or someone on the trusted list**. Editing the trusted list needs the owner. Discord's own command permissions are never relied on, because admins can change them.
|
||||||
|
- The owner and the bot are never punished.
|
||||||
|
- Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned.
|
||||||
|
- Messages ping only the configured alert role, and names are escaped.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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`. `/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.
|
||||||
|
3. A **private thread** is created in the ticket channel. The opener and the category's staff role can see it.
|
||||||
|
4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`.
|
||||||
|
5. Closing locks and archives the thread and posts a **transcript** to the log channel.
|
||||||
|
|
||||||
|
The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
|
||||||
|
|
||||||
|
The flows go through small interfaces for Discord and the database, so they are tested without either. The slash commands and buttons only translate interactions into those calls.
|
||||||
|
|
||||||
|
## Moderation
|
||||||
|
|
||||||
|
`/moderation` with `purge`, `timeout`, `kick`, `ban`, `unban`, `untimeout` and `slowmode`. Each subcommand checks the matching Discord permission in the handler (Manage Messages, Moderate Members, Kick Members, Ban Members, Manage Channels). Durations accept `10m`, `1h`, `2h30m` and so on, up to 28 days. Actions are written to the log channel if logging is on.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
When enabled with `/config enablelogging` and a channel set with `/config setlogchannel`, the bot logs edited and deleted messages, joins and leaves, member and role updates, bans and unbans, and moderation actions. Code: `internal/events/logging.go` and `internal/modlog`.
|
||||||
|
|
||||||
|
## Leveling
|
||||||
|
|
||||||
|
Opt in per server with `/slvl toggle`. Code: `internal/db/services/level` (the rules) and `internal/events/event_handlers.go` (the message handler).
|
||||||
|
|
||||||
|
- Every message may give 1 to 3 XP. Below level 5 every message counts. From level 5 on, a message counts with a chance of 1 in `level / 4`, so leveling slows down.
|
||||||
|
- 100 XP makes a level, and XP resets to 0.
|
||||||
|
- A level-up posts the level-up message (set with `/slvl set-message`, variables `{user}` and `{level}`) in the channel chosen with `/slvl set-channel`, or in the channel of the message.
|
||||||
|
- `/slvl set-reward` gives a role at a level. `/rank` draws a rank card (`internal/rankcard`), `/leaderboard` shows the top 10 and `/rewards` lists role rewards.
|
||||||
|
|
||||||
|
## Music
|
||||||
|
|
||||||
|
Lavalink-powered playback. Code: `internal/music`, commands in `internal/commands/music`.
|
||||||
|
|
||||||
|
- `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**.
|
||||||
|
- The now-playing message has buttons for playback and repeat. Buttons also check for the DJ role.
|
||||||
|
- `/music toggle` (Manage Server) turns music on or off in a server. `/music skip`, `pause`, `resume`, `stop`, `repeat`, `remove` and `move` control playback and the queue (DJ role).
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Meeting rooms
|
||||||
|
|
||||||
|
Join a **lobby** voice channel and get your own temporary room. Code: `internal/events/meeting_voice.go`, commands in `internal/commands/meeting`.
|
||||||
|
|
||||||
|
- A server can have up to **10 lobbies** (`/meeting add-lobby`, `remove-lobby`, `lobbies`). The limit is enforced by a database trigger, so the bot and the dashboard obey the same rule.
|
||||||
|
- When someone joins a lobby, the bot creates a voice channel in the same category with the lobby's user limit and bitrate, named after the lobby and the time, and moves the member in.
|
||||||
|
- When the temporary room is empty, it is deleted.
|
||||||
|
- If a lobby channel is deleted in Discord, it is dropped from the lobbies.
|
||||||
|
- `/meeting lock`, `lock-private` (also hides the room), `unlock`, `invite` and `uninvite` act on the room you are in. Every `/meeting` subcommand, including these, currently needs **Manage Server**.
|
||||||
|
|
||||||
|
## Scheduling and timezones
|
||||||
|
|
||||||
|
1:1 sessions between two members. Code: `internal/commands/schedule`, `internal/db/services/schedule`, reminders in `internal/events/schedule_reminders.go`.
|
||||||
|
|
||||||
|
- Each member sets a timezone with `/timezone set` (an IANA name such as `Europe/Lisbon`).
|
||||||
|
- `/schedule create` opens a form (date, time in your timezone, optional description) and DMs the other member a request with **Accept**, **Decline** and **Propose different time** buttons.
|
||||||
|
- `/schedule list` shows your upcoming sessions, and `/schedule reschedule` proposes a new time (in UTC, `2006-01-02 15:04`). The other side can accept or decline the proposal.
|
||||||
|
- Every minute, a loop DMs the participants of sessions starting within 10 minutes.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
A simple list of ongoing projects. `/projects create` opens a form (name, optional description), `/projects list` shows active projects with creators and helpers, `/projects add-helper` and `/projects close` manage them. Creating, adding helpers and closing need Manage Server.
|
||||||
|
|
||||||
|
## Welcome messages and default role
|
||||||
|
|
||||||
|
Set up with `/config` (Manage Server). Code: `internal/events/welcome.go`.
|
||||||
|
|
||||||
|
- A welcome message in a channel, and an optional DM, both templates where `{user}` becomes a mention and `{server}` the server name. An optional GIF or image URL goes in the welcome embed.
|
||||||
|
- A default role is granted when a member joins.
|
||||||
|
- Bots are skipped, and members the security engine removes get nothing.
|
||||||
|
|
||||||
|
## Twitch alerts
|
||||||
|
|
||||||
|
`/config addstreamer`, `removestreamer` and `settwitchnotificationchannel` (Manage Server). Every 30 seconds a loop checks each configured streamer and posts in the notification channel when one goes live. Code: `internal/events/twitch_live.go`. It needs `TWITCH_CLIENT_ID`.
|
||||||
|
|
||||||
|
## Onboarding
|
||||||
|
|
||||||
|
When the bot is added to a server, it applies safe defaults (modules off) and DMs setup instructions to the person who invited it, falling back to the server owner. Code: `internal/events/onboarding.go`.
|
||||||
|
|
||||||
|
## Fun
|
||||||
|
|
||||||
|
`/ping`, `/joke [type]`, `/coinflip`, `/dice` (expressions like `d20`, `2d20+1`, `2#d20+1`, parsed in `internal/diceexpr`), and `/rps` with `/rpsstats` and `/rpsleaderboard`.
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
`/help` DMs a command reference. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Getting started
|
||||||
|
|
||||||
|
This page takes you from nothing to a bot running on your machine.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Go**: the version in `go.mod` (the Dockerfile builds with the same one).
|
||||||
|
- **PostgreSQL 16 or newer**: the included `docker-compose.yml` starts one for you.
|
||||||
|
- **A Discord application** with a bot user (see below).
|
||||||
|
- Optional: [Air](https://github.com/air-verse/air) for live reload, [Just](https://github.com/casey/just) for shortcuts, and a [Lavalink](https://github.com/lavalink-devs/Lavalink) server if you want music.
|
||||||
|
|
||||||
|
## 1. Create the Discord application
|
||||||
|
|
||||||
|
1. Open the [Discord developer portal](https://discord.com/developers/applications) and create an application. Copy its **Application ID**: this is `APP_ID`.
|
||||||
|
2. Under **Bot**, create the bot user and copy its token: this is `BOT_TOKEN`. Keep it secret.
|
||||||
|
3. Under **Bot**, switch on these **privileged gateway intents**: **Server Members Intent** (welcome messages, security) and **Message Content Intent** (leveling and spam detection). The bot also uses the non-privileged Guilds, Guild Moderation (bans and the audit log), Guild Messages, Guild Voice States and Direct Messages intents. You do not need to enable those by hand.
|
||||||
|
4. Invite the bot with the `bot` and `applications.commands` scopes. It needs these permissions, depending on which modules you use:
|
||||||
|
|
||||||
|
| Permission | Used by |
|
||||||
|
| --- | --- |
|
||||||
|
| View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts |
|
||||||
|
| Read Message History, Manage Messages | moderation purge, spam heat |
|
||||||
|
| Use External Emojis | embeds that use custom emojis |
|
||||||
|
| Manage Roles | default role, level rewards, quarantine |
|
||||||
|
| Manage Channels | lockdown, slowmode, meeting rooms, snapshot restore |
|
||||||
|
| Manage Server | pausing invites during a raid |
|
||||||
|
| Kick Members, Ban Members, Moderate Members | moderation commands, Join Gate, raid mode, anti-nuke |
|
||||||
|
| View Audit Log | anti-nuke, unauthorised bot detection |
|
||||||
|
| Create Private Threads, Send Messages in Threads, Manage Threads | tickets |
|
||||||
|
| Connect, Speak, Move Members | music, meeting rooms |
|
||||||
|
|
||||||
|
The bot's role must sit **above** the roles it manages or protects against. Discord does not let a bot change members whose highest role is higher than its own.
|
||||||
|
|
||||||
|
## 2. Configure the environment
|
||||||
|
|
||||||
|
The bot reads environment variables, and loads a `.env` file from the working directory if there is one (`internal/config/config.go`).
|
||||||
|
|
||||||
|
| Variable | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BOT_TOKEN` | yes | The bot token. |
|
||||||
|
| `APP_ID` | yes | The application ID. |
|
||||||
|
| `GUILD_ID` | no | If set, slash commands are registered in this server only, and appear within seconds. If empty they are registered globally, which Discord can take up to an hour to show. Use a test server while developing. |
|
||||||
|
| `DB_HOST` | yes | A pgx connection string, for example `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`. |
|
||||||
|
| `TWITCH_CLIENT_ID` | yes | A Twitch client ID, used to check who is live. The bot refuses to start without it, even if you never use Twitch alerts. Any non-empty value works if you do not use them. |
|
||||||
|
| `LAVALINK_HOST` | no | Lavalink address, for example `ws://localhost:2333`. Leave empty to run without music. |
|
||||||
|
| `LAVALINK_PASSWORD` | no | Lavalink password. |
|
||||||
|
|
||||||
|
A minimal `.env` for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BOT_TOKEN=your-bot-token
|
||||||
|
APP_ID=123456789012345678
|
||||||
|
GUILD_ID=123456789012345678
|
||||||
|
DB_HOST=postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable
|
||||||
|
TWITCH_CLIENT_ID=unused
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` is ignored by git. Never commit it.
|
||||||
|
|
||||||
|
Who the beta testers are is **not** an environment variable. It is stored in the database. See [Beta features](beta-features.md).
|
||||||
|
|
||||||
|
## 3. Set up the database
|
||||||
|
|
||||||
|
Start PostgreSQL with Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load the schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
`schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is the schema of every table the bot uses. Read [Database](database.md) before changing a table.
|
||||||
|
|
||||||
|
## 4. Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the bot connect, and the slash commands appear in your test server. Type `/ping`.
|
||||||
|
|
||||||
|
With live reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
air
|
||||||
|
```
|
||||||
|
|
||||||
|
With Just (`Justfile`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just up # start PostgreSQL
|
||||||
|
just start # start PostgreSQL and run Air
|
||||||
|
just down # stop PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Turn modules on
|
||||||
|
|
||||||
|
Nothing is on by default. In your server:
|
||||||
|
|
||||||
|
- `/security toggle` to enable Join Gate, raid mode, anti-nuke or heat.
|
||||||
|
- `/tickets setup`, then `/tickets category add` and `/tickets panel`.
|
||||||
|
- `/slvl toggle` for leveling.
|
||||||
|
- `/config setlogchannel` and `/config enablelogging` for logs.
|
||||||
|
- `/music toggle` for music (needs Lavalink).
|
||||||
|
- `/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.
|
||||||
|
|
||||||
|
## Optional: music with Lavalink
|
||||||
|
|
||||||
|
Music needs a running Lavalink server and `LAVALINK_HOST` / `LAVALINK_PASSWORD`. Without them the bot starts normally and music simply does not work. Members need a role named exactly **DJ** to use `/play`, `/queue` and `/volume`.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- Running it for real: [Deployment](deployment.md).
|
||||||
|
- Changing or adding things: [Development guide](development.md).
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## The bot does not start
|
||||||
|
|
||||||
|
| Message | Cause |
|
||||||
|
| --- | --- |
|
||||||
|
| `BOT_TOKEN is not set`, `APP_ID is not set`, `DB_HOST is not set` | A required variable is missing. See [Getting started](getting-started.md#2-configure-the-environment). |
|
||||||
|
| `TWITCH_CLIENT_ID is not set` | It is required even if you do not use Twitch alerts. Set any non-empty value. |
|
||||||
|
| `Error creating database` | `DB_HOST` is wrong, or PostgreSQL is not running or not reachable. Try the connection string with `psql`. |
|
||||||
|
| `Error starting bot` | The token is wrong, or a privileged intent is not enabled in the developer portal. |
|
||||||
|
| `Disallowed intents` | Enable **Server Members Intent** and **Message Content Intent** in the developer portal, under Bot. |
|
||||||
|
|
||||||
|
## Slash commands do not appear
|
||||||
|
|
||||||
|
- With `GUILD_ID` set, commands appear in that server within seconds. If it is empty they are global and can take up to an hour.
|
||||||
|
- The bot must be invited with the `applications.commands` scope.
|
||||||
|
- If a command is defined but not in the `handlers` map in `internal/commands/commands.go`, it shows up and does nothing.
|
||||||
|
- If the bot logs `Cannot create '<name>' command`, read the error. A name or description that is too long, or an invalid option, makes Discord reject that one command.
|
||||||
|
- Commands that you removed from the code stay in Discord until you delete them (in the developer portal or through the API).
|
||||||
|
|
||||||
|
## A command says "You don't have permission"
|
||||||
|
|
||||||
|
Many commands need **Manage Server** (see the [Command reference](commands.md)), and the music commands need a role called exactly **DJ**. Security actions such as turning protection off need the **server owner or a trusted user**, whatever permissions you have.
|
||||||
|
|
||||||
|
## The bot cannot do something in a server
|
||||||
|
|
||||||
|
Most failures are about the bot's role:
|
||||||
|
|
||||||
|
- The bot's role must be **above** the roles it manages and the members it moderates or quarantines.
|
||||||
|
- It needs the permissions listed in [Getting started](getting-started.md#1-create-the-discord-application). Check the channel's own permission overrides too: a channel can deny what the server role allows.
|
||||||
|
- Security messages say "Check that Velox's role is high enough and has the permissions it needs" for this reason.
|
||||||
|
|
||||||
|
## Music does not play
|
||||||
|
|
||||||
|
- `LAVALINK_HOST` and `LAVALINK_PASSWORD` must match your Lavalink server, and Lavalink must be running and reachable from the bot (in Compose, use the service name, not `localhost`).
|
||||||
|
- You need the **DJ** role.
|
||||||
|
- A server admin can turn music off with `/music toggle`.
|
||||||
|
|
||||||
|
## Level-ups, logs or welcome messages do nothing
|
||||||
|
|
||||||
|
They are off until enabled: `/slvl toggle`, `/config enablelogging` with `/config setlogchannel`, and `/config setwelcomechannel`. Leveling and spam detection read message text, so the **Message Content Intent** must be on. Welcome messages and security need the **Server Members Intent**.
|
||||||
|
|
||||||
|
## A dashboard or tool's requests have no effect
|
||||||
|
|
||||||
|
A dashboard (the hosted Velox has a proprietary one) or any tool of yours talks to the bot through the database (see [Database](database.md#dashboard-to-bot-settings-and-requests)).
|
||||||
|
|
||||||
|
- Check that the tool and the bot use the **same database**.
|
||||||
|
- For a request such as skipping a song or ending a raid, look in the matching `*_commands` table. A row with an empty `processed_at` is waiting. If many are waiting, the bot's listener is down: look for `command listener stopped` in its log. It reconnects every 10 seconds.
|
||||||
|
- Settings are read when needed and may be cached briefly (the beta settings are polled every 30 seconds).
|
||||||
|
|
||||||
|
## Beta features are not available to a tester
|
||||||
|
|
||||||
|
Go through [Beta features](beta-features.md): 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
|
||||||
|
|
||||||
|
The bot logs to standard output. Run `docker compose logs -f bot`, or just `go run .`, and reproduce the problem. Errors from Discord calls are logged with the server and channel IDs involved.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// Package beta gates features that are still being tried out.
|
||||||
|
//
|
||||||
|
// A beta feature works for a beta tester, in a server where beta features are
|
||||||
|
// on:
|
||||||
|
//
|
||||||
|
// - A beta tester is a member of the beta server who has the Beta Tester
|
||||||
|
// role. Both are chosen by the bot's owner on the dashboard.
|
||||||
|
// - Beta features are on in the beta server itself, and in every other
|
||||||
|
// server whose owner opted in on the dashboard. A feature that can act on
|
||||||
|
// a whole server never reaches a server that didn't ask for it.
|
||||||
|
//
|
||||||
|
// Being a tester is about the person, so it is looked up in the beta server
|
||||||
|
// even when they use a feature somewhere else (see SetRolesLookup). Beta
|
||||||
|
// never replaces a feature's own permission checks: a beta command that needs
|
||||||
|
// Manage Server still needs it.
|
||||||
|
//
|
||||||
|
// Features that act without anyone running a command (an event handler, a
|
||||||
|
// background job) have no person to check, so they use ServerEnabled only.
|
||||||
|
//
|
||||||
|
// Everything fails closed. With no role configured, an ID that isn't a
|
||||||
|
// Discord ID, or a lookup that fails, nobody gets access.
|
||||||
|
package beta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a slash command handler.
|
||||||
|
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
|
||||||
|
|
||||||
|
type settings struct {
|
||||||
|
guildID, roleID string
|
||||||
|
servers map[string]bool // opted-in servers, besides the beta server
|
||||||
|
}
|
||||||
|
|
||||||
|
var current atomic.Pointer[settings]
|
||||||
|
|
||||||
|
var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
|
||||||
|
|
||||||
|
// idOrEmpty keeps a Discord ID and drops anything else, so a bad value turns
|
||||||
|
// beta features off instead of matching something odd.
|
||||||
|
func idOrEmpty(name, v string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !snowflake.MatchString(v) {
|
||||||
|
log.Printf("beta: %s %q isn't a Discord ID; ignoring it", name, v)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure sets the beta server, the Beta Tester role and the servers that
|
||||||
|
// opted in. It replaces everything set before, so call it with the full
|
||||||
|
// picture each time.
|
||||||
|
func Configure(guildID, roleID string, servers []string) {
|
||||||
|
set := make(map[string]bool, len(servers))
|
||||||
|
for _, id := range servers {
|
||||||
|
if id = idOrEmpty("beta server", id); id != "" {
|
||||||
|
set[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.Store(&settings{
|
||||||
|
guildID: idOrEmpty("beta server", guildID),
|
||||||
|
roleID: idOrEmpty("beta role", roleID),
|
||||||
|
servers: set,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() settings {
|
||||||
|
if s := current.Load(); s != nil {
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
return settings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolesFunc returns a member's role IDs in a server, as Discord has them now,
|
||||||
|
// or an error (including "not in that server").
|
||||||
|
type RolesFunc func(guildID, userID string) ([]string, error)
|
||||||
|
|
||||||
|
var lookup atomic.Pointer[RolesFunc]
|
||||||
|
|
||||||
|
// SetRolesLookup tells the package how to find someone's roles in the beta
|
||||||
|
// server when they use a feature in another server. Without it, testers only
|
||||||
|
// qualify in the beta server itself.
|
||||||
|
func SetRolesLookup(f RolesFunc) { lookup.Store(&f) }
|
||||||
|
|
||||||
|
// GuildID is the beta server, or "" when it isn't set.
|
||||||
|
func GuildID() string { return get().guildID }
|
||||||
|
|
||||||
|
// Servers lists the servers that opted in, besides the beta server, in a
|
||||||
|
// stable order.
|
||||||
|
func Servers() []string {
|
||||||
|
c := get()
|
||||||
|
out := make([]string, 0, len(c.servers))
|
||||||
|
for id := range c.servers {
|
||||||
|
if id != c.guildID {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerEnabled reports whether beta features are on in a server: the beta
|
||||||
|
// server, or one whose owner opted in. Use it alone for anything no person
|
||||||
|
// triggers.
|
||||||
|
func ServerEnabled(guildID string) bool {
|
||||||
|
c := get()
|
||||||
|
return guildID != "" && (guildID == c.guildID || c.servers[guildID])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status describes where a member stands, for the /beta command.
|
||||||
|
type Status struct {
|
||||||
|
ServerEnabled bool // beta features are on in this server
|
||||||
|
RoleConfigured bool // the owner has set a Beta Tester role
|
||||||
|
HasRole bool // the member is a tester
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusOf reports a member's standing in the server they're acting in.
|
||||||
|
func StatusOf(guildID string, member *discordgo.Member) Status {
|
||||||
|
c := get()
|
||||||
|
st := Status{
|
||||||
|
ServerEnabled: ServerEnabled(guildID),
|
||||||
|
RoleConfigured: c.roleID != "",
|
||||||
|
}
|
||||||
|
if st.ServerEnabled && st.RoleConfigured && member != nil {
|
||||||
|
st.HasRole = isTester(c, guildID, member)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTester checks the Beta Tester role in the beta server. In the beta server
|
||||||
|
// the roles come with the interaction. Anywhere else they are looked up.
|
||||||
|
func isTester(c settings, guildID string, member *discordgo.Member) bool {
|
||||||
|
if guildID == c.guildID {
|
||||||
|
return slices.Contains(member.Roles, c.roleID)
|
||||||
|
}
|
||||||
|
f := lookup.Load()
|
||||||
|
if f == nil || *f == nil || member.User == nil || member.User.ID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
roles, err := (*f)(c.guildID, member.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Contains(roles, c.roleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed reports whether a member may use beta features in guildID: beta
|
||||||
|
// features have to be on there, and the member has to be a tester. Use it for
|
||||||
|
// anything a person triggers that isn't a command, such as a button.
|
||||||
|
func Allowed(guildID string, member *discordgo.Member) bool {
|
||||||
|
st := StatusOf(guildID, member)
|
||||||
|
return st.ServerEnabled && st.RoleConfigured && st.HasRole
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedInteraction is Allowed for an interaction. A DM has no member, so it
|
||||||
|
// never qualifies. The member comes from Discord with the interaction, not
|
||||||
|
// from anything the user typed.
|
||||||
|
func AllowedInteraction(i *discordgo.InteractionCreate) bool {
|
||||||
|
return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member)
|
||||||
|
}
|
||||||
|
|
||||||
|
// respond is how a refusal is sent; tests replace it.
|
||||||
|
var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
|
if i.Type == discordgo.InteractionApplicationCommandAutocomplete {
|
||||||
|
// Nothing to suggest, and a message isn't a valid reply here.
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
|
||||||
|
Data: &discordgo.InteractionResponseData{Choices: []*discordgo.ApplicationCommandOptionChoice{}},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Content: msg,
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const denied = "This feature is in beta and isn't available to you or this server yet."
|
||||||
|
|
||||||
|
// Guard is for the start of a beta feature's handler, and for its component,
|
||||||
|
// modal and autocomplete handlers too (the command's gate doesn't cover
|
||||||
|
// those). It answers the user and returns false when they aren't allowed:
|
||||||
|
//
|
||||||
|
// if !beta.Guard(s, i) { return }
|
||||||
|
func Guard(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
|
if AllowedInteraction(i) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
respond(s, i, denied)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate wraps a command handler so it only runs for beta testers.
|
||||||
|
func Gate(h Handler) Handler {
|
||||||
|
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !Guard(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h(s, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package beta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
betaGuild = "700000000000000001"
|
||||||
|
otherGuild = "999000000000000001"
|
||||||
|
testerRol = "555000000000000001"
|
||||||
|
testerID = "777000000000000001"
|
||||||
|
)
|
||||||
|
|
||||||
|
func member(roles ...string) *discordgo.Member {
|
||||||
|
return &discordgo.Member{Roles: roles, User: &discordgo.User{ID: testerID}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate {
|
||||||
|
return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// useLookup makes the beta server say who holds which roles, as Discord would.
|
||||||
|
func useLookup(t *testing.T, rolesByUser map[string][]string, err error) *int {
|
||||||
|
t.Helper()
|
||||||
|
calls := 0
|
||||||
|
SetRolesLookup(func(guildID, userID string) ([]string, error) {
|
||||||
|
calls++
|
||||||
|
if guildID != betaGuild {
|
||||||
|
t.Errorf("the lookup must ask the beta server, asked %q", guildID)
|
||||||
|
}
|
||||||
|
return rolesByUser[userID], err
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { lookup.Store(nil) })
|
||||||
|
return &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedInTheBetaServer(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
guild, role string // configuration
|
||||||
|
inGuild string
|
||||||
|
member *discordgo.Member
|
||||||
|
want bool
|
||||||
|
on, hasRole bool
|
||||||
|
}{
|
||||||
|
{"tester in the beta server", betaGuild, testerRol, betaGuild, member("1", testerRol), true, true, true},
|
||||||
|
{"member without the role", betaGuild, testerRol, betaGuild, member("1", "2"), false, true, false},
|
||||||
|
{"member with no roles at all", betaGuild, testerRol, betaGuild, member(), false, true, false},
|
||||||
|
{"a DM has no member", betaGuild, testerRol, "", nil, false, false, false},
|
||||||
|
{"a nil member in the beta server", betaGuild, testerRol, betaGuild, nil, false, true, false},
|
||||||
|
{"no role configured: nobody", betaGuild, "", betaGuild, member(testerRol, ""), false, true, false},
|
||||||
|
{"no beta server configured: nobody", "", testerRol, betaGuild, member(testerRol), false, false, false},
|
||||||
|
{"a role ID that isn't a number is dropped", betaGuild, "tester-role", betaGuild, member("tester-role"), false, true, false},
|
||||||
|
{"a server ID that isn't a number is dropped", "my-server", testerRol, "my-server", member(testerRol), false, false, false},
|
||||||
|
{"a role that only contains the ID isn't a match", betaGuild, testerRol, betaGuild, member(testerRol + "9"), false, true, false},
|
||||||
|
{"stray spaces in the configuration are ignored", " " + betaGuild + " ", " " + testerRol + "\n", betaGuild, member(testerRol), true, true, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
Configure(tc.guild, tc.role, nil)
|
||||||
|
if got := Allowed(tc.inGuild, tc.member); got != tc.want {
|
||||||
|
t.Errorf("Allowed = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
if got := AllowedInteraction(interaction(tc.inGuild, tc.member)); got != tc.want {
|
||||||
|
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
st := StatusOf(tc.inGuild, tc.member)
|
||||||
|
if st.ServerEnabled != tc.on || st.HasRole != tc.hasRole {
|
||||||
|
t.Errorf("StatusOf = %+v, want server on %v, tester %v", st, tc.on, tc.hasRole)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptedInServers(t *testing.T) {
|
||||||
|
roles := map[string][]string{testerID: {"1", testerRol}}
|
||||||
|
|
||||||
|
t.Run("a tester in an opted-in server is allowed", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
calls := useLookup(t, roles, nil)
|
||||||
|
if !Allowed(otherGuild, member()) { // roles in this server don't matter
|
||||||
|
t.Error("tester in an opted-in server should be allowed")
|
||||||
|
}
|
||||||
|
if *calls != 1 {
|
||||||
|
t.Errorf("the beta server should be asked once, was asked %d times", *calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the role in the other server doesn't count", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, map[string][]string{}, nil)
|
||||||
|
if Allowed(otherGuild, member(testerRol)) {
|
||||||
|
t.Error("holding a role with that ID in another server must not make anyone a tester")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a server that didn't opt in is refused, and nothing is looked up", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
calls := useLookup(t, roles, nil)
|
||||||
|
if Allowed("123000000000000001", member()) {
|
||||||
|
t.Error("a server that didn't opt in must be refused")
|
||||||
|
}
|
||||||
|
if *calls != 0 {
|
||||||
|
t.Error("no lookup for a server where beta is off")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failing lookup fails closed", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, roles, errors.New("Unknown Member"))
|
||||||
|
if Allowed(otherGuild, member()) {
|
||||||
|
t.Error("an error from Discord must mean no")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no lookup set: testers only qualify in the beta server", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
lookup.Store(nil)
|
||||||
|
if Allowed(otherGuild, member()) {
|
||||||
|
t.Error("without a lookup there is no way to know")
|
||||||
|
}
|
||||||
|
if !Allowed(betaGuild, member(testerRol)) {
|
||||||
|
t.Error("the beta server doesn't need the lookup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a user without an ID can't be looked up", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, roles, nil)
|
||||||
|
if Allowed(otherGuild, &discordgo.Member{}) || Allowed(otherGuild, &discordgo.Member{User: &discordgo.User{}}) {
|
||||||
|
t.Error("no user, no access")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no role configured: nobody, even when the server opted in", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, "", []string{otherGuild})
|
||||||
|
useLookup(t, map[string][]string{testerID: {""}}, nil)
|
||||||
|
if Allowed(otherGuild, member()) {
|
||||||
|
t.Error("without a role there are no testers")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad IDs in the list are dropped", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild, "not-an-id", "", " 5 "})
|
||||||
|
got := Servers()
|
||||||
|
if len(got) != 2 || got[0] != "5" || got[1] != otherGuild {
|
||||||
|
t.Errorf("Servers = %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the beta server isn't listed twice", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{betaGuild, otherGuild})
|
||||||
|
if got := Servers(); len(got) != 1 || got[0] != otherGuild {
|
||||||
|
t.Errorf("Servers = %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerEnabledNeedsNoPerson(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
for guild, want := range map[string]bool{betaGuild: true, otherGuild: true, "123": false, "": false} {
|
||||||
|
if got := ServerEnabled(guild); got != want {
|
||||||
|
t.Errorf("ServerEnabled(%q) = %v, want %v", guild, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.Store(nil)
|
||||||
|
if ServerEnabled(betaGuild) {
|
||||||
|
t.Error("before Configure nothing is on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotConfiguredAtAll(t *testing.T) {
|
||||||
|
current.Store(nil)
|
||||||
|
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" || len(Servers()) != 0 {
|
||||||
|
t.Error("before Configure nothing is allowed and there's no beta server")
|
||||||
|
}
|
||||||
|
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
|
||||||
|
t.Error("a missing interaction is never allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, map[string][]string{}, nil) // nobody holds the role in the beta server
|
||||||
|
var answered []string
|
||||||
|
orig := respond
|
||||||
|
respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) {
|
||||||
|
answered = append(answered, msg)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { respond = orig })
|
||||||
|
|
||||||
|
ran := 0
|
||||||
|
h := Gate(func(*discordgo.Session, *discordgo.InteractionCreate) { ran++ })
|
||||||
|
|
||||||
|
h(nil, interaction(betaGuild, member(testerRol)))
|
||||||
|
if ran != 1 || len(answered) != 0 {
|
||||||
|
t.Fatalf("a tester runs the handler without being refused: ran=%d answered=%v", ran, answered)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, i := range map[string]*discordgo.InteractionCreate{
|
||||||
|
"no role": interaction(betaGuild, member("1")),
|
||||||
|
"opted-in, not a tester": interaction(otherGuild, member(testerRol)),
|
||||||
|
"server not opted in": interaction("123000000000000001", member(testerRol)),
|
||||||
|
"DM": interaction("", nil),
|
||||||
|
} {
|
||||||
|
before := ran
|
||||||
|
h(nil, i)
|
||||||
|
if ran != before {
|
||||||
|
t.Errorf("%s: the handler must not run", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(answered) != 4 || answered[0] != denied {
|
||||||
|
t.Errorf("each refusal is answered with the beta message: %v", answered)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLookup(t, map[string][]string{testerID: {testerRol}}, nil)
|
||||||
|
before := ran
|
||||||
|
h(nil, interaction(otherGuild, member()))
|
||||||
|
if ran != before+1 {
|
||||||
|
t.Error("a tester in an opted-in server runs the handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardOnItsOwn(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, nil)
|
||||||
|
n := 0
|
||||||
|
orig := respond
|
||||||
|
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
|
||||||
|
t.Cleanup(func() { respond = orig })
|
||||||
|
|
||||||
|
if !Guard(nil, interaction(betaGuild, member(testerRol))) || n != 0 {
|
||||||
|
t.Error("a tester passes without a reply")
|
||||||
|
}
|
||||||
|
if Guard(nil, interaction(betaGuild, member())) || n != 1 {
|
||||||
|
t.Error("someone else is refused with a reply")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"velox-bot/internal/beta"
|
||||||
|
"velox-bot/internal/commands"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appID = "100"
|
||||||
|
betaServer = "700000000000000001"
|
||||||
|
optedIn = "222"
|
||||||
|
leftServer = "333"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeDiscord answers the application-command calls the beta sync makes, from
|
||||||
|
// memory, and records what it was asked to change.
|
||||||
|
var registerOnce sync.Once
|
||||||
|
|
||||||
|
type fakeDiscord struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cmds map[string][]*discordgo.ApplicationCommand // guild -> commands
|
||||||
|
failures map[string]int // guild -> status for every call
|
||||||
|
nextID int
|
||||||
|
created []string // "guild/name"
|
||||||
|
deleted []string // "guild/name"
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdPath = regexp.MustCompile(`/applications/` + appID + `/guilds/(\d+)/commands(?:/(\d+))?$`)
|
||||||
|
|
||||||
|
func (f *fakeDiscord) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
reply := func(code int, v any) (*http.Response, error) {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
|
||||||
|
}
|
||||||
|
m := cmdPath.FindStringSubmatch(r.URL.Path)
|
||||||
|
if m == nil {
|
||||||
|
return reply(404, map[string]any{"message": "unknown route"})
|
||||||
|
}
|
||||||
|
guild, id := m[1], m[2]
|
||||||
|
if code := f.failures[guild]; code != 0 {
|
||||||
|
return reply(code, map[string]any{"message": "failure", "code": 0})
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
return reply(200, f.cmds[guild])
|
||||||
|
case http.MethodPost:
|
||||||
|
var c discordgo.ApplicationCommand
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||||
|
f.nextID++
|
||||||
|
c.ID = strconv.Itoa(1000 + f.nextID)
|
||||||
|
f.cmds[guild] = append(f.cmds[guild], &c)
|
||||||
|
f.created = append(f.created, guild+"/"+c.Name)
|
||||||
|
return reply(200, c)
|
||||||
|
case http.MethodDelete:
|
||||||
|
kept := f.cmds[guild][:0]
|
||||||
|
for _, c := range f.cmds[guild] {
|
||||||
|
if c.ID == id {
|
||||||
|
f.deleted = append(f.deleted, guild+"/"+c.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, c)
|
||||||
|
}
|
||||||
|
f.cmds[guild] = kept
|
||||||
|
return reply(204, nil)
|
||||||
|
}
|
||||||
|
return reply(405, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) names(guild string) []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, c := range f.cmds[guild] {
|
||||||
|
out = append(out, c.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
registered map[string]bool
|
||||||
|
servers []string
|
||||||
|
failWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeStore) GetBetaSettings(context.Context) (string, string, error) {
|
||||||
|
return betaServer, "555", nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) ListBetaGuilds(context.Context) ([]string, error) { return s.servers, nil }
|
||||||
|
func (s *fakeStore) ListBetaRegistered(context.Context) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for id := range s.registered {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) SetBetaRegistered(_ context.Context, id string, on bool) error {
|
||||||
|
if s.failWrite {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
s.registered[id] = true
|
||||||
|
} else {
|
||||||
|
delete(s.registered, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBot(t *testing.T, d *fakeDiscord) *Bot {
|
||||||
|
t.Helper()
|
||||||
|
sess, err := discordgo.New("Bot x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess.Client = &http.Client{Transport: d}
|
||||||
|
sess.MaxRestRetries = 0
|
||||||
|
return &Bot{
|
||||||
|
Session: sess,
|
||||||
|
AppID: appID,
|
||||||
|
betaRegistered: map[string]bool{},
|
||||||
|
betaKnown: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetaCommandsFollowTheServers(t *testing.T) {
|
||||||
|
registerOnce.Do(func() {
|
||||||
|
commands.RegisterBeta(&discordgo.ApplicationCommand{Name: "betatest", Description: "test"}, func(*discordgo.Session, *discordgo.InteractionCreate) {})
|
||||||
|
})
|
||||||
|
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
|
||||||
|
// A server that opted out while the bot was offline still has them,
|
||||||
|
// next to a command that isn't a beta one.
|
||||||
|
leftServer: {{ID: "1", Name: "betatest"}, {ID: "2", Name: "ping"}},
|
||||||
|
// A stale command in the beta server, which the bot cleans up.
|
||||||
|
betaServer: {{ID: "3", Name: "oldthing"}},
|
||||||
|
}, failures: map[string]int{}}
|
||||||
|
store := &fakeStore{registered: map[string]bool{leftServer: true}, servers: []string{optedIn}}
|
||||||
|
b := newTestBot(t, d)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
beta.Configure(betaServer, "555", store.servers)
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
|
||||||
|
if got := d.names(leftServer); len(got) != 1 || got[0] != "ping" {
|
||||||
|
t.Errorf("the beta command is removed from the server that left, nothing else: %v", got)
|
||||||
|
}
|
||||||
|
if got := d.names(optedIn); len(got) != 1 || got[0] != "betatest" {
|
||||||
|
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
|
||||||
|
}
|
||||||
|
got := d.names(betaServer)
|
||||||
|
if !contains(got, "betatest") || contains(got, "oldthing") || contains(got, "beta") {
|
||||||
|
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] {
|
||||||
|
t.Errorf("the database remembers where the commands are now: %v", store.registered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server opts out.
|
||||||
|
store.servers = nil
|
||||||
|
beta.Configure(betaServer, "555", store.servers)
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if got := d.names(optedIn); len(got) != 0 {
|
||||||
|
t.Errorf("an opted-out server loses them within one round: %v", got)
|
||||||
|
}
|
||||||
|
if store.registered[optedIn] || !store.registered[betaServer] {
|
||||||
|
t.Errorf("only the beta server stays remembered: %v", store.registered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetaCleanupIsRetriedAndGivesUpWhenTheBotIsGone(t *testing.T) {
|
||||||
|
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
|
||||||
|
leftServer: {{ID: "1", Name: "betatest"}},
|
||||||
|
}, failures: map[string]int{leftServer: 500}}
|
||||||
|
store := &fakeStore{registered: map[string]bool{leftServer: true}}
|
||||||
|
b := newTestBot(t, d)
|
||||||
|
ctx := context.Background()
|
||||||
|
beta.Configure(betaServer, "555", nil)
|
||||||
|
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if !store.registered[leftServer] {
|
||||||
|
t.Fatal("a failed cleanup is remembered so it is tried again")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.failures[leftServer] = 0
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if store.registered[leftServer] || len(d.names(leftServer)) != 0 {
|
||||||
|
t.Errorf("the retry cleans up: %v, %v", store.registered, d.names(leftServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bot was removed from a server: nothing to clean, don't try forever.
|
||||||
|
store.registered["444"] = true
|
||||||
|
b.betaKnown["444"] = true
|
||||||
|
d.failures["444"] = 403
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if store.registered["444"] {
|
||||||
|
t.Error("a server the bot can't reach any more is forgotten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close used to loop over a list that started with empty entries and read
|
||||||
|
// their IDs, so shutting down crashed. It must just disconnect, and must not
|
||||||
|
// touch the registered commands.
|
||||||
|
func TestCloseDoesNotTouchCommands(t *testing.T) {
|
||||||
|
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
|
||||||
|
"1": {{ID: "7", Name: "ping"}},
|
||||||
|
}, failures: map[string]int{}}
|
||||||
|
b := newTestBot(t, d)
|
||||||
|
b.GuildID = "1"
|
||||||
|
b.Commands = []*discordgo.ApplicationCommand{{Name: "ping"}}
|
||||||
|
|
||||||
|
if err := b.Close(); err != nil {
|
||||||
|
t.Fatalf("Close on a bot that never connected: %v", err)
|
||||||
|
}
|
||||||
|
if len(d.deleted) != 0 {
|
||||||
|
t.Errorf("Close must not delete commands: %v", d.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
+300
-11
@@ -2,12 +2,19 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"velox-bot/internal/beta"
|
||||||
"velox-bot/internal/commands"
|
"velox-bot/internal/commands"
|
||||||
"velox-bot/internal/db/repos/musicrepo"
|
"velox-bot/internal/db/repos/musicrepo"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/events"
|
"velox-bot/internal/events"
|
||||||
"velox-bot/internal/music"
|
"velox-bot/internal/music"
|
||||||
|
"velox-bot/internal/security"
|
||||||
|
"velox-bot/internal/tickets"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
@@ -17,12 +24,15 @@ type Bot struct {
|
|||||||
AppID string
|
AppID string
|
||||||
GuildID string
|
GuildID string
|
||||||
Commands []*discordgo.ApplicationCommand
|
Commands []*discordgo.ApplicationCommand
|
||||||
registeredCommands []*discordgo.ApplicationCommand
|
|
||||||
Services *services.Services
|
Services *services.Services
|
||||||
MusicRepo *musicrepo.Repo
|
MusicRepo *musicrepo.Repo
|
||||||
DBHost string
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass string
|
LavalinkPass string
|
||||||
|
betaRegistered map[string]bool // servers where this run registered the beta commands
|
||||||
|
betaKnown map[string]bool // servers where they were registered, now or in an earlier run
|
||||||
|
betaLoaded bool
|
||||||
|
betaRoles sync.Map // "guild:user" -> cachedRoles
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
|
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
|
||||||
@@ -45,6 +55,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
discordgo.IntentsDirectMessages
|
discordgo.IntentsDirectMessages
|
||||||
|
|
||||||
return &Bot{
|
return &Bot{
|
||||||
|
betaRegistered: map[string]bool{},
|
||||||
|
betaKnown: map[string]bool{},
|
||||||
Session: session,
|
Session: session,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
@@ -70,25 +82,76 @@ func (b *Bot) Start() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
if st := tickets.Default(); st != nil {
|
||||||
|
go func() {
|
||||||
|
// The connection can drop (database restart, network blip):
|
||||||
|
// reconnect instead of silently ignoring the dashboard from then on.
|
||||||
|
for {
|
||||||
|
err := tickets.StartCommandListener(context.Background(), b.DBHost, b.Session, st)
|
||||||
|
log.Printf("tickets: command listener stopped: %v (retrying in 10s)", err)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if eng := security.Start(b.Session, b.Session.State.User.ID); eng != nil {
|
||||||
|
// Moderators are exempt from heat; their permissions come from the
|
||||||
|
// gateway cache so a message never costs an API call.
|
||||||
|
eng.ChannelPerms = b.Session.State.UserChannelPermissions
|
||||||
|
eng.ResumeRaids(context.Background())
|
||||||
|
go eng.WatchRaids(context.Background(), 30*time.Second)
|
||||||
|
go eng.WatchSnapshots(context.Background())
|
||||||
|
go func() {
|
||||||
|
// Reconnect if the database connection drops, instead of
|
||||||
|
// silently ignoring the dashboard from then on.
|
||||||
|
for {
|
||||||
|
err := eng.StartCommandListener(context.Background(), b.DBHost)
|
||||||
|
log.Printf("security: command listener stopped: %v (retrying in 10s)", err)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registering is an upsert by name: existing commands are updated in
|
||||||
|
// place and keep their IDs (links like </help:ID> keep working).
|
||||||
for _, cmd := range b.Commands {
|
for _, cmd := range b.Commands {
|
||||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
if _, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Printf("Cannot create '%v' command: %v", cmd.Name, err)
|
log.Printf("Cannot create '%v' command: %v", cmd.Name, err)
|
||||||
continue
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
b.registeredCommands = append(b.registeredCommands, created)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
b.Session.AddHandler(commands.HandleInteraction)
|
b.Session.AddHandler(commands.HandleInteraction)
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
|
||||||
|
events.HandleChannelDelete(s, c, b.Services)
|
||||||
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||||
|
if eng := security.Default(); eng != nil {
|
||||||
|
eng.OnMessage(context.Background(), m)
|
||||||
|
}
|
||||||
events.HandleMessageCreate(s, m, b.Services)
|
events.HandleMessageCreate(s, m, b.Services)
|
||||||
})
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
||||||
events.HandleBotAddedOnGuild(s, m, b.Services)
|
events.HandleBotAddedOnGuild(s, m, b.Services)
|
||||||
|
|
||||||
|
// Security runs first: someone it removes (join lock, Join Gate,
|
||||||
|
// raid) gets no welcome message or default role.
|
||||||
|
if eng := security.Default(); eng != nil && m != nil && m.Member != nil {
|
||||||
|
if eng.OnMemberJoin(context.Background(), m.GuildID, m.Member) {
|
||||||
|
events.HandleGuildMemberAddLog(s, m, b.Services)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore bot members for welcome/log onboarding of normal users.
|
// Ignore bot members for welcome/log onboarding of normal users.
|
||||||
if m != nil && m.Member != nil && m.User != nil && m.User.Bot {
|
if m != nil && m.Member != nil && m.User != nil && m.User.Bot {
|
||||||
return
|
return
|
||||||
@@ -98,6 +161,14 @@ func (b *Bot) Start() error {
|
|||||||
events.HandleGuildMemberAddLog(s, m, b.Services)
|
events.HandleGuildMemberAddLog(s, m, b.Services)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Anti-nuke and unauthorized bot additions read the audit log as it
|
||||||
|
// happens (needs the Guild Moderation intent and View Audit Log).
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.GuildAuditLogEntryCreate) {
|
||||||
|
if eng := security.Default(); eng != nil && ev.AuditLogEntry != nil {
|
||||||
|
eng.OnAuditLogEntry(context.Background(), ev.GuildID, ev.AuditLogEntry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
|
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
|
||||||
events.HandleGuildMemberRemoveLog(s, m, b.Services)
|
events.HandleGuildMemberRemoveLog(s, m, b.Services)
|
||||||
})
|
})
|
||||||
@@ -122,6 +193,12 @@ func (b *Bot) Start() error {
|
|||||||
events.HandleGuildBanRemoveLog(s, m, b.Services)
|
events.HandleGuildBanRemoveLog(s, m, b.Services)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
b.Session.AddHandler(func(s *discordgo.Session, t *discordgo.ThreadDelete) {
|
||||||
|
if st := tickets.Default(); st != nil && t.Channel != nil {
|
||||||
|
tickets.ThreadDeleted(context.Background(), st, t.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||||
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
events.HandleVoiceStateUpdate(s, vs, b.Services)
|
||||||
music.OnVoiceStateUpdate(vs)
|
music.OnVoiceStateUpdate(vs)
|
||||||
@@ -137,11 +214,223 @@ func (b *Bot) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// betaPollEvery is how often the bot looks for a change to the beta settings
|
||||||
|
// made on the dashboard.
|
||||||
|
const betaPollEvery = 30 * time.Second
|
||||||
|
|
||||||
|
// BetaStore is where the bot reads the beta settings the dashboard writes,
|
||||||
|
// and remembers which servers it registered beta commands in.
|
||||||
|
type BetaStore interface {
|
||||||
|
GetBetaSettings(ctx context.Context) (guildID, roleID string, err error)
|
||||||
|
ListBetaGuilds(ctx context.Context) ([]string, error)
|
||||||
|
ListBetaRegistered(ctx context.Context) ([]string, error)
|
||||||
|
SetBetaRegistered(ctx context.Context, guildID string, registered bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchBeta keeps the bot's idea of who the beta testers are, and which
|
||||||
|
// servers opted in, in step with the dashboard. When the list of servers
|
||||||
|
// changes, the beta commands follow: they appear in servers that opted in and
|
||||||
|
// are removed from servers that opted out, also if that happened while the bot
|
||||||
|
// was offline (it remembers where it registered them). If the settings can't
|
||||||
|
// be read, the last known ones stay in force (and before the first read, beta
|
||||||
|
// stays off).
|
||||||
|
func (b *Bot) WatchBeta(ctx context.Context, store BetaStore) {
|
||||||
|
beta.SetRolesLookup(b.betaMemberRoles)
|
||||||
|
sync := func() {
|
||||||
|
guildID, roleID, err := store.GetBetaSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
servers, err := store.ListBetaGuilds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("beta: cannot read the opted-in servers, keeping the last ones: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beta.Configure(guildID, roleID, servers)
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(betaPollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedRoles struct {
|
||||||
|
roles []string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// betaRolesTTL is how long a member's roles in the beta server are remembered
|
||||||
|
// when they had to be fetched from Discord.
|
||||||
|
const betaRolesTTL = 30 * time.Second
|
||||||
|
|
||||||
|
// betaMemberRoles answers beta.SetRolesLookup: someone's roles in the beta
|
||||||
|
// server. The gateway cache is used when it has them (it is kept current by
|
||||||
|
// member updates), otherwise Discord is asked, at most once per
|
||||||
|
// betaRolesTTL per person.
|
||||||
|
func (b *Bot) betaMemberRoles(guildID, userID string) ([]string, error) {
|
||||||
|
if m, err := b.Session.State.Member(guildID, userID); err == nil && m != nil {
|
||||||
|
return m.Roles, nil
|
||||||
|
}
|
||||||
|
key := guildID + ":" + userID
|
||||||
|
if v, ok := b.betaRoles.Load(key); ok {
|
||||||
|
if c := v.(cachedRoles); time.Since(c.at) < betaRolesTTL {
|
||||||
|
return c.roles, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, err := b.Session.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.betaRoles.Store(key, cachedRoles{roles: m.Roles, at: time.Now()})
|
||||||
|
return m.Roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncBetaCommands makes the beta commands exist in exactly the servers where
|
||||||
|
// beta features are on (the beta server and the ones that opted in), and
|
||||||
|
// nowhere else. The servers it registered them in are remembered in the
|
||||||
|
// database, so a server that opted out while the bot was offline is cleaned up
|
||||||
|
// at the next start. A step that failed is retried on the next round.
|
||||||
|
func (b *Bot) syncBetaCommands(ctx context.Context, store BetaStore) {
|
||||||
|
if !b.betaLoaded {
|
||||||
|
known, err := store.ListBetaRegistered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("beta: cannot read where beta commands were registered: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range known {
|
||||||
|
b.betaKnown[id] = true
|
||||||
|
}
|
||||||
|
b.betaLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{}
|
||||||
|
if g := beta.GuildID(); g != "" {
|
||||||
|
want[g] = true
|
||||||
|
}
|
||||||
|
for _, id := range beta.Servers() {
|
||||||
|
want[id] = true
|
||||||
|
}
|
||||||
|
for id := range b.betaKnown {
|
||||||
|
if want[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !b.removeBetaCommands(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := store.SetBetaRegistered(ctx, id, false); err != nil {
|
||||||
|
log.Printf("beta: cannot forget server %s: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(b.betaKnown, id)
|
||||||
|
delete(b.betaRegistered, id)
|
||||||
|
}
|
||||||
|
for id := range want {
|
||||||
|
if b.betaRegistered[id] || !b.addBetaCommands(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.betaRegistered[id] = true
|
||||||
|
if err := store.SetBetaRegistered(ctx, id, true); err != nil {
|
||||||
|
log.Printf("beta: cannot remember server %s: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.betaKnown[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBetaCommands registers the beta commands in a server. In the beta server
|
||||||
|
// it also removes commands this bot left there that no longer exist (when the
|
||||||
|
// beta server is also the bot's main server, its regular commands are kept).
|
||||||
|
// /beta is for the bot's owner and is only registered in the beta server. It
|
||||||
|
// reports whether everything was registered.
|
||||||
|
func (b *Bot) addBetaCommands(guildID string) bool {
|
||||||
|
isBetaServer := guildID == beta.GuildID()
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, cmd := range b.Commands {
|
||||||
|
if b.GuildID == guildID {
|
||||||
|
keep[cmd.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, cmd := range commands.BetaCommands {
|
||||||
|
keep[cmd.Name] = true
|
||||||
|
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
|
||||||
|
log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isBetaServer {
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
for _, cmd := range existing {
|
||||||
|
if keep[cmd.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil {
|
||||||
|
log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// goneFromServer reports whether an error from Discord means the bot isn't in
|
||||||
|
// that server any more (or it no longer exists), so there is nothing left to
|
||||||
|
// clean up there.
|
||||||
|
func goneFromServer(err error) bool {
|
||||||
|
var rest *discordgo.RESTError
|
||||||
|
if errors.As(err, &rest) && rest.Response != nil {
|
||||||
|
return rest.Response.StatusCode == http.StatusForbidden || rest.Response.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeBetaCommands takes the beta commands out of a server that is no longer
|
||||||
|
// part of the beta, and leaves everything else there alone. It reports whether
|
||||||
|
// the server is clean (or out of reach for good).
|
||||||
|
func (b *Bot) removeBetaCommands(guildID string) bool {
|
||||||
|
isBeta := map[string]bool{}
|
||||||
|
for _, cmd := range commands.BetaCommands {
|
||||||
|
isBeta[cmd.Name] = true
|
||||||
|
}
|
||||||
|
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
||||||
|
if err != nil {
|
||||||
|
if goneFromServer(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Printf("beta: cannot list commands in %s: %v", guildID, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
clean := true
|
||||||
|
for _, cmd := range existing {
|
||||||
|
if !isBeta[cmd.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil && !goneFromServer(err) {
|
||||||
|
log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err)
|
||||||
|
clean = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close disconnects from Discord. The slash commands are left registered on
|
||||||
|
// purpose: deleting them on every shutdown would make them disappear during a
|
||||||
|
// restart, and registering them again would give them new IDs.
|
||||||
func (b *Bot) Close() error {
|
func (b *Bot) Close() error {
|
||||||
for _, cmd := range b.registeredCommands {
|
|
||||||
if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil {
|
|
||||||
log.Printf("cannot delete '%s' command: %v", cmd.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.Session.Close()
|
return b.Session.Close()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Package betacmd is /beta, the bot owner's control over the beta program.
|
||||||
|
// It is a command you use in a direct message with the bot, so it exists in no
|
||||||
|
// server's command menu, and only the owner of the Discord application can run
|
||||||
|
// it (the owner is looked up from the application, so there is nothing to
|
||||||
|
// 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
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/beta"
|
||||||
|
"velox-bot/internal/db/services"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ownerIDs sync.Map // user ID -> struct{}, filled once from the application
|
||||||
|
loadOnce sync.Mutex
|
||||||
|
loaded bool
|
||||||
|
|
||||||
|
snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
|
||||||
|
dmOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextBotDM}
|
||||||
|
)
|
||||||
|
|
||||||
|
var Beta = &discordgo.ApplicationCommand{
|
||||||
|
Name: "beta",
|
||||||
|
Description: "Manage the beta program (bot owner, in a DM with the bot)",
|
||||||
|
Contexts: dmOnly,
|
||||||
|
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
|
||||||
|
// on its team). If the application can't be read, nobody is the owner.
|
||||||
|
func isOwner(s *discordgo.Session, userID string) bool {
|
||||||
|
if userID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
loadOnce.Lock()
|
||||||
|
if !loaded {
|
||||||
|
app, err := s.Application("@me")
|
||||||
|
if err == nil && app != nil {
|
||||||
|
if app.Owner != nil {
|
||||||
|
ownerIDs.Store(app.Owner.ID, struct{}{})
|
||||||
|
}
|
||||||
|
if app.Team != nil {
|
||||||
|
ownerIDs.Store(app.Team.OwnerID, struct{}{})
|
||||||
|
for _, m := range app.Team.Members {
|
||||||
|
if m != nil && m.User != nil {
|
||||||
|
ownerIDs.Store(m.User.ID, struct{}{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadOnce.Unlock()
|
||||||
|
_, ok := ownerIDs.Load(userID)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{Content: msg, 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"velox-bot/internal/beta"
|
||||||
|
"velox-bot/internal/commands/betacmd"
|
||||||
"velox-bot/internal/commands/config"
|
"velox-bot/internal/commands/config"
|
||||||
"velox-bot/internal/commands/fun"
|
"velox-bot/internal/commands/fun"
|
||||||
"velox-bot/internal/commands/help"
|
"velox-bot/internal/commands/help"
|
||||||
@@ -11,6 +13,8 @@ import (
|
|||||||
cmdmusic "velox-bot/internal/commands/music"
|
cmdmusic "velox-bot/internal/commands/music"
|
||||||
"velox-bot/internal/commands/projects"
|
"velox-bot/internal/commands/projects"
|
||||||
"velox-bot/internal/commands/schedule"
|
"velox-bot/internal/commands/schedule"
|
||||||
|
cmdsecurity "velox-bot/internal/commands/security"
|
||||||
|
cmdtickets "velox-bot/internal/commands/tickets"
|
||||||
"velox-bot/internal/commands/timezone"
|
"velox-bot/internal/commands/timezone"
|
||||||
"velox-bot/internal/music"
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
@@ -40,6 +44,11 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
|||||||
schedule.Schedule,
|
schedule.Schedule,
|
||||||
projects.Projects,
|
projects.Projects,
|
||||||
config.Config,
|
config.Config,
|
||||||
|
cmdtickets.Tickets,
|
||||||
|
cmdtickets.Ticket,
|
||||||
|
cmdsecurity.Security,
|
||||||
|
cmdsecurity.Lockdown,
|
||||||
|
cmdsecurity.Unlock,
|
||||||
}
|
}
|
||||||
|
|
||||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||||
@@ -65,6 +74,33 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
|
|||||||
"volume": cmdmusic.VolumeHandler,
|
"volume": cmdmusic.VolumeHandler,
|
||||||
"music": cmdmusic.MusicHandler,
|
"music": cmdmusic.MusicHandler,
|
||||||
"config": config.ConfigHandler,
|
"config": config.ConfigHandler,
|
||||||
|
"tickets": cmdtickets.TicketsHandler,
|
||||||
|
"ticket": cmdtickets.TicketHandler,
|
||||||
|
"security": cmdsecurity.SecurityHandler,
|
||||||
|
"lockdown": cmdsecurity.LockdownHandler,
|
||||||
|
"unlock": cmdsecurity.UnlockHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BetaCommands are the beta features' slash commands. They're registered in
|
||||||
|
// the beta server only, so nobody else even sees them, and each handler is
|
||||||
|
// wrapped so it runs only for members with the Beta Tester role (see the beta
|
||||||
|
// package). To put a feature in beta, add it with RegisterBeta instead of to
|
||||||
|
// AllCommands and handlers; to release it, move it there.
|
||||||
|
var BetaCommands = []*discordgo.ApplicationCommand{}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
handlers[betacmd.Beta.Name] = betacmd.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterBeta adds a slash command that only beta testers can use.
|
||||||
|
func RegisterBeta(cmd *discordgo.ApplicationCommand, h func(s *discordgo.Session, i *discordgo.InteractionCreate)) {
|
||||||
|
BetaCommands = append(BetaCommands, cmd)
|
||||||
|
handlers[cmd.Name] = beta.Gate(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
@@ -82,6 +118,10 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
schedule.HandleComponent(s, i)
|
schedule.HandleComponent(s, i)
|
||||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||||
projects.HandleComponent(s, i)
|
projects.HandleComponent(s, i)
|
||||||
|
case strings.HasPrefix(data.CustomID, "tickets:"):
|
||||||
|
cmdtickets.HandleComponent(s, i)
|
||||||
|
case strings.HasPrefix(data.CustomID, "security:"):
|
||||||
|
cmdsecurity.HandleComponent(s, i)
|
||||||
}
|
}
|
||||||
case discordgo.InteractionModalSubmit:
|
case discordgo.InteractionModalSubmit:
|
||||||
data := i.ModalSubmitData()
|
data := i.ModalSubmitData()
|
||||||
@@ -90,6 +130,8 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
schedule.HandleModalSubmit(s, i)
|
schedule.HandleModalSubmit(s, i)
|
||||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||||
projects.HandleModalSubmit(s, i)
|
projects.HandleModalSubmit(s, i)
|
||||||
|
case strings.HasPrefix(data.CustomID, "tickets:"):
|
||||||
|
cmdtickets.HandleModalSubmit(s, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+179
-108
@@ -2,6 +2,7 @@ package help
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
@@ -11,6 +12,179 @@ var Help = &discordgo.ApplicationCommand{
|
|||||||
Description: "Get help with the bot",
|
Description: "Get help with the bot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A message's embeds share a limit of 6000 characters, and one embed holds at
|
||||||
|
// most 25 fields of 1024 characters. The reference is longer than that, so it
|
||||||
|
// is sent as a few messages.
|
||||||
|
const (
|
||||||
|
maxEmbedChars = 5000
|
||||||
|
maxFieldChars = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func fields() []*discordgo.MessageEmbedField {
|
||||||
|
return []*discordgo.MessageEmbedField{
|
||||||
|
{
|
||||||
|
Name: "Fun",
|
||||||
|
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\n" +
|
||||||
|
"`/music skip|pause|resume|stop` — Control playback\n" +
|
||||||
|
"`/music repeat <off|song|queue>`, `/music remove <n>`, `/music move <from> <to>` — Repeat and reorder\n" +
|
||||||
|
"`/music toggle` — Turn music on or off (**Manage Server**)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 settings`, `/tickets category add|edit|remove|list`, `/tickets panel`, `/tickets list` — Set up (**Manage Server**)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Security (anti-raid & anti-nuke)",
|
||||||
|
Value: "" +
|
||||||
|
"`/security status`, `/security settings`, `/security history` — What's on, every setting, what happened\n" +
|
||||||
|
"`/security toggle` — Join Gate, raid mode, anti-nuke, heat on/off (off: owner or trusted)\n" +
|
||||||
|
"`/security joingate`, `words`, `raid`, `antinuke`, `heat`, `alert-dms` — Tune thresholds and filters (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**)\n" +
|
||||||
|
"`/moderation bans`, `/moderation timeouts` — Who is banned, who is timed out",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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`, `/slvl remove-reward level:<n>` — Role rewards (**Manage Server**)\n" +
|
||||||
|
"`/slvl status` — Show the leveling settings (**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\n" +
|
||||||
|
"`/config toggletwitch enabled:<bool>`, `/config streamers` — Turn alerts on or off, list streamers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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\n" +
|
||||||
|
"`/config view`, `/config clear setting:<...>` — See or clear these settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "Beta features",
|
||||||
|
Value: "" +
|
||||||
|
"`/config beta-features [enabled]` — Let testers use beta features here (server owner only)\n" +
|
||||||
|
"Bot owner: `/beta` in a DM with the bot — set the beta server and role, list or remove servers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Other",
|
||||||
|
Value: "`/help` — Show this help in DMs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldSize(f *discordgo.MessageEmbedField) int {
|
||||||
|
return utf8.RuneCountInString(f.Name) + utf8.RuneCountInString(f.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: "Command reference. Some commands require server permissions or roles.",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
@@ -40,113 +214,10 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot",
|
URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot",
|
||||||
}
|
}
|
||||||
|
|
||||||
embed := &discordgo.MessageEmbed{
|
for _, e := range embeds(&author) {
|
||||||
Title: "Velox Bot Help",
|
if _, err := s.ChannelMessageSendEmbed(dmChannel.ID, e); err != nil {
|
||||||
Description: "Command reference. Some commands require server permissions or roles.",
|
log.Printf("Error sending help: %v", err)
|
||||||
Color: 0xFFA500,
|
return
|
||||||
Fields: []*discordgo.MessageEmbedField{
|
}
|
||||||
{
|
|
||||||
Name: "Fun",
|
|
||||||
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: "Meetings (**Manage Server**)",
|
|
||||||
Value: "" +
|
|
||||||
"`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\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)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package help
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package public
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"velox-bot/internal/commands/meeting/shared"
|
"velox-bot/internal/commands/meeting/shared"
|
||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
|
meetingsvc "velox-bot/internal/db/services/meeting"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
@@ -17,18 +20,37 @@ var Meeting = &discordgo.ApplicationCommand{
|
|||||||
Options: []*discordgo.ApplicationCommandOption{
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
{
|
{
|
||||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
Name: "set-lobby",
|
Name: "add-lobby",
|
||||||
Description: "Set the voice lobby channel used to create meetings",
|
Description: "Add a voice lobby: joining it creates a meeting room",
|
||||||
Options: []*discordgo.ApplicationCommandOption{
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
{
|
{
|
||||||
Type: discordgo.ApplicationCommandOptionChannel,
|
Type: discordgo.ApplicationCommandOptionChannel,
|
||||||
Name: "channel",
|
Name: "channel",
|
||||||
Description: "Voice channel lobby",
|
Description: "Voice channel to use as a lobby",
|
||||||
Required: true,
|
Required: true,
|
||||||
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
|
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "remove-lobby",
|
||||||
|
Description: "Stop using a voice channel as a lobby",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionChannel,
|
||||||
|
Name: "channel",
|
||||||
|
Description: "The lobby to remove",
|
||||||
|
Required: true,
|
||||||
|
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "lobbies",
|
||||||
|
Description: "List this server's meeting lobbies",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
Name: "lock",
|
Name: "lock",
|
||||||
@@ -85,8 +107,12 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch data.Options[0].Name {
|
switch data.Options[0].Name {
|
||||||
case "set-lobby":
|
case "add-lobby":
|
||||||
handleSetLobby(s, i)
|
handleAddLobby(s, i)
|
||||||
|
case "remove-lobby":
|
||||||
|
handleRemoveLobby(s, i)
|
||||||
|
case "lobbies":
|
||||||
|
handleListLobbies(s, i)
|
||||||
case "lock":
|
case "lock":
|
||||||
handleLock(s, i)
|
handleLock(s, i)
|
||||||
case "lock-private":
|
case "lock-private":
|
||||||
@@ -102,37 +128,97 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
// lobbyChannelOption reads the "channel" option of add-lobby and
|
||||||
guildID, ok := shared.ParseGuildID(s, i)
|
// remove-lobby, answering the user itself if it's missing or not a voice
|
||||||
if !ok {
|
// channel.
|
||||||
return
|
func lobbyChannelOption(s *discordgo.Session, i *discordgo.InteractionCreate) (*discordgo.Channel, int64, bool) {
|
||||||
}
|
|
||||||
|
|
||||||
optMap := shared.SubcommandOptionMap(i)
|
optMap := shared.SubcommandOptionMap(i)
|
||||||
channelOpt, ok := optMap["channel"]
|
channelOpt, ok := optMap["channel"]
|
||||||
if !ok {
|
if !ok {
|
||||||
shared.RespondEphemeral(s, i, "Missing channel option.")
|
shared.RespondEphemeral(s, i, "Missing channel option.")
|
||||||
return
|
return nil, 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := channelOpt.ChannelValue(s)
|
ch := channelOpt.ChannelValue(s)
|
||||||
if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice {
|
if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice {
|
||||||
shared.RespondEphemeral(s, i, "Invalid voice channel.")
|
shared.RespondEphemeral(s, i, "Invalid voice channel.")
|
||||||
return
|
return nil, 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
|
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
shared.RespondEphemeral(s, i, "Invalid channel ID.")
|
shared.RespondEphemeral(s, i, "Invalid channel ID.")
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
return ch, channelID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, channelID, ok := lobbyChannelOption(s, i)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil {
|
added, err := services.Global.Meeting.AddLobby(context.Background(), guildID, channelID)
|
||||||
shared.RespondEphemeral(s, i, "Failed to set meeting lobby.")
|
switch {
|
||||||
|
case errors.Is(err, meetingsvc.ErrLobbyLimit):
|
||||||
|
shared.RespondEphemeral(s, i, fmt.Sprintf("This server already has %d lobbies. Remove one with `/meeting remove-lobby` first.", meetingsvc.MaxLobbies))
|
||||||
|
case err != nil:
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to add the meeting lobby.")
|
||||||
|
case !added:
|
||||||
|
shared.RespondEphemeral(s, i, ch.Mention()+" is already a meeting lobby.")
|
||||||
|
default:
|
||||||
|
shared.RespondEphemeral(s, i, ch.Mention()+" is now a meeting lobby.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRemoveLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, channelID, ok := lobbyChannelOption(s, i)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".")
|
removed, err := services.Global.Meeting.RemoveLobby(context.Background(), guildID, channelID)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to remove the meeting lobby.")
|
||||||
|
case !removed:
|
||||||
|
shared.RespondEphemeral(s, i, ch.Mention()+" isn't a meeting lobby.")
|
||||||
|
default:
|
||||||
|
shared.RespondEphemeral(s, i, ch.Mention()+" is no longer a meeting lobby.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListLobbies(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
guildID, ok := shared.ParseGuildID(s, i)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := services.Global.Meeting.ListLobbies(context.Background(), guildID)
|
||||||
|
if err != nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Failed to list the meeting lobbies.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
shared.RespondEphemeral(s, i, "No meeting lobbies yet. Add one with `/meeting add-lobby`.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Meeting lobbies (%d of %d):\n", len(ids), meetingsvc.MaxLobbies)
|
||||||
|
for _, id := range ids {
|
||||||
|
fmt.Fprintf(&b, "<#%d>\n", id)
|
||||||
|
}
|
||||||
|
shared.RespondEphemeral(s, i, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"velox-bot/internal/commands/music/shared"
|
||||||
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The player controls. Everything a dashboard can do to the queue (skip, pause,
|
||||||
|
// resume, stop, repeat, remove and move a track) is also a command, so a
|
||||||
|
// server never needs 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("skip", "Skip the current song (DJ)"),
|
||||||
|
sub("pause", "Pause playback (DJ)"),
|
||||||
|
sub("resume", "Resume playback (DJ)"),
|
||||||
|
sub("stop", "Stop playback and clear the queue (DJ)"),
|
||||||
|
sub("repeat", "Repeat the current song, the whole queue, or nothing (DJ)",
|
||||||
|
&discordgo.ApplicationCommandOption{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString, Name: "mode", Description: "What repeats", Required: true,
|
||||||
|
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "off", Value: "off"}, {Name: "song", Value: "song"}, {Name: "queue", Value: "queue"}},
|
||||||
|
}),
|
||||||
|
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 player subcommand. The caller has checked that music is
|
||||||
|
// on and that the member has the DJ role.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
current, _ := music.GetQueue(g)
|
||||||
|
needsPlayer := sub.Name != "repeat" && sub.Name != "remove" && sub.Name != "move"
|
||||||
|
if needsPlayer && current == nil {
|
||||||
|
shared.RespondEphemeral(s, i, "Nothing is playing.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var msg string
|
||||||
|
switch sub.Name {
|
||||||
|
case "skip":
|
||||||
|
err, msg = music.Skip(g), "Skipped."
|
||||||
|
case "pause":
|
||||||
|
err, msg = music.Pause(g, true), "Paused. `/music resume` continues."
|
||||||
|
case "resume":
|
||||||
|
err, msg = music.Pause(g, false), "Resumed."
|
||||||
|
case "stop":
|
||||||
|
err, msg = music.Stop(g), "Stopped, and the queue is cleared."
|
||||||
|
case "repeat":
|
||||||
|
switch args["mode"].StringValue() {
|
||||||
|
case "song":
|
||||||
|
music.SetRepeat(g, true, false)
|
||||||
|
msg = "The current song repeats."
|
||||||
|
case "queue":
|
||||||
|
music.SetRepeat(g, false, true)
|
||||||
|
msg = "The whole queue repeats."
|
||||||
|
default:
|
||||||
|
music.SetRepeat(g, false, false)
|
||||||
|
msg = "Nothing repeats."
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
"velox-bot/internal/security"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every handler re-checks permissions through the security engine. The
|
||||||
|
// DefaultMemberPermissions below only hide commands from the menu; server
|
||||||
|
// admins can change them, so they are never what keeps anyone out.
|
||||||
|
|
||||||
|
var (
|
||||||
|
manageGuild = int64(discordgo.PermissionManageGuild)
|
||||||
|
manageChannel = int64(discordgo.PermissionManageChannels)
|
||||||
|
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
|
||||||
|
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}
|
||||||
|
chatChannels = []discordgo.ChannelType{
|
||||||
|
discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
|
||||||
|
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice,
|
||||||
|
}
|
||||||
|
opTimeout = 3 * time.Minute
|
||||||
|
genericFailed = "Something went wrong. Check that Velox's role is high enough and has the permissions it needs, then try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
var featureChoices = []*discordgo.ApplicationCommandOptionChoice{
|
||||||
|
{Name: "Join Gate", Value: securityrepo.FeatureJoinGate},
|
||||||
|
{Name: "Raid mode", Value: securityrepo.FeatureAntiRaid},
|
||||||
|
{Name: "Anti-nuke", Value: securityrepo.FeatureAntiNuke},
|
||||||
|
{Name: "Heat (anti-spam)", Value: securityrepo.FeatureHeat},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Security = &discordgo.ApplicationCommand{
|
||||||
|
Name: "security",
|
||||||
|
Description: "Anti-raid and anti-nuke protection",
|
||||||
|
DefaultMemberPermissions: &manageGuild,
|
||||||
|
Contexts: guildOnly,
|
||||||
|
Options: append([]*discordgo.ApplicationCommandOption{
|
||||||
|
{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",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionString, Name: "feature", Description: "Which protection", Required: true, Choices: featureChoices},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "on", Description: "On or off", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "alerts", Description: "Where alerts go and which role they ping (owner or trusted)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Alert channel", Required: true, ChannelTypes: textChannels},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Role to ping for raids and nukes"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantine-role", Description: "Role given to quarantined members (owner or trusted)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Leave empty to use none"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: "trust", Description: "Who is exempt from anti-nuke (owner only)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Trust a user or a role",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Stop trusting a user or a role",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "release", Description: "Release a quarantined member (owner or trusted)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to release", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantined", Description: "List quarantined members"},
|
||||||
|
{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: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
|
||||||
|
}, tuneOptions()...),
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
|
||||||
|
verb := "Lock"
|
||||||
|
if unlock {
|
||||||
|
verb = "Unlock"
|
||||||
|
}
|
||||||
|
joins := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "joins", Description: verb + " joins"}
|
||||||
|
if !unlock {
|
||||||
|
joins.Description = "Kick or ban everyone who joins until unlocked"
|
||||||
|
joins.Options = []*discordgo.ApplicationCommandOption{{
|
||||||
|
Type: discordgo.ApplicationCommandOptionString, Name: "action", Description: "What happens to new members", Required: true,
|
||||||
|
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "Kick", Value: securityrepo.ActionKick}, {Name: "Ban", Value: securityrepo.ActionBan}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "channel", Description: verb + " one channel",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Defaults to this channel", ChannelTypes: chatChannels}},
|
||||||
|
},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "server", Description: verb + " every channel"},
|
||||||
|
joins,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Lockdown = &discordgo.ApplicationCommand{
|
||||||
|
Name: "lockdown", Description: "Stop members from talking, or from joining", DefaultMemberPermissions: &manageChannel,
|
||||||
|
Contexts: guildOnly, Options: lockOptions(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
var Unlock = &discordgo.ApplicationCommand{
|
||||||
|
Name: "unlock", Description: "Undo a lockdown (owner or trusted)", DefaultMemberPermissions: &manageChannel,
|
||||||
|
Contexts: guildOnly, Options: lockOptions(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
|
return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
|
||||||
|
}) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func message(where string, err error) string {
|
||||||
|
var ue security.UserError
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return string(ue)
|
||||||
|
}
|
||||||
|
log.Printf("security command %s: %v", where, err)
|
||||||
|
return genericFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
|
||||||
|
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
|
||||||
|
for _, x := range o {
|
||||||
|
m[x.Name] = x
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func perms(i *discordgo.InteractionCreate) int64 {
|
||||||
|
if i.Member == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return i.Member.Permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
func userID(i *discordgo.InteractionCreate) string {
|
||||||
|
if i.Member == nil || i.Member.User == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return i.Member.User.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func engine(s *discordgo.Session, i *discordgo.InteractionCreate) *security.Engine {
|
||||||
|
e := security.Default()
|
||||||
|
if e == nil || i.GuildID == "" || i.Member == nil {
|
||||||
|
reply(s, i, "Security isn't available right now.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedOrTrusted lets an action through when the member has one of the
|
||||||
|
// permissions, or is the owner or trusted.
|
||||||
|
func allowedOrTrusted(ctx context.Context, e *security.Engine, i *discordgo.InteractionCreate, need int64) bool {
|
||||||
|
if perms(i)&(need|discordgo.PermissionAdministrator) != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return e.RequireTrusted(ctx, i.GuildID, i.Member) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
e := engine(s, i)
|
||||||
|
if e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub := data.Options[0]
|
||||||
|
args := opts(sub.Options)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if handleTune(ctx, s, i, e, sub) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sub.Name {
|
||||||
|
case "status":
|
||||||
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
||||||
|
reply(s, i, "You need Manage Server to see the security settings.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, status(ctx, e, i.GuildID))
|
||||||
|
case "toggle":
|
||||||
|
feature, on := args["feature"].StringValue(), args["on"].BoolValue()
|
||||||
|
if err := e.SetFeature(ctx, i.GuildID, i.Member, perms(i), feature, on); err != nil {
|
||||||
|
reply(s, i, message("toggle", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := "off"
|
||||||
|
if on {
|
||||||
|
state = "on"
|
||||||
|
}
|
||||||
|
reply(s, i, fmt.Sprintf("%s is now **%s**.", featureName(feature), state))
|
||||||
|
case "alerts":
|
||||||
|
ch := args["channel"].ChannelValue(nil).ID
|
||||||
|
role := ""
|
||||||
|
if r, ok := args["role"]; ok {
|
||||||
|
role = r.RoleValue(nil, i.GuildID).ID
|
||||||
|
}
|
||||||
|
if err := e.SetAlerts(ctx, i.GuildID, i.Member, ch, role); err != nil {
|
||||||
|
reply(s, i, message("alerts", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, fmt.Sprintf("Alerts will go to <#%s>.", ch))
|
||||||
|
case "quarantine-role":
|
||||||
|
role := ""
|
||||||
|
if r, ok := args["role"]; ok {
|
||||||
|
role = r.RoleValue(nil, i.GuildID).ID
|
||||||
|
}
|
||||||
|
if err := e.SetQuarantineRole(ctx, i.GuildID, i.Member, role); err != nil {
|
||||||
|
reply(s, i, message("quarantine-role", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
reply(s, i, "Quarantined members won't get a role; they still lose their roles and are timed out.")
|
||||||
|
} else {
|
||||||
|
reply(s, i, fmt.Sprintf("Quarantined members will get <@&%s>.", role))
|
||||||
|
}
|
||||||
|
case "trust":
|
||||||
|
if len(sub.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleTrust(ctx, s, i, e, sub.Options[0])
|
||||||
|
case "release":
|
||||||
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
||||||
|
reply(s, i, message("release", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := args["user"].UserValue(nil)
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := e.Release(ctx, i.GuildID, target.ID, userID(i)); err != nil {
|
||||||
|
edit(s, i, message("release", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, fmt.Sprintf("<@%s> was released and got their roles back.", target.ID))
|
||||||
|
case "quarantined":
|
||||||
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
||||||
|
reply(s, i, "You need Manage Server to see this.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qs, err := e.Quarantines(ctx, i.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
reply(s, i, message("quarantined", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(qs) == 0 {
|
||||||
|
reply(s, i, "Nobody is quarantined.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, q := range qs {
|
||||||
|
fmt.Fprintf(&b, "• <@%d>, <t:%d:R>: %s\n", q.UserID, q.CreatedAt.Unix(), q.Reason)
|
||||||
|
}
|
||||||
|
reply(s, i, clip(b.String(), 1900))
|
||||||
|
case "end-raid":
|
||||||
|
endRaid(ctx, s, i, e)
|
||||||
|
case "snapshot":
|
||||||
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
||||||
|
reply(s, i, "You need Manage Server to take a snapshot.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roles, channels, err := e.TakeSnapshot(ctx, i.GuildID, userID(i))
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("snapshot", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, fmt.Sprintf("Snapshot saved: %d roles and %d channels. The last three are kept.", roles, channels))
|
||||||
|
case "restore":
|
||||||
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
||||||
|
reply(s, i, message("restore", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Recreating many channels takes a while under Discord's rate limits.
|
||||||
|
res, err := e.Restore(ctx, i.GuildID, userID(i))
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("restore", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, fmt.Sprintf("Restored from the snapshot of <t:%d:f>: %d roles and %d channels recreated, %d roles had dangerous permissions reverted, %d steps failed.",
|
||||||
|
res.SnapshotAt.Unix(), res.RolesCreated, res.ChannelsCreated, res.RolesReverted, res.Failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTrust(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, sub *discordgo.ApplicationCommandInteractionDataOption) {
|
||||||
|
args := opts(sub.Options)
|
||||||
|
kind, target := "", ""
|
||||||
|
if u, ok := args["user"]; ok {
|
||||||
|
kind, target = "user", u.UserValue(nil).ID
|
||||||
|
}
|
||||||
|
if r, ok := args["role"]; ok {
|
||||||
|
if target != "" {
|
||||||
|
reply(s, i, "Pick a user or a role, not both.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind, target = "role", r.RoleValue(nil, i.GuildID).ID
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
reply(s, i, "Pick a user or a role.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mention := "<@" + target + ">"
|
||||||
|
if kind == "role" {
|
||||||
|
mention = "<@&" + target + ">"
|
||||||
|
}
|
||||||
|
switch sub.Name {
|
||||||
|
case "add":
|
||||||
|
if err := e.Trust(ctx, i.GuildID, i.Member, kind, target); err != nil {
|
||||||
|
reply(s, i, message("trust add", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, mention+" is now trusted: exempt from anti-nuke and able to change security settings.")
|
||||||
|
case "remove":
|
||||||
|
if err := e.Untrust(ctx, i.GuildID, i.Member, target); err != nil {
|
||||||
|
reply(s, i, message("trust remove", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, mention+" is no longer trusted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endRaid(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine) {
|
||||||
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
||||||
|
reply(s, i, message("end raid", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := e.EndRaid(ctx, i.GuildID, userID(i)); err != nil {
|
||||||
|
edit(s, i, message("end raid", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, "Raid mode is off.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func featureName(f string) string {
|
||||||
|
switch f {
|
||||||
|
case securityrepo.FeatureJoinGate:
|
||||||
|
return "Join Gate"
|
||||||
|
case securityrepo.FeatureAntiRaid:
|
||||||
|
return "Raid mode"
|
||||||
|
case securityrepo.FeatureAntiNuke:
|
||||||
|
return "Anti-nuke"
|
||||||
|
case securityrepo.FeatureHeat:
|
||||||
|
return "Heat"
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func onOff(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "✅ on"
|
||||||
|
}
|
||||||
|
return "⬜ off"
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(ctx context.Context, e *security.Engine, guildID string) string {
|
||||||
|
st, trusted, err := e.Settings(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return message("status", err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "**Join Gate:** %s\n**Raid mode:** %s (%d joins in %ds → %s)\n**Anti-nuke:** %s (auto restore %s)\n**Heat:** %s (%s)\n",
|
||||||
|
onOff(st.JoinGateEnabled), onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction,
|
||||||
|
onOff(st.AntiNukeEnabled), onOff(st.AntiNukeAutoRestore), onOff(st.HeatEnabled), st.HeatStrictness)
|
||||||
|
if e.RaidActive(guildID) {
|
||||||
|
b.WriteString("🚨 **A raid is in progress.**\n")
|
||||||
|
}
|
||||||
|
if st.JoinLock != securityrepo.ActionOff {
|
||||||
|
fmt.Fprintf(&b, "🔒 Joins are locked (%s).\n", st.JoinLock)
|
||||||
|
}
|
||||||
|
if st.AlertChannelID != 0 {
|
||||||
|
fmt.Fprintf(&b, "**Alerts:** <#%d>", st.AlertChannelID)
|
||||||
|
if st.AlertRoleID != 0 {
|
||||||
|
fmt.Fprintf(&b, ", pinging <@&%d>", st.AlertRoleID)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("**Alerts:** not set. Use `/security alerts`.\n")
|
||||||
|
}
|
||||||
|
if owner, err := e.OwnerID(guildID); err == nil {
|
||||||
|
fmt.Fprintf(&b, "**Trusted:** <@%s> (owner)", owner)
|
||||||
|
}
|
||||||
|
for _, t := range trusted {
|
||||||
|
if t.Kind == "role" {
|
||||||
|
fmt.Fprintf(&b, ", <@&%d>", t.TargetID)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, ", <@%d>", t.TargetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\nSee every setting with `/security settings`, and change them with `/security joingate`, `words`, `raid`, `antinuke` and `heat`.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clip(s string, n int) string {
|
||||||
|
if r := []rune(s); len(r) > n {
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
e := engine(s, i)
|
||||||
|
if e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub := data.Options[0]
|
||||||
|
args := opts(sub.Options)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch sub.Name {
|
||||||
|
case "channel":
|
||||||
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageChannels) {
|
||||||
|
reply(s, i, "You need Manage Channels to lock a channel.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := i.ChannelID
|
||||||
|
if c, ok := args["channel"]; ok {
|
||||||
|
ch = c.ChannelValue(nil).ID
|
||||||
|
}
|
||||||
|
if err := e.LockChannel(ctx, i.GuildID, ch, userID(i), false); err != nil {
|
||||||
|
reply(s, i, message("lock channel", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, fmt.Sprintf("🔒 <#%s> is locked. `/unlock channel` puts it back as it was.", ch))
|
||||||
|
case "server":
|
||||||
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
||||||
|
reply(s, i, "You need Manage Server to lock the server.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
locked, failed, err := e.LockServer(ctx, i.GuildID, userID(i))
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("lock server", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("🔒 Locked %d channels.", locked)
|
||||||
|
if failed > 0 {
|
||||||
|
msg += fmt.Sprintf(" %d couldn't be locked (Velox needs Manage Channels there).", failed)
|
||||||
|
}
|
||||||
|
edit(s, i, msg+" `/unlock server` puts them back as they were.")
|
||||||
|
case "joins":
|
||||||
|
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
||||||
|
reply(s, i, "You need Manage Server to lock joins.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := args["action"].StringValue()
|
||||||
|
if err := e.SetJoinLock(ctx, i.GuildID, mode, userID(i)); err != nil {
|
||||||
|
reply(s, i, message("lock joins", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, fmt.Sprintf("🔒 Everyone who joins will be %s until `/unlock joins`.", map[string]string{"kick": "kicked", "ban": "banned"}[mode]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockHandler relaxes protection, so it is owner or trusted only.
|
||||||
|
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
e := engine(s, i)
|
||||||
|
if e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub := data.Options[0]
|
||||||
|
args := opts(sub.Options)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
|
||||||
|
reply(s, i, message("unlock", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sub.Name {
|
||||||
|
case "channel":
|
||||||
|
ch := i.ChannelID
|
||||||
|
if c, ok := args["channel"]; ok {
|
||||||
|
ch = c.ChannelValue(nil).ID
|
||||||
|
}
|
||||||
|
if err := e.UnlockChannel(ctx, i.GuildID, ch, userID(i)); err != nil {
|
||||||
|
reply(s, i, message("unlock channel", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, fmt.Sprintf("🔓 <#%s> is unlocked.", ch))
|
||||||
|
case "server":
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unlocked, failed, err := e.UnlockServer(ctx, i.GuildID, userID(i))
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("unlock server", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("🔓 Unlocked %d channels.", unlocked)
|
||||||
|
if failed > 0 {
|
||||||
|
msg += fmt.Sprintf(" %d failed; try `/unlock server` again.", failed)
|
||||||
|
}
|
||||||
|
edit(s, i, msg)
|
||||||
|
case "joins":
|
||||||
|
if err := e.SetJoinLock(ctx, i.GuildID, securityrepo.ActionOff, userID(i)); err != nil {
|
||||||
|
reply(s, i, message("unlock joins", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, "🔓 Joins are open again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleComponent handles the alert's "End raid" button. Anyone in the alert
|
||||||
|
// channel can press it, so it checks trust like the command.
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
e := engine(s, i)
|
||||||
|
if e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.MessageComponentData().CustomID != security.CustomIDEndRaid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
endRaid(ctx, s, i, e)
|
||||||
|
}
|
||||||
@@ -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,29 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"velox-bot/internal/commands/security/public"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Security *discordgo.ApplicationCommand = public.Security
|
||||||
|
Lockdown *discordgo.ApplicationCommand = public.Lockdown
|
||||||
|
Unlock *discordgo.ApplicationCommand = public.Unlock
|
||||||
|
)
|
||||||
|
|
||||||
|
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.SecurityHandler(s, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.LockdownHandler(s, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.UnlockHandler(s, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.HandleComponent(s, i)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
"velox-bot/internal/tickets"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The session must provide everything the ticket flows call.
|
||||||
|
var _ tickets.Discord = (*discordgo.Session)(nil)
|
||||||
|
|
||||||
|
// Repo is the full ticket store, including the admin calls that only the
|
||||||
|
// slash commands make. Set by Init.
|
||||||
|
var Repo *ticketrepo.Repo
|
||||||
|
|
||||||
|
func Init(r *ticketrepo.Repo) {
|
||||||
|
Repo = r
|
||||||
|
tickets.Init(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
manageGuild = int64(discordgo.PermissionManageGuild)
|
||||||
|
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
|
||||||
|
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText}
|
||||||
|
opTimeout = 2 * time.Minute
|
||||||
|
genericFailed = "Something went wrong. Try again in a moment, and tell an admin if it keeps happening."
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tickets is the admin command for setting the system up.
|
||||||
|
var Tickets = &discordgo.ApplicationCommand{
|
||||||
|
Name: "tickets",
|
||||||
|
Description: "Set up support tickets",
|
||||||
|
DefaultMemberPermissions: &manageGuild,
|
||||||
|
Contexts: guildOnly,
|
||||||
|
Options: append([]*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "setup",
|
||||||
|
Description: "Choose where tickets are opened and where closed tickets are logged",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Where the panel goes and ticket threads are created", Required: true, ChannelTypes: textChannels},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionChannel, Name: "log-channel", Description: "Where closed tickets and their transcripts are posted", ChannelTypes: textChannels},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Creates a Support category handled by this role, if there are no categories yet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommandGroup,
|
||||||
|
Name: "category",
|
||||||
|
Description: "Manage ticket categories",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "add",
|
||||||
|
Description: "Add a category (an option in the panel's dropdown)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button", MaxLength: 64},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel", MaxLength: 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "remove",
|
||||||
|
Description: "Remove a category",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category's name", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
|
||||||
|
categoryEditOption(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||||
|
Name: "panel",
|
||||||
|
Description: "Post the ticket panel, or update it after changing categories",
|
||||||
|
},
|
||||||
|
}, ticketSettingsOptions()...),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket is used inside a ticket thread.
|
||||||
|
var Ticket = &discordgo.ApplicationCommand{
|
||||||
|
Name: "ticket",
|
||||||
|
Description: "Manage the ticket you're in",
|
||||||
|
Contexts: guildOnly,
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Add someone to this ticket",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to add", Required: true}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Remove someone from this ticket (staff only)",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to remove", Required: true}},
|
||||||
|
},
|
||||||
|
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "claim", Description: "Take this ticket (staff only)"},
|
||||||
|
{
|
||||||
|
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close", Description: "Close this ticket",
|
||||||
|
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Why it's being closed", MaxLength: 300}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||||
|
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
|
||||||
|
})
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// message turns an error into what the user sees; unexpected ones are logged.
|
||||||
|
func message(where string, err error) string {
|
||||||
|
var ue tickets.UserError
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return string(ue)
|
||||||
|
}
|
||||||
|
log.Printf("tickets: %s: %v", where, err)
|
||||||
|
return genericFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func actor(i *discordgo.InteractionCreate) tickets.Actor {
|
||||||
|
a := tickets.Actor{Member: i.Member}
|
||||||
|
if i.Member != nil {
|
||||||
|
a.Perms = i.Member.Permissions
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func guildID(i *discordgo.InteractionCreate) int64 {
|
||||||
|
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if Repo == nil {
|
||||||
|
reply(s, i, "Tickets aren't available right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub := data.Options[0]
|
||||||
|
switch sub.Name {
|
||||||
|
case "setup":
|
||||||
|
handleSetup(s, i, sub.Options)
|
||||||
|
case "panel":
|
||||||
|
handlePanel(s, i)
|
||||||
|
case "settings":
|
||||||
|
handleSettings(s, i, sub.Options)
|
||||||
|
case "list":
|
||||||
|
handleList(s, i, sub.Options)
|
||||||
|
case "category":
|
||||||
|
if len(sub.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch sub.Options[0].Name {
|
||||||
|
case "add":
|
||||||
|
handleCategoryAdd(s, i, sub.Options[0].Options)
|
||||||
|
case "remove":
|
||||||
|
handleCategoryRemove(s, i, sub.Options[0].Options)
|
||||||
|
case "list":
|
||||||
|
handleCategoryList(s, i)
|
||||||
|
case "edit":
|
||||||
|
handleCategoryEdit(s, i, sub.Options[0].Options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
|
||||||
|
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
|
||||||
|
for _, x := range o {
|
||||||
|
m[x.Name] = x
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionWarning names what the bot is missing in a channel, or "".
|
||||||
|
func permissionWarning(s *discordgo.Session, channelID string, need int64, what string) string {
|
||||||
|
have, err := s.UserChannelPermissions(s.State.User.ID, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if missing := tickets.MissingPerms(have, need); len(missing) > 0 {
|
||||||
|
return fmt.Sprintf("\n⚠️ I'm missing these permissions in <#%s> (%s): %s.", channelID, what, strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSetup(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := opts(o)
|
||||||
|
g := guildID(i)
|
||||||
|
|
||||||
|
ch := args["channel"].ChannelValue(nil).ID
|
||||||
|
var logID int64
|
||||||
|
logMention := "none"
|
||||||
|
if l, ok := args["log-channel"]; ok {
|
||||||
|
lc := l.ChannelValue(nil).ID
|
||||||
|
logID, _ = strconv.ParseInt(lc, 10, 64)
|
||||||
|
logMention = "<#" + lc + ">"
|
||||||
|
}
|
||||||
|
chID, _ := strconv.ParseInt(ch, 10, 64)
|
||||||
|
if err := Repo.SetChannels(ctx, g, chID, logID); err != nil {
|
||||||
|
reply(s, i, message("setup", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := Repo.ListCategories(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
reply(s, i, message("setup", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created := ""
|
||||||
|
if r, ok := args["staff-role"]; ok && len(cats) == 0 {
|
||||||
|
role := r.RoleValue(nil, i.GuildID)
|
||||||
|
roleID, _ := strconv.ParseInt(role.ID, 10, 64)
|
||||||
|
if _, err := Repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support", Emoji: "🎫", StaffRoleID: roleID}); err != nil {
|
||||||
|
reply(s, i, message("setup", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created = fmt.Sprintf("\nCreated a **Support** category handled by <@&%s>.", role.ID)
|
||||||
|
cats = append(cats, ticketrepo.Category{Name: "Support"})
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Tickets will open as private threads in <#%s>. Closed tickets are logged to %s.%s", ch, logMention, created)
|
||||||
|
msg += permissionWarning(s, ch, tickets.NeedTicketChannel, "ticket channel")
|
||||||
|
if logID != 0 {
|
||||||
|
msg += permissionWarning(s, strconv.FormatInt(logID, 10), tickets.NeedLogChannel, "log channel")
|
||||||
|
}
|
||||||
|
if len(cats) == 0 {
|
||||||
|
msg += "\nNext: add a category with `/tickets category add`, then post the panel with `/tickets panel`."
|
||||||
|
} else {
|
||||||
|
msg += "\nNext: post the panel with `/tickets panel`."
|
||||||
|
}
|
||||||
|
msg += "\nTip: give your staff role **Manage Threads** in that channel so they can see every ticket, even ones they weren't pinged in."
|
||||||
|
reply(s, i, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
msg, err := tickets.PostPanel(ctx, s, Repo, guildID(i))
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("panel", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, fmt.Sprintf("The ticket panel is up: https://discord.com/channels/%s/%s/%s", i.GuildID, msg.ChannelID, msg.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCategoryAdd(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := opts(o)
|
||||||
|
g := guildID(i)
|
||||||
|
|
||||||
|
cats, err := Repo.ListCategories(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
reply(s, i, message("category add", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(args["name"].StringValue())
|
||||||
|
for _, c := range cats {
|
||||||
|
if strings.EqualFold(c.Name, name) {
|
||||||
|
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cats) >= 25 {
|
||||||
|
reply(s, i, "A panel can hold at most 25 categories.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := ticketrepo.Category{GuildID: g, Name: name}
|
||||||
|
if r, ok := args["staff-role"]; ok {
|
||||||
|
c.StaffRoleID, _ = strconv.ParseInt(r.RoleValue(nil, i.GuildID).ID, 10, 64)
|
||||||
|
}
|
||||||
|
if e, ok := args["emoji"]; ok {
|
||||||
|
c.Emoji = strings.TrimSpace(e.StringValue())
|
||||||
|
}
|
||||||
|
if d, ok := args["description"]; ok {
|
||||||
|
c.Description = strings.TrimSpace(d.StringValue())
|
||||||
|
}
|
||||||
|
if _, err := Repo.AddCategory(ctx, c); err != nil {
|
||||||
|
reply(s, i, message("category add", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(s, i, fmt.Sprintf("Added **%s**. Run `/tickets panel` to show it on the panel.", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCategoryRemove(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
name := strings.TrimSpace(opts(o)["name"].StringValue())
|
||||||
|
ok, err := Repo.DeleteCategoryByName(ctx, guildID(i), name)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
reply(s, i, message("category remove", err))
|
||||||
|
case !ok:
|
||||||
|
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
|
||||||
|
default:
|
||||||
|
reply(s, i, fmt.Sprintf("Removed **%s**. Open tickets keep working. Run `/tickets panel` to update the panel.", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCategoryList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cats, err := Repo.ListCategories(ctx, guildID(i))
|
||||||
|
if err != nil {
|
||||||
|
reply(s, i, message("category list", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cats) == 0 {
|
||||||
|
reply(s, i, "No categories yet. Add one with `/tickets category add`.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range cats {
|
||||||
|
line := "• "
|
||||||
|
if c.Emoji != "" {
|
||||||
|
line += c.Emoji + " "
|
||||||
|
}
|
||||||
|
line += "**" + c.Name + "**"
|
||||||
|
if c.StaffRoleID != 0 {
|
||||||
|
line += fmt.Sprintf(" → <@&%d>", c.StaffRoleID)
|
||||||
|
} else {
|
||||||
|
line += " → no staff role (only Manage Threads)"
|
||||||
|
}
|
||||||
|
if c.Description != "" {
|
||||||
|
line += ": " + c.Description
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
reply(s, i, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if Repo == nil {
|
||||||
|
reply(s, i, "Tickets aren't available right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.ApplicationCommandData()
|
||||||
|
if len(data.Options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub := data.Options[0]
|
||||||
|
args := opts(sub.Options)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch sub.Name {
|
||||||
|
case "add", "remove":
|
||||||
|
user := args["user"].UserValue(nil)
|
||||||
|
if user == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if sub.Name == "add" {
|
||||||
|
err = tickets.AddMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
|
||||||
|
} else {
|
||||||
|
err = tickets.RemoveMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("ticket "+sub.Name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, "Done.")
|
||||||
|
case "claim":
|
||||||
|
claim(s, i)
|
||||||
|
case "close":
|
||||||
|
reason := ""
|
||||||
|
if r, ok := args["reason"]; ok {
|
||||||
|
reason = r.StringValue()
|
||||||
|
}
|
||||||
|
closeTicket(s, i, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claim(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := tickets.Claim(ctx, s, Repo, i.ChannelID, actor(i)); err != nil {
|
||||||
|
edit(s, i, message("claim", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, "You're handling this ticket now.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTicket(s *discordgo.Session, i *discordgo.InteractionCreate, reason string) {
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := tickets.Close(ctx, s, Repo, i.ChannelID, actor(i), reason); err != nil {
|
||||||
|
edit(s, i, message("close", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, "Ticket closed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleComponent handles the panel dropdown and Create ticket button and the Claim and
|
||||||
|
// Close buttons in a ticket.
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if Repo == nil {
|
||||||
|
reply(s, i, "Tickets aren't available right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.MessageComponentData()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(data.CustomID, "tickets:open:"):
|
||||||
|
// Panels posted before the dropdown had one button per category.
|
||||||
|
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
|
||||||
|
case data.CustomID == "tickets:select" && len(data.Values) > 0:
|
||||||
|
rememberPick(s, i, data.Values[0])
|
||||||
|
case data.CustomID == "tickets:create":
|
||||||
|
createFromPick(s, i)
|
||||||
|
case data.CustomID == "tickets:claim":
|
||||||
|
claim(s, i)
|
||||||
|
case data.CustomID == "tickets:close":
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := tickets.CheckCanClose(ctx, Repo, i.ChannelID, actor(i)); err != nil {
|
||||||
|
reply(s, i, message("close", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.InteractionRespond(i.Interaction, tickets.CloseForm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberPick stores the dropdown choice for the Create ticket button and
|
||||||
|
// acknowledges it without changing the panel, so the choice stays visible.
|
||||||
|
func rememberPick(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
|
||||||
|
catID, err := strconv.ParseInt(rawCategory, 10, 64)
|
||||||
|
if err != nil || i.Member == nil || i.Member.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
|
tickets.RememberPick(guildID(i), userID, catID)
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseDeferredMessageUpdate})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFromPick(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if i.Member == nil || i.Member.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
|
catID, ok := tickets.PickedCategory(guildID(i), userID)
|
||||||
|
if !ok {
|
||||||
|
reply(s, i, "Pick a category from the menu first, then press **Create ticket**.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showOpenForm(s, i, strconv.FormatInt(catID, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
|
||||||
|
catID, err := strconv.ParseInt(rawCategory, 10, 64)
|
||||||
|
if err != nil || i.Member == nil || i.Member.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||||
|
_, cat, err := tickets.CheckCanOpen(ctx, Repo, guildID(i), userID, catID)
|
||||||
|
if err != nil {
|
||||||
|
reply(s, i, message("open", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.InteractionRespond(i.Interaction, tickets.OpenForm(cat))
|
||||||
|
}
|
||||||
|
|
||||||
|
func field(data discordgo.ModalSubmitInteractionData, name string) string {
|
||||||
|
for _, row := range data.Components {
|
||||||
|
r, ok := row.(*discordgo.ActionsRow)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range r.Components {
|
||||||
|
if in, ok := c.(*discordgo.TextInput); ok && in.CustomID == name {
|
||||||
|
return strings.TrimSpace(in.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleModalSubmit handles the opening and closing forms.
|
||||||
|
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
if Repo == nil {
|
||||||
|
reply(s, i, "Tickets aren't available right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := i.ModalSubmitData()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(data.CustomID, "tickets:openform:"):
|
||||||
|
catID, err := strconv.ParseInt(strings.TrimPrefix(data.CustomID, "tickets:openform:"), 10, 64)
|
||||||
|
if err != nil || i.Member == nil || i.Member.User == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !deferReply(s, i) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||||
|
defer cancel()
|
||||||
|
thread, err := tickets.Open(ctx, s, Repo, tickets.OpenRequest{
|
||||||
|
GuildID: guildID(i), CategoryID: catID, User: i.Member.User,
|
||||||
|
Subject: field(data, "subject"), Details: field(data, "details"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
edit(s, i, message("open", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
edit(s, i, fmt.Sprintf("Your ticket is open: <#%s>. The staff will be with you there.", thread.ID))
|
||||||
|
case data.CustomID == "tickets:closeform":
|
||||||
|
closeTicket(s, i, field(data, "reason"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"velox-bot/internal/commands/tickets/public"
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Tickets *discordgo.ApplicationCommand = public.Tickets
|
||||||
|
Ticket *discordgo.ApplicationCommand = public.Ticket
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init(r *ticketrepo.Repo) { public.Init(r) }
|
||||||
|
|
||||||
|
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.TicketsHandler(s, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TicketHandler(s, i) }
|
||||||
|
|
||||||
|
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.HandleComponent(s, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
|
public.HandleModalSubmit(s, i)
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
// Package securityrepo stores the anti-raid and anti-nuke settings, the
|
||||||
|
// trusted list, quarantines, locked channels, the event history and the
|
||||||
|
// dashboard's requests. Discord IDs are int64; 0 means "not set".
|
||||||
|
package securityrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
|
return &Repo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter actions.
|
||||||
|
const (
|
||||||
|
ActionOff = "off"
|
||||||
|
ActionAlert = "alert" // raid mode only: alert staff, touch nobody
|
||||||
|
ActionTimeout = "timeout"
|
||||||
|
ActionKick = "kick"
|
||||||
|
ActionBan = "ban"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Anti-nuke limit keys.
|
||||||
|
const (
|
||||||
|
LimitChannelDelete = "channel_delete"
|
||||||
|
LimitChannelCreate = "channel_create"
|
||||||
|
LimitRoleDelete = "role_delete"
|
||||||
|
LimitRoleCreate = "role_create"
|
||||||
|
LimitBan = "ban"
|
||||||
|
LimitKick = "kick"
|
||||||
|
LimitWebhookCreate = "webhook_create"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLimits mirrors the column default in the migration.
|
||||||
|
func DefaultLimits() map[string]int {
|
||||||
|
return map[string]int{
|
||||||
|
LimitChannelDelete: 2, LimitChannelCreate: 5, LimitRoleDelete: 2, LimitRoleCreate: 5,
|
||||||
|
LimitBan: 3, LimitKick: 5, LimitWebhookCreate: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
GuildID int64
|
||||||
|
AlertChannelID int64
|
||||||
|
AlertRoleID int64
|
||||||
|
AlertDM bool
|
||||||
|
|
||||||
|
JoinGateEnabled bool
|
||||||
|
NoAvatar string
|
||||||
|
NewAccount string
|
||||||
|
MinAccountAgeDays int
|
||||||
|
AdvertisingName string
|
||||||
|
UsernameFilter string
|
||||||
|
UsernamePatterns []string
|
||||||
|
UnverifiedBot string
|
||||||
|
UnauthorizedBot string
|
||||||
|
AntiRaidEnabled bool
|
||||||
|
RaidJoinCount int
|
||||||
|
RaidJoinSeconds int
|
||||||
|
RaidAction string
|
||||||
|
RaidPauseInvites bool
|
||||||
|
RaidQuietMinutes int
|
||||||
|
AntiNukeEnabled bool
|
||||||
|
AntiNukeWindowSeconds int
|
||||||
|
AntiNukeLimits map[string]int
|
||||||
|
AntiNukeLockServer bool
|
||||||
|
QuarantineRoleID int64
|
||||||
|
AntiNukeAutoRestore bool
|
||||||
|
HeatEnabled bool
|
||||||
|
HeatStrictness string
|
||||||
|
HeatAction string
|
||||||
|
HeatTimeoutMinutes int
|
||||||
|
HeatEscalate bool
|
||||||
|
HeatBlockedWords []string
|
||||||
|
HeatPanicMembers int
|
||||||
|
HeatLockdownMentions int
|
||||||
|
JoinLock string
|
||||||
|
|
||||||
|
// Configured is false when the guild has no row yet; the fields then
|
||||||
|
// hold the defaults, with every system off.
|
||||||
|
Configured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Defaults(guildID int64) *Settings {
|
||||||
|
return &Settings{
|
||||||
|
GuildID: guildID,
|
||||||
|
AlertDM: true,
|
||||||
|
NoAvatar: ActionOff, NewAccount: ActionOff, MinAccountAgeDays: 7, AdvertisingName: ActionOff,
|
||||||
|
UsernameFilter: ActionOff, UnverifiedBot: ActionOff, UnauthorizedBot: ActionKick,
|
||||||
|
RaidJoinCount: 10, RaidJoinSeconds: 10, RaidAction: ActionKick, RaidPauseInvites: true, RaidQuietMinutes: 10,
|
||||||
|
AntiNukeWindowSeconds: 10, AntiNukeLimits: DefaultLimits(),
|
||||||
|
HeatStrictness: "medium", HeatAction: ActionTimeout, HeatTimeoutMinutes: 10, HeatEscalate: true, HeatPanicMembers: 3,
|
||||||
|
JoinLock: ActionOff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullID(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: v != 0} }
|
||||||
|
|
||||||
|
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT alert_channel_id, alert_role_id, alert_dm,
|
||||||
|
joingate_enabled, joingate_no_avatar, joingate_new_account, joingate_min_account_age_days,
|
||||||
|
joingate_advertising_name, joingate_username_filter, to_json(joingate_username_patterns),
|
||||||
|
joingate_unverified_bot, joingate_unauthorized_bot,
|
||||||
|
antiraid_enabled, raid_join_count, raid_join_seconds, raid_action, raid_pause_invites, raid_quiet_minutes,
|
||||||
|
antinuke_enabled, antinuke_window_seconds, antinuke_limits, antinuke_lock_server, quarantine_role_id,
|
||||||
|
antinuke_auto_restore, heat_enabled, heat_strictness, heat_action, heat_timeout_minutes, heat_escalate,
|
||||||
|
to_json(heat_blocked_words), heat_panic_members, heat_lockdown_mentions,
|
||||||
|
join_lock
|
||||||
|
FROM security_settings WHERE guild_id = $1`
|
||||||
|
s := &Settings{GuildID: guildID, Configured: true}
|
||||||
|
var alertCh, alertRole, qRole sql.NullInt64
|
||||||
|
var patterns, words []byte
|
||||||
|
var limits []byte
|
||||||
|
err := r.db.QueryRowContext(ctx, q, guildID).Scan(
|
||||||
|
&alertCh, &alertRole, &s.AlertDM,
|
||||||
|
&s.JoinGateEnabled, &s.NoAvatar, &s.NewAccount, &s.MinAccountAgeDays,
|
||||||
|
&s.AdvertisingName, &s.UsernameFilter, &patterns,
|
||||||
|
&s.UnverifiedBot, &s.UnauthorizedBot,
|
||||||
|
&s.AntiRaidEnabled, &s.RaidJoinCount, &s.RaidJoinSeconds, &s.RaidAction, &s.RaidPauseInvites, &s.RaidQuietMinutes,
|
||||||
|
&s.AntiNukeEnabled, &s.AntiNukeWindowSeconds, &limits, &s.AntiNukeLockServer, &qRole,
|
||||||
|
&s.AntiNukeAutoRestore, &s.HeatEnabled, &s.HeatStrictness, &s.HeatAction, &s.HeatTimeoutMinutes, &s.HeatEscalate,
|
||||||
|
&words, &s.HeatPanicMembers, &s.HeatLockdownMentions,
|
||||||
|
&s.JoinLock,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Defaults(guildID), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.AlertChannelID, s.AlertRoleID, s.QuarantineRoleID = alertCh.Int64, alertRole.Int64, qRole.Int64
|
||||||
|
_ = json.Unmarshal(patterns, &s.UsernamePatterns)
|
||||||
|
_ = json.Unmarshal(words, &s.HeatBlockedWords)
|
||||||
|
s.AntiNukeLimits = DefaultLimits()
|
||||||
|
if len(limits) > 0 {
|
||||||
|
// Saved values override the defaults key by key, so a limit added
|
||||||
|
// in a later version still has a value for older rows.
|
||||||
|
var saved map[string]int
|
||||||
|
if err := json.Unmarshal(limits, &saved); err == nil {
|
||||||
|
for k, v := range saved {
|
||||||
|
s.AntiNukeLimits[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureRow makes sure the guild has a settings row so single-column updates
|
||||||
|
// below can be plain UPDATEs.
|
||||||
|
func (r *Repo) ensureRow(ctx context.Context, guildID int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `INSERT INTO security_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) update(ctx context.Context, guildID int64, set string, args ...any) error {
|
||||||
|
if err := r.ensureRow(ctx, guildID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE security_settings SET `+set+` WHERE guild_id = $1`, append([]any{guildID}, args...)...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature names accepted by SetEnabled.
|
||||||
|
const (
|
||||||
|
FeatureJoinGate = "joingate"
|
||||||
|
FeatureAntiRaid = "antiraid"
|
||||||
|
FeatureAntiNuke = "antinuke"
|
||||||
|
FeatureHeat = "heat"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error {
|
||||||
|
col := map[string]string{FeatureJoinGate: "joingate_enabled", FeatureAntiRaid: "antiraid_enabled", FeatureAntiNuke: "antinuke_enabled", FeatureHeat: "heat_enabled"}[feature]
|
||||||
|
if col == "" {
|
||||||
|
return errors.New("unknown feature " + feature)
|
||||||
|
}
|
||||||
|
return r.update(ctx, guildID, col+` = $2`, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error {
|
||||||
|
return r.update(ctx, guildID, `alert_channel_id = $2, alert_role_id = $3`, nullID(channelID), nullID(roleID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetQuarantineRole(ctx context.Context, guildID, roleID int64) error {
|
||||||
|
return r.update(ctx, guildID, `quarantine_role_id = $2`, nullID(roleID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) error {
|
||||||
|
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 ----
|
||||||
|
|
||||||
|
type Trusted struct {
|
||||||
|
TargetID int64
|
||||||
|
Kind string // "user" or "role"
|
||||||
|
AddedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListTrusted(ctx context.Context, guildID int64) ([]Trusted, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT target_id, kind, added_by FROM security_trusted WHERE guild_id = $1 ORDER BY created_at`, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Trusted
|
||||||
|
for rows.Next() {
|
||||||
|
var t Trusted
|
||||||
|
if err := rows.Scan(&t.TargetID, &t.Kind, &t.AddedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) AddTrusted(ctx context.Context, guildID int64, t Trusted) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_trusted (guild_id, target_id, kind, added_by) VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (guild_id, target_id) DO UPDATE SET kind = EXCLUDED.kind`, guildID, t.TargetID, t.Kind, t.AddedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM security_trusted WHERE guild_id = $1 AND target_id = $2`, guildID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Quarantine ----
|
||||||
|
|
||||||
|
type Quarantine struct {
|
||||||
|
GuildID int64
|
||||||
|
UserID int64
|
||||||
|
PreviousRoleIDs []int64
|
||||||
|
Reason string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveQuarantine records a quarantine. If the member is already quarantined
|
||||||
|
// the first record is kept, since its roles are the ones to give back.
|
||||||
|
func (r *Repo) SaveQuarantine(ctx context.Context, q Quarantine) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_quarantines (guild_id, user_id, previous_role_ids, reason)
|
||||||
|
VALUES ($1, $2, COALESCE($3::bigint[], '{}'), $4)
|
||||||
|
ON CONFLICT (guild_id, user_id) DO NOTHING`, q.GuildID, q.UserID, q.PreviousRoleIDs, q.Reason)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanQuarantine(row interface{ Scan(...any) error }) (Quarantine, error) {
|
||||||
|
var q Quarantine
|
||||||
|
var roles []byte
|
||||||
|
err := row.Scan(&q.GuildID, &q.UserID, &roles, &q.Reason, &q.CreatedAt)
|
||||||
|
if err == nil {
|
||||||
|
err = json.Unmarshal(roles, &q.PreviousRoleIDs)
|
||||||
|
}
|
||||||
|
return q, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuarantine returns nil when the member isn't quarantined.
|
||||||
|
func (r *Repo) GetQuarantine(ctx context.Context, guildID, userID int64) (*Quarantine, error) {
|
||||||
|
q, err := scanQuarantine(r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
|
||||||
|
WHERE guild_id = $1 AND user_id = $2`, guildID, userID))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListQuarantines(ctx context.Context, guildID int64) ([]Quarantine, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
|
||||||
|
WHERE guild_id = $1 ORDER BY created_at DESC`, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Quarantine
|
||||||
|
for rows.Next() {
|
||||||
|
q, err := scanQuarantine(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) DeleteQuarantine(ctx context.Context, guildID, userID int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM security_quarantines WHERE guild_id = $1 AND user_id = $2`, guildID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lockdowns ----
|
||||||
|
|
||||||
|
type Lockdown struct {
|
||||||
|
GuildID int64
|
||||||
|
ChannelID int64
|
||||||
|
HadOverwrite bool
|
||||||
|
PreviousAllow int64
|
||||||
|
PreviousDeny int64
|
||||||
|
ServerWide bool
|
||||||
|
LockedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveLockdown remembers a channel's @everyone overwrite from before it was
|
||||||
|
// locked. A channel that is already locked keeps its first record, which is
|
||||||
|
// the state to go back to.
|
||||||
|
func (r *Repo) SaveLockdown(ctx context.Context, l Lockdown) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_lockdowns (guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (guild_id, channel_id) DO NOTHING`,
|
||||||
|
l.GuildID, l.ChannelID, l.HadOverwrite, l.PreviousAllow, l.PreviousDeny, l.ServerWide, l.LockedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLockdown returns nil when the channel isn't locked by the bot.
|
||||||
|
func (r *Repo) GetLockdown(ctx context.Context, guildID, channelID int64) (*Lockdown, error) {
|
||||||
|
var l Lockdown
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
|
||||||
|
FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID).
|
||||||
|
Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLockdowns lists locked channels; serverWideOnly limits it to the ones a
|
||||||
|
// server lockdown locked.
|
||||||
|
func (r *Repo) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]Lockdown, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
|
||||||
|
FROM security_lockdowns WHERE guild_id = $1 AND (NOT $2 OR server_wide) ORDER BY locked_at`, guildID, serverWideOnly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Lockdown
|
||||||
|
for rows.Next() {
|
||||||
|
var l Lockdown
|
||||||
|
if err := rows.Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Events ----
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
GuildID int64
|
||||||
|
Kind string
|
||||||
|
ActorID int64
|
||||||
|
TargetID int64
|
||||||
|
Detail string
|
||||||
|
CreatedAt time.Time // set when read
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event kinds.
|
||||||
|
const (
|
||||||
|
EventJoinGate = "joingate"
|
||||||
|
EventRaidStarted = "raid_started"
|
||||||
|
EventRaidEnded = "raid_ended"
|
||||||
|
EventNuke = "nuke"
|
||||||
|
EventQuarantined = "quarantined"
|
||||||
|
EventReleased = "released"
|
||||||
|
EventUnauthorizedBot = "unauthorized_bot"
|
||||||
|
EventLocked = "locked"
|
||||||
|
EventUnlocked = "unlocked"
|
||||||
|
EventHeat = "heat"
|
||||||
|
EventHeatPanic = "heat_panic"
|
||||||
|
EventSnapshot = "snapshot"
|
||||||
|
EventRestored = "restored"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Repo) AddEvent(ctx context.Context, e Event) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_events (guild_id, kind, actor_id, target_id, detail) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
e.GuildID, e.Kind, nullID(e.ActorID), nullID(e.TargetID), e.Detail)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dashboard commands ----
|
||||||
|
|
||||||
|
type Command struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
CommandType string
|
||||||
|
Payload []byte
|
||||||
|
RequestedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, guild_id, command_type, COALESCE(payload, '{}'::jsonb), requested_by
|
||||||
|
FROM security_commands WHERE processed_at IS NULL ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Command
|
||||||
|
for rows.Next() {
|
||||||
|
var c Command
|
||||||
|
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.Payload, &c.RequestedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE security_commands SET processed_at = NOW() WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Raids in progress ----
|
||||||
|
|
||||||
|
type Raid struct {
|
||||||
|
GuildID int64
|
||||||
|
StartedAt time.Time
|
||||||
|
LastJoinAt time.Time
|
||||||
|
InvitesPaused bool
|
||||||
|
CaughtTotal int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SaveRaid(ctx context.Context, x Raid) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_raids (guild_id, started_at, last_join_at, invites_paused, caught_total)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (guild_id) DO UPDATE SET
|
||||||
|
last_join_at = EXCLUDED.last_join_at, invites_paused = EXCLUDED.invites_paused, caught_total = EXCLUDED.caught_total`,
|
||||||
|
x.GuildID, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListRaids(ctx context.Context) ([]Raid, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT guild_id, started_at, last_join_at, invites_paused, caught_total FROM security_raids`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Raid
|
||||||
|
for rows.Next() {
|
||||||
|
var x Raid
|
||||||
|
if err := rows.Scan(&x.GuildID, &x.StartedAt, &x.LastJoinAt, &x.InvitesPaused, &x.CaughtTotal); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRaid ends a raid's record and its caught list.
|
||||||
|
func (r *Repo) DeleteRaid(ctx context.Context, guildID int64) error {
|
||||||
|
if _, err := r.db.ExecContext(ctx, `DELETE FROM security_raid_caught WHERE guild_id = $1`, guildID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM security_raids WHERE guild_id = $1`, guildID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Caught struct {
|
||||||
|
UserID int64
|
||||||
|
Action string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) AddRaidCaught(ctx context.Context, guildID int64, c Caught) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_raid_caught (guild_id, user_id, action, name) VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (guild_id, user_id) DO NOTHING`, guildID, c.UserID, c.Action, c.Name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]Caught, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT user_id, action, name FROM security_raid_caught WHERE guild_id = $1 ORDER BY caught_at LIMIT $2`, guildID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Caught
|
||||||
|
for rows.Next() {
|
||||||
|
var c Caught
|
||||||
|
if err := rows.Scan(&c.UserID, &c.Action, &c.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Snapshots ----
|
||||||
|
|
||||||
|
const keepSnapshots = 3
|
||||||
|
|
||||||
|
type Snapshot struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
Data []byte
|
||||||
|
Roles int
|
||||||
|
Channels int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSnapshot stores a snapshot and drops all but the newest three.
|
||||||
|
func (r *Repo) SaveSnapshot(ctx context.Context, s Snapshot) error {
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO security_snapshots (guild_id, data, roles, channels) VALUES ($1, $2, $3, $4)`,
|
||||||
|
s.GuildID, s.Data, s.Roles, s.Channels); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM security_snapshots WHERE guild_id = $1 AND id NOT IN (
|
||||||
|
SELECT id FROM security_snapshots WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2)`,
|
||||||
|
s.GuildID, keepSnapshots)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestSnapshot returns nil when the guild has none.
|
||||||
|
func (r *Repo) LatestSnapshot(ctx context.Context, guildID int64) (*Snapshot, error) {
|
||||||
|
var s Snapshot
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, guild_id, data, roles, channels, created_at FROM security_snapshots
|
||||||
|
WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, guildID).
|
||||||
|
Scan(&s.ID, &s.GuildID, &s.Data, &s.Roles, &s.Channels, &s.CreatedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuildsDueForSnapshot lists guilds with anti-nuke on whose newest snapshot
|
||||||
|
// is older than maxAge (or that have none).
|
||||||
|
func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT s.guild_id FROM security_settings s
|
||||||
|
WHERE s.antinuke_enabled AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM security_snapshots p WHERE p.guild_id = s.guild_id AND p.created_at > NOW() - make_interval(secs => $1))`,
|
||||||
|
maxAge.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var g int64
|
||||||
|
if err := rows.Scan(&g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Runs against a real Postgres with the dashboard API's migrations applied:
|
||||||
|
//
|
||||||
|
// SECURITY_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/securityrepo
|
||||||
|
package securityrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRepo(t *testing.T) (*Repo, int64) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("SECURITY_TEST_DB")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("SECURITY_TEST_DB not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
const g = 777
|
||||||
|
for _, tbl := range []string{"security_settings", "security_trusted", "security_quarantines", "security_lockdowns", "security_events", "security_commands"} {
|
||||||
|
if _, err := db.Exec(`DELETE FROM `+tbl+` WHERE guild_id = $1`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewRepo(db), g
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettings(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := r.GetSettings(ctx, g)
|
||||||
|
if err != nil || s.Configured || s.JoinGateEnabled || s.AntiNukeEnabled || s.UnauthorizedBot != ActionKick || s.AntiNukeLimits[LimitChannelDelete] != 2 || !s.AlertDM {
|
||||||
|
t.Fatalf("defaults wrong: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
for _, f := range []string{FeatureJoinGate, FeatureAntiRaid, FeatureAntiNuke} {
|
||||||
|
if err := r.SetEnabled(ctx, g, f, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.SetEnabled(ctx, g, "drop table", true); err == nil {
|
||||||
|
t.Error("unknown features must be refused, never put into SQL")
|
||||||
|
}
|
||||||
|
_ = r.SetAlerts(ctx, g, 10, 20)
|
||||||
|
if _, err := r.db.Exec(`UPDATE security_settings SET alert_dm = false WHERE guild_id = $1`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = r.SetQuarantineRole(ctx, g, 30)
|
||||||
|
_ = r.SetJoinLock(ctx, g, ActionBan)
|
||||||
|
if _, err := r.db.Exec(`UPDATE security_settings SET joingate_username_patterns = '{nitro,free}', antinuke_limits = '{"ban": 0}' WHERE guild_id = $1`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err = r.GetSettings(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.Configured || s.AlertDM || !s.JoinGateEnabled || !s.AntiRaidEnabled || !s.AntiNukeEnabled || s.AlertChannelID != 10 || s.AlertRoleID != 20 || s.QuarantineRoleID != 30 || s.JoinLock != ActionBan {
|
||||||
|
t.Errorf("saved settings wrong: %+v", s)
|
||||||
|
}
|
||||||
|
if len(s.UsernamePatterns) != 2 || s.AntiNukeLimits[LimitBan] != 0 || s.AntiNukeLimits[LimitKick] != 5 {
|
||||||
|
t.Errorf("patterns or limits wrong (saved limits override defaults key by key): %+v %+v", s.UsernamePatterns, s.AntiNukeLimits)
|
||||||
|
}
|
||||||
|
if err := r.SetJoinLock(ctx, g, "everyone"); err == nil {
|
||||||
|
t.Error("the database must refuse unknown join lock modes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedQuarantineLockdown(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_ = r.AddTrusted(ctx, g, Trusted{TargetID: 5, Kind: "user", AddedBy: 1})
|
||||||
|
_ = r.AddTrusted(ctx, g, Trusted{TargetID: 6, Kind: "role", AddedBy: 1})
|
||||||
|
if err := r.AddTrusted(ctx, g, Trusted{TargetID: 7, Kind: "everyone", AddedBy: 1}); err == nil {
|
||||||
|
t.Error("unknown kinds must be refused")
|
||||||
|
}
|
||||||
|
list, _ := r.ListTrusted(ctx, g)
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("trusted = %+v", list)
|
||||||
|
}
|
||||||
|
if ok, _ := r.RemoveTrusted(ctx, g, 5); !ok {
|
||||||
|
t.Error("remove should report true")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: []int64{1, 2}, Reason: "first"})
|
||||||
|
_ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: nil, Reason: "second"})
|
||||||
|
q, _ := r.GetQuarantine(ctx, g, 9)
|
||||||
|
if q == nil || len(q.PreviousRoleIDs) != 2 || q.Reason != "first" {
|
||||||
|
t.Errorf("the first quarantine record must be kept: %+v", q)
|
||||||
|
}
|
||||||
|
qs, _ := r.ListQuarantines(ctx, g)
|
||||||
|
if len(qs) != 1 {
|
||||||
|
t.Errorf("list = %+v", qs)
|
||||||
|
}
|
||||||
|
_ = r.DeleteQuarantine(ctx, g, 9)
|
||||||
|
if q, _ := r.GetQuarantine(ctx, g, 9); q != nil {
|
||||||
|
t.Error("deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: true, PreviousAllow: 1, PreviousDeny: 2, ServerWide: true, LockedBy: 1})
|
||||||
|
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: false, PreviousAllow: 9, PreviousDeny: 9, LockedBy: 2})
|
||||||
|
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 51, LockedBy: 1})
|
||||||
|
l, _ := r.GetLockdown(ctx, g, 50)
|
||||||
|
if l == nil || !l.HadOverwrite || l.PreviousAllow != 1 || !l.ServerWide {
|
||||||
|
t.Errorf("the first lockdown record must be kept: %+v", l)
|
||||||
|
}
|
||||||
|
if all, _ := r.ListLockdowns(ctx, g, false); len(all) != 2 {
|
||||||
|
t.Errorf("all = %+v", all)
|
||||||
|
}
|
||||||
|
if server, _ := r.ListLockdowns(ctx, g, true); len(server) != 1 {
|
||||||
|
t.Errorf("server-wide = %+v", server)
|
||||||
|
}
|
||||||
|
_ = r.DeleteLockdown(ctx, g, 50)
|
||||||
|
if l, _ := r.GetLockdown(ctx, g, 50); l != nil {
|
||||||
|
t.Error("deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.AddEvent(ctx, Event{GuildID: g, Kind: EventNuke, ActorID: 3, Detail: "x"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, payload, requested_by) VALUES ($1, 'release_quarantine', '{"user_id":"9"}', 1)`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, requested_by) VALUES ($1, 'grant_admin', 1)`, g); err == nil {
|
||||||
|
t.Error("unknown commands must be refused")
|
||||||
|
}
|
||||||
|
cmds, _ := r.ListUnprocessedCommands(ctx)
|
||||||
|
var mine *Command
|
||||||
|
for i := range cmds {
|
||||||
|
if cmds[i].GuildID == g {
|
||||||
|
mine = &cmds[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mine == nil || string(mine.Payload) != `{"user_id": "9"}` {
|
||||||
|
t.Fatalf("command = %+v", mine)
|
||||||
|
}
|
||||||
|
_ = r.MarkCommandProcessed(ctx, mine.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRaidsSnapshotsAndHeatSettings(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := r.db.Exec(`DELETE FROM security_raids WHERE guild_id = $1`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = r.db.Exec(`DELETE FROM security_raid_caught WHERE guild_id = $1`, g)
|
||||||
|
_, _ = r.db.Exec(`DELETE FROM security_snapshots WHERE guild_id = $1`, g)
|
||||||
|
|
||||||
|
s, _ := r.GetSettings(ctx, g)
|
||||||
|
if s.HeatEnabled || s.HeatStrictness != "medium" || s.HeatTimeoutMinutes != 10 || !s.HeatEscalate || s.HeatPanicMembers != 3 {
|
||||||
|
t.Errorf("heat defaults wrong: %+v", s)
|
||||||
|
}
|
||||||
|
_ = r.SetEnabled(ctx, g, FeatureHeat, true)
|
||||||
|
if _, err := r.db.Exec(`UPDATE security_settings SET heat_blocked_words = '{scam}', antinuke_auto_restore = true WHERE guild_id = $1`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _ = r.GetSettings(ctx, g)
|
||||||
|
if !s.HeatEnabled || !s.AntiNukeAutoRestore || len(s.HeatBlockedWords) != 1 {
|
||||||
|
t.Errorf("heat settings not read back: %+v", s)
|
||||||
|
}
|
||||||
|
if _, err := r.db.Exec(`UPDATE security_settings SET heat_strictness = 'insane' WHERE guild_id = $1`, g); err == nil {
|
||||||
|
t.Error("unknown strictness must be refused by the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now().Add(-time.Minute).UTC().Truncate(time.Millisecond)
|
||||||
|
_ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: start, LastJoinAt: start, InvitesPaused: true, CaughtTotal: 1})
|
||||||
|
_ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: time.Now(), LastJoinAt: start.Add(time.Second), InvitesPaused: true, CaughtTotal: 2})
|
||||||
|
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "kick", Name: "a"})
|
||||||
|
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "ban", Name: "a"})
|
||||||
|
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 2, Action: "kick", Name: "b"})
|
||||||
|
raids, _ := r.ListRaids(ctx)
|
||||||
|
var mine *Raid
|
||||||
|
for i := range raids {
|
||||||
|
if raids[i].GuildID == g {
|
||||||
|
mine = &raids[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mine == nil || !mine.StartedAt.Equal(start) || mine.CaughtTotal != 2 {
|
||||||
|
t.Fatalf("raid = %+v (the start time is kept on update)", mine)
|
||||||
|
}
|
||||||
|
caught, _ := r.ListRaidCaught(ctx, g, 10)
|
||||||
|
if len(caught) != 2 || caught[0].Action != "kick" {
|
||||||
|
t.Errorf("caught = %+v (one row per raider)", caught)
|
||||||
|
}
|
||||||
|
_ = r.DeleteRaid(ctx, g)
|
||||||
|
if c, _ := r.ListRaidCaught(ctx, g, 10); len(c) != 0 {
|
||||||
|
t.Error("deleting a raid clears its list")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if err := r.SaveSnapshot(ctx, Snapshot{GuildID: g, Data: []byte(`{"roles":[],"channels":[]}`), Roles: i}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_ = r.db.QueryRow(`SELECT COUNT(*) FROM security_snapshots WHERE guild_id = $1`, g).Scan(&n)
|
||||||
|
if n != 3 {
|
||||||
|
t.Errorf("only the last three snapshots are kept, have %d", n)
|
||||||
|
}
|
||||||
|
latest, _ := r.LatestSnapshot(ctx, g)
|
||||||
|
if latest == nil || latest.Roles != 4 {
|
||||||
|
t.Errorf("latest = %+v", latest)
|
||||||
|
}
|
||||||
|
_ = r.SetEnabled(ctx, g, FeatureAntiNuke, true)
|
||||||
|
due, _ := r.GuildsDueForSnapshot(ctx, time.Hour)
|
||||||
|
for _, d := range due {
|
||||||
|
if d == g {
|
||||||
|
t.Error("a guild with a fresh snapshot is not due")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
due, _ = r.GuildsDueForSnapshot(ctx, 0)
|
||||||
|
found := false
|
||||||
|
for _, d := range due {
|
||||||
|
found = found || d == g
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Runs against a real Postgres with the dashboard API's migrations applied
|
||||||
|
// (the lobby limit is a trigger from migration 000020):
|
||||||
|
//
|
||||||
|
// TEST_DB=postgres://... go test -tags integration ./internal/db/repos/settingsrepo
|
||||||
|
package settingsrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRepo(t *testing.T) (*Repo, int64) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_DB")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DB not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
const guild = 4242
|
||||||
|
if _, err := db.Exec(`DELETE FROM meeting_lobbies WHERE guild_id IN ($1, $2)`, guild, guild+1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return NewRepo(db), guild
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeetingLobbies(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if ids, err := r.ListMeetingLobbies(ctx, g); err != nil || len(ids) != 0 {
|
||||||
|
t.Fatalf("a fresh server has no lobbies: %v %v", ids, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ch := int64(1); ch <= MaxMeetingLobbies; ch++ {
|
||||||
|
added, err := r.AddMeetingLobby(ctx, g, ch)
|
||||||
|
if err != nil || !added {
|
||||||
|
t.Fatalf("adding lobby %d: added=%v err=%v", ch, added, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := r.AddMeetingLobby(ctx, g, 99); !errors.Is(err, ErrMeetingLobbyLimit) {
|
||||||
|
t.Errorf("an 11th lobby must hit the limit, got %v", err)
|
||||||
|
}
|
||||||
|
if added, err := r.AddMeetingLobby(ctx, g, 3); err != nil || added {
|
||||||
|
t.Errorf("adding an existing lobby is a no-op even at the limit: added=%v err=%v", added, err)
|
||||||
|
}
|
||||||
|
if added, err := r.AddMeetingLobby(ctx, g+1, 99); err != nil || !added {
|
||||||
|
t.Errorf("the limit is per server: added=%v err=%v", added, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, _ := r.IsMeetingLobby(ctx, g, 7); !ok {
|
||||||
|
t.Error("7 is a lobby")
|
||||||
|
}
|
||||||
|
if ok, _ := r.IsMeetingLobby(ctx, g, 99); ok {
|
||||||
|
t.Error("99 isn't a lobby of this server (it is one of the other server's)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if removed, err := r.RemoveMeetingLobby(ctx, g, 7); err != nil || !removed {
|
||||||
|
t.Errorf("removing 7: removed=%v err=%v", removed, err)
|
||||||
|
}
|
||||||
|
if removed, _ := r.RemoveMeetingLobby(ctx, g, 7); removed {
|
||||||
|
t.Error("7 was already removed")
|
||||||
|
}
|
||||||
|
if ok, _ := r.IsMeetingLobby(ctx, g, 7); ok {
|
||||||
|
t.Error("7 is no longer a lobby")
|
||||||
|
}
|
||||||
|
if added, err := r.AddMeetingLobby(ctx, g, 99); err != nil || !added {
|
||||||
|
t.Errorf("removing one makes room: added=%v err=%v", added, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := r.ListMeetingLobbies(ctx, g)
|
||||||
|
if err != nil || len(ids) != MaxMeetingLobbies || ids[len(ids)-1] != 99 {
|
||||||
|
t.Errorf("oldest first, newest last: %v %v", ids, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two people adding a lobby at the same moment must not both fit under the limit.
|
||||||
|
func TestMeetingLobbiesConcurrent(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for ch := int64(1); ch < MaxMeetingLobbies; ch++ {
|
||||||
|
if _, err := r.AddMeetingLobby(ctx, g, ch); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
succeeded := 0
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(ch int64) {
|
||||||
|
defer wg.Done()
|
||||||
|
if added, err := r.AddMeetingLobby(ctx, g, ch); err == nil && added {
|
||||||
|
mu.Lock()
|
||||||
|
succeeded++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}(int64(100 + i))
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if succeeded != 1 {
|
||||||
|
t.Errorf("exactly one of the concurrent adds fits, %d did", succeeded)
|
||||||
|
}
|
||||||
|
if ids, _ := r.ListMeetingLobbies(ctx, g); len(ids) != MaxMeetingLobbies {
|
||||||
|
t.Errorf("have %d lobbies, want %d", len(ids), MaxMeetingLobbies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBetaSettings(t *testing.T) {
|
||||||
|
r, _ := testRepo(t)
|
||||||
|
db := r.db
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_settings (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), guild_id BIGINT, role_id BIGINT, updated_by BIGINT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wasG, wasR sql.NullInt64
|
||||||
|
hadRow := db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if hadRow {
|
||||||
|
_, _ = db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR)
|
||||||
|
} else {
|
||||||
|
_, _ = db.ExecContext(ctx, `DELETE FROM beta_settings`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `DELETE FROM beta_settings`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g, role, err := r.GetBetaSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g != "" {
|
||||||
|
t.Fatalf("g = %q, want empty", g)
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
t.Fatalf("role = %q, want empty", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 700000000000000001)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g, role, err = r.GetBetaSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g != "700000000000000001" {
|
||||||
|
t.Fatalf("g = %q, want %q", g, "700000000000000001")
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
t.Fatalf("role = %q, want empty", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, `UPDATE beta_settings SET role_id = 42`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, role, err = r.GetBetaSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if role != "42" {
|
||||||
|
t.Fatalf("role = %q, want %q", role, "42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListBetaGuilds(t *testing.T) {
|
||||||
|
r, _ := testRepo(t)
|
||||||
|
db := r.db
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_guilds (
|
||||||
|
guild_id BIGINT PRIMARY KEY, enabled_by BIGINT NOT NULL, enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const g = 8800000000000004242
|
||||||
|
_, _ = db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, g)
|
||||||
|
t.Cleanup(func() { _, _ = db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, g) })
|
||||||
|
|
||||||
|
has := func() bool {
|
||||||
|
ids, err := r.ListBetaGuilds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == "8800000000000004242" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if has() {
|
||||||
|
t.Fatal("not listed before opting in")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO beta_guilds (guild_id, enabled_by) VALUES ($1, 1)`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !has() {
|
||||||
|
t.Fatal("listed after opting in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetaRegistered(t *testing.T) {
|
||||||
|
r, _ := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := r.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_registered (guild_id BIGINT PRIMARY KEY)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const g = "8800000000000005151"
|
||||||
|
_ = r.SetBetaRegistered(ctx, g, false)
|
||||||
|
t.Cleanup(func() { _ = r.SetBetaRegistered(ctx, g, false) })
|
||||||
|
|
||||||
|
has := func() bool {
|
||||||
|
ids, err := r.ListBetaRegistered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == g {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if has() {
|
||||||
|
t.Fatal("not remembered at first")
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ { // twice: harmless
|
||||||
|
if err := r.SetBetaRegistered(ctx, g, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has() {
|
||||||
|
t.Fatal("remembered after registering")
|
||||||
|
}
|
||||||
|
if err := r.SetBetaRegistered(ctx, g, false); err != nil || has() {
|
||||||
|
t.Fatalf("forgotten after removing: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.SetBetaRegistered(ctx, "not-a-number", true); err == nil {
|
||||||
|
t.Error("a bad ID is refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ package settingsrepo
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
@@ -25,36 +29,75 @@ func NewRepo(db *sql.DB) *Repo {
|
|||||||
return &Repo{db: db}
|
return &Repo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error {
|
// MaxMeetingLobbies is how many lobby channels one server can have. The
|
||||||
|
// database enforces it too (a trigger from the API's migration 000020), so
|
||||||
|
// the command and the dashboard can't disagree.
|
||||||
|
const MaxMeetingLobbies = 10
|
||||||
|
|
||||||
|
// ErrMeetingLobbyLimit means the server already has MaxMeetingLobbies.
|
||||||
|
var ErrMeetingLobbyLimit = errors.New("meeting lobby limit reached")
|
||||||
|
|
||||||
|
// pgLimitExceeded is the error code the database raises for one lobby too many.
|
||||||
|
const pgLimitExceeded = "54000"
|
||||||
|
|
||||||
|
// AddMeetingLobby makes channelID a lobby. added is false when it already
|
||||||
|
// was one; going over the limit is ErrMeetingLobbyLimit.
|
||||||
|
func (r *Repo) AddMeetingLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO meeting_settings (guild_id, lobby_channel_id)
|
INSERT INTO meeting_lobbies (guild_id, channel_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
ON CONFLICT (guild_id)
|
ON CONFLICT DO NOTHING
|
||||||
DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id
|
|
||||||
`
|
`
|
||||||
_, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID)
|
res, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||||
return err
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == pgLimitExceeded {
|
||||||
|
return false, ErrMeetingLobbyLimit
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) {
|
// RemoveMeetingLobby takes a channel off the lobby list; removed is false
|
||||||
const q = `
|
// when it wasn't one.
|
||||||
SELECT lobby_channel_id
|
func (r *Repo) RemoveMeetingLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
|
||||||
FROM meeting_settings
|
const q = `DELETE FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2`
|
||||||
WHERE guild_id = $1
|
res, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||||
`
|
if err != nil {
|
||||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
return false, err
|
||||||
var ch sql.NullInt64
|
|
||||||
switch err := row.Scan(&ch); err {
|
|
||||||
case nil:
|
|
||||||
if !ch.Valid {
|
|
||||||
return 0, false, nil
|
|
||||||
}
|
}
|
||||||
return ch.Int64, true, nil
|
n, err := res.RowsAffected()
|
||||||
case sql.ErrNoRows:
|
return n > 0, err
|
||||||
return 0, false, nil
|
|
||||||
default:
|
|
||||||
return 0, false, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListMeetingLobbies returns a server's lobbies, oldest first.
|
||||||
|
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||||
|
const q = `SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at, channel_id`
|
||||||
|
rows, err := r.db.QueryContext(ctx, q, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMeetingLobby reports whether channelID is one of the server's lobbies.
|
||||||
|
// It runs whenever someone joins a voice channel, so it's one indexed lookup.
|
||||||
|
func (r *Repo) IsMeetingLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||||
|
const q = `SELECT EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2)`
|
||||||
|
var ok bool
|
||||||
|
err := r.db.QueryRowContext(ctx, q, guildID, channelID).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
||||||
@@ -359,3 +402,116 @@ func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool)
|
|||||||
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBetaSettings returns the beta server and Beta Tester role as set on the
|
||||||
|
// dashboard. Either is "" when not set (or when there's no row yet).
|
||||||
|
func (r *Repo) GetBetaSettings(ctx context.Context) (guildID, roleID string, err error) {
|
||||||
|
var g, role sql.NullInt64
|
||||||
|
err = r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id = TRUE`).Scan(&g, &role)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if g.Valid {
|
||||||
|
guildID = strconv.FormatInt(g.Int64, 10)
|
||||||
|
}
|
||||||
|
if role.Valid {
|
||||||
|
roleID = strconv.FormatInt(role.Int64, 10)
|
||||||
|
}
|
||||||
|
return guildID, roleID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBetaGuilds returns the servers whose owner opted in to beta features
|
||||||
|
// (set on the dashboard), as ID strings.
|
||||||
|
func (r *Repo) ListBetaGuilds(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_guilds ORDER BY enabled_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBetaRegistered returns the servers where the bot registered beta
|
||||||
|
// commands, so it can clean up after itself even if a server opted out while
|
||||||
|
// it was offline.
|
||||||
|
func (r *Repo) ListBetaRegistered(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_registered`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBetaRegistered records (or forgets) that beta commands exist in a server.
|
||||||
|
func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered bool) error {
|
||||||
|
id, err := strconv.ParseInt(guildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if registered {
|
||||||
|
_, err = r.db.ExecContext(ctx, `INSERT INTO beta_registered (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, id)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecContext(ctx, `DELETE FROM beta_registered WHERE guild_id = $1`, id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// Package ticketrepo stores the ticket system's settings, categories, tickets
|
||||||
|
// and the dashboard's requests to the bot. Discord IDs are int64; 0 means
|
||||||
|
// "not set" for optional ones.
|
||||||
|
package ticketrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
|
return &Repo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultPanelTitle = "Need help?"
|
||||||
|
DefaultPanelDescription = "Pick a category below to open a private ticket with the staff team."
|
||||||
|
DefaultMaxOpenPerUser = 1
|
||||||
|
|
||||||
|
StatusOpen = "open"
|
||||||
|
StatusClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
GuildID int64
|
||||||
|
Enabled bool
|
||||||
|
ChannelID int64
|
||||||
|
LogChannelID int64
|
||||||
|
PanelMessageID int64
|
||||||
|
PanelTitle string
|
||||||
|
PanelDescription string
|
||||||
|
MaxOpenPerUser int
|
||||||
|
// Configured is false when the guild has no settings row yet; the other
|
||||||
|
// fields then hold the defaults.
|
||||||
|
Configured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
Name string
|
||||||
|
Emoji string
|
||||||
|
Description string
|
||||||
|
StaffRoleID int64
|
||||||
|
Position int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ticket struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
Number int
|
||||||
|
CategoryID int64
|
||||||
|
CategoryName string
|
||||||
|
ThreadID int64
|
||||||
|
IntroMessage int64
|
||||||
|
OpenerID int64
|
||||||
|
Subject string
|
||||||
|
Status string
|
||||||
|
ClaimedBy int64
|
||||||
|
ClosedBy int64
|
||||||
|
CloseReason string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ClosedAt time.Time // zero while open
|
||||||
|
}
|
||||||
|
|
||||||
|
type Command struct {
|
||||||
|
ID int64
|
||||||
|
GuildID int64
|
||||||
|
CommandType string
|
||||||
|
RequestedBy int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullID(v int64) sql.NullInt64 {
|
||||||
|
return sql.NullInt64{Int64: v, Valid: v != 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullText(v string) sql.NullString {
|
||||||
|
return sql.NullString{String: v, Valid: v != ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT enabled, channel_id, log_channel_id, panel_message_id, panel_title, panel_description, max_open_per_user
|
||||||
|
FROM ticket_settings WHERE guild_id = $1`
|
||||||
|
var ch, logCh, panel sql.NullInt64
|
||||||
|
s := &Settings{GuildID: guildID}
|
||||||
|
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
// Modules start off until the server sets them up.
|
||||||
|
return &Settings{
|
||||||
|
GuildID: guildID,
|
||||||
|
Enabled: false,
|
||||||
|
PanelTitle: DefaultPanelTitle,
|
||||||
|
PanelDescription: DefaultPanelDescription,
|
||||||
|
MaxOpenPerUser: DefaultMaxOpenPerUser,
|
||||||
|
}, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ChannelID, s.LogChannelID, s.PanelMessageID = ch.Int64, logCh.Int64, panel.Int64
|
||||||
|
s.Configured = true
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannels saves where tickets live and where transcripts go. A zero log
|
||||||
|
// channel clears it. Setting tickets up for the first time turns them on
|
||||||
|
// (they start off); after that, the on/off switch is left alone.
|
||||||
|
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id, enabled)
|
||||||
|
VALUES ($1, $2, $3, TRUE)
|
||||||
|
ON CONFLICT (guild_id) DO UPDATE SET
|
||||||
|
channel_id = EXCLUDED.channel_id,
|
||||||
|
log_channel_id = EXCLUDED.log_channel_id`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, nullID(channelID), nullID(logChannelID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) SetPanelMessage(ctx context.Context, guildID, messageID int64) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO ticket_settings (guild_id, panel_message_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (guild_id) DO UPDATE SET panel_message_id = EXCLUDED.panel_message_id`
|
||||||
|
_, err := r.db.ExecContext(ctx, q, guildID, nullID(messageID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextNumber hands out the guild's next ticket number. It is a single
|
||||||
|
// statement, so two tickets opened at the same moment never share a number.
|
||||||
|
func (r *Repo) NextNumber(ctx context.Context, guildID int64) (int, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO ticket_settings (guild_id, next_number) VALUES ($1, 2)
|
||||||
|
ON CONFLICT (guild_id) DO UPDATE SET next_number = ticket_settings.next_number + 1
|
||||||
|
RETURNING next_number - 1`
|
||||||
|
var n int
|
||||||
|
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCols = `id, guild_id, name, COALESCE(emoji, ''), description, COALESCE(staff_role_id, 0), position`
|
||||||
|
|
||||||
|
func scanCategory(row interface{ Scan(...any) error }) (Category, error) {
|
||||||
|
var c Category
|
||||||
|
err := row.Scan(&c.ID, &c.GuildID, &c.Name, &c.Emoji, &c.Description, &c.StaffRoleID, &c.Position)
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListCategories(ctx context.Context, guildID int64) ([]Category, error) {
|
||||||
|
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 ORDER BY position, id`
|
||||||
|
rows, err := r.db.QueryContext(ctx, q, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Category
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanCategory(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategory returns nil when the category does not exist in that guild.
|
||||||
|
func (r *Repo) GetCategory(ctx context.Context, guildID, id int64) (*Category, error) {
|
||||||
|
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 AND id = $2`
|
||||||
|
c, err := scanCategory(r.db.QueryRowContext(ctx, q, guildID, id))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCategory appends a category after the existing ones.
|
||||||
|
func (r *Repo) AddCategory(ctx context.Context, c Category) (*Category, error) {
|
||||||
|
q := `
|
||||||
|
INSERT INTO ticket_categories (guild_id, name, emoji, description, staff_role_id, position)
|
||||||
|
VALUES ($1, $2, $3, $4, $5,
|
||||||
|
(SELECT COALESCE(MAX(position) + 1, 0) FROM ticket_categories WHERE guild_id = $1))
|
||||||
|
RETURNING ` + categoryCols
|
||||||
|
out, err := scanCategory(r.db.QueryRowContext(ctx, q, c.GuildID, c.Name, nullText(c.Emoji), c.Description, nullID(c.StaffRoleID)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategoryByName removes a category (case-insensitively) and reports
|
||||||
|
// whether one was found. Tickets keep their copied category name.
|
||||||
|
func (r *Repo) DeleteCategoryByName(ctx context.Context, guildID int64, name string) (bool, error) {
|
||||||
|
const q = `DELETE FROM ticket_categories WHERE guild_id = $1 AND lower(name) = lower($2)`
|
||||||
|
res, err := r.db.ExecContext(ctx, q, guildID, name)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error) {
|
||||||
|
const q = `SELECT COUNT(*) FROM tickets WHERE guild_id = $1 AND opener_id = $2 AND status = 'open'`
|
||||||
|
var n int
|
||||||
|
err := r.db.QueryRowContext(ctx, q, guildID, openerID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateTicket(ctx context.Context, t Ticket) (int64, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO tickets (guild_id, number, category_id, category_name, thread_id, intro_message_id, opener_id, subject)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`
|
||||||
|
var id int64
|
||||||
|
err := r.db.QueryRowContext(ctx, q, t.GuildID, t.Number, nullID(t.CategoryID), t.CategoryName, t.ThreadID, nullID(t.IntroMessage), t.OpenerID, t.Subject).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketCols = `id, guild_id, number, COALESCE(category_id, 0), category_name, thread_id, COALESCE(intro_message_id, 0), opener_id, subject, status,
|
||||||
|
COALESCE(claimed_by, 0), COALESCE(closed_by, 0), COALESCE(close_reason, ''), created_at, closed_at`
|
||||||
|
|
||||||
|
func scanTicket(row interface{ Scan(...any) error }) (Ticket, error) {
|
||||||
|
var t Ticket
|
||||||
|
var closedAt sql.NullTime
|
||||||
|
err := row.Scan(&t.ID, &t.GuildID, &t.Number, &t.CategoryID, &t.CategoryName, &t.ThreadID, &t.IntroMessage, &t.OpenerID, &t.Subject, &t.Status,
|
||||||
|
&t.ClaimedBy, &t.ClosedBy, &t.CloseReason, &t.CreatedAt, &closedAt)
|
||||||
|
t.ClosedAt = closedAt.Time
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTicketByThread returns nil when the thread is not a ticket.
|
||||||
|
func (r *Repo) GetTicketByThread(ctx context.Context, threadID int64) (*Ticket, error) {
|
||||||
|
q := `SELECT ` + ticketCols + ` FROM tickets WHERE thread_id = $1`
|
||||||
|
t, err := scanTicket(r.db.QueryRowContext(ctx, q, threadID))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim sets the ticket's handler unless someone already has it, and returns
|
||||||
|
// whoever holds it afterwards (the caller when the claim worked).
|
||||||
|
func (r *Repo) Claim(ctx context.Context, threadID, staffID int64) (int64, error) {
|
||||||
|
const q = `
|
||||||
|
UPDATE tickets SET claimed_by = COALESCE(claimed_by, $2)
|
||||||
|
WHERE thread_id = $1 AND status = 'open'
|
||||||
|
RETURNING claimed_by`
|
||||||
|
var holder int64
|
||||||
|
err := r.db.QueryRowContext(ctx, q, threadID, staffID).Scan(&holder)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return holder, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close marks an open ticket closed and reports whether this call closed it,
|
||||||
|
// so two people pressing Close at once only produce one transcript.
|
||||||
|
func (r *Repo) Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error) {
|
||||||
|
const q = `
|
||||||
|
UPDATE tickets SET status = 'closed', closed_by = $2, close_reason = $3, closed_at = NOW()
|
||||||
|
WHERE thread_id = $1 AND status = 'open'`
|
||||||
|
res, err := r.db.ExecContext(ctx, q, threadID, nullID(closedBy), nullText(reason))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
|
||||||
|
const q = `SELECT id, guild_id, command_type, requested_by FROM ticket_commands WHERE processed_at IS NULL ORDER BY id`
|
||||||
|
rows, err := r.db.QueryContext(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Command
|
||||||
|
for rows.Next() {
|
||||||
|
var c Command
|
||||||
|
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.RequestedBy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Runs against a real Postgres with schema.sql loaded:
|
||||||
|
//
|
||||||
|
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
|
||||||
|
package ticketrepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRepo(t *testing.T) (*Repo, int64) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TICKETS_TEST_DB")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TICKETS_TEST_DB not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
const guild = 424242
|
||||||
|
for _, q := range []string{
|
||||||
|
`DELETE FROM tickets WHERE guild_id = $1`,
|
||||||
|
`DELETE FROM ticket_categories WHERE guild_id = $1`,
|
||||||
|
`DELETE FROM ticket_settings WHERE guild_id = $1`,
|
||||||
|
`DELETE FROM ticket_commands WHERE guild_id = $1`,
|
||||||
|
} {
|
||||||
|
if _, err := db.Exec(q, guild); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NewRepo(db), guild
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsDefaultsAndChannels(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := r.GetSettings(ctx, g)
|
||||||
|
if err != nil || s.Configured || s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
|
||||||
|
t.Fatalf("defaults wrong: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := r.SetPanelMessage(ctx, g, 333); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, _ = r.GetSettings(ctx, g)
|
||||||
|
if !s.Configured || s.ChannelID != 111 || s.LogChannelID != 222 || s.PanelMessageID != 333 {
|
||||||
|
t.Fatalf("saved settings wrong: %+v", s)
|
||||||
|
}
|
||||||
|
// Clearing the log channel.
|
||||||
|
_ = r.SetChannels(ctx, g, 111, 0)
|
||||||
|
s, _ = r.GetSettings(ctx, g)
|
||||||
|
if s.LogChannelID != 0 {
|
||||||
|
t.Errorf("log channel not cleared: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextNumberIsUniqueUnderConcurrency(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
seen := map[int]bool{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
n, err := r.NextNumber(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if seen[n] {
|
||||||
|
t.Errorf("number %d handed out twice", n)
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for n := 1; n <= 20; n++ {
|
||||||
|
if !seen[n] {
|
||||||
|
t.Errorf("number %d missing: numbers should be 1..20", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCategories(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
a, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🛟", StaffRoleID: 9})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Appeal", Description: "Ban appeals"})
|
||||||
|
if a.Position != 0 || b.Position != 1 || b.Emoji != "" || b.StaffRoleID != 0 {
|
||||||
|
t.Fatalf("positions or defaults wrong: %+v %+v", a, b)
|
||||||
|
}
|
||||||
|
list, _ := r.ListCategories(ctx, g)
|
||||||
|
if len(list) != 2 || list[0].Name != "Support" || list[1].Description != "Ban appeals" {
|
||||||
|
t.Fatalf("list wrong: %+v", list)
|
||||||
|
}
|
||||||
|
if c, _ := r.GetCategory(ctx, g, a.ID); c == nil || c.Emoji != "🛟" {
|
||||||
|
t.Errorf("get wrong: %+v", c)
|
||||||
|
}
|
||||||
|
if c, _ := r.GetCategory(ctx, g+1, a.ID); c != nil {
|
||||||
|
t.Error("a category must not be readable from another guild")
|
||||||
|
}
|
||||||
|
if ok, _ := r.DeleteCategoryByName(ctx, g, "support"); !ok {
|
||||||
|
t.Error("delete should match case-insensitively")
|
||||||
|
}
|
||||||
|
if ok, _ := r.DeleteCategoryByName(ctx, g, "nope"); ok {
|
||||||
|
t.Error("deleting a missing category should report false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketLifecycle(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cat, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Support"})
|
||||||
|
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: 1, CategoryID: cat.ID, CategoryName: "Support", ThreadID: 5001, IntroMessage: 6001, OpenerID: 77, Subject: "Help"})
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 1 {
|
||||||
|
t.Errorf("open count = %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if holder, _ := r.Claim(ctx, 5001, 10); holder != 10 {
|
||||||
|
t.Errorf("first claim should win, holder = %d", holder)
|
||||||
|
}
|
||||||
|
if holder, _ := r.Claim(ctx, 5001, 11); holder != 10 {
|
||||||
|
t.Errorf("second claim must not take over, holder = %d", holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok, _ := r.Close(ctx, 5001, 10, "solved"); !ok {
|
||||||
|
t.Error("first close should succeed")
|
||||||
|
}
|
||||||
|
if ok, _ := r.Close(ctx, 5001, 11, ""); ok {
|
||||||
|
t.Error("closing twice must report false")
|
||||||
|
}
|
||||||
|
tk, _ := r.GetTicketByThread(ctx, 5001)
|
||||||
|
if tk == nil || tk.Status != StatusClosed || tk.ClosedBy != 10 || tk.CloseReason != "solved" || tk.ClosedAt.IsZero() || tk.ClaimedBy != 10 || tk.IntroMessage != 6001 {
|
||||||
|
t.Fatalf("closed ticket wrong: %+v", tk)
|
||||||
|
}
|
||||||
|
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 0 {
|
||||||
|
t.Errorf("closed ticket still counted as open")
|
||||||
|
}
|
||||||
|
if holder, _ := r.Claim(ctx, 5001, 12); holder != 0 {
|
||||||
|
t.Error("a closed ticket cannot be claimed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the category keeps the ticket and its copied name.
|
||||||
|
_, _ = r.DeleteCategoryByName(ctx, g, "Support")
|
||||||
|
tk, _ = r.GetTicketByThread(ctx, 5001)
|
||||||
|
if tk.CategoryID != 0 || tk.CategoryName != "Support" {
|
||||||
|
t.Errorf("ticket after category delete: %+v", tk)
|
||||||
|
}
|
||||||
|
if tk, _ := r.GetTicketByThread(ctx, 999); tk != nil {
|
||||||
|
t.Error("unknown thread should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommands(t *testing.T) {
|
||||||
|
r, g := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := r.db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 5)`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmds, err := r.ListUnprocessedCommands(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mine *Command
|
||||||
|
for i := range cmds {
|
||||||
|
if cmds[i].GuildID == g {
|
||||||
|
mine = &cmds[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mine == nil || mine.CommandType != "post_panel" || mine.RequestedBy != 5 {
|
||||||
|
t.Fatalf("command not listed: %+v", cmds)
|
||||||
|
}
|
||||||
|
_ = r.MarkCommandProcessed(ctx, mine.ID)
|
||||||
|
cmds, _ = r.ListUnprocessedCommands(ctx)
|
||||||
|
for _, c := range cmds {
|
||||||
|
if c.ID == mine.ID {
|
||||||
|
t.Error("processed command still listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,10 +102,13 @@ func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64
|
|||||||
return chID.Int64, true, nil
|
return chID.Int64, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNotificationChannel sets where live alerts go. Setting it for the first
|
||||||
|
// time turns alerts on (they start off); after that, the on/off switch is
|
||||||
|
// left alone.
|
||||||
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO twitch_config (guild_id, twitch_channel_id)
|
INSERT INTO twitch_config (guild_id, twitch_channel_id, enabled)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, TRUE)
|
||||||
ON CONFLICT (guild_id)
|
ON CONFLICT (guild_id)
|
||||||
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
|
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
|
||||||
`
|
`
|
||||||
@@ -114,15 +117,14 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsEnabled reports whether Twitch notifications are enabled for guildID.
|
// IsEnabled reports whether Twitch notifications are enabled for guildID.
|
||||||
// A guild with no twitch_config row yet defaults to enabled, matching the
|
// A guild with no twitch_config row yet is off: modules start off until
|
||||||
// column's own DEFAULT TRUE - "never configured" and "explicitly on" are
|
// the server sets them up.
|
||||||
// the same state until someone actually flips it off.
|
|
||||||
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
|
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||||
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
|
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
|
||||||
var enabled bool
|
var enabled bool
|
||||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
|
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return true, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -14,12 +14,26 @@ func New(settings *settingsrepo.Repo) *Service {
|
|||||||
return &Service{settings: settings}
|
return &Service{settings: settings}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error {
|
// MaxLobbies is how many lobbies a server can have.
|
||||||
return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID)
|
const MaxLobbies = settingsrepo.MaxMeetingLobbies
|
||||||
|
|
||||||
|
// ErrLobbyLimit means the server already has MaxLobbies.
|
||||||
|
var ErrLobbyLimit = settingsrepo.ErrMeetingLobbyLimit
|
||||||
|
|
||||||
|
func (s *Service) AddLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) {
|
||||||
|
return s.settings.AddMeetingLobby(ctx, guildID, channelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) {
|
func (s *Service) RemoveLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
|
||||||
return s.settings.GetMeetingLobbyChannel(ctx, guildID)
|
return s.settings.RemoveMeetingLobby(ctx, guildID, channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||||
|
return s.settings.ListMeetingLobbies(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) IsLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||||
|
return s.settings.IsMeetingLobby(ctx, guildID, channelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleVoiceStateUpdate implements the "meeting lobby" behavior:
|
// HandleVoiceStateUpdate implements the "meeting lobby" behavior:
|
||||||
// - When a user joins the configured lobby voice channel, create a temporary voice channel
|
// - When a user joins one of the server's lobby voice channels, create a temporary voice channel
|
||||||
// with the same UserLimit and move the user into it.
|
// with the same UserLimit and move the user into it.
|
||||||
// - When a temporary channel becomes empty, delete it.
|
// - When a temporary channel becomes empty, delete it.
|
||||||
func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) {
|
func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) {
|
||||||
@@ -40,11 +40,12 @@ func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64)
|
joinedID, err := strconv.ParseInt(vs.ChannelID, 10, 64)
|
||||||
if err != nil || !ok || lobbyID == 0 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if vs.ChannelID != strconv.FormatInt(lobbyID, 10) {
|
isLobby, err := svc.Meeting.IsLobby(ctx, guildID64, joinedID)
|
||||||
|
if err != nil || !isLobby {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,3 +115,21 @@ func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *servi
|
|||||||
_ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64)
|
_ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleChannelDelete drops a deleted voice channel from the meeting lobbies,
|
||||||
|
// so it doesn't keep using up one of the server's slots.
|
||||||
|
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) {
|
||||||
|
if svc == nil || svc.Meeting == nil || c == nil || c.Channel == nil || c.GuildID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Type != discordgo.ChannelTypeGuildVoice {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guildID, err1 := strconv.ParseInt(c.GuildID, 10, 64)
|
||||||
|
channelID, err2 := strconv.ParseInt(c.ID, 10, 64)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := svc.Meeting.RemoveLobby(context.Background(), guildID, channelID); err != nil {
|
||||||
|
log.Printf("meeting: removing deleted lobby guild=%s channel=%s err=%v", c.GuildID, c.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -686,6 +686,23 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
|
|||||||
return repeatSong, repeatQueue
|
return repeatSong, repeatQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepeatFlags reports whether the current song or the whole queue repeats.
|
||||||
|
func RepeatFlags(guildID string) (song, queue bool) { return repeatFlags(guildID) }
|
||||||
|
|
||||||
|
// SetRepeat sets the repeat mode directly. At most one of the two applies;
|
||||||
|
// asking for both repeats the song.
|
||||||
|
func SetRepeat(guildID string, song, queue bool) {
|
||||||
|
if manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
|
manager.mu.Lock()
|
||||||
|
gp.RepeatSong = song
|
||||||
|
gp.RepeatQueue = queue && !song
|
||||||
|
manager.mu.Unlock()
|
||||||
|
syncState(guildID)
|
||||||
|
}
|
||||||
|
|
||||||
func repeatFlags(guildID string) (bool, bool) {
|
func repeatFlags(guildID string) (bool, bool) {
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return false, false
|
return false, false
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
colorAlert = 0xdc2626
|
||||||
|
colorWarn = 0xf59e0b
|
||||||
|
colorOK = 0x16a34a
|
||||||
|
)
|
||||||
|
|
||||||
|
type alert struct {
|
||||||
|
embed *discordgo.MessageEmbed
|
||||||
|
ping bool // ping the alert role (raids and nukes, not routine join gate actions)
|
||||||
|
pingOwner bool
|
||||||
|
// critical alerts also go by DM to the owner and trusted users, since
|
||||||
|
// the alert channel may be gone or locked in exactly these moments.
|
||||||
|
critical bool
|
||||||
|
components []discordgo.MessageComponent
|
||||||
|
file *discordgo.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// send posts an alert to the configured channel. It only ever pings the
|
||||||
|
// configured alert role (never @everyone, even if that was saved somehow) and
|
||||||
|
// optionally the owner.
|
||||||
|
func (e *Engine) send(ctx context.Context, guildID string, s *securityrepo.Settings, a alert) *discordgo.Message {
|
||||||
|
if a.critical && s.AlertDM {
|
||||||
|
if e.syncDMs {
|
||||||
|
e.dmAlert(ctx, guildID, s, a)
|
||||||
|
} else {
|
||||||
|
go e.dmAlert(context.Background(), guildID, s, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.AlertChannelID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := &discordgo.MessageSend{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{a.embed},
|
||||||
|
Components: a.components,
|
||||||
|
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
}
|
||||||
|
if a.ping && s.AlertRoleID != 0 && id(s.AlertRoleID) != guildID {
|
||||||
|
msg.Content = "<@&" + id(s.AlertRoleID) + ">"
|
||||||
|
msg.AllowedMentions.Roles = []string{id(s.AlertRoleID)}
|
||||||
|
}
|
||||||
|
if a.pingOwner {
|
||||||
|
if owner, err := e.OwnerID(guildID); err == nil && owner != "" {
|
||||||
|
msg.Content += " <@" + owner + ">"
|
||||||
|
msg.AllowedMentions.Users = []string{owner}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.file != nil {
|
||||||
|
msg.Files = []*discordgo.File{a.file}
|
||||||
|
}
|
||||||
|
m, err := e.d.ChannelMessageSendComplex(id(s.AlertChannelID), msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: alert in guild %s: %v", guildID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFile(name, content string) *discordgo.File {
|
||||||
|
return &discordgo.File{Name: name, ContentType: "text/plain; charset=utf-8", Reader: bytes.NewReader([]byte(content))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAlertDMs caps how many people get each critical alert by DM. Mass DMs
|
||||||
|
// from a bot look like spam to Discord.
|
||||||
|
const maxAlertDMs = 10
|
||||||
|
|
||||||
|
// alertDMRecipients is the owner plus trusted users (not roles, which could
|
||||||
|
// hold many members), without duplicates or the bot itself.
|
||||||
|
func (e *Engine) alertDMRecipients(ctx context.Context, guildID string) []string {
|
||||||
|
seen := map[string]bool{e.botID: true}
|
||||||
|
var out []string
|
||||||
|
add := func(u string) {
|
||||||
|
if u != "" && !seen[u] && len(out) < maxAlertDMs {
|
||||||
|
seen[u] = true
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if owner, err := e.OwnerID(guildID); err == nil {
|
||||||
|
add(owner)
|
||||||
|
}
|
||||||
|
if _, trusted, err := e.load(ctx, guildID); err == nil {
|
||||||
|
for _, t := range trusted {
|
||||||
|
if t.Kind == "user" {
|
||||||
|
add(id(t.TargetID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmAlert sends a critical alert to each recipient's DMs. Closed DMs and
|
||||||
|
// other failures are skipped: the alert channel is still the main record.
|
||||||
|
func (e *Engine) dmAlert(ctx context.Context, guildID string, s *securityrepo.Settings, a alert) {
|
||||||
|
if a.embed == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server := e.guildName(guildID)
|
||||||
|
if server == "" {
|
||||||
|
server = "a server"
|
||||||
|
}
|
||||||
|
embed := *a.embed
|
||||||
|
embed.Footer = &discordgo.MessageEmbedFooter{Text: clip("Velox security · "+server, 200)}
|
||||||
|
extra := ""
|
||||||
|
if a.file != nil {
|
||||||
|
if s.AlertChannelID != 0 {
|
||||||
|
extra = "\nThe full list is posted in the alert channel."
|
||||||
|
} else {
|
||||||
|
extra = "\nSet an alert channel on the dashboard to also get the full list of accounts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra != "" {
|
||||||
|
embed.Description = clip(embed.Description+extra, 4000)
|
||||||
|
}
|
||||||
|
msg := &discordgo.MessageSend{
|
||||||
|
Content: "Security alert in **" + escapeText(server) + "**. Use the dashboard or `/security` in the server to act.",
|
||||||
|
Embeds: []*discordgo.MessageEmbed{&embed},
|
||||||
|
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
}
|
||||||
|
for _, u := range e.alertDMRecipients(ctx, guildID) {
|
||||||
|
ch, err := e.d.UserChannelCreate(u)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := e.d.ChannelMessageSendComplex(ch.ID, msg); err != nil {
|
||||||
|
log.Printf("security: DM alert to %s for guild %s: %v", u, guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeText defuses markdown and mentions in a name (e.g. a server name).
|
||||||
|
func escapeText(s string) string {
|
||||||
|
return safeName(&discordgo.User{Username: s})
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The configuration calls below do their own permission checks, so every
|
||||||
|
// way in (slash commands, buttons, anything added later) gets the same rules:
|
||||||
|
//
|
||||||
|
// - switching a system on: Manage Server, or owner/trusted
|
||||||
|
// - switching a system off, alert and quarantine settings: owner/trusted
|
||||||
|
// - the trusted list: owner only
|
||||||
|
//
|
||||||
|
// actorPerms is the actor's permission set as Discord sent it with the
|
||||||
|
// interaction.
|
||||||
|
|
||||||
|
const maxTrusted = 25
|
||||||
|
|
||||||
|
func hasManageGuild(perms int64) bool {
|
||||||
|
return perms&(discordgo.PermissionAdministrator|discordgo.PermissionManageGuild) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFeature turns the Join Gate, raid mode or anti-nuke on or off.
|
||||||
|
func (e *Engine) SetFeature(ctx context.Context, guildID string, actor *discordgo.Member, actorPerms int64, feature string, on bool) error {
|
||||||
|
if actor == nil || actor.User == nil {
|
||||||
|
return ErrNotTrusted
|
||||||
|
}
|
||||||
|
switch feature {
|
||||||
|
case securityrepo.FeatureJoinGate, securityrepo.FeatureAntiRaid, securityrepo.FeatureAntiNuke, securityrepo.FeatureHeat:
|
||||||
|
default:
|
||||||
|
return UserError("Unknown feature.")
|
||||||
|
}
|
||||||
|
trustedErr := e.RequireTrusted(ctx, guildID, actor)
|
||||||
|
if !on && trustedErr != nil {
|
||||||
|
return trustedErr
|
||||||
|
}
|
||||||
|
if on && trustedErr != nil && !hasManageGuild(actorPerms) {
|
||||||
|
return UserError("You need Manage Server to turn security features on.")
|
||||||
|
}
|
||||||
|
if err := e.st.SetEnabled(ctx, parseID(guildID), feature, on); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Invalidate(guildID)
|
||||||
|
state := "off"
|
||||||
|
if on {
|
||||||
|
state = "on"
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, "settings", actor.User.ID, "", feature+" "+state)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) roleByID(guildID, roleID string) (*discordgo.Role, []*discordgo.Role, error) {
|
||||||
|
roles, err := e.d.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("listing roles: %w", err)
|
||||||
|
}
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.ID == roleID {
|
||||||
|
return r, roles, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, roles, UserError("That role isn't in this server.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAlerts sets where alerts go and which role they ping. Changing it could
|
||||||
|
// hide alerts from the staff, so it needs owner or trusted.
|
||||||
|
func (e *Engine) SetAlerts(ctx context.Context, guildID string, actor *discordgo.Member, channelID, roleID string) error {
|
||||||
|
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ch, err := e.d.Channel(channelID)
|
||||||
|
if err != nil || ch.GuildID != guildID {
|
||||||
|
return UserError("That channel isn't in this server.")
|
||||||
|
}
|
||||||
|
if ch.Type != discordgo.ChannelTypeGuildText && ch.Type != discordgo.ChannelTypeGuildNews {
|
||||||
|
return UserError("Alerts need a text channel.")
|
||||||
|
}
|
||||||
|
if roleID != "" {
|
||||||
|
if roleID == guildID {
|
||||||
|
return UserError("Alerts can't ping @everyone. Pick a staff role.")
|
||||||
|
}
|
||||||
|
if _, _, err := e.roleByID(guildID, roleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := e.st.SetAlerts(ctx, parseID(guildID), parseID(channelID), parseID(roleID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Invalidate(guildID)
|
||||||
|
e.event(ctx, guildID, "settings", actor.User.ID, channelID, "alerts channel and role changed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateQuarantineRole checks a role can safely be the quarantine role:
|
||||||
|
// in this server, not @everyone, not managed, no dangerous permissions, and
|
||||||
|
// below Velox's highest role so the bot can assign it.
|
||||||
|
func (e *Engine) ValidateQuarantineRole(guildID, roleID string) error {
|
||||||
|
if roleID == guildID {
|
||||||
|
return UserError("@everyone can't be the quarantine role.")
|
||||||
|
}
|
||||||
|
role, roles, err := e.roleByID(guildID, roleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if role.Managed {
|
||||||
|
return UserError("That role is managed by an integration and can't be assigned.")
|
||||||
|
}
|
||||||
|
if role.Permissions&DangerousPerms != 0 {
|
||||||
|
return UserError("The quarantine role can't have Administrator, Manage Server, Manage Roles or Manage Webhooks.")
|
||||||
|
}
|
||||||
|
me, err := e.d.GuildMember(guildID, e.botID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("looking up Velox: %w", err)
|
||||||
|
}
|
||||||
|
top := -1
|
||||||
|
for _, r := range roles {
|
||||||
|
for _, mine := range me.Roles {
|
||||||
|
if r.ID == mine && r.Position > top {
|
||||||
|
top = r.Position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if role.Position >= top {
|
||||||
|
return UserError("That role is above Velox's highest role, so Velox couldn't assign it. Move it below Velox's role.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQuarantineRole sets (or with "" clears) the quarantine role.
|
||||||
|
func (e *Engine) SetQuarantineRole(ctx context.Context, guildID string, actor *discordgo.Member, roleID string) error {
|
||||||
|
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if roleID != "" {
|
||||||
|
if err := e.ValidateQuarantineRole(guildID, roleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := e.st.SetQuarantineRole(ctx, parseID(guildID), parseID(roleID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Invalidate(guildID)
|
||||||
|
e.event(ctx, guildID, "settings", actor.User.ID, roleID, "quarantine role changed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust adds a user or role to the trusted list. Owner only: anyone trusted
|
||||||
|
// can switch protection off, so trust must not spread on its own.
|
||||||
|
func (e *Engine) Trust(ctx context.Context, guildID string, actor *discordgo.Member, kind, targetID string) error {
|
||||||
|
if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) {
|
||||||
|
return ErrNotOwner
|
||||||
|
}
|
||||||
|
_, trusted, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(trusted) >= maxTrusted {
|
||||||
|
return UserError(fmt.Sprintf("The trusted list can hold at most %d entries.", maxTrusted))
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "role":
|
||||||
|
if targetID == guildID {
|
||||||
|
return UserError("Trusting @everyone would make everyone trusted.")
|
||||||
|
}
|
||||||
|
role, _, err := e.roleByID(guildID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if role.Managed {
|
||||||
|
return UserError("Integration roles can't be trusted; trust the bot user instead.")
|
||||||
|
}
|
||||||
|
case "user":
|
||||||
|
if _, err := e.d.GuildMember(guildID, targetID); err != nil {
|
||||||
|
return UserError("That user isn't in this server.")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return UserError("Trust a user or a role.")
|
||||||
|
}
|
||||||
|
if err := e.st.AddTrusted(ctx, parseID(guildID), securityrepo.Trusted{TargetID: parseID(targetID), Kind: kind, AddedBy: parseID(actor.User.ID)}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Invalidate(guildID)
|
||||||
|
e.event(ctx, guildID, "trusted_added", actor.User.ID, targetID, kind)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untrust removes a user or role from the trusted list. Owner only.
|
||||||
|
func (e *Engine) Untrust(ctx context.Context, guildID string, actor *discordgo.Member, targetID string) error {
|
||||||
|
if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) {
|
||||||
|
return ErrNotOwner
|
||||||
|
}
|
||||||
|
ok, err := e.st.RemoveTrusted(ctx, parseID(guildID), parseID(targetID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return UserError("That isn't on the trusted list.")
|
||||||
|
}
|
||||||
|
e.Invalidate(guildID)
|
||||||
|
e.event(ctx, guildID, "trusted_removed", actor.User.ID, targetID, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nuke(e *Engine) {
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCriticalAlertsAreDMed(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
d.guildName = "Velox **HQ** @everyone"
|
||||||
|
// Owner (1) is also on the list as a user; a trusted role must not be expanded.
|
||||||
|
st.trusted[100] = []securityrepo.Trusted{{TargetID: 1, Kind: "user"}, {TargetID: 60, Kind: "user"}, {TargetID: 201, Kind: "role"}, {TargetID: 999, Kind: "user"}}
|
||||||
|
refresh(e)
|
||||||
|
nuke(e)
|
||||||
|
|
||||||
|
got := strings.Join(d.dmsTo(), ",")
|
||||||
|
if got != "1,60" {
|
||||||
|
t.Fatalf("DMs went to %q, want the owner and the trusted user once each (no role members, not the bot)", got)
|
||||||
|
}
|
||||||
|
var dm *discordgo.MessageSend
|
||||||
|
for i, ch := range d.sentTo {
|
||||||
|
if ch == "dm-1" {
|
||||||
|
dm = d.sent[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(dm.Embeds[0].Title, "Nuke attempt stopped") {
|
||||||
|
t.Errorf("DM should carry the alert: %+v", dm.Embeds[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(dm.Content, "@everyone") || strings.Contains(dm.Content, "**HQ**") || dm.AllowedMentions == nil {
|
||||||
|
t.Errorf("the server name must be escaped and nothing pinged: %q", dm.Content)
|
||||||
|
}
|
||||||
|
if len(dm.Components) != 0 {
|
||||||
|
t.Error("no action buttons in DMs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDMsWorkWithoutAnAlertChannel(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.set(100).AlertChannelID = 0
|
||||||
|
refresh(e)
|
||||||
|
nuke(e)
|
||||||
|
if strings.Join(d.dmsTo(), ",") != "1" {
|
||||||
|
t.Errorf("the owner should still be told: %v", d.dmsTo())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDMsCanBeTurnedOff(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.set(100).AlertDM = false
|
||||||
|
refresh(e)
|
||||||
|
nuke(e)
|
||||||
|
if len(d.dmsTo()) != 0 {
|
||||||
|
t.Errorf("DMs are off: %v", d.dmsTo())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutineAlertsAreNotDMed(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.JoinGateEnabled, s.NoAvatar = true, securityrepo.ActionKick
|
||||||
|
refresh(e)
|
||||||
|
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), 1)}})
|
||||||
|
if len(d.dmsTo()) != 0 {
|
||||||
|
t.Error("Join Gate actions only go to the alert channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosedDMsAreSkippedAndListIsCapped(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
d.closedDMs = map[string]bool{"1": true}
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
st.trusted[100] = append(st.trusted[100], securityrepo.Trusted{TargetID: int64(700 + i), Kind: "user"})
|
||||||
|
}
|
||||||
|
refresh(e)
|
||||||
|
nuke(e)
|
||||||
|
got := d.dmsTo()
|
||||||
|
if len(got) != maxAlertDMs-1 {
|
||||||
|
t.Errorf("owner's DMs are closed and the list is capped at %d: got %d (%v)", maxAlertDMs, len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] != fmt.Sprint(700) {
|
||||||
|
t.Errorf("the others still get it: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeStore keeps everything in memory with the same rules as the SQL.
|
||||||
|
type fakeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
settings map[int64]*securityrepo.Settings
|
||||||
|
trusted map[int64][]securityrepo.Trusted
|
||||||
|
quarantines map[string]securityrepo.Quarantine
|
||||||
|
lockdowns map[string]securityrepo.Lockdown
|
||||||
|
events []securityrepo.Event
|
||||||
|
cmds []securityrepo.Command
|
||||||
|
done map[int64]bool
|
||||||
|
raids map[int64]securityrepo.Raid
|
||||||
|
caught map[int64][]securityrepo.Caught
|
||||||
|
snapshots []securityrepo.Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStore() *fakeStore {
|
||||||
|
return &fakeStore{
|
||||||
|
settings: map[int64]*securityrepo.Settings{}, trusted: map[int64][]securityrepo.Trusted{},
|
||||||
|
quarantines: map[string]securityrepo.Quarantine{}, lockdowns: map[string]securityrepo.Lockdown{}, done: map[int64]bool{},
|
||||||
|
raids: map[int64]securityrepo.Raid{}, caught: map[int64][]securityrepo.Caught{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SaveRaid(_ context.Context, x securityrepo.Raid) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if old, ok := f.raids[x.GuildID]; ok {
|
||||||
|
x.StartedAt = old.StartedAt
|
||||||
|
}
|
||||||
|
f.raids[x.GuildID] = x
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListRaids(context.Context) ([]securityrepo.Raid, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []securityrepo.Raid
|
||||||
|
for _, x := range f.raids {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) DeleteRaid(_ context.Context, g int64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.raids, g)
|
||||||
|
delete(f.caught, g)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AddRaidCaught(_ context.Context, g int64, c securityrepo.Caught) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, x := range f.caught[g] {
|
||||||
|
if x.UserID == c.UserID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.caught[g] = append(f.caught[g], c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListRaidCaught(_ context.Context, g int64, limit int) ([]securityrepo.Caught, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := f.caught[g]
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return append([]securityrepo.Caught(nil), out...), nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SaveSnapshot(_ context.Context, s securityrepo.Snapshot) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
s.ID = int64(len(f.snapshots) + 1)
|
||||||
|
s.CreatedAt = time.Date(2026, 10, 20, 6, 0, 0, 0, time.UTC)
|
||||||
|
f.snapshots = append(f.snapshots, s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) LatestSnapshot(_ context.Context, g int64) (*securityrepo.Snapshot, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for i := len(f.snapshots) - 1; i >= 0; i-- {
|
||||||
|
if f.snapshots[i].GuildID == g {
|
||||||
|
s := f.snapshots[i]
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GuildsDueForSnapshot(context.Context, time.Duration) ([]int64, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func key(a, b int64) string { return fmt.Sprint(a, "/", b) }
|
||||||
|
|
||||||
|
func (f *fakeStore) set(g int64) *securityrepo.Settings {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
s, ok := f.settings[g]
|
||||||
|
if !ok {
|
||||||
|
s = securityrepo.Defaults(g)
|
||||||
|
s.Configured = true
|
||||||
|
f.settings[g] = s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*securityrepo.Settings, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if s, ok := f.settings[g]; ok {
|
||||||
|
cp := *s
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return securityrepo.Defaults(g), nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetEnabled(_ context.Context, g int64, feature string, on bool) error {
|
||||||
|
s := f.set(g)
|
||||||
|
switch feature {
|
||||||
|
case securityrepo.FeatureJoinGate:
|
||||||
|
s.JoinGateEnabled = on
|
||||||
|
case securityrepo.FeatureAntiRaid:
|
||||||
|
s.AntiRaidEnabled = on
|
||||||
|
case securityrepo.FeatureAntiNuke:
|
||||||
|
s.AntiNukeEnabled = on
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetAlerts(_ context.Context, g, ch, role int64) error {
|
||||||
|
s := f.set(g)
|
||||||
|
s.AlertChannelID, s.AlertRoleID = ch, role
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetQuarantineRole(_ context.Context, g, role int64) error {
|
||||||
|
f.set(g).QuarantineRoleID = role
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
|
||||||
|
f.set(g).JoinLock = mode
|
||||||
|
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) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]securityrepo.Trusted(nil), f.trusted[g]...), nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AddTrusted(_ context.Context, g int64, t securityrepo.Trusted) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.trusted[g] = append(f.trusted[g], t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) RemoveTrusted(_ context.Context, g, target int64) (bool, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var keep []securityrepo.Trusted
|
||||||
|
found := false
|
||||||
|
for _, t := range f.trusted[g] {
|
||||||
|
if t.TargetID == target {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keep = append(keep, t)
|
||||||
|
}
|
||||||
|
f.trusted[g] = keep
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SaveQuarantine(_ context.Context, q securityrepo.Quarantine) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.quarantines[key(q.GuildID, q.UserID)]; !ok {
|
||||||
|
f.quarantines[key(q.GuildID, q.UserID)] = q
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetQuarantine(_ context.Context, g, u int64) (*securityrepo.Quarantine, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if q, ok := f.quarantines[key(g, u)]; ok {
|
||||||
|
return &q, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListQuarantines(_ context.Context, g int64) ([]securityrepo.Quarantine, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []securityrepo.Quarantine
|
||||||
|
for _, q := range f.quarantines {
|
||||||
|
if q.GuildID == g {
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) DeleteQuarantine(_ context.Context, g, u int64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.quarantines, key(g, u))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SaveLockdown(_ context.Context, l securityrepo.Lockdown) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.lockdowns[key(l.GuildID, l.ChannelID)]; !ok {
|
||||||
|
f.lockdowns[key(l.GuildID, l.ChannelID)] = l
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetLockdown(_ context.Context, g, ch int64) (*securityrepo.Lockdown, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if l, ok := f.lockdowns[key(g, ch)]; ok {
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListLockdowns(_ context.Context, g int64, serverOnly bool) ([]securityrepo.Lockdown, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []securityrepo.Lockdown
|
||||||
|
for _, l := range f.lockdowns {
|
||||||
|
if l.GuildID == g && (!serverOnly || l.ServerWide) {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) DeleteLockdown(_ context.Context, g, ch int64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.lockdowns, key(g, ch))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.events = append(f.events, e)
|
||||||
|
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 {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, e := range f.events {
|
||||||
|
out = append(out, e.Kind)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]securityrepo.Command, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []securityrepo.Command
|
||||||
|
for _, c := range f.cmds {
|
||||||
|
if !f.done[c.ID] {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.done[id] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeDiscord is a tiny in-memory guild.
|
||||||
|
type fakeDiscord struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ownerID string
|
||||||
|
roles []*discordgo.Role
|
||||||
|
members map[string]*discordgo.Member
|
||||||
|
users map[string]*discordgo.User
|
||||||
|
channels map[string]*discordgo.Channel
|
||||||
|
kicked []string
|
||||||
|
banned []string
|
||||||
|
timeouts map[string]*time.Time
|
||||||
|
sent []*discordgo.MessageSend
|
||||||
|
sentTo []string
|
||||||
|
files map[string]string
|
||||||
|
requests []string // "METHOD url body"
|
||||||
|
deletedMsgs []string
|
||||||
|
nextID int
|
||||||
|
failEdits error
|
||||||
|
rejectRole string // edits that assign this role fail, like a role above Velox's
|
||||||
|
lookups int // member and role lookups, to check harmless entries cost nothing
|
||||||
|
closedDMs map[string]bool
|
||||||
|
guildName string
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = "100"
|
||||||
|
|
||||||
|
func newDiscord() *fakeDiscord {
|
||||||
|
return &fakeDiscord{
|
||||||
|
ownerID: "1",
|
||||||
|
roles: []*discordgo.Role{
|
||||||
|
{ID: guild, Name: "@everyone", Position: 0},
|
||||||
|
{ID: "200", Name: "Admin", Position: 5, Permissions: discordgo.PermissionAdministrator},
|
||||||
|
{ID: "201", Name: "Mod", Position: 3, Permissions: discordgo.PermissionKickMembers},
|
||||||
|
{ID: "202", Name: "Member", Position: 1},
|
||||||
|
{ID: "203", Name: "Quarantine", Position: 2},
|
||||||
|
{ID: "204", Name: "Booster", Position: 1, Managed: true},
|
||||||
|
{ID: "299", Name: "Velox", Position: 9, Managed: true, Permissions: discordgo.PermissionAdministrator},
|
||||||
|
},
|
||||||
|
members: map[string]*discordgo.Member{
|
||||||
|
"1": {User: &discordgo.User{ID: "1", Username: "owner"}},
|
||||||
|
"999": {User: &discordgo.User{ID: "999", Username: "velox", Bot: true}, Roles: []string{"299"}},
|
||||||
|
},
|
||||||
|
users: map[string]*discordgo.User{},
|
||||||
|
channels: map[string]*discordgo.Channel{},
|
||||||
|
timeouts: map[string]*time.Time{},
|
||||||
|
files: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) addMember(id string, roles ...string) *discordgo.Member {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
m := &discordgo.Member{User: &discordgo.User{ID: id, Username: "user" + id, Avatar: "a"}, Roles: roles}
|
||||||
|
f.members[id] = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) Guild(g string, _ ...discordgo.RequestOption) (*discordgo.Guild, error) {
|
||||||
|
return &discordgo.Guild{ID: g, OwnerID: f.ownerID, Name: f.guildName}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) UserChannelCreate(u string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.closedDMs[u] {
|
||||||
|
return nil, errors.New("Cannot send messages to this user")
|
||||||
|
}
|
||||||
|
return &discordgo.Channel{ID: "dm-" + u, Type: discordgo.ChannelTypeDM}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmsTo lists who got a DM, in order.
|
||||||
|
func (f *fakeDiscord) dmsTo() []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, ch := range f.sentTo {
|
||||||
|
if len(ch) > 3 && ch[:3] == "dm-" {
|
||||||
|
out = append(out, ch[3:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) User(id string, _ ...discordgo.RequestOption) (*discordgo.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if u, ok := f.users[id]; ok {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unknown user")
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildRoles(string, ...discordgo.RequestOption) ([]*discordgo.Role, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.lookups++
|
||||||
|
return f.roles, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildChannels(g string, _ ...discordgo.RequestOption) ([]*discordgo.Channel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []*discordgo.Channel
|
||||||
|
for _, c := range f.channels {
|
||||||
|
if c.GuildID == g {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildMember(_, u string, _ ...discordgo.RequestOption) (*discordgo.Member, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.lookups++
|
||||||
|
if m, ok := f.members[u]; ok {
|
||||||
|
cp := *m
|
||||||
|
cp.Roles = append([]string(nil), m.Roles...)
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unknown member")
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildMemberEdit(_, u string, data *discordgo.GuildMemberParams, _ ...discordgo.RequestOption) (*discordgo.Member, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.failEdits != nil {
|
||||||
|
return nil, f.failEdits
|
||||||
|
}
|
||||||
|
if data.Roles != nil && f.rejectRole != "" {
|
||||||
|
for _, r := range *data.Roles {
|
||||||
|
if r == f.rejectRole {
|
||||||
|
return nil, errors.New("403 Missing Permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, ok := f.members[u]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unknown member")
|
||||||
|
}
|
||||||
|
if data.Roles != nil {
|
||||||
|
m.Roles = append([]string(nil), (*data.Roles)...)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildMemberTimeout(_, u string, until *time.Time, _ ...discordgo.RequestOption) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.timeouts[u] = until
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildMemberDeleteWithReason(_, u, _ string, _ ...discordgo.RequestOption) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.kicked = append(f.kicked, u)
|
||||||
|
delete(f.members, u)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildBanCreateWithReason(_, u, _ string, _ int, _ ...discordgo.RequestOption) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.banned = append(f.banned, u)
|
||||||
|
delete(f.members, u)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) Channel(id string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if c, ok := f.channels[id]; ok {
|
||||||
|
cp := *c
|
||||||
|
cp.PermissionOverwrites = nil
|
||||||
|
for _, o := range c.PermissionOverwrites {
|
||||||
|
oc := *o
|
||||||
|
cp.PermissionOverwrites = append(cp.PermissionOverwrites, &oc)
|
||||||
|
}
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unknown channel")
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelPermissionSet(ch, target string, t discordgo.PermissionOverwriteType, allow, deny int64, _ ...discordgo.RequestOption) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
c := f.channels[ch]
|
||||||
|
for _, o := range c.PermissionOverwrites {
|
||||||
|
if o.ID == target {
|
||||||
|
o.Allow, o.Deny = allow, deny
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.PermissionOverwrites = append(c.PermissionOverwrites, &discordgo.PermissionOverwrite{ID: target, Type: t, Allow: allow, Deny: deny})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelPermissionDelete(ch, target string, _ ...discordgo.RequestOption) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
c := f.channels[ch]
|
||||||
|
var keep []*discordgo.PermissionOverwrite
|
||||||
|
for _, o := range c.PermissionOverwrites {
|
||||||
|
if o.ID != target {
|
||||||
|
keep = append(keep, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.PermissionOverwrites = keep
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.sentTo = append(f.sentTo, ch)
|
||||||
|
for _, file := range data.Files {
|
||||||
|
raw, _ := io.ReadAll(file.Reader)
|
||||||
|
f.files[file.Name] = string(raw)
|
||||||
|
}
|
||||||
|
f.sent = append(f.sent, data)
|
||||||
|
return &discordgo.Message{ID: "m"}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) RequestWithBucketID(method, url string, data interface{}, _ string, _ ...discordgo.RequestOption) ([]byte, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.requests = append(f.requests, fmt.Sprintf("%s %s %v", method, url, data))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentCount counts channel alerts (not DMs).
|
||||||
|
func (f *fakeDiscord) sentCount() int {
|
||||||
|
return len(f.channelSends())
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelSends lists messages sent to channels, leaving out DMs.
|
||||||
|
func (f *fakeDiscord) channelSends() []*discordgo.MessageSend {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []*discordgo.MessageSend
|
||||||
|
for i, m := range f.sent {
|
||||||
|
if i >= len(f.sentTo) || len(f.sentTo[i]) < 3 || f.sentTo[i][:3] != "dm-" {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) newID() string {
|
||||||
|
f.nextID++
|
||||||
|
return fmt.Sprint(8000 + f.nextID)
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelMessageDelete(ch, msg string, _ ...discordgo.RequestOption) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.deletedMsgs = append(f.deletedMsgs, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildRoleCreate(_ string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
r := &discordgo.Role{ID: f.newID(), Name: p.Name}
|
||||||
|
if p.Permissions != nil {
|
||||||
|
r.Permissions = *p.Permissions
|
||||||
|
}
|
||||||
|
f.roles = append(f.roles, r)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildRoleEdit(_, roleID string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, r := range f.roles {
|
||||||
|
if r.ID == roleID {
|
||||||
|
if p.Permissions != nil {
|
||||||
|
r.Permissions = *p.Permissions
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("unknown role")
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) GuildChannelCreateComplex(g string, d discordgo.GuildChannelCreateData, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
c := &discordgo.Channel{ID: f.newID(), GuildID: g, Name: d.Name, Type: d.Type, ParentID: d.ParentID, PermissionOverwrites: d.PermissionOverwrites}
|
||||||
|
f.channels[c.ID] = c
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Raid persistence ----
|
||||||
|
|
||||||
|
func TestRaidSurvivesARestart(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidQuietMinutes = true, 3, securityrepo.ActionKick, 5
|
||||||
|
refresh(e)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
e.OnMemberJoin(ctx, guild, d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), i)))
|
||||||
|
}
|
||||||
|
if r, ok := st.raids[100]; !ok || !r.InvitesPaused || r.CaughtTotal != 4 || len(st.caught[100]) != 4 {
|
||||||
|
t.Fatalf("raid not persisted: %+v caught=%d", st.raids[100], len(st.caught[100]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bot restarts: a fresh engine on the same database.
|
||||||
|
e2 := New(d, st, "999")
|
||||||
|
e2.now = e.now
|
||||||
|
e2.ResumeRaids(ctx)
|
||||||
|
if !e2.RaidActive(guild) {
|
||||||
|
t.Fatal("raid mode must resume after a restart")
|
||||||
|
}
|
||||||
|
// A join after the restart is still treated as a raider.
|
||||||
|
*now = now.Add(time.Minute)
|
||||||
|
m := d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), 50))
|
||||||
|
e2.OnMemberJoin(ctx, guild, m)
|
||||||
|
if !contains(d.kicked, m.User.ID) {
|
||||||
|
t.Error("raid actions continue after a restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet period: it ends, invites resume, the list covers both lifetimes.
|
||||||
|
*now = now.Add(6 * time.Minute)
|
||||||
|
e2.checkRaids(ctx)
|
||||||
|
if e2.RaidActive(guild) {
|
||||||
|
t.Fatal("the resumed raid should end after the quiet period")
|
||||||
|
}
|
||||||
|
if !strings.Contains(d.requests[len(d.requests)-1], "invites_disabled_until:<nil>") {
|
||||||
|
t.Error("invites paused before the restart must be resumed")
|
||||||
|
}
|
||||||
|
var list string
|
||||||
|
for name, content := range d.files {
|
||||||
|
if strings.HasPrefix(name, "raid-") {
|
||||||
|
list = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Count(list, "\tkick\t") != 5 {
|
||||||
|
t.Errorf("summary should list all 5 raiders:\n%s", list)
|
||||||
|
}
|
||||||
|
if _, ok := st.raids[100]; ok {
|
||||||
|
t.Error("the raid record should be cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Heat ----
|
||||||
|
|
||||||
|
func heatEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore, *time.Time) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.HeatEnabled, s.HeatPanicMembers = true, 0
|
||||||
|
refresh(e)
|
||||||
|
return e, d, st, now
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgN int
|
||||||
|
|
||||||
|
func say(e *Engine, userID, content string, edit ...func(m *discordgo.MessageCreate)) *discordgo.MessageCreate {
|
||||||
|
msgN++
|
||||||
|
m := &discordgo.MessageCreate{Message: &discordgo.Message{
|
||||||
|
ID: "msg" + string(rune('A'+msgN%26)) + strings.Repeat("x", msgN/26), GuildID: guild, ChannelID: "300", Content: content,
|
||||||
|
Author: &discordgo.User{ID: userID, Username: "u" + userID},
|
||||||
|
Member: &discordgo.Member{Roles: []string{}, JoinedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
}}
|
||||||
|
for _, f := range edit {
|
||||||
|
f(m)
|
||||||
|
}
|
||||||
|
e.OnMessage(ctx, m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalChatNeverHeatsUp(t *testing.T) {
|
||||||
|
e, d, _, now := heatEngine(t)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
say(e, "50", "just talking about the game tonight, number "+string(rune('a'+i%26)))
|
||||||
|
*now = now.Add(2 * time.Second)
|
||||||
|
}
|
||||||
|
if len(d.deletedMsgs) != 0 || len(d.timeouts) != 0 {
|
||||||
|
t.Error("a message every two seconds is normal conversation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpamIsPunishedAndEscalates(t *testing.T) {
|
||||||
|
e, d, st, now := heatEngine(t)
|
||||||
|
var last *discordgo.MessageCreate
|
||||||
|
for i := 0; i < 6 && d.timeouts["50"] == nil; i++ {
|
||||||
|
last = say(e, "50", "BUY CHEAP STUFF NOW")
|
||||||
|
*now = now.Add(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
first := d.timeouts["50"]
|
||||||
|
if first == nil || first.Sub(*now) > 11*time.Minute || first.Sub(*now) < 9*time.Minute {
|
||||||
|
t.Fatalf("repeated spam should earn the 10 minute timeout, got %v", first)
|
||||||
|
}
|
||||||
|
if !contains(d.deletedMsgs, last.ID) {
|
||||||
|
t.Error("the message that maxed the heat should be deleted")
|
||||||
|
}
|
||||||
|
if !contains(st.eventKinds(), securityrepo.EventHeat) {
|
||||||
|
t.Error("recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second offence the same day doubles it.
|
||||||
|
*now = now.Add(time.Hour)
|
||||||
|
d.timeouts["50"] = nil
|
||||||
|
for i := 0; i < 6 && d.timeouts["50"] == nil; i++ {
|
||||||
|
say(e, "50", "BUY CHEAP STUFF NOW")
|
||||||
|
*now = now.Add(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if second := d.timeouts["50"]; second == nil || second.Sub(*now) < 19*time.Minute {
|
||||||
|
t.Errorf("second offence should be 20 minutes, got %v", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteLinksAndMassMentions(t *testing.T) {
|
||||||
|
e, d, _, _ := heatEngine(t)
|
||||||
|
say(e, "51", "join my server discord.gg/abcdef")
|
||||||
|
if d.timeouts["51"] == nil {
|
||||||
|
t.Error("an invite link is an instant max")
|
||||||
|
}
|
||||||
|
say(e, "52", "hey @everyone check this")
|
||||||
|
say(e, "52", "@everyone again")
|
||||||
|
if d.timeouts["52"] == nil {
|
||||||
|
t.Error("repeated @everyone should max out fast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeatExemptions(t *testing.T) {
|
||||||
|
e, d, st, _ := heatEngine(t)
|
||||||
|
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
|
||||||
|
refresh(e)
|
||||||
|
e.ChannelPerms = func(userID, _ string) (int64, error) {
|
||||||
|
if userID == "61" {
|
||||||
|
return discordgo.PermissionManageMessages, nil
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
for _, who := range []string{"1", "60", "61"} {
|
||||||
|
say(e, who, "discord.gg/x")
|
||||||
|
}
|
||||||
|
say(e, "62", "discord.gg/x", func(m *discordgo.MessageCreate) { m.Author.Bot = true })
|
||||||
|
say(e, "63", "discord.gg/x", func(m *discordgo.MessageCreate) { m.WebhookID = "w" })
|
||||||
|
if len(d.timeouts) != 0 || len(d.deletedMsgs) != 0 {
|
||||||
|
t.Errorf("owner, trusted, moderators, bots and webhooks are exempt: %v", d.timeouts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeatPanicTimesOutNewMembers(t *testing.T) {
|
||||||
|
e, d, st, now := heatEngine(t)
|
||||||
|
st.set(100).HeatPanicMembers = 3
|
||||||
|
refresh(e)
|
||||||
|
for _, u := range []string{"70", "71", "72"} {
|
||||||
|
say(e, u, "discord.gg/spam")
|
||||||
|
}
|
||||||
|
if !contains(st.eventKinds(), securityrepo.EventHeatPanic) {
|
||||||
|
t.Fatal("three members maxing out within a minute starts heat panic")
|
||||||
|
}
|
||||||
|
newcomer := func(m *discordgo.MessageCreate) { m.Member.JoinedAt = now.Add(-time.Hour) }
|
||||||
|
say(e, "80", "hello everyone", newcomer)
|
||||||
|
if d.timeouts["80"] == nil {
|
||||||
|
t.Error("someone who joined an hour ago speaking during panic is timed out")
|
||||||
|
}
|
||||||
|
say(e, "81", "hello everyone")
|
||||||
|
if d.timeouts["81"] != nil {
|
||||||
|
t.Error("established members are unaffected by panic")
|
||||||
|
}
|
||||||
|
*now = now.Add(11 * time.Minute)
|
||||||
|
say(e, "82", "hello", newcomer)
|
||||||
|
if d.timeouts["82"] != nil {
|
||||||
|
t.Error("panic ends after 10 minutes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMentionFloodLocksTheServer(t *testing.T) {
|
||||||
|
e, d, st, _ := heatEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.HeatLockdownMentions, s.HeatStrictness = 20, "low"
|
||||||
|
refresh(e)
|
||||||
|
d.channels["300"] = &discordgo.Channel{ID: "300", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
|
||||||
|
five := func(m *discordgo.MessageCreate) {
|
||||||
|
m.Mentions = []*discordgo.User{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}, {ID: "e"}}
|
||||||
|
}
|
||||||
|
for i, u := range []string{"90", "91", "92", "93"} {
|
||||||
|
_ = i
|
||||||
|
say(e, u, "hi", five)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st.mu.Lock()
|
||||||
|
n := len(st.lockdowns)
|
||||||
|
st.mu.Unlock()
|
||||||
|
if n > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Error("20 mentions within 30 seconds should lock the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeatAlertsAreCapped(t *testing.T) {
|
||||||
|
e, d, _, _ := heatEngine(t)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
say(e, string(rune('A'+i)), "discord.gg/x")
|
||||||
|
}
|
||||||
|
if n := d.sentCount(); n > heatAlertsPerMin {
|
||||||
|
t.Errorf("a spam wave produced %d alerts, cap is %d", n, heatAlertsPerMin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Snapshots and restore ----
|
||||||
|
|
||||||
|
func TestSnapshotAndRestore(t *testing.T) {
|
||||||
|
e, d, st, _ := testEngine(t)
|
||||||
|
d.channels["400"] = &discordgo.Channel{ID: "400", GuildID: guild, Type: discordgo.ChannelTypeGuildCategory, Name: "Staff"}
|
||||||
|
d.channels["401"] = &discordgo.Channel{ID: "401", GuildID: guild, Type: discordgo.ChannelTypeGuildText, Name: "mod-chat", ParentID: "400",
|
||||||
|
PermissionOverwrites: []*discordgo.PermissionOverwrite{
|
||||||
|
{ID: guild, Type: discordgo.PermissionOverwriteTypeRole, Deny: discordgo.PermissionViewChannel},
|
||||||
|
{ID: "201", Type: discordgo.PermissionOverwriteTypeRole, Allow: discordgo.PermissionViewChannel},
|
||||||
|
}}
|
||||||
|
d.channels["402"] = &discordgo.Channel{ID: "402", GuildID: guild, Type: discordgo.ChannelTypeGuildPublicThread, Name: "a thread"}
|
||||||
|
|
||||||
|
if _, err := e.Restore(ctx, guild, "1"); !errors.As(err, new(UserError)) {
|
||||||
|
t.Fatalf("no snapshot yet should be a clear message: %v", err)
|
||||||
|
}
|
||||||
|
roles, channels, err := e.TakeSnapshot(ctx, guild, "1")
|
||||||
|
if err != nil || roles != 5 || channels != 2 {
|
||||||
|
t.Fatalf("snapshot = %d roles, %d channels, %v (managed roles and threads are skipped)", roles, channels, err)
|
||||||
|
}
|
||||||
|
var data snapshotData
|
||||||
|
_ = json.Unmarshal(st.snapshots[0].Data, &data)
|
||||||
|
for _, r := range data.Roles {
|
||||||
|
if r.ID == "204" || r.ID == "299" {
|
||||||
|
t.Error("integration roles must not be in the snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nuke: the Mod role and the whole Staff category are deleted, and
|
||||||
|
// the Member role is given Administrator.
|
||||||
|
var keep []*discordgo.Role
|
||||||
|
for _, r := range d.roles {
|
||||||
|
if r.ID != "201" {
|
||||||
|
keep = append(keep, r)
|
||||||
|
}
|
||||||
|
if r.ID == "202" {
|
||||||
|
r.Permissions |= discordgo.PermissionAdministrator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.roles = keep
|
||||||
|
delete(d.channels, "400")
|
||||||
|
delete(d.channels, "401")
|
||||||
|
|
||||||
|
res, err := e.Restore(ctx, guild, "1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.RolesCreated != 1 || res.RolesReverted != 1 || res.ChannelsCreated != 2 || res.Failed != 0 {
|
||||||
|
t.Fatalf("restore result = %+v", res)
|
||||||
|
}
|
||||||
|
var newMod, newCat, newText *string
|
||||||
|
for _, r := range d.roles {
|
||||||
|
if r.Name == "Mod" {
|
||||||
|
newMod = &r.ID
|
||||||
|
}
|
||||||
|
if r.ID == "202" && r.Permissions&discordgo.PermissionAdministrator != 0 {
|
||||||
|
t.Error("Administrator added to Member should be reverted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range d.channels {
|
||||||
|
switch c.Name {
|
||||||
|
case "Staff":
|
||||||
|
newCat = &c.ID
|
||||||
|
case "mod-chat":
|
||||||
|
newText = &c.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newMod == nil || newCat == nil || newText == nil {
|
||||||
|
t.Fatal("role or channels not recreated")
|
||||||
|
}
|
||||||
|
text := d.channels[*newText]
|
||||||
|
if text.ParentID != *newCat {
|
||||||
|
t.Error("the channel should go back into the recreated category")
|
||||||
|
}
|
||||||
|
foundMod := false
|
||||||
|
for _, o := range text.PermissionOverwrites {
|
||||||
|
if o.ID == *newMod && o.Allow == discordgo.PermissionViewChannel {
|
||||||
|
foundMod = true
|
||||||
|
}
|
||||||
|
if o.ID == "201" {
|
||||||
|
t.Error("overwrites must point at the recreated role, not the deleted one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMod {
|
||||||
|
t.Errorf("the Mod overwrite should be restored: %+v", text.PermissionOverwrites)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restoring again finds nothing missing.
|
||||||
|
res, _ = e.Restore(ctx, guild, "1")
|
||||||
|
if res.RolesCreated+res.ChannelsCreated+res.RolesReverted != 0 {
|
||||||
|
t.Errorf("a second restore should change nothing: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyOneRestoreAtATime(t *testing.T) {
|
||||||
|
e, _, _, _ := testEngine(t)
|
||||||
|
e.restoring[guild] = true
|
||||||
|
if _, err := e.Restore(ctx, guild, "1"); err == nil || !strings.Contains(err.Error(), "already running") {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heat is a per-member spam score, like Wick's. Every message adds heat,
|
||||||
|
// more for what spam looks like; heat cools down over time, so normal
|
||||||
|
// conversation never builds up. At maxHeat the message is deleted and the
|
||||||
|
// configured action applied.
|
||||||
|
const (
|
||||||
|
maxHeat = 100.0
|
||||||
|
heatDecayPerSec = 8.0 // a message every couple of seconds never accumulates
|
||||||
|
heatBase = 8.0
|
||||||
|
heatRepeat = 25.0
|
||||||
|
heatMention = 10.0
|
||||||
|
heatEveryone = 60.0
|
||||||
|
heatEmoji = 2.0
|
||||||
|
heatLine = 2.0 // per line beyond the fourth
|
||||||
|
heatPer100Chars = 1.0
|
||||||
|
heatAttachment = 10.0
|
||||||
|
heatLink = 8.0
|
||||||
|
heatBlockedWord = 60.0
|
||||||
|
maxHeatUsers = 50000 // per guild, swept when exceeded
|
||||||
|
panicWindow = time.Minute
|
||||||
|
panicDuration = 10 * time.Minute
|
||||||
|
panicTimeout = time.Hour
|
||||||
|
panicNewMember = 24 * time.Hour
|
||||||
|
mentionWindow = 30 * time.Second
|
||||||
|
floodCooldown = 10 * time.Minute
|
||||||
|
heatAlertsPerMin = 5
|
||||||
|
maxTimeout = 28*24*time.Hour - time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
linkRe = regexp.MustCompile(`https?://\S+`)
|
||||||
|
customEmojiRe = regexp.MustCompile(`<a?:\w+:\d+>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var strictness = map[string]float64{"low": 0.6, "medium": 1.0, "high": 1.5}
|
||||||
|
|
||||||
|
type memberHeat struct {
|
||||||
|
heat float64
|
||||||
|
last time.Time
|
||||||
|
recent []string // last few messages, normalized, for repeats
|
||||||
|
offences int // within the last day
|
||||||
|
lastOffence time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type guildHeat struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
members map[string]*memberHeat
|
||||||
|
maxedAt map[string]time.Time // who hit max heat recently, for panic
|
||||||
|
panicUntil time.Time
|
||||||
|
mentions []time.Time // mentions by non-exempt members, for flood lockdown
|
||||||
|
lastFlood time.Time
|
||||||
|
alertTimes []time.Time
|
||||||
|
alertsMuted int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) heatFor(guildID string) *guildHeat {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
g, ok := e.heat[guildID]
|
||||||
|
if !ok {
|
||||||
|
g = &guildHeat{members: map[string]*memberHeat{}, maxedAt: map[string]time.Time{}}
|
||||||
|
e.heat[guildID] = g
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(s string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func countEmoji(s string) int {
|
||||||
|
n := len(customEmojiRe.FindAllString(s, -1))
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= 0x1F000 || (r >= 0x2600 && r <= 0x27BF) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageHeat scores one message. recent is the author's last few messages
|
||||||
|
// (normalized) for spotting repeats. The second result is true when the
|
||||||
|
// message is an instant maximum (an invite link).
|
||||||
|
func MessageHeat(s *securityrepo.Settings, m *discordgo.Message, recent []string) (float64, bool, string) {
|
||||||
|
content := m.Content
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
if inviteRe.MatchString(lower) {
|
||||||
|
return maxHeat, true, "invite link"
|
||||||
|
}
|
||||||
|
reason := "spam"
|
||||||
|
h := heatBase
|
||||||
|
norm := normalize(content)
|
||||||
|
if norm != "" {
|
||||||
|
for _, r := range recent {
|
||||||
|
if r == norm {
|
||||||
|
h += heatRepeat
|
||||||
|
reason = "repeated messages"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mentions := len(m.Mentions) + len(m.MentionRoles)
|
||||||
|
h += float64(mentions) * heatMention
|
||||||
|
if m.MentionEveryone || strings.Contains(lower, "@everyone") || strings.Contains(lower, "@here") {
|
||||||
|
h += heatEveryone
|
||||||
|
reason = "mass mentions"
|
||||||
|
} else if mentions >= 3 {
|
||||||
|
reason = "mention spam"
|
||||||
|
}
|
||||||
|
h += float64(countEmoji(content)) * heatEmoji
|
||||||
|
if lines := strings.Count(content, "\n") + 1; lines > 4 {
|
||||||
|
h += float64(lines-4) * heatLine
|
||||||
|
}
|
||||||
|
h += float64(len([]rune(content))/100) * heatPer100Chars
|
||||||
|
h += float64(len(m.Attachments)+len(m.StickerItems)) * heatAttachment
|
||||||
|
h += float64(len(linkRe.FindAllString(content, -1))) * heatLink
|
||||||
|
for _, w := range s.HeatBlockedWords {
|
||||||
|
if w = strings.ToLower(strings.TrimSpace(w)); w != "" && strings.Contains(lower, w) {
|
||||||
|
h += heatBlockedWord
|
||||||
|
reason = "blocked word"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mult := strictness[s.HeatStrictness]
|
||||||
|
if mult == 0 {
|
||||||
|
mult = 1
|
||||||
|
}
|
||||||
|
return h * mult, false, reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// heatExempt: bots, webhooks, the owner, trusted members and anyone who can
|
||||||
|
// manage messages in the channel.
|
||||||
|
func (e *Engine) heatExempt(ctx context.Context, m *discordgo.MessageCreate) bool {
|
||||||
|
if m.Author == nil || m.Author.Bot || m.WebhookID != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var roles []string
|
||||||
|
if m.Member != nil {
|
||||||
|
roles = m.Member.Roles
|
||||||
|
}
|
||||||
|
if e.IsTrusted(ctx, m.GuildID, m.Author.ID, nonNil(roles)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.ChannelPerms != nil {
|
||||||
|
if p, err := e.ChannelPerms(m.Author.ID, m.ChannelID); err == nil &&
|
||||||
|
p&(discordgo.PermissionAdministrator|discordgo.PermissionManageMessages) != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage runs the heat system on a guild message.
|
||||||
|
func (e *Engine) OnMessage(ctx context.Context, m *discordgo.MessageCreate) {
|
||||||
|
if m == nil || m.Message == nil || m.GuildID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, _, err := e.load(ctx, m.GuildID)
|
||||||
|
if err != nil || !s.HeatEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.heatExempt(ctx, m) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := e.now()
|
||||||
|
g := e.heatFor(m.GuildID)
|
||||||
|
|
||||||
|
// Mention flood: lock the server once, then cool down.
|
||||||
|
if s.HeatLockdownMentions > 0 {
|
||||||
|
n := len(m.Mentions) + len(m.MentionRoles)
|
||||||
|
if m.MentionEveryone {
|
||||||
|
n += 10
|
||||||
|
}
|
||||||
|
if n > 0 && e.mentionFlood(g, now, n, s.HeatLockdownMentions) {
|
||||||
|
go e.floodLockdown(context.Background(), m.GuildID, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
if now.Before(g.panicUntil) && m.Member != nil && !m.Member.JoinedAt.IsZero() && now.Sub(m.Member.JoinedAt) < panicNewMember {
|
||||||
|
g.mu.Unlock()
|
||||||
|
e.heatPunish(ctx, m, s, "heat panic: new member speaking during a spam wave", panicTimeout, securityrepo.ActionTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mh, ok := g.members[m.Author.ID]
|
||||||
|
if !ok {
|
||||||
|
if len(g.members) >= maxHeatUsers {
|
||||||
|
g.sweep(now)
|
||||||
|
}
|
||||||
|
mh = &memberHeat{}
|
||||||
|
g.members[m.Author.ID] = mh
|
||||||
|
}
|
||||||
|
if !mh.last.IsZero() {
|
||||||
|
mh.heat = math.Max(0, mh.heat-now.Sub(mh.last).Seconds()*heatDecayPerSec)
|
||||||
|
}
|
||||||
|
mh.last = now
|
||||||
|
add, instant, reason := MessageHeat(s, m.Message, mh.recent)
|
||||||
|
mh.heat += add
|
||||||
|
if norm := normalize(m.Content); norm != "" {
|
||||||
|
mh.recent = append(mh.recent, norm)
|
||||||
|
if len(mh.recent) > 3 {
|
||||||
|
mh.recent = mh.recent[len(mh.recent)-3:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mh.heat < maxHeat && !instant {
|
||||||
|
g.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Over the top: reset, count the offence, and work out the timeout.
|
||||||
|
mh.heat = 0
|
||||||
|
if now.Sub(mh.lastOffence) > 24*time.Hour {
|
||||||
|
mh.offences = 0
|
||||||
|
}
|
||||||
|
mh.offences++
|
||||||
|
mh.lastOffence = now
|
||||||
|
timeout := time.Duration(s.HeatTimeoutMinutes) * time.Minute
|
||||||
|
if s.HeatEscalate && mh.offences > 1 {
|
||||||
|
timeout *= time.Duration(1 << min(mh.offences-1, 12))
|
||||||
|
}
|
||||||
|
if timeout > maxTimeout {
|
||||||
|
timeout = maxTimeout
|
||||||
|
}
|
||||||
|
// Heat panic: enough different members maxing out within a minute.
|
||||||
|
g.maxedAt[m.Author.ID] = now
|
||||||
|
for u, t := range g.maxedAt {
|
||||||
|
if now.Sub(t) > panicWindow {
|
||||||
|
delete(g.maxedAt, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startPanic := s.HeatPanicMembers > 0 && len(g.maxedAt) >= s.HeatPanicMembers && !now.Before(g.panicUntil)
|
||||||
|
if startPanic {
|
||||||
|
g.panicUntil = now.Add(panicDuration)
|
||||||
|
}
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
e.heatPunish(ctx, m, s, reason, timeout, s.HeatAction)
|
||||||
|
if startPanic {
|
||||||
|
e.event(ctx, m.GuildID, securityrepo.EventHeatPanic, e.botID, "", fmt.Sprintf("%d members hit max heat within a minute", s.HeatPanicMembers))
|
||||||
|
e.send(ctx, m.GuildID, s, alert{ping: true, critical: true, embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "🔥 Spam wave: heat panic on",
|
||||||
|
Description: fmt.Sprintf("%d members hit max heat within a minute. For the next 10 minutes, anyone who joined in the last day is timed out as soon as they speak.", s.HeatPanicMembers),
|
||||||
|
Color: colorAlert,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *guildHeat) sweep(now time.Time) {
|
||||||
|
for u, mh := range g.members {
|
||||||
|
cooled := mh.heat-now.Sub(mh.last).Seconds()*heatDecayPerSec <= 0
|
||||||
|
if cooled && now.Sub(mh.lastOffence) > 24*time.Hour {
|
||||||
|
delete(g.members, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionFlood records n mentions and reports whether the guild just went
|
||||||
|
// over the limit (once per cooldown).
|
||||||
|
func (e *Engine) mentionFlood(g *guildHeat, now time.Time, n, limit int) bool {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
for i := 0; i < n && len(g.mentions) < 5000; i++ {
|
||||||
|
g.mentions = append(g.mentions, now)
|
||||||
|
}
|
||||||
|
cut := 0
|
||||||
|
for cut < len(g.mentions) && now.Sub(g.mentions[cut]) > mentionWindow {
|
||||||
|
cut++
|
||||||
|
}
|
||||||
|
g.mentions = g.mentions[cut:]
|
||||||
|
if len(g.mentions) >= limit && now.Sub(g.lastFlood) > floodCooldown {
|
||||||
|
g.lastFlood = now
|
||||||
|
g.mentions = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) floodLockdown(ctx context.Context, guildID string, s *securityrepo.Settings) {
|
||||||
|
locked, failed, err := e.LockServer(ctx, guildID, e.botID)
|
||||||
|
desc := fmt.Sprintf("%d or more mentions within 30 seconds: every channel is locked (%d locked", s.HeatLockdownMentions, locked)
|
||||||
|
if failed > 0 {
|
||||||
|
desc += fmt.Sprintf(", %d failed", failed)
|
||||||
|
}
|
||||||
|
desc += "). Unlock with `/unlock server` once it's under control."
|
||||||
|
if err != nil {
|
||||||
|
desc = "A mention flood was detected but locking the server failed: " + clip(err.Error(), 200)
|
||||||
|
}
|
||||||
|
e.send(ctx, guildID, s, alert{ping: true, critical: true, embed: &discordgo.MessageEmbed{Title: "🔒 Mention flood: server locked", Description: desc, Color: colorAlert}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// heatPunish deletes the message and applies the action. Alerts are capped
|
||||||
|
// per minute so a spam wave can't flood the alert channel.
|
||||||
|
func (e *Engine) heatPunish(ctx context.Context, m *discordgo.MessageCreate, s *securityrepo.Settings, reason string, timeout time.Duration, action string) {
|
||||||
|
if err := e.d.ChannelMessageDelete(m.ChannelID, m.ID, discordgo.WithAuditLogReason("Velox heat: "+reason)); err != nil {
|
||||||
|
log.Printf("security: deleting a hot message in guild %s: %v", m.GuildID, err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if action == securityrepo.ActionTimeout {
|
||||||
|
if m.Author.ID == e.botID || e.IsOwner(m.GuildID, m.Author.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until := e.now().Add(timeout)
|
||||||
|
err = e.d.GuildMemberTimeout(m.GuildID, m.Author.ID, &until, discordgo.WithAuditLogReason(clip("Velox heat: "+reason, 400)))
|
||||||
|
} else {
|
||||||
|
err = e.punish(m.GuildID, m.Author, action, "heat: "+reason)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: heat action on %s in guild %s: %v", m.Author.ID, m.GuildID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
what := action
|
||||||
|
if action == securityrepo.ActionTimeout {
|
||||||
|
what = "timed out for " + humanize(timeout)
|
||||||
|
}
|
||||||
|
e.event(ctx, m.GuildID, securityrepo.EventHeat, e.botID, m.Author.ID, what+": "+reason)
|
||||||
|
|
||||||
|
g := e.heatFor(m.GuildID)
|
||||||
|
now := e.now()
|
||||||
|
g.mu.Lock()
|
||||||
|
cut := 0
|
||||||
|
for cut < len(g.alertTimes) && now.Sub(g.alertTimes[cut]) > time.Minute {
|
||||||
|
cut++
|
||||||
|
}
|
||||||
|
g.alertTimes = g.alertTimes[cut:]
|
||||||
|
allowed := len(g.alertTimes) < heatAlertsPerMin
|
||||||
|
muted := 0
|
||||||
|
if allowed {
|
||||||
|
g.alertTimes = append(g.alertTimes, now)
|
||||||
|
muted, g.alertsMuted = g.alertsMuted, 0
|
||||||
|
} else {
|
||||||
|
g.alertsMuted++
|
||||||
|
}
|
||||||
|
g.mu.Unlock()
|
||||||
|
if !allowed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf("<@%s> (%s) was %s: %s. Their message was deleted.", m.Author.ID, safeName(m.Author), what, reason)
|
||||||
|
if muted > 0 {
|
||||||
|
desc += fmt.Sprintf("\n(%d more since the last alert weren't posted, to keep this channel readable.)", muted)
|
||||||
|
}
|
||||||
|
e.send(ctx, m.GuildID, s, alert{embed: &discordgo.MessageEmbed{Title: "Heat", Description: desc, Color: colorWarn}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanize(d time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case d >= 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%.0f days", d.Hours()/24)
|
||||||
|
case d >= time.Hour:
|
||||||
|
return fmt.Sprintf("%.0f hours", d.Hours())
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.0f minutes", math.Max(1, d.Minutes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invite links in a name: discord.gg/x, discord.com/invite/x, dsc.gg/x and
|
||||||
|
// the common lookalikes. Matched case-insensitively against the lowercased
|
||||||
|
// name; the pattern is fixed, never built from user input.
|
||||||
|
var inviteRe = regexp.MustCompile(`(discord(app)?\.(gg|com/invite|io|me|li)|dsc\.gg|\.gg/)`)
|
||||||
|
|
||||||
|
var severity = map[string]int{
|
||||||
|
securityrepo.ActionOff: 0, securityrepo.ActionAlert: 1, securityrepo.ActionTimeout: 2,
|
||||||
|
securityrepo.ActionKick: 3, securityrepo.ActionBan: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateResult is what the Join Gate decided about one member.
|
||||||
|
type GateResult struct {
|
||||||
|
Action string // off, timeout, kick, ban
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckJoin runs the Join Gate filters on a new member. When several match,
|
||||||
|
// the strictest action wins. Bots are only checked for being unverified;
|
||||||
|
// bots added by untrusted members are handled from the audit log.
|
||||||
|
func CheckJoin(s *securityrepo.Settings, u *discordgo.User, now time.Time) GateResult {
|
||||||
|
var res GateResult
|
||||||
|
consider := func(action, reason string) {
|
||||||
|
if severity[action] > severity[res.Action] {
|
||||||
|
res = GateResult{Action: action, Reason: reason}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.Action = securityrepo.ActionOff
|
||||||
|
if !s.JoinGateEnabled || u == nil {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Bot {
|
||||||
|
if u.PublicFlags&discordgo.UserFlagVerifiedBot == 0 {
|
||||||
|
consider(s.UnverifiedBot, "unverified bot")
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
names := strings.ToLower(u.Username + " " + u.GlobalName)
|
||||||
|
if inviteRe.MatchString(names) {
|
||||||
|
consider(s.AdvertisingName, "invite link in name")
|
||||||
|
}
|
||||||
|
if s.UsernameFilter != securityrepo.ActionOff {
|
||||||
|
for _, p := range s.UsernamePatterns {
|
||||||
|
p = strings.ToLower(strings.TrimSpace(p))
|
||||||
|
if p != "" && strings.Contains(names, p) {
|
||||||
|
consider(s.UsernameFilter, "blocked word in name")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if created, err := discordgo.SnowflakeTimestamp(u.ID); err == nil {
|
||||||
|
if now.Sub(created) < time.Duration(s.MinAccountAgeDays)*24*time.Hour {
|
||||||
|
consider(s.NewAccount, fmt.Sprintf("account younger than %d days", s.MinAccountAgeDays))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Avatar == "" {
|
||||||
|
consider(s.NoAvatar, "no avatar")
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// errProtected is returned when asked to punish the owner or the bot.
|
||||||
|
var errProtected = errors.New("the owner and the bot are never punished")
|
||||||
|
|
||||||
|
// punish applies a member action. It never touches the owner or the bot.
|
||||||
|
func (e *Engine) punish(guildID string, u *discordgo.User, action, reason string) error {
|
||||||
|
if u == nil || u.ID == e.botID || e.IsOwner(guildID, u.ID) {
|
||||||
|
return errProtected
|
||||||
|
}
|
||||||
|
reason = clip("Velox security: "+reason, 400)
|
||||||
|
switch action {
|
||||||
|
case securityrepo.ActionTimeout:
|
||||||
|
until := e.now().Add(timeoutJoin)
|
||||||
|
return e.d.GuildMemberTimeout(guildID, u.ID, &until, discordgo.WithAuditLogReason(reason))
|
||||||
|
case securityrepo.ActionKick:
|
||||||
|
return e.d.GuildMemberDeleteWithReason(guildID, u.ID, reason)
|
||||||
|
case securityrepo.ActionBan:
|
||||||
|
return e.d.GuildBanCreateWithReason(guildID, u.ID, reason, 1)
|
||||||
|
}
|
||||||
|
return errors.New("no action " + action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMemberJoin runs every check for a new member: the join lock, the Join
|
||||||
|
// Gate and raid detection. It reports whether the member was removed, so
|
||||||
|
// welcome messages and default roles can be skipped.
|
||||||
|
func (e *Engine) OnMemberJoin(ctx context.Context, guildID string, m *discordgo.Member) (removed bool) {
|
||||||
|
if m == nil || m.User == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s, _, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: settings for guild %s: %v", guildID, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Join lockdown: everyone new is kicked or banned.
|
||||||
|
if s.JoinLock == securityrepo.ActionKick || s.JoinLock == securityrepo.ActionBan {
|
||||||
|
if err := e.punish(guildID, m.User, s.JoinLock, "joins are locked"); err != nil {
|
||||||
|
if !errors.Is(err, errProtected) {
|
||||||
|
log.Printf("security: join lock in guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e.raidJoin(ctx, guildID, s, m, true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Join Gate. A bot's verified flag isn't always in the join event, so
|
||||||
|
// it's read from Discord; if that fails the bot is left alone rather than
|
||||||
|
// risk kicking a verified one.
|
||||||
|
u := m.User
|
||||||
|
if u.Bot && s.JoinGateEnabled && s.UnverifiedBot != securityrepo.ActionOff {
|
||||||
|
fresh, err := e.d.User(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: looking up bot %s: %v", u.ID, err)
|
||||||
|
a := e.raidJoin(ctx, guildID, s, m, false)
|
||||||
|
return a == securityrepo.ActionKick || a == securityrepo.ActionBan
|
||||||
|
}
|
||||||
|
u = fresh
|
||||||
|
}
|
||||||
|
gate := CheckJoin(s, u, e.now())
|
||||||
|
if gate.Action != securityrepo.ActionOff {
|
||||||
|
if err := e.punish(guildID, m.User, gate.Action, gate.Reason); err != nil {
|
||||||
|
if !errors.Is(err, errProtected) {
|
||||||
|
log.Printf("security: join gate in guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removed = gate.Action == securityrepo.ActionKick || gate.Action == securityrepo.ActionBan
|
||||||
|
// During a raid the summary lists everyone; one alert per
|
||||||
|
// raider would flood the channel and Discord's rate limit.
|
||||||
|
if !e.RaidActive(guildID) {
|
||||||
|
e.event(ctx, guildID, securityrepo.EventJoinGate, e.botID, m.User.ID, gate.Action+": "+gate.Reason)
|
||||||
|
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "Join Gate",
|
||||||
|
Description: fmt.Sprintf("%s <@%s> (%s): %s.", actionPast(gate.Action), m.User.ID, safeName(m.User), gate.Reason),
|
||||||
|
Color: colorWarn,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Raid detection, which counts every join, gated or not.
|
||||||
|
if a := e.raidJoin(ctx, guildID, s, m, removed); a == securityrepo.ActionKick || a == securityrepo.ActionBan {
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionPast(a string) string {
|
||||||
|
switch a {
|
||||||
|
case securityrepo.ActionTimeout:
|
||||||
|
return "Timed out"
|
||||||
|
case securityrepo.ActionKick:
|
||||||
|
return "Kicked"
|
||||||
|
case securityrepo.ActionBan:
|
||||||
|
return "Banned"
|
||||||
|
}
|
||||||
|
return "Flagged"
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartCommandListener carries out the dashboard's requests
|
||||||
|
// (security_commands rows announced with NOTIFY). The dashboard API checks
|
||||||
|
// that the requester is the owner or trusted before inserting; the payload
|
||||||
|
// is still validated here.
|
||||||
|
func (e *Engine) StartCommandListener(ctx context.Context, connString string) error {
|
||||||
|
conn, err := pgx.Connect(ctx, connString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
if _, err := conn.Exec(ctx, "LISTEN security_commands"); err != nil {
|
||||||
|
return fmt.Errorf("listen: %w", err)
|
||||||
|
}
|
||||||
|
e.ProcessCommands(ctx)
|
||||||
|
for {
|
||||||
|
if _, err := conn.WaitForNotification(ctx); err != nil {
|
||||||
|
return fmt.Errorf("waiting for notifications: %w", err)
|
||||||
|
}
|
||||||
|
e.ProcessCommands(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessCommands runs every pending command once and marks it done, even
|
||||||
|
// when it fails (the error is logged; retrying would only fail again).
|
||||||
|
func (e *Engine) ProcessCommands(ctx context.Context) {
|
||||||
|
cmds, err := e.st.ListUnprocessedCommands(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: listing dashboard commands: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range cmds {
|
||||||
|
if err := e.runCommand(ctx, c); err != nil {
|
||||||
|
log.Printf("security: dashboard command %d (%s) for guild %d: %v", c.ID, c.CommandType, c.GuildID, err)
|
||||||
|
}
|
||||||
|
if err := e.st.MarkCommandProcessed(ctx, c.ID); err != nil {
|
||||||
|
log.Printf("security: marking command %d done: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) runCommand(ctx context.Context, c securityrepo.Command) error {
|
||||||
|
guildID, by := id(c.GuildID), id(c.RequestedBy)
|
||||||
|
switch c.CommandType {
|
||||||
|
case "end_raid":
|
||||||
|
return e.EndRaid(ctx, guildID, by)
|
||||||
|
case "unlock_server":
|
||||||
|
_, _, err := e.UnlockServer(ctx, guildID, by)
|
||||||
|
return err
|
||||||
|
case "unlock_joins":
|
||||||
|
return e.SetJoinLock(ctx, guildID, securityrepo.ActionOff, by)
|
||||||
|
case "snapshot":
|
||||||
|
_, _, err := e.TakeSnapshot(ctx, guildID, by)
|
||||||
|
return err
|
||||||
|
case "restore":
|
||||||
|
// Recreating many channels takes minutes under Discord's rate
|
||||||
|
// limits; don't hold up the other commands meanwhile.
|
||||||
|
go func() {
|
||||||
|
if _, err := e.Restore(context.Background(), guildID, by); err != nil {
|
||||||
|
log.Printf("security: restore for guild %s from the dashboard: %v", guildID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
case "release_quarantine":
|
||||||
|
var p struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(c.Payload, &p); err != nil {
|
||||||
|
return fmt.Errorf("bad payload: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := strconv.ParseUint(p.UserID, 10, 64); err != nil {
|
||||||
|
return fmt.Errorf("bad user id %q", p.UserID)
|
||||||
|
}
|
||||||
|
return e.Release(ctx, guildID, p.UserID, by)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown command %q", c.CommandType)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LockBits are what a lockdown denies @everyone: talking, threads, reactions.
|
||||||
|
const LockBits = discordgo.PermissionSendMessages | discordgo.PermissionSendMessagesInThreads |
|
||||||
|
discordgo.PermissionCreatePublicThreads | discordgo.PermissionCreatePrivateThreads | discordgo.PermissionAddReactions
|
||||||
|
|
||||||
|
// lockable reports whether a channel type has a chat to lock.
|
||||||
|
func lockable(t discordgo.ChannelType) bool {
|
||||||
|
switch t {
|
||||||
|
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
|
||||||
|
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice, discordgo.ChannelTypeGuildMedia:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func everyoneOverwrite(ch *discordgo.Channel, guildID string) (allow, deny int64, found bool) {
|
||||||
|
for _, o := range ch.PermissionOverwrites {
|
||||||
|
if o.ID == guildID && o.Type == discordgo.PermissionOverwriteTypeRole {
|
||||||
|
return o.Allow, o.Deny, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockChannel stops @everyone from talking in a channel, remembering the
|
||||||
|
// overwrite it had so UnlockChannel can put it back.
|
||||||
|
func (e *Engine) LockChannel(ctx context.Context, guildID, channelID, by string, serverWide bool) error {
|
||||||
|
ch, err := e.d.Channel(channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("looking up the channel: %w", err)
|
||||||
|
}
|
||||||
|
// Never act on a channel of another server, whatever ID was passed in.
|
||||||
|
if ch.GuildID != guildID {
|
||||||
|
return UserError("That channel isn't in this server.")
|
||||||
|
}
|
||||||
|
if !lockable(ch.Type) {
|
||||||
|
return UserError("That channel has no chat to lock.")
|
||||||
|
}
|
||||||
|
allow, deny, had := everyoneOverwrite(ch, guildID)
|
||||||
|
if err := e.st.SaveLockdown(ctx, securityrepo.Lockdown{
|
||||||
|
GuildID: parseID(guildID), ChannelID: parseID(channelID), HadOverwrite: had,
|
||||||
|
PreviousAllow: allow, PreviousDeny: deny, ServerWide: serverWide, LockedBy: parseID(by),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow&^LockBits, deny|LockBits,
|
||||||
|
discordgo.WithAuditLogReason("Velox lockdown")); err != nil {
|
||||||
|
_ = e.st.DeleteLockdown(ctx, parseID(guildID), parseID(channelID))
|
||||||
|
return fmt.Errorf("locking: %w", err)
|
||||||
|
}
|
||||||
|
if !serverWide {
|
||||||
|
e.event(ctx, guildID, securityrepo.EventLocked, by, channelID, "channel")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockChannel restores the lock bits of @everyone's overwrite to what they
|
||||||
|
// were before. Anything else changed in the overwrite during the lockdown is
|
||||||
|
// kept. Unlocking relaxes protection, so callers must check the actor first.
|
||||||
|
func (e *Engine) UnlockChannel(ctx context.Context, guildID, channelID, by string) error {
|
||||||
|
rec, err := e.st.GetLockdown(ctx, parseID(guildID), parseID(channelID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rec == nil {
|
||||||
|
return UserError("That channel isn't locked by Velox.")
|
||||||
|
}
|
||||||
|
ch, err := e.d.Channel(channelID)
|
||||||
|
if err != nil {
|
||||||
|
// The channel is gone: nothing to unlock.
|
||||||
|
return e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID)
|
||||||
|
}
|
||||||
|
allow, deny, _ := everyoneOverwrite(ch, guildID)
|
||||||
|
allow = allow&^LockBits | rec.PreviousAllow&LockBits
|
||||||
|
deny = deny&^LockBits | rec.PreviousDeny&LockBits
|
||||||
|
|
||||||
|
reason := discordgo.WithAuditLogReason("Velox unlock")
|
||||||
|
if allow == 0 && deny == 0 && !rec.HadOverwrite {
|
||||||
|
err = e.d.ChannelPermissionDelete(channelID, guildID, reason)
|
||||||
|
} else {
|
||||||
|
err = e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow, deny, reason)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unlocking: %w", err)
|
||||||
|
}
|
||||||
|
if err := e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !rec.ServerWide {
|
||||||
|
e.event(ctx, guildID, securityrepo.EventUnlocked, by, channelID, "channel")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockServer locks every channel with a chat.
|
||||||
|
func (e *Engine) LockServer(ctx context.Context, guildID, by string) (locked, failed int, err error) {
|
||||||
|
chans, err := e.d.GuildChannels(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("listing channels: %w", err)
|
||||||
|
}
|
||||||
|
for _, ch := range chans {
|
||||||
|
if !lockable(ch.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.LockChannel(ctx, guildID, ch.ID, by, true); err != nil {
|
||||||
|
log.Printf("security: locking channel %s in guild %s: %v", ch.ID, guildID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
locked++
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventLocked, by, "", fmt.Sprintf("server: %d channels locked, %d failed", locked, failed))
|
||||||
|
return locked, failed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockServer unlocks every channel the last server lockdown locked.
|
||||||
|
func (e *Engine) UnlockServer(ctx context.Context, guildID, by string) (unlocked, failed int, err error) {
|
||||||
|
recs, err := e.st.ListLockdowns(ctx, parseID(guildID), true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(recs) == 0 {
|
||||||
|
return 0, 0, UserError("The server isn't locked by Velox.")
|
||||||
|
}
|
||||||
|
for _, r := range recs {
|
||||||
|
if err := e.UnlockChannel(ctx, guildID, id(r.ChannelID), by); err != nil {
|
||||||
|
log.Printf("security: unlocking channel %d in guild %s: %v", r.ChannelID, guildID, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unlocked++
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventUnlocked, by, "", fmt.Sprintf("server: %d channels unlocked, %d failed", unlocked, failed))
|
||||||
|
return unlocked, failed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJoinLock turns the join lockdown on ("kick" or "ban") or off. Turning it
|
||||||
|
// off relaxes protection, so callers must check the actor first.
|
||||||
|
func (e *Engine) SetJoinLock(ctx context.Context, guildID, mode, by string) error {
|
||||||
|
switch mode {
|
||||||
|
case securityrepo.ActionOff, securityrepo.ActionKick, securityrepo.ActionBan:
|
||||||
|
default:
|
||||||
|
return UserError("Joins can be locked with kick or ban.")
|
||||||
|
}
|
||||||
|
if err := e.st.SetJoinLock(ctx, parseID(guildID), mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Invalidate(guildID)
|
||||||
|
kind := securityrepo.EventLocked
|
||||||
|
if mode == securityrepo.ActionOff {
|
||||||
|
kind = securityrepo.EventUnlocked
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, kind, by, "", "joins: "+mode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nukeCounter counts destructive actions per server, person and kind over a
|
||||||
|
// sliding window, and remembers whom it just punished so one nuke attempt
|
||||||
|
// is answered once.
|
||||||
|
type nukeCounter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hits map[string][]time.Time
|
||||||
|
punished map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxHitsPerKey = 200
|
||||||
|
punishCooldown = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func newNukeCounter() *nukeCounter {
|
||||||
|
return &nukeCounter{hits: map[string][]time.Time{}, punished: map[string]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add records a hit and returns how many fall inside the window.
|
||||||
|
func (c *nukeCounter) add(key string, now time.Time, window time.Duration) int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
h := append(c.hits[key], now)
|
||||||
|
cut := 0
|
||||||
|
for cut < len(h) && now.Sub(h[cut]) > window {
|
||||||
|
cut++
|
||||||
|
}
|
||||||
|
h = h[cut:]
|
||||||
|
if len(h) > maxHitsPerKey {
|
||||||
|
h = h[len(h)-maxHitsPerKey:]
|
||||||
|
}
|
||||||
|
c.hits[key] = h
|
||||||
|
// Opportunistic cleanup so the map can't grow forever.
|
||||||
|
if len(c.hits) > 5000 {
|
||||||
|
for k, v := range c.hits {
|
||||||
|
if len(v) == 0 || now.Sub(v[len(v)-1]) > window {
|
||||||
|
delete(c.hits, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claim reports whether this person may be punished now (not punished in the
|
||||||
|
// last minute), and marks them.
|
||||||
|
func (c *nukeCounter) claim(key string, now time.Time) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if t, ok := c.punished[key]; ok && now.Sub(t) < punishCooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.punished[key] = now
|
||||||
|
if len(c.punished) > 5000 {
|
||||||
|
for k, t := range c.punished {
|
||||||
|
if now.Sub(t) > punishCooldown {
|
||||||
|
delete(c.punished, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeKey(c *discordgo.AuditLogChange) string {
|
||||||
|
if c == nil || c.Key == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(*c.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func permValue(v any) int64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.ParseInt(x, 10, 64)
|
||||||
|
return n
|
||||||
|
case float64:
|
||||||
|
return int64(x)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify maps an audit log entry to a limit key, or flags it as always
|
||||||
|
// forbidden for untrusted members (instant). rolePerms looks up a role's
|
||||||
|
// permissions for role grants.
|
||||||
|
func Classify(entry *discordgo.AuditLogEntry, rolePerms func(roleID string) int64) (key string, instant bool, what string) {
|
||||||
|
if entry == nil || entry.ActionType == nil {
|
||||||
|
return "", false, ""
|
||||||
|
}
|
||||||
|
switch *entry.ActionType {
|
||||||
|
case discordgo.AuditLogActionChannelCreate:
|
||||||
|
return securityrepo.LimitChannelCreate, false, "created channels"
|
||||||
|
case discordgo.AuditLogActionChannelDelete:
|
||||||
|
return securityrepo.LimitChannelDelete, false, "deleted channels"
|
||||||
|
case discordgo.AuditLogActionRoleDelete:
|
||||||
|
return securityrepo.LimitRoleDelete, false, "deleted roles"
|
||||||
|
case discordgo.AuditLogActionMemberBanAdd:
|
||||||
|
return securityrepo.LimitBan, false, "banned members"
|
||||||
|
case discordgo.AuditLogActionMemberKick:
|
||||||
|
return securityrepo.LimitKick, false, "kicked members"
|
||||||
|
case discordgo.AuditLogActionWebhookCreate:
|
||||||
|
return securityrepo.LimitWebhookCreate, false, "created webhooks"
|
||||||
|
case discordgo.AuditLogActionMemberPrune:
|
||||||
|
return "", true, "pruned members"
|
||||||
|
case discordgo.AuditLogActionRoleCreate:
|
||||||
|
for _, c := range entry.Changes {
|
||||||
|
if changeKey(c) == "permissions" && permValue(c.NewValue)&DangerousPerms != 0 {
|
||||||
|
return "", true, "created a role with dangerous permissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return securityrepo.LimitRoleCreate, false, "created roles"
|
||||||
|
case discordgo.AuditLogActionRoleUpdate:
|
||||||
|
for _, c := range entry.Changes {
|
||||||
|
if changeKey(c) == "permissions" {
|
||||||
|
added := permValue(c.NewValue) &^ permValue(c.OldValue)
|
||||||
|
if added&DangerousPerms != 0 {
|
||||||
|
return "", true, "gave a role dangerous permissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case discordgo.AuditLogActionMemberRoleUpdate:
|
||||||
|
for _, c := range entry.Changes {
|
||||||
|
if changeKey(c) != "$add" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list, _ := c.NewValue.([]any)
|
||||||
|
for _, item := range list {
|
||||||
|
if r, ok := item.(map[string]any); ok {
|
||||||
|
if rid, _ := r["id"].(string); rid != "" && rolePerms != nil && rolePerms(rid)&DangerousPerms != 0 {
|
||||||
|
return "", true, "gave someone a role with dangerous permissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case discordgo.AuditLogActionGuildUpdate:
|
||||||
|
for _, c := range entry.Changes {
|
||||||
|
if changeKey(c) == "vanity_url_code" {
|
||||||
|
return "", true, "changed the vanity URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnAuditLogEntry watches what members do: bots added by untrusted members
|
||||||
|
// (Join Gate) and destructive actions over the limits (anti-nuke).
|
||||||
|
func (e *Engine) OnAuditLogEntry(ctx context.Context, guildID string, entry *discordgo.AuditLogEntry) {
|
||||||
|
if entry == nil || entry.ActionType == nil || entry.UserID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, _, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: settings for guild %s: %v", guildID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *entry.ActionType == discordgo.AuditLogActionBotAdd {
|
||||||
|
e.checkBotAdd(ctx, guildID, s, entry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.AntiNukeEnabled || !worthChecking(entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role grants need care: someone could give themselves (or an alt) a
|
||||||
|
// trusted role to slip past everything below. Roles added by this very
|
||||||
|
// entry never count towards the actor's own trust, and an untrusted
|
||||||
|
// member granting a trusted role is stopped and the role taken back.
|
||||||
|
added := addedRoles(entry)
|
||||||
|
var actorRoles []string
|
||||||
|
if m, err := e.d.GuildMember(guildID, entry.UserID); err == nil {
|
||||||
|
actorRoles = m.Roles
|
||||||
|
if entry.TargetID == entry.UserID {
|
||||||
|
actorRoles = without(actorRoles, added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.IsTrusted(ctx, guildID, entry.UserID, nonNil(actorRoles)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, instant, what := Classify(entry, func(roleID string) int64 { return e.rolePerms(guildID, roleID) })
|
||||||
|
if granted := e.trustedAmong(ctx, guildID, added); len(granted) > 0 {
|
||||||
|
instant, what = true, "gave someone a trusted role"
|
||||||
|
e.revokeRoles(guildID, entry.TargetID, granted)
|
||||||
|
}
|
||||||
|
if *entry.ActionType == discordgo.AuditLogActionRoleUpdate && e.isBotRole(guildID, entry.TargetID) {
|
||||||
|
instant, what = true, "changed Velox's own role"
|
||||||
|
}
|
||||||
|
if key == "" && !instant {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
who := guildID + "|" + entry.UserID
|
||||||
|
if !instant {
|
||||||
|
limit, ok := s.AntiNukeLimits[key]
|
||||||
|
if !ok {
|
||||||
|
limit = securityrepo.DefaultLimits()[key]
|
||||||
|
}
|
||||||
|
n := e.nukes.add(who+"|"+key, e.now(), time.Duration(s.AntiNukeWindowSeconds)*time.Second)
|
||||||
|
if n <= limit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
what = fmt.Sprintf("%s %d times in %d seconds (limit %d)", what, n, s.AntiNukeWindowSeconds, limit)
|
||||||
|
}
|
||||||
|
if !e.nukes.claim(who, e.now()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.stopNuke(ctx, guildID, s, entry.UserID, what)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) rolePerms(guildID, roleID string) int64 {
|
||||||
|
for _, r := range e.guildRoles(guildID) {
|
||||||
|
if r.ID == roleID {
|
||||||
|
return r.Permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// worthChecking skips audit log entries that can never trigger anti-nuke
|
||||||
|
// before anything is looked up on Discord. A busy server writes many entries
|
||||||
|
// (message deletes, nickname changes...), and lookups for each would eat
|
||||||
|
// into the rate limit the bot needs most during an attack.
|
||||||
|
func worthChecking(entry *discordgo.AuditLogEntry) bool {
|
||||||
|
switch *entry.ActionType {
|
||||||
|
case discordgo.AuditLogActionChannelCreate, discordgo.AuditLogActionChannelDelete,
|
||||||
|
discordgo.AuditLogActionRoleCreate, discordgo.AuditLogActionRoleDelete, discordgo.AuditLogActionRoleUpdate,
|
||||||
|
discordgo.AuditLogActionMemberBanAdd, discordgo.AuditLogActionMemberKick, discordgo.AuditLogActionMemberPrune,
|
||||||
|
discordgo.AuditLogActionWebhookCreate:
|
||||||
|
return true
|
||||||
|
case discordgo.AuditLogActionMemberRoleUpdate:
|
||||||
|
return len(addedRoles(entry)) > 0 // removing roles is harmless
|
||||||
|
case discordgo.AuditLogActionGuildUpdate:
|
||||||
|
for _, c := range entry.Changes {
|
||||||
|
if changeKey(c) == "vanity_url_code" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCacheTTL = 10 * time.Second
|
||||||
|
|
||||||
|
type cachedRoles struct {
|
||||||
|
roles []*discordgo.Role
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildRoles returns the guild's roles, cached briefly. Staleness can't hide
|
||||||
|
// an escalation: creating or editing a role with dangerous permissions is
|
||||||
|
// caught from its own audit log entry.
|
||||||
|
func (e *Engine) guildRoles(guildID string) []*discordgo.Role {
|
||||||
|
e.mu.Lock()
|
||||||
|
c, ok := e.roleCache[guildID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if ok && e.now().Sub(c.at) < roleCacheTTL {
|
||||||
|
return c.roles
|
||||||
|
}
|
||||||
|
roles, err := e.d.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.roleCache == nil {
|
||||||
|
e.roleCache = map[string]cachedRoles{}
|
||||||
|
}
|
||||||
|
e.roleCache[guildID] = cachedRoles{roles: roles, at: e.now()}
|
||||||
|
e.mu.Unlock()
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) stopNuke(ctx context.Context, guildID string, s *securityrepo.Settings, userID, what string) {
|
||||||
|
reason := "anti-nuke: " + what
|
||||||
|
e.event(ctx, guildID, securityrepo.EventNuke, userID, userID, what)
|
||||||
|
|
||||||
|
desc := fmt.Sprintf("<@%s> %s.", userID, what)
|
||||||
|
if err := e.Quarantine(ctx, guildID, userID, reason); err != nil {
|
||||||
|
log.Printf("security: quarantining %s in guild %s: %v", userID, guildID, err)
|
||||||
|
desc += "\n⚠️ I couldn't fully quarantine them: " + clip(err.Error(), 300) +
|
||||||
|
"\nMove the Velox role above their highest role and give it Manage Roles, Moderate Members and Kick Members."
|
||||||
|
} else {
|
||||||
|
desc += "\nThey were quarantined: roles removed (saved) and timed out. Release them with `/security release` if this was a mistake."
|
||||||
|
}
|
||||||
|
if s.AntiNukeLockServer {
|
||||||
|
locked, failed, err := e.LockServer(ctx, guildID, e.botID)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
desc += "\n⚠️ Locking the server failed: " + clip(err.Error(), 200)
|
||||||
|
default:
|
||||||
|
desc += fmt.Sprintf("\nThe server is locked (%d channels", locked)
|
||||||
|
if failed > 0 {
|
||||||
|
desc += fmt.Sprintf(", %d couldn't be locked", failed)
|
||||||
|
}
|
||||||
|
desc += "). Unlock it with `/unlock server`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.AntiNukeAutoRestore {
|
||||||
|
desc += "\nRestoring deleted roles and channels from the latest snapshot; a report follows."
|
||||||
|
go func() {
|
||||||
|
// Give the attack a moment to stop, then rebuild.
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
if _, err := e.Restore(context.Background(), guildID, e.botID); err != nil {
|
||||||
|
log.Printf("security: auto restore in guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
e.send(ctx, guildID, s, alert{
|
||||||
|
ping: true, pingOwner: true, critical: true,
|
||||||
|
embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "🛡️ Nuke attempt stopped", Description: desc, Color: colorAlert,
|
||||||
|
Timestamp: e.now().UTC().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBotAdd removes a bot that someone other than the owner or a trusted
|
||||||
|
// member added, when the Join Gate is on.
|
||||||
|
func (e *Engine) checkBotAdd(ctx context.Context, guildID string, s *securityrepo.Settings, entry *discordgo.AuditLogEntry) {
|
||||||
|
if !s.JoinGateEnabled || s.UnauthorizedBot == securityrepo.ActionOff || entry.TargetID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.IsTrusted(ctx, guildID, entry.UserID, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bot := &discordgo.User{ID: entry.TargetID}
|
||||||
|
if err := e.punish(guildID, bot, s.UnauthorizedBot, fmt.Sprintf("bot added by %s, who isn't trusted", entry.UserID)); err != nil {
|
||||||
|
log.Printf("security: removing unauthorized bot %s in guild %s: %v", entry.TargetID, guildID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventUnauthorizedBot, entry.UserID, entry.TargetID, s.UnauthorizedBot)
|
||||||
|
e.send(ctx, guildID, s, alert{
|
||||||
|
ping: true, critical: true,
|
||||||
|
embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "Bot removed",
|
||||||
|
Description: fmt.Sprintf("<@%s> added the bot <@%s> but isn't on the trusted list, so the bot was %s.", entry.UserID, entry.TargetID, map[string]string{securityrepo.ActionKick: "kicked", securityrepo.ActionBan: "banned"}[s.UnauthorizedBot]),
|
||||||
|
Color: colorWarn,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addedRoles lists the role IDs a member role update added.
|
||||||
|
func addedRoles(entry *discordgo.AuditLogEntry) []string {
|
||||||
|
if entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionMemberRoleUpdate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, c := range entry.Changes {
|
||||||
|
if changeKey(c) != "$add" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list, _ := c.NewValue.([]any)
|
||||||
|
for _, item := range list {
|
||||||
|
if r, ok := item.(map[string]any); ok {
|
||||||
|
if rid, _ := r["id"].(string); rid != "" {
|
||||||
|
out = append(out, rid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func without(list, remove []string) []string {
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, r := range remove {
|
||||||
|
drop[r] = true
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range list {
|
||||||
|
if !drop[r] {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonNil keeps an empty role list from meaning "look the member up".
|
||||||
|
func nonNil(roles []string) []string {
|
||||||
|
if roles == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedAmong returns which of the roles are on the trusted list.
|
||||||
|
func (e *Engine) trustedAmong(ctx context.Context, guildID string, roles []string) []string {
|
||||||
|
if len(roles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, trusted, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range roles {
|
||||||
|
for _, t := range trusted {
|
||||||
|
if t.Kind == "role" && id(t.TargetID) == r {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeRoles takes roles back from a member (best effort; never the owner).
|
||||||
|
func (e *Engine) revokeRoles(guildID, userID string, roles []string) {
|
||||||
|
if userID == "" || e.IsOwner(guildID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := e.d.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := without(m.Roles, roles)
|
||||||
|
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &next},
|
||||||
|
discordgo.WithAuditLogReason("Velox anti-nuke: trusted role granted by someone untrusted")); err != nil {
|
||||||
|
log.Printf("security: taking back trusted roles from %s in guild %s: %v", userID, guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBotRole reports whether roleID is one of Velox's own roles.
|
||||||
|
func (e *Engine) isBotRole(guildID, roleID string) bool {
|
||||||
|
m, err := e.d.GuildMember(guildID, e.botID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range m.Roles {
|
||||||
|
if r == roleID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usableQuarantineRole returns the configured quarantine role if it is safe
|
||||||
|
// to hand out right now: it exists, isn't @everyone or managed by an
|
||||||
|
// integration, and has none of the dangerous permissions. Checked at use, so
|
||||||
|
// a role that gained permissions since it was configured is never assigned.
|
||||||
|
func usableQuarantineRole(s *securityrepo.Settings, guildID string, roles []*discordgo.Role) string {
|
||||||
|
if s.QuarantineRoleID == 0 || id(s.QuarantineRoleID) == guildID {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.ID == id(s.QuarantineRoleID) {
|
||||||
|
if r.Managed || r.Permissions&DangerousPerms != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quarantine neutralises a member: every role it can remove is taken away
|
||||||
|
// (and saved, so it can be given back), the quarantine role is added if one
|
||||||
|
// is configured, and the member is timed out for the longest Discord allows.
|
||||||
|
// Bots are kicked instead, since their own integration role can't be
|
||||||
|
// removed. The owner and the bot itself are never touched.
|
||||||
|
func (e *Engine) Quarantine(ctx context.Context, guildID, userID, reason string) error {
|
||||||
|
if userID == "" || userID == e.botID || e.IsOwner(guildID, userID) {
|
||||||
|
return errors.New("refusing to quarantine the owner or the bot")
|
||||||
|
}
|
||||||
|
s, _, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := e.d.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("looking up the member: %w", err)
|
||||||
|
}
|
||||||
|
if m.User != nil && m.User.Bot {
|
||||||
|
return e.d.GuildMemberDeleteWithReason(guildID, userID, clip("Velox anti-nuke: "+reason, 400))
|
||||||
|
}
|
||||||
|
roles, err := e.d.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing roles: %w", err)
|
||||||
|
}
|
||||||
|
managed := map[string]bool{}
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.Managed {
|
||||||
|
managed[r.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep []string
|
||||||
|
var previous []int64
|
||||||
|
for _, r := range m.Roles {
|
||||||
|
if managed[r] {
|
||||||
|
keep = append(keep, r) // can't be removed (e.g. a Nitro booster role)
|
||||||
|
} else {
|
||||||
|
previous = append(previous, parseID(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qRole := usableQuarantineRole(s, guildID, roles)
|
||||||
|
withRole := keep
|
||||||
|
if qRole != "" {
|
||||||
|
withRole = append(append([]string(nil), keep...), qRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save first: if Discord fails half-way, the roles are still on record.
|
||||||
|
if err := e.st.SaveQuarantine(ctx, securityrepo.Quarantine{
|
||||||
|
GuildID: parseID(guildID), UserID: parseID(userID), PreviousRoleIDs: previous, Reason: clip(reason, 300),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("saving the quarantine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audit := discordgo.WithAuditLogReason(clip("Velox anti-nuke: "+reason, 400))
|
||||||
|
var errs []error
|
||||||
|
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &withRole}, audit); err != nil {
|
||||||
|
// A quarantine role Velox can't assign (e.g. moved above its own
|
||||||
|
// role) must not stop the roles from being removed: retry without it.
|
||||||
|
if qRole == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("removing roles: %w", err))
|
||||||
|
} else if _, err2 := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &keep}, audit); err2 != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("removing roles: %w", err2))
|
||||||
|
} else {
|
||||||
|
log.Printf("security: quarantine role %s couldn't be assigned in guild %s (%v); roles were still removed", qRole, guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
until := e.now().Add(timeoutQuarantine)
|
||||||
|
if err := e.d.GuildMemberTimeout(guildID, userID, &until, audit); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("timing out: %w", err))
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventQuarantined, e.botID, userID, reason)
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release undoes a quarantine: the saved roles that still exist are given
|
||||||
|
// back, the quarantine role and the timeout are removed. Releasing relaxes
|
||||||
|
// protection, so callers must check the actor first.
|
||||||
|
func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error {
|
||||||
|
rec, err := e.st.GetQuarantine(ctx, parseID(guildID), parseID(userID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rec == nil {
|
||||||
|
return UserError("That member isn't quarantined.")
|
||||||
|
}
|
||||||
|
s, _, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := e.d.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
// They left: there's nothing to give back.
|
||||||
|
if derr := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); derr != nil {
|
||||||
|
return derr
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, "member had left")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
roles, err := e.d.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing roles: %w", err)
|
||||||
|
}
|
||||||
|
exists := map[string]bool{}
|
||||||
|
for _, r := range roles {
|
||||||
|
if !r.Managed && r.ID != guildID {
|
||||||
|
exists[r.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qRole := ""
|
||||||
|
if s.QuarantineRoleID != 0 {
|
||||||
|
qRole = id(s.QuarantineRoleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var next []string
|
||||||
|
add := func(r string) {
|
||||||
|
if !seen[r] && r != qRole {
|
||||||
|
seen[r] = true
|
||||||
|
next = append(next, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range m.Roles {
|
||||||
|
add(r)
|
||||||
|
}
|
||||||
|
for _, r := range rec.PreviousRoleIDs {
|
||||||
|
if exists[id(r)] {
|
||||||
|
add(id(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audit := discordgo.WithAuditLogReason("Velox security: quarantine released")
|
||||||
|
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &next}, audit); err != nil {
|
||||||
|
return fmt.Errorf("giving the roles back: %w", err)
|
||||||
|
}
|
||||||
|
if err := e.d.GuildMemberTimeout(guildID, userID, nil, audit); err != nil {
|
||||||
|
log.Printf("security: removing the timeout of %s in guild %s: %v", userID, guildID, err)
|
||||||
|
}
|
||||||
|
if err := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, fmt.Sprintf("%d roles given back", len(rec.PreviousRoleIDs)))
|
||||||
|
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "Quarantine released",
|
||||||
|
Description: fmt.Sprintf("<@%s> was released by <@%s> and got their roles back.", userID, by),
|
||||||
|
Color: colorOK,
|
||||||
|
Timestamp: e.now().UTC().Format(time.RFC3339),
|
||||||
|
}})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
return e.st.ListQuarantines(ctx, parseID(guildID))
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Memory bounds per server, so a huge raid can't exhaust the bot.
|
||||||
|
const (
|
||||||
|
maxRecentJoins = 500
|
||||||
|
maxCaught = 20000
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomIDEndRaid is the alert's "End raid" button.
|
||||||
|
const CustomIDEndRaid = "security:endraid"
|
||||||
|
|
||||||
|
type joinRec struct {
|
||||||
|
user *discordgo.User
|
||||||
|
at time.Time
|
||||||
|
handled bool // already removed by the join lock or Join Gate
|
||||||
|
}
|
||||||
|
|
||||||
|
type caughtRec struct {
|
||||||
|
userID, name, action string
|
||||||
|
}
|
||||||
|
|
||||||
|
type raid struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
joins []joinRec
|
||||||
|
active bool
|
||||||
|
started time.Time
|
||||||
|
lastJoin time.Time
|
||||||
|
caught []caughtRec
|
||||||
|
caughtTotal int
|
||||||
|
pausedInvites bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) raidFor(guildID string) *raid {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
r, ok := e.raids[guildID]
|
||||||
|
if !ok {
|
||||||
|
r = &raid{}
|
||||||
|
e.raids[guildID] = r
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaidActive reports whether raid mode is on in a guild.
|
||||||
|
func (e *Engine) RaidActive(guildID string) bool {
|
||||||
|
e.mu.Lock()
|
||||||
|
r, ok := e.raids[guildID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// raidJoin records a join and, during a raid, acts on the raiders. It returns
|
||||||
|
// the action taken on this member ("" when none).
|
||||||
|
func (e *Engine) raidJoin(ctx context.Context, guildID string, s *securityrepo.Settings, m *discordgo.Member, handled bool) string {
|
||||||
|
if !s.AntiRaidEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
now := e.now()
|
||||||
|
window := time.Duration(s.RaidJoinSeconds) * time.Second
|
||||||
|
r := e.raidFor(guildID)
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.joins = append(r.joins, joinRec{user: m.User, at: now, handled: handled})
|
||||||
|
cut := 0
|
||||||
|
for cut < len(r.joins) && now.Sub(r.joins[cut].at) > window {
|
||||||
|
cut++
|
||||||
|
}
|
||||||
|
r.joins = r.joins[cut:]
|
||||||
|
if len(r.joins) > maxRecentJoins {
|
||||||
|
r.joins = r.joins[len(r.joins)-maxRecentJoins:]
|
||||||
|
}
|
||||||
|
r.lastJoin = now
|
||||||
|
|
||||||
|
var targets []joinRec
|
||||||
|
starting := false
|
||||||
|
switch {
|
||||||
|
case !r.active && len(r.joins) >= s.RaidJoinCount:
|
||||||
|
// Raid starts: everyone in the burst that triggered it is a raider.
|
||||||
|
starting = true
|
||||||
|
r.active, r.started, r.caught, r.caughtTotal, r.pausedInvites = true, now, nil, 0, false
|
||||||
|
targets = append(targets, r.joins...)
|
||||||
|
case r.active:
|
||||||
|
targets = []joinRec{{user: m.User, at: now, handled: handled}}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if starting {
|
||||||
|
e.startRaid(ctx, guildID, s, len(targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
action := s.RaidAction
|
||||||
|
thisMember := ""
|
||||||
|
for _, t := range targets {
|
||||||
|
did := action
|
||||||
|
if t.handled || action == securityrepo.ActionAlert {
|
||||||
|
did = "flagged"
|
||||||
|
} else if err := e.punish(guildID, t.user, action, "raid"); errors.Is(err, errProtected) {
|
||||||
|
did = "skipped"
|
||||||
|
} else if err != nil {
|
||||||
|
log.Printf("security: raid action on %s in guild %s: %v", t.user.ID, guildID, err)
|
||||||
|
did = "failed"
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.caughtTotal++
|
||||||
|
keep := len(r.caught) < maxCaught
|
||||||
|
if keep {
|
||||||
|
r.caught = append(r.caught, caughtRec{userID: t.user.ID, name: safeName(t.user), action: did})
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if keep {
|
||||||
|
if err := e.st.AddRaidCaught(ctx, parseID(guildID), securityrepo.Caught{UserID: parseID(t.user.ID), Action: did, Name: safeName(t.user)}); err != nil {
|
||||||
|
log.Printf("security: recording raider %s in guild %s: %v", t.user.ID, guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.user.ID == m.User.ID && did == action {
|
||||||
|
thisMember = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(targets) > 0 {
|
||||||
|
e.persistRaid(ctx, guildID, r)
|
||||||
|
}
|
||||||
|
return thisMember
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistRaid writes the raid's state so a restart can resume it.
|
||||||
|
func (e *Engine) persistRaid(ctx context.Context, guildID string, r *raid) {
|
||||||
|
r.mu.Lock()
|
||||||
|
x := securityrepo.Raid{GuildID: parseID(guildID), StartedAt: r.started, LastJoinAt: r.lastJoin, InvitesPaused: r.pausedInvites, CaughtTotal: r.caughtTotal}
|
||||||
|
active := r.active
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := e.st.SaveRaid(ctx, x); err != nil {
|
||||||
|
log.Printf("security: saving raid state for guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeRaids reloads raids that were in progress when the bot stopped, so
|
||||||
|
// raid mode stays on, its quiet timer keeps counting from the last join, and
|
||||||
|
// paused invites are resumed when it ends.
|
||||||
|
func (e *Engine) ResumeRaids(ctx context.Context) {
|
||||||
|
raids, err := e.st.ListRaids(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: loading raids in progress: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, x := range raids {
|
||||||
|
r := e.raidFor(id(x.GuildID))
|
||||||
|
r.mu.Lock()
|
||||||
|
r.active, r.started, r.lastJoin, r.pausedInvites, r.caughtTotal = true, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) startRaid(ctx context.Context, guildID string, s *securityrepo.Settings, joins int) {
|
||||||
|
e.event(ctx, guildID, securityrepo.EventRaidStarted, e.botID, "", fmt.Sprintf("%d joins in %ds", joins, s.RaidJoinSeconds))
|
||||||
|
|
||||||
|
paused := false
|
||||||
|
if s.RaidPauseInvites {
|
||||||
|
if err := e.setInvitesPaused(guildID, true); err != nil {
|
||||||
|
log.Printf("security: pausing invites in guild %s: %v", guildID, err)
|
||||||
|
} else {
|
||||||
|
paused = true
|
||||||
|
r := e.raidFor(guildID)
|
||||||
|
r.mu.Lock()
|
||||||
|
r.pausedInvites = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.persistRaid(ctx, guildID, e.raidFor(guildID))
|
||||||
|
|
||||||
|
what := map[string]string{
|
||||||
|
securityrepo.ActionAlert: "nobody is being removed (alert only)", securityrepo.ActionTimeout: "raiders are being timed out",
|
||||||
|
securityrepo.ActionKick: "raiders are being kicked", securityrepo.ActionBan: "raiders are being banned",
|
||||||
|
}[s.RaidAction]
|
||||||
|
desc := fmt.Sprintf("%d members joined within %d seconds. Raid mode is on: %s.", joins, s.RaidJoinSeconds, what)
|
||||||
|
if paused {
|
||||||
|
desc += "\nInvites are paused until the raid ends."
|
||||||
|
}
|
||||||
|
desc += fmt.Sprintf("\nIt ends after %d minutes without a join, or with the button below (owner or trusted only).", s.RaidQuietMinutes)
|
||||||
|
e.send(ctx, guildID, s, alert{
|
||||||
|
ping: true, critical: true,
|
||||||
|
embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "🚨 Raid detected", Description: desc, Color: colorAlert,
|
||||||
|
Timestamp: e.now().UTC().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{Label: "End raid", Style: discordgo.SecondaryButton, CustomID: CustomIDEndRaid},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndRaid turns raid mode off. by is who ended it ("" for the quiet timer).
|
||||||
|
// Ending a raid relaxes protection, so callers must check the actor first.
|
||||||
|
func (e *Engine) EndRaid(ctx context.Context, guildID, by string) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
r, ok := e.raids[guildID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return UserError("There's no raid going on.")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
if !r.active {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return UserError("There's no raid going on.")
|
||||||
|
}
|
||||||
|
started, caught, total, paused := r.started, r.caught, r.caughtTotal, r.pausedInvites
|
||||||
|
r.active, r.caught, r.caughtTotal, r.pausedInvites, r.joins = false, nil, 0, false, nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if paused {
|
||||||
|
if err := e.setInvitesPaused(guildID, false); err != nil {
|
||||||
|
log.Printf("security: resuming invites in guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
how := "after a quiet period"
|
||||||
|
if by != "" {
|
||||||
|
how = "by <@" + by + ">"
|
||||||
|
}
|
||||||
|
e.event(ctx, guildID, securityrepo.EventRaidEnded, by, "", fmt.Sprintf("%d raiders caught", total))
|
||||||
|
|
||||||
|
// The database has the full list even across a restart; memory is the
|
||||||
|
// fallback if it can't be read.
|
||||||
|
if saved, err := e.st.ListRaidCaught(ctx, parseID(guildID), maxCaught); err == nil && len(saved) >= len(caught) {
|
||||||
|
caught = caught[:0]
|
||||||
|
for _, c := range saved {
|
||||||
|
caught = append(caught, caughtRec{userID: id(c.UserID), action: c.Action, name: c.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := e.st.DeleteRaid(ctx, parseID(guildID)); err != nil {
|
||||||
|
log.Printf("security: clearing raid state for guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list strings.Builder
|
||||||
|
fmt.Fprintf(&list, "Raid from %s to %s (UTC)\n\n", started.UTC().Format("2006-01-02 15:04:05"), e.now().UTC().Format("15:04:05"))
|
||||||
|
for _, c := range caught {
|
||||||
|
fmt.Fprintf(&list, "%s\t%s\t%s\n", c.userID, c.action, c.name)
|
||||||
|
}
|
||||||
|
if total > len(caught) {
|
||||||
|
fmt.Fprintf(&list, "... and %d more\n", total-len(caught))
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf("Raid mode ended %s. %d accounts were caught; the list is attached.", how, total)
|
||||||
|
if paused {
|
||||||
|
desc += "\nInvites are open again."
|
||||||
|
}
|
||||||
|
if s, _, err := e.load(ctx, guildID); err == nil {
|
||||||
|
e.send(ctx, guildID, s, alert{
|
||||||
|
critical: true,
|
||||||
|
embed: &discordgo.MessageEmbed{Title: "Raid over", Description: desc, Color: colorOK},
|
||||||
|
file: textFile("raid-"+started.UTC().Format("20060102-1504")+".txt", list.String()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchRaids ends raids after their quiet period and frees memory for
|
||||||
|
// servers that have gone quiet. It runs until ctx ends.
|
||||||
|
func (e *Engine) WatchRaids(ctx context.Context, every time.Duration) {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
e.checkRaids(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkRaids(ctx context.Context) {
|
||||||
|
e.mu.Lock()
|
||||||
|
guilds := make([]string, 0, len(e.raids))
|
||||||
|
for g := range e.raids {
|
||||||
|
guilds = append(guilds, g)
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
now := e.now()
|
||||||
|
for _, g := range guilds {
|
||||||
|
s, _, err := e.load(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := e.raidFor(g)
|
||||||
|
r.mu.Lock()
|
||||||
|
active, last := r.active, r.lastJoin
|
||||||
|
r.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case active && now.Sub(last) >= time.Duration(s.RaidQuietMinutes)*time.Minute:
|
||||||
|
_ = e.EndRaid(ctx, g, "")
|
||||||
|
case !active && now.Sub(last) > 10*time.Minute:
|
||||||
|
e.mu.Lock()
|
||||||
|
delete(e.raids, g)
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setInvitesPaused uses Discord's built-in "pause invites" (guild incident
|
||||||
|
// actions). Paused for up to 24 hours, Discord's maximum; resumed when the
|
||||||
|
// raid ends.
|
||||||
|
func (e *Engine) setInvitesPaused(guildID string, paused bool) error {
|
||||||
|
body := map[string]any{"invites_disabled_until": nil}
|
||||||
|
if paused {
|
||||||
|
body["invites_disabled_until"] = e.now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
url := discordgo.EndpointGuild(guildID) + "/incident-actions"
|
||||||
|
_, err := e.d.RequestWithBucketID("PUT", url, body, url)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshots record a guild's roles and channels (names, settings,
|
||||||
|
// permissions, channel permission overwrites) so a nuke can be undone.
|
||||||
|
// Messages and who had which role can't be captured or brought back; Discord
|
||||||
|
// offers no way to.
|
||||||
|
const (
|
||||||
|
snapshotEvery = 6 * time.Hour
|
||||||
|
snapshotCheckGap = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type snapRole struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color int `json:"color"`
|
||||||
|
Hoist bool `json:"hoist"`
|
||||||
|
Mentionable bool `json:"mentionable"`
|
||||||
|
Permissions int64 `json:"permissions"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapOverwrite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
Allow int64 `json:"allow"`
|
||||||
|
Deny int64 `json:"deny"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapChannel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
Topic string `json:"topic,omitempty"`
|
||||||
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
NSFW bool `json:"nsfw,omitempty"`
|
||||||
|
RateLimit int `json:"rate_limit,omitempty"`
|
||||||
|
Bitrate int `json:"bitrate,omitempty"`
|
||||||
|
UserLimit int `json:"user_limit,omitempty"`
|
||||||
|
Overwrites []snapOverwrite `json:"overwrites,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotData struct {
|
||||||
|
Roles []snapRole `json:"roles"`
|
||||||
|
Channels []snapChannel `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// restorable channel types: no threads, which belong to their parent.
|
||||||
|
func restorable(t discordgo.ChannelType) bool {
|
||||||
|
switch t {
|
||||||
|
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildCategory,
|
||||||
|
discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildStageVoice, discordgo.ChannelTypeGuildForum:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeSnapshot records the guild's current roles and channels.
|
||||||
|
func (e *Engine) TakeSnapshot(ctx context.Context, guildID, by string) (roles, channels int, err error) {
|
||||||
|
rs, err := e.d.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("listing roles: %w", err)
|
||||||
|
}
|
||||||
|
cs, err := e.d.GuildChannels(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("listing channels: %w", err)
|
||||||
|
}
|
||||||
|
var data snapshotData
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.Managed {
|
||||||
|
continue // integration roles come back with their bot
|
||||||
|
}
|
||||||
|
data.Roles = append(data.Roles, snapRole{ID: r.ID, Name: r.Name, Color: r.Color, Hoist: r.Hoist,
|
||||||
|
Mentionable: r.Mentionable, Permissions: r.Permissions, Position: r.Position})
|
||||||
|
}
|
||||||
|
for _, c := range cs {
|
||||||
|
if !restorable(c.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sc := snapChannel{ID: c.ID, Name: c.Name, Type: int(c.Type), Topic: c.Topic, ParentID: c.ParentID, Position: c.Position,
|
||||||
|
NSFW: c.NSFW, RateLimit: c.RateLimitPerUser, Bitrate: c.Bitrate, UserLimit: c.UserLimit}
|
||||||
|
for _, o := range c.PermissionOverwrites {
|
||||||
|
sc.Overwrites = append(sc.Overwrites, snapOverwrite{ID: o.ID, Type: int(o.Type), Allow: o.Allow, Deny: o.Deny})
|
||||||
|
}
|
||||||
|
data.Channels = append(data.Channels, sc)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if err := e.st.SaveSnapshot(ctx, securityrepo.Snapshot{GuildID: parseID(guildID), Data: raw, Roles: len(data.Roles), Channels: len(data.Channels)}); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if by != "" {
|
||||||
|
e.event(ctx, guildID, securityrepo.EventSnapshot, by, "", fmt.Sprintf("%d roles, %d channels", len(data.Roles), len(data.Channels)))
|
||||||
|
}
|
||||||
|
return len(data.Roles), len(data.Channels), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchSnapshots takes a snapshot of every guild with anti-nuke on whose
|
||||||
|
// newest one is older than six hours. It runs until ctx ends.
|
||||||
|
func (e *Engine) WatchSnapshots(ctx context.Context) {
|
||||||
|
run := func() {
|
||||||
|
due, err := e.st.GuildsDueForSnapshot(ctx, snapshotEvery)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: finding guilds due a snapshot: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, g := range due {
|
||||||
|
if _, _, err := e.TakeSnapshot(ctx, id(g), ""); err != nil {
|
||||||
|
log.Printf("security: snapshot of guild %d: %v", g, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
t := time.NewTicker(snapshotCheckGap)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreResult says what a restore did.
|
||||||
|
type RestoreResult struct {
|
||||||
|
RolesCreated, RolesReverted, ChannelsCreated, Failed int
|
||||||
|
SnapshotAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore brings the guild back towards its latest snapshot:
|
||||||
|
//
|
||||||
|
// - roles that are gone are recreated (same name, colour, permissions);
|
||||||
|
// - roles that gained Administrator, Manage Server, Manage Roles or Manage
|
||||||
|
// Webhooks since the snapshot get their snapshot permissions back;
|
||||||
|
// - channels that are gone are recreated, categories first, with their
|
||||||
|
// permission overwrites pointing at the recreated roles.
|
||||||
|
//
|
||||||
|
// Nothing is deleted: channels and roles made since the snapshot stay, since
|
||||||
|
// some may be legitimate. Restoring rewrites the server, so callers must check
|
||||||
|
// the actor is the owner or trusted. One restore per guild runs at a time.
|
||||||
|
func (e *Engine) Restore(ctx context.Context, guildID, by string) (*RestoreResult, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.restoring[guildID] {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil, UserError("A restore is already running for this server.")
|
||||||
|
}
|
||||||
|
e.restoring[guildID] = true
|
||||||
|
e.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
e.mu.Lock()
|
||||||
|
delete(e.restoring, guildID)
|
||||||
|
e.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
snap, err := e.st.LatestSnapshot(ctx, parseID(guildID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snap == nil {
|
||||||
|
return nil, UserError("There's no snapshot to restore from yet. Turn anti-nuke on (snapshots are taken every 6 hours) or take one with `/security snapshot`.")
|
||||||
|
}
|
||||||
|
var data snapshotData
|
||||||
|
if err := json.Unmarshal(snap.Data, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("reading the snapshot: %w", err)
|
||||||
|
}
|
||||||
|
res := &RestoreResult{SnapshotAt: snap.CreatedAt}
|
||||||
|
reason := discordgo.WithAuditLogReason("Velox restore from snapshot")
|
||||||
|
|
||||||
|
// Roles.
|
||||||
|
current, err := e.d.GuildRoles(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing roles: %w", err)
|
||||||
|
}
|
||||||
|
now := map[string]*discordgo.Role{}
|
||||||
|
for _, r := range current {
|
||||||
|
now[r.ID] = r
|
||||||
|
}
|
||||||
|
roleMap := map[string]string{} // snapshot role ID -> current role ID
|
||||||
|
sort.Slice(data.Roles, func(i, j int) bool { return data.Roles[i].Position < data.Roles[j].Position })
|
||||||
|
for _, sr := range data.Roles {
|
||||||
|
if cur, ok := now[sr.ID]; ok {
|
||||||
|
roleMap[sr.ID] = sr.ID
|
||||||
|
if added := cur.Permissions &^ sr.Permissions; added&DangerousPerms != 0 && !cur.Managed {
|
||||||
|
perms := sr.Permissions
|
||||||
|
if _, err := e.d.GuildRoleEdit(guildID, sr.ID, &discordgo.RoleParams{Permissions: &perms}, reason); err != nil {
|
||||||
|
log.Printf("security: restore: reverting role %s in guild %s: %v", sr.ID, guildID, err)
|
||||||
|
res.Failed++
|
||||||
|
} else {
|
||||||
|
res.RolesReverted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sr.ID == guildID {
|
||||||
|
continue // @everyone always exists
|
||||||
|
}
|
||||||
|
color, hoist, mention, perms := sr.Color, sr.Hoist, sr.Mentionable, sr.Permissions
|
||||||
|
r, err := e.d.GuildRoleCreate(guildID, &discordgo.RoleParams{Name: sr.Name, Color: &color, Hoist: &hoist, Mentionable: &mention, Permissions: &perms}, reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: restore: recreating role %q in guild %s: %v", sr.Name, guildID, err)
|
||||||
|
res.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
roleMap[sr.ID] = r.ID
|
||||||
|
res.RolesCreated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels: categories first so the others can be put back in them.
|
||||||
|
chans, err := e.d.GuildChannels(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("listing channels: %w", err)
|
||||||
|
}
|
||||||
|
exists := map[string]bool{}
|
||||||
|
for _, c := range chans {
|
||||||
|
exists[c.ID] = true
|
||||||
|
}
|
||||||
|
chanMap := map[string]string{}
|
||||||
|
for _, c := range chans {
|
||||||
|
chanMap[c.ID] = c.ID
|
||||||
|
}
|
||||||
|
sort.SliceStable(data.Channels, func(i, j int) bool {
|
||||||
|
ci := data.Channels[i].Type == int(discordgo.ChannelTypeGuildCategory)
|
||||||
|
cj := data.Channels[j].Type == int(discordgo.ChannelTypeGuildCategory)
|
||||||
|
if ci != cj {
|
||||||
|
return ci
|
||||||
|
}
|
||||||
|
return data.Channels[i].Position < data.Channels[j].Position
|
||||||
|
})
|
||||||
|
for _, sc := range data.Channels {
|
||||||
|
if exists[sc.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var overwrites []*discordgo.PermissionOverwrite
|
||||||
|
for _, o := range sc.Overwrites {
|
||||||
|
target := o.ID
|
||||||
|
if discordgo.PermissionOverwriteType(o.Type) == discordgo.PermissionOverwriteTypeRole {
|
||||||
|
mapped, ok := roleMap[o.ID]
|
||||||
|
if !ok {
|
||||||
|
continue // the role couldn't be brought back
|
||||||
|
}
|
||||||
|
target = mapped
|
||||||
|
}
|
||||||
|
overwrites = append(overwrites, &discordgo.PermissionOverwrite{ID: target, Type: discordgo.PermissionOverwriteType(o.Type), Allow: o.Allow, Deny: o.Deny})
|
||||||
|
}
|
||||||
|
parent := ""
|
||||||
|
if sc.ParentID != "" {
|
||||||
|
parent = chanMap[sc.ParentID] // "" if the category couldn't be restored
|
||||||
|
}
|
||||||
|
c, err := e.d.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
|
||||||
|
Name: sc.Name, Type: discordgo.ChannelType(sc.Type), Topic: sc.Topic, Bitrate: sc.Bitrate, UserLimit: sc.UserLimit,
|
||||||
|
RateLimitPerUser: sc.RateLimit, Position: sc.Position, PermissionOverwrites: overwrites, ParentID: parent, NSFW: sc.NSFW,
|
||||||
|
}, reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("security: restore: recreating channel %q in guild %s: %v", sc.Name, guildID, err)
|
||||||
|
res.Failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chanMap[sc.ID] = c.ID
|
||||||
|
res.ChannelsCreated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point the snapshot at the recreated roles and channels, so restoring
|
||||||
|
// again doesn't create them a second time. The content stays the
|
||||||
|
// snapshot's: nothing made since (or by an attacker) is added.
|
||||||
|
if res.RolesCreated+res.ChannelsCreated > 0 {
|
||||||
|
for i := range data.Roles {
|
||||||
|
if nid, ok := roleMap[data.Roles[i].ID]; ok {
|
||||||
|
data.Roles[i].ID = nid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range data.Channels {
|
||||||
|
c := &data.Channels[i]
|
||||||
|
if nid, ok := chanMap[c.ID]; ok {
|
||||||
|
c.ID = nid
|
||||||
|
}
|
||||||
|
if nid, ok := chanMap[c.ParentID]; ok && c.ParentID != "" {
|
||||||
|
c.ParentID = nid
|
||||||
|
}
|
||||||
|
for j := range c.Overwrites {
|
||||||
|
if c.Overwrites[j].Type == int(discordgo.PermissionOverwriteTypeRole) {
|
||||||
|
if nid, ok := roleMap[c.Overwrites[j].ID]; ok {
|
||||||
|
c.Overwrites[j].ID = nid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := json.Marshal(data); err == nil {
|
||||||
|
if err := e.st.SaveSnapshot(ctx, securityrepo.Snapshot{GuildID: parseID(guildID), Data: raw, Roles: snap.Roles, Channels: snap.Channels}); err != nil {
|
||||||
|
log.Printf("security: saving the remapped snapshot for guild %s: %v", guildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.event(ctx, guildID, securityrepo.EventRestored, by, "", fmt.Sprintf("%d roles recreated, %d reverted, %d channels recreated, %d failed",
|
||||||
|
res.RolesCreated, res.RolesReverted, res.ChannelsCreated, res.Failed))
|
||||||
|
if s, _, err := e.load(ctx, guildID); err == nil {
|
||||||
|
e.send(ctx, guildID, s, alert{critical: true, embed: &discordgo.MessageEmbed{
|
||||||
|
Title: "♻️ Server restored from snapshot",
|
||||||
|
Description: fmt.Sprintf("Snapshot from <t:%d:f>. Recreated %d roles and %d channels, reverted dangerous permissions on %d roles. %d steps failed.\nMembers need their recreated roles given back by hand; Discord doesn't let bots see who had them.",
|
||||||
|
res.SnapshotAt.Unix(), res.RolesCreated, res.ChannelsCreated, res.RolesReverted, res.Failed),
|
||||||
|
Color: colorOK,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// Package security is the bot's anti-raid and anti-nuke system, modelled on
|
||||||
|
// Wick:
|
||||||
|
//
|
||||||
|
// - Join Gate: checks on every join (no avatar, new account, invite links
|
||||||
|
// or blocked words in the name, unverified bots), each with its own
|
||||||
|
// action, plus kicking bots added by anyone who isn't trusted.
|
||||||
|
// - Raid mode: a burst of joins starts it; raiders are timed out, kicked or
|
||||||
|
// banned, invites can be paused, staff are alerted, and it ends after a
|
||||||
|
// quiet period or when staff end it.
|
||||||
|
// - Anti-nuke: destructive actions (deleting channels or roles, mass bans
|
||||||
|
// and kicks, webhooks, dangerous permissions) are counted per person from
|
||||||
|
// the audit log; anyone who isn't the owner or trusted and goes over a
|
||||||
|
// limit is quarantined: roles removed and saved, timed out.
|
||||||
|
// - Lockdown: lock a channel, every channel, or joins, and unlock exactly
|
||||||
|
// back to how they were.
|
||||||
|
//
|
||||||
|
// Safety rules every entry point follows:
|
||||||
|
//
|
||||||
|
// - Turning protection off, changing limits, releasing a quarantine and
|
||||||
|
// unlocking need the server owner or someone on the trusted list.
|
||||||
|
// Editing the trusted list needs the owner. Discord's command
|
||||||
|
// permissions are never relied on, since admins can change them.
|
||||||
|
// - The owner and the bot itself are never punished.
|
||||||
|
// - Configured roles are checked when used, not just when saved: a
|
||||||
|
// quarantine role that gained dangerous permissions is not assigned.
|
||||||
|
// - Messages only ever ping the configured alert role, and user-controlled
|
||||||
|
// text (names) is escaped.
|
||||||
|
// - Memory per server is bounded, and settings are cached briefly so a
|
||||||
|
// raid of thousands of joins doesn't turn into thousands of queries.
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the subset of securityrepo.Repo the system uses.
|
||||||
|
type Store interface {
|
||||||
|
GetSettings(ctx context.Context, guildID int64) (*securityrepo.Settings, error)
|
||||||
|
SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error
|
||||||
|
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
|
||||||
|
SetQuarantineRole(ctx context.Context, guildID, roleID int64) 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)
|
||||||
|
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
|
||||||
|
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
|
||||||
|
SaveQuarantine(ctx context.Context, q securityrepo.Quarantine) error
|
||||||
|
GetQuarantine(ctx context.Context, guildID, userID int64) (*securityrepo.Quarantine, error)
|
||||||
|
ListQuarantines(ctx context.Context, guildID int64) ([]securityrepo.Quarantine, error)
|
||||||
|
DeleteQuarantine(ctx context.Context, guildID, userID int64) error
|
||||||
|
SaveLockdown(ctx context.Context, l securityrepo.Lockdown) error
|
||||||
|
GetLockdown(ctx context.Context, guildID, channelID int64) (*securityrepo.Lockdown, error)
|
||||||
|
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
|
||||||
|
DeleteLockdown(ctx context.Context, guildID, channelID int64) 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)
|
||||||
|
MarkCommandProcessed(ctx context.Context, id int64) error
|
||||||
|
SaveRaid(ctx context.Context, x securityrepo.Raid) error
|
||||||
|
ListRaids(ctx context.Context) ([]securityrepo.Raid, error)
|
||||||
|
DeleteRaid(ctx context.Context, guildID int64) error
|
||||||
|
AddRaidCaught(ctx context.Context, guildID int64, c securityrepo.Caught) error
|
||||||
|
ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]securityrepo.Caught, error)
|
||||||
|
SaveSnapshot(ctx context.Context, s securityrepo.Snapshot) error
|
||||||
|
LatestSnapshot(ctx context.Context, guildID int64) (*securityrepo.Snapshot, error)
|
||||||
|
GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord is the subset of *discordgo.Session the system uses.
|
||||||
|
type Discord interface {
|
||||||
|
Guild(guildID string, options ...discordgo.RequestOption) (*discordgo.Guild, error)
|
||||||
|
UserChannelCreate(recipientID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||||
|
User(userID string, options ...discordgo.RequestOption) (*discordgo.User, error)
|
||||||
|
GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error)
|
||||||
|
GuildChannels(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Channel, error)
|
||||||
|
GuildMember(guildID, userID string, options ...discordgo.RequestOption) (*discordgo.Member, error)
|
||||||
|
GuildMemberEdit(guildID, userID string, data *discordgo.GuildMemberParams, options ...discordgo.RequestOption) (*discordgo.Member, error)
|
||||||
|
GuildMemberTimeout(guildID, userID string, until *time.Time, options ...discordgo.RequestOption) error
|
||||||
|
GuildMemberDeleteWithReason(guildID, userID, reason string, options ...discordgo.RequestOption) error
|
||||||
|
GuildBanCreateWithReason(guildID, userID, reason string, days int, options ...discordgo.RequestOption) error
|
||||||
|
Channel(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||||
|
ChannelPermissionSet(channelID, targetID string, targetType discordgo.PermissionOverwriteType, allow, deny int64, options ...discordgo.RequestOption) error
|
||||||
|
ChannelPermissionDelete(channelID, targetID string, options ...discordgo.RequestOption) error
|
||||||
|
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||||
|
RequestWithBucketID(method, urlStr string, data interface{}, bucketID string, options ...discordgo.RequestOption) ([]byte, error)
|
||||||
|
ChannelMessageDelete(channelID, messageID string, options ...discordgo.RequestOption) error
|
||||||
|
GuildRoleCreate(guildID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
|
||||||
|
GuildRoleEdit(guildID, roleID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
|
||||||
|
GuildChannelCreateComplex(guildID string, data discordgo.GuildChannelCreateData, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserError is shown to the person who acted, as written.
|
||||||
|
type UserError string
|
||||||
|
|
||||||
|
func (e UserError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrNotTrusted = UserError("Only the server owner or someone on the security trusted list can do that.")
|
||||||
|
ErrNotOwner = UserError("Only the server owner can change the trusted list.")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dangerous permissions: what a nuker needs, and what no quarantine or alert
|
||||||
|
// role may have.
|
||||||
|
const DangerousPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild |
|
||||||
|
discordgo.PermissionManageRoles | discordgo.PermissionManageWebhooks
|
||||||
|
|
||||||
|
const (
|
||||||
|
timeoutJoin = 24 * time.Hour
|
||||||
|
timeoutQuarantine = 28*24*time.Hour - time.Hour // Discord's maximum is 28 days
|
||||||
|
settingsTTL = 15 * time.Second
|
||||||
|
ownerTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine holds the system's state. One per bot.
|
||||||
|
type Engine struct {
|
||||||
|
d Discord
|
||||||
|
st Store
|
||||||
|
botID string
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
settings map[string]cachedSettings
|
||||||
|
owners map[string]cachedOwner
|
||||||
|
raids map[string]*raid
|
||||||
|
nukes *nukeCounter
|
||||||
|
roleCache map[string]cachedRoles
|
||||||
|
heat map[string]*guildHeat
|
||||||
|
restoring map[string]bool
|
||||||
|
|
||||||
|
// syncDMs sends alert DMs before returning (tests); normally they go out
|
||||||
|
// in the background so an alert never waits on them.
|
||||||
|
syncDMs bool
|
||||||
|
|
||||||
|
// ChannelPerms reports a member's permissions in a channel from the
|
||||||
|
// gateway cache (no REST call), used to exempt moderators from heat.
|
||||||
|
// Nil means nobody is exempt by permission.
|
||||||
|
ChannelPerms func(userID, channelID string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedSettings struct {
|
||||||
|
s *securityrepo.Settings
|
||||||
|
trusted []securityrepo.Trusted
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedOwner struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(d Discord, st Store, botID string) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
d: d, st: st, botID: botID, now: time.Now,
|
||||||
|
settings: map[string]cachedSettings{},
|
||||||
|
owners: map[string]cachedOwner{},
|
||||||
|
raids: map[string]*raid{},
|
||||||
|
nukes: newNukeCounter(),
|
||||||
|
heat: map[string]*guildHeat{},
|
||||||
|
restoring: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultEngine *Engine
|
||||||
|
pendingStore Store
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure sets the store; Start creates the engine once the bot knows its
|
||||||
|
// own user ID (after the gateway connects).
|
||||||
|
func Configure(st Store) { pendingStore = st }
|
||||||
|
|
||||||
|
// Start creates the default engine for a connected session. It returns nil
|
||||||
|
// when no store was configured.
|
||||||
|
func Start(d Discord, botID string) *Engine {
|
||||||
|
if pendingStore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defaultEngine = New(d, pendingStore, botID)
|
||||||
|
return defaultEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init(e *Engine) { defaultEngine = e }
|
||||||
|
|
||||||
|
func Default() *Engine { return defaultEngine }
|
||||||
|
|
||||||
|
func parseID(s string) int64 {
|
||||||
|
n, _ := strconv.ParseInt(s, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func id(n int64) string { return strconv.FormatInt(n, 10) }
|
||||||
|
|
||||||
|
// load returns the guild's settings and trusted list, cached briefly.
|
||||||
|
func (e *Engine) load(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
c, ok := e.settings[guildID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if ok && e.now().Sub(c.at) < settingsTTL {
|
||||||
|
return c.s, c.trusted, nil
|
||||||
|
}
|
||||||
|
g := parseID(guildID)
|
||||||
|
s, err := e.st.GetSettings(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
trusted, err := e.st.ListTrusted(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.settings[guildID] = cachedSettings{s: s, trusted: trusted, at: e.now()}
|
||||||
|
e.mu.Unlock()
|
||||||
|
return s, trusted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate drops the cached settings after a change, so it applies at once.
|
||||||
|
func (e *Engine) Invalidate(guildID string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
delete(e.settings, guildID)
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings returns the current settings (for status displays).
|
||||||
|
func (e *Engine) Settings(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
|
||||||
|
return e.load(ctx, guildID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerID returns the guild owner's ID, cached.
|
||||||
|
func (e *Engine) OwnerID(guildID string) (string, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
c, ok := e.owners[guildID]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if ok && e.now().Sub(c.at) < ownerTTL {
|
||||||
|
return c.id, nil
|
||||||
|
}
|
||||||
|
g, err := e.d.Guild(guildID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.owners[guildID] = cachedOwner{id: g.OwnerID, name: g.Name, at: e.now()}
|
||||||
|
e.mu.Unlock()
|
||||||
|
return g.OwnerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guildName returns the guild's name (cached with the owner), or "".
|
||||||
|
func (e *Engine) guildName(guildID string) string {
|
||||||
|
if _, err := e.OwnerID(guildID); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.owners[guildID].name
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedBy reports whether a user (with their role IDs) is on the list.
|
||||||
|
func trustedBy(list []securityrepo.Trusted, userID string, roles []string) bool {
|
||||||
|
uid := parseID(userID)
|
||||||
|
for _, t := range list {
|
||||||
|
switch t.Kind {
|
||||||
|
case "user":
|
||||||
|
if t.TargetID == uid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "role":
|
||||||
|
for _, r := range roles {
|
||||||
|
if parseID(r) == t.TargetID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTrusted reports whether a user is exempt from anti-nuke and may change
|
||||||
|
// security settings: the owner, the bot itself, or someone on the trusted
|
||||||
|
// list (directly or by role). When roles is nil the member is looked up.
|
||||||
|
// Any lookup failure counts as not trusted.
|
||||||
|
func (e *Engine) IsTrusted(ctx context.Context, guildID, userID string, roles []string) bool {
|
||||||
|
if userID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if userID == e.botID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if owner, err := e.OwnerID(guildID); err == nil && owner == userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, trusted, err := e.load(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
m, err := e.d.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return trustedBy(trusted, userID, nil)
|
||||||
|
}
|
||||||
|
roles = m.Roles
|
||||||
|
}
|
||||||
|
return trustedBy(trusted, userID, roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOwner reports whether the user owns the guild. Lookup failures are "no".
|
||||||
|
func (e *Engine) IsOwner(guildID, userID string) bool {
|
||||||
|
owner, err := e.OwnerID(guildID)
|
||||||
|
return err == nil && owner != "" && owner == userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireTrusted returns ErrNotTrusted unless the actor is owner or trusted.
|
||||||
|
func (e *Engine) RequireTrusted(ctx context.Context, guildID string, actor *discordgo.Member) error {
|
||||||
|
if actor == nil || actor.User == nil || !e.IsTrusted(ctx, guildID, actor.User.ID, actor.Roles) {
|
||||||
|
return ErrNotTrusted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) event(ctx context.Context, guildID string, kind string, actor, target string, detail string) {
|
||||||
|
_ = e.st.AddEvent(ctx, securityrepo.Event{
|
||||||
|
GuildID: parseID(guildID), Kind: kind, ActorID: parseID(actor), TargetID: parseID(target), Detail: clip(detail, 1000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clip(s string, n int) string {
|
||||||
|
if r := []rune(s); len(r) > n {
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeName renders a user-controlled name for an embed: markdown escaped and
|
||||||
|
// mentions defused (allowed mentions are restricted too, this is belt and
|
||||||
|
// braces).
|
||||||
|
func safeName(u *discordgo.User) string {
|
||||||
|
if u == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
name := u.Username
|
||||||
|
if u.GlobalName != "" && u.GlobalName != u.Username {
|
||||||
|
name = u.GlobalName + " (" + u.Username + ")"
|
||||||
|
}
|
||||||
|
r := strings.NewReplacer(
|
||||||
|
`\`, `\\`, `*`, `\*`, `_`, `\_`, "`", "\\`", `~`, `\~`, `|`, `\|`, `>`, `\>`, `#`, `\#`,
|
||||||
|
`[`, `\[`, `]`, `\]`, `(`, `\(`, `)`, `\)`, `@`, "@", `<`, "<",
|
||||||
|
)
|
||||||
|
return clip(r.Replace(name), 80)
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
// testEngine is a guild (id 100) owned by user 1, with Velox as user 999,
|
||||||
|
// alerts going to channel 500 and pinging role 201.
|
||||||
|
func testEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore, *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
d, st := newDiscord(), newStore()
|
||||||
|
e := New(d, st, "999")
|
||||||
|
e.syncDMs = true
|
||||||
|
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
e.now = func() time.Time { return now }
|
||||||
|
s := st.set(100)
|
||||||
|
s.AlertChannelID, s.AlertRoleID = 500, 201
|
||||||
|
return e, d, st, &now
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(e *Engine) { e.Invalidate(guild) }
|
||||||
|
|
||||||
|
// snowflake for an account created at t.
|
||||||
|
func snowflakeAt(t time.Time, n int) string {
|
||||||
|
ms := t.UnixMilli() - 1420070400000
|
||||||
|
return fmt.Sprint(ms<<22 | int64(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(action discordgo.AuditLogAction, user, target string, changes ...*discordgo.AuditLogChange) *discordgo.AuditLogEntry {
|
||||||
|
return &discordgo.AuditLogEntry{ActionType: &action, UserID: user, TargetID: target, Changes: changes}
|
||||||
|
}
|
||||||
|
|
||||||
|
func change(key discordgo.AuditLogChangeKey, old, new any) *discordgo.AuditLogChange {
|
||||||
|
return &discordgo.AuditLogChange{Key: &key, OldValue: old, NewValue: new}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Join Gate ----
|
||||||
|
|
||||||
|
func TestCheckJoin(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
s := securityrepo.Defaults(1)
|
||||||
|
s.JoinGateEnabled = true
|
||||||
|
s.NoAvatar, s.NewAccount, s.AdvertisingName = securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan
|
||||||
|
s.UsernameFilter, s.UsernamePatterns = securityrepo.ActionKick, []string{" FreeNitro "}
|
||||||
|
s.UnverifiedBot = securityrepo.ActionKick
|
||||||
|
|
||||||
|
old := snowflakeAt(now.AddDate(-1, 0, 0), 1)
|
||||||
|
young := snowflakeAt(now.Add(-48*time.Hour), 2)
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
u *discordgo.User
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"clean", &discordgo.User{ID: old, Username: "ana", Avatar: "x"}, "off"},
|
||||||
|
{"no avatar", &discordgo.User{ID: old, Username: "ana"}, "timeout"},
|
||||||
|
{"new account beats no avatar", &discordgo.User{ID: young, Username: "ana"}, "kick"},
|
||||||
|
{"invite in display name", &discordgo.User{ID: old, Username: "ana", GlobalName: "join DISCORD.GG/abc", Avatar: "x"}, "ban"},
|
||||||
|
{"blocked word, case-insensitive", &discordgo.User{ID: old, Username: "freenitro_giveaway", Avatar: "x"}, "kick"},
|
||||||
|
{"verified bot passes", &discordgo.User{ID: young, Bot: true, PublicFlags: discordgo.UserFlagVerifiedBot}, "off"},
|
||||||
|
{"unverified bot", &discordgo.User{ID: young, Bot: true}, "kick"},
|
||||||
|
} {
|
||||||
|
if got := CheckJoin(s, c.u, now).Action; got != c.want {
|
||||||
|
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.JoinGateEnabled = false
|
||||||
|
if CheckJoin(s, &discordgo.User{ID: young}, now).Action != "off" {
|
||||||
|
t.Error("a disabled Join Gate must not act")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinGateAndJoinLock(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.JoinGateEnabled, s.NewAccount = true, securityrepo.ActionKick
|
||||||
|
refresh(e)
|
||||||
|
|
||||||
|
young := d.addMember(snowflakeAt(now.Add(-time.Hour), 5))
|
||||||
|
if removed := e.OnMemberJoin(ctx, guild, young); !removed || !contains(d.kicked, young.User.ID) {
|
||||||
|
t.Errorf("new account should be kicked: removed=%v kicked=%v", removed, d.kicked)
|
||||||
|
}
|
||||||
|
if d.sentCount() != 1 || !contains(st.eventKinds(), securityrepo.EventJoinGate) {
|
||||||
|
t.Error("the action should be alerted and recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
st.set(100).JoinLock = securityrepo.ActionBan
|
||||||
|
refresh(e)
|
||||||
|
m := d.addMember(snowflakeAt(now.AddDate(-2, 0, 0), 6))
|
||||||
|
if !e.OnMemberJoin(ctx, guild, m) || !contains(d.banned, m.User.ID) {
|
||||||
|
t.Error("a join lock bans everyone new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerAndBotAreNeverPunished(t *testing.T) {
|
||||||
|
e, d, st, _ := testEngine(t)
|
||||||
|
st.set(100).JoinLock = securityrepo.ActionBan
|
||||||
|
refresh(e)
|
||||||
|
if e.OnMemberJoin(ctx, guild, d.members["1"]) || len(d.banned) != 0 {
|
||||||
|
t.Error("the owner must never be banned")
|
||||||
|
}
|
||||||
|
if err := e.Quarantine(ctx, guild, "1", "x"); err == nil {
|
||||||
|
t.Error("the owner must never be quarantined")
|
||||||
|
}
|
||||||
|
if err := e.Quarantine(ctx, guild, "999", "x"); err == nil {
|
||||||
|
t.Error("the bot must never quarantine itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Raids ----
|
||||||
|
|
||||||
|
func TestRaidModeStartsActsAndEnds(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidJoinSeconds, s.RaidAction, s.RaidQuietMinutes = true, 3, 10, securityrepo.ActionKick, 5
|
||||||
|
refresh(e)
|
||||||
|
|
||||||
|
old := now.AddDate(-1, 0, 0)
|
||||||
|
join := func(n int) *discordgo.Member {
|
||||||
|
m := d.addMember(snowflakeAt(old, n))
|
||||||
|
e.OnMemberJoin(ctx, guild, m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
a := join(1)
|
||||||
|
*now = now.Add(2 * time.Second)
|
||||||
|
join(2)
|
||||||
|
if len(d.kicked) != 0 || e.RaidActive(guild) {
|
||||||
|
t.Fatal("two joins are not a raid")
|
||||||
|
}
|
||||||
|
*now = now.Add(2 * time.Second)
|
||||||
|
join(3)
|
||||||
|
if !e.RaidActive(guild) || len(d.kicked) != 3 || !contains(d.kicked, a.User.ID) {
|
||||||
|
t.Fatalf("the third join starts a raid and the whole burst is kicked: %v", d.kicked)
|
||||||
|
}
|
||||||
|
if len(d.requests) != 1 || !strings.Contains(d.requests[0], "/incident-actions") || !strings.Contains(d.requests[0], "2026-10-21") {
|
||||||
|
t.Errorf("invites should be paused for 24h: %v", d.requests)
|
||||||
|
}
|
||||||
|
alert := d.channelSends()[0]
|
||||||
|
if alert.Content != "<@&201>" || strings.Join(alert.AllowedMentions.Roles, ",") != "201" || len(alert.AllowedMentions.Users) != 0 {
|
||||||
|
t.Errorf("a raid pings only the alert role: %+v", alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// During the raid every join is acted on.
|
||||||
|
*now = now.Add(time.Minute)
|
||||||
|
join(4)
|
||||||
|
if len(d.kicked) != 4 {
|
||||||
|
t.Errorf("joins during a raid are kicked: %v", d.kicked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet for 5 minutes: it ends, invites resume, the list is posted.
|
||||||
|
*now = now.Add(6 * time.Minute)
|
||||||
|
e.checkRaids(ctx)
|
||||||
|
if e.RaidActive(guild) {
|
||||||
|
t.Fatal("the raid should end after the quiet period")
|
||||||
|
}
|
||||||
|
if len(d.requests) != 2 || !strings.Contains(d.requests[1], "invites_disabled_until:<nil>") {
|
||||||
|
t.Errorf("invites should be resumed: %v", d.requests)
|
||||||
|
}
|
||||||
|
var list string
|
||||||
|
for name, content := range d.files {
|
||||||
|
if strings.HasPrefix(name, "raid-") {
|
||||||
|
list = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Count(list, "\tkick\t") != 4 {
|
||||||
|
t.Errorf("the summary should list the 4 kicked accounts:\n%s", list)
|
||||||
|
}
|
||||||
|
if err := e.EndRaid(ctx, guild, "1"); err == nil {
|
||||||
|
t.Error("ending twice should say there's no raid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoAlertFloodDuringARaid(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.JoinGateEnabled, s.NoAvatar = true, securityrepo.ActionKick
|
||||||
|
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidPauseInvites = true, 3, securityrepo.ActionKick, false
|
||||||
|
refresh(e)
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), i)}}) // no avatar
|
||||||
|
}
|
||||||
|
if n := d.sentCount(); n > 4 {
|
||||||
|
t.Errorf("a raid of 50 should produce a handful of alerts, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRaidAlertOnlyTouchesNobody(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidPauseInvites = true, 3, securityrepo.ActionAlert, false
|
||||||
|
refresh(e)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
e.OnMemberJoin(ctx, guild, d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), i)))
|
||||||
|
}
|
||||||
|
if !e.RaidActive(guild) || len(d.kicked)+len(d.banned) != 0 || len(d.requests) != 0 {
|
||||||
|
t.Error("alert-only raid mode must not remove anyone or pause invites")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRaidMemoryIsBounded(t *testing.T) {
|
||||||
|
e, d, st, now := testEngine(t)
|
||||||
|
s := st.set(100)
|
||||||
|
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction = true, 3, securityrepo.ActionAlert
|
||||||
|
refresh(e)
|
||||||
|
for i := 0; i < maxRecentJoins+300; i++ {
|
||||||
|
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), i), Avatar: "a"}})
|
||||||
|
}
|
||||||
|
r := e.raidFor(guild)
|
||||||
|
if len(r.joins) > maxRecentJoins {
|
||||||
|
t.Errorf("recent joins not capped: %d", len(r.joins))
|
||||||
|
}
|
||||||
|
_ = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Anti-nuke ----
|
||||||
|
|
||||||
|
func nukeEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore) {
|
||||||
|
e, d, st, _ := testEngine(t)
|
||||||
|
st.set(100).AntiNukeEnabled = true
|
||||||
|
refresh(e)
|
||||||
|
d.addMember("50", "200", "202", "204") // a rogue admin with a booster role
|
||||||
|
return e, d, st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNukeOverTheLimitIsQuarantined(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
// Limit is 2 channel deletes per 10s.
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c1"))
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c2"))
|
||||||
|
if _, ok := st.quarantines[key(100, 50)]; ok {
|
||||||
|
t.Fatal("two deletes are within the limit")
|
||||||
|
}
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c3"))
|
||||||
|
|
||||||
|
q, ok := st.quarantines[key(100, 50)]
|
||||||
|
if !ok || fmt.Sprint(q.PreviousRoleIDs) != "[200 202]" {
|
||||||
|
t.Fatalf("roles should be saved (not the managed one): %+v", q)
|
||||||
|
}
|
||||||
|
if got := strings.Join(d.members["50"].Roles, ","); got != "204" {
|
||||||
|
t.Errorf("only the unremovable booster role should remain, got %s", got)
|
||||||
|
}
|
||||||
|
if d.timeouts["50"] == nil {
|
||||||
|
t.Error("the nuker should be timed out")
|
||||||
|
}
|
||||||
|
sends := d.channelSends()
|
||||||
|
last := sends[len(sends)-1]
|
||||||
|
if !strings.Contains(last.Embeds[0].Title, "Nuke attempt stopped") || !contains(last.AllowedMentions.Users, "1") {
|
||||||
|
t.Errorf("the owner should be pinged: %+v", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next delete within a minute doesn't trigger a second response.
|
||||||
|
before := len(d.sent)
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c4"))
|
||||||
|
if len(d.sent) != before {
|
||||||
|
t.Error("one attempt should be answered once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedOwnerAndBotAreExempt(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
d.addMember("60", "201")
|
||||||
|
st.trusted[100] = []securityrepo.Trusted{{TargetID: 50, Kind: "user"}, {TargetID: 201, Kind: "role"}}
|
||||||
|
refresh(e)
|
||||||
|
for _, who := range []string{"50", "60", "1", "999"} {
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberBanAdd, who, "x"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(st.quarantines) != 0 {
|
||||||
|
t.Errorf("trusted users, trusted roles, the owner and the bot are exempt: %v", st.quarantines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstantTriggers(t *testing.T) {
|
||||||
|
for name, ent := range map[string]*discordgo.AuditLogEntry{
|
||||||
|
"prune": entry(discordgo.AuditLogActionMemberPrune, "50", ""),
|
||||||
|
"vanity": entry(discordgo.AuditLogActionGuildUpdate, "50", guild, change("vanity_url_code", "old", "scam")),
|
||||||
|
"perms": entry(discordgo.AuditLogActionRoleUpdate, "50", "202", change("permissions", "0", fmt.Sprint(discordgo.PermissionAdministrator))),
|
||||||
|
"grant": entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$add", nil, []any{map[string]any{"id": "200"}})),
|
||||||
|
"newrole": entry(discordgo.AuditLogActionRoleCreate, "50", "x", change("permissions", nil, fmt.Sprint(discordgo.PermissionManageRoles))),
|
||||||
|
"botrole": entry(discordgo.AuditLogActionRoleUpdate, "50", "299", change("name", "Velox", "x")),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
d.addMember("70")
|
||||||
|
e.OnAuditLogEntry(ctx, guild, ent)
|
||||||
|
if _, ok := st.quarantines[key(100, 50)]; !ok {
|
||||||
|
t.Errorf("%s should quarantine at once", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarmlessChangesAreIgnored(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
d.addMember("70")
|
||||||
|
// Renaming a role, removing permissions, giving a plain role.
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionRoleUpdate, "50", "202", change("permissions", fmt.Sprint(discordgo.PermissionAdministrator), "0")))
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$add", nil, []any{map[string]any{"id": "202"}})))
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelUpdate, "50", "c"))
|
||||||
|
if len(st.quarantines) != 0 {
|
||||||
|
t.Error("harmless changes must not trigger anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarmlessEntriesCostNoLookups(t *testing.T) {
|
||||||
|
e, d, _ := nukeEngine(t)
|
||||||
|
d.lookups = 0
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMessageDelete, "50", "x"))
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberUpdate, "50", "x"))
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$remove", nil, []any{map[string]any{"id": "202"}})))
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionGuildUpdate, "50", guild, change("name", "a", "b")))
|
||||||
|
if d.lookups != 0 {
|
||||||
|
t.Errorf("harmless entries made %d Discord lookups", d.lookups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelfGrantedTrustedRoleIsNotABypass(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.trusted[100] = []securityrepo.Trusted{{TargetID: 201, Kind: "role"}}
|
||||||
|
refresh(e)
|
||||||
|
// The rogue admin gives themselves the trusted role...
|
||||||
|
d.members["50"].Roles = append(d.members["50"].Roles, "201")
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "50", change("$add", nil, []any{map[string]any{"id": "201"}})))
|
||||||
|
if _, ok := st.quarantines[key(100, 50)]; !ok {
|
||||||
|
t.Fatal("granting yourself a trusted role must be stopped, not count as trust")
|
||||||
|
}
|
||||||
|
if contains(d.members["50"].Roles, "201") {
|
||||||
|
t.Error("the trusted role should be gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedRoleGrantedToAnAltIsTakenBack(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.trusted[100] = []securityrepo.Trusted{{TargetID: 201, Kind: "role"}}
|
||||||
|
refresh(e)
|
||||||
|
d.addMember("71", "201")
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "71", change("$add", nil, []any{map[string]any{"id": "201"}})))
|
||||||
|
if _, ok := st.quarantines[key(100, 50)]; !ok {
|
||||||
|
t.Error("the granter should be quarantined")
|
||||||
|
}
|
||||||
|
if contains(d.members["71"].Roles, "201") {
|
||||||
|
t.Error("the alt should lose the trusted role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNukingBotIsKicked(t *testing.T) {
|
||||||
|
e, d, _ := nukeEngine(t)
|
||||||
|
d.members["80"] = &discordgo.Member{User: &discordgo.User{ID: "80", Bot: true}}
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "80", ""))
|
||||||
|
if !contains(d.kicked, "80") {
|
||||||
|
t.Error("a nuking bot should be kicked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntiNukeOffDoesNothing(t *testing.T) {
|
||||||
|
e, _, st := nukeEngine(t)
|
||||||
|
st.set(100).AntiNukeEnabled = false
|
||||||
|
refresh(e)
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
|
||||||
|
if len(st.quarantines) != 0 {
|
||||||
|
t.Error("anti-nuke off must not act")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthorizedBotAdd(t *testing.T) {
|
||||||
|
e, d, st, _ := testEngine(t)
|
||||||
|
st.set(100).JoinGateEnabled = true
|
||||||
|
refresh(e)
|
||||||
|
d.addMember("50", "200")
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionBotAdd, "50", "81"))
|
||||||
|
if !contains(d.kicked, "81") {
|
||||||
|
t.Error("a bot added by someone untrusted should be kicked")
|
||||||
|
}
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionBotAdd, "1", "82"))
|
||||||
|
if contains(d.kicked, "82") {
|
||||||
|
t.Error("the owner may add bots")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDangerousQuarantineRoleIsNotAssigned(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.set(100).QuarantineRoleID = 200 // misconfigured: the Admin role
|
||||||
|
refresh(e)
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
|
||||||
|
if contains(d.members["50"].Roles, "200") {
|
||||||
|
t.Error("a quarantine role with dangerous permissions must never be assigned")
|
||||||
|
}
|
||||||
|
|
||||||
|
st.set(100).QuarantineRoleID = 203
|
||||||
|
refresh(e)
|
||||||
|
d.addMember("51", "201")
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "51", ""))
|
||||||
|
if !contains(d.members["51"].Roles, "203") {
|
||||||
|
t.Error("a safe quarantine role should be assigned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuarantineRoleVeloxCantAssignDoesNotBlockQuarantine(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.set(100).QuarantineRoleID = 203
|
||||||
|
refresh(e)
|
||||||
|
d.rejectRole = "203" // e.g. moved above Velox's role after it was configured
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
|
||||||
|
if got := strings.Join(d.members["50"].Roles, ","); got != "204" {
|
||||||
|
t.Errorf("roles must still be removed without the quarantine role, got %s", got)
|
||||||
|
}
|
||||||
|
if d.timeouts["50"] == nil {
|
||||||
|
t.Error("and the member still timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseGivesRolesBack(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
st.set(100).QuarantineRoleID = 203
|
||||||
|
refresh(e)
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
|
||||||
|
|
||||||
|
// The Mod role was deleted meanwhile... no wait, Member (202) was.
|
||||||
|
d.roles = append(d.roles[:3], d.roles[4:]...)
|
||||||
|
if err := e.Release(ctx, guild, "50", "1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := strings.Join(d.members["50"].Roles, ",")
|
||||||
|
if got != "204,200" {
|
||||||
|
t.Errorf("roles after release = %s (want the booster role kept, Admin back, deleted Member skipped, quarantine role gone)", got)
|
||||||
|
}
|
||||||
|
if d.timeouts["50"] != nil {
|
||||||
|
t.Error("the timeout should be removed")
|
||||||
|
}
|
||||||
|
if _, ok := st.quarantines[key(100, 50)]; ok {
|
||||||
|
t.Error("the quarantine record should be gone")
|
||||||
|
}
|
||||||
|
if err := e.Release(ctx, guild, "50", "1"); err == nil {
|
||||||
|
t.Error("releasing twice should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lockdown ----
|
||||||
|
|
||||||
|
func TestLockAndUnlockRestoreExactly(t *testing.T) {
|
||||||
|
e, d, _, _ := testEngine(t)
|
||||||
|
d.channels["300"] = &discordgo.Channel{ID: "300", GuildID: guild, Type: discordgo.ChannelTypeGuildText,
|
||||||
|
PermissionOverwrites: []*discordgo.PermissionOverwrite{{ID: guild, Type: discordgo.PermissionOverwriteTypeRole,
|
||||||
|
Allow: discordgo.PermissionAddReactions, Deny: discordgo.PermissionAttachFiles}}}
|
||||||
|
d.channels["301"] = &discordgo.Channel{ID: "301", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
|
||||||
|
d.channels["302"] = &discordgo.Channel{ID: "302", GuildID: guild, Type: discordgo.ChannelTypeGuildCategory}
|
||||||
|
d.channels["400"] = &discordgo.Channel{ID: "400", GuildID: "other", Type: discordgo.ChannelTypeGuildText}
|
||||||
|
|
||||||
|
if err := e.LockChannel(ctx, guild, "400", "1", false); err == nil {
|
||||||
|
t.Fatal("a channel of another server must never be touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, failed, err := e.LockServer(ctx, guild, "1")
|
||||||
|
if err != nil || locked != 2 || failed != 0 {
|
||||||
|
t.Fatalf("locked=%d failed=%d err=%v (categories are skipped)", locked, failed, err)
|
||||||
|
}
|
||||||
|
o := d.channels["300"].PermissionOverwrites[0]
|
||||||
|
if o.Deny&discordgo.PermissionSendMessages == 0 || o.Allow&discordgo.PermissionAddReactions != 0 || o.Deny&discordgo.PermissionAttachFiles == 0 {
|
||||||
|
t.Errorf("lock should deny chat and keep other bits: %+v", o)
|
||||||
|
}
|
||||||
|
// Someone changes an unrelated bit during the lockdown; it must survive.
|
||||||
|
o.Allow |= discordgo.PermissionEmbedLinks
|
||||||
|
// Locking again must not overwrite the saved "before" state.
|
||||||
|
_ = e.LockChannel(ctx, guild, "300", "1", false)
|
||||||
|
|
||||||
|
if _, _, err := e.UnlockServer(ctx, guild, "1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o = d.channels["300"].PermissionOverwrites[0]
|
||||||
|
if o.Allow != discordgo.PermissionAddReactions|discordgo.PermissionEmbedLinks || o.Deny != discordgo.PermissionAttachFiles {
|
||||||
|
t.Errorf("unlock should restore the original lock bits and keep the new one: allow=%d deny=%d", o.Allow, o.Deny)
|
||||||
|
}
|
||||||
|
if len(d.channels["301"].PermissionOverwrites) != 0 {
|
||||||
|
t.Error("a channel that had no overwrite should have none again")
|
||||||
|
}
|
||||||
|
if _, _, err := e.UnlockServer(ctx, guild, "1"); err == nil {
|
||||||
|
t.Error("unlocking an unlocked server should say so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Configuration rules ----
|
||||||
|
|
||||||
|
func TestConfigurationPermissions(t *testing.T) {
|
||||||
|
e, d, st, _ := testEngine(t)
|
||||||
|
admin := d.addMember("50", "200")
|
||||||
|
owner := d.members["1"]
|
||||||
|
trustedUser := d.addMember("60")
|
||||||
|
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
|
||||||
|
refresh(e)
|
||||||
|
|
||||||
|
// Turning on: Manage Server is enough. Turning off: owner or trusted only.
|
||||||
|
if err := e.SetFeature(ctx, guild, admin, discordgo.PermissionManageGuild, securityrepo.FeatureAntiNuke, true); err != nil {
|
||||||
|
t.Errorf("an admin may turn anti-nuke on: %v", err)
|
||||||
|
}
|
||||||
|
if err := e.SetFeature(ctx, guild, admin, discordgo.PermissionAdministrator, securityrepo.FeatureAntiNuke, false); !errors.Is(err, ErrNotTrusted) {
|
||||||
|
t.Errorf("an untrusted admin must not turn anti-nuke off: %v", err)
|
||||||
|
}
|
||||||
|
if err := e.SetFeature(ctx, guild, trustedUser, 0, securityrepo.FeatureAntiNuke, false); err != nil {
|
||||||
|
t.Errorf("a trusted user may: %v", err)
|
||||||
|
}
|
||||||
|
if err := e.SetFeature(ctx, guild, d.addMember("61"), 0, securityrepo.FeatureJoinGate, true); err == nil {
|
||||||
|
t.Error("a regular member must not turn things on")
|
||||||
|
}
|
||||||
|
if err := e.SetFeature(ctx, guild, nil, discordgo.PermissionAdministrator, securityrepo.FeatureJoinGate, true); err == nil {
|
||||||
|
t.Error("no actor, no change")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alerts: trusted only, never @everyone, only this server's channels.
|
||||||
|
d.channels["500"] = &discordgo.Channel{ID: "500", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
|
||||||
|
d.channels["501"] = &discordgo.Channel{ID: "501", GuildID: "other", Type: discordgo.ChannelTypeGuildText}
|
||||||
|
if err := e.SetAlerts(ctx, guild, admin, "500", "201"); !errors.Is(err, ErrNotTrusted) {
|
||||||
|
t.Errorf("untrusted alert change: %v", err)
|
||||||
|
}
|
||||||
|
if err := e.SetAlerts(ctx, guild, owner, "500", guild); err == nil {
|
||||||
|
t.Error("alerts must never ping @everyone")
|
||||||
|
}
|
||||||
|
if err := e.SetAlerts(ctx, guild, owner, "501", ""); err == nil {
|
||||||
|
t.Error("another server's channel must be refused")
|
||||||
|
}
|
||||||
|
if err := e.SetAlerts(ctx, guild, owner, "500", "201"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quarantine role: safe roles below Velox only.
|
||||||
|
for role, ok := range map[string]bool{"200": false, guild: false, "204": false, "299": false, "203": true} {
|
||||||
|
err := e.SetQuarantineRole(ctx, guild, owner, role)
|
||||||
|
if (err == nil) != ok {
|
||||||
|
t.Errorf("quarantine role %s: err=%v", role, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust: owner only, never @everyone, bounded.
|
||||||
|
if err := e.Trust(ctx, guild, trustedUser, "user", "50"); !errors.Is(err, ErrNotOwner) {
|
||||||
|
t.Errorf("trusted users must not spread trust: %v", err)
|
||||||
|
}
|
||||||
|
if err := e.Trust(ctx, guild, owner, "role", guild); err == nil {
|
||||||
|
t.Error("trusting @everyone must be refused")
|
||||||
|
}
|
||||||
|
if err := e.Trust(ctx, guild, owner, "user", "404"); err == nil {
|
||||||
|
t.Error("trusting someone not in the server must be refused")
|
||||||
|
}
|
||||||
|
if err := e.Trust(ctx, guild, owner, "role", "201"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := e.Untrust(ctx, guild, admin, "201"); !errors.Is(err, ErrNotOwner) {
|
||||||
|
t.Errorf("only the owner untrusts: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardCommands(t *testing.T) {
|
||||||
|
e, d, st := nukeEngine(t)
|
||||||
|
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
|
||||||
|
st.cmds = []securityrepo.Command{
|
||||||
|
{ID: 1, GuildID: 100, CommandType: "release_quarantine", Payload: []byte(`{"user_id":"50; DROP"}`), RequestedBy: 1},
|
||||||
|
{ID: 2, GuildID: 100, CommandType: "release_quarantine", Payload: []byte(`{"user_id":"50"}`), RequestedBy: 1},
|
||||||
|
{ID: 3, GuildID: 100, CommandType: "nope", RequestedBy: 1},
|
||||||
|
}
|
||||||
|
e.ProcessCommands(ctx)
|
||||||
|
if _, ok := st.quarantines[key(100, 50)]; ok {
|
||||||
|
t.Error("the valid release should have run")
|
||||||
|
}
|
||||||
|
if len(st.done) != 3 {
|
||||||
|
t.Errorf("every command must be marked done: %v", st.done)
|
||||||
|
}
|
||||||
|
_ = d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeNameDefusesMentionsAndMarkdown(t *testing.T) {
|
||||||
|
got := safeName(&discordgo.User{Username: "x", GlobalName: "@everyone **hi** <@&1> [link](http://a)"})
|
||||||
|
if strings.Contains(got, "@everyone") || strings.Contains(got, "<@&") || strings.Contains(got, "**") || strings.Contains(got, "](") {
|
||||||
|
t.Errorf("unsafe name rendering: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import "github.com/bwmarrin/discordgo"
|
||||||
|
|
||||||
|
// The real session must provide everything the system calls.
|
||||||
|
var _ Discord = (*discordgo.Session)(nil)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeStore is an in-memory ticketrepo with the same rules as the SQL.
|
||||||
|
type fakeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
settings map[int64]*ticketrepo.Settings
|
||||||
|
cats []ticketrepo.Category
|
||||||
|
tickets []*ticketrepo.Ticket
|
||||||
|
next map[int64]int
|
||||||
|
cmds []ticketrepo.Command
|
||||||
|
done []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStore() *fakeStore {
|
||||||
|
return &fakeStore{settings: map[int64]*ticketrepo.Settings{}, next: map[int64]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) configure(guild, channel, logCh int64) {
|
||||||
|
f.settings[guild] = &ticketrepo.Settings{
|
||||||
|
GuildID: guild, Enabled: true, ChannelID: channel, LogChannelID: logCh,
|
||||||
|
PanelTitle: ticketrepo.DefaultPanelTitle, PanelDescription: ticketrepo.DefaultPanelDescription,
|
||||||
|
MaxOpenPerUser: 1, Configured: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) addCat(c ticketrepo.Category) ticketrepo.Category {
|
||||||
|
c.ID = int64(len(f.cats) + 1)
|
||||||
|
f.cats = append(f.cats, c)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*ticketrepo.Settings, error) {
|
||||||
|
if s, ok := f.settings[g]; ok {
|
||||||
|
cp := *s
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return &ticketrepo.Settings{GuildID: g, Enabled: true, PanelTitle: ticketrepo.DefaultPanelTitle, MaxOpenPerUser: 1}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) SetPanelMessage(_ context.Context, g, m int64) error {
|
||||||
|
f.settings[g].PanelMessageID = m
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) NextNumber(_ context.Context, g int64) (int, error) {
|
||||||
|
f.next[g]++
|
||||||
|
return f.next[g], nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListCategories(_ context.Context, g int64) ([]ticketrepo.Category, error) {
|
||||||
|
var out []ticketrepo.Category
|
||||||
|
for _, c := range f.cats {
|
||||||
|
if c.GuildID == g {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetCategory(_ context.Context, g, id int64) (*ticketrepo.Category, error) {
|
||||||
|
for _, c := range f.cats {
|
||||||
|
if c.GuildID == g && c.ID == id {
|
||||||
|
cp := c
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CountOpenByOpener(_ context.Context, g, u int64) (int, error) {
|
||||||
|
n := 0
|
||||||
|
for _, t := range f.tickets {
|
||||||
|
if t.GuildID == g && t.OpenerID == u && t.Status == ticketrepo.StatusOpen {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CreateTicket(_ context.Context, t ticketrepo.Ticket) (int64, error) {
|
||||||
|
t.ID = int64(len(f.tickets) + 1)
|
||||||
|
t.Status = ticketrepo.StatusOpen
|
||||||
|
t.CreatedAt = time.Now().Add(-90 * time.Minute)
|
||||||
|
f.tickets = append(f.tickets, &t)
|
||||||
|
return t.ID, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) byThread(th int64) *ticketrepo.Ticket {
|
||||||
|
for _, t := range f.tickets {
|
||||||
|
if t.ThreadID == th {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetTicketByThread(_ context.Context, th int64) (*ticketrepo.Ticket, error) {
|
||||||
|
if t := f.byThread(th); t != nil {
|
||||||
|
cp := *t
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) Claim(_ context.Context, th, staff int64) (int64, error) {
|
||||||
|
t := f.byThread(th)
|
||||||
|
if t == nil || t.Status != ticketrepo.StatusOpen {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if t.ClaimedBy == 0 {
|
||||||
|
t.ClaimedBy = staff
|
||||||
|
}
|
||||||
|
return t.ClaimedBy, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) Close(_ context.Context, th, by int64, reason string) (bool, error) {
|
||||||
|
t := f.byThread(th)
|
||||||
|
if t == nil || t.Status != ticketrepo.StatusOpen {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, by, reason, time.Now()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]ticketrepo.Command, error) {
|
||||||
|
var out []ticketrepo.Command
|
||||||
|
for _, c := range f.cmds {
|
||||||
|
done := false
|
||||||
|
for _, d := range f.done {
|
||||||
|
done = done || d == c.ID
|
||||||
|
}
|
||||||
|
if !done {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
|
||||||
|
f.done = append(f.done, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeDiscord records what the flows ask Discord to do.
|
||||||
|
type fakeDiscord struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
nextID int
|
||||||
|
threads []*discordgo.ThreadStart
|
||||||
|
members map[string][]string // thread -> user IDs
|
||||||
|
sent map[string][]*discordgo.MessageSend
|
||||||
|
edits []*discordgo.MessageEdit
|
||||||
|
chEdits map[string]*discordgo.ChannelEdit
|
||||||
|
deleted []string
|
||||||
|
files map[string]string // file name -> contents
|
||||||
|
history map[string][]*discordgo.Message
|
||||||
|
messages map[string]*discordgo.Message // by "channel/message"
|
||||||
|
failSend error
|
||||||
|
failEdit error
|
||||||
|
failFetch error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiscord() *fakeDiscord {
|
||||||
|
return &fakeDiscord{
|
||||||
|
nextID: 1000, members: map[string][]string{}, sent: map[string][]*discordgo.MessageSend{},
|
||||||
|
chEdits: map[string]*discordgo.ChannelEdit{}, files: map[string]string{},
|
||||||
|
history: map[string][]*discordgo.Message{}, messages: map[string]*discordgo.Message{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) newID() string {
|
||||||
|
f.nextID++
|
||||||
|
return fmt.Sprint(f.nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) ThreadStartComplex(ch string, data *discordgo.ThreadStart, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||||
|
f.threads = append(f.threads, data)
|
||||||
|
return &discordgo.Channel{ID: f.newID(), ParentID: ch, Name: data.Name, Type: data.Type}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ThreadMemberAdd(th, u string, _ ...discordgo.RequestOption) error {
|
||||||
|
f.members[th] = append(f.members[th], u)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ThreadMemberRemove(th, u string, _ ...discordgo.RequestOption) error {
|
||||||
|
var keep []string
|
||||||
|
for _, m := range f.members[th] {
|
||||||
|
if m != u {
|
||||||
|
keep = append(keep, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.members[th] = keep
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||||
|
if f.failSend != nil {
|
||||||
|
return nil, f.failSend
|
||||||
|
}
|
||||||
|
for _, file := range data.Files {
|
||||||
|
raw, _ := io.ReadAll(file.Reader)
|
||||||
|
f.files[file.Name] = string(raw)
|
||||||
|
}
|
||||||
|
f.sent[ch] = append(f.sent[ch], data)
|
||||||
|
m := &discordgo.Message{ID: f.newID(), ChannelID: ch, Content: data.Content, Embeds: data.Embeds, Components: data.Components}
|
||||||
|
f.messages[ch+"/"+m.ID] = m
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelMessageEditComplex(e *discordgo.MessageEdit, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||||
|
if f.failEdit != nil {
|
||||||
|
return nil, f.failEdit
|
||||||
|
}
|
||||||
|
f.edits = append(f.edits, e)
|
||||||
|
return &discordgo.Message{ID: e.ID, ChannelID: e.Channel}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelMessage(ch, m string, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
|
||||||
|
if msg, ok := f.messages[ch+"/"+m]; ok {
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unknown message")
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelMessages(ch string, limit int, before, _, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) {
|
||||||
|
if f.failFetch != nil {
|
||||||
|
return nil, f.failFetch
|
||||||
|
}
|
||||||
|
// history is stored newest first, like Discord returns it.
|
||||||
|
all := f.history[ch]
|
||||||
|
start := 0
|
||||||
|
if before != "" {
|
||||||
|
for i, m := range all {
|
||||||
|
if m.ID == before {
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
return all[start:end], nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelEditComplex(ch string, data *discordgo.ChannelEdit, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||||
|
f.chEdits[ch] = data
|
||||||
|
return &discordgo.Channel{ID: ch}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeDiscord) ChannelDelete(ch string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
|
||||||
|
f.deleted = append(f.deleted, ch)
|
||||||
|
return &discordgo.Channel{ID: ch}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartCommandListener waits for the dashboard's requests (ticket_commands
|
||||||
|
// rows, announced with NOTIFY) and carries them out. It returns when the
|
||||||
|
// connection fails or ctx ends.
|
||||||
|
func StartCommandListener(ctx context.Context, connString string, d Discord, st Store) error {
|
||||||
|
conn, err := pgx.Connect(ctx, connString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, "LISTEN ticket_commands"); err != nil {
|
||||||
|
return fmt.Errorf("listen: %w", err)
|
||||||
|
}
|
||||||
|
// Anything queued while the bot was down.
|
||||||
|
ProcessCommands(ctx, d, st)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, err := conn.WaitForNotification(ctx); err != nil {
|
||||||
|
return fmt.Errorf("waiting for notifications: %w", err)
|
||||||
|
}
|
||||||
|
ProcessCommands(ctx, d, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessCommands carries out every pending command once. A command that
|
||||||
|
// fails is still marked done: retrying a panel post that failed for missing
|
||||||
|
// permissions would only fail again, and the error is logged.
|
||||||
|
func ProcessCommands(ctx context.Context, d Discord, st Store) {
|
||||||
|
cmds, err := st.ListUnprocessedCommands(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tickets: listing dashboard commands: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range cmds {
|
||||||
|
switch c.CommandType {
|
||||||
|
case "post_panel":
|
||||||
|
if _, err := PostPanel(ctx, d, st, c.GuildID); err != nil {
|
||||||
|
var ue UserError
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
log.Printf("tickets: posting the panel for guild %d from the dashboard: %s", c.GuildID, ue)
|
||||||
|
} else {
|
||||||
|
log.Printf("tickets: posting the panel for guild %d from the dashboard: %v", c.GuildID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Printf("tickets: unknown dashboard command %q (%d)", c.CommandType, c.ID)
|
||||||
|
}
|
||||||
|
if err := st.MarkCommandProcessed(ctx, c.ID); err != nil {
|
||||||
|
log.Printf("tickets: marking command %d done: %v", c.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dashboard's request reaches the bot through the real trigger and
|
||||||
|
// LISTEN/NOTIFY:
|
||||||
|
//
|
||||||
|
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/tickets
|
||||||
|
func TestListenerPostsPanelOnNotify(t *testing.T) {
|
||||||
|
dsn := os.Getenv("TICKETS_TEST_DB")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TICKETS_TEST_DB not set")
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
const g = 515151
|
||||||
|
for _, q := range []string{`DELETE FROM ticket_commands`, `DELETE FROM ticket_categories WHERE guild_id = $1`, `DELETE FROM ticket_settings WHERE guild_id = $1`} {
|
||||||
|
if q == `DELETE FROM ticket_commands` {
|
||||||
|
_, err = db.Exec(q)
|
||||||
|
} else {
|
||||||
|
_, err = db.Exec(q, g)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := ticketrepo.NewRepo(db)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := repo.SetChannels(ctx, g, 10, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := newDiscord()
|
||||||
|
go func() { _ = StartCommandListener(ctx, dsn, d, repo) }()
|
||||||
|
time.Sleep(500 * time.Millisecond) // let LISTEN start
|
||||||
|
|
||||||
|
if _, err := db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 1)`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
s, _ := repo.GetSettings(ctx, g)
|
||||||
|
if s.PanelMessageID != 0 {
|
||||||
|
cmds, _ := repo.ListUnprocessedCommands(ctx)
|
||||||
|
if len(cmds) != 0 {
|
||||||
|
t.Errorf("command not marked processed: %+v", cmds)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("the panel was never posted")
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actor is whoever pressed the button or ran the command, with their
|
||||||
|
// permissions in the ticket thread.
|
||||||
|
type Actor struct {
|
||||||
|
Member *discordgo.Member
|
||||||
|
Perms int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Actor) userID() string {
|
||||||
|
if a.Member == nil || a.Member.User == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.Member.User.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// load finds the open ticket behind a thread and its category (nil when the
|
||||||
|
// category was deleted since).
|
||||||
|
func load(ctx context.Context, st Store, threadID string) (*ticketrepo.Ticket, *ticketrepo.Category, error) {
|
||||||
|
t, err := st.GetTicketByThread(ctx, mustID(threadID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if t == nil {
|
||||||
|
return nil, nil, ErrNotATicket
|
||||||
|
}
|
||||||
|
if t.Status != ticketrepo.StatusOpen {
|
||||||
|
return nil, nil, ErrClosed
|
||||||
|
}
|
||||||
|
var cat *ticketrepo.Category
|
||||||
|
if t.CategoryID != 0 {
|
||||||
|
if cat, err = st.GetCategory(ctx, t.GuildID, t.CategoryID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateIntro edits the ticket's first message to show who claimed it and
|
||||||
|
// whether it is closed. It keeps the original text (the details are only in
|
||||||
|
// that message) and is best effort: a deleted first message doesn't stop the
|
||||||
|
// ticket from being claimed or closed.
|
||||||
|
func updateIntro(d Discord, t *ticketrepo.Ticket) {
|
||||||
|
if t.IntroMessage == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err := d.ChannelMessage(id(t.ThreadID), id(t.IntroMessage))
|
||||||
|
if err != nil || len(msg.Embeds) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fresh := IntroEmbed(t, "")
|
||||||
|
e := *msg.Embeds[0]
|
||||||
|
e.Color, e.Footer = fresh.Color, fresh.Footer
|
||||||
|
e.Fields = fresh.Fields
|
||||||
|
embeds := []*discordgo.MessageEmbed{&e}
|
||||||
|
comps := IntroComponents(t.ClaimedBy != 0, t.Status == ticketrepo.StatusClosed)
|
||||||
|
if _, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||||
|
Channel: id(t.ThreadID), ID: id(t.IntroMessage), Embeds: &embeds, Components: &comps,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("tickets: updating the first message of ticket %d: %v", t.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim marks the actor as the ticket's handler. Only staff can claim, and a
|
||||||
|
// claimed ticket stays with its first claimer.
|
||||||
|
func Claim(ctx context.Context, d Discord, st Store, threadID string, a Actor) error {
|
||||||
|
t, cat, err := load(ctx, st, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !IsStaff(a.Member, a.Perms, cat) {
|
||||||
|
return ErrNotStaff
|
||||||
|
}
|
||||||
|
me := mustID(a.userID())
|
||||||
|
holder, err := st.Claim(ctx, t.ThreadID, me)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if holder == 0 {
|
||||||
|
return ErrClosed
|
||||||
|
}
|
||||||
|
if holder != me {
|
||||||
|
return UserError(fmt.Sprintf("<@%d> is already handling this ticket.", holder))
|
||||||
|
}
|
||||||
|
t.ClaimedBy = me
|
||||||
|
updateIntro(d, t)
|
||||||
|
_, err = d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
|
||||||
|
Content: fmt.Sprintf("<@%d> is handling this ticket.", me),
|
||||||
|
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCanClose runs before the close form is shown.
|
||||||
|
func CheckCanClose(ctx context.Context, st Store, threadID string, a Actor) error {
|
||||||
|
t, cat, err := load(ctx, st, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
|
||||||
|
return ErrNotInvolved
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the ticket: a note in the thread, a summary with the
|
||||||
|
// transcript in the log channel, the first message's buttons disabled, and
|
||||||
|
// the thread locked and archived. The opener or staff can close.
|
||||||
|
func Close(ctx context.Context, d Discord, st Store, threadID string, a Actor, reason string) error {
|
||||||
|
t, cat, err := load(ctx, st, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
me := mustID(a.userID())
|
||||||
|
if me != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
|
||||||
|
return ErrNotInvolved
|
||||||
|
}
|
||||||
|
reason = clip(reason, maxReason)
|
||||||
|
|
||||||
|
// The transcript is taken before anything else changes in the thread.
|
||||||
|
transcript, count, terr := Transcript(d, t)
|
||||||
|
|
||||||
|
closed, err := st.Close(ctx, t.ThreadID, me, reason)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !closed {
|
||||||
|
return ErrClosed // someone else closed it a moment ago
|
||||||
|
}
|
||||||
|
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, me, reason, time.Now()
|
||||||
|
|
||||||
|
note := fmt.Sprintf("🔒 Closed by <@%d>.", me)
|
||||||
|
if reason != "" {
|
||||||
|
note += " Reason: " + reason
|
||||||
|
}
|
||||||
|
if _, err := d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
|
||||||
|
Content: note, AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("tickets: posting the close note in ticket %d: %v", t.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr != nil {
|
||||||
|
log.Printf("tickets: transcript of ticket %d: %v", t.ID, terr)
|
||||||
|
}
|
||||||
|
postLog(ctx, d, st, t, transcript, count)
|
||||||
|
updateIntro(d, t)
|
||||||
|
|
||||||
|
yes := true
|
||||||
|
if _, err := d.ChannelEditComplex(id(t.ThreadID), &discordgo.ChannelEdit{Locked: &yes, Archived: &yes}); err != nil {
|
||||||
|
return fmt.Errorf("locking the thread: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// postLog sends the closed ticket's summary and transcript to the log
|
||||||
|
// channel, if the guild has one.
|
||||||
|
func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, transcript []byte, count int) {
|
||||||
|
s, err := st.GetSettings(ctx, t.GuildID)
|
||||||
|
if err != nil || s.LogChannelID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claimed := "Nobody"
|
||||||
|
if t.ClaimedBy != 0 {
|
||||||
|
claimed = "<@" + id(t.ClaimedBy) + ">"
|
||||||
|
}
|
||||||
|
reason := t.CloseReason
|
||||||
|
if reason == "" {
|
||||||
|
reason = "No reason given"
|
||||||
|
}
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
|
||||||
|
Description: "**" + clip(t.Subject, maxSubject) + "**",
|
||||||
|
Color: accent,
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
||||||
|
{Name: "Claimed by", Value: claimed, Inline: true},
|
||||||
|
{Name: "Closed by", Value: "<@" + id(t.ClosedBy) + ">", Inline: true},
|
||||||
|
{Name: "Reason", Value: clip(reason, 1024)},
|
||||||
|
{Name: "Thread", Value: "<#" + id(t.ThreadID) + ">", Inline: true},
|
||||||
|
{Name: "Messages", Value: fmt.Sprint(count), Inline: true},
|
||||||
|
{Name: "Open for", Value: humanDuration(t.ClosedAt.Sub(t.CreatedAt)), Inline: true},
|
||||||
|
},
|
||||||
|
Timestamp: t.ClosedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
send := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{embed}, AllowedMentions: &discordgo.MessageAllowedMentions{}}
|
||||||
|
if len(transcript) > 0 {
|
||||||
|
send.Files = []*discordgo.File{{
|
||||||
|
Name: fmt.Sprintf("ticket-%04d.txt", t.Number),
|
||||||
|
ContentType: "text/plain; charset=utf-8",
|
||||||
|
Reader: bytes.NewReader(transcript),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if _, err := d.ChannelMessageSendComplex(id(s.LogChannelID), send); err != nil {
|
||||||
|
log.Printf("tickets: posting ticket %d to the log channel: %v", t.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanDuration(d time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case d <= 0:
|
||||||
|
return "-"
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%d min", int(d.Minutes()+0.5))
|
||||||
|
case d < 48*time.Hour:
|
||||||
|
return fmt.Sprintf("%.1f h", d.Hours())
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.1f days", d.Hours()/24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMember brings someone into a ticket. The opener and staff can add.
|
||||||
|
func AddMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
|
||||||
|
t, cat, err := load(ctx, st, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
|
||||||
|
return ErrNotInvolved
|
||||||
|
}
|
||||||
|
if err := d.ThreadMemberAdd(threadID, userID); err != nil {
|
||||||
|
return fmt.Errorf("adding to the thread: %w", err)
|
||||||
|
}
|
||||||
|
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
|
||||||
|
Content: fmt.Sprintf("<@%s> added <@%s> to this ticket.", a.userID(), userID),
|
||||||
|
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMember takes someone out of a ticket. Only staff can, and never the
|
||||||
|
// opener.
|
||||||
|
func RemoveMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
|
||||||
|
t, cat, err := load(ctx, st, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !IsStaff(a.Member, a.Perms, cat) {
|
||||||
|
return ErrNotStaff
|
||||||
|
}
|
||||||
|
if mustID(userID) == t.OpenerID {
|
||||||
|
return UserError("The person who opened the ticket can't be removed. Close the ticket instead.")
|
||||||
|
}
|
||||||
|
if err := d.ThreadMemberRemove(threadID, userID); err != nil {
|
||||||
|
return fmt.Errorf("removing from the thread: %w", err)
|
||||||
|
}
|
||||||
|
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
|
||||||
|
Content: fmt.Sprintf("<@%s> removed <@%s> from this ticket.", a.userID(), userID),
|
||||||
|
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThreadDeleted closes the ticket of a thread someone deleted by hand, so it
|
||||||
|
// stops counting towards the opener's limit.
|
||||||
|
func ThreadDeleted(ctx context.Context, st Store, threadID string) {
|
||||||
|
if _, err := st.Close(ctx, mustID(threadID), 0, "Thread deleted"); err != nil {
|
||||||
|
log.Printf("tickets: closing the ticket of deleted thread %s: %v", threadID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckCanOpen runs before the form is shown, so a member who can't open a
|
||||||
|
// ticket finds out before typing anything.
|
||||||
|
func CheckCanOpen(ctx context.Context, st Store, guildID, userID, categoryID int64) (*ticketrepo.Settings, *ticketrepo.Category, error) {
|
||||||
|
s, err := st.GetSettings(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !s.Configured || s.ChannelID == 0 {
|
||||||
|
return nil, nil, ErrNotSetUp
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
return nil, nil, ErrDisabled
|
||||||
|
}
|
||||||
|
cat, err := st.GetCategory(ctx, guildID, categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if cat == nil {
|
||||||
|
return nil, nil, ErrUnknownCategory
|
||||||
|
}
|
||||||
|
open, err := st.CountOpenByOpener(ctx, guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if open >= s.MaxOpenPerUser {
|
||||||
|
if s.MaxOpenPerUser == 1 {
|
||||||
|
return nil, nil, UserError("You already have an open ticket. Close it before opening another one.")
|
||||||
|
}
|
||||||
|
return nil, nil, UserError(fmt.Sprintf("You already have %d open tickets, the most this server allows. Close one before opening another.", open))
|
||||||
|
}
|
||||||
|
return s, cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenRequest struct {
|
||||||
|
GuildID int64
|
||||||
|
CategoryID int64
|
||||||
|
User *discordgo.User
|
||||||
|
Subject string
|
||||||
|
Details string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntroComponents are the Claim and Close buttons under a ticket's first
|
||||||
|
// message, disabled as the ticket moves on.
|
||||||
|
func IntroComponents(claimed, closed bool) []discordgo.MessageComponent {
|
||||||
|
claimLabel := "Claim"
|
||||||
|
if claimed {
|
||||||
|
claimLabel = "Claimed"
|
||||||
|
}
|
||||||
|
return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{Label: claimLabel, Style: discordgo.SuccessButton, CustomID: idClaim, Disabled: claimed || closed, Emoji: &discordgo.ComponentEmoji{Name: "🙋"}},
|
||||||
|
discordgo.Button{Label: "Close", Style: discordgo.DangerButton, CustomID: idClose, Disabled: closed, Emoji: &discordgo.ComponentEmoji{Name: "🔒"}},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntroEmbed describes the ticket at the top of its thread.
|
||||||
|
func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
|
||||||
|
desc := "**" + clip(t.Subject, maxSubject) + "**"
|
||||||
|
if details != "" {
|
||||||
|
desc += "\n\n" + clip(details, maxDetails)
|
||||||
|
}
|
||||||
|
claimed := "Nobody yet"
|
||||||
|
if t.ClaimedBy != 0 {
|
||||||
|
claimed = "<@" + id(t.ClaimedBy) + ">"
|
||||||
|
}
|
||||||
|
e := &discordgo.MessageEmbed{
|
||||||
|
Title: fmt.Sprintf("Ticket #%04d · %s", t.Number, clip(t.CategoryName, 60)),
|
||||||
|
Description: desc,
|
||||||
|
Color: accent,
|
||||||
|
Fields: []*discordgo.MessageEmbedField{
|
||||||
|
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
|
||||||
|
{Name: "Claimed by", Value: claimed, Inline: true},
|
||||||
|
},
|
||||||
|
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
|
||||||
|
}
|
||||||
|
if t.Status == ticketrepo.StatusClosed {
|
||||||
|
e.Footer.Text = "This ticket is closed."
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open creates the ticket: a private thread in the ticket channel with the
|
||||||
|
// opener added, a first message that mentions the category's staff role
|
||||||
|
// (which adds that role's members to the thread), and the database row.
|
||||||
|
func Open(ctx context.Context, d Discord, st Store, req OpenRequest) (*discordgo.Channel, error) {
|
||||||
|
s, cat, err := CheckCanOpen(ctx, st, req.GuildID, mustID(req.User.ID), req.CategoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
number, err := st.NextNumber(ctx, req.GuildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := req.User.GlobalName
|
||||||
|
if name == "" {
|
||||||
|
name = req.User.Username
|
||||||
|
}
|
||||||
|
thread, err := d.ThreadStartComplex(id(s.ChannelID), &discordgo.ThreadStart{
|
||||||
|
Name: ThreadName(cat.Name, number, name),
|
||||||
|
Type: discordgo.ChannelTypeGuildPrivateThread,
|
||||||
|
AutoArchiveDuration: autoArchiveWeek,
|
||||||
|
Invitable: false, // only staff can add people
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating the thread: %w", err)
|
||||||
|
}
|
||||||
|
// From here on a failure leaves an empty thread behind unless it's removed.
|
||||||
|
fail := func(err error) (*discordgo.Channel, error) {
|
||||||
|
if _, derr := d.ChannelDelete(thread.ID); derr != nil {
|
||||||
|
log.Printf("tickets: removing thread %s after a failed open: %v", thread.ID, derr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.ThreadMemberAdd(thread.ID, req.User.ID); err != nil {
|
||||||
|
return fail(fmt.Errorf("adding the opener: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &ticketrepo.Ticket{
|
||||||
|
GuildID: req.GuildID,
|
||||||
|
Number: number,
|
||||||
|
CategoryID: cat.ID,
|
||||||
|
CategoryName: cat.Name,
|
||||||
|
OpenerID: mustID(req.User.ID),
|
||||||
|
Subject: clip(req.Subject, maxSubject),
|
||||||
|
Status: ticketrepo.StatusOpen,
|
||||||
|
}
|
||||||
|
content := "<@" + req.User.ID + ">"
|
||||||
|
mentions := &discordgo.MessageAllowedMentions{Users: []string{req.User.ID}}
|
||||||
|
if cat.StaffRoleID != 0 {
|
||||||
|
content += " <@&" + id(cat.StaffRoleID) + ">"
|
||||||
|
mentions.Roles = []string{id(cat.StaffRoleID)}
|
||||||
|
}
|
||||||
|
intro, err := d.ChannelMessageSendComplex(thread.ID, &discordgo.MessageSend{
|
||||||
|
Content: content,
|
||||||
|
Embeds: []*discordgo.MessageEmbed{IntroEmbed(t, req.Details)},
|
||||||
|
Components: IntroComponents(false, false),
|
||||||
|
AllowedMentions: mentions,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("posting the first message: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.ThreadID = mustID(thread.ID)
|
||||||
|
t.IntroMessage = mustID(intro.ID)
|
||||||
|
if _, err := st.CreateTicket(ctx, *t); err != nil {
|
||||||
|
return fail(fmt.Errorf("saving the ticket: %w", err))
|
||||||
|
}
|
||||||
|
return thread, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustID parses a snowflake that came from Discord; those are always valid.
|
||||||
|
func mustID(s string) int64 {
|
||||||
|
n, _ := strconv.ParseInt(s, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Custom IDs. Everything starts with "tickets:" so the interaction router can
|
||||||
|
// hand it to this package.
|
||||||
|
const (
|
||||||
|
idOpenPrefix = "tickets:open:" // older panels: one button per category, + category ID
|
||||||
|
idSelect = "tickets:select" // panel dropdown
|
||||||
|
idCreate = "tickets:create" // panel "Create ticket" button
|
||||||
|
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
|
||||||
|
idClaim = "tickets:claim"
|
||||||
|
idClose = "tickets:close"
|
||||||
|
idCloseForm = "tickets:closeform"
|
||||||
|
|
||||||
|
fieldSubject = "subject"
|
||||||
|
fieldDetails = "details"
|
||||||
|
fieldReason = "reason"
|
||||||
|
)
|
||||||
|
|
||||||
|
var customEmojiRe = regexp.MustCompile(`^<(a?):(\w+):(\d+)>$`)
|
||||||
|
|
||||||
|
// parseEmoji accepts a Unicode emoji or a custom one written as <:name:id>.
|
||||||
|
func parseEmoji(s string) *discordgo.ComponentEmoji {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if m := customEmojiRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return &discordgo.ComponentEmoji{Name: m[2], ID: m[3], Animated: m[1] == "a"}
|
||||||
|
}
|
||||||
|
return &discordgo.ComponentEmoji{Name: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPanel is the message members open tickets from: an embed with the
|
||||||
|
// guild's text and each category's description, a dropdown to pick the
|
||||||
|
// category, and a Create ticket button that opens the form for that pick.
|
||||||
|
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
|
||||||
|
embed := &discordgo.MessageEmbed{
|
||||||
|
Title: clip(s.PanelTitle, 256),
|
||||||
|
Description: clip(s.PanelDescription, 4000),
|
||||||
|
Color: accent,
|
||||||
|
}
|
||||||
|
if len(cats) > maxCategories {
|
||||||
|
cats = cats[:maxCategories]
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := make([]discordgo.SelectMenuOption, 0, len(cats))
|
||||||
|
for _, c := range cats {
|
||||||
|
opts = append(opts, discordgo.SelectMenuOption{
|
||||||
|
Label: clip(c.Name, 100),
|
||||||
|
Value: id(c.ID),
|
||||||
|
Description: clip(c.Description, 100),
|
||||||
|
Emoji: parseEmoji(c.Emoji),
|
||||||
|
})
|
||||||
|
if c.Description != "" {
|
||||||
|
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return embed, []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
|
||||||
|
}},
|
||||||
|
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.Button{Label: "Create ticket", Style: discordgo.PrimaryButton, CustomID: idCreate, Emoji: &discordgo.ComponentEmoji{Name: "🎫"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostPanel posts the panel in the ticket channel, or updates the one it
|
||||||
|
// posted before, and returns where it ended up.
|
||||||
|
func PostPanel(ctx context.Context, d Discord, st Store, guildID int64) (*discordgo.Message, error) {
|
||||||
|
s, err := st.GetSettings(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.ChannelID == 0 {
|
||||||
|
return nil, ErrNotSetUp
|
||||||
|
}
|
||||||
|
cats, err := st.ListCategories(ctx, guildID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(cats) == 0 {
|
||||||
|
return nil, ErrNoCategories
|
||||||
|
}
|
||||||
|
embed, comps := BuildPanel(s, cats)
|
||||||
|
|
||||||
|
if s.PanelMessageID != 0 {
|
||||||
|
embeds := []*discordgo.MessageEmbed{embed}
|
||||||
|
msg, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||||
|
Channel: id(s.ChannelID),
|
||||||
|
ID: id(s.PanelMessageID),
|
||||||
|
Embeds: &embeds,
|
||||||
|
Components: &comps,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
// The old panel was deleted or the channel changed: post a new one.
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := d.ChannelMessageSendComplex(id(s.ChannelID), &discordgo.MessageSend{
|
||||||
|
Embeds: []*discordgo.MessageEmbed{embed},
|
||||||
|
Components: comps,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("posting the panel: %w", err)
|
||||||
|
}
|
||||||
|
if mid, err := strconv.ParseInt(msg.ID, 10, 64); err == nil {
|
||||||
|
if err := st.SetPanelMessage(ctx, guildID, mid); err != nil {
|
||||||
|
return msg, fmt.Errorf("saving the panel message: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenForm is the short form shown after a member picks a category.
|
||||||
|
func OpenForm(cat *ticketrepo.Category) *discordgo.InteractionResponse {
|
||||||
|
return &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseModal,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
CustomID: idOpenFormPrefix + id(cat.ID),
|
||||||
|
Title: clip("Open a ticket: "+cat.Name, 45),
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
|
||||||
|
CustomID: fieldSubject,
|
||||||
|
Label: "What do you need help with?",
|
||||||
|
Style: discordgo.TextInputShort,
|
||||||
|
Placeholder: "A short summary",
|
||||||
|
Required: true,
|
||||||
|
MinLength: 3,
|
||||||
|
MaxLength: maxSubject,
|
||||||
|
}}},
|
||||||
|
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
|
||||||
|
CustomID: fieldDetails,
|
||||||
|
Label: "Details",
|
||||||
|
Style: discordgo.TextInputParagraph,
|
||||||
|
Placeholder: "Anything the staff should know (optional)",
|
||||||
|
Required: false,
|
||||||
|
MaxLength: maxDetails,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseForm asks for an optional reason before closing.
|
||||||
|
func CloseForm() *discordgo.InteractionResponse {
|
||||||
|
return &discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseModal,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
CustomID: idCloseForm,
|
||||||
|
Title: "Close this ticket",
|
||||||
|
Components: []discordgo.MessageComponent{
|
||||||
|
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
|
||||||
|
CustomID: fieldReason,
|
||||||
|
Label: "Reason (optional)",
|
||||||
|
Style: discordgo.TextInputParagraph,
|
||||||
|
Required: false,
|
||||||
|
MaxLength: maxReason,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Discord doesn't send a dropdown's value along with a button click, so the
|
||||||
|
// panel's category pick is remembered here between the two interactions:
|
||||||
|
// per guild and member, for a short while, in memory. A bot restart forgets
|
||||||
|
// it, which only means picking the category again.
|
||||||
|
const (
|
||||||
|
pickTTL = 15 * time.Minute
|
||||||
|
maxPicks = 10000 // a ceiling, so a flood of picks can't grow this forever
|
||||||
|
)
|
||||||
|
|
||||||
|
type pickKey struct{ guild, user int64 }
|
||||||
|
|
||||||
|
type pick struct {
|
||||||
|
category int64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
picksMu sync.Mutex
|
||||||
|
picks = map[pickKey]pick{}
|
||||||
|
now = time.Now
|
||||||
|
)
|
||||||
|
|
||||||
|
// RememberPick records the category a member chose in the panel dropdown.
|
||||||
|
func RememberPick(guildID, userID, categoryID int64) {
|
||||||
|
picksMu.Lock()
|
||||||
|
defer picksMu.Unlock()
|
||||||
|
t := now()
|
||||||
|
k := pickKey{guildID, userID}
|
||||||
|
if _, ok := picks[k]; !ok && len(picks) >= maxPicks {
|
||||||
|
for k, p := range picks {
|
||||||
|
if t.Sub(p.at) > pickTTL {
|
||||||
|
delete(picks, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(picks) >= maxPicks {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picks[k] = pick{categoryID, t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickedCategory returns the category a member chose, if they did recently.
|
||||||
|
func PickedCategory(guildID, userID int64) (int64, bool) {
|
||||||
|
picksMu.Lock()
|
||||||
|
defer picksMu.Unlock()
|
||||||
|
p, ok := picks[pickKey{guildID, userID}]
|
||||||
|
if !ok || now().Sub(p.at) > pickTTL {
|
||||||
|
delete(picks, pickKey{guildID, userID})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return p.category, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// Package tickets is the support ticket system: a panel message with a
|
||||||
|
// category dropdown and a Create ticket button, a short form, and a private
|
||||||
|
// thread per ticket that staff can claim and anyone involved can close.
|
||||||
|
// Closing locks and archives the thread and posts a transcript to the log
|
||||||
|
// channel.
|
||||||
|
//
|
||||||
|
// The Discord and database calls go through small interfaces so the flows
|
||||||
|
// can be tested without either; the slash command and button handlers in
|
||||||
|
// internal/commands/tickets only translate interactions into these calls.
|
||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the subset of ticketrepo.Repo the flows use.
|
||||||
|
type Store interface {
|
||||||
|
GetSettings(ctx context.Context, guildID int64) (*ticketrepo.Settings, error)
|
||||||
|
SetPanelMessage(ctx context.Context, guildID, messageID int64) error
|
||||||
|
NextNumber(ctx context.Context, guildID int64) (int, error)
|
||||||
|
ListCategories(ctx context.Context, guildID int64) ([]ticketrepo.Category, error)
|
||||||
|
GetCategory(ctx context.Context, guildID, id int64) (*ticketrepo.Category, error)
|
||||||
|
CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error)
|
||||||
|
CreateTicket(ctx context.Context, t ticketrepo.Ticket) (int64, error)
|
||||||
|
GetTicketByThread(ctx context.Context, threadID int64) (*ticketrepo.Ticket, error)
|
||||||
|
Claim(ctx context.Context, threadID, staffID int64) (int64, error)
|
||||||
|
Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error)
|
||||||
|
ListUnprocessedCommands(ctx context.Context) ([]ticketrepo.Command, error)
|
||||||
|
MarkCommandProcessed(ctx context.Context, id int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord is the subset of *discordgo.Session the flows use.
|
||||||
|
type Discord interface {
|
||||||
|
ThreadStartComplex(channelID string, data *discordgo.ThreadStart, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||||
|
ThreadMemberAdd(threadID, memberID string, options ...discordgo.RequestOption) error
|
||||||
|
ThreadMemberRemove(threadID, memberID string, options ...discordgo.RequestOption) error
|
||||||
|
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||||
|
ChannelMessageEditComplex(m *discordgo.MessageEdit, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||||
|
ChannelMessage(channelID, messageID string, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||||||
|
ChannelMessages(channelID string, limit int, beforeID, afterID, aroundID string, options ...discordgo.RequestOption) ([]*discordgo.Message, error)
|
||||||
|
ChannelEditComplex(channelID string, data *discordgo.ChannelEdit, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||||
|
ChannelDelete(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserError is a problem worth showing the person who clicked, as written.
|
||||||
|
// Anything else is logged and shown as a generic failure.
|
||||||
|
type UserError string
|
||||||
|
|
||||||
|
func (e UserError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrNotSetUp = UserError("Tickets aren't set up on this server yet. An admin can run `/tickets setup`.")
|
||||||
|
ErrDisabled = UserError("Tickets are turned off on this server right now.")
|
||||||
|
ErrNoCategories = UserError("There are no ticket categories yet. Add one with `/tickets category add` or on the dashboard.")
|
||||||
|
ErrUnknownCategory = UserError("That ticket category no longer exists. Ask an admin to repost the ticket panel.")
|
||||||
|
ErrNotATicket = UserError("This isn't a ticket thread.")
|
||||||
|
ErrClosed = UserError("This ticket is already closed.")
|
||||||
|
ErrNotStaff = UserError("Only the staff handling this kind of ticket can do that.")
|
||||||
|
ErrNotInvolved = UserError("Only the person who opened this ticket or the staff can do that.")
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultStore Store
|
||||||
|
// Every ticket embed (panel, ticket, log) is orange, so support stands
|
||||||
|
// apart from the bot's other messages.
|
||||||
|
accent = 0xf97316
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init(s Store) { defaultStore = s }
|
||||||
|
|
||||||
|
func Default() Store { return defaultStore }
|
||||||
|
|
||||||
|
// Limits Discord puts on what the bot sends.
|
||||||
|
const (
|
||||||
|
maxThreadName = 100
|
||||||
|
maxSubject = 100
|
||||||
|
maxDetails = 1000
|
||||||
|
maxReason = 300
|
||||||
|
maxCategories = 25
|
||||||
|
autoArchiveWeek = 10080
|
||||||
|
)
|
||||||
|
|
||||||
|
func id(v int64) string { return fmt.Sprintf("%d", v) }
|
||||||
|
|
||||||
|
// IsStaff reports whether a member may claim, close and manage tickets of
|
||||||
|
// this category: anyone with the category's staff role, or with Manage
|
||||||
|
// Threads / Manage Server / Administrator (perms is the member's permission
|
||||||
|
// set in the ticket's channel, as Discord sends it with the interaction).
|
||||||
|
func IsStaff(member *discordgo.Member, perms int64, cat *ticketrepo.Category) bool {
|
||||||
|
const staffPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild | discordgo.PermissionManageThreads
|
||||||
|
if perms&staffPerms != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if member == nil || cat == nil || cat.StaffRoleID == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := id(cat.StaffRoleID)
|
||||||
|
for _, r := range member.Roles {
|
||||||
|
if r == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug turns a name into lowercase ASCII words joined by dashes, for thread
|
||||||
|
// names that read well in the channel's thread list.
|
||||||
|
func slug(s string, max int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
dash := false
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
switch {
|
||||||
|
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
|
||||||
|
if dash && b.Len() > 0 {
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
dash = false
|
||||||
|
default:
|
||||||
|
dash = true
|
||||||
|
}
|
||||||
|
if b.Len() >= max {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
if len(out) > max {
|
||||||
|
out = out[:max]
|
||||||
|
}
|
||||||
|
return strings.Trim(out, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThreadName is "support-0042-ana": the category, the ticket number and who
|
||||||
|
// opened it.
|
||||||
|
func ThreadName(category string, number int, opener string) string {
|
||||||
|
cat := slug(category, 30)
|
||||||
|
if cat == "" {
|
||||||
|
cat = "ticket"
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("%s-%04d", cat, number)
|
||||||
|
if u := slug(opener, 32); u != "" {
|
||||||
|
name += "-" + u
|
||||||
|
}
|
||||||
|
if len(name) > maxThreadName {
|
||||||
|
name = name[:maxThreadName]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func clip(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if r := []rune(s); len(r) > n {
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions the bot needs, by channel.
|
||||||
|
const (
|
||||||
|
NeedTicketChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks |
|
||||||
|
discordgo.PermissionCreatePrivateThreads | discordgo.PermissionSendMessagesInThreads | discordgo.PermissionManageThreads
|
||||||
|
NeedLogChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks |
|
||||||
|
discordgo.PermissionAttachFiles
|
||||||
|
)
|
||||||
|
|
||||||
|
var permNames = []struct {
|
||||||
|
bit int64
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{discordgo.PermissionViewChannel, "View Channel"},
|
||||||
|
{discordgo.PermissionSendMessages, "Send Messages"},
|
||||||
|
{discordgo.PermissionEmbedLinks, "Embed Links"},
|
||||||
|
{discordgo.PermissionAttachFiles, "Attach Files"},
|
||||||
|
{discordgo.PermissionCreatePrivateThreads, "Create Private Threads"},
|
||||||
|
{discordgo.PermissionSendMessagesInThreads, "Send Messages in Threads"},
|
||||||
|
{discordgo.PermissionManageThreads, "Manage Threads"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissingPerms names the permissions in need that have lacks. Administrator
|
||||||
|
// counts as having everything.
|
||||||
|
func MissingPerms(have, need int64) []string {
|
||||||
|
if have&discordgo.PermissionAdministrator != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, p := range permNames {
|
||||||
|
if need&p.bit != 0 && have&p.bit == 0 {
|
||||||
|
out = append(out, p.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
guild = int64(1)
|
||||||
|
channel = int64(10)
|
||||||
|
logCh = int64(20)
|
||||||
|
staffRl = int64(30)
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
func member(userID string, roles ...string) *discordgo.Member {
|
||||||
|
return &discordgo.Member{User: &discordgo.User{ID: userID, Username: "user" + userID}, Roles: roles}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreadName(t *testing.T) {
|
||||||
|
for _, c := range []struct{ cat, user, want string }{
|
||||||
|
{"Support", "Ana", "support-0042-ana"},
|
||||||
|
{"Ban Appeal!", "João Silva", "ban-appeal-0042-jo-o-silva"},
|
||||||
|
{"🎫", "〈ぬべ〉", "ticket-0042"},
|
||||||
|
{strings.Repeat("x", 80), strings.Repeat("y", 80), strings.Repeat("x", 30) + "-0042-" + strings.Repeat("y", 32)},
|
||||||
|
} {
|
||||||
|
if got := ThreadName(c.cat, 42, c.user); got != c.want {
|
||||||
|
t.Errorf("ThreadName(%q, %q) = %q, want %q", c.cat, c.user, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsStaff(t *testing.T) {
|
||||||
|
cat := &ticketrepo.Category{StaffRoleID: staffRl}
|
||||||
|
if !IsStaff(member("1", "30"), 0, cat) {
|
||||||
|
t.Error("the staff role should count")
|
||||||
|
}
|
||||||
|
if IsStaff(member("1", "31"), 0, cat) {
|
||||||
|
t.Error("another role must not count")
|
||||||
|
}
|
||||||
|
for _, p := range []int64{discordgo.PermissionManageThreads, discordgo.PermissionManageGuild, discordgo.PermissionAdministrator} {
|
||||||
|
if !IsStaff(member("1"), p, nil) {
|
||||||
|
t.Errorf("permission %d should count, even with the category deleted", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if IsStaff(member("1"), discordgo.PermissionSendMessages, &ticketrepo.Category{}) {
|
||||||
|
t.Error("a category without a staff role must not make everyone staff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingPerms(t *testing.T) {
|
||||||
|
got := MissingPerms(discordgo.PermissionViewChannel|discordgo.PermissionSendMessages, NeedLogChannel)
|
||||||
|
if strings.Join(got, ",") != "Embed Links,Attach Files" {
|
||||||
|
t.Errorf("missing = %v", got)
|
||||||
|
}
|
||||||
|
if MissingPerms(discordgo.PermissionAdministrator, NeedTicketChannel) != nil {
|
||||||
|
t.Error("Administrator has everything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPanel(t *testing.T) {
|
||||||
|
s := &ticketrepo.Settings{PanelTitle: "Help", PanelDescription: "Pick one"}
|
||||||
|
cats := []ticketrepo.Category{
|
||||||
|
{ID: 1, Name: "Support", Emoji: "🛟", Description: "General help"},
|
||||||
|
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
|
||||||
|
}
|
||||||
|
embed, comps := BuildPanel(s, cats)
|
||||||
|
if embed.Title != "Help" || embed.Color != 0xf97316 {
|
||||||
|
t.Errorf("panel embed wrong: %+v", embed)
|
||||||
|
}
|
||||||
|
if len(comps) != 2 {
|
||||||
|
t.Fatalf("want a dropdown row and a button row, got %d rows", len(comps))
|
||||||
|
}
|
||||||
|
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
|
||||||
|
if menu.CustomID != "tickets:select" || len(menu.Options) != 2 || menu.Options[0].Value != "1" || menu.Options[0].Emoji.Name != "🛟" {
|
||||||
|
t.Errorf("dropdown wrong: %+v", menu)
|
||||||
|
}
|
||||||
|
if e := menu.Options[1].Emoji; e.ID != "123" || e.Name != "party" || !e.Animated {
|
||||||
|
t.Errorf("custom emoji not parsed: %+v", e)
|
||||||
|
}
|
||||||
|
button := comps[1].(discordgo.ActionsRow).Components[0].(discordgo.Button)
|
||||||
|
if button.CustomID != "tickets:create" || button.Label != "Create ticket" {
|
||||||
|
t.Errorf("create button wrong: %+v", button)
|
||||||
|
}
|
||||||
|
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
|
||||||
|
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
var many []ticketrepo.Category
|
||||||
|
for i := 1; i <= 30; i++ {
|
||||||
|
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
|
||||||
|
}
|
||||||
|
_, comps = BuildPanel(s, many)
|
||||||
|
if n := len(comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu).Options); n != 25 {
|
||||||
|
t.Errorf("a dropdown holds at most 25 options, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPicks(t *testing.T) {
|
||||||
|
clock := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
now = func() time.Time { return clock }
|
||||||
|
t.Cleanup(func() { now = time.Now; picks = map[pickKey]pick{} })
|
||||||
|
|
||||||
|
if _, ok := PickedCategory(guild, 5); ok {
|
||||||
|
t.Error("nothing picked yet")
|
||||||
|
}
|
||||||
|
RememberPick(guild, 5, 3)
|
||||||
|
RememberPick(guild, 5, 4)
|
||||||
|
RememberPick(guild, 6, 9)
|
||||||
|
if c, ok := PickedCategory(guild, 5); !ok || c != 4 {
|
||||||
|
t.Errorf("the latest pick wins: %d %v", c, ok)
|
||||||
|
}
|
||||||
|
if _, ok := PickedCategory(guild+1, 5); ok {
|
||||||
|
t.Error("picks are per guild")
|
||||||
|
}
|
||||||
|
|
||||||
|
clock = clock.Add(pickTTL + time.Second)
|
||||||
|
if _, ok := PickedCategory(guild, 5); ok {
|
||||||
|
t.Error("a pick expires")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full: new members can't add more, but expired ones make room.
|
||||||
|
picks = map[pickKey]pick{}
|
||||||
|
for i := 0; i < maxPicks; i++ {
|
||||||
|
picks[pickKey{guild + 100, int64(i)}] = pick{1, clock}
|
||||||
|
}
|
||||||
|
RememberPick(guild, 7, 1)
|
||||||
|
if _, ok := PickedCategory(guild, 7); ok {
|
||||||
|
t.Error("a full map takes no new members")
|
||||||
|
}
|
||||||
|
RememberPick(guild+100, 0, 2)
|
||||||
|
if c, _ := PickedCategory(guild+100, 0); c != 2 {
|
||||||
|
t.Error("someone already in the map can still change their pick")
|
||||||
|
}
|
||||||
|
clock = clock.Add(pickTTL + time.Second)
|
||||||
|
RememberPick(guild, 7, 1)
|
||||||
|
if _, ok := PickedCategory(guild, 7); !ok {
|
||||||
|
t.Error("expired picks should be cleared to make room")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostPanel(t *testing.T) {
|
||||||
|
st, d := newStore(), newDiscord()
|
||||||
|
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNotSetUp) {
|
||||||
|
t.Errorf("err = %v, want not set up", err)
|
||||||
|
}
|
||||||
|
st.configure(guild, channel, logCh)
|
||||||
|
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNoCategories) {
|
||||||
|
t.Errorf("err = %v, want no categories", err)
|
||||||
|
}
|
||||||
|
st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support"})
|
||||||
|
|
||||||
|
msg, err := PostPanel(ctx, d, st, guild)
|
||||||
|
if err != nil || len(d.sent["10"]) != 1 || st.settings[guild].PanelMessageID != mustID(msg.ID) {
|
||||||
|
t.Fatalf("first post: err=%v sent=%d saved=%d", err, len(d.sent["10"]), st.settings[guild].PanelMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second post updates the same message.
|
||||||
|
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.edits) != 1 || len(d.sent["10"]) != 1 {
|
||||||
|
t.Errorf("repost should edit: err=%v edits=%d sent=%d", err, len(d.edits), len(d.sent["10"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the old panel is gone, a new one is posted and remembered.
|
||||||
|
d.failEdit = errors.New("unknown message")
|
||||||
|
old := st.settings[guild].PanelMessageID
|
||||||
|
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.sent["10"]) != 2 || st.settings[guild].PanelMessageID == old {
|
||||||
|
t.Errorf("deleted panel should be replaced: err=%v sent=%d", err, len(d.sent["10"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup() (*fakeStore, *fakeDiscord, ticketrepo.Category) {
|
||||||
|
st, d := newStore(), newDiscord()
|
||||||
|
st.configure(guild, channel, logCh)
|
||||||
|
cat := st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support", StaffRoleID: staffRl})
|
||||||
|
return st, d, cat
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(t *testing.T, st *fakeStore, d *fakeDiscord, cat ticketrepo.Category, userID string) *discordgo.Channel {
|
||||||
|
t.Helper()
|
||||||
|
th, err := Open(ctx, d, st, OpenRequest{
|
||||||
|
GuildID: guild, CategoryID: cat.ID,
|
||||||
|
User: &discordgo.User{ID: userID, Username: "ana", GlobalName: "Ana"},
|
||||||
|
Subject: "Can't join voice", Details: "It says no permission",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return th
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCanOpen(t *testing.T) {
|
||||||
|
st := newStore()
|
||||||
|
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrNotSetUp) {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
st.configure(guild, channel, logCh)
|
||||||
|
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 99); !errors.Is(err, ErrUnknownCategory) {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
st.settings[guild].Enabled = false
|
||||||
|
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrDisabled) {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpen(t *testing.T) {
|
||||||
|
st, d, cat := setup()
|
||||||
|
th := open(t, st, d, cat, "500")
|
||||||
|
|
||||||
|
start := d.threads[0]
|
||||||
|
if start.Type != discordgo.ChannelTypeGuildPrivateThread || start.Invitable || start.Name != "support-0001-ana" {
|
||||||
|
t.Errorf("thread wrong: %+v", start)
|
||||||
|
}
|
||||||
|
if strings.Join(d.members[th.ID], ",") != "500" {
|
||||||
|
t.Errorf("opener not added: %v", d.members[th.ID])
|
||||||
|
}
|
||||||
|
intro := d.sent[th.ID][0]
|
||||||
|
if intro.Content != "<@500> <@&30>" || strings.Join(intro.AllowedMentions.Roles, ",") != "30" {
|
||||||
|
t.Errorf("the staff role must be mentioned so its members join: %+v", intro)
|
||||||
|
}
|
||||||
|
if !strings.Contains(intro.Embeds[0].Description, "It says no permission") || intro.Embeds[0].Title != "Ticket #0001 · Support" {
|
||||||
|
t.Errorf("intro embed wrong: %+v", intro.Embeds[0])
|
||||||
|
}
|
||||||
|
tk := st.tickets[0]
|
||||||
|
if tk.Number != 1 || tk.ThreadID != mustID(th.ID) || tk.OpenerID != 500 || tk.IntroMessage == 0 || tk.Subject != "Can't join voice" {
|
||||||
|
t.Errorf("ticket row wrong: %+v", tk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limit applies to the second ticket.
|
||||||
|
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "again"})
|
||||||
|
var ue UserError
|
||||||
|
if !errors.As(err, &ue) || !strings.Contains(string(ue), "already have an open ticket") {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCleansUpOnFailure(t *testing.T) {
|
||||||
|
st, d, cat := setup()
|
||||||
|
d.failSend = errors.New("missing access")
|
||||||
|
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "help"})
|
||||||
|
if err == nil || len(d.deleted) != 1 || len(st.tickets) != 0 {
|
||||||
|
t.Errorf("a half-made ticket must be removed: err=%v deleted=%v tickets=%d", err, d.deleted, len(st.tickets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaim(t *testing.T) {
|
||||||
|
st, d, cat := setup()
|
||||||
|
th := open(t, st, d, cat, "500")
|
||||||
|
|
||||||
|
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("600")}); !errors.Is(err, ErrNotStaff) {
|
||||||
|
t.Errorf("non-staff claim: %v", err)
|
||||||
|
}
|
||||||
|
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("700", "30")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.tickets[0].ClaimedBy != 700 {
|
||||||
|
t.Errorf("claim not saved")
|
||||||
|
}
|
||||||
|
if len(d.edits) != 1 {
|
||||||
|
t.Fatalf("the first message should be updated")
|
||||||
|
}
|
||||||
|
e := (*d.edits[0].Embeds)[0]
|
||||||
|
if e.Fields[1].Value != "<@700>" || !strings.Contains(e.Description, "It says no permission") {
|
||||||
|
t.Errorf("updated intro lost details or claim: %+v", e)
|
||||||
|
}
|
||||||
|
if b := (*d.edits[0].Components)[0].(discordgo.ActionsRow).Components[0].(discordgo.Button); !b.Disabled || b.Label != "Claimed" {
|
||||||
|
t.Errorf("claim button should be disabled: %+v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Claim(ctx, d, st, th.ID, Actor{Member: member("701"), Perms: discordgo.PermissionManageThreads})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "<@700> is already handling") {
|
||||||
|
t.Errorf("second claim: %v", err)
|
||||||
|
}
|
||||||
|
if err := Claim(ctx, d, st, "999", Actor{Member: member("700", "30")}); !errors.Is(err, ErrNotATicket) {
|
||||||
|
t.Errorf("claim outside a ticket: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClose(t *testing.T) {
|
||||||
|
st, d, cat := setup()
|
||||||
|
th := open(t, st, d, cat, "500")
|
||||||
|
ts := time.Date(2026, 10, 20, 18, 0, 0, 0, time.UTC)
|
||||||
|
d.history[th.ID] = []*discordgo.Message{ // newest first
|
||||||
|
{ID: "3", Content: "thanks, works now", Author: &discordgo.User{Username: "ana"}, Timestamp: ts.Add(2 * time.Minute)},
|
||||||
|
{ID: "2", Content: "try rejoining", Author: &discordgo.User{Username: "mod", GlobalName: "Mod"}, Timestamp: ts.Add(time.Minute),
|
||||||
|
Attachments: []*discordgo.MessageAttachment{{Filename: "steps.png", URL: "https://cdn/x.png"}}},
|
||||||
|
{ID: "1", Author: &discordgo.User{Username: "velox", Bot: true}, Embeds: []*discordgo.MessageEmbed{{Title: "Ticket #0001 · Support"}}, Timestamp: ts},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Close(ctx, d, st, th.ID, Actor{Member: member("600")}, ""); !errors.Is(err, ErrNotInvolved) {
|
||||||
|
t.Errorf("an outsider must not close: %v", err)
|
||||||
|
}
|
||||||
|
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, "Solved"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tk := st.tickets[0]
|
||||||
|
if tk.Status != ticketrepo.StatusClosed || tk.ClosedBy != 500 || tk.CloseReason != "Solved" {
|
||||||
|
t.Errorf("ticket not closed: %+v", tk)
|
||||||
|
}
|
||||||
|
if e := d.chEdits[th.ID]; e == nil || !*e.Locked || !*e.Archived {
|
||||||
|
t.Errorf("thread should be locked and archived: %+v", e)
|
||||||
|
}
|
||||||
|
logged := d.sent["20"]
|
||||||
|
if len(logged) != 1 || !strings.Contains(logged[0].Embeds[0].Title, "Ticket #0001 closed") {
|
||||||
|
t.Fatalf("log channel post missing: %+v", logged)
|
||||||
|
}
|
||||||
|
transcript := d.files["ticket-0001.txt"]
|
||||||
|
i1, i2, i3 := strings.Index(transcript, "[embed] Ticket #0001"), strings.Index(transcript, "try rejoining"), strings.Index(transcript, "thanks, works now")
|
||||||
|
if i1 < 0 || i2 < i1 || i3 < i2 {
|
||||||
|
t.Errorf("transcript must be oldest first:\n%s", transcript)
|
||||||
|
}
|
||||||
|
if !strings.Contains(transcript, "Mod (mod)") || !strings.Contains(transcript, "[file] steps.png https://cdn/x.png") || !strings.Contains(transcript, "velox [bot]") {
|
||||||
|
t.Errorf("transcript details missing:\n%s", transcript)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, ""); !errors.Is(err, ErrClosed) {
|
||||||
|
t.Errorf("closing twice: %v", err)
|
||||||
|
}
|
||||||
|
// A closed ticket no longer counts towards the limit.
|
||||||
|
open(t, st, d, cat, "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscriptPages(t *testing.T) {
|
||||||
|
d := newDiscord()
|
||||||
|
var hist []*discordgo.Message
|
||||||
|
for i := 250; i >= 1; i-- {
|
||||||
|
hist = append(hist, &discordgo.Message{ID: fmt.Sprint(i), Content: fmt.Sprint("msg ", i), Author: &discordgo.User{Username: "u"}})
|
||||||
|
}
|
||||||
|
d.history["5"] = hist
|
||||||
|
out, n, err := Transcript(d, &ticketrepo.Ticket{ThreadID: 5, Number: 1})
|
||||||
|
if err != nil || n != 250 {
|
||||||
|
t.Fatalf("n=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
if strings.Index(string(out), "msg 1\n") > strings.Index(string(out), "msg 250\n") {
|
||||||
|
t.Error("pages must be stitched oldest first")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.failFetch = errors.New("boom")
|
||||||
|
out, _, err = Transcript(d, &ticketrepo.Ticket{ThreadID: 5})
|
||||||
|
if err == nil || !strings.Contains(string(out), "only part of the thread") {
|
||||||
|
t.Error("a failed read should still produce a transcript that says so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAndRemoveMember(t *testing.T) {
|
||||||
|
st, d, cat := setup()
|
||||||
|
th := open(t, st, d, cat, "500")
|
||||||
|
|
||||||
|
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("600")}, "601"); !errors.Is(err, ErrNotInvolved) {
|
||||||
|
t.Errorf("outsider add: %v", err)
|
||||||
|
}
|
||||||
|
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); err != nil {
|
||||||
|
t.Errorf("the opener can add: %v", err)
|
||||||
|
}
|
||||||
|
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); !errors.Is(err, ErrNotStaff) {
|
||||||
|
t.Errorf("only staff remove: %v", err)
|
||||||
|
}
|
||||||
|
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "500"); err == nil {
|
||||||
|
t.Error("the opener can't be removed")
|
||||||
|
}
|
||||||
|
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "601"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if strings.Join(d.members[th.ID], ",") != "500" {
|
||||||
|
t.Errorf("members = %v", d.members[th.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreadDeletedFreesTheLimit(t *testing.T) {
|
||||||
|
st, d, cat := setup()
|
||||||
|
th := open(t, st, d, cat, "500")
|
||||||
|
ThreadDeleted(ctx, st, th.ID)
|
||||||
|
if st.tickets[0].Status != ticketrepo.StatusClosed || st.tickets[0].CloseReason != "Thread deleted" {
|
||||||
|
t.Errorf("ticket = %+v", st.tickets[0])
|
||||||
|
}
|
||||||
|
open(t, st, d, cat, "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCommands(t *testing.T) {
|
||||||
|
st, d, _ := setup()
|
||||||
|
st.cmds = []ticketrepo.Command{{ID: 1, GuildID: guild, CommandType: "post_panel"}, {ID: 2, GuildID: 99, CommandType: "post_panel"}, {ID: 3, CommandType: "nope"}}
|
||||||
|
ProcessCommands(ctx, d, st)
|
||||||
|
if len(d.sent["10"]) != 1 {
|
||||||
|
t.Errorf("panel not posted")
|
||||||
|
}
|
||||||
|
if len(st.done) != 3 {
|
||||||
|
t.Errorf("every command must be marked done, even failed ones: %v", st.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package tickets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxTranscriptMessages bounds how far back a transcript reads: 50 pages of
|
||||||
|
// 100, far beyond any normal ticket.
|
||||||
|
const maxTranscriptMessages = 5000
|
||||||
|
|
||||||
|
// Transcript renders the whole thread as plain text, oldest first, and
|
||||||
|
// reports how many messages it holds. Attachments and embeds are listed so
|
||||||
|
// the log keeps their links and titles.
|
||||||
|
func Transcript(d Discord, t *ticketrepo.Ticket) ([]byte, int, error) {
|
||||||
|
var msgs []*discordgo.Message
|
||||||
|
before := ""
|
||||||
|
var fetchErr error
|
||||||
|
for len(msgs) < maxTranscriptMessages {
|
||||||
|
page, err := d.ChannelMessages(id(t.ThreadID), 100, before, "", "")
|
||||||
|
if err != nil {
|
||||||
|
fetchErr = err
|
||||||
|
break
|
||||||
|
}
|
||||||
|
msgs = append(msgs, page...)
|
||||||
|
if len(page) < 100 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
before = page[len(page)-1].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Ticket #%04d · %s\n", t.Number, t.CategoryName)
|
||||||
|
fmt.Fprintf(&b, "Subject: %s\n", t.Subject)
|
||||||
|
fmt.Fprintf(&b, "Opened by user %d on %s\n", t.OpenerID, t.CreatedAt.UTC().Format("2006-01-02 15:04 MST"))
|
||||||
|
if fetchErr != nil {
|
||||||
|
fmt.Fprintf(&b, "Note: only part of the thread could be read (%v)\n", fetchErr)
|
||||||
|
}
|
||||||
|
b.WriteString(strings.Repeat("-", 60) + "\n\n")
|
||||||
|
|
||||||
|
// Discord returns newest first.
|
||||||
|
for i := len(msgs) - 1; i >= 0; i-- {
|
||||||
|
writeMessage(&b, msgs[i])
|
||||||
|
}
|
||||||
|
return []byte(b.String()), len(msgs), fetchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMessage(b *strings.Builder, m *discordgo.Message) {
|
||||||
|
name := "unknown"
|
||||||
|
if m.Author != nil {
|
||||||
|
name = m.Author.Username
|
||||||
|
if m.Author.GlobalName != "" {
|
||||||
|
name = m.Author.GlobalName + " (" + m.Author.Username + ")"
|
||||||
|
}
|
||||||
|
if m.Author.Bot {
|
||||||
|
name += " [bot]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, "[%s] %s\n", m.Timestamp.UTC().Format("2006-01-02 15:04"), name)
|
||||||
|
|
||||||
|
if content := strings.TrimSpace(m.Content); content != "" {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
b.WriteString(" " + line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range m.Embeds {
|
||||||
|
if e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := e.Title
|
||||||
|
if title == "" {
|
||||||
|
title = clip(e.Description, 120)
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
b.WriteString(" [embed] " + strings.ReplaceAll(title, "\n", " ") + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range m.Attachments {
|
||||||
|
if a != nil {
|
||||||
|
b.WriteString(" [file] " + a.Filename + " " + a.URL + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"velox-bot/internal/bot"
|
"velox-bot/internal/bot"
|
||||||
"velox-bot/internal/commands"
|
"velox-bot/internal/commands"
|
||||||
|
cmdtickets "velox-bot/internal/commands/tickets"
|
||||||
"velox-bot/internal/config"
|
"velox-bot/internal/config"
|
||||||
"velox-bot/internal/db"
|
"velox-bot/internal/db"
|
||||||
"velox-bot/internal/db/repos/defaultrolerepo"
|
"velox-bot/internal/db/repos/defaultrolerepo"
|
||||||
@@ -16,11 +18,14 @@ import (
|
|||||||
"velox-bot/internal/db/repos/projectsrepo"
|
"velox-bot/internal/db/repos/projectsrepo"
|
||||||
"velox-bot/internal/db/repos/rpsrepo"
|
"velox-bot/internal/db/repos/rpsrepo"
|
||||||
"velox-bot/internal/db/repos/schedulerepo"
|
"velox-bot/internal/db/repos/schedulerepo"
|
||||||
|
"velox-bot/internal/db/repos/securityrepo"
|
||||||
"velox-bot/internal/db/repos/settingsrepo"
|
"velox-bot/internal/db/repos/settingsrepo"
|
||||||
|
"velox-bot/internal/db/repos/ticketrepo"
|
||||||
"velox-bot/internal/db/repos/twitchrepo"
|
"velox-bot/internal/db/repos/twitchrepo"
|
||||||
"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"
|
||||||
@@ -33,6 +38,7 @@ import (
|
|||||||
"velox-bot/internal/db/services/twitch"
|
"velox-bot/internal/db/services/twitch"
|
||||||
"velox-bot/internal/db/services/usersettings"
|
"velox-bot/internal/db/services/usersettings"
|
||||||
"velox-bot/internal/db/services/welcome"
|
"velox-bot/internal/db/services/welcome"
|
||||||
|
"velox-bot/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -59,6 +65,8 @@ func main() {
|
|||||||
welcomeRepo := welcomerepo.NewRepo(db)
|
welcomeRepo := welcomerepo.NewRepo(db)
|
||||||
twitchRepo := twitchrepo.NewRepo(db)
|
twitchRepo := twitchrepo.NewRepo(db)
|
||||||
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||||
|
cmdtickets.Init(ticketrepo.NewRepo(db))
|
||||||
|
security.Configure(securityrepo.NewRepo(db))
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
logSettingsService := logsettings.New(settingsRepo)
|
logSettingsService := logsettings.New(settingsRepo)
|
||||||
@@ -71,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 {
|
||||||
@@ -86,6 +94,9 @@ func main() {
|
|||||||
|
|
||||||
defer bot.Close()
|
defer bot.Close()
|
||||||
|
|
||||||
|
// Who the beta testers are is set on the dashboard; follow it.
|
||||||
|
bot.WatchBeta(context.Background(), settingsRepo)
|
||||||
|
|
||||||
fmt.Println("Bot is running...")
|
fmt.Println("Bot is running...")
|
||||||
|
|
||||||
sc := make(chan os.Signal, 1)
|
sc := make(chan os.Signal, 1)
|
||||||
|
|||||||
+304
-1
@@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS levelup (
|
|||||||
CREATE TABLE IF NOT EXISTS twitch_config (
|
CREATE TABLE IF NOT EXISTS twitch_config (
|
||||||
guild_id BIGINT PRIMARY KEY,
|
guild_id BIGINT PRIMARY KEY,
|
||||||
twitch_channel_id BIGINT,
|
twitch_channel_id BIGINT,
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE
|
enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rps (
|
CREATE TABLE IF NOT EXISTS rps (
|
||||||
@@ -73,6 +73,59 @@ CREATE TABLE IF NOT EXISTS meeting_settings (
|
|||||||
lobby_channel_id BIGINT
|
lobby_channel_id BIGINT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Who counts as a beta tester: holders of role_id in guild_id. One row, set by
|
||||||
|
-- the owner on the dashboard's admin page (the API creates and writes it, see
|
||||||
|
-- its migration 000021); the bot only reads it.
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_settings (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
|
||||||
|
guild_id BIGINT,
|
||||||
|
role_id BIGINT,
|
||||||
|
updated_by BIGINT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Servers whose owner opted in to beta features (API migration 000022). The
|
||||||
|
-- beta server itself is always eligible and isn't listed. The bot only reads it.
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_guilds (
|
||||||
|
guild_id BIGINT PRIMARY KEY,
|
||||||
|
enabled_by BIGINT NOT NULL,
|
||||||
|
enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Servers where the bot registered beta commands, so it can remove them even
|
||||||
|
-- if the server opted out while the bot was offline. Bot-owned (the API's
|
||||||
|
-- migration 000022 creates it for a combined database).
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_registered (
|
||||||
|
guild_id BIGINT PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A server can have several lobbies (at most 10; the trigger enforces it, and
|
||||||
|
-- is the same as the API's migration 000020). meeting_settings.lobby_channel_id
|
||||||
|
-- is the old single-lobby column, no longer read.
|
||||||
|
CREATE TABLE IF NOT EXISTS meeting_lobbies (
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (guild_id, channel_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION meeting_lobbies_limit() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(NEW.guild_id);
|
||||||
|
IF EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = NEW.guild_id AND channel_id = NEW.channel_id) THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF (SELECT COUNT(*) FROM meeting_lobbies WHERE guild_id = NEW.guild_id) >= 10 THEN
|
||||||
|
RAISE EXCEPTION 'a server can have at most 10 lobby channels' USING ERRCODE = '54000';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS meeting_lobbies_limit ON meeting_lobbies;
|
||||||
|
CREATE TRIGGER meeting_lobbies_limit BEFORE INSERT ON meeting_lobbies
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION meeting_lobbies_limit();
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS meeting_temp_channels (
|
CREATE TABLE IF NOT EXISTS meeting_temp_channels (
|
||||||
guild_id BIGINT NOT NULL,
|
guild_id BIGINT NOT NULL,
|
||||||
channel_id BIGINT NOT NULL,
|
channel_id BIGINT NOT NULL,
|
||||||
@@ -176,3 +229,253 @@ CREATE TABLE IF NOT EXISTS music_commands (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
processed_at TIMESTAMPTZ
|
processed_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_settings (
|
||||||
|
guild_id BIGINT PRIMARY KEY,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
channel_id BIGINT,
|
||||||
|
log_channel_id BIGINT,
|
||||||
|
panel_message_id BIGINT,
|
||||||
|
panel_title TEXT NOT NULL DEFAULT 'Need help?',
|
||||||
|
panel_description TEXT NOT NULL DEFAULT 'Pick a category below to open a private ticket with the staff team.',
|
||||||
|
max_open_per_user INT NOT NULL DEFAULT 1 CHECK (max_open_per_user BETWEEN 1 AND 10),
|
||||||
|
next_number INT NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_categories (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 40),
|
||||||
|
emoji TEXT,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
staff_role_id BIGINT,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
number INT NOT NULL,
|
||||||
|
category_id BIGINT REFERENCES ticket_categories (id) ON DELETE SET NULL,
|
||||||
|
category_name TEXT NOT NULL,
|
||||||
|
thread_id BIGINT NOT NULL UNIQUE,
|
||||||
|
intro_message_id BIGINT,
|
||||||
|
opener_id BIGINT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
|
||||||
|
claimed_by BIGINT,
|
||||||
|
closed_by BIGINT,
|
||||||
|
close_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
closed_at TIMESTAMPTZ,
|
||||||
|
UNIQUE (guild_id, number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_commands (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
command_type TEXT NOT NULL CHECK (command_type IN ('post_panel')),
|
||||||
|
requested_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Security: anti-raid and anti-nuke (mirror of velox-dashboard-api migration 000017_security)
|
||||||
|
CREATE TABLE IF NOT EXISTS security_settings (
|
||||||
|
guild_id BIGINT PRIMARY KEY,
|
||||||
|
-- Where alerts go, and who gets pinged for raids and nukes.
|
||||||
|
alert_channel_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.
|
||||||
|
joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
joingate_no_avatar TEXT NOT NULL DEFAULT 'off' CHECK (joingate_no_avatar IN ('off', 'timeout', 'kick', 'ban')),
|
||||||
|
joingate_new_account TEXT NOT NULL DEFAULT 'off' CHECK (joingate_new_account IN ('off', 'timeout', 'kick', 'ban')),
|
||||||
|
joingate_min_account_age_days INT NOT NULL DEFAULT 7 CHECK (joingate_min_account_age_days BETWEEN 1 AND 365),
|
||||||
|
joingate_advertising_name TEXT NOT NULL DEFAULT 'off' CHECK (joingate_advertising_name IN ('off', 'timeout', 'kick', 'ban')),
|
||||||
|
joingate_username_filter TEXT NOT NULL DEFAULT 'off' CHECK (joingate_username_filter IN ('off', 'timeout', 'kick', 'ban')),
|
||||||
|
joingate_username_patterns TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
joingate_unverified_bot TEXT NOT NULL DEFAULT 'off' CHECK (joingate_unverified_bot IN ('off', 'kick', 'ban')),
|
||||||
|
-- A bot added by someone who isn't the owner or trusted.
|
||||||
|
joingate_unauthorized_bot TEXT NOT NULL DEFAULT 'kick' CHECK (joingate_unauthorized_bot IN ('off', 'kick', 'ban')),
|
||||||
|
|
||||||
|
-- Raid mode: raid_join_count joins within raid_join_seconds starts it; it
|
||||||
|
-- ends after raid_quiet_minutes without a join, or when staff end it.
|
||||||
|
antiraid_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
raid_join_count INT NOT NULL DEFAULT 10 CHECK (raid_join_count BETWEEN 3 AND 100),
|
||||||
|
raid_join_seconds INT NOT NULL DEFAULT 10 CHECK (raid_join_seconds BETWEEN 5 AND 300),
|
||||||
|
raid_action TEXT NOT NULL DEFAULT 'kick' CHECK (raid_action IN ('alert', 'timeout', 'kick', 'ban')),
|
||||||
|
raid_pause_invites BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
raid_quiet_minutes INT NOT NULL DEFAULT 10 CHECK (raid_quiet_minutes BETWEEN 1 AND 120),
|
||||||
|
|
||||||
|
-- Anti-nuke: anyone who isn't the owner or trusted and goes over a limit
|
||||||
|
-- within antinuke_window_seconds is quarantined. Limits are per action
|
||||||
|
-- (channel_delete, channel_create, role_delete, role_create, ban, kick,
|
||||||
|
-- webhook_create); a limit of 0 means the action is never allowed.
|
||||||
|
antinuke_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
antinuke_window_seconds INT NOT NULL DEFAULT 10 CHECK (antinuke_window_seconds BETWEEN 5 AND 300),
|
||||||
|
antinuke_limits JSONB NOT NULL DEFAULT '{"channel_delete": 2, "channel_create": 5, "role_delete": 2, "role_create": 5, "ban": 3, "kick": 5, "webhook_create": 3}',
|
||||||
|
-- Also lock every channel when a nuke is stopped.
|
||||||
|
antinuke_lock_server BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
quarantine_role_id BIGINT,
|
||||||
|
|
||||||
|
-- Restore: snapshots of roles and channels are taken every few hours; a
|
||||||
|
-- restore recreates what's missing. Automatic after a stopped nuke when
|
||||||
|
-- antinuke_auto_restore is on, otherwise by the owner or trusted.
|
||||||
|
antinuke_auto_restore BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Heat: every message adds heat to its author (more for repeats,
|
||||||
|
-- mentions, emoji walls, links, blocked words; an invite link maxes it),
|
||||||
|
-- and heat cools down over time. Reaching the maximum deletes the message
|
||||||
|
-- and applies heat_action. heat_strictness scales how fast heat builds.
|
||||||
|
heat_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
heat_strictness TEXT NOT NULL DEFAULT 'medium' CHECK (heat_strictness IN ('low', 'medium', 'high')),
|
||||||
|
heat_action TEXT NOT NULL DEFAULT 'timeout' CHECK (heat_action IN ('timeout', 'kick', 'ban')),
|
||||||
|
heat_timeout_minutes INT NOT NULL DEFAULT 10 CHECK (heat_timeout_minutes BETWEEN 1 AND 10080),
|
||||||
|
-- Each new offence within a day doubles the timeout (capped at 28 days).
|
||||||
|
heat_escalate BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
heat_blocked_words TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
-- Heat panic: this many members maxing out within a minute times out
|
||||||
|
-- anyone who joined in the last day as soon as they speak, for 10
|
||||||
|
-- minutes. 0 turns it off.
|
||||||
|
heat_panic_members INT NOT NULL DEFAULT 3 CHECK (heat_panic_members BETWEEN 0 AND 50),
|
||||||
|
-- Mention flood: this many mentions by non-exempt members within 30
|
||||||
|
-- seconds locks every channel. 0 turns it off.
|
||||||
|
heat_lockdown_mentions INT NOT NULL DEFAULT 0 CHECK (heat_lockdown_mentions BETWEEN 0 AND 1000),
|
||||||
|
|
||||||
|
-- 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'))
|
||||||
|
);
|
||||||
|
-- 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.
|
||||||
|
-- The server owner is always trusted and never listed here.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_trusted (
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
target_id BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('user', 'role')),
|
||||||
|
added_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (guild_id, target_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Members currently quarantined, with the roles they had, so releasing them
|
||||||
|
-- gives every role back.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_quarantines (
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
previous_role_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (guild_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Channels the bot locked, with @everyone's permission overwrite from before,
|
||||||
|
-- so unlocking puts back exactly what was there.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_lockdowns (
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
had_overwrite BOOLEAN NOT NULL,
|
||||||
|
previous_allow BIGINT NOT NULL DEFAULT 0,
|
||||||
|
previous_deny BIGINT NOT NULL DEFAULT 0,
|
||||||
|
server_wide BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
locked_by BIGINT NOT NULL,
|
||||||
|
locked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (guild_id, channel_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A raid in progress, so raid mode survives a bot restart: the quiet timer
|
||||||
|
-- carries on and the summary still lists everyone caught.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_raids (
|
||||||
|
guild_id BIGINT PRIMARY KEY,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_join_at TIMESTAMPTZ NOT NULL,
|
||||||
|
invites_paused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
caught_total INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS security_raid_caught (
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
caught_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (guild_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Snapshots of a guild's roles and channels for restoring after a nuke. The
|
||||||
|
-- bot keeps the last three per guild.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_snapshots (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
roles INT NOT NULL,
|
||||||
|
channels INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS security_snapshots_guild_idx ON security_snapshots (guild_id, created_at DESC);
|
||||||
|
|
||||||
|
-- What the system did, for the dashboard's history.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
actor_id BIGINT,
|
||||||
|
target_id BIGINT,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS security_events_guild_idx ON security_events (guild_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Dashboard -> bot requests, same pattern as music_commands.
|
||||||
|
CREATE TABLE IF NOT EXISTS security_commands (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT NOT NULL,
|
||||||
|
command_type TEXT NOT NULL CHECK (command_type IN ('end_raid', 'release_quarantine', 'unlock_server', 'unlock_joins', 'snapshot', 'restore')),
|
||||||
|
payload JSONB,
|
||||||
|
requested_by BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
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();
|
||||||
Reference in new issue
Block a user