Every dashboard feature is also a command #30

Merged
FernandoJVideira merged 4 commits from ft/commands-parity into dev 2026-10-23 01:12:37 +00:00
46 changed files with 2853 additions and 263 deletions

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ These are the things a reviewer will check first. The reasons are in the [Develo
- **Check permissions in the handler.** `DefaultMemberPermissions` only hides a command from the menu. - **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. - **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. - **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. - **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. - **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. - **No secrets** in code, logs, tests or commit messages.
+1 -1
View File
@@ -2,7 +2,7 @@
Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on. Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on.
**This repository is the bot, and it is the only open source part of Velox.** The web dashboard and its API, which the hosted Velox uses, are separate proprietary software and are not part of this repository or its license. The bot does not need them: everything in the [command reference](docs/commands.md) works on its own, with PostgreSQL as its only dependency. **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.
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). 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).
+8 -7
View File
@@ -9,7 +9,7 @@ The rule is the same on the bot and on the hosted Velox dashboard. The dashboard
## Who the testers are ## Who the testers are
- The **beta server** and the **Beta Tester role** live in the `beta_settings` table, one row. On the hosted Velox the owner sets them on the dashboard. If you host the bot yourself, set them with SQL (see [Settings without a dashboard](self-hosting.md#settings-without-a-dashboard)). - 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). - Giving someone the role in Discord makes them a tester. Taking it away removes their access, within about 30 seconds on the bot (a few seconds if the bot has them cached).
- Until a role is chosen, **nobody** has beta access. - Until a role is chosen, **nobody** has beta access.
- Being a tester is about the person, so it is always checked in the **beta server**, also when they use a feature somewhere else. Having a role with the same ID in another server means nothing. - Being a tester is about the person, so it is always checked in the **beta server**, also when they use a feature somewhere else. Having a role with the same ID in another server means nothing.
@@ -17,15 +17,16 @@ The rule is the same on the bot and on the hosted Velox dashboard. The dashboard
## Which servers have beta features ## Which servers have beta features
- The beta server always has them. - The beta server always has them.
- Any other server gets them only when it is in the `beta_guilds` table. Beta features can act on a whole server (anti-raid behaviour, for example), so a server should only be listed with its owner's agreement. On the hosted Velox, only the server's own owner can switch it on, from the dashboard. If you host the bot yourself, you add rows yourself. - 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 polls the table every 30 seconds, together with the beta settings. - 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. Beta never replaces a feature's own permission checks. A beta command that needs Manage Server still needs it, a music command still needs the DJ role, and anything for the owner or trusted users still needs that.
## What a tester sees ## What a tester sees
- **Bot:** beta commands appear only in the beta server and in opted-in servers, and run only for testers. When a server opts in, the commands show up within about 30 seconds. When it opts out, they are removed. - **Bot:** beta commands appear only in the beta server and in opted-in servers, and run only for testers. When a server opts in, the commands show up within about 30 seconds. When it opts out, they are removed.
- **On the hosted Velox dashboard:** beta pages and links show a **Beta** badge, only to testers and only for servers where beta features are on. - **On the hosted Velox dashboard** (proprietary, not in this repository): beta pages and links show a **Beta** badge, only to testers and only for servers where beta features are on.
## On the bot ## On the bot
@@ -34,8 +35,8 @@ Code: `internal/beta` (the rules), `internal/commands/commands.go` (`RegisterBet
- Beta commands are registered in each server where beta features are on, and only there. `/beta` is only in the beta server. - Beta commands are registered in each server where beta features are on, and only there. `/beta` is only in the beta server.
- Each beta handler is wrapped in `beta.Gate`. It checks that beta is on in the server of the interaction and that the person is a tester, and otherwise answers "This feature is in beta and isn't available to you or this server yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection. - Each beta handler is wrapped in `beta.Gate`. It checks that beta is on in the server of the interaction and that the person is a tester, and otherwise answers "This feature is in beta and isn't available to you or this server yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
- For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no. - For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no.
- If the beta server changes in `beta_settings`, the beta commands move within 30 seconds. In the beta server, commands this bot left there that no longer exist are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept. - If the beta server changes, the beta commands move within 30 seconds. In the beta server, commands this bot left there that no longer exist are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
- `/beta` shows the bot's owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators. - `/beta` is registered globally but works only in a direct message with the bot, so it is in no server's menu, and only the owner of the Discord application can use it.
- The bot remembers the servers where it registered beta commands (`beta_registered` table). A server that opted out while the bot was **offline** is cleaned up on the next start, and a cleanup that fails is retried every 30 seconds. A server the bot has left is forgotten. - The bot remembers the servers where it registered beta commands (`beta_registered` table). A server that opted out while the bot was **offline** is cleaned up on the next start, and a cleanup that fails is retried every 30 seconds. A server the bot has left is forgotten.
### Add a beta command ### Add a beta command
@@ -76,4 +77,4 @@ The hosted Velox has a proprietary dashboard and API that apply the same rule to
- Servers choose to take part, because a beta feature can affect everyone in them. - Servers choose to take part, because a beta feature can affect everyone in them.
- Access is decided on the server, never in the browser. - Access is decided on the server, never in the browser.
- Beta commands do not exist outside the beta server, and the gate checks again when they run. - Beta commands do not exist outside the beta server, and the gate checks again when they run.
- The settings are in the database, not in the environment, so they can change without a deploy. - The settings are in the database and changed with commands, not in the environment, so they change without a deploy.
+35 -4
View File
@@ -46,6 +46,8 @@ Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers
| `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go | | `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go |
| `/slvl set-message` | `message`* | Manage Server, leveling on | The level-up message. Variables: `{user}`, `{level}` | | `/slvl set-message` | `message`* | Manage Server, leveling on | The level-up message. Variables: `{user}`, `{level}` |
| `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level | | `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level |
| `/slvl remove-reward` | `level`* | Manage Server, leveling on | Removes a level's role reward |
| `/slvl status` | | Manage Server | Shows the leveling settings |
| `/rank` | `user` | Leveling on | A rank card for you or another member | | `/rank` | `user` | Leveling on | A rank card for you or another member |
| `/leaderboard` | | Leveling on | Top 10 in the server | | `/leaderboard` | | Leveling on | Top 10 in the server |
| `/rewards` | | Leveling on | The role rewards | | `/rewards` | | Leveling on | The role rewards |
@@ -58,6 +60,12 @@ Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers
| `/queue` | | **DJ** role | Shows the queue | | `/queue` | | **DJ** role | Shows the queue |
| `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume | | `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume |
| `/music toggle` | | Manage Server | Turns music on or off in this server | | `/music toggle` | | Manage Server | Turns music on or off in this server |
| `/music 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). Requires Lavalink. See [Getting started](getting-started.md#optional-music-with-lavalink).
@@ -89,6 +97,8 @@ Each subcommand checks the Discord permission in its "Needs" column.
| `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans a member | | `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans a member |
| `/moderation unban` | `user_id`*, `reason` | Ban Members | Unbans by ID | | `/moderation unban` | `user_id`*, `reason` | Ban Members | Unbans by ID |
| `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off | | `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off |
| `/moderation bans` | | Ban Members | Lists banned users, with their IDs |
| `/moderation timeouts` | | Moderate Members | Lists members who are timed out now |
## Timezone and scheduling ## Timezone and scheduling
@@ -125,6 +135,11 @@ Every subcommand needs **Manage Server**.
| `/config setlogchannel` | `channel`* | Where logs go | | `/config setlogchannel` | `channel`* | Where logs go |
| `/config enablelogging` | | Turns logging on | | `/config enablelogging` | | Turns logging on |
| `/config disablelogging` | | Turns it off | | `/config disablelogging` | | Turns it off |
| `/config view` | | Shows the welcome, default role, logging and Twitch settings |
| `/config clear` | `setting`* (welcome-channel, welcome-message, welcome-dm, welcome-gif, default-role, log-channel) | Clears one |
| `/config toggletwitch` | `enabled`* | Turns Twitch live notifications on or off |
| `/config streamers` | | Lists the watched streamers |
| `/config beta-features` | `enabled` | Lets testers use beta features here. Only the **server owner** can change it. Leave out `enabled` to see the state |
| `/config addstreamer` | `username`* | Adds a Twitch streamer to watch | | `/config addstreamer` | `username`* | Adds a Twitch streamer to watch |
| `/config removestreamer` | `username`* | Removes one | | `/config removestreamer` | `username`* | Removes one |
| `/config settwitchnotificationchannel` | `channel`* | Where live alerts go | | `/config settwitchnotificationchannel` | `channel`* | Where live alerts go |
@@ -137,7 +152,10 @@ Every subcommand needs **Manage Server**.
| `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown | | `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown |
| `/tickets category remove` | `name`* | Manage Server | Removes a category | | `/tickets category remove` | `name`* | Manage Server | Removes a category |
| `/tickets category list` | | Manage Server | Lists categories | | `/tickets category list` | | Manage Server | Lists categories |
| `/tickets category edit` | `name`*, `new-name`, `staff-role`, `clear-staff-role`, `emoji`, `description` | Manage Server | Changes a category (`-` removes the emoji or description) |
| `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories | | `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories |
| `/tickets settings` | `enabled`, `panel-title`, `panel-description`, `max-open` | Manage Server | Shows the settings, or changes the ones you give |
| `/tickets list` | `status` (open or closed) | Manage Server | Lists recent tickets |
| `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket | | `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket |
| `/ticket remove` | `user`* | Staff | Removes someone | | `/ticket remove` | `user`* | Staff | Removes someone |
| `/ticket claim` | | Staff | Takes the ticket | | `/ticket claim` | | Staff | Takes the ticket |
@@ -160,6 +178,17 @@ In this table "Manage Server" also lets Administrators and trusted members throu
| `/security quarantined` | | Manage Server | Lists quarantined members | | `/security quarantined` | | Manage Server | Lists quarantined members |
| `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back | | `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back |
| `/security end-raid` | | Owner or trusted | Ends raid mode now | | `/security end-raid` | | Owner or trusted | Ends raid mode now |
| `/security settings` | | Manage Server | Shows every setting |
| `/security history` | | Manage Server | Shows the latest security events |
| `/security joingate check` | `check`* (no-avatar, new-account, invite-in-name, blocked-names, unverified-bot, unauthorized-bot), `action`* | Owner or trusted | What happens when a check catches a new member |
| `/security joingate account-age` | `days`* (1 to 365) | Owner or trusted | How old an account must be |
| `/security words add`, `remove` | `list`* (username or chat), `word`* | Owner or trusted | Blocked words for new members' names (Join Gate) or messages (heat) |
| `/security words list` | `list`* | Manage Server | Shows a list |
| `/security raid` | `join-count`, `join-seconds`, `action`, `pause-invites`, `quiet-minutes` | Owner or trusted | Tunes raid mode |
| `/security antinuke set` | `window-seconds`, `lock-server`, `auto-restore` | Owner or trusted | Tunes anti-nuke |
| `/security antinuke limit` | `action`*, `count`* (0 to 100) | Owner or trusted | How many times someone may do an action in the window (0: never) |
| `/security heat` | `strictness`, `action`, `timeout-minutes`, `escalate`, `panic-members`, `mention-lockdown` | Owner or trusted | Tunes the spam heat system |
| `/security alert-dms` | `on`* | Owner or trusted | DM critical alerts to the owner and trusted users |
| `/security snapshot` | | Manage Server | Saves roles and channels | | `/security snapshot` | | Manage Server | Saves roles and channels |
| `/security restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot | | `/security restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot |
| `/lockdown channel` | `channel` (default: this one) | Manage Channels | Locks a channel | | `/lockdown channel` | `channel` (default: this one) | Manage Channels | Locks a channel |
@@ -173,8 +202,10 @@ In this table "Manage Server" also lets Administrators and trusted members throu
| Command | Needs | What it does | | Command | Needs | What it does |
| --- | --- | --- | | --- | --- | --- |
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only | | `/beta status` | The bot's owner, **in a DM with the bot** | Shows how beta access is set up |
| `/beta set` | The bot's owner, in a DM | `server-id`* and `role-id`*: chooses the beta server and the Beta Tester role |
| `/beta off` | The bot's owner, in a DM | Turns beta features off for everyone |
| `/beta servers` | The bot's owner, in a DM | Lists the servers that opted in |
| `/beta remove-server` | The bot's owner, in a DM | `server-id`*: takes a server out of the beta |
Other beta commands appear only in the beta server and in servers whose owner opted in, and only testers can run them. `/beta` is registered globally but only works in a direct message with the bot, so it is in no server's menu. A server opts itself in with `/config beta-features` (its owner). Other beta commands appear only in the beta server and in servers that opted in, and only testers can run them. See [Beta features](beta-features.md).
Beta features come and go. See [Beta features](beta-features.md).
+2 -17
View File
@@ -8,22 +8,7 @@ The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL live
The hosted Velox also has a proprietary dashboard API that keeps its own migrations for the same tables. That is the maintainer's concern: if your change adds or alters a table, update `schema.sql` and say so in your pull request, and the maintainer ports it. You never need the API to develop or run the bot. The hosted Velox also has a proprietary dashboard API that keeps its own migrations for the same tables. That is the maintainer's concern: if your change adds or alters a table, update `schema.sql` and say so in your pull request, and the maintainer ports it. You never need the API to develop or run the bot.
Two things are not in `schema.sql`, because only a dashboard needs them: the `NOTIFY` triggers on the three `*_commands` tables (see below), and a migration history. If you write a tool that inserts command rows, add the trigger yourself: `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.
```sql
CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('music_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER music_commands_notify
AFTER INSERT ON music_commands
FOR EACH ROW EXECUTE FUNCTION notify_music_command();
```
The same works for `ticket_commands` and `security_commands`. Without the trigger the bot still picks the rows up, but only the next time it starts or is notified for another reason.
## Tables ## Tables
@@ -62,7 +47,7 @@ Every listener **validates the request again**. The dashboard should check that
## Changing the schema ## Changing the schema
1. Update `schema.sql`. Use `CREATE TABLE IF NOT EXISTS`, and avoid destructive changes: a bot that is a version behind (or a tool reading the same tables) must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release. 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`. 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. 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. 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.
+3 -1
View File
@@ -190,7 +190,9 @@ 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. **6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and anything else writing to the table cannot disagree and two simultaneous requests cannot both slip through.
**7. Default to off.** A module that does something to a server without being asked (posts messages, moderates, grants roles) must start disabled and be turned on by an admin. **7. Give it commands.** Every setting must be visible and changeable from Discord (a slash command to set it, and a way to see it). The bot has to work completely without a dashboard, so never add a setting that only a dashboard or SQL can change. Put the options in the command definition, and `internal/commands/limits_test.go` will tell you if it goes over Discord's limits.
**8. Default to off.** A module that does something to a server without being asked (posts messages, moderates, grants roles) must start disabled and be turned on by an admin.
## React to a gateway event ## React to a gateway event
+4 -4
View File
@@ -29,13 +29,13 @@ Rules that hold everywhere in this module:
- Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned. - Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned.
- Messages ping only the configured alert role, and names are escaped. - Messages ping only the configured alert role, and names are escaped.
Thresholds, Join Gate filters and heat settings are rows in the `security_settings` table (the hosted Velox edits them on its dashboard; see [Settings without a dashboard](self-hosting.md#settings-without-a-dashboard)). The bot's role must be above the roles it protects against: it can only quarantine members whose highest role is below its own. Every threshold, filter and action is a command: `/security settings` shows them all, and `/security joingate`, `words`, `raid`, `antinuke`, `heat` and `alert-dms` change them (owner or trusted users only). `/security history` shows what the system has done. The bot's role must be above the roles it protects against: it can only quarantine members whose highest role is below its own.
## Tickets ## Tickets
Support tickets in private threads. Code: `internal/tickets`, commands in `internal/commands/tickets`. Support tickets in private threads. Code: `internal/tickets`, commands in `internal/commands/tickets`.
1. An admin runs `/tickets setup`, adds categories with `/tickets category add` (each with its own staff role), and posts the panel with `/tickets panel`. 1. An admin runs `/tickets setup`, adds categories with `/tickets category add` (each with its own staff role), and posts the panel with `/tickets panel`. `/tickets settings` changes the panel text and the open ticket limit and turns tickets on or off, `/tickets category edit` changes a category, and `/tickets list` shows tickets.
2. The panel has a category dropdown and a **Create ticket** button. A short form asks what the ticket is about. 2. The panel has a category dropdown and a **Create ticket** button. A short form asks what the ticket is about.
3. A **private thread** is created in the ticket channel. The opener and the category's staff role can see it. 3. A **private thread** is created in the ticket channel. The opener and the category's staff role can see it.
4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`. 4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`.
@@ -68,8 +68,8 @@ Lavalink-powered playback. Code: `internal/music`, commands in `internal/command
- `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**. - `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**.
- The now-playing message has buttons for playback and repeat. Buttons also check for the DJ role. - The now-playing message has buttons for playback and repeat. Buttons also check for the DJ role.
- `/music toggle` (Manage Server) turns music on or off in a server. - `/music toggle` (Manage Server) turns music on or off in a server. `/music 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 be controlled by writing to the `music_commands` table. The hosted Velox dashboard uses this (see [Database](database.md)). - The queue and now-playing state are kept in the database, and playback can also be controlled by writing to the `music_commands` table. The hosted Velox dashboard uses this (see [Database](database.md)); you never need it, the commands above do the same.
- Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not. - Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not.
## Meeting rooms ## Meeting rooms
+3
View File
@@ -106,6 +106,9 @@ Nothing is on by default. In your server:
- `/slvl toggle` for leveling. - `/slvl toggle` for leveling.
- `/config setlogchannel` and `/config enablelogging` for logs. - `/config setlogchannel` and `/config enablelogging` for logs.
- `/music toggle` for music (needs Lavalink). - `/music toggle` for music (needs Lavalink).
- `/config toggletwitch` and `/config addstreamer` for Twitch alerts.
`/security settings`, `/tickets settings`, `/slvl status` and `/config view` show what is set.
The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option. The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option.
+14 -28
View File
@@ -8,7 +8,7 @@ You will run:
- **PostgreSQL**, started by the same Compose file. - **PostgreSQL**, started by the same Compose file.
- Optionally **Lavalink**, if you want music. - Optionally **Lavalink**, if you want music.
The hosted Velox also has a web dashboard. It is proprietary and not part of this repository, so it is not something you can host. You do not need it. See [Settings without a dashboard](#settings-without-a-dashboard). 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 ## Before you start
@@ -154,38 +154,24 @@ Run it on a schedule and keep copies off the server. To restore into an empty da
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. Discord requires you to **verify** your application once the bot is in 100 servers, and to be approved for the privileged intents (Server Members, Message Content). Plan for that if you intend to run a public bot. For a bot in a few servers you manage yourself, nothing is needed.
## Settings without a dashboard ## Everything is a command
The bot works fully on its own, and every command in the [Command reference](commands.md) does. Most things are set with slash commands. A few settings that the hosted Velox sets on its dashboard have no command, so you set them in the database. They are plain rows, described in `schema.sql`. 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:
**Security thresholds and filters.** The defaults are in `security_settings`. To change a server's settings, insert or update its row (the columns are documented in `schema.sql`, and invalid values are refused): - **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.
```sql ### Setting up beta features
INSERT INTO security_settings (guild_id, raid_join_count, raid_join_seconds)
VALUES (123456789012345678, 15, 10) 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:
ON CONFLICT (guild_id) DO UPDATE
SET raid_join_count = EXCLUDED.raid_join_count, raid_join_seconds = EXCLUDED.raid_join_seconds; ```
/beta set server-id:<the server's ID> role-id:<the role's ID>
``` ```
Protections are switched on with `/security toggle`, not with SQL. (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).
**Beta features.** Beta features are off until you choose a beta server and a Beta Tester role. Run this once, with your own IDs (the bot reads it every 30 seconds):
```sql
INSERT INTO beta_settings (id, guild_id, role_id) VALUES (TRUE, 123456789012345678, 234567890123456789)
ON CONFLICT (id) DO UPDATE SET guild_id = EXCLUDED.guild_id, role_id = EXCLUDED.role_id;
```
To let another server's beta features on, add it (its owner's agreement is yours to arrange):
```sql
INSERT INTO beta_guilds (guild_id, enabled_by) VALUES (345678901234567890, 123456789012345678)
ON CONFLICT DO NOTHING;
```
See [Beta features](beta-features.md) for what this does.
**Queue and panel requests.** The `*_commands` tables let another program ask the bot to skip a song, post the ticket panel or end a raid. See [Database](database.md#dashboard-to-bot-settings-and-requests) if you want to build something on them.
## Making it your own ## Making it your own
+1 -1
View File
@@ -50,7 +50,7 @@ A dashboard (the hosted Velox has a proprietary one) or any tool of yours talks
## Beta features are not available to a tester ## Beta features are not available to a tester
Go through [Beta features](beta-features.md): is the Beta Tester role set in `beta_settings`? Is the person in the **beta server**, and do they have the role there? Is the server where they use it the beta server, or is it listed in `beta_guilds`? Has 30 seconds passed? `/beta` (owner only) shows how access is set up. Go through [Beta features](beta-features.md): has the bot's owner set the Beta Tester role (`/beta status` in a DM with the bot shows it)? Is the person in the **beta server**, and do they have the role there? Is the server where they use it the beta server, or has its owner run `/config beta-features enabled:true`? Has 30 seconds passed?
## Getting more detail ## Getting more detail
+2 -2
View File
@@ -171,8 +171,8 @@ func TestBetaCommandsFollowTheServers(t *testing.T) {
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got) t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
} }
got := d.names(betaServer) got := d.names(betaServer)
if !contains(got, "beta") || !contains(got, "betatest") || contains(got, "oldthing") { if !contains(got, "betatest") || contains(got, "oldthing") || contains(got, "beta") {
t.Errorf("the beta server gets /beta and the beta commands, and loses stale ones: %v", got) t.Errorf("the beta server gets the beta commands (not /beta, which lives in DMs) and loses stale ones: %v", got)
} }
if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] { if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("the database remembers where the commands are now: %v", store.registered) t.Errorf("the database remembers where the commands are now: %v", store.registered)
+7 -4
View File
@@ -9,7 +9,6 @@ import (
"time" "time"
"velox-bot/internal/beta" "velox-bot/internal/beta"
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/commands/betacmd"
"velox-bot/internal/db/repos/musicrepo" "velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/events" "velox-bot/internal/events"
@@ -121,6 +120,13 @@ func (b *Bot) Start() error {
} }
} }
// 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.Session.AddHandler(commands.HandleInteraction) b.Session.AddHandler(commands.HandleInteraction)
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) { b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
@@ -357,9 +363,6 @@ func (b *Bot) addBetaCommands(guildID string) bool {
} }
ok := true ok := true
for _, cmd := range commands.BetaCommands { for _, cmd := range commands.BetaCommands {
if cmd.Name == betacmd.Beta.Name && !isBetaServer {
continue
}
keep[cmd.Name] = true keep[cmd.Name] = true
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil { if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err) log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
+189 -36
View File
@@ -1,31 +1,59 @@
// Package betacmd is the /beta command: it shows how beta access is set up. // Package betacmd is /beta, the bot owner's control over the beta program.
// It exists only in the beta server (see commands.BetaCommands), is hidden // It is a command you use in a direct message with the bot, so it exists in no
// from everyone but administrators in Discord's command picker, and only the // server's command menu, and only the owner of the Discord application can run
// bot's owner can actually run it (the owner is looked up from the Discord // it (the owner is looked up from the application, so there is nothing to
// application, so there's nothing to configure). // configure). It chooses the beta server and the Beta Tester role, and
// removes servers from the beta; a server's own owner opts it in with
// /config beta-features. Nothing about the beta needs a dashboard.
package betacmd package betacmd
import ( import (
"context"
"fmt" "fmt"
"log"
"regexp"
"strconv"
"strings"
"sync" "sync"
"time"
"velox-bot/internal/beta" "velox-bot/internal/beta"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
var ( var (
noDM = false ownerIDs sync.Map // user ID -> struct{}, filled once from the application
noPerms = int64(0) // hides the command from everyone but administrators
ownerIDs sync.Map // user ID -> struct{}, filled once from the application
loadOnce sync.Mutex loadOnce sync.Mutex
loaded bool loaded bool
snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
dmOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextBotDM}
) )
var Beta = &discordgo.ApplicationCommand{ var Beta = &discordgo.ApplicationCommand{
Name: "beta", Name: "beta",
Description: "Show how beta access is set up (bot owner only)", Description: "Manage the beta program (bot owner, in a DM with the bot)",
DMPermission: &noDM, Contexts: dmOnly,
DefaultMemberPermissions: &noPerms, Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show how beta access is set up"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "set", Description: "Choose the beta server and the Beta Tester role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "server-id", Description: "The beta server's ID (the bot must be in it)", Required: true, MaxLength: 20},
{Type: discordgo.ApplicationCommandOptionString, Name: "role-id", Description: "The Beta Tester role's ID in that server", Required: true, MaxLength: 20},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "off", Description: "Turn beta features off for everyone (clears the role)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "servers", Description: "List the servers that opted in to beta features"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove-server", Description: "Take a server out of the beta",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "server-id", Description: "The server's ID", Required: true, MaxLength: 20},
},
},
},
} }
// isOwner reports whether the user owns the bot's Discord application (or is // isOwner reports whether the user owns the bot's Discord application (or is
@@ -57,31 +85,156 @@ func isOwner(s *discordgo.Session, userID string) bool {
return ok return ok
} }
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) { func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
var userID string
if i.Member != nil && i.Member.User != nil {
userID = i.Member.User.ID
}
var msg string
st := beta.StatusOf(i.GuildID, i.Member)
switch {
case !isOwner(s, userID):
msg = "Only the bot's owner can use this command."
case !st.ServerEnabled:
msg = "Beta features are off in this server."
case !st.RoleConfigured:
msg = "No Beta Tester role is set up, so beta features are locked for everyone."
case st.HasRole:
msg = fmt.Sprintf("You're a beta tester. Beta features are on in this server, and in %d other server(s) that opted in.", len(beta.Servers()))
default:
msg = "You don't have the Beta Tester role, so beta features are locked for you."
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{Content: msg, AllowedMentions: &discordgo.MessageAllowedMentions{}},
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
AllowedMentions: &discordgo.MessageAllowedMentions{},
},
}) })
} }
func guildName(s *discordgo.Session, id string) string {
if g, err := s.State.Guild(id); err == nil && g != nil && g.Name != "" {
return g.Name
}
if g, err := s.Guild(id); err == nil && g != nil {
return g.Name
}
return "unknown server"
}
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
userID := ""
switch {
case i.User != nil:
userID = i.User.ID
case i.Member != nil && i.Member.User != nil:
userID = i.Member.User.ID
}
if !isOwner(s, userID) {
reply(s, i, "Only the bot's owner can use this command.")
return
}
if i.GuildID != "" {
reply(s, i, "Use this command in a direct message with me, not in a server.")
return
}
svc := services.Global
if svc == nil || svc.BetaSettings == nil {
reply(s, i, "Beta settings aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
fail := func(err error) {
reply(s, i, "Something went wrong. Try again in a moment.")
log.Printf("beta %s: %v", sub.Name, err)
}
switch sub.Name {
case "status":
guildID, roleID, err := svc.BetaSettings.Get(ctx)
if err != nil {
fail(err)
return
}
opted, _ := svc.BetaSettings.OptedIn(ctx)
var b strings.Builder
switch {
case guildID == "":
b.WriteString("No beta server is set. Choose one with `/beta set`.\n")
case roleID == "":
fmt.Fprintf(&b, "Beta server: **%s** (`%s`).\nNo Beta Tester role is set, so beta features are locked for everyone. Set one with `/beta set`.\n", guildName(s, guildID), guildID)
default:
fmt.Fprintf(&b, "Beta server: **%s** (`%s`).\nBeta Tester role: `%s`. Members of the beta server who have it are testers.\n", guildName(s, guildID), guildID, roleID)
}
fmt.Fprintf(&b, "%d other server(s) opted in. The bot is using: server `%s`, %d opted-in server(s).", len(opted), beta.GuildID(), len(beta.Servers()))
reply(s, i, b.String())
case "set":
guildArg, roleArg := strings.TrimSpace(args["server-id"].StringValue()), strings.TrimSpace(args["role-id"].StringValue())
if !snowflake.MatchString(guildArg) || !snowflake.MatchString(roleArg) {
reply(s, i, "Both IDs must be numbers. In Discord, turn on Developer Mode, then right-click the server or role and choose Copy ID.")
return
}
roles, err := s.GuildRoles(guildArg)
if err != nil {
reply(s, i, "I couldn't read that server. Check the ID, and that I'm in it.")
return
}
found := false
for _, r := range roles {
if r.ID == roleArg && r.ID != guildArg && !r.Managed {
found = true
}
}
if !found {
reply(s, i, "That role isn't a normal role of that server.")
return
}
g, _ := strconv.ParseInt(guildArg, 10, 64)
r, _ := strconv.ParseInt(roleArg, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetServer(ctx, g, r, by); err != nil {
fail(err)
return
}
reply(s, i, fmt.Sprintf("Saved. **%s** is the beta server, and members with the role `%s` are beta testers. The bot picks this up within 30 seconds.", guildName(s, guildArg), roleArg))
case "off":
guildID, _, err := svc.BetaSettings.Get(ctx)
if err != nil {
fail(err)
return
}
if guildID == "" {
reply(s, i, "No beta server is set, so beta features are already off.")
return
}
g, _ := strconv.ParseInt(guildID, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetServer(ctx, g, 0, by); err != nil {
fail(err)
return
}
reply(s, i, "Beta features are off for everyone. Choose a role again with `/beta set` to turn them back on.")
case "servers":
opted, err := svc.BetaSettings.OptedIn(ctx)
if err != nil {
fail(err)
return
}
if len(opted) == 0 {
reply(s, i, "No other server has opted in. A server's owner does that with `/config beta-features`.")
return
}
var b strings.Builder
for _, id := range opted {
fmt.Fprintf(&b, "• %s (`%s`)\n", guildName(s, id), id)
}
out := b.String()
if r := []rune(out); len(r) > 1900 {
out = string(r[:1899]) + "…"
}
reply(s, i, "**Servers in the beta**\n"+out)
case "remove-server":
id := strings.TrimSpace(args["server-id"].StringValue())
if !snowflake.MatchString(id) {
reply(s, i, "The ID must be a number.")
return
}
g, _ := strconv.ParseInt(id, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetOptedIn(ctx, g, by, false); err != nil {
fail(err)
return
}
reply(s, i, "Removed. Its beta commands disappear within a minute. Only that server's owner can put it back.")
}
}
+6 -2
View File
@@ -86,10 +86,14 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
// wrapped so it runs only for members with the Beta Tester role (see the beta // wrapped so it runs only for members with the Beta Tester role (see the beta
// package). To put a feature in beta, add it with RegisterBeta instead of to // package). To put a feature in beta, add it with RegisterBeta instead of to
// AllCommands and handlers; to release it, move it there. // AllCommands and handlers; to release it, move it there.
var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta} var BetaCommands = []*discordgo.ApplicationCommand{}
// OwnerCommands are registered globally but can only be used in a direct
// message with the bot, so they are in no server's menu. /beta is the bot
// owner's control over the beta program (see betacmd).
var OwnerCommands = []*discordgo.ApplicationCommand{betacmd.Beta}
func init() { func init() {
// /beta itself is open to the whole beta server (see betacmd).
handlers[betacmd.Beta.Name] = betacmd.Handler handlers[betacmd.Beta.Name] = betacmd.Handler
} }
+6 -2
View File
@@ -14,7 +14,7 @@ import (
var Config = &discordgo.ApplicationCommand{ var Config = &discordgo.ApplicationCommand{
Name: "config", Name: "config",
Description: "Server configuration", Description: "Server configuration",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "addstreamer", Name: "addstreamer",
@@ -145,7 +145,7 @@ var Config = &discordgo.ApplicationCommand{
Name: "disablelogging", Name: "disablelogging",
Description: "Disable server logging events", Description: "Disable server logging events",
}, },
}, }, extraOptions()...),
} }
func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -168,6 +168,10 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
if handleExtra(s, i, data.Options[0]) {
return
}
switch data.Options[0].Name { switch data.Options[0].Name {
case "addstreamer": case "addstreamer":
log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
+214
View File
@@ -0,0 +1,214 @@
package public
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// Everything else /config needs so that no setting depends on a dashboard:
// seeing the settings, clearing one, switching Twitch alerts on and off,
// listing streamers, and the server owner's beta features switch.
var clearChoices = []string{"welcome-channel", "welcome-message", "welcome-dm", "welcome-gif", "default-role", "log-channel"}
func extraOptions() []*discordgo.ApplicationCommandOption {
clear := &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString, Name: "setting", Description: "What to clear", Required: true,
}
for _, c := range clearChoices {
clear.Choices = append(clear.Choices, &discordgo.ApplicationCommandOptionChoice{Name: c, Value: c})
}
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "view", Description: "Show the welcome, role, logging and Twitch settings"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "clear", Description: "Clear a welcome, role or log setting", Options: []*discordgo.ApplicationCommandOption{clear}},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggletwitch", Description: "Turn Twitch live notifications on or off",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "On or off", Required: true}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "streamers", Description: "List the Twitch streamers being watched"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "beta-features",
Description: "Let testers use beta features here. Only the server owner can change it",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "On or off. Leave out to see the state"}},
},
}
}
func guildInt(i *discordgo.InteractionCreate) int64 {
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
return n
}
func ownerOf(s *discordgo.Session, guildID string) (string, error) {
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
return g.OwnerID, nil
}
g, err := s.Guild(guildID)
if err != nil {
return "", err
}
return g.OwnerID, nil
}
// handleExtra runs the subcommands above. It reports whether it handled name.
func handleExtra(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) bool {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
g := guildInt(i)
svc := services.Global
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
fail := func(err error) {
respondEphemeral(s, i, "Something went wrong. Try again in a moment.")
log.Printf("config %s: %v", sub.Name, err)
}
switch sub.Name {
case "view":
var b strings.Builder
if w, err := svc.Welcome.GetSettings(ctx, g); err == nil {
fmt.Fprintf(&b, "**Welcome**\nChannel: %s\nMessage: %s\nDM: %s\nGIF: %s\n", mention("#", w.ChannelID), textOr(w.WelcomeMessage), textOr(w.WelcomeDM), textOr(w.WelcomeGIFURL))
}
if r, err := svc.DefaultRole.GetRole(ctx, g); err == nil {
fmt.Fprintf(&b, "**Default role:** %s\n", mention("@&", r))
}
if l, err := svc.LogSettings.GetSettings(ctx, g); err == nil && l != nil {
state := "off"
if l.IsEnabled {
state = "on"
}
fmt.Fprintf(&b, "**Logging:** %s, channel %s\n", state, mention("#", l.ChannelID))
} else {
b.WriteString("**Logging:** off\n")
}
if on, err := svc.Twitch.IsEnabled(ctx, g); err == nil {
ch, _, _ := svc.Twitch.GetNotificationChannel(ctx, g)
users, _ := svc.Twitch.ListUsersForGuild(ctx, g)
state := "off"
if on {
state = "on"
}
fmt.Fprintf(&b, "**Twitch alerts:** %s, channel %s, %d streamer(s)\n", state, mention("#", ch), len(users))
}
respondEphemeral(s, i, clipConfig(b.String(), 1900))
case "clear":
var err error
setting := args["setting"].StringValue()
switch setting {
case "welcome-channel":
err = svc.Welcome.SetChannel(ctx, g, 0)
case "welcome-message":
err = svc.Welcome.SetMessage(ctx, g, "")
case "welcome-dm":
err = svc.Welcome.SetDM(ctx, g, "")
case "welcome-gif":
err = svc.Welcome.SetGIF(ctx, g, "")
case "default-role":
err = svc.DefaultRole.SetRole(ctx, g, 0)
case "log-channel":
err = svc.LogSettings.SetChannel(ctx, g, 0)
default:
respondEphemeral(s, i, "Unknown setting.")
return true
}
if err != nil {
fail(err)
return true
}
respondEphemeral(s, i, "Cleared **"+setting+"**.")
case "toggletwitch":
on := args["enabled"].BoolValue()
if err := svc.Twitch.SetEnabled(ctx, g, on); err != nil {
fail(err)
return true
}
if on {
if ch, ok, _ := svc.Twitch.GetNotificationChannel(ctx, g); !ok || ch == 0 {
respondEphemeral(s, i, "Twitch alerts are **on**, but there is no channel yet. Set one with `/config settwitchnotificationchannel`.")
return true
}
respondEphemeral(s, i, "Twitch alerts are **on**.")
} else {
respondEphemeral(s, i, "Twitch alerts are **off**.")
}
case "streamers":
users, err := svc.Twitch.ListUsersForGuild(ctx, g)
if err != nil {
fail(err)
return true
}
if len(users) == 0 {
respondEphemeral(s, i, "No streamers yet. Add one with `/config addstreamer`.")
return true
}
respondEphemeral(s, i, clipConfig("Watching: "+strings.Join(users, ", "), 1900))
case "beta-features":
on, err := svc.BetaSettings.IsOptedIn(ctx, g)
if err != nil {
fail(err)
return true
}
state := "off"
if on {
state = "on"
}
want, ok := args["enabled"]
if !ok {
respondEphemeral(s, i, "Beta features are **"+state+"** in this server. Testers can use them here when this is on. Only the server owner can change it.")
return true
}
owner, err := ownerOf(s, i.GuildID)
if err != nil {
fail(err)
return true
}
if i.Member == nil || i.Member.User == nil || i.Member.User.ID != owner {
respondEphemeral(s, i, "Only the server owner can change this. Beta features can act on the whole server.")
return true
}
by, _ := strconv.ParseInt(owner, 10, 64)
if err := svc.BetaSettings.SetOptedIn(ctx, g, by, want.BoolValue()); err != nil {
fail(err)
return true
}
if want.BoolValue() {
respondEphemeral(s, i, "Beta features are **on**. Beta commands appear here within a minute, for testers only.")
} else {
respondEphemeral(s, i, "Beta features are **off**. Beta commands disappear from this server within a minute.")
}
default:
return false
}
return true
}
func mention(prefix string, id int64) string {
if id == 0 {
return "not set"
}
return fmt.Sprintf("<%s%d>", prefix, id)
}
func textOr(v string) string {
if strings.TrimSpace(v) == "" {
return "not set"
}
return "`" + clipConfig(v, 120) + "`"
}
func clipConfig(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
+179 -129
View File
@@ -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,134 +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: "Tickets",
Value: "" +
"Open a ticket from the panel: you get a private thread with the staff.\n" +
"`/ticket add user:@someone` — Add someone to your ticket\n" +
"`/ticket close [reason]` — Close the ticket (opener or staff)\n" +
"`/ticket claim`, `/ticket remove user:@someone` — Staff only\n" +
"`/tickets setup`, `/tickets category add|remove|list`, `/tickets panel` — Set up (**Manage Server**)",
},
{
Name: "Security (anti-raid & anti-nuke)",
Value: "" +
"`/security status` — What's on and who is trusted\n" +
"`/security toggle` — Join Gate, raid mode, anti-nuke on/off (off: owner or trusted)\n" +
"`/security alerts`, `/security quarantine-role`, `/security release`, `/security end-raid` — Owner or trusted\n" +
"`/security trust add|remove` — Owner only\n" +
"`/security snapshot` — Save roles and channels; `/security restore` — Rebuild what a nuke deleted (owner or trusted)\n" +
"`/lockdown channel|server|joins`, `/unlock …` (unlock: owner or trusted)",
},
{
Name: "Meetings (**Manage Server**)",
Value: "" +
"`/meeting add-lobby channel:#voice` — Add a voice lobby that creates meetings (up to 10)\n" +
"`/meeting remove-lobby channel:#voice` — Stop using a voice lobby\n" +
"`/meeting lobbies` — List the voice lobbies\n" +
"`/meeting lock` — Lock your meeting room\n" +
"`/meeting lock-private` — Lock + hide your meeting room\n" +
"`/meeting unlock` — Unlock your meeting room\n" +
"`/meeting invite user:@someone` — Allow a user to join\n" +
"`/meeting uninvite user:@someone` — Remove a user's permission",
},
{
Name: "Moderation (focused tools)",
Value: "" +
"`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" +
"`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" +
"`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" +
"`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" +
"`/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)\n" +
"`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" +
"`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)",
},
{
Name: "Leveling",
Value: "" +
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
"`/slvl set user:@someone level:<n>` — Set user level (**Manage Server**)\n" +
"`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" +
"`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" +
"`/slvl set-reward level:<n> role:@role` — Role rewards (**Manage Server**)\n" +
"`/rank [user]` — Your rank card (server only)\n" +
"`/leaderboard` — Top levels (server only)\n" +
"`/rewards` — List configured rewards (server only)",
},
{
Name: "Twitch live notifications (**Manage Server**)",
Value: "" +
"`/config addstreamer username:<twitch-name>` — Add a streamer\n" +
"`/config removestreamer username:<twitch-name>` — Remove a streamer\n" +
"`/config settwitchnotificationchannel channel:#channel` — Set notification channel",
},
{
Name: "Welcome / onboarding (**Manage Server**)",
Value: "" +
"`/config setwelcomechannel channel:#channel` — Channel for welcome messages\n" +
"`/config setwelcomemessage message:\"...\"` — Welcome text (supports `{user}`, `{server}`)\n" +
"`/config setwelcomedm message:\"...\"` — DM template (supports `{user}`, `{server}`)\n" +
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
"`/config setdefaultrole role:@role` — Role granted to new members",
},
{
Name: "Logging (**Manage Server**)",
Value: "" +
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
"`/config enablelogging` — Enable event/moderation logging\n" +
"`/config disablelogging` — Disable event/moderation logging",
},
{
Name: "Other",
Value: "`/help` — Show this help in DMs",
},
},
Author: &author,
} }
s.ChannelMessageSendEmbed(dmChannel.ID, embed)
} }
+40
View File
@@ -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.")
} }
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"context"
"fmt"
"strconv"
"strings"
"velox-bot/internal/commands/level/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// Looking at the leveling settings and removing a role reward, so nothing
// about leveling needs a dashboard.
func slvlRemoveRewardOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove-reward", Description: "Remove the role reward of a level",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionInteger, Name: "level", Description: "The level", Required: true},
},
}
}
func slvlStatusOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show the leveling settings",
}
}
func slvlRemoveRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) || !shared.RequireLevelSettingsService(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
level := int(shared.SubcommandOptionMap(i)["level"].IntValue())
removed, err := services.Global.LevelSettings.DeleteRoleRewardForLevel(context.Background(), guildID, level)
switch {
case err != nil:
shared.RespondEphemeral(s, i, "Failed to remove the reward.")
case !removed:
shared.RespondEphemeral(s, i, "Level "+strconv.Itoa(level)+" has no role reward.")
default:
shared.RespondEphemeral(s, i, "Removed the reward for level "+strconv.Itoa(level)+".")
}
}
func slvlStatusHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSettingsService(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
ctx := context.Background()
ls := services.Global.LevelSettings
enabled, err := ls.IsLevelSystemEnabled(ctx, guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load the settings.")
return
}
state := "off"
if enabled {
state = "on"
}
var b strings.Builder
fmt.Fprintf(&b, "**Leveling:** %s\n", state)
if ch, ok, _ := ls.GetLevelUpChannelID(ctx, guildID); ok && ch != 0 {
fmt.Fprintf(&b, "**Level-up channel:** <#%d>\n", ch)
} else {
b.WriteString("**Level-up channel:** the channel of the message\n")
}
if msg, err := ls.GetLevelUpMessage(ctx, guildID); err == nil {
fmt.Fprintf(&b, "**Level-up message:** %s\n", msg)
}
rewards, _ := ls.ListRoleRewards(ctx, guildID, 25)
if len(rewards) == 0 {
b.WriteString("**Role rewards:** none")
} else {
b.WriteString("**Role rewards:** ")
for n, r := range rewards {
if n > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "level %d → <@&%d>", r.Level, r.RoleID)
}
}
out := b.String()
if r := []rune(out); len(r) > 1900 {
out = string(r[:1899]) + "…"
}
shared.RespondEphemeral(s, i, out)
}
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"regexp"
"testing"
"unicode/utf8"
"github.com/bwmarrin/discordgo"
)
// Discord refuses a command that is over its limits, and then the whole
// command silently doesn't exist. These checks catch that in a test instead
// of after a deploy. See
// https://discord.com/developers/docs/interactions/application-commands
var commandName = regexp.MustCompile(`^[-_\p{L}\p{N}]{1,32}$`)
func checkOptions(t *testing.T, path string, options []*discordgo.ApplicationCommandOption, size *int) {
t.Helper()
if len(options) > 25 {
t.Errorf("%s: %d options, at most 25 allowed", path, len(options))
}
seen := map[string]bool{}
sawOptional := false
for _, o := range options {
where := path + " " + o.Name
if !commandName.MatchString(o.Name) || o.Name != lower(o.Name) {
t.Errorf("%s: option names are 1 to 32 lowercase letters, digits, - or _", where)
}
if seen[o.Name] {
t.Errorf("%s: duplicate option name", where)
}
seen[o.Name] = true
if n := utf8.RuneCountInString(o.Description); n < 1 || n > 100 {
t.Errorf("%s: description is %d characters, must be 1 to 100", where, n)
}
*size += utf8.RuneCountInString(o.Name) + utf8.RuneCountInString(o.Description)
if len(o.Choices) > 25 {
t.Errorf("%s: %d choices, at most 25", where, len(o.Choices))
}
for _, c := range o.Choices {
*size += utf8.RuneCountInString(c.Name)
if s, ok := c.Value.(string); ok {
*size += utf8.RuneCountInString(s)
if utf8.RuneCountInString(s) > 100 || utf8.RuneCountInString(c.Name) > 100 {
t.Errorf("%s: a choice is over 100 characters", where)
}
}
}
switch o.Type {
case discordgo.ApplicationCommandOptionSubCommand, discordgo.ApplicationCommandOptionSubCommandGroup:
if len(o.Options) == 0 && o.Type == discordgo.ApplicationCommandOptionSubCommandGroup {
t.Errorf("%s: a group needs subcommands", where)
}
checkOptions(t, where, o.Options, size)
default:
// Required options come before optional ones.
if o.Required && sawOptional {
t.Errorf("%s: a required option after an optional one", where)
}
if !o.Required {
sawOptional = true
}
}
}
}
func lower(s string) string {
out := []rune(s)
for i, r := range out {
if r >= 'A' && r <= 'Z' {
out[i] = r + 32
}
}
return string(out)
}
func TestCommandsStayWithinDiscordLimits(t *testing.T) {
all := append(append(append([]*discordgo.ApplicationCommand{}, AllCommands...), BetaCommands...), OwnerCommands...)
names := map[string]bool{}
for _, c := range all {
if names[c.Name] {
t.Errorf("/%s is defined twice", c.Name)
}
names[c.Name] = true
if !commandName.MatchString(c.Name) || c.Name != lower(c.Name) {
t.Errorf("/%s: names are 1 to 32 lowercase letters, digits, - or _", c.Name)
}
if n := utf8.RuneCountInString(c.Description); n < 1 || n > 100 {
t.Errorf("/%s: description is %d characters, must be 1 to 100", c.Name, n)
}
size := utf8.RuneCountInString(c.Name) + utf8.RuneCountInString(c.Description)
checkOptions(t, "/"+c.Name, c.Options, &size)
if size > 8000 {
t.Errorf("/%s: %d characters in total, Discord allows 8000", c.Name, size)
}
if _, ok := handlers[c.Name]; !ok {
t.Errorf("/%s has no handler", c.Name)
}
}
for name := range handlers {
if !names[name] {
t.Errorf("a handler for /%s exists but the command is never registered", name)
}
}
}
@@ -0,0 +1,106 @@
package public
import (
"context"
"fmt"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
// Looking at who is banned and who is timed out, so unbanning and ending a
// timeout never needs a dashboard to find the person first.
func listOptions() []*discordgo.ApplicationCommandOption {
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "bans", Description: "List banned users, to use with /moderation unban"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "timeouts", Description: "List members who are timed out right now"},
}
}
const listShown = 20
func handleBans(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
bans, err := s.GuildBans(i.GuildID, 1000, "", "")
if err != nil {
respondEphemeral(s, i, "I couldn't read the ban list. I need the **Ban Members** permission.")
return
}
if len(bans) == 0 {
respondEphemeral(s, i, "Nobody is banned.")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Banned users (%d)**\n", len(bans))
for n, ban := range bans {
if n == listShown {
fmt.Fprintf(&b, "…and %d more.\n", len(bans)-listShown)
break
}
name := "unknown"
id := ""
if ban.User != nil {
name, id = ban.User.Username, ban.User.ID
}
reason := strings.TrimSpace(ban.Reason)
if reason == "" {
reason = "no reason"
}
fmt.Fprintf(&b, "• %s (`%s`): %s\n", escape(name), id, escape(clip(reason, 80)))
}
b.WriteString("Unban with `/moderation unban user_id:<id>`.")
respondEphemeral(s, i, clip(b.String(), 1950))
}
func handleTimeouts(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
_, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
members, err := s.GuildMembers(i.GuildID, "", 1000)
if err != nil {
respondEphemeral(s, i, "I couldn't read the member list.")
return
}
now := time.Now()
var out []*discordgo.Member
for _, m := range members {
if m.CommunicationDisabledUntil != nil && m.CommunicationDisabledUntil.After(now) {
out = append(out, m)
}
}
if len(out) == 0 {
respondEphemeral(s, i, "Nobody is timed out (among the first 1000 members).")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Timed out (%d)**\n", len(out))
for n, m := range out {
if n == listShown {
fmt.Fprintf(&b, "…and %d more.\n", len(out)-listShown)
break
}
fmt.Fprintf(&b, "• <@%s> until <t:%d:f>\n", m.User.ID, m.CommunicationDisabledUntil.Unix())
}
b.WriteString("End one with `/moderation untimeout`.")
respondEphemeral(s, i, clip(b.String(), 1950))
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// escape keeps names and reasons from becoming markdown or mentions.
func escape(s string) string {
return strings.NewReplacer("`", "'", "*", "", "_", " ", "~", "", "@", "@​", "|", "/").Replace(s)
}
@@ -14,7 +14,7 @@ import (
var Moderation = &discordgo.ApplicationCommand{ var Moderation = &discordgo.ApplicationCommand{
Name: "moderation", Name: "moderation",
Description: "High-value moderation tools", Description: "High-value moderation tools",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "purge", Name: "purge",
@@ -155,7 +155,7 @@ var Moderation = &discordgo.ApplicationCommand{
}, },
}, },
}, },
}, }, listOptions()...),
} }
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -185,6 +185,10 @@ func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleUntimeout(s, i) handleUntimeout(s, i)
case "slowmode": case "slowmode":
handleSlowmode(s, i) handleSlowmode(s, i)
case "bans":
handleBans(s, i)
case "timeouts":
handleTimeouts(s, i)
default: default:
respondEphemeral(s, i, "Unknown subcommand.") respondEphemeral(s, i, "Unknown subcommand.")
} }
+126
View File
@@ -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)
}
+21 -9
View File
@@ -10,14 +10,14 @@ import (
var Music = &discordgo.ApplicationCommand{ var Music = &discordgo.ApplicationCommand{
Name: "music", Name: "music",
Description: "Music settings", Description: "Music settings and player controls",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "toggle", Name: "toggle",
Description: "Turn the music player on or off for this server", Description: "Turn the music player on or off for this server (Manage Server)",
}, },
}, }, playerOptions()...),
} }
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -25,9 +25,6 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
shared.RespondEphemeral(s, i, "This command can only be used in a server.") shared.RespondEphemeral(s, i, "This command can only be used in a server.")
return return
} }
if !shared.RequireManageGuild(s, i) {
return
}
if !shared.RequireMusicService(s, i) { if !shared.RequireMusicService(s, i) {
return return
} }
@@ -37,11 +34,26 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
shared.RespondEphemeral(s, i, "Missing subcommand.") shared.RespondEphemeral(s, i, "Missing subcommand.")
return return
} }
sub := data.Options[0]
switch data.Options[0].Name { if sub.Name == "toggle" {
case "toggle": if !shared.RequireManageGuild(s, i) {
return
}
handleToggle(s, i) handleToggle(s, i)
return
} }
// Every other subcommand controls the player: music has to be on and the
// member needs the DJ role, like /play, /queue and /volume.
if !shared.RequireMusicEnabled(s, i) {
return
}
if !memberHasDJRole(s, i.GuildID, i.Member) {
shared.RespondEphemeral(s, i, "You need the **DJ** role to use music commands.")
return
}
handlePlayer(s, i, sub)
} }
func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -43,7 +43,7 @@ var Security = &discordgo.ApplicationCommand{
Description: "Anti-raid and anti-nuke protection", Description: "Anti-raid and anti-nuke protection",
DefaultMemberPermissions: &manageGuild, DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly, Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"},
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off", Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off",
@@ -94,7 +94,7 @@ var Security = &discordgo.ApplicationCommand{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
}, }, tuneOptions()...),
} }
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption { func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
@@ -211,6 +211,10 @@ func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout) ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel() defer cancel()
if handleTune(ctx, s, i, e, sub) {
return
}
switch sub.Name { switch sub.Name {
case "status": case "status":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) { if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
@@ -434,7 +438,7 @@ func status(ctx context.Context, e *security.Engine, guildID string) string {
fmt.Fprintf(&b, ", <@%d>", t.TargetID) fmt.Fprintf(&b, ", <@%d>", t.TargetID)
} }
} }
b.WriteString("\nFine-tune filters, limits and actions on the dashboard.") b.WriteString("\nSee every setting with `/security settings`, and change them with `/security joingate`, `words`, `raid`, `antinuke` and `heat`.")
return b.String() return b.String()
} }
+377
View File
@@ -0,0 +1,377 @@
package public
import (
"context"
"fmt"
"sort"
"strings"
"velox-bot/internal/db/repos/securityrepo"
"velox-bot/internal/security"
"github.com/bwmarrin/discordgo"
)
// The commands that tune the Join Gate, raid mode, anti-nuke and heat. They
// reach the same settings a dashboard would, so a server never needs one.
// Changing them needs the server owner or someone on the trusted list; that is
// enforced by the security engine, not by Discord's command permissions.
func intOption(name, desc string, min, max int) *discordgo.ApplicationCommandOption {
lo := float64(min)
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionInteger, Name: name, Description: desc,
MinValue: &lo, MaxValue: float64(max),
}
}
func boolOption(name, desc string) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionBoolean, Name: name, Description: desc}
}
func choiceOption(name, desc string, required bool, choices ...string) *discordgo.ApplicationCommandOption {
o := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString, Name: name, Description: desc, Required: required}
for _, c := range choices {
o.Choices = append(o.Choices, &discordgo.ApplicationCommandOptionChoice{Name: c, Value: c})
}
return o
}
func sub(name, desc string, options ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: name, Description: desc, Options: options}
}
func group(name, desc string, subs ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: name, Description: desc, Options: subs}
}
func required(o *discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
o.Required = true
return o
}
var nukeLimitChoices = []string{
securityrepo.LimitChannelDelete, securityrepo.LimitChannelCreate, securityrepo.LimitRoleDelete, securityrepo.LimitRoleCreate,
securityrepo.LimitBan, securityrepo.LimitKick, securityrepo.LimitWebhookCreate,
}
// Which Join Gate check an option name stands for.
var joinGateChecks = []string{"no-avatar", "new-account", "invite-in-name", "blocked-names", "unverified-bot", "unauthorized-bot"}
// tuneOptions are the subcommands added to /security.
func tuneOptions() []*discordgo.ApplicationCommandOption {
acts := []string{securityrepo.ActionOff, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan}
lists := []string{string(security.UsernameWords), string(security.ChatWords)}
listOpt := func() *discordgo.ApplicationCommandOption {
return choiceOption("list", "username: new members' names (Join Gate). chat: messages (heat)", true, lists...)
}
wordOpt := func() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString, Name: "word", Description: "A word or phrase, matched as plain text", Required: true, MaxLength: 50}
}
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings", Description: "Show every security setting"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "history", Description: "Show the latest security events (raids, quarantines, setting changes)"},
group("joingate", "Tune the Join Gate (owner or trusted)",
sub("check", "Choose what happens when a check catches a new member",
choiceOption("check", "Which check", true, joinGateChecks...),
choiceOption("action", "What to do. Bot checks accept off, kick or ban", true, acts...)),
sub("account-age", "How old an account must be to pass the new-account check",
required(intOption("days", "Days, 1 to 365", 1, 365)))),
group("words", "Blocked words (owner or trusted)",
sub("add", "Block a word", listOpt(), wordOpt()),
sub("remove", "Stop blocking a word", listOpt(), wordOpt()),
sub("list", "Show the blocked words", listOpt())),
sub("raid", "Tune raid mode (owner or trusted). Leave out what you don't change",
intOption("join-count", "Joins that start a raid, 3 to 100", 3, 100),
intOption("join-seconds", "Within this many seconds, 5 to 300", 5, 300),
choiceOption("action", "What happens to raiders", false, securityrepo.ActionAlert, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan),
boolOption("pause-invites", "Pause server invites during a raid"),
intOption("quiet-minutes", "Minutes without a join before the raid ends, 1 to 120", 1, 120)),
group("antinuke", "Tune anti-nuke (owner or trusted)",
sub("set", "Leave out what you don't change",
intOption("window-seconds", "Actions are counted within this many seconds, 5 to 300", 5, 300),
boolOption("lock-server", "Also lock every channel when a nuke is stopped"),
boolOption("auto-restore", "Restore deleted roles and channels after a stopped nuke")),
sub("limit", "How many times someone may do an action within the window (0 means never)",
choiceOption("action", "Which action", true, nukeLimitChoices...),
required(intOption("count", "0 to 100", 0, 100)))),
sub("heat", "Tune the spam heat system (owner or trusted). Leave out what you don't change",
choiceOption("strictness", "How fast heat builds", false, "low", "medium", "high"),
choiceOption("action", "What happens at maximum heat", false, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan),
intOption("timeout-minutes", "Timeout length, 1 to 10080", 1, 10080),
boolOption("escalate", "Double the timeout for repeat offences"),
intOption("panic-members", "Members maxing out in a minute that start heat panic, 0 (off) to 50", 0, 50),
intOption("mention-lockdown", "Mentions in 30 seconds that lock the server, 0 (off) or 5 to 1000", 0, 1000)),
sub("alert-dms", "Send critical alerts to the owner and trusted users too (owner or trusted)",
required(boolOption("on", "On or off"))),
}
}
func intArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *int {
if a, ok := args[name]; ok {
v := int(a.IntValue())
return &v
}
return nil
}
func boolArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *bool {
if a, ok := args[name]; ok {
v := a.BoolValue()
return &v
}
return nil
}
func strArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *string {
if a, ok := args[name]; ok {
v := a.StringValue()
return &v
}
return nil
}
// handleTune runs the tuning subcommands. It reports whether it handled name.
func handleTune(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, subcmd *discordgo.ApplicationCommandInteractionDataOption) bool {
args := opts(subcmd.Options)
switch subcmd.Name {
case "settings":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security settings.")
return true
}
st, _, err := e.Settings(ctx, i.GuildID)
if err != nil {
reply(s, i, message("settings", err))
return true
}
reply(s, i, describeSettings(st))
case "history":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security history.")
return true
}
events, err := e.Events(ctx, i.GuildID, 15)
if err != nil {
reply(s, i, message("history", err))
return true
}
if len(events) == 0 {
reply(s, i, "Nothing has happened yet.")
return true
}
var b strings.Builder
for _, ev := range events {
fmt.Fprintf(&b, "<t:%d:R> **%s**", ev.CreatedAt.Unix(), ev.Kind)
if ev.ActorID != 0 {
fmt.Fprintf(&b, " by <@%d>", ev.ActorID)
}
if ev.TargetID != 0 {
fmt.Fprintf(&b, " on <@%d>", ev.TargetID)
}
if ev.Detail != "" {
fmt.Fprintf(&b, ": %s", clip(ev.Detail, 120))
}
b.WriteString("\n")
}
reply(s, i, clip(b.String(), 1950))
case "joingate":
if len(subcmd.Options) == 0 {
return true
}
inner := subcmd.Options[0]
innerArgs := opts(inner.Options)
t := securityrepo.Tuning{}
var summary, done string
switch inner.Name {
case "check":
check, action := innerArgs["check"].StringValue(), innerArgs["action"].StringValue()
switch check {
case "no-avatar":
t.NoAvatar = &action
case "new-account":
t.NewAccount = &action
case "invite-in-name":
t.AdvertisingName = &action
case "blocked-names":
t.UsernameFilter = &action
case "unverified-bot":
t.UnverifiedBot = &action
case "unauthorized-bot":
t.UnauthorizedBot = &action
}
summary = "joingate " + check + " " + action
done = fmt.Sprintf("The **%s** check is now **%s**. The Join Gate itself is switched with `/security toggle`.", check, action)
case "account-age":
days := int(innerArgs["days"].IntValue())
t.MinAccountAgeDays = &days
summary = fmt.Sprintf("joingate minimum account age %d days", days)
done = fmt.Sprintf("Accounts younger than **%d days** are caught by the new-account check.", days)
default:
return true
}
finishTune(ctx, s, i, e, t, summary, done)
case "words":
if len(subcmd.Options) == 0 {
return true
}
handleWords(ctx, s, i, e, subcmd.Options[0])
case "raid":
t := securityrepo.Tuning{
RaidJoinCount: intArg(args, "join-count"), RaidJoinSeconds: intArg(args, "join-seconds"), RaidAction: strArg(args, "action"),
RaidPauseInvites: boolArg(args, "pause-invites"), RaidQuietMinutes: intArg(args, "quiet-minutes"),
}
if len(args) == 0 {
reply(s, i, "Say what to change: join-count, join-seconds, action, pause-invites or quiet-minutes.")
return true
}
finishTune(ctx, s, i, e, t, "raid settings changed", "Raid settings saved.\n"+raidLine(ctx, e, i.GuildID, t))
case "antinuke":
if len(subcmd.Options) == 0 {
return true
}
inner := subcmd.Options[0]
innerArgs := opts(inner.Options)
t := securityrepo.Tuning{}
var summary, done string
switch inner.Name {
case "set":
if len(innerArgs) == 0 {
reply(s, i, "Say what to change: window-seconds, lock-server or auto-restore.")
return true
}
t.AntiNukeWindowSeconds, t.AntiNukeLockServer, t.AntiNukeAutoRestore = intArg(innerArgs, "window-seconds"), boolArg(innerArgs, "lock-server"), boolArg(innerArgs, "auto-restore")
summary, done = "antinuke settings changed", "Anti-nuke settings saved. See them with `/security settings`."
case "limit":
action, count := innerArgs["action"].StringValue(), int(innerArgs["count"].IntValue())
t.AntiNukeLimits = map[string]int{action: count}
summary = fmt.Sprintf("antinuke limit %s = %d", action, count)
if count == 0 {
done = fmt.Sprintf("**%s** is never allowed for anyone who isn't the owner or trusted.", action)
} else {
done = fmt.Sprintf("**%s** is limited to **%d** per window.", action, count)
}
default:
return true
}
finishTune(ctx, s, i, e, t, summary, done)
case "heat":
if len(args) == 0 {
reply(s, i, "Say what to change: strictness, action, timeout-minutes, escalate, panic-members or mention-lockdown.")
return true
}
t := securityrepo.Tuning{
HeatStrictness: strArg(args, "strictness"), HeatAction: strArg(args, "action"), HeatTimeoutMinutes: intArg(args, "timeout-minutes"),
HeatEscalate: boolArg(args, "escalate"), HeatPanicMembers: intArg(args, "panic-members"), HeatLockdownMentions: intArg(args, "mention-lockdown"),
}
finishTune(ctx, s, i, e, t, "heat settings changed", "Heat settings saved. See them with `/security settings`.")
case "alert-dms":
on := args["on"].BoolValue()
state := "off"
if on {
state = "on"
}
finishTune(ctx, s, i, e, securityrepo.Tuning{AlertDM: &on}, "alert DMs "+state, "Critical alerts by DM are now **"+state+"**.")
default:
return false
}
return true
}
func finishTune(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, t securityrepo.Tuning, summary, done string) {
if err := e.Tune(ctx, i.GuildID, i.Member, t, summary); err != nil {
reply(s, i, message("tune", err))
return
}
reply(s, i, done)
}
func raidLine(ctx context.Context, e *security.Engine, guildID string, _ securityrepo.Tuning) string {
st, _, err := e.Settings(ctx, guildID)
if err != nil {
return ""
}
return fmt.Sprintf("%d joins within %ds start a raid, raiders get **%s**, it ends after %d quiet minutes (invites %s).",
st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction, st.RaidQuietMinutes, pausedText(st.RaidPauseInvites))
}
func pausedText(on bool) string {
if on {
return "are paused"
}
return "stay open"
}
func handleWords(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, inner *discordgo.ApplicationCommandInteractionDataOption) {
args := opts(inner.Options)
list := security.WordList(args["list"].StringValue())
what := map[security.WordList]string{security.UsernameWords: "new members' names", security.ChatWords: "messages"}[list]
switch inner.Name {
case "list":
st, _, err := e.Settings(ctx, i.GuildID)
if err != nil {
reply(s, i, message("words", err))
return
}
words := st.UsernamePatterns
if list == security.ChatWords {
words = st.HeatBlockedWords
}
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the blocked words.")
return
}
if len(words) == 0 {
reply(s, i, "No words are blocked in "+what+".")
return
}
reply(s, i, fmt.Sprintf("Blocked in %s (%d): %s", what, len(words), clip("`"+strings.Join(words, "`, `")+"`", 1700)))
case "add", "remove":
word := args["word"].StringValue()
words, err := e.EditWords(ctx, i.GuildID, i.Member, list, inner.Name == "add", word)
if err != nil {
reply(s, i, message("words", err))
return
}
verb := "is now blocked in"
if inner.Name == "remove" {
verb = "is no longer blocked in"
}
reply(s, i, fmt.Sprintf("`%s` %s %s. %d word(s) on the list.", strings.ToLower(strings.TrimSpace(word)), verb, what, len(words)))
}
}
// describeSettings lists every setting, for /security settings.
func describeSettings(st *securityrepo.Settings) string {
var b strings.Builder
fmt.Fprintf(&b, "**Join Gate** %s\n", onOff(st.JoinGateEnabled))
fmt.Fprintf(&b, "No avatar: %s · New account (under %d days): %s · Invite in name: %s · Blocked names: %s (%d words)\n",
st.NoAvatar, st.MinAccountAgeDays, st.NewAccount, st.AdvertisingName, st.UsernameFilter, len(st.UsernamePatterns))
fmt.Fprintf(&b, "Unverified bots: %s · Bots added by someone untrusted: %s\n\n", st.UnverifiedBot, st.UnauthorizedBot)
fmt.Fprintf(&b, "**Raid mode** %s\n%d joins in %ds → %s · ends after %d quiet minutes · invites %s\n\n",
onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction, st.RaidQuietMinutes, pausedText(st.RaidPauseInvites))
fmt.Fprintf(&b, "**Anti-nuke** %s\nWindow %ds · lock server on a nuke: %s · auto restore: %s\n",
onOff(st.AntiNukeEnabled), st.AntiNukeWindowSeconds, onOff(st.AntiNukeLockServer), onOff(st.AntiNukeAutoRestore))
keys := make([]string, 0, len(st.AntiNukeLimits))
for k := range st.AntiNukeLimits {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s %d", k, st.AntiNukeLimits[k]))
}
fmt.Fprintf(&b, "Limits: %s\n\n", strings.Join(parts, " · "))
mention := "off"
if st.HeatLockdownMentions > 0 {
mention = fmt.Sprintf("%d mentions", st.HeatLockdownMentions)
}
panicMembers := "off"
if st.HeatPanicMembers > 0 {
panicMembers = fmt.Sprintf("%d members", st.HeatPanicMembers)
}
fmt.Fprintf(&b, "**Heat** %s\nStrictness %s · action %s (%d min timeout, escalating %s) · panic at %s · mention flood lockdown at %s · %d blocked words\n\n",
onOff(st.HeatEnabled), st.HeatStrictness, st.HeatAction, st.HeatTimeoutMinutes, onOff(st.HeatEscalate), panicMembers, mention, len(st.HeatBlockedWords))
fmt.Fprintf(&b, "**Alerts** by DM to the owner and trusted: %s", onOff(st.AlertDM))
return clip(b.String(), 1990)
}
@@ -0,0 +1,247 @@
package public
import (
"context"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// The rest of the ticket configuration: the on/off switch, the panel's text,
// the open ticket limit, editing a category, and looking at tickets. All of it
// is available from Discord, so a dashboard is never needed.
func ticketSettingsOptions() []*discordgo.ApplicationCommandOption {
lo, hi := 1.0, 10.0
return []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings",
Description: "Show the ticket settings, or change them. Leave out what you don't change",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "Turn tickets on or off"},
{Type: discordgo.ApplicationCommandOptionString, Name: "panel-title", Description: "The panel's title", MaxLength: 256},
{Type: discordgo.ApplicationCommandOptionString, Name: "panel-description", Description: "The panel's text", MaxLength: 4000},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "max-open", Description: "Open tickets one member can have, 1 to 10", MinValue: &lo, MaxValue: hi},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list",
Description: "List recent tickets",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString, Name: "status", Description: "Open or closed (default: open)",
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "open", Value: ticketrepo.StatusOpen}, {Name: "closed", Value: ticketrepo.StatusClosed}},
},
},
},
}
}
func categoryEditOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "edit",
Description: "Change a category. Leave out what you don't change",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category to change", Required: true},
{Type: discordgo.ApplicationCommandOptionString, Name: "new-name", Description: "A new name", MaxLength: 40},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "clear-staff-role", Description: "Remove the staff role"},
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button. Use - to remove", MaxLength: 64},
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel. Use - to remove", MaxLength: 200},
},
}
}
func onOff(b bool) string {
if b {
return "on"
}
return "off"
}
func handleSettings(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
if len(args) == 0 {
st, err := Repo.GetSettings(ctx, g)
if err != nil {
reply(s, i, message("settings", err))
return
}
where := "not set (use `/tickets setup`)"
if st.ChannelID != 0 {
where = fmt.Sprintf("<#%d>", st.ChannelID)
}
logs := "none"
if st.LogChannelID != 0 {
logs = fmt.Sprintf("<#%d>", st.LogChannelID)
}
reply(s, i, fmt.Sprintf("**Tickets:** %s\n**Channel:** %s · **Log channel:** %s\n**Open tickets per member:** %d\n**Panel title:** %s\n**Panel text:** %s",
onOff(st.Enabled), where, logs, st.MaxOpenPerUser, clipText(st.PanelTitle, 200), clipText(st.PanelDescription, 600)))
return
}
var p ticketrepo.SettingsPatch
if a, ok := args["enabled"]; ok {
v := a.BoolValue()
p.Enabled = &v
}
if a, ok := args["panel-title"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "" || utf8.RuneCountInString(v) > 256 {
reply(s, i, "The panel title must be between 1 and 256 characters.")
return
}
p.PanelTitle = &v
}
if a, ok := args["panel-description"]; ok {
v := strings.TrimSpace(a.StringValue())
if utf8.RuneCountInString(v) > 4000 {
reply(s, i, "The panel text can be at most 4000 characters.")
return
}
p.PanelDescription = &v
}
if a, ok := args["max-open"]; ok {
v := int(a.IntValue())
if v < 1 || v > 10 {
reply(s, i, "Open tickets per member must be between 1 and 10.")
return
}
p.MaxOpenPerUser = &v
}
if err := Repo.UpdateSettings(ctx, g, p); err != nil {
reply(s, i, message("settings", err))
return
}
msg := "Saved."
if p.PanelTitle != nil || p.PanelDescription != nil {
msg += " Run `/tickets panel` to update the panel with the new text."
}
if p.Enabled != nil {
msg += " Tickets are now **" + onOff(*p.Enabled) + "**."
}
reply(s, i, msg)
}
func clipText(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
func handleCategoryEdit(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
name := strings.TrimSpace(args["name"].StringValue())
cats, err := Repo.ListCategories(ctx, g)
if err != nil {
reply(s, i, message("category edit", err))
return
}
var target *ticketrepo.Category
for idx := range cats {
if strings.EqualFold(cats[idx].Name, name) {
target = &cats[idx]
}
}
if target == nil {
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
return
}
var p ticketrepo.CategoryPatch
if a, ok := args["new-name"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "" || utf8.RuneCountInString(v) > 40 {
reply(s, i, "The category name must be between 1 and 40 characters.")
return
}
for _, c := range cats {
if c.ID != target.ID && strings.EqualFold(c.Name, v) {
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
return
}
}
p.Name = &v
}
if a, ok := args["staff-role"]; ok {
id, _ := strconv.ParseInt(a.RoleValue(nil, i.GuildID).ID, 10, 64)
p.StaffRoleID = &id
}
if a, ok := args["clear-staff-role"]; ok && a.BoolValue() {
if p.StaffRoleID != nil {
reply(s, i, "Choose either a staff role or clearing it, not both.")
return
}
zero := int64(0)
p.StaffRoleID = &zero
}
if a, ok := args["emoji"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "-" {
v = ""
}
p.Emoji = &v
}
if a, ok := args["description"]; ok {
v := strings.TrimSpace(a.StringValue())
if v == "-" {
v = ""
}
if utf8.RuneCountInString(v) > 200 {
reply(s, i, "The category description can be at most 200 characters.")
return
}
p.Description = &v
}
if p == (ticketrepo.CategoryPatch{}) {
reply(s, i, "Say what to change: new-name, staff-role, clear-staff-role, emoji or description.")
return
}
if _, err := Repo.UpdateCategory(ctx, g, target.ID, p); err != nil {
reply(s, i, message("category edit", err))
return
}
reply(s, i, fmt.Sprintf("Updated **%s**. Run `/tickets panel` to update the panel.", target.Name))
}
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
status := ticketrepo.StatusOpen
if a, ok := opts(o)["status"]; ok {
status = a.StringValue()
}
ts, err := Repo.ListTickets(ctx, guildID(i), status, 15)
if err != nil {
reply(s, i, message("list", err))
return
}
if len(ts) == 0 {
reply(s, i, "No "+status+" tickets.")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Latest %s tickets**\n", status)
for _, t := range ts {
fmt.Fprintf(&b, "• #%d <#%d> %s, by <@%d>", t.Number, t.ThreadID, clipText(t.CategoryName, 30), t.OpenerID)
if t.ClaimedBy != 0 {
fmt.Fprintf(&b, ", claimed by <@%d>", t.ClaimedBy)
}
b.WriteString("\n")
}
reply(s, i, clipText(b.String(), 1900))
}
+9 -2
View File
@@ -41,7 +41,7 @@ var Tickets = &discordgo.ApplicationCommand{
Description: "Set up support tickets", Description: "Set up support tickets",
DefaultMemberPermissions: &manageGuild, DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly, Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setup", Name: "setup",
@@ -77,6 +77,7 @@ var Tickets = &discordgo.ApplicationCommand{
}, },
}, },
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
categoryEditOption(),
}, },
}, },
{ {
@@ -84,7 +85,7 @@ var Tickets = &discordgo.ApplicationCommand{
Name: "panel", Name: "panel",
Description: "Post the ticket panel, or update it after changing categories", Description: "Post the ticket panel, or update it after changing categories",
}, },
}, }, ticketSettingsOptions()...),
} }
// Ticket is used inside a ticket thread. // Ticket is used inside a ticket thread.
@@ -166,6 +167,10 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleSetup(s, i, sub.Options) handleSetup(s, i, sub.Options)
case "panel": case "panel":
handlePanel(s, i) handlePanel(s, i)
case "settings":
handleSettings(s, i, sub.Options)
case "list":
handleList(s, i, sub.Options)
case "category": case "category":
if len(sub.Options) == 0 { if len(sub.Options) == 0 {
return return
@@ -177,6 +182,8 @@ func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleCategoryRemove(s, i, sub.Options[0].Options) handleCategoryRemove(s, i, sub.Options[0].Options)
case "list": case "list":
handleCategoryList(s, i) handleCategoryList(s, i)
case "edit":
handleCategoryEdit(s, i, sub.Options[0].Options)
} }
} }
} }
+163 -5
View File
@@ -8,6 +8,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"strconv"
"strings"
"time" "time"
) )
@@ -197,6 +199,138 @@ func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) erro
return r.update(ctx, guildID, `join_lock = $2`, mode) return r.update(ctx, guildID, `join_lock = $2`, mode)
} }
// Tuning is a partial update of a server's thresholds, filters and actions.
// A nil field is left as it is. The security package validates it before it
// gets here; the column constraints are the last line of defence.
type Tuning struct {
AlertDM *bool
NoAvatar *string
NewAccount *string
MinAccountAgeDays *int
AdvertisingName *string
UsernameFilter *string
UsernamePatterns *[]string
UnverifiedBot *string
UnauthorizedBot *string
RaidJoinCount *int
RaidJoinSeconds *int
RaidAction *string
RaidPauseInvites *bool
RaidQuietMinutes *int
AntiNukeWindowSeconds *int
AntiNukeLimits map[string]int // only the keys present change
AntiNukeLockServer *bool
AntiNukeAutoRestore *bool
HeatStrictness *string
HeatAction *string
HeatTimeoutMinutes *int
HeatEscalate *bool
HeatBlockedWords *[]string
HeatPanicMembers *int
HeatLockdownMentions *int
}
// UpdateTuning applies the fields that are set, in one statement.
func (r *Repo) UpdateTuning(ctx context.Context, guildID int64, t Tuning) error {
var sets []string
args := []any{}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)+1)) // $1 is the guild
}
addList := func(col string, v []string) {
b, _ := json.Marshal(v)
args = append(args, string(b))
sets = append(sets, col+` = ARRAY(SELECT jsonb_array_elements_text($`+strconv.Itoa(len(args)+1)+`::jsonb))`)
}
if t.AlertDM != nil {
add("alert_dm", *t.AlertDM)
}
if t.NoAvatar != nil {
add("joingate_no_avatar", *t.NoAvatar)
}
if t.NewAccount != nil {
add("joingate_new_account", *t.NewAccount)
}
if t.MinAccountAgeDays != nil {
add("joingate_min_account_age_days", *t.MinAccountAgeDays)
}
if t.AdvertisingName != nil {
add("joingate_advertising_name", *t.AdvertisingName)
}
if t.UsernameFilter != nil {
add("joingate_username_filter", *t.UsernameFilter)
}
if t.UsernamePatterns != nil {
addList("joingate_username_patterns", *t.UsernamePatterns)
}
if t.UnverifiedBot != nil {
add("joingate_unverified_bot", *t.UnverifiedBot)
}
if t.UnauthorizedBot != nil {
add("joingate_unauthorized_bot", *t.UnauthorizedBot)
}
if t.RaidJoinCount != nil {
add("raid_join_count", *t.RaidJoinCount)
}
if t.RaidJoinSeconds != nil {
add("raid_join_seconds", *t.RaidJoinSeconds)
}
if t.RaidAction != nil {
add("raid_action", *t.RaidAction)
}
if t.RaidPauseInvites != nil {
add("raid_pause_invites", *t.RaidPauseInvites)
}
if t.RaidQuietMinutes != nil {
add("raid_quiet_minutes", *t.RaidQuietMinutes)
}
if t.AntiNukeWindowSeconds != nil {
add("antinuke_window_seconds", *t.AntiNukeWindowSeconds)
}
if len(t.AntiNukeLimits) > 0 {
b, _ := json.Marshal(t.AntiNukeLimits)
args = append(args, string(b))
// Merge into what is saved, so setting one limit keeps the others.
sets = append(sets, `antinuke_limits = antinuke_limits || $`+strconv.Itoa(len(args)+1)+`::jsonb`)
}
if t.AntiNukeLockServer != nil {
add("antinuke_lock_server", *t.AntiNukeLockServer)
}
if t.AntiNukeAutoRestore != nil {
add("antinuke_auto_restore", *t.AntiNukeAutoRestore)
}
if t.HeatStrictness != nil {
add("heat_strictness", *t.HeatStrictness)
}
if t.HeatAction != nil {
add("heat_action", *t.HeatAction)
}
if t.HeatTimeoutMinutes != nil {
add("heat_timeout_minutes", *t.HeatTimeoutMinutes)
}
if t.HeatEscalate != nil {
add("heat_escalate", *t.HeatEscalate)
}
if t.HeatBlockedWords != nil {
addList("heat_blocked_words", *t.HeatBlockedWords)
}
if t.HeatPanicMembers != nil {
add("heat_panic_members", *t.HeatPanicMembers)
}
if t.HeatLockdownMentions != nil {
add("heat_lockdown_mentions", *t.HeatLockdownMentions)
}
if len(sets) == 0 {
return nil
}
return r.update(ctx, guildID, strings.Join(sets, ", "), args...)
}
// ---- Trusted ---- // ---- Trusted ----
type Trusted struct { type Trusted struct {
@@ -375,11 +509,12 @@ func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) err
// ---- Events ---- // ---- Events ----
type Event struct { type Event struct {
GuildID int64 GuildID int64
Kind string Kind string
ActorID int64 ActorID int64
TargetID int64 TargetID int64
Detail string Detail string
CreatedAt time.Time // set when read
} }
// Event kinds. // Event kinds.
@@ -582,3 +717,26 @@ func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) (
} }
return out, rows.Err() return out, rows.Err()
} }
// ListEvents returns a server's most recent security events, newest first.
func (r *Repo) ListEvents(ctx context.Context, guildID int64, limit int) ([]Event, error) {
if limit < 1 || limit > 100 {
limit = 20
}
rows, err := r.db.QueryContext(ctx, `
SELECT kind, COALESCE(actor_id, 0), COALESCE(target_id, 0), detail, created_at
FROM security_events WHERE guild_id = $1 ORDER BY id DESC LIMIT $2`, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Event
for rows.Next() {
e := Event{GuildID: guildID}
if err := rows.Scan(&e.Kind, &e.ActorID, &e.TargetID, &e.Detail, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
@@ -229,3 +229,27 @@ func TestRaidsSnapshotsAndHeatSettings(t *testing.T) {
t.Error("with a zero max age every anti-nuke guild is due") t.Error("with a zero max age every anti-nuke guild is due")
} }
} }
func TestListEvents(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
for _, kind := range []string{"settings", "raid_started", "quarantine"} {
if err := r.AddEvent(ctx, Event{GuildID: g, Kind: kind, ActorID: 5, Detail: "d"}); err != nil {
t.Fatal(err)
}
}
if err := r.AddEvent(ctx, Event{GuildID: g + 1, Kind: "other server"}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = r.db.Exec(`DELETE FROM security_events WHERE guild_id = $1`, g+1) })
got, err := r.ListEvents(ctx, g, 2)
if err != nil || len(got) != 2 || got[0].Kind != "quarantine" || got[1].Kind != "raid_started" {
t.Fatalf("newest first, limited: %+v %v", got, err)
}
if got[0].CreatedAt.IsZero() || got[0].ActorID != 5 || got[0].TargetID != 0 {
t.Errorf("fields: %+v", got[0])
}
if all, _ := r.ListEvents(ctx, g, 0); len(all) != 3 {
t.Errorf("only this server's events: %v", all)
}
}
@@ -256,3 +256,66 @@ func TestBetaRegistered(t *testing.T) {
t.Error("a bad ID is refused") t.Error("a bad ID is refused")
} }
} }
func TestBetaSettingsFromCommands(t *testing.T) {
r, _ := testRepo(t)
ctx := context.Background()
var wasG, wasR sql.NullInt64
hadRow := r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil
t.Cleanup(func() {
if hadRow {
_, _ = r.db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR)
} else {
_, _ = r.db.ExecContext(ctx, `DELETE FROM beta_settings`)
}
_ = r.SetBetaGuild(ctx, 8800000000000007171, 1, false)
})
if err := r.SetBetaServer(ctx, 700000000000000001, 800000000000000002, 5); err != nil {
t.Fatal(err)
}
g, role, err := r.GetBetaSettings(ctx)
if err != nil || g != "700000000000000001" || role != "800000000000000002" {
t.Fatalf("settings: %q %q %v", g, role, err)
}
if err := r.SetBetaServer(ctx, 700000000000000001, 0, 5); err != nil {
t.Fatal(err)
}
if g, role, _ = r.GetBetaSettings(ctx); g != "700000000000000001" || role != "" {
t.Errorf("a zero role clears the role and keeps the server: %q %q", g, role)
}
const guild = 8800000000000007171
for i := 0; i < 2; i++ {
if err := r.SetBetaGuild(ctx, guild, 9, true); err != nil {
t.Fatal(err)
}
}
if on, _ := r.IsBetaGuild(ctx, guild); !on {
t.Error("opted in")
}
if err := r.SetBetaGuild(ctx, guild, 9, false); err != nil {
t.Fatal(err)
}
if on, _ := r.IsBetaGuild(ctx, guild); on {
t.Error("opted out")
}
}
func TestDeleteRoleReward(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if err := r.SetRoleRewardForLevel(ctx, g, 5, 77); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = r.db.ExecContext(ctx, `DELETE FROM levelrewards WHERE guild_id = $1`, g) })
if removed, err := r.DeleteRoleRewardForLevel(ctx, g, 6); err != nil || removed {
t.Errorf("nothing at level 6: %v %v", removed, err)
}
if removed, err := r.DeleteRoleRewardForLevel(ctx, g, 5); err != nil || !removed {
t.Errorf("remove level 5: %v %v", removed, err)
}
if _, ok, _ := r.GetRoleRewardForLevel(ctx, g, 5); ok {
t.Error("the reward is gone")
}
}
+40
View File
@@ -475,3 +475,43 @@ func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered
} }
return err return err
} }
// SetBetaServer saves the beta server and the Beta Tester role (0 clears the
// role, which turns beta features off for everyone).
func (r *Repo) SetBetaServer(ctx context.Context, guildID, roleID, updatedBy int64) error {
const q = `
INSERT INTO beta_settings (id, guild_id, role_id, updated_by, updated_at)
VALUES (TRUE, $1, $2, $3, NOW())
ON CONFLICT (id) DO UPDATE SET guild_id = EXCLUDED.guild_id, role_id = EXCLUDED.role_id,
updated_by = EXCLUDED.updated_by, updated_at = NOW()`
_, err := r.db.ExecContext(ctx, q, guildID, sql.NullInt64{Int64: roleID, Valid: roleID != 0}, updatedBy)
return err
}
// SetBetaGuild opts a server in or out of beta features.
func (r *Repo) SetBetaGuild(ctx context.Context, guildID, enabledBy int64, enabled bool) error {
if enabled {
_, err := r.db.ExecContext(ctx, `INSERT INTO beta_guilds (guild_id, enabled_by) VALUES ($1, $2) ON CONFLICT (guild_id) DO NOTHING`, guildID, enabledBy)
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, guildID)
return err
}
// IsBetaGuild reports whether a server opted in to beta features.
func (r *Repo) IsBetaGuild(ctx context.Context, guildID int64) (bool, error) {
var on bool
err := r.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM beta_guilds WHERE guild_id = $1)`, guildID).Scan(&on)
return on, err
}
// DeleteRoleRewardForLevel removes the role reward of a level and reports
// whether there was one.
func (r *Repo) DeleteRoleRewardForLevel(ctx context.Context, guildID int64, level int) (bool, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM levelrewards WHERE guild_id = $1 AND levelreq = $2`, guildID, level)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
+109
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"strconv"
"strings"
"time" "time"
) )
@@ -301,3 +303,110 @@ func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id) _, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
return err return err
} }
// SettingsPatch changes ticket settings. A nil field is left as it is.
type SettingsPatch struct {
Enabled *bool
PanelTitle *string
PanelDescription *string
MaxOpenPerUser *int
}
// UpdateSettings applies the fields that are set. The server gets a settings
// row if it has none yet, with everything else at its default and the system
// still off, until /tickets setup or this turns it on.
func (r *Repo) UpdateSettings(ctx context.Context, guildID int64, p SettingsPatch) error {
if _, err := r.db.ExecContext(ctx, `INSERT INTO ticket_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID); err != nil {
return err
}
var sets []string
args := []any{guildID}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)))
}
if p.Enabled != nil {
add("enabled", *p.Enabled)
}
if p.PanelTitle != nil {
add("panel_title", *p.PanelTitle)
}
if p.PanelDescription != nil {
add("panel_description", *p.PanelDescription)
}
if p.MaxOpenPerUser != nil {
add("max_open_per_user", *p.MaxOpenPerUser)
}
if len(sets) == 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `UPDATE ticket_settings SET `+strings.Join(sets, ", ")+` WHERE guild_id = $1`, args...)
return err
}
// CategoryPatch changes a category. A nil field is left as it is; an empty
// emoji or description clears it, and a zero StaffRoleID clears the role.
type CategoryPatch struct {
Name *string
Emoji *string
Description *string
StaffRoleID *int64
}
// UpdateCategory applies the fields that are set and returns the category as
// saved, or nil when there is none with that ID in the guild.
func (r *Repo) UpdateCategory(ctx context.Context, guildID, id int64, p CategoryPatch) (*Category, error) {
var sets []string
args := []any{guildID, id}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)))
}
if p.Name != nil {
add("name", *p.Name)
}
if p.Emoji != nil {
add("emoji", nullText(*p.Emoji))
}
if p.Description != nil {
add("description", *p.Description)
}
if p.StaffRoleID != nil {
add("staff_role_id", nullID(*p.StaffRoleID))
}
if len(sets) == 0 {
return r.GetCategory(ctx, guildID, id)
}
q := `UPDATE ticket_categories SET ` + strings.Join(sets, ", ") + ` WHERE guild_id = $1 AND id = $2 RETURNING ` + categoryCols
c, err := scanCategory(r.db.QueryRowContext(ctx, q, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// ListTickets returns a guild's most recent tickets with the given status
// ("open" or "closed"), newest first.
func (r *Repo) ListTickets(ctx context.Context, guildID int64, status string, limit int) ([]Ticket, error) {
if limit < 1 || limit > 100 {
limit = 25
}
q := `SELECT ` + ticketCols + ` FROM tickets WHERE guild_id = $1 AND status = $2 ORDER BY number DESC LIMIT $3`
rows, err := r.db.QueryContext(ctx, q, guildID, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
@@ -1,6 +1,6 @@
//go:build integration //go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied: // Runs against a real Postgres with schema.sql loaded:
// //
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo // TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
package ticketrepo package ticketrepo
@@ -46,7 +46,7 @@ func TestSettingsDefaultsAndChannels(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s, err := r.GetSettings(ctx, g) s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 { if err != nil || s.Configured || s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
t.Fatalf("defaults wrong: %+v %v", s, err) t.Fatalf("defaults wrong: %+v %v", s, err)
} }
if err := r.SetChannels(ctx, g, 111, 222); err != nil { if err := r.SetChannels(ctx, g, 111, 222); err != nil {
@@ -204,3 +204,84 @@ func TestCommands(t *testing.T) {
} }
} }
} }
func TestUpdateSettingsCategoriesAndList(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
str := func(v string) *string { return &v }
num := func(v int) *int { return &v }
on := true
// Changing the text of a server that never set tickets up creates its row
// and leaves the system off.
if err := r.UpdateSettings(ctx, g, SettingsPatch{PanelTitle: str("Help desk"), MaxOpenPerUser: num(3)}); err != nil {
t.Fatal(err)
}
st, _ := r.GetSettings(ctx, g)
if st.Enabled || st.PanelTitle != "Help desk" || st.MaxOpenPerUser != 3 || st.PanelDescription != DefaultPanelDescription {
t.Errorf("settings: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{Enabled: &on}); err != nil {
t.Fatal(err)
}
if st, _ = r.GetSettings(ctx, g); !st.Enabled || st.PanelTitle != "Help desk" {
t.Errorf("enabling keeps the rest: %+v", st)
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{MaxOpenPerUser: num(11)}); err == nil {
t.Error("the database refuses more than 10 open tickets per member")
}
if err := r.UpdateSettings(ctx, g, SettingsPatch{}); err != nil {
t.Errorf("an empty patch is fine: %v", err)
}
cat, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🎫", Description: "Questions", StaffRoleID: 99})
if err != nil {
t.Fatal(err)
}
other, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Report"})
if err != nil {
t.Fatal(err)
}
zero := int64(0)
empty := ""
got, err := r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{Name: str("Help"), Emoji: &empty, StaffRoleID: &zero})
if err != nil || got == nil {
t.Fatalf("update: %v %v", got, err)
}
if got.Name != "Help" || got.Emoji != "" || got.StaffRoleID != 0 || got.Description != "Questions" {
t.Errorf("only the named fields change, and empty clears: %+v", got)
}
if got, _ = r.UpdateCategory(ctx, g, cat.ID, CategoryPatch{StaffRoleID: func() *int64 { v := int64(5); return &v }()}); got.StaffRoleID != 5 {
t.Errorf("setting the role: %+v", got)
}
if gone, err := r.UpdateCategory(ctx, g+1, cat.ID, CategoryPatch{Name: str("x")}); err != nil || gone != nil {
t.Errorf("another server's category can't be edited: %v %v", gone, err)
}
if _, err := r.UpdateCategory(ctx, g, other.ID, CategoryPatch{Name: str("")}); err == nil {
t.Error("the database refuses an empty name")
}
for n, status := range []string{"open", "open", "closed"} {
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: n + 1, CategoryID: cat.ID, CategoryName: "Help", ThreadID: int64(9100 + n), OpenerID: 7, Subject: "x"})
if err != nil {
t.Fatal(err)
}
if status == "closed" {
if _, err := r.Close(ctx, int64(9100+n), 7, "done"); err != nil {
t.Fatal(err)
}
}
_ = id
}
open, err := r.ListTickets(ctx, g, StatusOpen, 10)
if err != nil || len(open) != 2 || open[0].Number != 2 {
t.Errorf("open tickets, newest first: %v %v", open, err)
}
closed, _ := r.ListTickets(ctx, g, StatusClosed, 10)
if len(closed) != 1 {
t.Errorf("closed tickets: %v", closed)
}
if other, _ := r.ListTickets(ctx, g+1, StatusOpen, 10); len(other) != 0 {
t.Error("another server's tickets must not show")
}
}
@@ -0,0 +1,39 @@
// Package betasettings is where the beta server, the Beta Tester role and the
// servers that opted in to beta features are read and changed. The bot polls
// the same tables, so a change here reaches the beta gate within 30 seconds.
package betasettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
// Get returns the beta server and role as ID strings ("" when not set).
func (s *Service) Get(ctx context.Context) (guildID, roleID string, err error) {
return s.settings.GetBetaSettings(ctx)
}
func (s *Service) SetServer(ctx context.Context, guildID, roleID, updatedBy int64) error {
return s.settings.SetBetaServer(ctx, guildID, roleID, updatedBy)
}
func (s *Service) OptedIn(ctx context.Context) ([]string, error) {
return s.settings.ListBetaGuilds(ctx)
}
func (s *Service) IsOptedIn(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsBetaGuild(ctx, guildID)
}
func (s *Service) SetOptedIn(ctx context.Context, guildID, by int64, on bool) error {
return s.settings.SetBetaGuild(ctx, guildID, by, on)
}
@@ -55,3 +55,11 @@ func (s *Service) ListRoleRewards(ctx context.Context, guildID int64, limit int)
func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) { func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsLevelSystemEnabled(ctx, guildID) return s.settings.IsLevelSystemEnabled(ctx, guildID)
} }
func (s *Service) DeleteRoleRewardForLevel(ctx context.Context, guildID int64, level int) (bool, error) {
return s.settings.DeleteRoleRewardForLevel(ctx, guildID, level)
}
func (s *Service) GetLevelUpChannelID(ctx context.Context, guildID int64) (int64, bool, error) {
return s.settings.GetLevelupChannelID(ctx, guildID)
}
+4 -1
View File
@@ -1,6 +1,7 @@
package services package services
import ( import (
"velox-bot/internal/db/services/betasettings"
"velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
@@ -28,11 +29,12 @@ type Services struct {
DefaultRole *defaultrole.Service DefaultRole *defaultrole.Service
LogSettings *logsettings.Service LogSettings *logsettings.Service
MusicSettings *musicsettings.Service MusicSettings *musicsettings.Service
BetaSettings *betasettings.Service
} }
var Global *Services var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service) *Services { func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service, betaSvc *betasettings.Service) *Services {
s := &Services{ s := &Services{
Level: level, Level: level,
LevelSettings: levelSettings, LevelSettings: levelSettings,
@@ -46,6 +48,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
DefaultRole: defaultRoleSvc, DefaultRole: defaultRoleSvc,
LogSettings: logSettingsSvc, LogSettings: logSettingsSvc,
MusicSettings: musicSvc, MusicSettings: musicSvc,
BetaSettings: betaSvc,
} }
Global = s Global = s
return s return s
+17
View File
@@ -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
+66
View File
@@ -153,6 +153,61 @@ func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
f.set(g).JoinLock = mode f.set(g).JoinLock = mode
return nil return nil
} }
func (f *fakeStore) UpdateTuning(_ context.Context, g int64, t securityrepo.Tuning) error {
s := f.set(g)
str := func(dst *string, v *string) {
if v != nil {
*dst = *v
}
}
num := func(dst *int, v *int) {
if v != nil {
*dst = *v
}
}
flag := func(dst *bool, v *bool) {
if v != nil {
*dst = *v
}
}
if t.AlertDM != nil {
s.AlertDM = *t.AlertDM
}
str(&s.NoAvatar, t.NoAvatar)
str(&s.NewAccount, t.NewAccount)
num(&s.MinAccountAgeDays, t.MinAccountAgeDays)
str(&s.AdvertisingName, t.AdvertisingName)
str(&s.UsernameFilter, t.UsernameFilter)
if t.UsernamePatterns != nil {
s.UsernamePatterns = *t.UsernamePatterns
}
str(&s.UnverifiedBot, t.UnverifiedBot)
str(&s.UnauthorizedBot, t.UnauthorizedBot)
num(&s.RaidJoinCount, t.RaidJoinCount)
num(&s.RaidJoinSeconds, t.RaidJoinSeconds)
str(&s.RaidAction, t.RaidAction)
flag(&s.RaidPauseInvites, t.RaidPauseInvites)
num(&s.RaidQuietMinutes, t.RaidQuietMinutes)
num(&s.AntiNukeWindowSeconds, t.AntiNukeWindowSeconds)
if s.AntiNukeLimits == nil {
s.AntiNukeLimits = securityrepo.DefaultLimits()
}
for k, v := range t.AntiNukeLimits {
s.AntiNukeLimits[k] = v
}
flag(&s.AntiNukeLockServer, t.AntiNukeLockServer)
flag(&s.AntiNukeAutoRestore, t.AntiNukeAutoRestore)
str(&s.HeatStrictness, t.HeatStrictness)
str(&s.HeatAction, t.HeatAction)
num(&s.HeatTimeoutMinutes, t.HeatTimeoutMinutes)
flag(&s.HeatEscalate, t.HeatEscalate)
if t.HeatBlockedWords != nil {
s.HeatBlockedWords = *t.HeatBlockedWords
}
num(&s.HeatPanicMembers, t.HeatPanicMembers)
num(&s.HeatLockdownMentions, t.HeatLockdownMentions)
return nil
}
func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) { func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -252,6 +307,17 @@ func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error {
f.events = append(f.events, e) f.events = append(f.events, e)
return nil return nil
} }
func (f *fakeStore) ListEvents(_ context.Context, g int64, limit int) ([]securityrepo.Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Event
for k := len(f.events) - 1; k >= 0 && len(out) < limit; k-- {
if f.events[k].GuildID == g {
out = append(out, f.events[k])
}
}
return out, nil
}
func (f *fakeStore) eventKinds() []string { func (f *fakeStore) eventKinds() []string {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+5
View File
@@ -182,6 +182,11 @@ func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error
} }
// Quarantines lists who is quarantined in a guild. // Quarantines lists who is quarantined in a guild.
// Events returns the server's recent security history, newest first.
func (e *Engine) Events(ctx context.Context, guildID string, limit int) ([]securityrepo.Event, error) {
return e.st.ListEvents(ctx, parseID(guildID), limit)
}
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) { func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
return e.st.ListQuarantines(ctx, parseID(guildID)) return e.st.ListQuarantines(ctx, parseID(guildID))
} }
+2
View File
@@ -48,6 +48,7 @@ type Store interface {
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
SetJoinLock(ctx context.Context, guildID int64, mode string) error SetJoinLock(ctx context.Context, guildID int64, mode string) error
UpdateTuning(ctx context.Context, guildID int64, t securityrepo.Tuning) error
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error) ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
@@ -60,6 +61,7 @@ type Store interface {
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
DeleteLockdown(ctx context.Context, guildID, channelID int64) error DeleteLockdown(ctx context.Context, guildID, channelID int64) error
AddEvent(ctx context.Context, e securityrepo.Event) error AddEvent(ctx context.Context, e securityrepo.Event) error
ListEvents(ctx context.Context, guildID int64, limit int) ([]securityrepo.Event, error)
ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error) ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error)
MarkCommandProcessed(ctx context.Context, id int64) error MarkCommandProcessed(ctx context.Context, id int64) error
SaveRaid(ctx context.Context, x securityrepo.Raid) error SaveRaid(ctx context.Context, x securityrepo.Raid) error
+217
View File
@@ -0,0 +1,217 @@
package security
import (
"context"
"fmt"
"sort"
"strings"
"unicode"
"unicode/utf8"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Tuning the thresholds, filters and actions. Everything the Join Gate, raid
// mode, anti-nuke and heat do can be adjusted from Discord with these calls;
// a dashboard is never needed. They follow the same rules as the rest of the
// configuration: the server owner or someone on the trusted list, checked
// here and not left to Discord's command permissions.
const (
maxWords = 50 // blocked words per list
maxWordLength = 50
)
var (
filterActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
botActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
raidActions = map[string]bool{securityrepo.ActionAlert: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
heatActions = map[string]bool{securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
strictnessSet = map[string]bool{"low": true, "medium": true, "high": true}
nukeLimitKeys = map[string]bool{
securityrepo.LimitChannelDelete: true, securityrepo.LimitChannelCreate: true, securityrepo.LimitRoleDelete: true,
securityrepo.LimitRoleCreate: true, securityrepo.LimitBan: true, securityrepo.LimitKick: true, securityrepo.LimitWebhookCreate: true,
}
)
// CleanWords trims, lowercases and de-duplicates a list of blocked words and
// rejects anything oversized or containing control characters. They are
// matched as plain text, never as regular expressions.
func CleanWords(in []string) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, w := range in {
w = strings.ToLower(strings.TrimSpace(w))
if w == "" || seen[w] {
continue
}
if utf8.RuneCountInString(w) > maxWordLength {
return nil, UserError("Each blocked word can be at most 50 characters.")
}
for _, r := range w {
if unicode.IsControl(r) {
return nil, UserError("Blocked words can't contain control characters.")
}
}
seen[w] = true
out = append(out, w)
}
if len(out) > maxWords {
return nil, UserError("You can block at most 50 words.")
}
sort.Strings(out)
return out, nil
}
func inRange(v *int, lo, hi int) bool { return v == nil || (*v >= lo && *v <= hi) }
func oneOf(v *string, allowed map[string]bool) bool { return v == nil || allowed[*v] }
// ValidateTuning returns a message for the first invalid field, or "". It
// also cleans the word lists in place.
func ValidateTuning(t *securityrepo.Tuning) error {
for name, v := range map[string]*string{
"No avatar": t.NoAvatar, "New account": t.NewAccount, "Invite in name": t.AdvertisingName, "Blocked words": t.UsernameFilter,
} {
if !oneOf(v, filterActions) {
return UserError(name + " can be off, timeout, kick or ban.")
}
}
if !oneOf(t.UnverifiedBot, botActions) || !oneOf(t.UnauthorizedBot, botActions) {
return UserError("Bot filters can only be off, kick or ban.")
}
if !oneOf(t.RaidAction, raidActions) {
return UserError("The raid action can be alert, timeout, kick or ban.")
}
if !oneOf(t.HeatStrictness, strictnessSet) {
return UserError("Heat strictness must be low, medium or high.")
}
if !oneOf(t.HeatAction, heatActions) {
return UserError("The heat action can be timeout, kick or ban.")
}
switch {
case !inRange(t.MinAccountAgeDays, 1, 365):
return UserError("Minimum account age must be between 1 and 365 days.")
case !inRange(t.RaidJoinCount, 3, 100):
return UserError("The raid join count must be between 3 and 100.")
case !inRange(t.RaidJoinSeconds, 5, 300):
return UserError("The raid window must be between 5 and 300 seconds.")
case !inRange(t.RaidQuietMinutes, 1, 120):
return UserError("The raid quiet period must be between 1 and 120 minutes.")
case !inRange(t.AntiNukeWindowSeconds, 5, 300):
return UserError("The anti-nuke window must be between 5 and 300 seconds.")
case !inRange(t.HeatTimeoutMinutes, 1, 10080):
return UserError("The heat timeout must be between 1 minute and 7 days (10080 minutes).")
case !inRange(t.HeatPanicMembers, 0, 50):
return UserError("Heat panic must be between 0 (off) and 50 members.")
case t.HeatLockdownMentions != nil && *t.HeatLockdownMentions != 0 && (*t.HeatLockdownMentions < 5 || *t.HeatLockdownMentions > 1000):
return UserError("The mention flood lockdown must be 0 (off) or between 5 and 1000 mentions.")
}
for k, v := range t.AntiNukeLimits {
if !nukeLimitKeys[k] {
return UserError("Unknown anti-nuke limit " + fmt.Sprintf("%q", k) + ".")
}
if v < 0 || v > 100 {
return UserError("Anti-nuke limits must be between 0 and 100 (0 means never allowed).")
}
}
if t.UsernamePatterns != nil {
words, err := CleanWords(*t.UsernamePatterns)
if err != nil {
return err
}
t.UsernamePatterns = &words
}
if t.HeatBlockedWords != nil {
words, err := CleanWords(*t.HeatBlockedWords)
if err != nil {
return err
}
t.HeatBlockedWords = &words
}
return nil
}
// Tune changes thresholds, filters and actions. Only the server owner or
// someone on the trusted list may.
func (e *Engine) Tune(ctx context.Context, guildID string, actor *discordgo.Member, t securityrepo.Tuning, summary string) error {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return err
}
if err := ValidateTuning(&t); err != nil {
return err
}
if err := e.st.UpdateTuning(ctx, parseID(guildID), t); err != nil {
return err
}
e.Invalidate(guildID)
e.event(ctx, guildID, "settings", actor.User.ID, "", summary)
return nil
}
// WordList names one of the two blocked word lists.
type WordList string
const (
UsernameWords WordList = "username" // checked against new members' names by the Join Gate
ChatWords WordList = "chat" // checked against messages by heat
)
// EditWords adds or removes one blocked word and returns the list as saved.
func (e *Engine) EditWords(ctx context.Context, guildID string, actor *discordgo.Member, list WordList, add bool, word string) ([]string, error) {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return nil, err
}
e.Invalidate(guildID) // read what is saved now, not what was cached
cur, _, err := e.load(ctx, guildID)
if err != nil {
return nil, err
}
var words []string
switch list {
case UsernameWords:
words = append(words, cur.UsernamePatterns...)
case ChatWords:
words = append(words, cur.HeatBlockedWords...)
default:
return nil, UserError("Unknown list.")
}
word = strings.ToLower(strings.TrimSpace(word))
if word == "" {
return nil, UserError("Give a word to block.")
}
if add {
words = append(words, word)
} else {
kept := words[:0]
for _, w := range words {
if w != word {
kept = append(kept, w)
}
}
if len(kept) == len(words) {
return nil, UserError("That word isn't on the list.")
}
words = kept
}
clean, err := CleanWords(words)
if err != nil {
return nil, err
}
t := securityrepo.Tuning{}
if list == UsernameWords {
t.UsernamePatterns = &clean
} else {
t.HeatBlockedWords = &clean
}
verb := "removed"
if add {
verb = "added"
}
if err := e.Tune(ctx, guildID, actor, t, fmt.Sprintf("%s word list: %s %q", list, verb, word)); err != nil {
return nil, err
}
return clean, nil
}
+147
View File
@@ -0,0 +1,147 @@
package security
import (
"context"
"errors"
"strings"
"testing"
"velox-bot/internal/db/repos/securityrepo"
)
func ptr[T any](v T) *T { return &v }
func TestValidateTuning(t *testing.T) {
cases := []struct {
name string
t securityrepo.Tuning
ok bool
}{
{"nothing to change is fine", securityrepo.Tuning{}, true},
{"a filter action", securityrepo.Tuning{NoAvatar: ptr("timeout")}, true},
{"an unknown filter action", securityrepo.Tuning{NoAvatar: ptr("nuke")}, false},
{"bots can't be timed out", securityrepo.Tuning{UnverifiedBot: ptr("timeout")}, false},
{"bots can be kicked", securityrepo.Tuning{UnauthorizedBot: ptr("kick")}, true},
{"raid alert only", securityrepo.Tuning{RaidAction: ptr("alert")}, true},
{"heat can't just alert", securityrepo.Tuning{HeatAction: ptr("alert")}, false},
{"strictness", securityrepo.Tuning{HeatStrictness: ptr("extreme")}, false},
{"account age low", securityrepo.Tuning{MinAccountAgeDays: ptr(0)}, false},
{"account age high", securityrepo.Tuning{MinAccountAgeDays: ptr(366)}, false},
{"raid join count edges", securityrepo.Tuning{RaidJoinCount: ptr(3)}, true},
{"raid join count too low", securityrepo.Tuning{RaidJoinCount: ptr(2)}, false},
{"raid window too long", securityrepo.Tuning{RaidJoinSeconds: ptr(301)}, false},
{"quiet minutes", securityrepo.Tuning{RaidQuietMinutes: ptr(121)}, false},
{"nuke window", securityrepo.Tuning{AntiNukeWindowSeconds: ptr(4)}, false},
{"heat timeout max", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10080)}, true},
{"heat timeout over", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10081)}, false},
{"panic off", securityrepo.Tuning{HeatPanicMembers: ptr(0)}, true},
{"panic too many", securityrepo.Tuning{HeatPanicMembers: ptr(51)}, false},
{"mention flood off", securityrepo.Tuning{HeatLockdownMentions: ptr(0)}, true},
{"mention flood too small", securityrepo.Tuning{HeatLockdownMentions: ptr(4)}, false},
{"mention flood max", securityrepo.Tuning{HeatLockdownMentions: ptr(1000)}, true},
{"a known limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 0}}, true},
{"an unknown limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban_everyone": 1}}, false},
{"a negative limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": -1}}, false},
{"a huge limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 101}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateTuning(&tc.t)
if (err == nil) != tc.ok {
t.Errorf("ValidateTuning = %v, want ok=%v", err, tc.ok)
}
var ue UserError
if err != nil && !errors.As(err, &ue) {
t.Errorf("a refusal must be a UserError the command can show: %T", err)
}
})
}
}
func TestCleanWords(t *testing.T) {
got, err := CleanWords([]string{" Spam ", "spam", "", "ADS", "ads "})
if err != nil || len(got) != 2 || got[0] != "ads" || got[1] != "spam" {
t.Errorf("trimmed, lowercased, de-duplicated and sorted: %v, %v", got, err)
}
if _, err := CleanWords([]string{strings.Repeat("x", 51)}); err == nil {
t.Error("an oversized word is refused")
}
if _, err := CleanWords([]string{"bad\x00word"}); err == nil {
t.Error("control characters are refused")
}
many := make([]string, 51)
for i := range many {
many[i] = strings.Repeat("a", i+1)
}
if _, err := CleanWords(many); err == nil {
t.Error("more than 50 words is refused")
}
}
func TestTuneNeedsTheOwnerOrTrusted(t *testing.T) {
e, d, st, _ := testEngine(t)
ctx := context.Background()
owner := d.members["1"]
admin := d.addMember("50")
trustedUser := d.addMember("60")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
refresh(e)
tun := securityrepo.Tuning{RaidJoinCount: ptr(20)}
if err := e.Tune(ctx, guild, admin, tun, "x"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("an admin who isn't trusted must not tune: %v", err)
}
if err := e.Tune(ctx, guild, nil, tun, "x"); err == nil {
t.Error("no actor, no change")
}
if st.set(100).RaidJoinCount == 20 {
t.Fatal("nothing may be saved for a refused change")
}
if err := e.Tune(ctx, guild, owner, tun, "raid join count 20"); err != nil {
t.Fatalf("the owner may: %v", err)
}
if st.set(100).RaidJoinCount != 20 {
t.Error("the owner's change is saved")
}
if err := e.Tune(ctx, guild, trustedUser, securityrepo.Tuning{RaidJoinCount: ptr(2)}, "x"); err == nil {
t.Error("an invalid value is refused even for the owner or trusted")
}
if st.set(100).RaidJoinCount != 20 {
t.Error("an invalid change must leave the old value")
}
if len(st.events) == 0 || st.events[len(st.events)-1].Kind != "settings" {
t.Errorf("the change is written to the history: %v", st.events)
}
}
func TestEditWords(t *testing.T) {
e, d, st, _ := testEngine(t)
ctx := context.Background()
owner := d.members["1"]
admin := d.addMember("50")
if _, err := e.EditWords(ctx, guild, admin, UsernameWords, true, "scam"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("untrusted: %v", err)
}
words, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " SCAM ")
if err != nil || len(words) != 1 || words[0] != "scam" {
t.Fatalf("add: %v, %v", words, err)
}
if _, err := e.EditWords(ctx, guild, owner, ChatWords, true, "free nitro"); err != nil {
t.Fatalf("the chat list is separate: %v", err)
}
s := st.set(100)
if len(s.UsernamePatterns) != 1 || len(s.HeatBlockedWords) != 1 {
t.Errorf("each list keeps its own words: %v %v", s.UsernamePatterns, s.HeatBlockedWords)
}
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, false, "nothing"); err == nil {
t.Error("removing a word that isn't there says so")
}
words, err = e.EditWords(ctx, guild, owner, UsernameWords, false, "scam")
if err != nil || len(words) != 0 {
t.Errorf("remove: %v, %v", words, err)
}
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " "); err == nil {
t.Error("a blank word is refused")
}
}
+2 -1
View File
@@ -25,6 +25,7 @@ import (
"velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/welcomerepo" "velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/db/services/betasettings"
"velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
@@ -78,7 +79,7 @@ func main() {
welcomeService := welcome.New(welcomeRepo) welcomeService := welcome.New(welcomeRepo)
defaultRoleService := defaultrole.New(defaultRoleRepo) defaultRoleService := defaultrole.New(defaultRoleRepo)
musicSettingsService := musicsettings.New(settingsRepo) musicSettingsService := musicsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService) services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService, betasettings.New(settingsRepo))
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost)
if err != nil { if err != nil {
+39
View File
@@ -288,6 +288,8 @@ CREATE TABLE IF NOT EXISTS security_settings (
-- Where alerts go, and who gets pinged for raids and nukes. -- Where alerts go, and who gets pinged for raids and nukes.
alert_channel_id BIGINT, alert_channel_id BIGINT,
alert_role_id BIGINT, alert_role_id BIGINT,
-- Also DM critical alerts to the owner and trusted users.
alert_dm BOOLEAN NOT NULL DEFAULT TRUE,
-- Join Gate: checks run on every join, each with its own action. -- Join Gate: checks run on every join, each with its own action.
joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE, joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE,
@@ -348,6 +350,9 @@ CREATE TABLE IF NOT EXISTS security_settings (
-- Join lockdown (/lockdown joins): every new member is kicked or banned. -- Join lockdown (/lockdown joins): every new member is kicked or banned.
join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban')) join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban'))
); );
-- Servers set up before alert_dm existed get it here (CREATE TABLE IF NOT EXISTS
-- doesn't change an existing table).
ALTER TABLE security_settings ADD COLUMN IF NOT EXISTS alert_dm BOOLEAN NOT NULL DEFAULT TRUE;
-- Users and roles exempt from anti-nuke, who may also manage its settings. -- Users and roles exempt from anti-nuke, who may also manage its settings.
-- The server owner is always trusted and never listed here. -- The server owner is always trusted and never listed here.
@@ -440,3 +445,37 @@ CREATE TABLE IF NOT EXISTS security_commands (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ processed_at TIMESTAMPTZ
); );
-- Wake the bot when a request is added. The bot also processes everything
-- waiting when it starts, so a request is never lost; this only makes it
-- immediate. Whatever writes to these tables (a dashboard, a script) needs no
-- NOTIFY of its own.
CREATE OR REPLACE FUNCTION notify_music_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('music_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS music_commands_notify ON music_commands;
CREATE TRIGGER music_commands_notify AFTER INSERT ON music_commands
FOR EACH ROW EXECUTE FUNCTION notify_music_command();
CREATE OR REPLACE FUNCTION notify_ticket_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('ticket_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_commands_notify ON ticket_commands;
CREATE TRIGGER ticket_commands_notify AFTER INSERT ON ticket_commands
FOR EACH ROW EXECUTE FUNCTION notify_ticket_command();
CREATE OR REPLACE FUNCTION notify_security_command() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('security_commands', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS security_commands_notify ON security_commands;
CREATE TRIGGER security_commands_notify AFTER INSERT ON security_commands
FOR EACH ROW EXECUTE FUNCTION notify_security_command();