Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:58:19 +01:00
FernandoJVideira e453a9ba94 Merge pull request 'Add anti-raid and anti-nuke protection' (#19) from ft/antiraid into dev
Reviewed-on: #19
2026-10-20 21:04:12 +00:00
FernandoJVideira 95072fcd53 Merge pull request 'Add support tickets with private threads' (#18) from ft/tickets into dev
Reviewed-on: #18
2026-10-20 21:03:04 +00:00
FernandoJVideiraandClaude Opus 5.5 3f87e68a2c Add anti-raid and anti-nuke protection
Join Gate (account age, avatar, advertising and username filters, bot
checks), raid detection with invite pausing, anti-nuke quarantine of
admins who mass delete, manual and automatic lockdowns, a decaying heat
score against spam, role and channel snapshots with restore, and alert
DMs to the owner and trusted users. Everything that weakens protection
is limited to the owner and the trusted list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:59:52 +01:00
FernandoJVideiraandClaude Opus 5.5 d294b3463b Add support tickets that work with private threads
Members open a ticket from a panel in the ticket channel: one button per
category (a dropdown past five), then a short form. Each ticket is a private
thread named after the category, number and opener, with a first message
that mentions the category's staff role so its members join, and Claim and
Close buttons.

- Claim: staff (the category's role, or Manage Threads / Manage Server)
  take a ticket; the first message shows who has it.
- Close: the opener or staff close it, with an optional reason. The thread
  is locked and archived, and the log channel gets a summary and a .txt
  transcript of the whole thread.
- /ticket add|remove|claim|close inside a ticket; /tickets setup,
  /tickets category add|remove|list and /tickets panel for admins.
- Per-member limit on open tickets (default 1), per-server ticket numbers
  handed out atomically, a deleted thread closes its ticket.
- The dashboard asks the bot to post or refresh the panel through
  ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if
  the database connection drops.
- Setup warns about missing bot permissions in the ticket and log channels.

The flows live in internal/tickets behind small Discord and store
interfaces, with unit tests on fakes; the repo and the listener have
integration tests (-tags integration, TICKETS_TEST_DB).

Needs the ticket tables (velox-dashboard-api migration 000016_tickets).
.gitignore gains the /tldr benchmark rules so private chat exports and
benchmark results on disk can't be committed from this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:31:52 +01:00
FernandoJVideira b1ea5f17e3 Merge branch 'master' into dev 2026-08-29 18:53:18 +00:00
FernandoJVideira 23d417ef27 Merge pull request 'Ft/music command listener' (#16) from ft/music-command-listener into dev
Reviewed-on: #16
2026-08-29 18:52:50 +00:00
FernandoJVideira 52dfe859ee feat(music): add a stop command
Clears the whole queue and disconnects, mirrors the existing Stop
Manager function, just wires it up to the outbox like every other
dashboard command.
2026-08-29 19:48:43 +01:00
FernandoJVideira da66eb8dd5 feat(music): let the dashboard add songs, starting a session if needed
New AddToQueue appends to an already-active session without touching
voice (the dashboard can't join a channel on its own), and add_song's
dispatch case now branches: a voice_channel_id in the payload means
"start fresh", so it goes through EnqueueAndPlay (join + play) instead.
Query normalization (plain text -> YouTube search, stripping
autoplay/radio params off pasted YouTube URLs) moves out of /play's
handler into a shared NormalizeQuery, both entry points need it, not
just one.

Starting playback from the dashboard also posts the now-playing embed
into the voice channel's own built-in text chat, matching what /play
already does, instead of leaving it with nowhere to show up.
2026-08-29 19:44:24 +01:00
FernandoJVideira fcc7c9c66a feat(music): consume playback commands via LISTEN/NOTIFY
The dashboard has been writing skip/pause/volume/etc. requests into
music_commands with nothing on this end reading them. Adds a
dedicated LISTEN connection (can't use the pooled *sql.DB for this,
LISTEN has to stay bound to one specific connection) that wakes on
NOTIFY, drains unprocessed rows, and dispatches each to the matching
Manager function. A command's error is logged, not fatal, and every
row gets marked processed regardless of outcome so a permanently
broken command doesn't retry forever.
2026-08-29 19:22:16 +01:00
FernandoJVideira 7a17c55774 feat(music): stable queue identity, remove/reorder, and repeat commands
TrackEntry gets a QueueID generated once when a track is queued and
kept for its whole life there, and musicrepo.ReplaceQueue now upserts
by that ID instead of deleting and reinserting the whole queue on
every sync (which churned every track's database id constantly, even
ones that hadn't moved, and would've made remove/reorder commands
target the wrong track). RemoveFromQueue and Reorder are new Manager
functions built on top of that stable identity. Also adds repeat_song/
repeat_queue to the persisted now-playing state, and makes the two
existing toggle functions actually sync it, they never did before.
2026-08-29 19:22:02 +01:00
FernandoJVideira 39962457b3 Merge pull request 'v3.3.0' (#15) from dev into master
Reviewed-on: #15
2026-08-29 15:39:18 +00:00
FernandoJVideira 539c0a69af Merge pull request 'feat(music): persist live playback state to postgres' (#14) from ft/music-live-state into dev
Reviewed-on: #14
2026-08-29 15:35:05 +00:00
FernandoJVideira d6687879dd feat(music): persist live playback state to postgres
The queue and now-playing state only ever lived in the bot's memory,
so the dashboard had nothing real to show and a restart silently
wiped whatever was queued. Adds a musicrepo package and a syncState
call after every queue-changing action (play, skip, pause, volume,
stop, track end) that mirrors the current track and queue into
music_now_playing and music_queue.

No user-visible change yet, this is groundwork for the dashboard
queue/now-playing view.
2026-08-29 15:48:27 +01:00
FernandoJVideira e507225cae Merge pull request 'v3.2.0' (#13) from dev into master
Reviewed-on: #13
2026-08-29 03:14:33 +00:00
FernandoJVideira c7b960e14a Merge pull request 'feat(music): add /music toggle and gate playback behind it' (#12) from ft/music-toggle into dev
Reviewed-on: #12
2026-08-29 02:53:57 +00:00
FernandoJVideira 691b505175 feat(music): add /music toggle and gate playback behind it
Music could be started and controlled with no way to turn it off for
a server. Adds a music_settings table (mirrors the same one
velox-dashboard-api's dashboard toggle reads/writes), a /music toggle
command gated to Manage Server, and checks in /play, /queue, and
/volume so they refuse to run when a server has music turned off.
2026-08-29 03:49:52 +01:00
FernandoJVideira 95ba434194 Merge pull request 'Feat/schedule rework' (#11) from feat/schedule_rework into dev
Reviewed-on: #11
2026-08-26 14:05:52 +00:00
FernandoJVideira 5f4e893971 chore .gitignore 2026-08-26 15:05:06 +01:00
FernandoJVideira 01dbb98450 Command rework 2026-08-18 21:20:31 +01:00
FernandoJVideira 5024af2789 feat: schedule rework 2026-08-18 19:22:21 +01:00
FernandoJVideira 483dc666d1 Merge pull request 'feat: update Docker configuration and environment handling' (#10) from dev into master
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/10
2026-03-18 14:14:27 +00:00
FernandoJVideira 3585fa0658 Merge branch 'master' into dev 2026-03-18 14:14:21 +00:00
FernandoJVideira d1be60e201 Merge pull request 'dev' (#9) from dev into master
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/9
2026-03-18 14:11:39 +00:00
FernandoJVideira 26e1d9b094 feat: update Docker configuration and environment handling
- Added PostgreSQL service to docker-compose.yml with health checks and persistent storage.
- Updated bot service to use environment variable substitution for database connection string.
- Upgraded Go version in Dockerfile to 1.26.1-alpine.
- Modified config loading to ignore missing .env file for better compatibility with Docker.
2026-03-18 14:08:21 +00:00
FernandoJVideira 461f8435c0 feat: add Docker support and bot configuration
- Introduced Dockerfile for building the bot application using Go.
- Added .dockerignore to exclude unnecessary files from the Docker context.
- Updated docker-compose.yml to define the bot service and its dependencies, including environment variables for database and Lavalink configuration.
- Modified internal configuration to streamline environment variable handling.
2026-03-18 13:39:31 +00:00
FernandoJVideira 1b47bd13a3 Merge pull request 'ft/mod' (#8) from ft/mod into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/8
2026-03-18 13:22:26 +00:00
FernandoJVideira 035e383db2 feat: implement server logging functionality
- Added a logging service to manage server event logs including message edits, deletions, member joins/leaves, and moderation actions.
- Introduced commands to configure logging settings, including setting the log channel and enabling/disabling logging.
- Updated the README to document the new logging features and commands.
- Enhanced moderation commands to log actions taken on members.
2026-03-18 13:18:12 +00:00
FernandoJVideira 2b14079e50 feat: add moderation commands and update README
- Introduced a new set of moderation commands including `/moderation purge`, `/moderation timeout`, `/moderation kick`, `/moderation ban`, `/moderation unban`, `/moderation untimeout`, and `/moderation slowmode`.
- Updated README to include details about the new moderation features and commands.
- Enhanced help command to display moderation tools and their usage.
2026-03-18 13:00:30 +00:00
FernandoJVideira a92c4131db Merge pull request 'ft/welcome_message' (#7) from ft/welcome_message into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/7
2026-03-18 05:16:08 +00:00
FernandoJVideira 397a8a459a chore: readme & help update 2026-03-18 05:15:12 +00:00
FernandoJVideira 4080ce8aec feat: add default role management functionality
- Introduced a new default role service and repository for managing default roles assigned to new members.
- Added command to set the default role through the bot's configuration.
- Implemented event handling to assign the default role to new guild members upon joining.
- Updated main application to include the new default role service in the bot's services.
2026-03-18 05:12:40 +00:00
FernandoJVideira e6aa5def96 feat: add welcome message functionality
- Introduced a new welcome service and repository for managing welcome messages, channels, and GIFs.
- Added commands to set welcome channel, message, DM, and GIF through the bot's configuration.
- Implemented event handling for new guild members to send welcome messages and DMs based on configuration.
- Updated main application to include the new welcome service in the bot's services.
2026-03-18 05:07:11 +00:00
FernandoJVideira 6410619662 Merge pull request 'feat: twitch notifications' (#6) from ft/twitch_notifications into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/6
2026-03-18 04:42:54 +00:00
88 changed files with 13416 additions and 539 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
.env
*.md
Dockerfile
docker-compose*.yml
.dockerignore
+8 -1
View File
@@ -1,3 +1,10 @@
.env
*.env
.cursor
tmp/
# Local /tldr benchmark: chat dumps and results never get committed.
bench-out/
internal/tldr/tldrbench/private/
# Real chat dumps used by the benchmark never get committed.
internal/tldr/tldrbench/testdata/private/
+21
View File
@@ -0,0 +1,21 @@
# Build stage
FROM golang:1.26.1-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /velox-bot .
# Run stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /velox-bot .
ENTRYPOINT ["./velox-bot"]
+52
View File
@@ -9,7 +9,15 @@ It currently includes:
- Meeting rooms (voice lobby → auto-created temporary voice channels)
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
- Projects (list/create projects + helpers)
- Support tickets (a panel with categories, a private thread per ticket, claim/close, transcripts to a log channel)
- Anti-raid and anti-nuke (Join Gate, raid mode, quarantine of anyone who mass-deletes or mass-bans, lockdowns), modelled on Wick
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
- Twitch live notifications (per-guild `/config` setup)
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions)
When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions.
## Requirements
@@ -34,6 +42,10 @@ Optional (music):
- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup)
- `LAVALINK_PASSWORD`: Lavalink password
Optional (Twitch live notifications):
- `TWITCH_CLIENT_ID`: Twitch Client ID used to query live status
## Database setup
If you use the compose file:
@@ -88,8 +100,48 @@ just down # stop postgres
- `/schedule create`, `/schedule list`, `/schedule reschedule`
- **Projects**
- `/projects list`, `/projects create`, `/projects add-helper`
- **Tickets**
- `/tickets setup channel:#tickets [log-channel] [staff-role]` — Where tickets open and closed ones are logged (**Manage Server**)
- `/tickets category add|remove|list` — One entry in the panel's dropdown per category, each with its own staff role (**Manage Server**)
- `/tickets panel` — Post or refresh the panel (**Manage Server**)
- `/ticket add`, `/ticket close [reason]` — Opener or staff; `/ticket claim`, `/ticket remove` — staff
- The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
- **Security** (anti-raid and anti-nuke; everything is off until turned on)
- `/security status`, `/security toggle feature on`, `/security alerts channel [role]`
- `/security trust add|remove user|role` — owner only; trusted members are exempt from anti-nuke and may change security settings
- `/security release user`, `/security quarantined`, `/security end-raid`, `/security quarantine-role`
- `/lockdown channel|server|joins`, `/unlock channel|server|joins` — unlocking needs the owner or a trusted member
- `/security snapshot`, `/security restore` — snapshots of roles and channels are also taken every 6 hours while anti-nuke is on (last three kept); restore recreates deleted roles and channels and reverts dangerous permission changes, and can run automatically after a stopped nuke
- Heat (anti-spam): messages build a per-member score that cools over time; spam, repeats, mass mentions and invite links max it, which deletes the message and times the member out (doubling for repeat offences). A wave of spammers turns on heat panic (newcomers timed out on sight for 10 minutes); a mention flood can lock the server. Moderators (Manage Messages) and trusted members are exempt.
- Raid mode survives a bot restart.
- Critical alerts (raids, stopped nukes, restores, spam waves, removed bots) are also DMed to the owner and trusted users (up to 10; trusted roles aren't expanded), since the alert channel may be the first thing a nuker deletes. One setting turns this off.
- Join Gate filters, raid thresholds, anti-nuke limits and heat settings are set on the dashboard.
- The bot needs View Audit Log, Manage Roles, Manage Channels, Manage Server (to pause invites), Moderate Members, Kick Members and Ban Members, and its role must be above the roles it protects against: it can only quarantine members whose highest role is below its own.
- Turning things off, unlocking and releasing always need the owner or someone on the trusted list, whatever Discord's command permissions say.
- **Music** (requires **DJ** role)
- `/play`, `/queue`, `/volume`
- **Moderation** (focused + practical)
- `/moderation purge amount:<1-100>` — Fast recent message cleanup (**Manage Messages**)
- `/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Temporary member timeout (**Moderate Members**)
- `/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)
- `/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)
- `/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)
- `/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)
- `/moderation slowmode channel:#channel seconds:<0-21600>` — Control channel flood (**Manage Channels**)
- **Twitch live notifications** (**Manage Server**)
- `/config addstreamer username:<twitch-name>` — Add a streamer for notifications
- `/config removestreamer username:<twitch-name>` — Remove a streamer
- `/config settwitchnotificationchannel channel:#channel` — Channel for live pings
- **Welcome / onboarding** (**Manage Server**)
- `/config setwelcomechannel channel:#channel` — Channel for welcome messages
- `/config setwelcomemessage message:"..."` — Server welcome template (supports `{user}`, `{server}`)
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
- `/config setdefaultrole role:@role` — Role automatically granted on join
- **Logging** (**Manage Server**)
- `/config setlogchannel channel:#channel` — Set where logs are sent
- `/config enablelogging` — Enable event/moderation logs
- `/config disablelogging` — Disable event/moderation logs
## Notes
+15 -2
View File
@@ -1,5 +1,3 @@
version: "3.9"
services:
db:
image: postgres:16
@@ -19,5 +17,20 @@ services:
timeout: 5s
retries: 5
bot:
build: .
container_name: velox-bot
restart: unless-stopped
env_file: .env
environment:
DB_HOST: ${DB_HOST:-postgres://velox:velox_pwd@db:5432/velox?sslmode=disable}
LAVALINK_HOST: ${LAVALINK_HOST:-ws://lavalink:2333}
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD:-youshallnotpass}
depends_on:
db:
condition: service_healthy
networks:
- default
volumes:
velox_pgdata:
+1
View File
@@ -15,6 +15,7 @@ require (
require (
github.com/disgoorg/json v1.2.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+2
View File
@@ -13,6 +13,8 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+114 -10
View File
@@ -1,11 +1,16 @@
package bot
import (
"context"
"log"
"time"
"velox-bot/internal/commands"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
"velox-bot/internal/music"
"velox-bot/internal/tickets"
"velox-bot/internal/security"
"github.com/bwmarrin/discordgo"
)
@@ -17,11 +22,13 @@ type Bot struct {
Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand
Services *services.Services
MusicRepo *musicrepo.Repo
DBHost string
LavalinkHost string
LavalinkPass string
}
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
session, err := discordgo.New("Bot " + token)
if err != nil {
return nil, err
@@ -30,14 +37,15 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
// Intents needed for:
// - InteractionCreate (slash commands): Guilds
// - MessageCreate (leveling): GuildMessages
// - GuildMemberAdd (welcome messages): GuildMembers
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
session.Identify.Intents = discordgo.IntentsGuilds |
discordgo.IntentsGuildBans |
discordgo.IntentsGuildMembers |
discordgo.IntentsGuildMessages |
discordgo.IntentsMessageContent |
discordgo.IntentsGuildVoiceStates |
discordgo.IntentsGuildMessageReactions |
discordgo.IntentsDirectMessages |
discordgo.IntentsDirectMessageReactions
discordgo.IntentsDirectMessages
return &Bot{
Session: session,
@@ -45,6 +53,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
GuildID: guildID,
Commands: cmds,
Services: services,
MusicRepo: musicRepo,
DBHost: dbHost,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
}, nil
@@ -55,7 +65,43 @@ func (b *Bot) Start() error {
return err
}
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
_ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass)
go func() {
if err := music.StartCommandListener(context.Background(), b.DBHost, b.MusicRepo); err != nil {
log.Printf("music: command listener stopped: %v", err)
}
}()
if st := tickets.Default(); st != nil {
go func() {
// The connection can drop (database restart, network blip):
// reconnect instead of silently ignoring the dashboard from then on.
for {
err := tickets.StartCommandListener(context.Background(), b.DBHost, b.Session, st)
log.Printf("tickets: command listener stopped: %v (retrying in 10s)", err)
time.Sleep(10 * time.Second)
}
}()
}
if eng := security.Start(b.Session, b.Session.State.User.ID); eng != nil {
// Moderators are exempt from heat; their permissions come from the
// gateway cache so a message never costs an API call.
eng.ChannelPerms = b.Session.State.UserChannelPermissions
eng.ResumeRaids(context.Background())
go eng.WatchRaids(context.Background(), 30*time.Second)
go eng.WatchSnapshots(context.Background())
go func() {
// Reconnect if the database connection drops, instead of
// silently ignoring the dashboard from then on.
for {
err := eng.StartCommandListener(context.Background(), b.DBHost)
log.Printf("security: command listener stopped: %v (retrying in 10s)", err)
time.Sleep(10 * time.Second)
}
}()
}
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
for _, cmd := range b.Commands {
@@ -70,9 +116,71 @@ func (b *Bot) Start() error {
b.Session.AddHandler(commands.HandleInteraction)
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if eng := security.Default(); eng != nil {
eng.OnMessage(context.Background(), m)
}
events.HandleMessageCreate(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
events.HandleBotAddedOnGuild(s, m, b.Services)
// Security runs first: someone it removes (join lock, Join Gate,
// raid) gets no welcome message or default role.
if eng := security.Default(); eng != nil && m != nil && m.Member != nil {
if eng.OnMemberJoin(context.Background(), m.GuildID, m.Member) {
events.HandleGuildMemberAddLog(s, m, b.Services)
return
}
}
// Ignore bot members for welcome/log onboarding of normal users.
if m != nil && m.Member != nil && m.User != nil && m.User.Bot {
return
}
events.HandleGuildMemberAdd(s, m, b.Services)
events.HandleGuildMemberAddLog(s, m, b.Services)
})
// Anti-nuke and unauthorized bot additions read the audit log as it
// happens (needs the Guild Moderation intent and View Audit Log).
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.GuildAuditLogEntryCreate) {
if eng := security.Default(); eng != nil && ev.AuditLogEntry != nil {
eng.OnAuditLogEntry(context.Background(), ev.GuildID, ev.AuditLogEntry)
}
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
events.HandleGuildMemberRemoveLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
events.HandleGuildMemberUpdateLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) {
events.HandleMessageUpdateLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) {
events.HandleMessageDeleteLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanAdd) {
events.HandleGuildBanAddLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanRemove) {
events.HandleGuildBanRemoveLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, t *discordgo.ThreadDelete) {
if st := tickets.Default(); st != nil && t.Channel != nil {
tickets.ThreadDeleted(context.Background(), st, t.ID)
}
})
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
events.HandleVoiceStateUpdate(s, vs, b.Services)
music.OnVoiceStateUpdate(vs)
@@ -82,10 +190,6 @@ func (b *Bot) Start() error {
music.OnVoiceServerUpdate(ev)
})
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
events.HandleMessageReactionAdd(s, r, b.Services)
})
events.StartScheduleReminderLoop(b.Session, b.Services)
events.StartTwitchLiveLoop(b.Session, b.Services)
+39 -1
View File
@@ -1,14 +1,18 @@
package commands
import (
"strings"
"velox-bot/internal/commands/config"
"velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting"
"velox-bot/internal/commands/moderation"
cmdmusic "velox-bot/internal/commands/music"
"velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule"
cmdtickets "velox-bot/internal/commands/tickets"
cmdsecurity "velox-bot/internal/commands/security"
"velox-bot/internal/commands/timezone"
"velox-bot/internal/music"
@@ -31,11 +35,18 @@ var AllCommands = []*discordgo.ApplicationCommand{
cmdmusic.Play,
cmdmusic.Queue,
cmdmusic.Volume,
cmdmusic.Music,
meeting.Meeting,
moderation.Moderation,
timezone.Timezone,
schedule.Schedule,
projects.Projects,
config.Config,
cmdtickets.Tickets,
cmdtickets.Ticket,
cmdsecurity.Security,
cmdsecurity.Lockdown,
cmdsecurity.Unlock,
}
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
@@ -52,13 +63,20 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler,
"meeting": meeting.MeetingHandler,
"moderation": moderation.ModerationHandler,
"timezone": timezone.TimezoneHandler,
"schedule": schedule.ScheduleHandler,
"projects": projects.ProjectsHandler,
"play": cmdmusic.PlayHandler,
"queue": cmdmusic.QueueHandler,
"volume": cmdmusic.VolumeHandler,
"music": cmdmusic.MusicHandler,
"config": config.ConfigHandler,
"tickets": cmdtickets.TicketsHandler,
"ticket": cmdtickets.TicketHandler,
"security": cmdsecurity.SecurityHandler,
"lockdown": cmdsecurity.LockdownHandler,
"unlock": cmdsecurity.UnlockHandler,
}
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -69,8 +87,28 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
}
case discordgo.InteractionMessageComponent:
data := i.MessageComponentData()
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
switch {
case strings.HasPrefix(data.CustomID, "music:"):
music.HandleComponent(s, i)
case strings.HasPrefix(data.CustomID, "schedule:"):
schedule.HandleComponent(s, i)
case strings.HasPrefix(data.CustomID, "projects:"):
projects.HandleComponent(s, i)
case strings.HasPrefix(data.CustomID, "tickets:"):
cmdtickets.HandleComponent(s, i)
case strings.HasPrefix(data.CustomID, "security:"):
cmdsecurity.HandleComponent(s, i)
}
case discordgo.InteractionModalSubmit:
data := i.ModalSubmitData()
switch {
case strings.HasPrefix(data.CustomID, "schedule:"):
schedule.HandleModalSubmit(s, i)
case strings.HasPrefix(data.CustomID, "projects:"):
projects.HandleModalSubmit(s, i)
case strings.HasPrefix(data.CustomID, "tickets:"):
cmdtickets.HandleModalSubmit(s, i)
}
}
}
+375 -2
View File
@@ -55,6 +55,96 @@ var Config = &discordgo.ApplicationCommand{
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setwelcomechannel",
Description: "Set the channel for welcome messages",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel",
Description: "Text channel for welcomes",
Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setwelcomemessage",
Description: "Set the welcome message template shown in the server",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "message",
Description: "Use {user} where the new member mention should appear",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setwelcomedm",
Description: "Set the welcome DM template sent to new members",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "message",
Description: "Use {user} where the new member mention should appear",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setwelcomegif",
Description: "Set the GIF/image URL used in welcome messages",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "url",
Description: "Direct image or GIF URL",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setdefaultrole",
Description: "Set the default role given to new members",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionRole,
Name: "role",
Description: "Role to assign on join",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setlogchannel",
Description: "Set the moderation log channel",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel",
Description: "Text channel for logs",
Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "enablelogging",
Description: "Enable server logging events",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "disablelogging",
Description: "Disable server logging events",
},
},
}
@@ -67,8 +157,8 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
respondEphemeral(s, i, "You need the **Manage Server** permission to use this.")
return
}
if services.Global == nil || services.Global.Twitch == nil {
respondEphemeral(s, i, "Twitch configuration is not available.")
if services.Global == nil {
respondEphemeral(s, i, "Configuration services are not available.")
return
}
@@ -88,6 +178,30 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
case "settwitchnotificationchannel":
log.Printf("twitch: /config settwitchnotificationchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetChannel(s, i)
case "setwelcomechannel":
log.Printf("welcome: /config setwelcomechannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetWelcomeChannel(s, i)
case "setwelcomemessage":
log.Printf("welcome: /config setwelcomemessage invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetWelcomeMessage(s, i)
case "setwelcomedm":
log.Printf("welcome: /config setwelcomedm invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetWelcomeDM(s, i)
case "setwelcomegif":
log.Printf("welcome: /config setwelcomegif invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetWelcomeGIF(s, i)
case "setdefaultrole":
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetDefaultRole(s, i)
case "setlogchannel":
log.Printf("log: /config setlogchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetLogChannel(s, i)
case "enablelogging":
log.Printf("log: /config enablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetLoggingEnabled(s, i, true)
case "disablelogging":
log.Printf("log: /config disablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetLoggingEnabled(s, i, false)
default:
respondEphemeral(s, i, "Unknown subcommand.")
}
@@ -193,6 +307,265 @@ func handleSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
respondEphemeral(s, i, "Twitch notifications will be sent to "+ch.Mention()+".")
}
func handleSetWelcomeChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.Welcome == nil {
respondEphemeral(s, i, "Welcome configuration is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var chOpt *discordgo.ApplicationCommandInteractionDataOption
for _, o := range opt.Options {
if o.Name == "channel" {
chOpt = o
break
}
}
if chOpt == nil {
respondEphemeral(s, i, "Missing channel option.")
return
}
ch := chOpt.ChannelValue(s)
if ch == nil {
respondEphemeral(s, i, "Invalid channel.")
return
}
chID, err := strconv.ParseInt(ch.ID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid channel ID.")
return
}
if err := services.Global.Welcome.SetChannel(context.Background(), guildID, chID); err != nil {
respondEphemeral(s, i, "Failed to set welcome channel.")
return
}
respondEphemeral(s, i, "Welcome messages will be sent to "+ch.Mention()+".")
}
func handleSetWelcomeMessage(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.Welcome == nil {
respondEphemeral(s, i, "Welcome configuration is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var msg string
for _, o := range opt.Options {
if o.Name == "message" {
msg = strings.TrimSpace(o.StringValue())
}
}
if msg == "" {
respondEphemeral(s, i, "Message cannot be empty.")
return
}
if err := services.Global.Welcome.SetMessage(context.Background(), guildID, msg); err != nil {
respondEphemeral(s, i, "Failed to set welcome message.")
return
}
respondEphemeral(s, i, "Welcome message updated.")
}
func handleSetWelcomeDM(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.Welcome == nil {
respondEphemeral(s, i, "Welcome configuration is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var msg string
for _, o := range opt.Options {
if o.Name == "message" {
msg = strings.TrimSpace(o.StringValue())
}
}
if msg == "" {
respondEphemeral(s, i, "DM message cannot be empty.")
return
}
if err := services.Global.Welcome.SetDM(context.Background(), guildID, msg); err != nil {
respondEphemeral(s, i, "Failed to set welcome DM.")
return
}
respondEphemeral(s, i, "Welcome DM updated.")
}
func handleSetWelcomeGIF(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.Welcome == nil {
respondEphemeral(s, i, "Welcome configuration is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var url string
for _, o := range opt.Options {
if o.Name == "url" {
url = strings.TrimSpace(o.StringValue())
}
}
if url == "" {
respondEphemeral(s, i, "URL cannot be empty.")
return
}
if err := services.Global.Welcome.SetGIF(context.Background(), guildID, url); err != nil {
respondEphemeral(s, i, "Failed to set welcome GIF.")
return
}
respondEphemeral(s, i, "Welcome GIF updated.")
}
func handleSetDefaultRole(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.DefaultRole == nil {
respondEphemeral(s, i, "Default role configuration is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var roleOpt *discordgo.ApplicationCommandInteractionDataOption
for _, o := range opt.Options {
if o.Name == "role" {
roleOpt = o
break
}
}
if roleOpt == nil {
respondEphemeral(s, i, "Missing role option.")
return
}
role := roleOpt.RoleValue(s, i.GuildID)
if role == nil {
respondEphemeral(s, i, "Invalid role.")
return
}
roleID, err := strconv.ParseInt(role.ID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid role ID.")
return
}
if err := services.Global.DefaultRole.SetRole(context.Background(), guildID, roleID); err != nil {
respondEphemeral(s, i, "Failed to set default role.")
return
}
respondEphemeral(s, i, "Default role set to "+role.Mention()+".")
}
func handleSetLogChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.LogSettings == nil {
respondEphemeral(s, i, "Log settings service is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var chOpt *discordgo.ApplicationCommandInteractionDataOption
for _, o := range opt.Options {
if o.Name == "channel" {
chOpt = o
break
}
}
if chOpt == nil {
respondEphemeral(s, i, "Missing channel option.")
return
}
ch := chOpt.ChannelValue(s)
if ch == nil {
respondEphemeral(s, i, "Invalid channel.")
return
}
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid channel ID.")
return
}
if err := services.Global.LogSettings.SetChannel(context.Background(), guildID, channelID); err != nil {
respondEphemeral(s, i, "Failed to set log channel.")
return
}
if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, true); err != nil {
respondEphemeral(s, i, "Log channel set, but failed to enable logging.")
return
}
respondEphemeral(s, i, "Logging enabled in "+ch.Mention()+".")
}
func handleSetLoggingEnabled(s *discordgo.Session, i *discordgo.InteractionCreate, enabled bool) {
if services.Global.LogSettings == nil {
respondEphemeral(s, i, "Log settings service is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, enabled); err != nil {
respondEphemeral(s, i, "Failed to update logging state.")
return
}
if enabled {
respondEphemeral(s, i, "Logging is now enabled.")
return
}
respondEphemeral(s, i, "Logging is now disabled.")
}
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
+53
View File
@@ -84,6 +84,25 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/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: "" +
@@ -94,6 +113,17 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/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: "" +
@@ -106,6 +136,29 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/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",
@@ -0,0 +1,578 @@
package public
import (
"fmt"
"strings"
"time"
"velox-bot/internal/db/services"
"velox-bot/internal/modlog"
"github.com/bwmarrin/discordgo"
)
var Moderation = &discordgo.ApplicationCommand{
Name: "moderation",
Description: "High-value moderation tools",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "purge",
Description: "Delete recent messages in this channel (max 100)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "amount",
Description: "How many recent messages to delete (1-100)",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "timeout",
Description: "Temporarily timeout a member",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to timeout",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "duration",
Description: "Duration like 10m, 1h, 2h30m (max 28d)",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "kick",
Description: "Kick a member from the server",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to kick",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "ban",
Description: "Ban a member from the server",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to ban",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "delete_days",
Description: "Delete message history (0-7 days)",
Required: false,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "unban",
Description: "Unban a user by ID",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "user_id",
Description: "User ID to unban",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "untimeout",
Description: "Remove timeout from a member",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to untimeout",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "slowmode",
Description: "Set channel slowmode (0 disables)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel",
Description: "Text channel to update",
Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "seconds",
Description: "Slowmode delay in seconds (0-21600)",
Required: true,
},
},
},
},
}
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.GuildID == "" {
respondEphemeral(s, i, "This command can only be used in a server.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
respondEphemeral(s, i, "Missing subcommand.")
return
}
switch data.Options[0].Name {
case "purge":
handlePurge(s, i)
case "timeout":
handleTimeout(s, i)
case "kick":
handleKick(s, i)
case "ban":
handleBan(s, i)
case "unban":
handleUnban(s, i)
case "untimeout":
handleUntimeout(s, i)
case "slowmode":
handleSlowmode(s, i)
default:
respondEphemeral(s, i, "Unknown subcommand.")
}
}
func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionManageMessages) {
respondEphemeral(s, i, "You need **Manage Messages** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var amount int64
for _, o := range opt.Options {
if o.Name == "amount" {
amount = o.IntValue()
break
}
}
if amount < 1 || amount > 100 {
respondEphemeral(s, i, "Amount must be between 1 and 100.")
return
}
msgs, err := s.ChannelMessages(i.ChannelID, int(amount), "", "", "")
if err != nil {
respondEphemeral(s, i, "Failed to fetch messages.")
return
}
if len(msgs) == 0 {
respondEphemeral(s, i, "No messages found to delete.")
return
}
tooOldCutoff := time.Now().Add(-14 * 24 * time.Hour)
ids := make([]string, 0, len(msgs))
for _, m := range msgs {
if m == nil {
continue
}
if m.Timestamp.Before(tooOldCutoff) {
continue
}
ids = append(ids, m.ID)
}
if len(ids) == 0 {
respondEphemeral(s, i, "No messages can be bulk deleted (older than 14 days).")
return
}
if len(ids) == 1 {
if err := s.ChannelMessageDelete(i.ChannelID, ids[0]); err != nil {
respondEphemeral(s, i, "Failed to delete message.")
return
}
respondEphemeral(s, i, "Deleted 1 message.")
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted 1 message in <#%s>.", i.ChannelID), "")
return
}
if err := s.ChannelMessagesBulkDelete(i.ChannelID, ids); err != nil {
respondEphemeral(s, i, "Failed to bulk delete messages.")
return
}
respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids)))
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted %d messages in <#%s>.", len(ids), i.ChannelID), "")
}
func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var durationInput string
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "duration":
durationInput = strings.TrimSpace(o.StringValue())
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
dur, err := time.ParseDuration(durationInput)
if err != nil || dur <= 0 {
respondEphemeral(s, i, "Invalid duration. Examples: `10m`, `1h`, `2h30m`.")
return
}
maxTimeout := 28 * 24 * time.Hour
if dur > maxTimeout {
respondEphemeral(s, i, "Duration cannot exceed 28 days.")
return
}
until := time.Now().UTC().Add(dur)
if err := s.GuildMemberTimeout(i.GuildID, target.ID, &until); err != nil {
respondEphemeral(s, i, "Failed to timeout user. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String()))
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason))
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), reason)
}
func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionKickMembers) {
respondEphemeral(s, i, "You need **Kick Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID {
respondEphemeral(s, i, "You cannot kick yourself.")
return
}
if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID {
respondEphemeral(s, i, "I can't kick myself.")
return
}
if err := s.GuildMemberDeleteWithReason(i.GuildID, target.ID, reason); err != nil {
respondEphemeral(s, i, "Failed to kick user. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, "Kicked "+target.Mention()+".")
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), "")
return
}
respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason)
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), reason)
}
func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var deleteDays int64
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "delete_days":
deleteDays = o.IntValue()
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
if deleteDays < 0 || deleteDays > 7 {
respondEphemeral(s, i, "delete_days must be between 0 and 7.")
return
}
if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID {
respondEphemeral(s, i, "You cannot ban yourself.")
return
}
if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID {
respondEphemeral(s, i, "I can't ban myself.")
return
}
if err := s.GuildBanCreateWithReason(i.GuildID, target.ID, reason, int(deleteDays)); err != nil {
respondEphemeral(s, i, "Failed to ban user. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays))
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason))
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), reason)
}
func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userID string
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user_id":
userID = strings.TrimSpace(o.StringValue())
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userID == "" {
respondEphemeral(s, i, "Missing user ID.")
return
}
if err := s.GuildBanDelete(i.GuildID, userID); err != nil {
respondEphemeral(s, i, "Failed to unban user. Make sure the user ID is valid and banned.")
return
}
if reason == "" {
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.")
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", "")
return
}
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason)
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", reason)
}
func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
if err := s.GuildMemberTimeout(i.GuildID, target.ID, nil); err != nil {
respondEphemeral(s, i, "Failed to remove timeout. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".")
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), "")
return
}
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason)
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), reason)
}
func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionManageChannels) {
respondEphemeral(s, i, "You need **Manage Channels** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var chOpt *discordgo.ApplicationCommandInteractionDataOption
var seconds int64
for _, o := range opt.Options {
switch o.Name {
case "channel":
chOpt = o
case "seconds":
seconds = o.IntValue()
}
}
if chOpt == nil {
respondEphemeral(s, i, "Missing channel option.")
return
}
if seconds < 0 || seconds > 21600 {
respondEphemeral(s, i, "Seconds must be between 0 and 21600.")
return
}
ch := chOpt.ChannelValue(s)
if ch == nil {
respondEphemeral(s, i, "Invalid channel.")
return
}
secondsInt := int(seconds)
_, err := s.ChannelEditComplex(ch.ID, &discordgo.ChannelEdit{
RateLimitPerUser: &secondsInt,
})
if err != nil {
respondEphemeral(s, i, "Failed to update slowmode.")
return
}
if seconds == 0 {
respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".")
sendModerationActionLog(s, i, "Slowmode", "Channel: "+ch.Mention()+"\nSet to: `0s`", "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds))
sendModerationActionLog(s, i, "Slowmode", fmt.Sprintf("Channel: %s\nSet to: `%ds`", ch.Mention(), seconds), "")
}
func hasPerm(i *discordgo.InteractionCreate, perm int64) bool {
return i.Member != nil && (i.Member.Permissions&perm) == perm
}
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func sendModerationActionLog(s *discordgo.Session, i *discordgo.InteractionCreate, action, details, reason string) {
if services.Global == nil {
return
}
moderator := "Unknown"
if i != nil && i.Member != nil && i.Member.User != nil {
moderator = i.Member.User.Mention()
}
desc := "Moderator: " + moderator + "\n" + details
if strings.TrimSpace(reason) != "" {
desc += "\nReason: " + reason
}
embed := &discordgo.MessageEmbed{
Title: "Moderation Action: " + action,
Color: 0x5865F2,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
_ = modlog.Send(s, services.Global, i.GuildID, embed)
}
+16
View File
@@ -0,0 +1,16 @@
package moderation
import (
"velox-bot/internal/commands/moderation/public"
"github.com/bwmarrin/discordgo"
)
var (
Moderation *discordgo.ApplicationCommand = public.Moderation
)
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.ModerationHandler(s, i)
}
+6 -41
View File
@@ -2,8 +2,7 @@ package public
import (
"fmt"
"net/url"
"strings"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
@@ -23,6 +22,10 @@ var Play = &discordgo.ApplicationCommand{
}
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireMusicEnabled(s, i) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
@@ -44,13 +47,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
// If user provided plain text, turn it into a YouTube search
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
query = "ytsearch:" + query
} else {
query = normalizeYouTubeRadioURL(query)
}
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
@@ -59,7 +55,7 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username, i.Member.User.ID)
if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Error: %v", err)),
@@ -85,37 +81,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
func ptr[T any](v T) *T { return new(v) }
// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like:
// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID
// It intentionally does NOT strip normal playlist URLs.
func normalizeYouTubeRadioURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u == nil {
return raw
}
host := strings.ToLower(u.Host)
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
return raw
}
q := u.Query()
v := q.Get("v")
if v == "" {
return raw
}
list := q.Get("list")
_, hasStartRadio := q["start_radio"]
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
return raw
}
u.RawQuery = url.Values{"v": []string{v}}.Encode()
u.Fragment = ""
return u.String()
}
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
g, err := s.State.Guild(guildID)
if err != nil {
+4 -1
View File
@@ -3,6 +3,7 @@ package public
import (
"fmt"
"strings"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
@@ -14,6 +15,9 @@ var Queue = &discordgo.ApplicationCommand{
}
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireMusicEnabled(s, i) {
return
}
if !memberHasDJRole(s, i.GuildID, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -74,4 +78,3 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
},
})
}
+63
View File
@@ -0,0 +1,63 @@
package public
import (
"context"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
var Music = &discordgo.ApplicationCommand{
Name: "music",
Description: "Music settings",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "toggle",
Description: "Turn the music player on or off for this server",
},
},
}
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.GuildID == "" {
shared.RespondEphemeral(s, i, "This command can only be used in a server.")
return
}
if !shared.RequireManageGuild(s, i) {
return
}
if !shared.RequireMusicService(s, i) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
shared.RespondEphemeral(s, i, "Missing subcommand.")
return
}
switch data.Options[0].Name {
case "toggle":
handleToggle(s, i)
}
}
func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
enabled, err := services.Global.MusicSettings.ToggleMusic(context.Background(), guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to toggle music playback.")
return
}
status := "disabled"
if enabled {
status = "enabled"
}
shared.RespondEphemeral(s, i, "Music playback "+status+" for this server.")
}
+4 -1
View File
@@ -2,6 +2,7 @@ package public
import (
"fmt"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
@@ -23,6 +24,9 @@ var Volume = &discordgo.ApplicationCommand{
}
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireMusicEnabled(s, i) {
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@@ -60,4 +64,3 @@ func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
}
func ptrFloat(v float64) *float64 { return &v }
+2 -1
View File
@@ -10,6 +10,7 @@ var (
Play *discordgo.ApplicationCommand = public.Play
Queue *discordgo.ApplicationCommand = public.Queue
Volume *discordgo.ApplicationCommand = public.Volume
Music *discordgo.ApplicationCommand = public.Music
)
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
@@ -17,4 +18,4 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.QueueHandler(s, i)
}
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MusicHandler(s, i) }
+61
View File
@@ -0,0 +1,61 @@
package shared
import (
"context"
"strconv"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func RequireMusicEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
guildID, ok := ParseGuildID(s, i)
if !ok {
return false
}
enabled, err := services.Global.MusicSettings.IsMusicEnabled(context.Background(), guildID)
if err != nil {
RespondEphemeral(s, i, "Failed to load music settings.")
return false
}
if !enabled {
RespondEphemeral(s, i, "Music is disabled on this server. Use /music toggle to enable it.")
return false
}
return true
}
func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 {
RespondEphemeral(s, i, "You need the **Manage Server** permission to use this.")
return false
}
return true
}
func RequireMusicService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if services.Global == nil || services.Global.MusicSettings == nil {
RespondEphemeral(s, i, "Music service is not available.")
return false
}
return true
}
func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) {
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
RespondEphemeral(s, i, "Invalid guild ID.")
return 0, false
}
return guildID, true
}
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
@@ -0,0 +1,222 @@
package public
import (
"context"
"strconv"
"strings"
"velox-bot/internal/commands/projects/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleComponent routes the select menus shown after /projects add-helper
// and /projects close.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.Split(i.MessageComponentData().CustomID, ":")
if len(parts) < 2 {
return
}
switch parts[1] {
case "add_helper_project_select":
handleAddHelperProjectSelect(s, i)
case "add_helper_user_select":
handleAddHelperUserSelect(s, i, parts)
case "close_select":
handleCloseSelect(s, i)
}
}
// handleAddHelperProjectSelect is step 1: a project was picked, now show a
// native Discord user-select to pick who helps - the actual AddHelper call
// happens in handleAddHelperUserSelect once both are known.
func handleAddHelperProjectSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
values := i.MessageComponentData().Values
if len(values) == 0 {
return
}
projectID := values[0]
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Who should help with this project?",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
MenuType: discordgo.UserSelectMenu,
CustomID: "projects:add_helper_user_select:" + projectID,
Placeholder: "Select a user",
},
},
},
},
},
})
}
// handleAddHelperUserSelect is step 2: a user was picked from the native
// select menu, so both the project (carried in the CustomID) and the user
// (the selected value) are now known - perform the actual AddHelper call.
func handleAddHelperUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate, parts []string) {
if !shared.RequireManageGuild(s, i) {
return
}
if len(parts) < 3 {
return
}
projectID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
values := i.MessageComponentData().Values
if len(values) == 0 {
return
}
userID := values[0]
userID64, err := strconv.ParseInt(userID, 10, 64)
if err != nil {
return
}
ctx := context.Background()
project, err := services.Global.Projects.GetProject(ctx, guildID, projectID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load project.")
return
}
if project == nil {
shared.RespondEphemeral(s, i, "That project no longer exists in this server.")
return
}
if err := services.Global.Projects.AddHelper(ctx, projectID, userID64); err != nil {
shared.RespondEphemeral(s, i, "Failed to add helper to project.")
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Added <@" + userID + "> as helper to project **" + project.Name + "**.",
Components: []discordgo.MessageComponent{},
},
})
}
func handleCloseSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
values := i.MessageComponentData().Values
if len(values) == 0 {
return
}
projectID, err := strconv.ParseInt(values[0], 10, 64)
if err != nil {
return
}
ctx := context.Background()
project, err := services.Global.Projects.GetProject(ctx, guildID, projectID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load project.")
return
}
if project == nil {
shared.RespondEphemeral(s, i, "That project no longer exists in this server.")
return
}
if err := services.Global.Projects.DeactivateProject(ctx, guildID, projectID); err != nil {
shared.RespondEphemeral(s, i, "Failed to close project.")
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Closed project **" + project.Name + "**.",
Components: []discordgo.MessageComponent{},
},
})
}
// HandleModalSubmit handles the /projects create modal.
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.Split(i.ModalSubmitData().CustomID, ":")
if len(parts) < 2 || parts[1] != "create_modal" {
return
}
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
if i.Member == nil || i.Member.User == nil {
shared.RespondEphemeral(s, i, "Missing member info.")
return
}
values := modalTextInputValues(i.ModalSubmitData().Components)
name := strings.TrimSpace(values["name"])
description := strings.TrimSpace(values["description"])
if name == "" {
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
return
}
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to create project.")
return
}
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
}
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
values := make(map[string]string)
for _, c := range components {
row, ok := c.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, rc := range row.Components {
ti, ok := rc.(*discordgo.TextInput)
if !ok {
continue
}
values[ti.CustomID] = ti.Value
}
}
return values
}
+138 -107
View File
@@ -24,39 +24,16 @@ var Projects = &discordgo.ApplicationCommand{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "create",
Description: "Create a new active project",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "name",
Description: "Project name",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "description",
Description: "Short description (optional)",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add-helper",
Description: "Add a helper to an existing project",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "project-id",
Description: "ID of the project",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to add as helper",
Required: true,
},
},
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "close",
Description: "Close (deactivate) a project",
},
},
}
@@ -79,6 +56,8 @@ func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleCreate(s, i)
case "add-helper":
handleAddHelper(s, i)
case "close":
handleClose(s, i)
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
}
@@ -154,56 +133,53 @@ func mentionUser(id int64) string {
return "<@" + strconv.FormatInt(id, 10) + ">"
}
// handleCreate just opens the modal - the actual creation happens on
// submit, in HandleModalSubmit (components.go).
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
if i.Member == nil || i.Member.User == nil {
shared.RespondEphemeral(s, i, "Missing member info.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
shared.RespondEphemeral(s, i, "Missing options.")
return
}
opt := data.Options[0]
var name, description string
for _, o := range opt.Options {
switch o.Name {
case "name":
name = o.StringValue()
case "description":
description = o.StringValue()
}
}
if strings.TrimSpace(name) == "" {
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
return
}
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to create project.")
return
}
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "projects:create_modal",
Title: "Create a project",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "name",
Label: "Project name",
Style: discordgo.TextInputShort,
Required: true,
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "description",
Label: "Description (optional)",
Style: discordgo.TextInputParagraph,
Required: false,
},
},
},
},
},
})
}
// handleAddHelper collects the user via Discord's native user-option (a
// modal can't hold one of these), then responds with a select menu of the
// guild's active projects instead of making the caller remember/type a
// numeric project ID - the actual add-helper call happens on selection, in
// HandleComponent (components.go).
// handleAddHelper is the first of two dropdown steps: pick a project here,
// then HandleComponent (components.go) follows up with a native Discord
// user-select to pick who helps, and performs the actual AddHelper call
// once both are known.
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
@@ -214,55 +190,110 @@ func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
shared.RespondEphemeral(s, i, "Missing options.")
return
}
opt := data.Options[0]
var projectID int64
var userID string
for _, o := range opt.Options {
switch o.Name {
case "project-id":
projectID = o.IntValue()
case "user":
if u := o.UserValue(s); u != nil {
userID = u.ID
}
}
}
if projectID <= 0 {
shared.RespondEphemeral(s, i, "Invalid project ID.")
return
}
if userID == "" {
shared.RespondEphemeral(s, i, "Invalid user.")
return
}
exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID)
options, err := activeProjectOptions(context.Background(), guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load project.")
shared.RespondEphemeral(s, i, "Failed to load projects.")
return
}
if !exists {
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.")
if len(options) == 0 {
shared.RespondEphemeral(s, i, "There are no active projects to add a helper to.")
return
}
userID64, err := strconv.ParseInt(userID, 10, 64)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Which project should get a new helper?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "projects:add_helper_project_select",
Placeholder: "Select a project",
Options: options,
},
},
},
},
},
})
}
// handleClose responds with a select menu of the guild's active projects -
// same reasoning as handleAddHelper, picking from a live list beats typing
// an ID. The actual deactivation happens on selection, in HandleComponent.
func handleClose(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
options, err := activeProjectOptions(context.Background(), guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
shared.RespondEphemeral(s, i, "Failed to load projects.")
return
}
if len(options) == 0 {
shared.RespondEphemeral(s, i, "There are no active projects to close.")
return
}
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil {
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")")
return
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Which project should be closed?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "projects:close_select",
Placeholder: "Select a project",
Options: options,
},
},
},
},
},
})
}
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.")
// activeProjectOptions builds select-menu options for the guild's active
// projects, truncating name/description to Discord's option limits
// (label: 100 chars, description: 100 chars).
func activeProjectOptions(ctx context.Context, guildID int64) ([]discordgo.SelectMenuOption, error) {
const limit = 25 // Discord's own cap on select menu options
items, err := services.Global.Projects.ListActive(ctx, guildID, limit)
if err != nil {
return nil, err
}
options := make([]discordgo.SelectMenuOption, 0, len(items))
for _, p := range items {
if p == nil {
continue
}
name := p.Name
if name == "" {
name = "Unnamed project"
}
options = append(options, discordgo.SelectMenuOption{
Label: truncate(name, 100),
Value: strconv.FormatInt(p.ID, 10),
Description: truncate(p.Description, 100),
})
}
return options, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
+6
View File
@@ -12,3 +12,9 @@ var (
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HandleComponent(s, i) }
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleModalSubmit(s, i)
}
@@ -0,0 +1,794 @@
package public
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/commands/schedule/shared"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func scheduleActionButtons(scheduleID int64) []discordgo.MessageComponent {
idStr := strconv.FormatInt(scheduleID, 10)
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Style: discordgo.SuccessButton,
CustomID: "schedule:accept:" + idStr,
},
discordgo.Button{
Label: "Decline",
Style: discordgo.DangerButton,
CustomID: "schedule:decline:" + idStr,
},
discordgo.Button{
Label: "Reschedule",
Style: discordgo.SecondaryButton,
CustomID: "schedule:reschedule:" + idStr,
},
},
},
}
}
func rescheduleProposalButtons(scheduleID int64) []discordgo.MessageComponent {
idStr := strconv.FormatInt(scheduleID, 10)
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Style: discordgo.SuccessButton,
CustomID: "schedule:accept_reschedule:" + idStr,
},
discordgo.Button{
Label: "Decline",
Style: discordgo.DangerButton,
CustomID: "schedule:decline_reschedule:" + idStr,
},
discordgo.Button{
Label: "Propose different time",
Style: discordgo.SecondaryButton,
CustomID: "schedule:reschedule:" + idStr,
},
},
},
}
}
const createUserSelectCustomID = "schedule:create_user_select"
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.MessageComponentData().CustomID == createUserSelectCustomID {
handleCreateUserSelect(s, i)
return
}
ctx := context.Background()
action, scheduleID := parseComponentCustomID(i.MessageComponentData().CustomID)
userIDStr := interactionUserID(i)
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error parsing user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
if err != nil || sch == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error retrieving schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
var status schedulerepo.ScheduleStatus
var message string
switch action {
case "accept":
if userID != sch.InviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule is no longer pending.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
status = schedulerepo.StatusAccepted
message = "✅ Accepted"
case "decline":
if userID != sch.InviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule is no longer pending.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
status = schedulerepo.StatusDeclined
message = "❌ Declined"
case "reschedule":
if userID != sch.InviteeID && userID != sch.RequesterID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule can't be rescheduled right now.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "schedule:reschedule_modal:" + strconv.FormatInt(scheduleID, 10),
Title: "Propose a new time",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "date",
Label: "Date (YYYY-MM-DD)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "2026-01-02",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "time",
Label: "Time (HH:MM, your timezone)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "15:04",
},
},
},
},
},
})
return
case "accept_reschedule":
if sch.ProposedBy == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule proposal to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusRescheduleRequested {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule request to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.AcceptReschedule(ctx, scheduleID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error accepting reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "✅ Reschedule accepted",
Components: []discordgo.MessageComponent{},
},
})
newTime := *sch.ProposedAt
utcStr := newTime.UTC().Format("2006-01-02 15:04")
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
if err != nil {
loc = time.UTC
zone = "UTC"
}
localStr := newTime.In(loc).Format("2006-01-02 15:04")
content := "Your proposed time for the session was accepted: " +
utcStr + " UTC (" + localStr + " " + zone + ")."
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM proposer about accepted reschedule: %v", err)
}
}
return
case "decline_reschedule":
if sch.ProposedBy == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule proposal to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusRescheduleRequested {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule request to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.DeclineReschedule(ctx, scheduleID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error declining reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "❌ Reschedule declined",
Components: []discordgo.MessageComponent{},
},
})
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
if err != nil {
loc = time.UTC
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your proposed new time was declined. The session remains scheduled for " +
utcStr + " UTC (" + localStr + " " + zone + ")."
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM proposer about declined reschedule: %v", err)
}
}
return
default:
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Unknown action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.UpdateStatus(ctx, scheduleID, status)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error updating schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: message,
Components: []discordgo.MessageComponent{},
},
})
statusText := map[schedulerepo.ScheduleStatus]string{
schedulerepo.StatusAccepted: "accepted ✅",
schedulerepo.StatusDeclined: "declined ❌",
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
}[status]
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
// Notify requester with local time if configured.
if services.Global.UserSettings != nil {
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
requesterID := strconv.FormatInt(sch.RequesterID, 10)
dmCh, err := s.UserChannelCreate(requesterID)
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM requester about status change: %v", err)
}
}
}
}
func handleCreateUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
values := i.MessageComponentData().Values
if len(values) == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "No user selected.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
targetUserID := values[0]
if targetUserID == interactionUserID(i) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You cannot schedule a session with yourself.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "schedule:create_modal:" + targetUserID,
Title: "Schedule a session",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "date",
Label: "Date (YYYY-MM-DD)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "2026-01-02",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "time",
Label: "Time (HH:MM, your timezone)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "15:04",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "description",
Label: "Description (optional)",
Style: discordgo.TextInputShort,
Required: false,
},
},
},
},
},
})
}
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
values := make(map[string]string)
for _, c := range components {
row, ok := c.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, rc := range row.Components {
ti, ok := rc.(*discordgo.TextInput)
if !ok {
continue
}
values[ti.CustomID] = ti.Value
}
}
return values
}
func handleCreateModalSubmit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, inviteeID int64) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
requesterID, err := strconv.ParseInt(interactionUserID(i), 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if requesterID == inviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You cannot schedule a session with yourself.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
values := modalTextInputValues(i.ModalSubmitData().Components)
dateStr := strings.TrimSpace(values["date"])
timeStr := strings.TrimSpace(values["time"])
desc := strings.TrimSpace(values["description"])
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, requesterID)
if err != nil {
loc = time.UTC
zone = "UTC"
}
when, err := shared.ParseScheduleDatetime(dateStr+" "+timeStr, loc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time, in your timezone (" + zone + ").",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
when = when.In(time.UTC)
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Failed to create schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
dmCh, err := s.UserChannelCreate(strconv.FormatInt(inviteeID, 10))
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session created, but failed to DM the invited user (are DMs disabled?).",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
embed := &discordgo.MessageEmbed{
Title: "New session request",
Description: fmt.Sprintf("You have been invited to a session by <@%d>.", requesterID),
Color: 0x4caf50,
}
utcStr := when.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(ctx, inviteeID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{Name: "When (UTC)", Value: utcStr},
{Name: "When (" + zoneInv + ")", Value: localInvStr},
}
if desc != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Description",
Value: desc,
})
}
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "Respond to this session request:",
Embed: embed,
Components: scheduleActionButtons(scheduleID),
})
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session created, but failed to DM the invited user.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session request created and sent to <@" + strconv.FormatInt(inviteeID, 10) + ">.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
action, idOrUserID := parseComponentCustomID(i.ModalSubmitData().CustomID)
if action == "create_modal" {
handleCreateModalSubmit(ctx, s, i, idOrUserID)
return
}
scheduleID := idOrUserID
if scheduleID == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid schedule ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
modalValues := modalTextInputValues(i.ModalSubmitData().Components)
datetimeInput := strings.TrimSpace(modalValues["date"]) + " " + strings.TrimSpace(modalValues["time"])
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
if err != nil || sch == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error retrieving schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
userIDStr := interactionUserID(i)
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error parsing user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if userID != sch.InviteeID && userID != sch.RequesterID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
loc, _, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
}
newTime, err := shared.ParseScheduleDatetime(datetimeInput, loc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
//Convert time to UTC before storing in the database
newTime = newTime.UTC()
err = services.Global.Schedule.ProposeReschedule(ctx, scheduleID, newTime, userID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error proposing reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
otherID := sch.RequesterID
if userID == sch.RequesterID {
otherID = sch.InviteeID
}
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
return
}
embed := &discordgo.MessageEmbed{
Title: "Reschedule Proposal",
Description: fmt.Sprintf("<@%d> has proposed a new time for your session request. Please respond.", userID),
Color: 0x4caf50,
}
// Show both UTC and invitee-local time if available.
utcStr := newTime.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), otherID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := newTime.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: utcStr,
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
},
}
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "A new time has been proposed for your session:",
Embed: embed,
Components: rescheduleProposalButtons(scheduleID),
})
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the other participant.")
return
}
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
}
func parseComponentCustomID(customID string) (string, int64) {
parts := strings.Split(customID, ":")
if len(parts) != 3 {
return "", 0
}
action := parts[1]
scheduleID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", 0
}
return action, scheduleID
}
func interactionUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
+47 -197
View File
@@ -8,6 +8,7 @@ import (
"time"
"velox-bot/internal/commands/schedule/shared"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
@@ -21,26 +22,6 @@ var Schedule = &discordgo.ApplicationCommand{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "create",
Description: "Create a new session with a user",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to invite",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "datetime",
Description: "When (UTC), format: 2006-01-02 15:04",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "description",
Description: "What is this session about? (optional)",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
@@ -93,8 +74,7 @@ func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
}
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
if _, ok := shared.ParseGuildID(s, i); !ok {
return
}
if i.Member == nil || i.Member.User == nil {
@@ -102,135 +82,26 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
shared.RespondEphemeral(s, i, "Missing options.")
return
}
opt := data.Options[0]
var (
targetUser *discordgo.User
whenStr string
desc string
)
for _, o := range opt.Options {
switch o.Name {
case "user":
targetUser = o.UserValue(s)
case "datetime":
whenStr = o.StringValue()
case "description":
desc = o.StringValue()
}
}
if targetUser == nil {
shared.RespondEphemeral(s, i, "Invalid user.")
return
}
if targetUser.ID == i.Member.User.ID {
shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.")
return
}
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid target user ID.")
return
}
whenStr = strings.TrimSpace(whenStr)
if whenStr == "" {
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
return
}
// Resolve requester's timezone and parse input using explicit layout in that zone.
loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID)
if err != nil {
loc = time.UTC
zone = "UTC"
}
// Expect layout "2006-01-02 15:04" in the user's timezone.
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
return
}
when = when.In(time.UTC)
ctx := context.Background()
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to create schedule.")
return
}
// Send DM to invitee.
dmCh, err := s.UserChannelCreate(targetUser.ID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
return
}
embed := &discordgo.MessageEmbed{
Title: "New session request",
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
Color: 0x4caf50,
}
// Show both UTC and invitee-local time if available.
utcStr := when.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: utcStr,
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Who do you want to schedule a session with?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
MenuType: discordgo.UserSelectMenu,
CustomID: createUserSelectCustomID,
Placeholder: "Select a user",
},
},
},
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
},
}
if desc != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Description",
Value: desc,
})
}
msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.",
Embed: embed,
})
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user.")
return
}
// Add reactions for interaction.
for _, emoji := range []string{"✅", "❌", "🔁"} {
_ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji)
}
// Store linkage between message and schedule.
msgID64, _ := strconv.ParseInt(msg.ID, 10, 64)
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
_ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true)
shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".")
}
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
@@ -338,6 +209,11 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
shared.RespondEphemeral(s, i, "This session can't be rescheduled right now.")
return
}
// Use rescheduler's timezone for parsing.
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
if err != nil {
@@ -345,19 +221,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
zone = "UTC"
}
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
when, err := shared.ParseScheduleDatetime(whenStr, loc)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
shared.RespondEphemeral(s, i, err.Error()+" (use `2006-01-02 15:04` in your timezone: "+zone+")")
return
}
when = when.In(time.UTC)
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
if err := services.Global.Schedule.ProposeReschedule(ctx, sch.ID, when, userID); err != nil {
shared.RespondEphemeral(s, i, "Failed to propose reschedule.")
return
}
// Notify participants via DM and send new request DM to invitee.
whenFmt := when.Format("2006-01-02 15:04")
otherID := sch.InviteeID
@@ -365,51 +240,32 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
otherID = sch.RequesterID
}
// DM both about the change, including their local times if available.
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
if err != nil {
locU = time.UTC
zoneU = "UTC"
}
localU := when.In(locU).Format("2006-01-02 15:04")
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
if sch.Description != "" {
msg += "\nTopic: " + sch.Description
}
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
if err != nil {
continue
}
_, _ = s.ChannelMessageSend(ch.ID, msg)
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).")
return
}
// Send new "request" DM to invitee with reactions again.
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
inviteeUser, err := s.User(inviteeStr)
if err == nil && inviteeUser != nil {
dmCh, err := s.UserChannelCreate(inviteeStr)
if err == nil {
embed := &discordgo.MessageEmbed{
Title: "Rescheduled session",
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
Color: 0xffc107,
Title: "Reschedule proposed",
Description: fmt.Sprintf("<@%s> has proposed a new time for your session.", i.Member.User.ID),
Color: 0x4caf50,
}
// Show both UTC and invitee-local time if available.
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
// Show both UTC and the other participant's local time if available.
locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID)
if errTZ != nil {
locInv = time.UTC
zoneInv = "UTC"
locOther = time.UTC
zoneOther = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
localOtherStr := when.In(locOther).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: whenFmt,
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
Name: "When (" + zoneOther + ")",
Value: localOtherStr,
},
}
if sch.Description != "" {
@@ -418,22 +274,16 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
Value: sch.Description,
})
}
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.",
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "A new time has been proposed for your session:",
Embed: embed,
Components: rescheduleProposalButtons(sch.ID),
})
if err == nil && msgObj != nil {
for _, emoji := range []string{"✅", "❌", "🔁"} {
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji)
}
msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64)
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
_ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true)
}
}
if err != nil {
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.")
return
}
shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.")
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
}
+9 -1
View File
@@ -10,5 +10,13 @@ var (
Schedule *discordgo.ApplicationCommand = public.Schedule
)
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) }
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.ScheduleHandler(s, i)
}
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleComponent(s, i)
}
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleModalSubmit(s, i)
}
+18 -1
View File
@@ -1,13 +1,22 @@
package shared
import (
"fmt"
"strconv"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
var scheduleDatetimeLayouts = []string{
"2006-01-02 15:04",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"01/02/2006 15:04",
}
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -18,6 +27,15 @@ func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg
})
}
func ParseScheduleDatetime(input string, loc *time.Location) (time.Time, error) {
for _, layout := range scheduleDatetimeLayouts {
if t, err := time.ParseInLocation(layout, input, loc); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("invalid schedule datetime format")
}
func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if i.GuildID == "" {
RespondEphemeral(s, i, "This command can only be used in a server.")
@@ -42,4 +60,3 @@ func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64,
}
return guildID, true
}
@@ -0,0 +1,575 @@
package public
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"velox-bot/internal/db/repos/securityrepo"
"velox-bot/internal/security"
"github.com/bwmarrin/discordgo"
)
// Every handler re-checks permissions through the security engine. The
// DefaultMemberPermissions below only hide commands from the menu; server
// admins can change them, so they are never what keeps anyone out.
var (
manageGuild = int64(discordgo.PermissionManageGuild)
manageChannel = int64(discordgo.PermissionManageChannels)
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}
chatChannels = []discordgo.ChannelType{
discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice,
}
opTimeout = 3 * time.Minute
genericFailed = "Something went wrong. Check that Velox's role is high enough and has the permissions it needs, then try again."
)
var featureChoices = []*discordgo.ApplicationCommandOptionChoice{
{Name: "Join Gate", Value: securityrepo.FeatureJoinGate},
{Name: "Raid mode", Value: securityrepo.FeatureAntiRaid},
{Name: "Anti-nuke", Value: securityrepo.FeatureAntiNuke},
{Name: "Heat (anti-spam)", Value: securityrepo.FeatureHeat},
}
var Security = &discordgo.ApplicationCommand{
Name: "security",
Description: "Anti-raid and anti-nuke protection",
DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "feature", Description: "Which protection", Required: true, Choices: featureChoices},
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "on", Description: "On or off", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "alerts", Description: "Where alerts go and which role they ping (owner or trusted)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Alert channel", Required: true, ChannelTypes: textChannels},
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Role to ping for raids and nukes"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantine-role", Description: "Role given to quarantined members (owner or trusted)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Leave empty to use none"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: "trust", Description: "Who is exempt from anti-nuke (owner only)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Trust a user or a role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Stop trusting a user or a role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
},
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "release", Description: "Release a quarantined member (owner or trusted)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to release", Required: true},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantined", Description: "List quarantined members"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
},
}
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
verb := "Lock"
if unlock {
verb = "Unlock"
}
joins := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "joins", Description: verb + " joins"}
if !unlock {
joins.Description = "Kick or ban everyone who joins until unlocked"
joins.Options = []*discordgo.ApplicationCommandOption{{
Type: discordgo.ApplicationCommandOptionString, Name: "action", Description: "What happens to new members", Required: true,
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "Kick", Value: securityrepo.ActionKick}, {Name: "Ban", Value: securityrepo.ActionBan}},
}}
}
return []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "channel", Description: verb + " one channel",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Defaults to this channel", ChannelTypes: chatChannels}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "server", Description: verb + " every channel"},
joins,
}
}
var Lockdown = &discordgo.ApplicationCommand{
Name: "lockdown", Description: "Stop members from talking, or from joining", DefaultMemberPermissions: &manageChannel,
Contexts: guildOnly, Options: lockOptions(false),
}
var Unlock = &discordgo.ApplicationCommand{
Name: "unlock", Description: "Undo a lockdown (owner or trusted)", DefaultMemberPermissions: &manageChannel,
Contexts: guildOnly, Options: lockOptions(true),
}
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}},
})
}
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
}) == nil
}
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
}
func message(where string, err error) string {
var ue security.UserError
if errors.As(err, &ue) {
return string(ue)
}
log.Printf("security command %s: %v", where, err)
return genericFailed
}
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, x := range o {
m[x.Name] = x
}
return m
}
func perms(i *discordgo.InteractionCreate) int64 {
if i.Member == nil {
return 0
}
return i.Member.Permissions
}
func userID(i *discordgo.InteractionCreate) string {
if i.Member == nil || i.Member.User == nil {
return ""
}
return i.Member.User.ID
}
func engine(s *discordgo.Session, i *discordgo.InteractionCreate) *security.Engine {
e := security.Default()
if e == nil || i.GuildID == "" || i.Member == nil {
reply(s, i, "Security isn't available right now.")
return nil
}
return e
}
// allowedOrTrusted lets an action through when the member has one of the
// permissions, or is the owner or trusted.
func allowedOrTrusted(ctx context.Context, e *security.Engine, i *discordgo.InteractionCreate, need int64) bool {
if perms(i)&(need|discordgo.PermissionAdministrator) != 0 {
return true
}
return e.RequireTrusted(ctx, i.GuildID, i.Member) == nil
}
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
switch sub.Name {
case "status":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security settings.")
return
}
reply(s, i, status(ctx, e, i.GuildID))
case "toggle":
feature, on := args["feature"].StringValue(), args["on"].BoolValue()
if err := e.SetFeature(ctx, i.GuildID, i.Member, perms(i), feature, on); err != nil {
reply(s, i, message("toggle", err))
return
}
state := "off"
if on {
state = "on"
}
reply(s, i, fmt.Sprintf("%s is now **%s**.", featureName(feature), state))
case "alerts":
ch := args["channel"].ChannelValue(nil).ID
role := ""
if r, ok := args["role"]; ok {
role = r.RoleValue(nil, i.GuildID).ID
}
if err := e.SetAlerts(ctx, i.GuildID, i.Member, ch, role); err != nil {
reply(s, i, message("alerts", err))
return
}
reply(s, i, fmt.Sprintf("Alerts will go to <#%s>.", ch))
case "quarantine-role":
role := ""
if r, ok := args["role"]; ok {
role = r.RoleValue(nil, i.GuildID).ID
}
if err := e.SetQuarantineRole(ctx, i.GuildID, i.Member, role); err != nil {
reply(s, i, message("quarantine-role", err))
return
}
if role == "" {
reply(s, i, "Quarantined members won't get a role; they still lose their roles and are timed out.")
} else {
reply(s, i, fmt.Sprintf("Quarantined members will get <@&%s>.", role))
}
case "trust":
if len(sub.Options) == 0 {
return
}
handleTrust(ctx, s, i, e, sub.Options[0])
case "release":
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("release", err))
return
}
target := args["user"].UserValue(nil)
if !deferReply(s, i) {
return
}
if err := e.Release(ctx, i.GuildID, target.ID, userID(i)); err != nil {
edit(s, i, message("release", err))
return
}
edit(s, i, fmt.Sprintf("<@%s> was released and got their roles back.", target.ID))
case "quarantined":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see this.")
return
}
qs, err := e.Quarantines(ctx, i.GuildID)
if err != nil {
reply(s, i, message("quarantined", err))
return
}
if len(qs) == 0 {
reply(s, i, "Nobody is quarantined.")
return
}
var b strings.Builder
for _, q := range qs {
fmt.Fprintf(&b, "• <@%d>, <t:%d:R>: %s\n", q.UserID, q.CreatedAt.Unix(), q.Reason)
}
reply(s, i, clip(b.String(), 1900))
case "end-raid":
endRaid(ctx, s, i, e)
case "snapshot":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to take a snapshot.")
return
}
if !deferReply(s, i) {
return
}
roles, channels, err := e.TakeSnapshot(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("snapshot", err))
return
}
edit(s, i, fmt.Sprintf("Snapshot saved: %d roles and %d channels. The last three are kept.", roles, channels))
case "restore":
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("restore", err))
return
}
if !deferReply(s, i) {
return
}
// Recreating many channels takes a while under Discord's rate limits.
res, err := e.Restore(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("restore", err))
return
}
edit(s, i, fmt.Sprintf("Restored from the snapshot of <t:%d:f>: %d roles and %d channels recreated, %d roles had dangerous permissions reverted, %d steps failed.",
res.SnapshotAt.Unix(), res.RolesCreated, res.ChannelsCreated, res.RolesReverted, res.Failed))
}
}
func handleTrust(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, sub *discordgo.ApplicationCommandInteractionDataOption) {
args := opts(sub.Options)
kind, target := "", ""
if u, ok := args["user"]; ok {
kind, target = "user", u.UserValue(nil).ID
}
if r, ok := args["role"]; ok {
if target != "" {
reply(s, i, "Pick a user or a role, not both.")
return
}
kind, target = "role", r.RoleValue(nil, i.GuildID).ID
}
if target == "" {
reply(s, i, "Pick a user or a role.")
return
}
mention := "<@" + target + ">"
if kind == "role" {
mention = "<@&" + target + ">"
}
switch sub.Name {
case "add":
if err := e.Trust(ctx, i.GuildID, i.Member, kind, target); err != nil {
reply(s, i, message("trust add", err))
return
}
reply(s, i, mention+" is now trusted: exempt from anti-nuke and able to change security settings.")
case "remove":
if err := e.Untrust(ctx, i.GuildID, i.Member, target); err != nil {
reply(s, i, message("trust remove", err))
return
}
reply(s, i, mention+" is no longer trusted.")
}
}
func endRaid(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine) {
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("end raid", err))
return
}
if !deferReply(s, i) {
return
}
if err := e.EndRaid(ctx, i.GuildID, userID(i)); err != nil {
edit(s, i, message("end raid", err))
return
}
edit(s, i, "Raid mode is off.")
}
func featureName(f string) string {
switch f {
case securityrepo.FeatureJoinGate:
return "Join Gate"
case securityrepo.FeatureAntiRaid:
return "Raid mode"
case securityrepo.FeatureAntiNuke:
return "Anti-nuke"
case securityrepo.FeatureHeat:
return "Heat"
}
return f
}
func onOff(b bool) string {
if b {
return "✅ on"
}
return "⬜ off"
}
func status(ctx context.Context, e *security.Engine, guildID string) string {
st, trusted, err := e.Settings(ctx, guildID)
if err != nil {
return message("status", err)
}
var b strings.Builder
fmt.Fprintf(&b, "**Join Gate:** %s\n**Raid mode:** %s (%d joins in %ds → %s)\n**Anti-nuke:** %s (auto restore %s)\n**Heat:** %s (%s)\n",
onOff(st.JoinGateEnabled), onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction,
onOff(st.AntiNukeEnabled), onOff(st.AntiNukeAutoRestore), onOff(st.HeatEnabled), st.HeatStrictness)
if e.RaidActive(guildID) {
b.WriteString("🚨 **A raid is in progress.**\n")
}
if st.JoinLock != securityrepo.ActionOff {
fmt.Fprintf(&b, "🔒 Joins are locked (%s).\n", st.JoinLock)
}
if st.AlertChannelID != 0 {
fmt.Fprintf(&b, "**Alerts:** <#%d>", st.AlertChannelID)
if st.AlertRoleID != 0 {
fmt.Fprintf(&b, ", pinging <@&%d>", st.AlertRoleID)
}
b.WriteString("\n")
} else {
b.WriteString("**Alerts:** not set. Use `/security alerts`.\n")
}
if owner, err := e.OwnerID(guildID); err == nil {
fmt.Fprintf(&b, "**Trusted:** <@%s> (owner)", owner)
}
for _, t := range trusted {
if t.Kind == "role" {
fmt.Fprintf(&b, ", <@&%d>", t.TargetID)
} else {
fmt.Fprintf(&b, ", <@%d>", t.TargetID)
}
}
b.WriteString("\nFine-tune filters, limits and actions on the dashboard.")
return b.String()
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
switch sub.Name {
case "channel":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageChannels) {
reply(s, i, "You need Manage Channels to lock a channel.")
return
}
ch := i.ChannelID
if c, ok := args["channel"]; ok {
ch = c.ChannelValue(nil).ID
}
if err := e.LockChannel(ctx, i.GuildID, ch, userID(i), false); err != nil {
reply(s, i, message("lock channel", err))
return
}
reply(s, i, fmt.Sprintf("🔒 <#%s> is locked. `/unlock channel` puts it back as it was.", ch))
case "server":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to lock the server.")
return
}
if !deferReply(s, i) {
return
}
locked, failed, err := e.LockServer(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("lock server", err))
return
}
msg := fmt.Sprintf("🔒 Locked %d channels.", locked)
if failed > 0 {
msg += fmt.Sprintf(" %d couldn't be locked (Velox needs Manage Channels there).", failed)
}
edit(s, i, msg+" `/unlock server` puts them back as they were.")
case "joins":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to lock joins.")
return
}
mode := args["action"].StringValue()
if err := e.SetJoinLock(ctx, i.GuildID, mode, userID(i)); err != nil {
reply(s, i, message("lock joins", err))
return
}
reply(s, i, fmt.Sprintf("🔒 Everyone who joins will be %s until `/unlock joins`.", map[string]string{"kick": "kicked", "ban": "banned"}[mode]))
}
}
// UnlockHandler relaxes protection, so it is owner or trusted only.
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("unlock", err))
return
}
switch sub.Name {
case "channel":
ch := i.ChannelID
if c, ok := args["channel"]; ok {
ch = c.ChannelValue(nil).ID
}
if err := e.UnlockChannel(ctx, i.GuildID, ch, userID(i)); err != nil {
reply(s, i, message("unlock channel", err))
return
}
reply(s, i, fmt.Sprintf("🔓 <#%s> is unlocked.", ch))
case "server":
if !deferReply(s, i) {
return
}
unlocked, failed, err := e.UnlockServer(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("unlock server", err))
return
}
msg := fmt.Sprintf("🔓 Unlocked %d channels.", unlocked)
if failed > 0 {
msg += fmt.Sprintf(" %d failed; try `/unlock server` again.", failed)
}
edit(s, i, msg)
case "joins":
if err := e.SetJoinLock(ctx, i.GuildID, securityrepo.ActionOff, userID(i)); err != nil {
reply(s, i, message("unlock joins", err))
return
}
reply(s, i, "🔓 Joins are open again.")
}
}
// HandleComponent handles the alert's "End raid" button. Anyone in the alert
// channel can press it, so it checks trust like the command.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
if i.MessageComponentData().CustomID != security.CustomIDEndRaid {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
endRaid(ctx, s, i, e)
}
+29
View File
@@ -0,0 +1,29 @@
package security
import (
"velox-bot/internal/commands/security/public"
"github.com/bwmarrin/discordgo"
)
var (
Security *discordgo.ApplicationCommand = public.Security
Lockdown *discordgo.ApplicationCommand = public.Lockdown
Unlock *discordgo.ApplicationCommand = public.Unlock
)
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.SecurityHandler(s, i)
}
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.LockdownHandler(s, i)
}
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.UnlockHandler(s, i)
}
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleComponent(s, i)
}
+542
View File
@@ -0,0 +1,542 @@
package public
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"velox-bot/internal/tickets"
"github.com/bwmarrin/discordgo"
)
// The session must provide everything the ticket flows call.
var _ tickets.Discord = (*discordgo.Session)(nil)
// Repo is the full ticket store, including the admin calls that only the
// slash commands make. Set by Init.
var Repo *ticketrepo.Repo
func Init(r *ticketrepo.Repo) {
Repo = r
tickets.Init(r)
}
var (
manageGuild = int64(discordgo.PermissionManageGuild)
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText}
opTimeout = 2 * time.Minute
genericFailed = "Something went wrong. Try again in a moment, and tell an admin if it keeps happening."
)
// Tickets is the admin command for setting the system up.
var Tickets = &discordgo.ApplicationCommand{
Name: "tickets",
Description: "Set up support tickets",
DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setup",
Description: "Choose where tickets are opened and where closed tickets are logged",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Where the panel goes and ticket threads are created", Required: true, ChannelTypes: textChannels},
{Type: discordgo.ApplicationCommandOptionChannel, Name: "log-channel", Description: "Where closed tickets and their transcripts are posted", ChannelTypes: textChannels},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Creates a Support category handled by this role, if there are no categories yet"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommandGroup,
Name: "category",
Description: "Manage ticket categories",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add",
Description: "Add a category (an option in the panel's dropdown)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button", MaxLength: 64},
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel", MaxLength: 200},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "remove",
Description: "Remove a category",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category's name", Required: true},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "panel",
Description: "Post the ticket panel, or update it after changing categories",
},
},
}
// Ticket is used inside a ticket thread.
var Ticket = &discordgo.ApplicationCommand{
Name: "ticket",
Description: "Manage the ticket you're in",
Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Add someone to this ticket",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to add", Required: true}},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Remove someone from this ticket (staff only)",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to remove", Required: true}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "claim", Description: "Take this ticket (staff only)"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close", Description: "Close this ticket",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Why it's being closed", MaxLength: 300}},
},
},
}
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}},
})
}
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
return err == nil
}
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
}
// message turns an error into what the user sees; unexpected ones are logged.
func message(where string, err error) string {
var ue tickets.UserError
if errors.As(err, &ue) {
return string(ue)
}
log.Printf("tickets: %s: %v", where, err)
return genericFailed
}
func actor(i *discordgo.InteractionCreate) tickets.Actor {
a := tickets.Actor{Member: i.Member}
if i.Member != nil {
a.Perms = i.Member.Permissions
}
return a
}
func guildID(i *discordgo.InteractionCreate) int64 {
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
return n
}
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
switch sub.Name {
case "setup":
handleSetup(s, i, sub.Options)
case "panel":
handlePanel(s, i)
case "category":
if len(sub.Options) == 0 {
return
}
switch sub.Options[0].Name {
case "add":
handleCategoryAdd(s, i, sub.Options[0].Options)
case "remove":
handleCategoryRemove(s, i, sub.Options[0].Options)
case "list":
handleCategoryList(s, i)
}
}
}
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, x := range o {
m[x.Name] = x
}
return m
}
// permissionWarning names what the bot is missing in a channel, or "".
func permissionWarning(s *discordgo.Session, channelID string, need int64, what string) string {
have, err := s.UserChannelPermissions(s.State.User.ID, channelID)
if err != nil {
return ""
}
if missing := tickets.MissingPerms(have, need); len(missing) > 0 {
return fmt.Sprintf("\n⚠️ I'm missing these permissions in <#%s> (%s): %s.", channelID, what, strings.Join(missing, ", "))
}
return ""
}
func handleSetup(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
ch := args["channel"].ChannelValue(nil).ID
var logID int64
logMention := "none"
if l, ok := args["log-channel"]; ok {
lc := l.ChannelValue(nil).ID
logID, _ = strconv.ParseInt(lc, 10, 64)
logMention = "<#" + lc + ">"
}
chID, _ := strconv.ParseInt(ch, 10, 64)
if err := Repo.SetChannels(ctx, g, chID, logID); err != nil {
reply(s, i, message("setup", err))
return
}
cats, err := Repo.ListCategories(ctx, g)
if err != nil {
reply(s, i, message("setup", err))
return
}
created := ""
if r, ok := args["staff-role"]; ok && len(cats) == 0 {
role := r.RoleValue(nil, i.GuildID)
roleID, _ := strconv.ParseInt(role.ID, 10, 64)
if _, err := Repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support", Emoji: "🎫", StaffRoleID: roleID}); err != nil {
reply(s, i, message("setup", err))
return
}
created = fmt.Sprintf("\nCreated a **Support** category handled by <@&%s>.", role.ID)
cats = append(cats, ticketrepo.Category{Name: "Support"})
}
msg := fmt.Sprintf("Tickets will open as private threads in <#%s>. Closed tickets are logged to %s.%s", ch, logMention, created)
msg += permissionWarning(s, ch, tickets.NeedTicketChannel, "ticket channel")
if logID != 0 {
msg += permissionWarning(s, strconv.FormatInt(logID, 10), tickets.NeedLogChannel, "log channel")
}
if len(cats) == 0 {
msg += "\nNext: add a category with `/tickets category add`, then post the panel with `/tickets panel`."
} else {
msg += "\nNext: post the panel with `/tickets panel`."
}
msg += "\nTip: give your staff role **Manage Threads** in that channel so they can see every ticket, even ones they weren't pinged in."
reply(s, i, msg)
}
func handlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
msg, err := tickets.PostPanel(ctx, s, Repo, guildID(i))
if err != nil {
edit(s, i, message("panel", err))
return
}
edit(s, i, fmt.Sprintf("The ticket panel is up: https://discord.com/channels/%s/%s/%s", i.GuildID, msg.ChannelID, msg.ID))
}
func handleCategoryAdd(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
cats, err := Repo.ListCategories(ctx, g)
if err != nil {
reply(s, i, message("category add", err))
return
}
name := strings.TrimSpace(args["name"].StringValue())
for _, c := range cats {
if strings.EqualFold(c.Name, name) {
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
return
}
}
if len(cats) >= 25 {
reply(s, i, "A panel can hold at most 25 categories.")
return
}
c := ticketrepo.Category{GuildID: g, Name: name}
if r, ok := args["staff-role"]; ok {
c.StaffRoleID, _ = strconv.ParseInt(r.RoleValue(nil, i.GuildID).ID, 10, 64)
}
if e, ok := args["emoji"]; ok {
c.Emoji = strings.TrimSpace(e.StringValue())
}
if d, ok := args["description"]; ok {
c.Description = strings.TrimSpace(d.StringValue())
}
if _, err := Repo.AddCategory(ctx, c); err != nil {
reply(s, i, message("category add", err))
return
}
reply(s, i, fmt.Sprintf("Added **%s**. Run `/tickets panel` to show it on the panel.", name))
}
func handleCategoryRemove(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
name := strings.TrimSpace(opts(o)["name"].StringValue())
ok, err := Repo.DeleteCategoryByName(ctx, guildID(i), name)
switch {
case err != nil:
reply(s, i, message("category remove", err))
case !ok:
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
default:
reply(s, i, fmt.Sprintf("Removed **%s**. Open tickets keep working. Run `/tickets panel` to update the panel.", name))
}
}
func handleCategoryList(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
cats, err := Repo.ListCategories(ctx, guildID(i))
if err != nil {
reply(s, i, message("category list", err))
return
}
if len(cats) == 0 {
reply(s, i, "No categories yet. Add one with `/tickets category add`.")
return
}
var b strings.Builder
for _, c := range cats {
line := "• "
if c.Emoji != "" {
line += c.Emoji + " "
}
line += "**" + c.Name + "**"
if c.StaffRoleID != 0 {
line += fmt.Sprintf(" → <@&%d>", c.StaffRoleID)
} else {
line += " → no staff role (only Manage Threads)"
}
if c.Description != "" {
line += ": " + c.Description
}
b.WriteString(line + "\n")
}
reply(s, i, b.String())
}
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
switch sub.Name {
case "add", "remove":
user := args["user"].UserValue(nil)
if user == nil {
return
}
if !deferReply(s, i) {
return
}
var err error
if sub.Name == "add" {
err = tickets.AddMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
} else {
err = tickets.RemoveMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
}
if err != nil {
edit(s, i, message("ticket "+sub.Name, err))
return
}
edit(s, i, "Done.")
case "claim":
claim(s, i)
case "close":
reason := ""
if r, ok := args["reason"]; ok {
reason = r.StringValue()
}
closeTicket(s, i, reason)
}
}
func claim(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := tickets.Claim(ctx, s, Repo, i.ChannelID, actor(i)); err != nil {
edit(s, i, message("claim", err))
return
}
edit(s, i, "You're handling this ticket now.")
}
func closeTicket(s *discordgo.Session, i *discordgo.InteractionCreate, reason string) {
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := tickets.Close(ctx, s, Repo, i.ChannelID, actor(i), reason); err != nil {
edit(s, i, message("close", err))
return
}
edit(s, i, "Ticket closed.")
}
// HandleComponent handles the panel dropdown and Create ticket button and the Claim and
// Close buttons in a ticket.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.MessageComponentData()
switch {
case strings.HasPrefix(data.CustomID, "tickets:open:"):
// Panels posted before the dropdown had one button per category.
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
case data.CustomID == "tickets:select" && len(data.Values) > 0:
rememberPick(s, i, data.Values[0])
case data.CustomID == "tickets:create":
createFromPick(s, i)
case data.CustomID == "tickets:claim":
claim(s, i)
case data.CustomID == "tickets:close":
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := tickets.CheckCanClose(ctx, Repo, i.ChannelID, actor(i)); err != nil {
reply(s, i, message("close", err))
return
}
_ = s.InteractionRespond(i.Interaction, tickets.CloseForm())
}
}
// rememberPick stores the dropdown choice for the Create ticket button and
// acknowledges it without changing the panel, so the choice stays visible.
func rememberPick(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
catID, err := strconv.ParseInt(rawCategory, 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
tickets.RememberPick(guildID(i), userID, catID)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseDeferredMessageUpdate})
}
func createFromPick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
catID, ok := tickets.PickedCategory(guildID(i), userID)
if !ok {
reply(s, i, "Pick a category from the menu first, then press **Create ticket**.")
return
}
showOpenForm(s, i, strconv.FormatInt(catID, 10))
}
func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
catID, err := strconv.ParseInt(rawCategory, 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
_, cat, err := tickets.CheckCanOpen(ctx, Repo, guildID(i), userID, catID)
if err != nil {
reply(s, i, message("open", err))
return
}
_ = s.InteractionRespond(i.Interaction, tickets.OpenForm(cat))
}
func field(data discordgo.ModalSubmitInteractionData, name string) string {
for _, row := range data.Components {
r, ok := row.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, c := range r.Components {
if in, ok := c.(*discordgo.TextInput); ok && in.CustomID == name {
return strings.TrimSpace(in.Value)
}
}
}
return ""
}
// HandleModalSubmit handles the opening and closing forms.
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.ModalSubmitData()
switch {
case strings.HasPrefix(data.CustomID, "tickets:openform:"):
catID, err := strconv.ParseInt(strings.TrimPrefix(data.CustomID, "tickets:openform:"), 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
thread, err := tickets.Open(ctx, s, Repo, tickets.OpenRequest{
GuildID: guildID(i), CategoryID: catID, User: i.Member.User,
Subject: field(data, "subject"), Details: field(data, "details"),
})
if err != nil {
edit(s, i, message("open", err))
return
}
edit(s, i, fmt.Sprintf("Your ticket is open: <#%s>. The staff will be with you there.", thread.ID))
case data.CustomID == "tickets:closeform":
closeTicket(s, i, field(data, "reason"))
}
}
+29
View File
@@ -0,0 +1,29 @@
package tickets
import (
"velox-bot/internal/commands/tickets/public"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
var (
Tickets *discordgo.ApplicationCommand = public.Tickets
Ticket *discordgo.ApplicationCommand = public.Ticket
)
func Init(r *ticketrepo.Repo) { public.Init(r) }
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.TicketsHandler(s, i)
}
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TicketHandler(s, i) }
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleComponent(s, i)
}
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleModalSubmit(s, i)
}
+2 -6
View File
@@ -18,9 +18,8 @@ type Config struct {
}
func LoadConfig() (*Config, error) {
if err := godotenv.Load(); err != nil {
return nil, err
}
// Load .env if present (e.g. local dev). Ignore if missing (e.g. Docker injects env vars).
_ = godotenv.Load()
token := os.Getenv("BOT_TOKEN")
if token == "" {
@@ -33,9 +32,6 @@ func LoadConfig() (*Config, error) {
}
guildID := os.Getenv("GUILD_ID")
if guildID == "" {
return nil, fmt.Errorf("GUILD_ID is not set")
}
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
+47
View File
@@ -0,0 +1,47 @@
package defaultrolerepo
import (
"context"
"database/sql"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
// GetRole returns the configured default role ID for a guild, or 0 if none.
func (r *Repo) GetRole(ctx context.Context, guildID int64) (int64, error) {
const q = `
SELECT role_id
FROM defaultrole
WHERE guild_id = $1
`
var roleID sql.NullInt64
if err := r.db.QueryRowContext(ctx, q, guildID).Scan(&roleID); err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
if !roleID.Valid {
return 0, nil
}
return roleID.Int64, nil
}
// SetRole upserts the default role for a guild.
func (r *Repo) SetRole(ctx context.Context, guildID, roleID int64) error {
const q = `
INSERT INTO defaultrole (guild_id, role_id)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET role_id = EXCLUDED.role_id
`
_, err := r.db.ExecContext(ctx, q, guildID, roleID)
return err
}
+144
View File
@@ -0,0 +1,144 @@
package musicrepo
import (
"context"
"database/sql"
"time"
)
type QueueEntry struct {
Title string
URL string
RequestedBy int64
// ClientID is the track's stable identity (set once by the manager
// when a track is first queued), used to upsert this row across
// syncs instead of assigning it a fresh DB id every single time.
ClientID string
}
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int, repeatSong, repeatQueue bool) error {
const q = `
INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume, repeat_song, repeat_queue)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (guild_id) DO UPDATE SET
track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5,
paused = $6, volume = $7, repeat_song = $8, repeat_queue = $9
`
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume, repeatSong, repeatQueue)
return err
}
func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
const q = `DELETE FROM music_now_playing WHERE guild_id = $1`
_, err := r.db.ExecContext(ctx, q, guildID)
return err
}
// ReplaceQueue syncs music_queue with the current in-memory queue by
// upserting each entry on its stable ClientID, rather than deleting and
// recreating every row on every call. That matters because music_queue.id
// is only an internal DB identity, remove/reorder commands target a
// track by ClientID, and that has to survive a track just sitting in the
// queue unchanged across syncs.
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
existing := make(map[string]bool)
rows, err := tx.QueryContext(ctx, `SELECT client_id FROM music_queue WHERE guild_id = $1`, guildID)
if err != nil {
return err
}
for rows.Next() {
var clientID string
if err := rows.Scan(&clientID); err != nil {
rows.Close()
return err
}
existing[clientID] = true
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
keep := make(map[string]bool, len(entries))
for i, entry := range entries {
keep[entry.ClientID] = true
const q = `
INSERT INTO music_queue (guild_id, position, track_title, track_url, requested_by, client_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (guild_id, client_id) DO UPDATE SET
position = EXCLUDED.position,
track_title = EXCLUDED.track_title,
track_url = EXCLUDED.track_url,
requested_by = EXCLUDED.requested_by
`
if _, err := tx.ExecContext(ctx, q, guildID, i, entry.Title, entry.URL, entry.RequestedBy, entry.ClientID); err != nil {
return err
}
}
for clientID := range existing {
if keep[clientID] {
continue
}
const q = `DELETE FROM music_queue WHERE guild_id = $1 AND client_id = $2`
if _, err := tx.ExecContext(ctx, q, guildID, clientID); err != nil {
return err
}
}
return tx.Commit()
}
type MusicCommand struct {
ID int64
GuildID int64
CommandType string
Payload []byte
RequestedBy int64
}
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]MusicCommand, error) {
const q = `SELECT id, guild_id, command_type, payload, requested_by FROM music_commands WHERE processed_at IS NULL ORDER BY id ASC`
rows, err := r.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var commands []MusicCommand
for rows.Next() {
var cmd MusicCommand
if err := rows.Scan(&cmd.ID, &cmd.GuildID, &cmd.CommandType, &cmd.Payload, &cmd.RequestedBy); err != nil {
return nil, err
}
commands = append(commands, cmd)
}
if err := rows.Err(); err != nil {
return nil, err
}
return commands, nil
}
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
const q = `UPDATE music_commands SET processed_at = NOW() WHERE id = $1`
_, err := r.db.ExecContext(ctx, q, id)
return err
}
+60
View File
@@ -61,6 +61,35 @@ func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
return err
}
func (r *Repo) DeactivateProject(ctx context.Context, guildID, projectID int64) error {
const q = `
UPDATE projects
SET is_active = FALSE
WHERE id = $1 AND guild_id = $2
`
_, err := r.db.ExecContext(ctx, q, projectID, guildID)
return err
}
// GetProject fetches a single project, scoped to guildID so a project ID
// from one server can't be used to reach into another's data.
func (r *Repo) GetProject(ctx context.Context, guildID, projectID int64) (*Project, error) {
const q = `
SELECT id, name, description, created_by
FROM projects
WHERE id = $1 AND guild_id = $2
`
row := r.db.QueryRowContext(ctx, q, projectID, guildID)
p := &Project{GuildID: guildID, IsActive: true}
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return p, nil
}
func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
const q = `
SELECT 1
@@ -79,6 +108,37 @@ func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int6
}
}
// ListActiveProjects is a lighter version of ListActiveProjectsWithMembers,
// without the member-join query - for callers (like building a select menu
// of projects to pick from) that only need name/id, not creator/helpers.
func (r *Repo) ListActiveProjects(ctx context.Context, guildID int64, limit int) ([]*Project, error) {
const q = `
SELECT id, name, description, created_by
FROM projects
WHERE guild_id = $1 AND is_active = TRUE
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Project
for rows.Next() {
p := &Project{GuildID: guildID, IsActive: true}
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
return nil, err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) {
const qProjects = `
SELECT id, name, description, created_by
+44 -31
View File
@@ -25,6 +25,9 @@ type Schedule struct {
RequesterID int64
InviteeID int64
ScheduledAt time.Time
ProposedAt *time.Time
ProposedBy *int64
PreRescheduleStatus *ScheduleStatus
Status ScheduleStatus
Description string
ReminderSent bool
@@ -48,35 +51,6 @@ func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, invitee
return id, nil
}
func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
const q = `
INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm)
VALUES ($1, $2, $3, $4)
ON CONFLICT (schedule_id, message_id) DO NOTHING
`
_, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM)
return err
}
func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) {
const q = `
SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at
FROM schedules s
JOIN schedule_messages m ON m.schedule_id = s.id
WHERE m.message_id = $1
LIMIT 1
`
row := r.db.QueryRowContext(ctx, q, messageID)
var sch Schedule
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &sch, nil
}
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
const q = `
UPDATE schedules
@@ -119,13 +93,13 @@ func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
const q = `
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, proposed_at, proposed_by, pre_reschedule_status, status, description, reminder_sent, created_at
FROM schedules
WHERE id = $1
`
row := r.db.QueryRowContext(ctx, q, id)
var sch Schedule
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.ProposedAt, &sch.ProposedBy, &sch.PreRescheduleStatus, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
@@ -186,4 +160,43 @@ func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
return err
}
func (r *Repo) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
const q = `
UPDATE schedules
SET proposed_at = $1,
proposed_by = $2,
pre_reschedule_status = status,
status = 'reschedule_requested'
WHERE id = $3
`
_, err := r.db.ExecContext(ctx, q, newTime, proposedBy, scheduleID)
return err
}
func (r *Repo) AcceptReschedule(ctx context.Context, scheduleID int64) error {
const q = `
UPDATE schedules
SET scheduled_at = proposed_at,
proposed_at = NULL,
proposed_by = NULL,
pre_reschedule_status = NULL,
status = 'accepted',
reminder_sent = FALSE
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, q, scheduleID)
return err
}
func (r *Repo) DeclineReschedule(ctx context.Context, scheduleID int64) error {
const q = `
UPDATE schedules
SET proposed_at = NULL,
proposed_by = NULL,
status = pre_reschedule_status,
pre_reschedule_status = NULL
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, q, scheduleID)
return err
}
+584
View File
@@ -0,0 +1,584 @@
// Package securityrepo stores the anti-raid and anti-nuke settings, the
// trusted list, quarantines, locked channels, the event history and the
// dashboard's requests. Discord IDs are int64; 0 means "not set".
package securityrepo
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
// Filter actions.
const (
ActionOff = "off"
ActionAlert = "alert" // raid mode only: alert staff, touch nobody
ActionTimeout = "timeout"
ActionKick = "kick"
ActionBan = "ban"
)
// Anti-nuke limit keys.
const (
LimitChannelDelete = "channel_delete"
LimitChannelCreate = "channel_create"
LimitRoleDelete = "role_delete"
LimitRoleCreate = "role_create"
LimitBan = "ban"
LimitKick = "kick"
LimitWebhookCreate = "webhook_create"
)
// DefaultLimits mirrors the column default in the migration.
func DefaultLimits() map[string]int {
return map[string]int{
LimitChannelDelete: 2, LimitChannelCreate: 5, LimitRoleDelete: 2, LimitRoleCreate: 5,
LimitBan: 3, LimitKick: 5, LimitWebhookCreate: 3,
}
}
type Settings struct {
GuildID int64
AlertChannelID int64
AlertRoleID int64
AlertDM bool
JoinGateEnabled bool
NoAvatar string
NewAccount string
MinAccountAgeDays int
AdvertisingName string
UsernameFilter string
UsernamePatterns []string
UnverifiedBot string
UnauthorizedBot string
AntiRaidEnabled bool
RaidJoinCount int
RaidJoinSeconds int
RaidAction string
RaidPauseInvites bool
RaidQuietMinutes int
AntiNukeEnabled bool
AntiNukeWindowSeconds int
AntiNukeLimits map[string]int
AntiNukeLockServer bool
QuarantineRoleID int64
AntiNukeAutoRestore bool
HeatEnabled bool
HeatStrictness string
HeatAction string
HeatTimeoutMinutes int
HeatEscalate bool
HeatBlockedWords []string
HeatPanicMembers int
HeatLockdownMentions int
JoinLock string
// Configured is false when the guild has no row yet; the fields then
// hold the defaults, with every system off.
Configured bool
}
func Defaults(guildID int64) *Settings {
return &Settings{
GuildID: guildID,
AlertDM: true,
NoAvatar: ActionOff, NewAccount: ActionOff, MinAccountAgeDays: 7, AdvertisingName: ActionOff,
UsernameFilter: ActionOff, UnverifiedBot: ActionOff, UnauthorizedBot: ActionKick,
RaidJoinCount: 10, RaidJoinSeconds: 10, RaidAction: ActionKick, RaidPauseInvites: true, RaidQuietMinutes: 10,
AntiNukeWindowSeconds: 10, AntiNukeLimits: DefaultLimits(),
HeatStrictness: "medium", HeatAction: ActionTimeout, HeatTimeoutMinutes: 10, HeatEscalate: true, HeatPanicMembers: 3,
JoinLock: ActionOff,
}
}
func nullID(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: v != 0} }
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
const q = `
SELECT alert_channel_id, alert_role_id, alert_dm,
joingate_enabled, joingate_no_avatar, joingate_new_account, joingate_min_account_age_days,
joingate_advertising_name, joingate_username_filter, to_json(joingate_username_patterns),
joingate_unverified_bot, joingate_unauthorized_bot,
antiraid_enabled, raid_join_count, raid_join_seconds, raid_action, raid_pause_invites, raid_quiet_minutes,
antinuke_enabled, antinuke_window_seconds, antinuke_limits, antinuke_lock_server, quarantine_role_id,
antinuke_auto_restore, heat_enabled, heat_strictness, heat_action, heat_timeout_minutes, heat_escalate,
to_json(heat_blocked_words), heat_panic_members, heat_lockdown_mentions,
join_lock
FROM security_settings WHERE guild_id = $1`
s := &Settings{GuildID: guildID, Configured: true}
var alertCh, alertRole, qRole sql.NullInt64
var patterns, words []byte
var limits []byte
err := r.db.QueryRowContext(ctx, q, guildID).Scan(
&alertCh, &alertRole, &s.AlertDM,
&s.JoinGateEnabled, &s.NoAvatar, &s.NewAccount, &s.MinAccountAgeDays,
&s.AdvertisingName, &s.UsernameFilter, &patterns,
&s.UnverifiedBot, &s.UnauthorizedBot,
&s.AntiRaidEnabled, &s.RaidJoinCount, &s.RaidJoinSeconds, &s.RaidAction, &s.RaidPauseInvites, &s.RaidQuietMinutes,
&s.AntiNukeEnabled, &s.AntiNukeWindowSeconds, &limits, &s.AntiNukeLockServer, &qRole,
&s.AntiNukeAutoRestore, &s.HeatEnabled, &s.HeatStrictness, &s.HeatAction, &s.HeatTimeoutMinutes, &s.HeatEscalate,
&words, &s.HeatPanicMembers, &s.HeatLockdownMentions,
&s.JoinLock,
)
if errors.Is(err, sql.ErrNoRows) {
return Defaults(guildID), nil
}
if err != nil {
return nil, err
}
s.AlertChannelID, s.AlertRoleID, s.QuarantineRoleID = alertCh.Int64, alertRole.Int64, qRole.Int64
_ = json.Unmarshal(patterns, &s.UsernamePatterns)
_ = json.Unmarshal(words, &s.HeatBlockedWords)
s.AntiNukeLimits = DefaultLimits()
if len(limits) > 0 {
// Saved values override the defaults key by key, so a limit added
// in a later version still has a value for older rows.
var saved map[string]int
if err := json.Unmarshal(limits, &saved); err == nil {
for k, v := range saved {
s.AntiNukeLimits[k] = v
}
}
}
return s, nil
}
// ensureRow makes sure the guild has a settings row so single-column updates
// below can be plain UPDATEs.
func (r *Repo) ensureRow(ctx context.Context, guildID int64) error {
_, err := r.db.ExecContext(ctx, `INSERT INTO security_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID)
return err
}
func (r *Repo) update(ctx context.Context, guildID int64, set string, args ...any) error {
if err := r.ensureRow(ctx, guildID); err != nil {
return err
}
_, err := r.db.ExecContext(ctx, `UPDATE security_settings SET `+set+` WHERE guild_id = $1`, append([]any{guildID}, args...)...)
return err
}
// Feature names accepted by SetEnabled.
const (
FeatureJoinGate = "joingate"
FeatureAntiRaid = "antiraid"
FeatureAntiNuke = "antinuke"
FeatureHeat = "heat"
)
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error {
col := map[string]string{FeatureJoinGate: "joingate_enabled", FeatureAntiRaid: "antiraid_enabled", FeatureAntiNuke: "antinuke_enabled", FeatureHeat: "heat_enabled"}[feature]
if col == "" {
return errors.New("unknown feature " + feature)
}
return r.update(ctx, guildID, col+` = $2`, on)
}
func (r *Repo) SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error {
return r.update(ctx, guildID, `alert_channel_id = $2, alert_role_id = $3`, nullID(channelID), nullID(roleID))
}
func (r *Repo) SetQuarantineRole(ctx context.Context, guildID, roleID int64) error {
return r.update(ctx, guildID, `quarantine_role_id = $2`, nullID(roleID))
}
func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) error {
return r.update(ctx, guildID, `join_lock = $2`, mode)
}
// ---- Trusted ----
type Trusted struct {
TargetID int64
Kind string // "user" or "role"
AddedBy int64
}
func (r *Repo) ListTrusted(ctx context.Context, guildID int64) ([]Trusted, error) {
rows, err := r.db.QueryContext(ctx, `SELECT target_id, kind, added_by FROM security_trusted WHERE guild_id = $1 ORDER BY created_at`, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Trusted
for rows.Next() {
var t Trusted
if err := rows.Scan(&t.TargetID, &t.Kind, &t.AddedBy); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *Repo) AddTrusted(ctx context.Context, guildID int64, t Trusted) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO security_trusted (guild_id, target_id, kind, added_by) VALUES ($1, $2, $3, $4)
ON CONFLICT (guild_id, target_id) DO UPDATE SET kind = EXCLUDED.kind`, guildID, t.TargetID, t.Kind, t.AddedBy)
return err
}
func (r *Repo) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM security_trusted WHERE guild_id = $1 AND target_id = $2`, guildID, targetID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// ---- Quarantine ----
type Quarantine struct {
GuildID int64
UserID int64
PreviousRoleIDs []int64
Reason string
CreatedAt time.Time
}
// SaveQuarantine records a quarantine. If the member is already quarantined
// the first record is kept, since its roles are the ones to give back.
func (r *Repo) SaveQuarantine(ctx context.Context, q Quarantine) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO security_quarantines (guild_id, user_id, previous_role_ids, reason)
VALUES ($1, $2, COALESCE($3::bigint[], '{}'), $4)
ON CONFLICT (guild_id, user_id) DO NOTHING`, q.GuildID, q.UserID, q.PreviousRoleIDs, q.Reason)
return err
}
func scanQuarantine(row interface{ Scan(...any) error }) (Quarantine, error) {
var q Quarantine
var roles []byte
err := row.Scan(&q.GuildID, &q.UserID, &roles, &q.Reason, &q.CreatedAt)
if err == nil {
err = json.Unmarshal(roles, &q.PreviousRoleIDs)
}
return q, err
}
// GetQuarantine returns nil when the member isn't quarantined.
func (r *Repo) GetQuarantine(ctx context.Context, guildID, userID int64) (*Quarantine, error) {
q, err := scanQuarantine(r.db.QueryRowContext(ctx, `
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
WHERE guild_id = $1 AND user_id = $2`, guildID, userID))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &q, nil
}
func (r *Repo) ListQuarantines(ctx context.Context, guildID int64) ([]Quarantine, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
WHERE guild_id = $1 ORDER BY created_at DESC`, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Quarantine
for rows.Next() {
q, err := scanQuarantine(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (r *Repo) DeleteQuarantine(ctx context.Context, guildID, userID int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM security_quarantines WHERE guild_id = $1 AND user_id = $2`, guildID, userID)
return err
}
// ---- Lockdowns ----
type Lockdown struct {
GuildID int64
ChannelID int64
HadOverwrite bool
PreviousAllow int64
PreviousDeny int64
ServerWide bool
LockedBy int64
}
// SaveLockdown remembers a channel's @everyone overwrite from before it was
// locked. A channel that is already locked keeps its first record, which is
// the state to go back to.
func (r *Repo) SaveLockdown(ctx context.Context, l Lockdown) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO security_lockdowns (guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (guild_id, channel_id) DO NOTHING`,
l.GuildID, l.ChannelID, l.HadOverwrite, l.PreviousAllow, l.PreviousDeny, l.ServerWide, l.LockedBy)
return err
}
// GetLockdown returns nil when the channel isn't locked by the bot.
func (r *Repo) GetLockdown(ctx context.Context, guildID, channelID int64) (*Lockdown, error) {
var l Lockdown
err := r.db.QueryRowContext(ctx, `
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID).
Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &l, nil
}
// ListLockdowns lists locked channels; serverWideOnly limits it to the ones a
// server lockdown locked.
func (r *Repo) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]Lockdown, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
FROM security_lockdowns WHERE guild_id = $1 AND (NOT $2 OR server_wide) ORDER BY locked_at`, guildID, serverWideOnly)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Lockdown
for rows.Next() {
var l Lockdown
if err := rows.Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID)
return err
}
// ---- Events ----
type Event struct {
GuildID int64
Kind string
ActorID int64
TargetID int64
Detail string
}
// Event kinds.
const (
EventJoinGate = "joingate"
EventRaidStarted = "raid_started"
EventRaidEnded = "raid_ended"
EventNuke = "nuke"
EventQuarantined = "quarantined"
EventReleased = "released"
EventUnauthorizedBot = "unauthorized_bot"
EventLocked = "locked"
EventUnlocked = "unlocked"
EventHeat = "heat"
EventHeatPanic = "heat_panic"
EventSnapshot = "snapshot"
EventRestored = "restored"
)
func (r *Repo) AddEvent(ctx context.Context, e Event) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO security_events (guild_id, kind, actor_id, target_id, detail) VALUES ($1, $2, $3, $4, $5)`,
e.GuildID, e.Kind, nullID(e.ActorID), nullID(e.TargetID), e.Detail)
return err
}
// ---- Dashboard commands ----
type Command struct {
ID int64
GuildID int64
CommandType string
Payload []byte
RequestedBy int64
}
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, guild_id, command_type, COALESCE(payload, '{}'::jsonb), requested_by
FROM security_commands WHERE processed_at IS NULL ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Command
for rows.Next() {
var c Command
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.Payload, &c.RequestedBy); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE security_commands SET processed_at = NOW() WHERE id = $1`, id)
return err
}
// ---- Raids in progress ----
type Raid struct {
GuildID int64
StartedAt time.Time
LastJoinAt time.Time
InvitesPaused bool
CaughtTotal int
}
func (r *Repo) SaveRaid(ctx context.Context, x Raid) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO security_raids (guild_id, started_at, last_join_at, invites_paused, caught_total)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (guild_id) DO UPDATE SET
last_join_at = EXCLUDED.last_join_at, invites_paused = EXCLUDED.invites_paused, caught_total = EXCLUDED.caught_total`,
x.GuildID, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal)
return err
}
func (r *Repo) ListRaids(ctx context.Context) ([]Raid, error) {
rows, err := r.db.QueryContext(ctx, `SELECT guild_id, started_at, last_join_at, invites_paused, caught_total FROM security_raids`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Raid
for rows.Next() {
var x Raid
if err := rows.Scan(&x.GuildID, &x.StartedAt, &x.LastJoinAt, &x.InvitesPaused, &x.CaughtTotal); err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
// DeleteRaid ends a raid's record and its caught list.
func (r *Repo) DeleteRaid(ctx context.Context, guildID int64) error {
if _, err := r.db.ExecContext(ctx, `DELETE FROM security_raid_caught WHERE guild_id = $1`, guildID); err != nil {
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM security_raids WHERE guild_id = $1`, guildID)
return err
}
type Caught struct {
UserID int64
Action string
Name string
}
func (r *Repo) AddRaidCaught(ctx context.Context, guildID int64, c Caught) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO security_raid_caught (guild_id, user_id, action, name) VALUES ($1, $2, $3, $4)
ON CONFLICT (guild_id, user_id) DO NOTHING`, guildID, c.UserID, c.Action, c.Name)
return err
}
func (r *Repo) ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]Caught, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, action, name FROM security_raid_caught WHERE guild_id = $1 ORDER BY caught_at LIMIT $2`, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Caught
for rows.Next() {
var c Caught
if err := rows.Scan(&c.UserID, &c.Action, &c.Name); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ---- Snapshots ----
const keepSnapshots = 3
type Snapshot struct {
ID int64
GuildID int64
Data []byte
Roles int
Channels int
CreatedAt time.Time
}
// SaveSnapshot stores a snapshot and drops all but the newest three.
func (r *Repo) SaveSnapshot(ctx context.Context, s Snapshot) error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO security_snapshots (guild_id, data, roles, channels) VALUES ($1, $2, $3, $4)`,
s.GuildID, s.Data, s.Roles, s.Channels); err != nil {
return err
}
_, err := r.db.ExecContext(ctx, `
DELETE FROM security_snapshots WHERE guild_id = $1 AND id NOT IN (
SELECT id FROM security_snapshots WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2)`,
s.GuildID, keepSnapshots)
return err
}
// LatestSnapshot returns nil when the guild has none.
func (r *Repo) LatestSnapshot(ctx context.Context, guildID int64) (*Snapshot, error) {
var s Snapshot
err := r.db.QueryRowContext(ctx, `
SELECT id, guild_id, data, roles, channels, created_at FROM security_snapshots
WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, guildID).
Scan(&s.ID, &s.GuildID, &s.Data, &s.Roles, &s.Channels, &s.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// GuildsDueForSnapshot lists guilds with anti-nuke on whose newest snapshot
// is older than maxAge (or that have none).
func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT s.guild_id FROM security_settings s
WHERE s.antinuke_enabled AND NOT EXISTS (
SELECT 1 FROM security_snapshots p WHERE p.guild_id = s.guild_id AND p.created_at > NOW() - make_interval(secs => $1))`,
maxAge.Seconds())
if err != nil {
return nil, err
}
defer rows.Close()
var out []int64
for rows.Next() {
var g int64
if err := rows.Scan(&g); err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}
@@ -0,0 +1,231 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied:
//
// SECURITY_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/securityrepo
package securityrepo
import (
"context"
"database/sql"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testRepo(t *testing.T) (*Repo, int64) {
t.Helper()
dsn := os.Getenv("SECURITY_TEST_DB")
if dsn == "" {
t.Skip("SECURITY_TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
const g = 777
for _, tbl := range []string{"security_settings", "security_trusted", "security_quarantines", "security_lockdowns", "security_events", "security_commands"} {
if _, err := db.Exec(`DELETE FROM `+tbl+` WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
}
return NewRepo(db), g
}
func TestSettings(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || s.JoinGateEnabled || s.AntiNukeEnabled || s.UnauthorizedBot != ActionKick || s.AntiNukeLimits[LimitChannelDelete] != 2 || !s.AlertDM {
t.Fatalf("defaults wrong: %+v %v", s, err)
}
for _, f := range []string{FeatureJoinGate, FeatureAntiRaid, FeatureAntiNuke} {
if err := r.SetEnabled(ctx, g, f, true); err != nil {
t.Fatal(err)
}
}
if err := r.SetEnabled(ctx, g, "drop table", true); err == nil {
t.Error("unknown features must be refused, never put into SQL")
}
_ = r.SetAlerts(ctx, g, 10, 20)
if _, err := r.db.Exec(`UPDATE security_settings SET alert_dm = false WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
_ = r.SetQuarantineRole(ctx, g, 30)
_ = r.SetJoinLock(ctx, g, ActionBan)
if _, err := r.db.Exec(`UPDATE security_settings SET joingate_username_patterns = '{nitro,free}', antinuke_limits = '{"ban": 0}' WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
s, err = r.GetSettings(ctx, g)
if err != nil {
t.Fatal(err)
}
if !s.Configured || s.AlertDM || !s.JoinGateEnabled || !s.AntiRaidEnabled || !s.AntiNukeEnabled || s.AlertChannelID != 10 || s.AlertRoleID != 20 || s.QuarantineRoleID != 30 || s.JoinLock != ActionBan {
t.Errorf("saved settings wrong: %+v", s)
}
if len(s.UsernamePatterns) != 2 || s.AntiNukeLimits[LimitBan] != 0 || s.AntiNukeLimits[LimitKick] != 5 {
t.Errorf("patterns or limits wrong (saved limits override defaults key by key): %+v %+v", s.UsernamePatterns, s.AntiNukeLimits)
}
if err := r.SetJoinLock(ctx, g, "everyone"); err == nil {
t.Error("the database must refuse unknown join lock modes")
}
}
func TestTrustedQuarantineLockdown(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
_ = r.AddTrusted(ctx, g, Trusted{TargetID: 5, Kind: "user", AddedBy: 1})
_ = r.AddTrusted(ctx, g, Trusted{TargetID: 6, Kind: "role", AddedBy: 1})
if err := r.AddTrusted(ctx, g, Trusted{TargetID: 7, Kind: "everyone", AddedBy: 1}); err == nil {
t.Error("unknown kinds must be refused")
}
list, _ := r.ListTrusted(ctx, g)
if len(list) != 2 {
t.Fatalf("trusted = %+v", list)
}
if ok, _ := r.RemoveTrusted(ctx, g, 5); !ok {
t.Error("remove should report true")
}
_ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: []int64{1, 2}, Reason: "first"})
_ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: nil, Reason: "second"})
q, _ := r.GetQuarantine(ctx, g, 9)
if q == nil || len(q.PreviousRoleIDs) != 2 || q.Reason != "first" {
t.Errorf("the first quarantine record must be kept: %+v", q)
}
qs, _ := r.ListQuarantines(ctx, g)
if len(qs) != 1 {
t.Errorf("list = %+v", qs)
}
_ = r.DeleteQuarantine(ctx, g, 9)
if q, _ := r.GetQuarantine(ctx, g, 9); q != nil {
t.Error("deleted")
}
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: true, PreviousAllow: 1, PreviousDeny: 2, ServerWide: true, LockedBy: 1})
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: false, PreviousAllow: 9, PreviousDeny: 9, LockedBy: 2})
_ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 51, LockedBy: 1})
l, _ := r.GetLockdown(ctx, g, 50)
if l == nil || !l.HadOverwrite || l.PreviousAllow != 1 || !l.ServerWide {
t.Errorf("the first lockdown record must be kept: %+v", l)
}
if all, _ := r.ListLockdowns(ctx, g, false); len(all) != 2 {
t.Errorf("all = %+v", all)
}
if server, _ := r.ListLockdowns(ctx, g, true); len(server) != 1 {
t.Errorf("server-wide = %+v", server)
}
_ = r.DeleteLockdown(ctx, g, 50)
if l, _ := r.GetLockdown(ctx, g, 50); l != nil {
t.Error("deleted")
}
if err := r.AddEvent(ctx, Event{GuildID: g, Kind: EventNuke, ActorID: 3, Detail: "x"}); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, payload, requested_by) VALUES ($1, 'release_quarantine', '{"user_id":"9"}', 1)`, g); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, requested_by) VALUES ($1, 'grant_admin', 1)`, g); err == nil {
t.Error("unknown commands must be refused")
}
cmds, _ := r.ListUnprocessedCommands(ctx)
var mine *Command
for i := range cmds {
if cmds[i].GuildID == g {
mine = &cmds[i]
}
}
if mine == nil || string(mine.Payload) != `{"user_id": "9"}` {
t.Fatalf("command = %+v", mine)
}
_ = r.MarkCommandProcessed(ctx, mine.ID)
}
func TestRaidsSnapshotsAndHeatSettings(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if _, err := r.db.Exec(`DELETE FROM security_raids WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
_, _ = r.db.Exec(`DELETE FROM security_raid_caught WHERE guild_id = $1`, g)
_, _ = r.db.Exec(`DELETE FROM security_snapshots WHERE guild_id = $1`, g)
s, _ := r.GetSettings(ctx, g)
if s.HeatEnabled || s.HeatStrictness != "medium" || s.HeatTimeoutMinutes != 10 || !s.HeatEscalate || s.HeatPanicMembers != 3 {
t.Errorf("heat defaults wrong: %+v", s)
}
_ = r.SetEnabled(ctx, g, FeatureHeat, true)
if _, err := r.db.Exec(`UPDATE security_settings SET heat_blocked_words = '{scam}', antinuke_auto_restore = true WHERE guild_id = $1`, g); err != nil {
t.Fatal(err)
}
s, _ = r.GetSettings(ctx, g)
if !s.HeatEnabled || !s.AntiNukeAutoRestore || len(s.HeatBlockedWords) != 1 {
t.Errorf("heat settings not read back: %+v", s)
}
if _, err := r.db.Exec(`UPDATE security_settings SET heat_strictness = 'insane' WHERE guild_id = $1`, g); err == nil {
t.Error("unknown strictness must be refused by the database")
}
start := time.Now().Add(-time.Minute).UTC().Truncate(time.Millisecond)
_ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: start, LastJoinAt: start, InvitesPaused: true, CaughtTotal: 1})
_ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: time.Now(), LastJoinAt: start.Add(time.Second), InvitesPaused: true, CaughtTotal: 2})
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "kick", Name: "a"})
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "ban", Name: "a"})
_ = r.AddRaidCaught(ctx, g, Caught{UserID: 2, Action: "kick", Name: "b"})
raids, _ := r.ListRaids(ctx)
var mine *Raid
for i := range raids {
if raids[i].GuildID == g {
mine = &raids[i]
}
}
if mine == nil || !mine.StartedAt.Equal(start) || mine.CaughtTotal != 2 {
t.Fatalf("raid = %+v (the start time is kept on update)", mine)
}
caught, _ := r.ListRaidCaught(ctx, g, 10)
if len(caught) != 2 || caught[0].Action != "kick" {
t.Errorf("caught = %+v (one row per raider)", caught)
}
_ = r.DeleteRaid(ctx, g)
if c, _ := r.ListRaidCaught(ctx, g, 10); len(c) != 0 {
t.Error("deleting a raid clears its list")
}
for i := 0; i < 5; i++ {
if err := r.SaveSnapshot(ctx, Snapshot{GuildID: g, Data: []byte(`{"roles":[],"channels":[]}`), Roles: i}); err != nil {
t.Fatal(err)
}
}
var n int
_ = r.db.QueryRow(`SELECT COUNT(*) FROM security_snapshots WHERE guild_id = $1`, g).Scan(&n)
if n != 3 {
t.Errorf("only the last three snapshots are kept, have %d", n)
}
latest, _ := r.LatestSnapshot(ctx, g)
if latest == nil || latest.Roles != 4 {
t.Errorf("latest = %+v", latest)
}
_ = r.SetEnabled(ctx, g, FeatureAntiNuke, true)
due, _ := r.GuildsDueForSnapshot(ctx, time.Hour)
for _, d := range due {
if d == g {
t.Error("a guild with a fresh snapshot is not due")
}
}
due, _ = r.GuildsDueForSnapshot(ctx, 0)
found := false
for _, d := range due {
found = found || d == g
}
if !found {
t.Error("with a zero max age every anti-nuke guild is due")
}
}
+99
View File
@@ -15,6 +15,12 @@ type LevelReward struct {
RoleID int64
}
type LogSettings struct {
GuildID int64
ChannelID int64
IsEnabled bool
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
@@ -240,6 +246,80 @@ func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message str
return err
}
func (r *Repo) SetLogChannel(ctx context.Context, guildID int64, channelID int64) error {
const q = `
INSERT INTO logsettings (guild_id, log_channel_id, is_enabled)
VALUES ($1, $2, TRUE)
ON CONFLICT (guild_id)
DO UPDATE SET log_channel_id = EXCLUDED.log_channel_id
`
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
return err
}
func (r *Repo) SetLogEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `
INSERT INTO logsettings (guild_id, is_enabled)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
func (r *Repo) GetLogSettings(ctx context.Context, guildID int64) (*LogSettings, error) {
const q = `
SELECT log_channel_id, is_enabled
FROM logsettings
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var channelID sql.NullInt64
var enabled sql.NullBool
switch err := row.Scan(&channelID, &enabled); err {
case nil:
out := &LogSettings{
GuildID: guildID,
ChannelID: 0,
IsEnabled: false,
}
if channelID.Valid {
out.ChannelID = channelID.Int64
}
if enabled.Valid {
out.IsEnabled = enabled.Bool
}
return out, nil
case sql.ErrNoRows:
return &LogSettings{
GuildID: guildID,
ChannelID: 0,
IsEnabled: false,
}, nil
default:
return nil, err
}
}
func (r *Repo) HasLogSettings(ctx context.Context, guildID int64) (bool, error) {
const q = `
SELECT 1
FROM logsettings
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var one int
switch err := row.Scan(&one); err {
case nil:
return true, nil
case sql.ErrNoRows:
return false, nil
default:
return false, err
}
}
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
const q = `
SELECT message
@@ -260,3 +340,22 @@ func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, er
return "", err
}
}
func (r *Repo) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) {
const q = `SELECT is_enabled FROM music_settings WHERE guild_id = $1`
var enabled bool
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return enabled, nil
}
func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `INSERT INTO music_settings (guild_id, is_enabled) VALUES ($1, $2) ON CONFLICT (guild_id) DO UPDATE SET is_enabled = $2`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
+303
View File
@@ -0,0 +1,303 @@
// Package ticketrepo stores the ticket system's settings, categories, tickets
// and the dashboard's requests to the bot. Discord IDs are int64; 0 means
// "not set" for optional ones.
package ticketrepo
import (
"context"
"database/sql"
"errors"
"time"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
const (
DefaultPanelTitle = "Need help?"
DefaultPanelDescription = "Pick a category below to open a private ticket with the staff team."
DefaultMaxOpenPerUser = 1
StatusOpen = "open"
StatusClosed = "closed"
)
type Settings struct {
GuildID int64
Enabled bool
ChannelID int64
LogChannelID int64
PanelMessageID int64
PanelTitle string
PanelDescription string
MaxOpenPerUser int
// Configured is false when the guild has no settings row yet; the other
// fields then hold the defaults.
Configured bool
}
type Category struct {
ID int64
GuildID int64
Name string
Emoji string
Description string
StaffRoleID int64
Position int
}
type Ticket struct {
ID int64
GuildID int64
Number int
CategoryID int64
CategoryName string
ThreadID int64
IntroMessage int64
OpenerID int64
Subject string
Status string
ClaimedBy int64
ClosedBy int64
CloseReason string
CreatedAt time.Time
ClosedAt time.Time // zero while open
}
type Command struct {
ID int64
GuildID int64
CommandType string
RequestedBy int64
}
func nullID(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: v != 0}
}
func nullText(v string) sql.NullString {
return sql.NullString{String: v, Valid: v != ""}
}
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
const q = `
SELECT enabled, channel_id, log_channel_id, panel_message_id, panel_title, panel_description, max_open_per_user
FROM ticket_settings WHERE guild_id = $1`
var ch, logCh, panel sql.NullInt64
s := &Settings{GuildID: guildID}
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
switch {
case errors.Is(err, sql.ErrNoRows):
// Modules start off until the server sets them up.
return &Settings{
GuildID: guildID,
Enabled: false,
PanelTitle: DefaultPanelTitle,
PanelDescription: DefaultPanelDescription,
MaxOpenPerUser: DefaultMaxOpenPerUser,
}, nil
case err != nil:
return nil, err
}
s.ChannelID, s.LogChannelID, s.PanelMessageID = ch.Int64, logCh.Int64, panel.Int64
s.Configured = true
return s, nil
}
// SetChannels saves where tickets live and where transcripts go. A zero log
// channel clears it. Setting tickets up for the first time turns them on
// (they start off); after that, the on/off switch is left alone.
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
const q = `
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id, enabled)
VALUES ($1, $2, $3, TRUE)
ON CONFLICT (guild_id) DO UPDATE SET
channel_id = EXCLUDED.channel_id,
log_channel_id = EXCLUDED.log_channel_id`
_, err := r.db.ExecContext(ctx, q, guildID, nullID(channelID), nullID(logChannelID))
return err
}
func (r *Repo) SetPanelMessage(ctx context.Context, guildID, messageID int64) error {
const q = `
INSERT INTO ticket_settings (guild_id, panel_message_id) VALUES ($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET panel_message_id = EXCLUDED.panel_message_id`
_, err := r.db.ExecContext(ctx, q, guildID, nullID(messageID))
return err
}
// NextNumber hands out the guild's next ticket number. It is a single
// statement, so two tickets opened at the same moment never share a number.
func (r *Repo) NextNumber(ctx context.Context, guildID int64) (int, error) {
const q = `
INSERT INTO ticket_settings (guild_id, next_number) VALUES ($1, 2)
ON CONFLICT (guild_id) DO UPDATE SET next_number = ticket_settings.next_number + 1
RETURNING next_number - 1`
var n int
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&n)
return n, err
}
const categoryCols = `id, guild_id, name, COALESCE(emoji, ''), description, COALESCE(staff_role_id, 0), position`
func scanCategory(row interface{ Scan(...any) error }) (Category, error) {
var c Category
err := row.Scan(&c.ID, &c.GuildID, &c.Name, &c.Emoji, &c.Description, &c.StaffRoleID, &c.Position)
return c, err
}
func (r *Repo) ListCategories(ctx context.Context, guildID int64) ([]Category, error) {
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 ORDER BY position, id`
rows, err := r.db.QueryContext(ctx, q, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Category
for rows.Next() {
c, err := scanCategory(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetCategory returns nil when the category does not exist in that guild.
func (r *Repo) GetCategory(ctx context.Context, guildID, id int64) (*Category, error) {
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 AND id = $2`
c, err := scanCategory(r.db.QueryRowContext(ctx, q, guildID, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// AddCategory appends a category after the existing ones.
func (r *Repo) AddCategory(ctx context.Context, c Category) (*Category, error) {
q := `
INSERT INTO ticket_categories (guild_id, name, emoji, description, staff_role_id, position)
VALUES ($1, $2, $3, $4, $5,
(SELECT COALESCE(MAX(position) + 1, 0) FROM ticket_categories WHERE guild_id = $1))
RETURNING ` + categoryCols
out, err := scanCategory(r.db.QueryRowContext(ctx, q, c.GuildID, c.Name, nullText(c.Emoji), c.Description, nullID(c.StaffRoleID)))
if err != nil {
return nil, err
}
return &out, nil
}
// DeleteCategoryByName removes a category (case-insensitively) and reports
// whether one was found. Tickets keep their copied category name.
func (r *Repo) DeleteCategoryByName(ctx context.Context, guildID int64, name string) (bool, error) {
const q = `DELETE FROM ticket_categories WHERE guild_id = $1 AND lower(name) = lower($2)`
res, err := r.db.ExecContext(ctx, q, guildID, name)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (r *Repo) CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error) {
const q = `SELECT COUNT(*) FROM tickets WHERE guild_id = $1 AND opener_id = $2 AND status = 'open'`
var n int
err := r.db.QueryRowContext(ctx, q, guildID, openerID).Scan(&n)
return n, err
}
func (r *Repo) CreateTicket(ctx context.Context, t Ticket) (int64, error) {
const q = `
INSERT INTO tickets (guild_id, number, category_id, category_name, thread_id, intro_message_id, opener_id, subject)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`
var id int64
err := r.db.QueryRowContext(ctx, q, t.GuildID, t.Number, nullID(t.CategoryID), t.CategoryName, t.ThreadID, nullID(t.IntroMessage), t.OpenerID, t.Subject).Scan(&id)
return id, err
}
const ticketCols = `id, guild_id, number, COALESCE(category_id, 0), category_name, thread_id, COALESCE(intro_message_id, 0), opener_id, subject, status,
COALESCE(claimed_by, 0), COALESCE(closed_by, 0), COALESCE(close_reason, ''), created_at, closed_at`
func scanTicket(row interface{ Scan(...any) error }) (Ticket, error) {
var t Ticket
var closedAt sql.NullTime
err := row.Scan(&t.ID, &t.GuildID, &t.Number, &t.CategoryID, &t.CategoryName, &t.ThreadID, &t.IntroMessage, &t.OpenerID, &t.Subject, &t.Status,
&t.ClaimedBy, &t.ClosedBy, &t.CloseReason, &t.CreatedAt, &closedAt)
t.ClosedAt = closedAt.Time
return t, err
}
// GetTicketByThread returns nil when the thread is not a ticket.
func (r *Repo) GetTicketByThread(ctx context.Context, threadID int64) (*Ticket, error) {
q := `SELECT ` + ticketCols + ` FROM tickets WHERE thread_id = $1`
t, err := scanTicket(r.db.QueryRowContext(ctx, q, threadID))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
// Claim sets the ticket's handler unless someone already has it, and returns
// whoever holds it afterwards (the caller when the claim worked).
func (r *Repo) Claim(ctx context.Context, threadID, staffID int64) (int64, error) {
const q = `
UPDATE tickets SET claimed_by = COALESCE(claimed_by, $2)
WHERE thread_id = $1 AND status = 'open'
RETURNING claimed_by`
var holder int64
err := r.db.QueryRowContext(ctx, q, threadID, staffID).Scan(&holder)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return holder, err
}
// Close marks an open ticket closed and reports whether this call closed it,
// so two people pressing Close at once only produce one transcript.
func (r *Repo) Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error) {
const q = `
UPDATE tickets SET status = 'closed', closed_by = $2, close_reason = $3, closed_at = NOW()
WHERE thread_id = $1 AND status = 'open'`
res, err := r.db.ExecContext(ctx, q, threadID, nullID(closedBy), nullText(reason))
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
const q = `SELECT id, guild_id, command_type, requested_by FROM ticket_commands WHERE processed_at IS NULL ORDER BY id`
rows, err := r.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Command
for rows.Next() {
var c Command
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.RequestedBy); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
return err
}
@@ -0,0 +1,206 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied:
//
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
package ticketrepo
import (
"context"
"database/sql"
"os"
"sync"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testRepo(t *testing.T) (*Repo, int64) {
t.Helper()
dsn := os.Getenv("TICKETS_TEST_DB")
if dsn == "" {
t.Skip("TICKETS_TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
const guild = 424242
for _, q := range []string{
`DELETE FROM tickets WHERE guild_id = $1`,
`DELETE FROM ticket_categories WHERE guild_id = $1`,
`DELETE FROM ticket_settings WHERE guild_id = $1`,
`DELETE FROM ticket_commands WHERE guild_id = $1`,
} {
if _, err := db.Exec(q, guild); err != nil {
t.Fatal(err)
}
}
return NewRepo(db), guild
}
func TestSettingsDefaultsAndChannels(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
t.Fatalf("defaults wrong: %+v %v", s, err)
}
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
t.Fatal(err)
}
if err := r.SetPanelMessage(ctx, g, 333); err != nil {
t.Fatal(err)
}
s, _ = r.GetSettings(ctx, g)
if !s.Configured || s.ChannelID != 111 || s.LogChannelID != 222 || s.PanelMessageID != 333 {
t.Fatalf("saved settings wrong: %+v", s)
}
// Clearing the log channel.
_ = r.SetChannels(ctx, g, 111, 0)
s, _ = r.GetSettings(ctx, g)
if s.LogChannelID != 0 {
t.Errorf("log channel not cleared: %+v", s)
}
}
func TestNextNumberIsUniqueUnderConcurrency(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
var mu sync.Mutex
seen := map[int]bool{}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n, err := r.NextNumber(ctx, g)
if err != nil {
t.Error(err)
return
}
mu.Lock()
defer mu.Unlock()
if seen[n] {
t.Errorf("number %d handed out twice", n)
}
seen[n] = true
}()
}
wg.Wait()
for n := 1; n <= 20; n++ {
if !seen[n] {
t.Errorf("number %d missing: numbers should be 1..20", n)
}
}
}
func TestCategories(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
a, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🛟", StaffRoleID: 9})
if err != nil {
t.Fatal(err)
}
b, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Appeal", Description: "Ban appeals"})
if a.Position != 0 || b.Position != 1 || b.Emoji != "" || b.StaffRoleID != 0 {
t.Fatalf("positions or defaults wrong: %+v %+v", a, b)
}
list, _ := r.ListCategories(ctx, g)
if len(list) != 2 || list[0].Name != "Support" || list[1].Description != "Ban appeals" {
t.Fatalf("list wrong: %+v", list)
}
if c, _ := r.GetCategory(ctx, g, a.ID); c == nil || c.Emoji != "🛟" {
t.Errorf("get wrong: %+v", c)
}
if c, _ := r.GetCategory(ctx, g+1, a.ID); c != nil {
t.Error("a category must not be readable from another guild")
}
if ok, _ := r.DeleteCategoryByName(ctx, g, "support"); !ok {
t.Error("delete should match case-insensitively")
}
if ok, _ := r.DeleteCategoryByName(ctx, g, "nope"); ok {
t.Error("deleting a missing category should report false")
}
}
func TestTicketLifecycle(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
cat, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Support"})
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: 1, CategoryID: cat.ID, CategoryName: "Support", ThreadID: 5001, IntroMessage: 6001, OpenerID: 77, Subject: "Help"})
if err != nil || id == 0 {
t.Fatal(err)
}
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 1 {
t.Errorf("open count = %d", n)
}
if holder, _ := r.Claim(ctx, 5001, 10); holder != 10 {
t.Errorf("first claim should win, holder = %d", holder)
}
if holder, _ := r.Claim(ctx, 5001, 11); holder != 10 {
t.Errorf("second claim must not take over, holder = %d", holder)
}
if ok, _ := r.Close(ctx, 5001, 10, "solved"); !ok {
t.Error("first close should succeed")
}
if ok, _ := r.Close(ctx, 5001, 11, ""); ok {
t.Error("closing twice must report false")
}
tk, _ := r.GetTicketByThread(ctx, 5001)
if tk == nil || tk.Status != StatusClosed || tk.ClosedBy != 10 || tk.CloseReason != "solved" || tk.ClosedAt.IsZero() || tk.ClaimedBy != 10 || tk.IntroMessage != 6001 {
t.Fatalf("closed ticket wrong: %+v", tk)
}
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 0 {
t.Errorf("closed ticket still counted as open")
}
if holder, _ := r.Claim(ctx, 5001, 12); holder != 0 {
t.Error("a closed ticket cannot be claimed")
}
// Deleting the category keeps the ticket and its copied name.
_, _ = r.DeleteCategoryByName(ctx, g, "Support")
tk, _ = r.GetTicketByThread(ctx, 5001)
if tk.CategoryID != 0 || tk.CategoryName != "Support" {
t.Errorf("ticket after category delete: %+v", tk)
}
if tk, _ := r.GetTicketByThread(ctx, 999); tk != nil {
t.Error("unknown thread should return nil")
}
}
func TestCommands(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if _, err := r.db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 5)`, g); err != nil {
t.Fatal(err)
}
cmds, err := r.ListUnprocessedCommands(ctx)
if err != nil {
t.Fatal(err)
}
var mine *Command
for i := range cmds {
if cmds[i].GuildID == g {
mine = &cmds[i]
}
}
if mine == nil || mine.CommandType != "post_panel" || mine.RequestedBy != 5 {
t.Fatalf("command not listed: %+v", cmds)
}
_ = r.MarkCommandProcessed(ctx, mine.ID)
cmds, _ = r.ListUnprocessedCommands(ctx)
for _, c := range cmds {
if c.ID == mine.ID {
t.Error("processed command still listed")
}
}
}
+32 -2
View File
@@ -102,10 +102,13 @@ func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64
return chID.Int64, true, nil
}
// SetNotificationChannel sets where live alerts go. Setting it for the first
// time turns alerts on (they start off); after that, the on/off switch is
// left alone.
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
const q = `
INSERT INTO twitch_config (guild_id, twitch_channel_id)
VALUES ($1, $2)
INSERT INTO twitch_config (guild_id, twitch_channel_id, enabled)
VALUES ($1, $2, TRUE)
ON CONFLICT (guild_id)
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
`
@@ -113,3 +116,30 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in
return err
}
// IsEnabled reports whether Twitch notifications are enabled for guildID.
// A guild with no twitch_config row yet is off: modules start off until
// the server sets them up.
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
var enabled bool
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return enabled, nil
}
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `
INSERT INTO twitch_config (guild_id, enabled)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET enabled = EXCLUDED.enabled
`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
+111
View File
@@ -0,0 +1,111 @@
package welcomerepo
import (
"context"
"database/sql"
)
type Repo struct {
db *sql.DB
}
type Settings struct {
GuildID int64
ChannelID int64
WelcomeMessage string
WelcomeDM string
WelcomeGIFURL string
HasConfiguration bool
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
const q = `
SELECT welcome_channel_id, welcome_message, welcome_dm, welcome_gif_url
FROM welcome
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var ch sql.NullInt64
var msg, dm, gif sql.NullString
switch err := row.Scan(&ch, &msg, &dm, &gif); err {
case nil:
s := &Settings{
GuildID: guildID,
ChannelID: 0,
WelcomeMessage: "",
WelcomeDM: "",
WelcomeGIFURL: "",
HasConfiguration: true,
}
if ch.Valid {
s.ChannelID = ch.Int64
}
if msg.Valid {
s.WelcomeMessage = msg.String
}
if dm.Valid {
s.WelcomeDM = dm.String
}
if gif.Valid {
s.WelcomeGIFURL = gif.String
}
return s, nil
case sql.ErrNoRows:
return &Settings{
GuildID: guildID,
HasConfiguration: false,
}, nil
default:
return nil, err
}
}
func (r *Repo) UpsertChannel(ctx context.Context, guildID, channelID int64) error {
const q = `
INSERT INTO welcome (guild_id, welcome_channel_id)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET welcome_channel_id = EXCLUDED.welcome_channel_id
`
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
return err
}
func (r *Repo) UpsertMessage(ctx context.Context, guildID int64, message string) error {
const q = `
INSERT INTO welcome (guild_id, welcome_message)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET welcome_message = EXCLUDED.welcome_message
`
_, err := r.db.ExecContext(ctx, q, guildID, message)
return err
}
func (r *Repo) UpsertDM(ctx context.Context, guildID int64, dm string) error {
const q = `
INSERT INTO welcome (guild_id, welcome_dm)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET welcome_dm = EXCLUDED.welcome_dm
`
_, err := r.db.ExecContext(ctx, q, guildID, dm)
return err
}
func (r *Repo) UpsertGIF(ctx context.Context, guildID int64, gifURL string) error {
const q = `
INSERT INTO welcome (guild_id, welcome_gif_url)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET welcome_gif_url = EXCLUDED.welcome_gif_url
`
_, err := r.db.ExecContext(ctx, q, guildID, gifURL)
return err
}
@@ -0,0 +1,24 @@
package defaultrole
import (
"context"
"velox-bot/internal/db/repos/defaultrolerepo"
)
type Service struct {
repo *defaultrolerepo.Repo
}
func New(repo *defaultrolerepo.Repo) *Service {
return &Service{repo: repo}
}
func (s *Service) GetRole(ctx context.Context, guildID int64) (int64, error) {
return s.repo.GetRole(ctx, guildID)
}
func (s *Service) SetRole(ctx context.Context, guildID, roleID int64) error {
return s.repo.SetRole(ctx, guildID, roleID)
}
@@ -0,0 +1,32 @@
package logsettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error {
return s.settings.SetLogChannel(ctx, guildID, channelID)
}
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
return s.settings.SetLogEnabled(ctx, guildID, enabled)
}
func (s *Service) GetSettings(ctx context.Context, guildID int64) (*settingsrepo.LogSettings, error) {
return s.settings.GetLogSettings(ctx, guildID)
}
func (s *Service) HasSettings(ctx context.Context, guildID int64) (bool, error) {
return s.settings.HasLogSettings(ctx, guildID)
}
@@ -0,0 +1,32 @@
package musicsettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
func (s *Service) ToggleMusic(ctx context.Context, guildID int64) (bool, error) {
enabled, err := s.settings.IsMusicEnabled(ctx, guildID)
if err != nil {
return false, err
}
newEnabled := !enabled
if err := s.settings.SetMusicEnabled(ctx, guildID, newEnabled); err != nil {
return false, err
}
return newEnabled, nil
}
func (s *Service) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsMusicEnabled(ctx, guildID)
}
+12
View File
@@ -26,7 +26,19 @@ func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limi
return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit)
}
func (s *Service) ListActive(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.Project, error) {
return s.repo.ListActiveProjects(ctx, guildID, limit)
}
func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
return s.repo.ProjectExistsInGuild(ctx, guildID, projectID)
}
func (s *Service) GetProject(ctx context.Context, guildID, projectID int64) (*projectsrepo.Project, error) {
return s.repo.GetProject(ctx, guildID, projectID)
}
func (s *Service) DeactivateProject(ctx context.Context, guildID, projectID int64) error {
return s.repo.DeactivateProject(ctx, guildID, projectID)
}
+11 -8
View File
@@ -19,14 +19,6 @@ func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, invi
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
}
func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM)
}
func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) {
return s.repo.GetByMessageID(ctx, messageID)
}
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
return s.repo.UpdateStatus(ctx, scheduleID, status)
}
@@ -51,3 +43,14 @@ func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) erro
return s.repo.Reschedule(ctx, id, when)
}
func (s *Service) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
return s.repo.ProposeReschedule(ctx, scheduleID, newTime, proposedBy)
}
func (s *Service) AcceptReschedule(ctx context.Context, id int64) error {
return s.repo.AcceptReschedule(ctx, id)
}
func (s *Service) DeclineReschedule(ctx context.Context, id int64) error {
return s.repo.DeclineReschedule(ctx, id)
}
+13 -1
View File
@@ -1,14 +1,18 @@
package services
import (
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/musicsettings"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
"velox-bot/internal/db/services/schedule"
"velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome"
)
type Services struct {
@@ -20,11 +24,15 @@ type Services struct {
Projects *projects.Service
RPS *rps.Service
Twitch *twitch.Service
Welcome *welcome.Service
DefaultRole *defaultrole.Service
LogSettings *logsettings.Service
MusicSettings *musicsettings.Service
}
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) *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 {
s := &Services{
Level: level,
LevelSettings: levelSettings,
@@ -34,6 +42,10 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
Projects: projects,
RPS: rps,
Twitch: twitchSvc,
Welcome: welcomeSvc,
DefaultRole: defaultRoleSvc,
LogSettings: logSettingsSvc,
MusicSettings: musicSvc,
}
Global = s
return s
+8
View File
@@ -59,6 +59,14 @@ func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID
return s.repo.SetNotificationChannel(ctx, guildID, channelID)
}
func (s *Service) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.repo.IsEnabled(ctx, guildID)
}
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
return s.repo.SetEnabled(ctx, guildID, enabled)
}
func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) {
type gqlRequest struct {
Query string `json:"query"`
+36
View File
@@ -0,0 +1,36 @@
package welcome
import (
"context"
"velox-bot/internal/db/repos/welcomerepo"
)
type Service struct {
repo *welcomerepo.Repo
}
func New(repo *welcomerepo.Repo) *Service {
return &Service{repo: repo}
}
func (s *Service) GetSettings(ctx context.Context, guildID int64) (*welcomerepo.Settings, error) {
return s.repo.GetSettings(ctx, guildID)
}
func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error {
return s.repo.UpsertChannel(ctx, guildID, channelID)
}
func (s *Service) SetMessage(ctx context.Context, guildID int64, message string) error {
return s.repo.UpsertMessage(ctx, guildID, message)
}
func (s *Service) SetDM(ctx context.Context, guildID int64, dm string) error {
return s.repo.UpsertDM(ctx, guildID, dm)
}
func (s *Service) SetGIF(ctx context.Context, guildID int64, gifURL string) error {
return s.repo.UpsertGIF(ctx, guildID, gifURL)
}
+2
View File
@@ -11,6 +11,8 @@ import (
)
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
CacheMessageFromCreate(m)
if m.Author.Bot || m.GuildID == "" {
return
}
+376
View File
@@ -0,0 +1,376 @@
package events
import (
"fmt"
"log"
"slices"
"strings"
"sync"
"time"
"velox-bot/internal/db/services"
"velox-bot/internal/modlog"
"github.com/bwmarrin/discordgo"
)
type cachedMessage struct {
Content string
AuthorID string
}
var messageCache sync.Map // map[messageID]cachedMessage
func HandleMessageUpdateLog(s *discordgo.Session, m *discordgo.MessageUpdate, svc *services.Services) {
if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil {
return
}
if m.Author != nil && m.Author.Bot {
return
}
before := ""
if m.BeforeUpdate != nil {
before = strings.TrimSpace(m.BeforeUpdate.Content)
}
if before == "" {
if prev, ok := getCachedMessage(m.ID); ok {
before = strings.TrimSpace(prev.Content)
}
}
after := strings.TrimSpace(m.Content)
if before == after {
return
}
jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", m.GuildID, m.ChannelID, m.ID)
authorMention := "Unknown"
if m.Author != nil {
authorMention = m.Author.Mention()
}
embed := &discordgo.MessageEmbed{
Title: "Message Edited",
Color: 0xF1C40F,
Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\n[Jump to message](%s)", authorMention, m.ChannelID, jumpURL),
Fields: []*discordgo.MessageEmbedField{
{Name: "Before", Value: truncateOrPlaceholder(before), Inline: false},
{Name: "After", Value: truncateOrPlaceholder(after), Inline: false},
},
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send message edit log: %v", err)
}
cacheMessage(m.ID, after, authorIDOrEmpty(m.Author))
}
func HandleMessageDeleteLog(s *discordgo.Session, m *discordgo.MessageDelete, svc *services.Services) {
if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil {
return
}
authorID := ""
authorLabel := "Unknown"
content := ""
if m.BeforeDelete != nil {
if m.BeforeDelete.Author != nil {
authorID = m.BeforeDelete.Author.ID
authorLabel = m.BeforeDelete.Author.Mention()
}
content = strings.TrimSpace(m.BeforeDelete.Content)
}
if content == "" {
if prev, ok := getCachedMessage(m.ID); ok {
content = strings.TrimSpace(prev.Content)
if authorID == "" && prev.AuthorID != "" {
authorID = prev.AuthorID
authorLabel = "<@" + prev.AuthorID + ">"
}
}
}
if authorID == "" && m.Author != nil {
authorID = m.Author.ID
authorLabel = m.Author.Mention()
}
if authorID == "" {
authorLabel = "Unknown"
}
embed := &discordgo.MessageEmbed{
Title: "Message Deleted",
Color: 0xE74C3C,
Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\nMessage ID: `%s`", authorLabel, m.ChannelID, m.ID),
Fields: []*discordgo.MessageEmbedField{
{Name: "Content", Value: truncateOrPlaceholder(content), Inline: false},
},
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send message delete log: %v", err)
}
deleteCachedMessage(m.ID)
}
func HandleGuildMemberAddLog(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
embed := &discordgo.MessageEmbed{
Title: "Member Joined",
Color: 0x2ECC71,
Description: fmt.Sprintf("%s joined the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID),
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send member join log: %v", err)
}
}
func HandleGuildMemberRemoveLog(s *discordgo.Session, m *discordgo.GuildMemberRemove, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
// Try to classify this leave as a kick via recent audit log.
kickEntry, kickMod := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberKick)
title := "Member Left"
color := 0x95A5A6
description := fmt.Sprintf("%s left the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if kickEntry != nil {
title = "Member Kicked"
color = 0xE67E22
description = fmt.Sprintf("%s was kicked.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if kickMod != nil {
description += "\nModerator: " + kickMod.Mention()
}
if strings.TrimSpace(kickEntry.Reason) != "" {
description += "\nReason: " + kickEntry.Reason
}
}
embed := &discordgo.MessageEmbed{
Title: title,
Color: color,
Description: description,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send member remove log: %v", err)
}
}
func HandleGuildMemberUpdateLog(s *discordgo.Session, m *discordgo.GuildMemberUpdate, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil || m.BeforeUpdate == nil {
return
}
added := diffRoleIDs(m.BeforeUpdate.Roles, m.Roles)
removed := diffRoleIDs(m.Roles, m.BeforeUpdate.Roles)
if len(added) == 0 && len(removed) == 0 {
return
}
fields := make([]*discordgo.MessageEmbedField, 0, 2)
if len(added) > 0 {
fields = append(fields, &discordgo.MessageEmbedField{
Name: "Roles Added",
Value: joinRoleMentions(added),
Inline: false,
})
}
if len(removed) > 0 {
fields = append(fields, &discordgo.MessageEmbedField{
Name: "Roles Removed",
Value: joinRoleMentions(removed),
Inline: false,
})
}
embed := &discordgo.MessageEmbed{
Title: "Member Roles Updated",
Color: 0x3498DB,
Description: fmt.Sprintf("Member: %s (`%s`)", m.User.Mention(), m.User.ID),
Fields: fields,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send member update log: %v", err)
}
}
func HandleGuildBanAddLog(s *discordgo.Session, m *discordgo.GuildBanAdd, svc *services.Services) {
if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanAdd)
desc := fmt.Sprintf("%s was banned.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if moderator != nil {
desc += "\nModerator: " + moderator.Mention()
}
if entry != nil && strings.TrimSpace(entry.Reason) != "" {
desc += "\nReason: " + entry.Reason
}
embed := &discordgo.MessageEmbed{
Title: "Member Banned",
Color: 0xC0392B,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send ban add log: %v", err)
}
}
func HandleGuildBanRemoveLog(s *discordgo.Session, m *discordgo.GuildBanRemove, svc *services.Services) {
if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanRemove)
desc := fmt.Sprintf("%s was unbanned.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if moderator != nil {
desc += "\nModerator: " + moderator.Mention()
}
if entry != nil && strings.TrimSpace(entry.Reason) != "" {
desc += "\nReason: " + entry.Reason
}
embed := &discordgo.MessageEmbed{
Title: "Member Unbanned",
Color: 0x27AE60,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send ban remove log: %v", err)
}
}
func findRecentAuditEntryForTarget(s *discordgo.Session, guildID, targetID string, action discordgo.AuditLogAction) (*discordgo.AuditLogEntry, *discordgo.User) {
if s == nil || guildID == "" || targetID == "" {
return nil, nil
}
logs, err := s.GuildAuditLog(guildID, "", "", int(action), 10)
if err != nil || logs == nil {
return nil, nil
}
var chosen *discordgo.AuditLogEntry
now := time.Now().UTC()
for _, e := range logs.AuditLogEntries {
if e == nil || e.TargetID != targetID || e.ActionType == nil || *e.ActionType != action {
continue
}
ts, err := discordgo.SnowflakeTimestamp(e.ID)
if err != nil {
continue
}
// Keep only very recent entries to reduce false positives.
if now.Sub(ts) > 2*time.Minute {
continue
}
chosen = e
break
}
if chosen == nil {
return nil, nil
}
for _, u := range logs.Users {
if u != nil && u.ID == chosen.UserID {
return chosen, u
}
}
return chosen, nil
}
func truncateOrPlaceholder(s string) string {
v := strings.TrimSpace(s)
if v == "" {
return "*empty*"
}
if len(v) <= 1000 {
return v
}
return v[:997] + "..."
}
func diffRoleIDs(base, compare []string) []string {
out := make([]string, 0)
for _, id := range base {
if id == "" {
continue
}
if !slices.Contains(compare, id) {
out = append(out, id)
}
}
return out
}
func joinRoleMentions(roleIDs []string) string {
if len(roleIDs) == 0 {
return "*none*"
}
const max = 12
parts := make([]string, 0, len(roleIDs))
for idx, id := range roleIDs {
if idx >= max {
parts = append(parts, fmt.Sprintf("... and %d more", len(roleIDs)-max))
break
}
parts = append(parts, "<@&"+id+">")
}
return strings.Join(parts, ", ")
}
func CacheMessageFromCreate(m *discordgo.MessageCreate) {
if m == nil || m.Message == nil || m.ID == "" {
return
}
cacheMessage(m.ID, strings.TrimSpace(m.Content), authorIDOrEmpty(m.Author))
}
func cacheMessage(messageID, content, authorID string) {
if messageID == "" {
return
}
messageCache.Store(messageID, cachedMessage{
Content: content,
AuthorID: authorID,
})
}
func getCachedMessage(messageID string) (cachedMessage, bool) {
if messageID == "" {
return cachedMessage{}, false
}
raw, ok := messageCache.Load(messageID)
if !ok {
return cachedMessage{}, false
}
msg, ok := raw.(cachedMessage)
if !ok {
return cachedMessage{}, false
}
return msg, true
}
func deleteCachedMessage(messageID string) {
if messageID == "" {
return
}
messageCache.Delete(messageID)
}
func authorIDOrEmpty(u *discordgo.User) string {
if u == nil {
return ""
}
return u.ID
}
+163
View File
@@ -0,0 +1,163 @@
package events
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleBotAddedOnGuild applies safe defaults when this bot is added to a guild
// and DMs setup instructions to the inviter (or guild owner fallback).
func HandleBotAddedOnGuild(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || svc == nil {
return
}
if s.State == nil || s.State.User == nil {
return
}
if m.User.ID != s.State.User.ID {
return
}
guildID64, err := strconv.ParseInt(m.GuildID, 10, 64)
if err != nil {
return
}
// Treat an existing logsettings row as "already onboarded once".
alreadyOnboarded := false
if svc.LogSettings != nil {
exists, err := svc.LogSettings.HasSettings(context.Background(), guildID64)
if err == nil {
alreadyOnboarded = exists
}
}
if alreadyOnboarded {
return
}
// Explicitly disable toggleable systems by default.
if svc.LevelSettings != nil {
if err := svc.LevelSettings.SetLevelSystemEnabled(context.Background(), guildID64, false); err != nil {
log.Printf("onboarding: failed to set default level state for guild %s: %v", m.GuildID, err)
}
}
if svc.LogSettings != nil {
if err := svc.LogSettings.SetEnabled(context.Background(), guildID64, false); err != nil {
log.Printf("onboarding: failed to set default log state for guild %s: %v", m.GuildID, err)
}
}
recipientID := findRecentBotAdderID(s, m.GuildID)
if recipientID == "" {
recipientID = guildOwnerID(s, m.GuildID)
}
if recipientID == "" {
log.Printf("onboarding: unable to resolve DM recipient for guild %s", m.GuildID)
return
}
dmCh, err := s.UserChannelCreate(recipientID)
if err != nil {
log.Printf("onboarding: failed creating DM for user %s: %v", recipientID, err)
return
}
guildName := m.GuildID
if g, err := s.State.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
guildName = g.Name
} else if g, err := s.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
guildName = g.Name
}
embed := &discordgo.MessageEmbed{
Title: "Thanks for adding Velox Bot!",
Color: 0x5865F2,
Description: fmt.Sprintf(
"Server: **%s**\n\nFor safety, toggleable systems start **disabled by default**.\nUse these commands in your server to enable features:",
guildName,
),
Fields: []*discordgo.MessageEmbedField{
{
Name: "Level System",
Value: "`/slvl toggle`",
Inline: false,
},
{
Name: "Logging System",
Value: "" +
"`/config setlogchannel channel:#channel`\n" +
"`/config enablelogging`",
Inline: false,
},
{
Name: "Welcome System",
Value: "" +
"`/config setwelcomechannel channel:#channel`\n" +
"`/config setwelcomemessage message:\"...\"`\n" +
"`/config setwelcomedm message:\"...\"`",
Inline: false,
},
},
Footer: &discordgo.MessageEmbedFooter{
Text: "Tip: run /help in any server channel for the full command reference.",
},
Timestamp: time.Now().Format(time.RFC3339),
}
if _, err := s.ChannelMessageSendEmbed(dmCh.ID, embed); err != nil {
log.Printf("onboarding: failed sending setup DM to user %s: %v", recipientID, err)
}
}
func findRecentBotAdderID(s *discordgo.Session, guildID string) string {
if s == nil || s.State == nil || s.State.User == nil {
return ""
}
logs, err := s.GuildAuditLog(guildID, "", "", int(discordgo.AuditLogActionBotAdd), 10)
if err != nil || logs == nil {
return ""
}
botID := s.State.User.ID
now := time.Now().UTC()
for _, entry := range logs.AuditLogEntries {
if entry == nil || entry.TargetID != botID || entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionBotAdd {
continue
}
ts, err := discordgo.SnowflakeTimestamp(entry.ID)
if err != nil {
continue
}
if now.Sub(ts) > 10*time.Minute {
continue
}
if entry.UserID != "" {
return entry.UserID
}
}
return ""
}
func guildOwnerID(s *discordgo.Session, guildID string) string {
if s == nil || guildID == "" {
return ""
}
if s.State != nil {
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
return g.OwnerID
}
}
if g, err := s.Guild(guildID); err == nil && g != nil {
return g.OwnerID
}
return ""
}
-95
View File
@@ -1,95 +0,0 @@
package events
import (
"context"
"log"
"strconv"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
// ✅ accept, ❌ decline, 🔁 request reschedule.
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
if svc == nil || svc.Schedule == nil {
return
}
if r == nil || r.UserID == "" || r.MessageID == "" {
return
}
// Ignore bot reactions.
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
return
}
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
if err != nil || msgID64 == 0 {
return
}
ctx := context.Background()
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
if err != nil || sch == nil {
return
}
// Only invitee can react to change status.
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
return
}
// Only pending can be transitioned by reaction.
if sch.Status != schedulerepo.StatusPending {
return
}
var newStatus schedulerepo.ScheduleStatus
switch r.Emoji.Name {
case "✅":
newStatus = schedulerepo.StatusAccepted
case "❌":
newStatus = schedulerepo.StatusDeclined
case "🔁":
newStatus = schedulerepo.StatusRescheduleRequested
default:
return
}
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
return
}
statusText := map[schedulerepo.ScheduleStatus]string{
schedulerepo.StatusAccepted: "accepted ✅",
schedulerepo.StatusDeclined: "declined ❌",
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
}[newStatus]
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
// Notify requester with local time if configured.
if svc.UserSettings != nil {
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
requesterID := strconv.FormatInt(sch.RequesterID, 10)
dmCh, err := s.UserChannelCreate(requesterID)
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM requester about status change: %v", err)
}
}
}
}
+10
View File
@@ -47,6 +47,16 @@ func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) {
for _, guildID := range guildIDs {
log.Printf("twitch: processing guild %d", guildID)
enabled, err := svc.Twitch.IsEnabled(ctx, guildID)
if err != nil {
log.Printf("twitch: failed to check enabled state for guild %d: %v", guildID, err)
continue
}
if !enabled {
log.Printf("twitch: notifications disabled for guild %d", guildID)
continue
}
channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID)
if err != nil {
log.Printf("twitch: failed to get channel for guild %d: %v", guildID, err)
+133
View File
@@ -0,0 +1,133 @@
package events
import (
"context"
"log"
"strconv"
"strings"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleGuildMemberAdd sends a configurable welcome message in a guild channel
// and an optional configurable DM to the joining user.
func HandleGuildMemberAdd(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
if s == nil || m == nil || svc == nil || svc.Welcome == nil {
return
}
if m.User == nil {
return
}
ctx := context.Background()
guildID := mustParseInt64(m.GuildID)
settings, err := svc.Welcome.GetSettings(ctx, guildID)
if err != nil {
log.Printf("welcome: failed to load settings for guild %s: %v", m.GuildID, err)
return
}
hasWelcomeChannel := settings != nil && settings.ChannelID != 0
memberMention := m.User.Mention()
// Resolve guild/server name and user avatar for placeholders / visuals.
guildName := ""
userAvatarURL := ""
if g, err := s.State.Guild(m.GuildID); err == nil && g != nil {
guildName = g.Name
} else if g, err := s.Guild(m.GuildID); err == nil && g != nil {
guildName = g.Name
}
if m.User.Avatar != "" {
userAvatarURL = discordgo.EndpointUserAvatar(m.User.ID, m.User.Avatar)
}
if hasWelcomeChannel {
// Build guild welcome message.
channelID := strconv.FormatInt(settings.ChannelID, 10)
welcomeMsg := strings.TrimSpace(settings.WelcomeMessage)
if welcomeMsg == "" {
// Default message when none configured.
// "<user>! Welcome to <server>! Have fun!"
welcomeMsg = "<user>! Welcome to <server>! Have fun!"
}
welcomeMsg = strings.ReplaceAll(welcomeMsg, "{user}", memberMention)
if guildName != "" {
welcomeMsg = strings.ReplaceAll(welcomeMsg, "{server}", guildName)
welcomeMsg = strings.ReplaceAll(welcomeMsg, "<server>", guildName)
}
welcomeMsg = strings.ReplaceAll(welcomeMsg, "<user>", memberMention)
embed := &discordgo.MessageEmbed{
Title: "👋 Welcome!",
Description: welcomeMsg,
Color: 0xFFA500,
Footer: &discordgo.MessageEmbedFooter{
Text: "ID: " + m.User.ID,
},
}
if userAvatarURL != "" {
embed.Author = &discordgo.MessageEmbedAuthor{
Name: m.User.Username,
IconURL: userAvatarURL,
}
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{
URL: userAvatarURL,
}
}
gif := strings.TrimSpace(settings.WelcomeGIFURL)
if gif == "" {
// Default GIF when none configured.
gif = "https://images-ext-1.discordapp.net/external/uJ6XfdK2WwDnei3RmNWUqSiOVboC4mK9r78TtgVE_9g/https/media.giphy.com/media/XD9o33QG9BoMis7iM4/giphy.gif"
}
embed.Image = &discordgo.MessageEmbedImage{URL: gif}
if _, err := s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: memberMention,
Embed: embed,
}); err != nil {
log.Printf("welcome: failed to send channel welcome in guild %s: %v", m.GuildID, err)
}
}
// Optional DM.
dmText := ""
if settings != nil {
dmText = strings.TrimSpace(settings.WelcomeDM)
}
if dmText == "" {
// Default DM when none configured.
// "Welcome to <server>! Have fun!"
dmText = "Welcome to <server>! Have fun!"
}
dmText = strings.ReplaceAll(dmText, "{user}", memberMention)
if guildName != "" {
dmText = strings.ReplaceAll(dmText, "{server}", guildName)
dmText = strings.ReplaceAll(dmText, "<server>", guildName)
}
dmCh, err := s.UserChannelCreate(m.User.ID)
if err != nil {
log.Printf("welcome: failed to create DM channel for user %s: %v", m.User.ID, err)
return
}
if _, err := s.ChannelMessageSend(dmCh.ID, dmText); err != nil {
log.Printf("welcome: failed to send DM welcome to user %s: %v", m.User.ID, err)
}
// Default role assignment.
if svc.DefaultRole != nil {
roleID, err := svc.DefaultRole.GetRole(ctx, guildID)
if err != nil {
log.Printf("defaultrole: failed to get default role for guild %s: %v", m.GuildID, err)
} else if roleID != 0 {
if err := s.GuildMemberRoleAdd(m.GuildID, m.User.ID, strconv.FormatInt(roleID, 10)); err != nil {
log.Printf("defaultrole: failed to assign role %d to user %s in guild %s: %v", roleID, m.User.ID, m.GuildID, err)
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package modlog
import (
"context"
"strconv"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func Send(s *discordgo.Session, svc *services.Services, guildID string, embed *discordgo.MessageEmbed) error {
if s == nil || svc == nil || svc.LogSettings == nil || embed == nil || guildID == "" {
return nil
}
gid, err := strconv.ParseInt(guildID, 10, 64)
if err != nil {
return nil
}
cfg, err := svc.LogSettings.GetSettings(context.Background(), gid)
if err != nil || cfg == nil || !cfg.IsEnabled || cfg.ChannelID == 0 {
return err
}
_, err = s.ChannelMessageSendEmbed(strconv.FormatInt(cfg.ChannelID, 10), embed)
return err
}
+4 -1
View File
@@ -97,7 +97,10 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
content = "Done."
}
if err != nil {
content = "Error: " + err.Error()
// err can carry a raw Lavalink/network failure here (Player.Update),
// not just our own "music manager not initialized" - don't echo
// that detail into the ephemeral reply.
content = "That didn't work. Please try again."
}
// send ephemeral confirmation
+133
View File
@@ -0,0 +1,133 @@
package music
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"velox-bot/internal/db/repos/musicrepo"
"github.com/jackc/pgx/v5"
)
func StartCommandListener(ctx context.Context, connString string, repo *musicrepo.Repo) error {
conn, err := pgx.Connect(ctx, connString)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close(ctx)
_, err = conn.Exec(ctx, "LISTEN music_commands")
if err != nil {
return fmt.Errorf("exec: %w", err)
}
for {
_, err := conn.WaitForNotification(ctx)
if err != nil {
return fmt.Errorf("waitfornotification: %w", err)
}
processCommands(ctx, repo)
}
}
func processCommands(ctx context.Context, repo *musicrepo.Repo) {
cmds, err := repo.ListUnprocessedCommands(ctx)
if err != nil {
log.Printf("music: failed to list unprocessed commands: %v", err)
return
}
for _, cmd := range cmds {
if err := dispatchCommand(cmd); err != nil {
log.Printf("music: command %d (%s) for guild %d failed: %v", cmd.ID, cmd.CommandType, cmd.GuildID, err)
}
if err := repo.MarkCommandProcessed(ctx, cmd.ID); err != nil {
log.Printf("music: failed to mark command %d processed: %v", cmd.ID, err)
}
}
}
func dispatchCommand(cmd musicrepo.MusicCommand) error {
guildID := strconv.FormatInt(cmd.GuildID, 10)
switch cmd.CommandType {
case "skip":
return Skip(guildID)
case "pause":
return Pause(guildID, true)
case "resume":
return Pause(guildID, false)
case "stop":
return Stop(guildID)
case "set_volume":
var payload struct {
Volume int `json:"volume"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
return SetVolume(guildID, payload.Volume)
case "remove_track":
var payload struct {
QueueID string `json:"queue_id"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
return RemoveFromQueue(guildID, payload.QueueID)
case "reorder":
var payload struct {
QueueID string `json:"queue_id"`
Position int `json:"position"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
return Reorder(guildID, payload.QueueID, payload.Position)
case "repeat_song":
ToggleRepeatSong(guildID)
return nil
case "repeat_queue":
ToggleRepeatQueue(guildID)
return nil
case "add_song":
var payload struct {
Query string `json:"query"`
VoiceChannelID string `json:"voice_channel_id"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
requesterID := strconv.FormatInt(cmd.RequestedBy, 10)
// A voice channel means "start fresh" (nothing was playing, the
// dashboard had the user pick one), otherwise this just adds to
// whatever's already going.
if payload.VoiceChannelID != "" {
// Voice channels have their own built-in text chat at the same
// channel ID, post the now-playing embed there so starting
// playback from the dashboard still shows up somewhere in
// Discord, same as starting it with /play would.
_, _, err := EnqueueAndPlay(guildID, payload.VoiceChannelID, payload.VoiceChannelID, payload.Query, "Dashboard", requesterID)
return err
}
_, err := AddToQueue(guildID, payload.Query, requesterID)
return err
default:
return fmt.Errorf("unknown command type %q", cmd.CommandType)
}
}
+305 -23
View File
@@ -3,19 +3,34 @@ package music
import (
"context"
"fmt"
"log"
"net/url"
"strconv"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/musicrepo"
"github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/disgolink"
"github.com/disgoorg/disgolink/v3/lavalink"
"github.com/disgoorg/snowflake/v2"
"github.com/google/uuid"
)
type TrackEntry struct {
Track lavalink.Track
RequestedBy string
RequesterID string
StartedAt time.Time
// QueueID is generated once, when the track is first added to the
// queue, and never changes for the rest of its life there - it's
// what lets syncState upsert this track's row across syncs instead
// of deleting and recreating it (and its DB id) every single time,
// which is what remove/reorder commands need to reliably target a
// specific track.
QueueID string
}
type guildPlayer struct {
@@ -33,6 +48,7 @@ type guildPlayer struct {
type Manager struct {
client disgolink.Client
session *discordgo.Session
repo *musicrepo.Repo
mu sync.Mutex
players map[string]*guildPlayer
@@ -40,7 +56,7 @@ type Manager struct {
var manager *Manager
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
if lavalinkHost == "" {
return nil
}
@@ -53,6 +69,7 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
m := &Manager{
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
session: session,
repo: repo,
players: make(map[string]*guildPlayer),
}
@@ -67,18 +84,26 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
password = "youshallnotpass"
}
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
// Connecting to the Lavalink node is a blocking network call (and, on
// failure, disgolink retries indefinitely with backoff) — do it in the
// background so bot startup isn't held hostage by it. Music commands
// simply fail with "not initialized" via the manager==nil checks until
// this completes.
go func() {
_, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
Name: "main",
Address: u.Host,
Password: password,
Secure: secure,
})
if err != nil {
return fmt.Errorf("add lavalink node: %w", err)
log.Printf("music: failed to connect to lavalink node: %v", err)
return
}
manager = m
go idleDisconnectLoop()
}()
return nil
}
@@ -183,23 +208,126 @@ func getOrCreateGuildPlayer(guildID string) *guildPlayer {
return gp
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
if manager == nil {
return nil, false, fmt.Errorf("music manager not initialized")
func syncState(guidID string) {
if manager == nil || manager.repo == nil {
return
}
// Join voice channel
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
return nil, false, fmt.Errorf("join voice channel: %w", err)
}
gp := getOrCreateGuildPlayer(guildID)
if textChannelID != "" {
manager.mu.Lock()
gp.TextChannelID = textChannelID
gp, ok := manager.players[guidID]
if !ok {
manager.mu.Unlock()
return
}
queue := make([]TrackEntry, len(gp.Queue))
copy(queue, gp.Queue)
paused := gp.Paused
volume := gp.Volume
repeatSong := gp.RepeatSong
repeatQueue := gp.RepeatQueue
manager.mu.Unlock()
gID, err := strconv.ParseInt(guidID, 10, 64)
if err != nil {
return
}
if len(queue) == 0 {
err := manager.repo.ClearNowPlaying(context.Background(), gID)
if err != nil {
return
}
err = manager.repo.ReplaceQueue(context.Background(), gID, nil)
if err != nil {
return
}
return
}
current := queue[0]
title := current.Track.Info.Title
var uri string
if current.Track.Info.URI != nil {
uri = *current.Track.Info.URI
}
length := int(current.Track.Info.Length / 1000)
startedAt := current.StartedAt
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
if err != nil {
return
}
rest := queue[1:]
var queueEntries []musicrepo.QueueEntry
for _, entry := range rest {
reqID, _ := strconv.ParseInt(entry.RequesterID, 10, 64)
var uri string
if entry.Track.Info.URI != nil {
uri = *entry.Track.Info.URI
}
queueEntries = append(queueEntries, musicrepo.QueueEntry{
Title: entry.Track.Info.Title,
URL: uri,
RequestedBy: reqID,
ClientID: entry.QueueID,
})
}
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
if err != nil {
return
}
}
// NormalizeQuery turns plain text into a YouTube search and strips
// auto-generated "radio/mix" params off a pasted YouTube URL
// (https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 becomes
// .../watch?v=ID), while leaving normal playlist URLs and non-YouTube
// links (SoundCloud, etc.) untouched. Every entry point that accepts a
// user-supplied query - /play and the dashboard's "add song" - should
// normalize through this before handing the query to Lavalink.
func NormalizeQuery(query string) string {
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
return "ytsearch:" + query
}
u, err := url.Parse(query)
if err != nil || u == nil {
return query
}
host := strings.ToLower(u.Host)
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
return query
}
q := u.Query()
v := q.Get("v")
if v == "" {
return query
}
list := q.Get("list")
_, hasStartRadio := q["start_radio"]
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
return query
}
u.RawQuery = url.Values{"v": []string{v}}.Encode()
u.Fragment = ""
return u.String()
}
// resolveTracks searches Lavalink for query (already normalized) and
// returns a playable track list - a single track, a search result's
// first hit, or a whole playlist (rotated to respect Lavalink's own
// selectedTrack when present).
func resolveTracks(query string) ([]lavalink.Track, error) {
var loadedTracks []lavalink.Track
manager.client.BestNode().LoadTracksHandler(
@@ -237,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
)
if len(loadedTracks) == 0 {
return nil, false, fmt.Errorf("no tracks found for query")
return nil, fmt.Errorf("no tracks found for query")
}
// Some sources may return unplayable entries; pick the first with an encoded track.
@@ -252,11 +380,38 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
}
return loadedTracks, nil
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID string) (*TrackEntry, bool, error) {
if manager == nil {
return nil, false, fmt.Errorf("music manager not initialized")
}
// Join voice channel
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
return nil, false, fmt.Errorf("join voice channel: %w", err)
}
gp := getOrCreateGuildPlayer(guildID)
if textChannelID != "" {
manager.mu.Lock()
gp.TextChannelID = textChannelID
manager.mu.Unlock()
}
loadedTracks, err := resolveTracks(NormalizeQuery(query))
if err != nil {
return nil, false, err
}
entries := make([]TrackEntry, len(loadedTracks))
for idx, t := range loadedTracks {
entries[idx] = TrackEntry{
Track: t,
RequestedBy: requester,
RequesterID: requesterID,
QueueID: uuid.New().String(),
}
}
firstEntry := entries[0]
@@ -268,6 +423,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
manager.mu.Unlock()
if shouldStart {
gp.Queue[0].StartedAt = time.Now()
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
return nil, false, fmt.Errorf("start track: %w", err)
}
@@ -276,9 +433,52 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
}
}
syncState(guildID)
return &firstEntry, shouldStart, nil
}
// AddToQueue adds a track to a guild's existing queue without joining a
// voice channel, unlike EnqueueAndPlay it can't start a brand new
// session (that needs a voice channel to join, which this has no way to
// pick), so it requires one to already be active.
func AddToQueue(guildID, query, requesterID string) (*TrackEntry, error) {
if manager == nil {
return nil, fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
alreadyActive := len(gp.Queue) > 0
manager.mu.Unlock()
if !alreadyActive {
return nil, fmt.Errorf("nothing is currently playing in this server")
}
loadedTracks, err := resolveTracks(NormalizeQuery(query))
if err != nil {
return nil, err
}
entries := make([]TrackEntry, len(loadedTracks))
for idx, t := range loadedTracks {
entries[idx] = TrackEntry{
Track: t,
RequestedBy: "Dashboard",
RequesterID: requesterID,
QueueID: uuid.New().String(),
}
}
manager.mu.Lock()
gp.Queue = append(gp.Queue, entries...)
manager.mu.Unlock()
syncState(guildID)
return &entries[0], nil
}
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if manager == nil {
return
@@ -298,6 +498,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
// repeat current track
if gp.RepeatSong {
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -308,12 +509,14 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
return
}
// pop current and optionally cycle it to end
finished := gp.Queue[0]
gp.Queue = gp.Queue[1:]
if gp.RepeatQueue {
gp.Queue = append(gp.Queue, finished)
}
@@ -328,9 +531,11 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
syncState(guildID)
return
}
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -341,6 +546,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
}
func Pause(guildID string, pause bool) error {
@@ -354,6 +560,7 @@ func Pause(guildID string, pause bool) error {
gp.IdleSince = time.Time{}
}
manager.mu.Unlock()
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
}
@@ -384,9 +591,11 @@ func Skip(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -397,6 +606,7 @@ func Skip(guildID string) error {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
return nil
}
@@ -418,6 +628,7 @@ func Stop(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
@@ -447,12 +658,15 @@ func ToggleRepeatSong(guildID string) (bool, bool) {
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
gp.RepeatSong = !gp.RepeatSong
if gp.RepeatSong {
gp.RepeatQueue = false
}
return gp.RepeatSong, gp.RepeatQueue
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
manager.mu.Unlock()
syncState(guildID)
return repeatSong, repeatQueue
}
func ToggleRepeatQueue(guildID string) (bool, bool) {
@@ -461,12 +675,15 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
gp.RepeatQueue = !gp.RepeatQueue
if gp.RepeatQueue {
gp.RepeatSong = false
}
return gp.RepeatSong, gp.RepeatQueue
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
manager.mu.Unlock()
syncState(guildID)
return repeatSong, repeatQueue
}
func repeatFlags(guildID string) (bool, bool) {
@@ -522,6 +739,73 @@ func SetVolume(guildID string, volume int) error {
postNowPlaying(guildID, textChannelID, *current)
}
}
syncState(guildID)
return nil
}
func RemoveFromQueue(guildID, queueID string) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
idx := -1
for i, q := range gp.Queue {
if q.QueueID == queueID {
idx = i
break
}
}
if idx == -1 {
manager.mu.Unlock()
return fmt.Errorf("queue not found")
}
if idx == 0 {
manager.mu.Unlock()
return Skip(guildID)
}
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
manager.mu.Unlock()
syncState(guildID)
return nil
}
func Reorder(guildID, queueID string, newPosition int) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
idx := -1
for i, q := range gp.Queue {
if q.QueueID == queueID {
idx = i
break
}
}
if idx == -1 {
manager.mu.Unlock()
return fmt.Errorf("queue not found")
}
if idx == 0 {
manager.mu.Unlock()
return Skip(guildID)
}
entry := gp.Queue[idx]
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
pos := max(1, min(newPosition, len(gp.Queue)))
gp.Queue = append(gp.Queue[:pos], append([]TrackEntry{entry}, gp.Queue[pos:]...)...)
manager.mu.Unlock()
syncState(guildID)
return nil
}
@@ -534,5 +818,3 @@ func CurrentNowPlayingMessageID(guildID string) string {
defer manager.mu.Unlock()
return gp.NowPlayingMsgID
}
+144
View File
@@ -0,0 +1,144 @@
package security
import (
"bytes"
"context"
"log"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
const (
colorAlert = 0xdc2626
colorWarn = 0xf59e0b
colorOK = 0x16a34a
)
type alert struct {
embed *discordgo.MessageEmbed
ping bool // ping the alert role (raids and nukes, not routine join gate actions)
pingOwner bool
// critical alerts also go by DM to the owner and trusted users, since
// the alert channel may be gone or locked in exactly these moments.
critical bool
components []discordgo.MessageComponent
file *discordgo.File
}
// send posts an alert to the configured channel. It only ever pings the
// configured alert role (never @everyone, even if that was saved somehow) and
// optionally the owner.
func (e *Engine) send(ctx context.Context, guildID string, s *securityrepo.Settings, a alert) *discordgo.Message {
if a.critical && s.AlertDM {
if e.syncDMs {
e.dmAlert(ctx, guildID, s, a)
} else {
go e.dmAlert(context.Background(), guildID, s, a)
}
}
if s.AlertChannelID == 0 {
return nil
}
msg := &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{a.embed},
Components: a.components,
AllowedMentions: &discordgo.MessageAllowedMentions{},
}
if a.ping && s.AlertRoleID != 0 && id(s.AlertRoleID) != guildID {
msg.Content = "<@&" + id(s.AlertRoleID) + ">"
msg.AllowedMentions.Roles = []string{id(s.AlertRoleID)}
}
if a.pingOwner {
if owner, err := e.OwnerID(guildID); err == nil && owner != "" {
msg.Content += " <@" + owner + ">"
msg.AllowedMentions.Users = []string{owner}
}
}
if a.file != nil {
msg.Files = []*discordgo.File{a.file}
}
m, err := e.d.ChannelMessageSendComplex(id(s.AlertChannelID), msg)
if err != nil {
log.Printf("security: alert in guild %s: %v", guildID, err)
return nil
}
return m
}
func textFile(name, content string) *discordgo.File {
return &discordgo.File{Name: name, ContentType: "text/plain; charset=utf-8", Reader: bytes.NewReader([]byte(content))}
}
// maxAlertDMs caps how many people get each critical alert by DM. Mass DMs
// from a bot look like spam to Discord.
const maxAlertDMs = 10
// alertDMRecipients is the owner plus trusted users (not roles, which could
// hold many members), without duplicates or the bot itself.
func (e *Engine) alertDMRecipients(ctx context.Context, guildID string) []string {
seen := map[string]bool{e.botID: true}
var out []string
add := func(u string) {
if u != "" && !seen[u] && len(out) < maxAlertDMs {
seen[u] = true
out = append(out, u)
}
}
if owner, err := e.OwnerID(guildID); err == nil {
add(owner)
}
if _, trusted, err := e.load(ctx, guildID); err == nil {
for _, t := range trusted {
if t.Kind == "user" {
add(id(t.TargetID))
}
}
}
return out
}
// dmAlert sends a critical alert to each recipient's DMs. Closed DMs and
// other failures are skipped: the alert channel is still the main record.
func (e *Engine) dmAlert(ctx context.Context, guildID string, s *securityrepo.Settings, a alert) {
if a.embed == nil {
return
}
server := e.guildName(guildID)
if server == "" {
server = "a server"
}
embed := *a.embed
embed.Footer = &discordgo.MessageEmbedFooter{Text: clip("Velox security · "+server, 200)}
extra := ""
if a.file != nil {
if s.AlertChannelID != 0 {
extra = "\nThe full list is posted in the alert channel."
} else {
extra = "\nSet an alert channel on the dashboard to also get the full list of accounts."
}
}
if extra != "" {
embed.Description = clip(embed.Description+extra, 4000)
}
msg := &discordgo.MessageSend{
Content: "Security alert in **" + escapeText(server) + "**. Use the dashboard or `/security` in the server to act.",
Embeds: []*discordgo.MessageEmbed{&embed},
AllowedMentions: &discordgo.MessageAllowedMentions{},
}
for _, u := range e.alertDMRecipients(ctx, guildID) {
ch, err := e.d.UserChannelCreate(u)
if err != nil {
continue
}
if _, err := e.d.ChannelMessageSendComplex(ch.ID, msg); err != nil {
log.Printf("security: DM alert to %s for guild %s: %v", u, guildID, err)
}
}
}
// escapeText defuses markdown and mentions in a name (e.g. a server name).
func escapeText(s string) string {
return safeName(&discordgo.User{Username: s})
}
+207
View File
@@ -0,0 +1,207 @@
package security
import (
"context"
"fmt"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// The configuration calls below do their own permission checks, so every
// way in (slash commands, buttons, anything added later) gets the same rules:
//
// - switching a system on: Manage Server, or owner/trusted
// - switching a system off, alert and quarantine settings: owner/trusted
// - the trusted list: owner only
//
// actorPerms is the actor's permission set as Discord sent it with the
// interaction.
const maxTrusted = 25
func hasManageGuild(perms int64) bool {
return perms&(discordgo.PermissionAdministrator|discordgo.PermissionManageGuild) != 0
}
// SetFeature turns the Join Gate, raid mode or anti-nuke on or off.
func (e *Engine) SetFeature(ctx context.Context, guildID string, actor *discordgo.Member, actorPerms int64, feature string, on bool) error {
if actor == nil || actor.User == nil {
return ErrNotTrusted
}
switch feature {
case securityrepo.FeatureJoinGate, securityrepo.FeatureAntiRaid, securityrepo.FeatureAntiNuke, securityrepo.FeatureHeat:
default:
return UserError("Unknown feature.")
}
trustedErr := e.RequireTrusted(ctx, guildID, actor)
if !on && trustedErr != nil {
return trustedErr
}
if on && trustedErr != nil && !hasManageGuild(actorPerms) {
return UserError("You need Manage Server to turn security features on.")
}
if err := e.st.SetEnabled(ctx, parseID(guildID), feature, on); err != nil {
return err
}
e.Invalidate(guildID)
state := "off"
if on {
state = "on"
}
e.event(ctx, guildID, "settings", actor.User.ID, "", feature+" "+state)
return nil
}
func (e *Engine) roleByID(guildID, roleID string) (*discordgo.Role, []*discordgo.Role, error) {
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return nil, nil, fmt.Errorf("listing roles: %w", err)
}
for _, r := range roles {
if r.ID == roleID {
return r, roles, nil
}
}
return nil, roles, UserError("That role isn't in this server.")
}
// SetAlerts sets where alerts go and which role they ping. Changing it could
// hide alerts from the staff, so it needs owner or trusted.
func (e *Engine) SetAlerts(ctx context.Context, guildID string, actor *discordgo.Member, channelID, roleID string) error {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return err
}
ch, err := e.d.Channel(channelID)
if err != nil || ch.GuildID != guildID {
return UserError("That channel isn't in this server.")
}
if ch.Type != discordgo.ChannelTypeGuildText && ch.Type != discordgo.ChannelTypeGuildNews {
return UserError("Alerts need a text channel.")
}
if roleID != "" {
if roleID == guildID {
return UserError("Alerts can't ping @everyone. Pick a staff role.")
}
if _, _, err := e.roleByID(guildID, roleID); err != nil {
return err
}
}
if err := e.st.SetAlerts(ctx, parseID(guildID), parseID(channelID), parseID(roleID)); err != nil {
return err
}
e.Invalidate(guildID)
e.event(ctx, guildID, "settings", actor.User.ID, channelID, "alerts channel and role changed")
return nil
}
// ValidateQuarantineRole checks a role can safely be the quarantine role:
// in this server, not @everyone, not managed, no dangerous permissions, and
// below Velox's highest role so the bot can assign it.
func (e *Engine) ValidateQuarantineRole(guildID, roleID string) error {
if roleID == guildID {
return UserError("@everyone can't be the quarantine role.")
}
role, roles, err := e.roleByID(guildID, roleID)
if err != nil {
return err
}
if role.Managed {
return UserError("That role is managed by an integration and can't be assigned.")
}
if role.Permissions&DangerousPerms != 0 {
return UserError("The quarantine role can't have Administrator, Manage Server, Manage Roles or Manage Webhooks.")
}
me, err := e.d.GuildMember(guildID, e.botID)
if err != nil {
return fmt.Errorf("looking up Velox: %w", err)
}
top := -1
for _, r := range roles {
for _, mine := range me.Roles {
if r.ID == mine && r.Position > top {
top = r.Position
}
}
}
if role.Position >= top {
return UserError("That role is above Velox's highest role, so Velox couldn't assign it. Move it below Velox's role.")
}
return nil
}
// SetQuarantineRole sets (or with "" clears) the quarantine role.
func (e *Engine) SetQuarantineRole(ctx context.Context, guildID string, actor *discordgo.Member, roleID string) error {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return err
}
if roleID != "" {
if err := e.ValidateQuarantineRole(guildID, roleID); err != nil {
return err
}
}
if err := e.st.SetQuarantineRole(ctx, parseID(guildID), parseID(roleID)); err != nil {
return err
}
e.Invalidate(guildID)
e.event(ctx, guildID, "settings", actor.User.ID, roleID, "quarantine role changed")
return nil
}
// Trust adds a user or role to the trusted list. Owner only: anyone trusted
// can switch protection off, so trust must not spread on its own.
func (e *Engine) Trust(ctx context.Context, guildID string, actor *discordgo.Member, kind, targetID string) error {
if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) {
return ErrNotOwner
}
_, trusted, err := e.load(ctx, guildID)
if err != nil {
return err
}
if len(trusted) >= maxTrusted {
return UserError(fmt.Sprintf("The trusted list can hold at most %d entries.", maxTrusted))
}
switch kind {
case "role":
if targetID == guildID {
return UserError("Trusting @everyone would make everyone trusted.")
}
role, _, err := e.roleByID(guildID, targetID)
if err != nil {
return err
}
if role.Managed {
return UserError("Integration roles can't be trusted; trust the bot user instead.")
}
case "user":
if _, err := e.d.GuildMember(guildID, targetID); err != nil {
return UserError("That user isn't in this server.")
}
default:
return UserError("Trust a user or a role.")
}
if err := e.st.AddTrusted(ctx, parseID(guildID), securityrepo.Trusted{TargetID: parseID(targetID), Kind: kind, AddedBy: parseID(actor.User.ID)}); err != nil {
return err
}
e.Invalidate(guildID)
e.event(ctx, guildID, "trusted_added", actor.User.ID, targetID, kind)
return nil
}
// Untrust removes a user or role from the trusted list. Owner only.
func (e *Engine) Untrust(ctx context.Context, guildID string, actor *discordgo.Member, targetID string) error {
if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) {
return ErrNotOwner
}
ok, err := e.st.RemoveTrusted(ctx, parseID(guildID), parseID(targetID))
if err != nil {
return err
}
if !ok {
return UserError("That isn't on the trusted list.")
}
e.Invalidate(guildID)
e.event(ctx, guildID, "trusted_removed", actor.User.ID, targetID, "")
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package security
import (
"fmt"
"strings"
"testing"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
func nuke(e *Engine) {
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
}
func TestCriticalAlertsAreDMed(t *testing.T) {
e, d, st := nukeEngine(t)
d.guildName = "Velox **HQ** @everyone"
// Owner (1) is also on the list as a user; a trusted role must not be expanded.
st.trusted[100] = []securityrepo.Trusted{{TargetID: 1, Kind: "user"}, {TargetID: 60, Kind: "user"}, {TargetID: 201, Kind: "role"}, {TargetID: 999, Kind: "user"}}
refresh(e)
nuke(e)
got := strings.Join(d.dmsTo(), ",")
if got != "1,60" {
t.Fatalf("DMs went to %q, want the owner and the trusted user once each (no role members, not the bot)", got)
}
var dm *discordgo.MessageSend
for i, ch := range d.sentTo {
if ch == "dm-1" {
dm = d.sent[i]
}
}
if !strings.Contains(dm.Embeds[0].Title, "Nuke attempt stopped") {
t.Errorf("DM should carry the alert: %+v", dm.Embeds[0])
}
if strings.Contains(dm.Content, "@everyone") || strings.Contains(dm.Content, "**HQ**") || dm.AllowedMentions == nil {
t.Errorf("the server name must be escaped and nothing pinged: %q", dm.Content)
}
if len(dm.Components) != 0 {
t.Error("no action buttons in DMs")
}
}
func TestDMsWorkWithoutAnAlertChannel(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).AlertChannelID = 0
refresh(e)
nuke(e)
if strings.Join(d.dmsTo(), ",") != "1" {
t.Errorf("the owner should still be told: %v", d.dmsTo())
}
}
func TestDMsCanBeTurnedOff(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).AlertDM = false
refresh(e)
nuke(e)
if len(d.dmsTo()) != 0 {
t.Errorf("DMs are off: %v", d.dmsTo())
}
}
func TestRoutineAlertsAreNotDMed(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.JoinGateEnabled, s.NoAvatar = true, securityrepo.ActionKick
refresh(e)
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), 1)}})
if len(d.dmsTo()) != 0 {
t.Error("Join Gate actions only go to the alert channel")
}
}
func TestClosedDMsAreSkippedAndListIsCapped(t *testing.T) {
e, d, st := nukeEngine(t)
d.closedDMs = map[string]bool{"1": true}
for i := 0; i < 15; i++ {
st.trusted[100] = append(st.trusted[100], securityrepo.Trusted{TargetID: int64(700 + i), Kind: "user"})
}
refresh(e)
nuke(e)
got := d.dmsTo()
if len(got) != maxAlertDMs-1 {
t.Errorf("owner's DMs are closed and the list is capped at %d: got %d (%v)", maxAlertDMs, len(got), got)
}
if got[0] != fmt.Sprint(700) {
t.Errorf("the others still get it: %v", got)
}
}
+557
View File
@@ -0,0 +1,557 @@
package security
import (
"context"
"errors"
"fmt"
"io"
"sort"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// fakeStore keeps everything in memory with the same rules as the SQL.
type fakeStore struct {
mu sync.Mutex
settings map[int64]*securityrepo.Settings
trusted map[int64][]securityrepo.Trusted
quarantines map[string]securityrepo.Quarantine
lockdowns map[string]securityrepo.Lockdown
events []securityrepo.Event
cmds []securityrepo.Command
done map[int64]bool
raids map[int64]securityrepo.Raid
caught map[int64][]securityrepo.Caught
snapshots []securityrepo.Snapshot
}
func newStore() *fakeStore {
return &fakeStore{
settings: map[int64]*securityrepo.Settings{}, trusted: map[int64][]securityrepo.Trusted{},
quarantines: map[string]securityrepo.Quarantine{}, lockdowns: map[string]securityrepo.Lockdown{}, done: map[int64]bool{},
raids: map[int64]securityrepo.Raid{}, caught: map[int64][]securityrepo.Caught{},
}
}
func (f *fakeStore) SaveRaid(_ context.Context, x securityrepo.Raid) error {
f.mu.Lock()
defer f.mu.Unlock()
if old, ok := f.raids[x.GuildID]; ok {
x.StartedAt = old.StartedAt
}
f.raids[x.GuildID] = x
return nil
}
func (f *fakeStore) ListRaids(context.Context) ([]securityrepo.Raid, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Raid
for _, x := range f.raids {
out = append(out, x)
}
return out, nil
}
func (f *fakeStore) DeleteRaid(_ context.Context, g int64) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.raids, g)
delete(f.caught, g)
return nil
}
func (f *fakeStore) AddRaidCaught(_ context.Context, g int64, c securityrepo.Caught) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, x := range f.caught[g] {
if x.UserID == c.UserID {
return nil
}
}
f.caught[g] = append(f.caught[g], c)
return nil
}
func (f *fakeStore) ListRaidCaught(_ context.Context, g int64, limit int) ([]securityrepo.Caught, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := f.caught[g]
if len(out) > limit {
out = out[:limit]
}
return append([]securityrepo.Caught(nil), out...), nil
}
func (f *fakeStore) SaveSnapshot(_ context.Context, s securityrepo.Snapshot) error {
f.mu.Lock()
defer f.mu.Unlock()
s.ID = int64(len(f.snapshots) + 1)
s.CreatedAt = time.Date(2026, 10, 20, 6, 0, 0, 0, time.UTC)
f.snapshots = append(f.snapshots, s)
return nil
}
func (f *fakeStore) LatestSnapshot(_ context.Context, g int64) (*securityrepo.Snapshot, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.snapshots) - 1; i >= 0; i-- {
if f.snapshots[i].GuildID == g {
s := f.snapshots[i]
return &s, nil
}
}
return nil, nil
}
func (f *fakeStore) GuildsDueForSnapshot(context.Context, time.Duration) ([]int64, error) {
return nil, nil
}
func key(a, b int64) string { return fmt.Sprint(a, "/", b) }
func (f *fakeStore) set(g int64) *securityrepo.Settings {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.settings[g]
if !ok {
s = securityrepo.Defaults(g)
s.Configured = true
f.settings[g] = s
}
return s
}
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*securityrepo.Settings, error) {
f.mu.Lock()
defer f.mu.Unlock()
if s, ok := f.settings[g]; ok {
cp := *s
return &cp, nil
}
return securityrepo.Defaults(g), nil
}
func (f *fakeStore) SetEnabled(_ context.Context, g int64, feature string, on bool) error {
s := f.set(g)
switch feature {
case securityrepo.FeatureJoinGate:
s.JoinGateEnabled = on
case securityrepo.FeatureAntiRaid:
s.AntiRaidEnabled = on
case securityrepo.FeatureAntiNuke:
s.AntiNukeEnabled = on
}
return nil
}
func (f *fakeStore) SetAlerts(_ context.Context, g, ch, role int64) error {
s := f.set(g)
s.AlertChannelID, s.AlertRoleID = ch, role
return nil
}
func (f *fakeStore) SetQuarantineRole(_ context.Context, g, role int64) error {
f.set(g).QuarantineRoleID = role
return nil
}
func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
f.set(g).JoinLock = mode
return nil
}
func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]securityrepo.Trusted(nil), f.trusted[g]...), nil
}
func (f *fakeStore) AddTrusted(_ context.Context, g int64, t securityrepo.Trusted) error {
f.mu.Lock()
defer f.mu.Unlock()
f.trusted[g] = append(f.trusted[g], t)
return nil
}
func (f *fakeStore) RemoveTrusted(_ context.Context, g, target int64) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
var keep []securityrepo.Trusted
found := false
for _, t := range f.trusted[g] {
if t.TargetID == target {
found = true
continue
}
keep = append(keep, t)
}
f.trusted[g] = keep
return found, nil
}
func (f *fakeStore) SaveQuarantine(_ context.Context, q securityrepo.Quarantine) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.quarantines[key(q.GuildID, q.UserID)]; !ok {
f.quarantines[key(q.GuildID, q.UserID)] = q
}
return nil
}
func (f *fakeStore) GetQuarantine(_ context.Context, g, u int64) (*securityrepo.Quarantine, error) {
f.mu.Lock()
defer f.mu.Unlock()
if q, ok := f.quarantines[key(g, u)]; ok {
return &q, nil
}
return nil, nil
}
func (f *fakeStore) ListQuarantines(_ context.Context, g int64) ([]securityrepo.Quarantine, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Quarantine
for _, q := range f.quarantines {
if q.GuildID == g {
out = append(out, q)
}
}
return out, nil
}
func (f *fakeStore) DeleteQuarantine(_ context.Context, g, u int64) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.quarantines, key(g, u))
return nil
}
func (f *fakeStore) SaveLockdown(_ context.Context, l securityrepo.Lockdown) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.lockdowns[key(l.GuildID, l.ChannelID)]; !ok {
f.lockdowns[key(l.GuildID, l.ChannelID)] = l
}
return nil
}
func (f *fakeStore) GetLockdown(_ context.Context, g, ch int64) (*securityrepo.Lockdown, error) {
f.mu.Lock()
defer f.mu.Unlock()
if l, ok := f.lockdowns[key(g, ch)]; ok {
return &l, nil
}
return nil, nil
}
func (f *fakeStore) ListLockdowns(_ context.Context, g int64, serverOnly bool) ([]securityrepo.Lockdown, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Lockdown
for _, l := range f.lockdowns {
if l.GuildID == g && (!serverOnly || l.ServerWide) {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
return out, nil
}
func (f *fakeStore) DeleteLockdown(_ context.Context, g, ch int64) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.lockdowns, key(g, ch))
return nil
}
func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, e)
return nil
}
func (f *fakeStore) eventKinds() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, e := range f.events {
out = append(out, e.Kind)
}
return out
}
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]securityrepo.Command, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Command
for _, c := range f.cmds {
if !f.done[c.ID] {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.done[id] = true
return nil
}
// fakeDiscord is a tiny in-memory guild.
type fakeDiscord struct {
mu sync.Mutex
ownerID string
roles []*discordgo.Role
members map[string]*discordgo.Member
users map[string]*discordgo.User
channels map[string]*discordgo.Channel
kicked []string
banned []string
timeouts map[string]*time.Time
sent []*discordgo.MessageSend
sentTo []string
files map[string]string
requests []string // "METHOD url body"
deletedMsgs []string
nextID int
failEdits error
rejectRole string // edits that assign this role fail, like a role above Velox's
lookups int // member and role lookups, to check harmless entries cost nothing
closedDMs map[string]bool
guildName string
}
const guild = "100"
func newDiscord() *fakeDiscord {
return &fakeDiscord{
ownerID: "1",
roles: []*discordgo.Role{
{ID: guild, Name: "@everyone", Position: 0},
{ID: "200", Name: "Admin", Position: 5, Permissions: discordgo.PermissionAdministrator},
{ID: "201", Name: "Mod", Position: 3, Permissions: discordgo.PermissionKickMembers},
{ID: "202", Name: "Member", Position: 1},
{ID: "203", Name: "Quarantine", Position: 2},
{ID: "204", Name: "Booster", Position: 1, Managed: true},
{ID: "299", Name: "Velox", Position: 9, Managed: true, Permissions: discordgo.PermissionAdministrator},
},
members: map[string]*discordgo.Member{
"1": {User: &discordgo.User{ID: "1", Username: "owner"}},
"999": {User: &discordgo.User{ID: "999", Username: "velox", Bot: true}, Roles: []string{"299"}},
},
users: map[string]*discordgo.User{},
channels: map[string]*discordgo.Channel{},
timeouts: map[string]*time.Time{},
files: map[string]string{},
}
}
func (f *fakeDiscord) addMember(id string, roles ...string) *discordgo.Member {
f.mu.Lock()
defer f.mu.Unlock()
m := &discordgo.Member{User: &discordgo.User{ID: id, Username: "user" + id, Avatar: "a"}, Roles: roles}
f.members[id] = m
return m
}
func (f *fakeDiscord) Guild(g string, _ ...discordgo.RequestOption) (*discordgo.Guild, error) {
return &discordgo.Guild{ID: g, OwnerID: f.ownerID, Name: f.guildName}, nil
}
func (f *fakeDiscord) UserChannelCreate(u string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.closedDMs[u] {
return nil, errors.New("Cannot send messages to this user")
}
return &discordgo.Channel{ID: "dm-" + u, Type: discordgo.ChannelTypeDM}, nil
}
// dmsTo lists who got a DM, in order.
func (f *fakeDiscord) dmsTo() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, ch := range f.sentTo {
if len(ch) > 3 && ch[:3] == "dm-" {
out = append(out, ch[3:])
}
}
return out
}
func (f *fakeDiscord) User(id string, _ ...discordgo.RequestOption) (*discordgo.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
if u, ok := f.users[id]; ok {
return u, nil
}
return nil, errors.New("unknown user")
}
func (f *fakeDiscord) GuildRoles(string, ...discordgo.RequestOption) ([]*discordgo.Role, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lookups++
return f.roles, nil
}
func (f *fakeDiscord) GuildChannels(g string, _ ...discordgo.RequestOption) ([]*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []*discordgo.Channel
for _, c := range f.channels {
if c.GuildID == g {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func (f *fakeDiscord) GuildMember(_, u string, _ ...discordgo.RequestOption) (*discordgo.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lookups++
if m, ok := f.members[u]; ok {
cp := *m
cp.Roles = append([]string(nil), m.Roles...)
return &cp, nil
}
return nil, errors.New("unknown member")
}
func (f *fakeDiscord) GuildMemberEdit(_, u string, data *discordgo.GuildMemberParams, _ ...discordgo.RequestOption) (*discordgo.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failEdits != nil {
return nil, f.failEdits
}
if data.Roles != nil && f.rejectRole != "" {
for _, r := range *data.Roles {
if r == f.rejectRole {
return nil, errors.New("403 Missing Permissions")
}
}
}
m, ok := f.members[u]
if !ok {
return nil, errors.New("unknown member")
}
if data.Roles != nil {
m.Roles = append([]string(nil), (*data.Roles)...)
}
return m, nil
}
func (f *fakeDiscord) GuildMemberTimeout(_, u string, until *time.Time, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.timeouts[u] = until
return nil
}
func (f *fakeDiscord) GuildMemberDeleteWithReason(_, u, _ string, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.kicked = append(f.kicked, u)
delete(f.members, u)
return nil
}
func (f *fakeDiscord) GuildBanCreateWithReason(_, u, _ string, _ int, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.banned = append(f.banned, u)
delete(f.members, u)
return nil
}
func (f *fakeDiscord) Channel(id string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
if c, ok := f.channels[id]; ok {
cp := *c
cp.PermissionOverwrites = nil
for _, o := range c.PermissionOverwrites {
oc := *o
cp.PermissionOverwrites = append(cp.PermissionOverwrites, &oc)
}
return &cp, nil
}
return nil, errors.New("unknown channel")
}
func (f *fakeDiscord) ChannelPermissionSet(ch, target string, t discordgo.PermissionOverwriteType, allow, deny int64, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
c := f.channels[ch]
for _, o := range c.PermissionOverwrites {
if o.ID == target {
o.Allow, o.Deny = allow, deny
return nil
}
}
c.PermissionOverwrites = append(c.PermissionOverwrites, &discordgo.PermissionOverwrite{ID: target, Type: t, Allow: allow, Deny: deny})
return nil
}
func (f *fakeDiscord) ChannelPermissionDelete(ch, target string, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
c := f.channels[ch]
var keep []*discordgo.PermissionOverwrite
for _, o := range c.PermissionOverwrites {
if o.ID != target {
keep = append(keep, o)
}
}
c.PermissionOverwrites = keep
return nil
}
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sentTo = append(f.sentTo, ch)
for _, file := range data.Files {
raw, _ := io.ReadAll(file.Reader)
f.files[file.Name] = string(raw)
}
f.sent = append(f.sent, data)
return &discordgo.Message{ID: "m"}, nil
}
func (f *fakeDiscord) RequestWithBucketID(method, url string, data interface{}, _ string, _ ...discordgo.RequestOption) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, fmt.Sprintf("%s %s %v", method, url, data))
return nil, nil
}
// sentCount counts channel alerts (not DMs).
func (f *fakeDiscord) sentCount() int {
return len(f.channelSends())
}
// channelSends lists messages sent to channels, leaving out DMs.
func (f *fakeDiscord) channelSends() []*discordgo.MessageSend {
f.mu.Lock()
defer f.mu.Unlock()
var out []*discordgo.MessageSend
for i, m := range f.sent {
if i >= len(f.sentTo) || len(f.sentTo[i]) < 3 || f.sentTo[i][:3] != "dm-" {
out = append(out, m)
}
}
return out
}
func (f *fakeDiscord) newID() string {
f.nextID++
return fmt.Sprint(8000 + f.nextID)
}
func (f *fakeDiscord) ChannelMessageDelete(ch, msg string, _ ...discordgo.RequestOption) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deletedMsgs = append(f.deletedMsgs, msg)
return nil
}
func (f *fakeDiscord) GuildRoleCreate(_ string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) {
f.mu.Lock()
defer f.mu.Unlock()
r := &discordgo.Role{ID: f.newID(), Name: p.Name}
if p.Permissions != nil {
r.Permissions = *p.Permissions
}
f.roles = append(f.roles, r)
return r, nil
}
func (f *fakeDiscord) GuildRoleEdit(_, roleID string, p *discordgo.RoleParams, _ ...discordgo.RequestOption) (*discordgo.Role, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, r := range f.roles {
if r.ID == roleID {
if p.Permissions != nil {
r.Permissions = *p.Permissions
}
return r, nil
}
}
return nil, errors.New("unknown role")
}
func (f *fakeDiscord) GuildChannelCreateComplex(g string, d discordgo.GuildChannelCreateData, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.mu.Lock()
defer f.mu.Unlock()
c := &discordgo.Channel{ID: f.newID(), GuildID: g, Name: d.Name, Type: d.Type, ParentID: d.ParentID, PermissionOverwrites: d.PermissionOverwrites}
f.channels[c.ID] = c
return c, nil
}
+328
View File
@@ -0,0 +1,328 @@
package security
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// ---- Raid persistence ----
func TestRaidSurvivesARestart(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidQuietMinutes = true, 3, securityrepo.ActionKick, 5
refresh(e)
for i := 0; i < 4; i++ {
e.OnMemberJoin(ctx, guild, d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), i)))
}
if r, ok := st.raids[100]; !ok || !r.InvitesPaused || r.CaughtTotal != 4 || len(st.caught[100]) != 4 {
t.Fatalf("raid not persisted: %+v caught=%d", st.raids[100], len(st.caught[100]))
}
// The bot restarts: a fresh engine on the same database.
e2 := New(d, st, "999")
e2.now = e.now
e2.ResumeRaids(ctx)
if !e2.RaidActive(guild) {
t.Fatal("raid mode must resume after a restart")
}
// A join after the restart is still treated as a raider.
*now = now.Add(time.Minute)
m := d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), 50))
e2.OnMemberJoin(ctx, guild, m)
if !contains(d.kicked, m.User.ID) {
t.Error("raid actions continue after a restart")
}
// Quiet period: it ends, invites resume, the list covers both lifetimes.
*now = now.Add(6 * time.Minute)
e2.checkRaids(ctx)
if e2.RaidActive(guild) {
t.Fatal("the resumed raid should end after the quiet period")
}
if !strings.Contains(d.requests[len(d.requests)-1], "invites_disabled_until:<nil>") {
t.Error("invites paused before the restart must be resumed")
}
var list string
for name, content := range d.files {
if strings.HasPrefix(name, "raid-") {
list = content
}
}
if strings.Count(list, "\tkick\t") != 5 {
t.Errorf("summary should list all 5 raiders:\n%s", list)
}
if _, ok := st.raids[100]; ok {
t.Error("the raid record should be cleared")
}
}
// ---- Heat ----
func heatEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore, *time.Time) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.HeatEnabled, s.HeatPanicMembers = true, 0
refresh(e)
return e, d, st, now
}
var msgN int
func say(e *Engine, userID, content string, edit ...func(m *discordgo.MessageCreate)) *discordgo.MessageCreate {
msgN++
m := &discordgo.MessageCreate{Message: &discordgo.Message{
ID: "msg" + string(rune('A'+msgN%26)) + strings.Repeat("x", msgN/26), GuildID: guild, ChannelID: "300", Content: content,
Author: &discordgo.User{ID: userID, Username: "u" + userID},
Member: &discordgo.Member{Roles: []string{}, JoinedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)},
}}
for _, f := range edit {
f(m)
}
e.OnMessage(ctx, m)
return m
}
func TestNormalChatNeverHeatsUp(t *testing.T) {
e, d, _, now := heatEngine(t)
for i := 0; i < 60; i++ {
say(e, "50", "just talking about the game tonight, number "+string(rune('a'+i%26)))
*now = now.Add(2 * time.Second)
}
if len(d.deletedMsgs) != 0 || len(d.timeouts) != 0 {
t.Error("a message every two seconds is normal conversation")
}
}
func TestSpamIsPunishedAndEscalates(t *testing.T) {
e, d, st, now := heatEngine(t)
var last *discordgo.MessageCreate
for i := 0; i < 6 && d.timeouts["50"] == nil; i++ {
last = say(e, "50", "BUY CHEAP STUFF NOW")
*now = now.Add(200 * time.Millisecond)
}
first := d.timeouts["50"]
if first == nil || first.Sub(*now) > 11*time.Minute || first.Sub(*now) < 9*time.Minute {
t.Fatalf("repeated spam should earn the 10 minute timeout, got %v", first)
}
if !contains(d.deletedMsgs, last.ID) {
t.Error("the message that maxed the heat should be deleted")
}
if !contains(st.eventKinds(), securityrepo.EventHeat) {
t.Error("recorded")
}
// A second offence the same day doubles it.
*now = now.Add(time.Hour)
d.timeouts["50"] = nil
for i := 0; i < 6 && d.timeouts["50"] == nil; i++ {
say(e, "50", "BUY CHEAP STUFF NOW")
*now = now.Add(200 * time.Millisecond)
}
if second := d.timeouts["50"]; second == nil || second.Sub(*now) < 19*time.Minute {
t.Errorf("second offence should be 20 minutes, got %v", second)
}
}
func TestInviteLinksAndMassMentions(t *testing.T) {
e, d, _, _ := heatEngine(t)
say(e, "51", "join my server discord.gg/abcdef")
if d.timeouts["51"] == nil {
t.Error("an invite link is an instant max")
}
say(e, "52", "hey @everyone check this")
say(e, "52", "@everyone again")
if d.timeouts["52"] == nil {
t.Error("repeated @everyone should max out fast")
}
}
func TestHeatExemptions(t *testing.T) {
e, d, st, _ := heatEngine(t)
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
refresh(e)
e.ChannelPerms = func(userID, _ string) (int64, error) {
if userID == "61" {
return discordgo.PermissionManageMessages, nil
}
return 0, nil
}
for _, who := range []string{"1", "60", "61"} {
say(e, who, "discord.gg/x")
}
say(e, "62", "discord.gg/x", func(m *discordgo.MessageCreate) { m.Author.Bot = true })
say(e, "63", "discord.gg/x", func(m *discordgo.MessageCreate) { m.WebhookID = "w" })
if len(d.timeouts) != 0 || len(d.deletedMsgs) != 0 {
t.Errorf("owner, trusted, moderators, bots and webhooks are exempt: %v", d.timeouts)
}
}
func TestHeatPanicTimesOutNewMembers(t *testing.T) {
e, d, st, now := heatEngine(t)
st.set(100).HeatPanicMembers = 3
refresh(e)
for _, u := range []string{"70", "71", "72"} {
say(e, u, "discord.gg/spam")
}
if !contains(st.eventKinds(), securityrepo.EventHeatPanic) {
t.Fatal("three members maxing out within a minute starts heat panic")
}
newcomer := func(m *discordgo.MessageCreate) { m.Member.JoinedAt = now.Add(-time.Hour) }
say(e, "80", "hello everyone", newcomer)
if d.timeouts["80"] == nil {
t.Error("someone who joined an hour ago speaking during panic is timed out")
}
say(e, "81", "hello everyone")
if d.timeouts["81"] != nil {
t.Error("established members are unaffected by panic")
}
*now = now.Add(11 * time.Minute)
say(e, "82", "hello", newcomer)
if d.timeouts["82"] != nil {
t.Error("panic ends after 10 minutes")
}
}
func TestMentionFloodLocksTheServer(t *testing.T) {
e, d, st, _ := heatEngine(t)
s := st.set(100)
s.HeatLockdownMentions, s.HeatStrictness = 20, "low"
refresh(e)
d.channels["300"] = &discordgo.Channel{ID: "300", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
five := func(m *discordgo.MessageCreate) {
m.Mentions = []*discordgo.User{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}, {ID: "e"}}
}
for i, u := range []string{"90", "91", "92", "93"} {
_ = i
say(e, u, "hi", five)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st.mu.Lock()
n := len(st.lockdowns)
st.mu.Unlock()
if n > 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Error("20 mentions within 30 seconds should lock the server")
}
func TestHeatAlertsAreCapped(t *testing.T) {
e, d, _, _ := heatEngine(t)
for i := 0; i < 20; i++ {
say(e, string(rune('A'+i)), "discord.gg/x")
}
if n := d.sentCount(); n > heatAlertsPerMin {
t.Errorf("a spam wave produced %d alerts, cap is %d", n, heatAlertsPerMin)
}
}
// ---- Snapshots and restore ----
func TestSnapshotAndRestore(t *testing.T) {
e, d, st, _ := testEngine(t)
d.channels["400"] = &discordgo.Channel{ID: "400", GuildID: guild, Type: discordgo.ChannelTypeGuildCategory, Name: "Staff"}
d.channels["401"] = &discordgo.Channel{ID: "401", GuildID: guild, Type: discordgo.ChannelTypeGuildText, Name: "mod-chat", ParentID: "400",
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{ID: guild, Type: discordgo.PermissionOverwriteTypeRole, Deny: discordgo.PermissionViewChannel},
{ID: "201", Type: discordgo.PermissionOverwriteTypeRole, Allow: discordgo.PermissionViewChannel},
}}
d.channels["402"] = &discordgo.Channel{ID: "402", GuildID: guild, Type: discordgo.ChannelTypeGuildPublicThread, Name: "a thread"}
if _, err := e.Restore(ctx, guild, "1"); !errors.As(err, new(UserError)) {
t.Fatalf("no snapshot yet should be a clear message: %v", err)
}
roles, channels, err := e.TakeSnapshot(ctx, guild, "1")
if err != nil || roles != 5 || channels != 2 {
t.Fatalf("snapshot = %d roles, %d channels, %v (managed roles and threads are skipped)", roles, channels, err)
}
var data snapshotData
_ = json.Unmarshal(st.snapshots[0].Data, &data)
for _, r := range data.Roles {
if r.ID == "204" || r.ID == "299" {
t.Error("integration roles must not be in the snapshot")
}
}
// The nuke: the Mod role and the whole Staff category are deleted, and
// the Member role is given Administrator.
var keep []*discordgo.Role
for _, r := range d.roles {
if r.ID != "201" {
keep = append(keep, r)
}
if r.ID == "202" {
r.Permissions |= discordgo.PermissionAdministrator
}
}
d.roles = keep
delete(d.channels, "400")
delete(d.channels, "401")
res, err := e.Restore(ctx, guild, "1")
if err != nil {
t.Fatal(err)
}
if res.RolesCreated != 1 || res.RolesReverted != 1 || res.ChannelsCreated != 2 || res.Failed != 0 {
t.Fatalf("restore result = %+v", res)
}
var newMod, newCat, newText *string
for _, r := range d.roles {
if r.Name == "Mod" {
newMod = &r.ID
}
if r.ID == "202" && r.Permissions&discordgo.PermissionAdministrator != 0 {
t.Error("Administrator added to Member should be reverted")
}
}
for _, c := range d.channels {
switch c.Name {
case "Staff":
newCat = &c.ID
case "mod-chat":
newText = &c.ID
}
}
if newMod == nil || newCat == nil || newText == nil {
t.Fatal("role or channels not recreated")
}
text := d.channels[*newText]
if text.ParentID != *newCat {
t.Error("the channel should go back into the recreated category")
}
foundMod := false
for _, o := range text.PermissionOverwrites {
if o.ID == *newMod && o.Allow == discordgo.PermissionViewChannel {
foundMod = true
}
if o.ID == "201" {
t.Error("overwrites must point at the recreated role, not the deleted one")
}
}
if !foundMod {
t.Errorf("the Mod overwrite should be restored: %+v", text.PermissionOverwrites)
}
// Restoring again finds nothing missing.
res, _ = e.Restore(ctx, guild, "1")
if res.RolesCreated+res.ChannelsCreated+res.RolesReverted != 0 {
t.Errorf("a second restore should change nothing: %+v", res)
}
}
func TestOnlyOneRestoreAtATime(t *testing.T) {
e, _, _, _ := testEngine(t)
e.restoring[guild] = true
if _, err := e.Restore(ctx, guild, "1"); err == nil || !strings.Contains(err.Error(), "already running") {
t.Errorf("err = %v", err)
}
}
+368
View File
@@ -0,0 +1,368 @@
package security
import (
"context"
"fmt"
"log"
"math"
"regexp"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Heat is a per-member spam score, like Wick's. Every message adds heat,
// more for what spam looks like; heat cools down over time, so normal
// conversation never builds up. At maxHeat the message is deleted and the
// configured action applied.
const (
maxHeat = 100.0
heatDecayPerSec = 8.0 // a message every couple of seconds never accumulates
heatBase = 8.0
heatRepeat = 25.0
heatMention = 10.0
heatEveryone = 60.0
heatEmoji = 2.0
heatLine = 2.0 // per line beyond the fourth
heatPer100Chars = 1.0
heatAttachment = 10.0
heatLink = 8.0
heatBlockedWord = 60.0
maxHeatUsers = 50000 // per guild, swept when exceeded
panicWindow = time.Minute
panicDuration = 10 * time.Minute
panicTimeout = time.Hour
panicNewMember = 24 * time.Hour
mentionWindow = 30 * time.Second
floodCooldown = 10 * time.Minute
heatAlertsPerMin = 5
maxTimeout = 28*24*time.Hour - time.Hour
)
var (
linkRe = regexp.MustCompile(`https?://\S+`)
customEmojiRe = regexp.MustCompile(`<a?:\w+:\d+>`)
)
var strictness = map[string]float64{"low": 0.6, "medium": 1.0, "high": 1.5}
type memberHeat struct {
heat float64
last time.Time
recent []string // last few messages, normalized, for repeats
offences int // within the last day
lastOffence time.Time
}
type guildHeat struct {
mu sync.Mutex
members map[string]*memberHeat
maxedAt map[string]time.Time // who hit max heat recently, for panic
panicUntil time.Time
mentions []time.Time // mentions by non-exempt members, for flood lockdown
lastFlood time.Time
alertTimes []time.Time
alertsMuted int
}
func (e *Engine) heatFor(guildID string) *guildHeat {
e.mu.Lock()
defer e.mu.Unlock()
g, ok := e.heat[guildID]
if !ok {
g = &guildHeat{members: map[string]*memberHeat{}, maxedAt: map[string]time.Time{}}
e.heat[guildID] = g
}
return g
}
func normalize(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
func countEmoji(s string) int {
n := len(customEmojiRe.FindAllString(s, -1))
for _, r := range s {
if r >= 0x1F000 || (r >= 0x2600 && r <= 0x27BF) {
n++
}
}
return n
}
// MessageHeat scores one message. recent is the author's last few messages
// (normalized) for spotting repeats. The second result is true when the
// message is an instant maximum (an invite link).
func MessageHeat(s *securityrepo.Settings, m *discordgo.Message, recent []string) (float64, bool, string) {
content := m.Content
lower := strings.ToLower(content)
if inviteRe.MatchString(lower) {
return maxHeat, true, "invite link"
}
reason := "spam"
h := heatBase
norm := normalize(content)
if norm != "" {
for _, r := range recent {
if r == norm {
h += heatRepeat
reason = "repeated messages"
break
}
}
}
mentions := len(m.Mentions) + len(m.MentionRoles)
h += float64(mentions) * heatMention
if m.MentionEveryone || strings.Contains(lower, "@everyone") || strings.Contains(lower, "@here") {
h += heatEveryone
reason = "mass mentions"
} else if mentions >= 3 {
reason = "mention spam"
}
h += float64(countEmoji(content)) * heatEmoji
if lines := strings.Count(content, "\n") + 1; lines > 4 {
h += float64(lines-4) * heatLine
}
h += float64(len([]rune(content))/100) * heatPer100Chars
h += float64(len(m.Attachments)+len(m.StickerItems)) * heatAttachment
h += float64(len(linkRe.FindAllString(content, -1))) * heatLink
for _, w := range s.HeatBlockedWords {
if w = strings.ToLower(strings.TrimSpace(w)); w != "" && strings.Contains(lower, w) {
h += heatBlockedWord
reason = "blocked word"
break
}
}
mult := strictness[s.HeatStrictness]
if mult == 0 {
mult = 1
}
return h * mult, false, reason
}
// heatExempt: bots, webhooks, the owner, trusted members and anyone who can
// manage messages in the channel.
func (e *Engine) heatExempt(ctx context.Context, m *discordgo.MessageCreate) bool {
if m.Author == nil || m.Author.Bot || m.WebhookID != "" {
return true
}
var roles []string
if m.Member != nil {
roles = m.Member.Roles
}
if e.IsTrusted(ctx, m.GuildID, m.Author.ID, nonNil(roles)) {
return true
}
if e.ChannelPerms != nil {
if p, err := e.ChannelPerms(m.Author.ID, m.ChannelID); err == nil &&
p&(discordgo.PermissionAdministrator|discordgo.PermissionManageMessages) != 0 {
return true
}
}
return false
}
// OnMessage runs the heat system on a guild message.
func (e *Engine) OnMessage(ctx context.Context, m *discordgo.MessageCreate) {
if m == nil || m.Message == nil || m.GuildID == "" {
return
}
s, _, err := e.load(ctx, m.GuildID)
if err != nil || !s.HeatEnabled {
return
}
if e.heatExempt(ctx, m) {
return
}
now := e.now()
g := e.heatFor(m.GuildID)
// Mention flood: lock the server once, then cool down.
if s.HeatLockdownMentions > 0 {
n := len(m.Mentions) + len(m.MentionRoles)
if m.MentionEveryone {
n += 10
}
if n > 0 && e.mentionFlood(g, now, n, s.HeatLockdownMentions) {
go e.floodLockdown(context.Background(), m.GuildID, s)
}
}
g.mu.Lock()
if now.Before(g.panicUntil) && m.Member != nil && !m.Member.JoinedAt.IsZero() && now.Sub(m.Member.JoinedAt) < panicNewMember {
g.mu.Unlock()
e.heatPunish(ctx, m, s, "heat panic: new member speaking during a spam wave", panicTimeout, securityrepo.ActionTimeout)
return
}
mh, ok := g.members[m.Author.ID]
if !ok {
if len(g.members) >= maxHeatUsers {
g.sweep(now)
}
mh = &memberHeat{}
g.members[m.Author.ID] = mh
}
if !mh.last.IsZero() {
mh.heat = math.Max(0, mh.heat-now.Sub(mh.last).Seconds()*heatDecayPerSec)
}
mh.last = now
add, instant, reason := MessageHeat(s, m.Message, mh.recent)
mh.heat += add
if norm := normalize(m.Content); norm != "" {
mh.recent = append(mh.recent, norm)
if len(mh.recent) > 3 {
mh.recent = mh.recent[len(mh.recent)-3:]
}
}
if mh.heat < maxHeat && !instant {
g.mu.Unlock()
return
}
// Over the top: reset, count the offence, and work out the timeout.
mh.heat = 0
if now.Sub(mh.lastOffence) > 24*time.Hour {
mh.offences = 0
}
mh.offences++
mh.lastOffence = now
timeout := time.Duration(s.HeatTimeoutMinutes) * time.Minute
if s.HeatEscalate && mh.offences > 1 {
timeout *= time.Duration(1 << min(mh.offences-1, 12))
}
if timeout > maxTimeout {
timeout = maxTimeout
}
// Heat panic: enough different members maxing out within a minute.
g.maxedAt[m.Author.ID] = now
for u, t := range g.maxedAt {
if now.Sub(t) > panicWindow {
delete(g.maxedAt, u)
}
}
startPanic := s.HeatPanicMembers > 0 && len(g.maxedAt) >= s.HeatPanicMembers && !now.Before(g.panicUntil)
if startPanic {
g.panicUntil = now.Add(panicDuration)
}
g.mu.Unlock()
e.heatPunish(ctx, m, s, reason, timeout, s.HeatAction)
if startPanic {
e.event(ctx, m.GuildID, securityrepo.EventHeatPanic, e.botID, "", fmt.Sprintf("%d members hit max heat within a minute", s.HeatPanicMembers))
e.send(ctx, m.GuildID, s, alert{ping: true, critical: true, embed: &discordgo.MessageEmbed{
Title: "🔥 Spam wave: heat panic on",
Description: fmt.Sprintf("%d members hit max heat within a minute. For the next 10 minutes, anyone who joined in the last day is timed out as soon as they speak.", s.HeatPanicMembers),
Color: colorAlert,
}})
}
}
func (g *guildHeat) sweep(now time.Time) {
for u, mh := range g.members {
cooled := mh.heat-now.Sub(mh.last).Seconds()*heatDecayPerSec <= 0
if cooled && now.Sub(mh.lastOffence) > 24*time.Hour {
delete(g.members, u)
}
}
}
// mentionFlood records n mentions and reports whether the guild just went
// over the limit (once per cooldown).
func (e *Engine) mentionFlood(g *guildHeat, now time.Time, n, limit int) bool {
g.mu.Lock()
defer g.mu.Unlock()
for i := 0; i < n && len(g.mentions) < 5000; i++ {
g.mentions = append(g.mentions, now)
}
cut := 0
for cut < len(g.mentions) && now.Sub(g.mentions[cut]) > mentionWindow {
cut++
}
g.mentions = g.mentions[cut:]
if len(g.mentions) >= limit && now.Sub(g.lastFlood) > floodCooldown {
g.lastFlood = now
g.mentions = nil
return true
}
return false
}
func (e *Engine) floodLockdown(ctx context.Context, guildID string, s *securityrepo.Settings) {
locked, failed, err := e.LockServer(ctx, guildID, e.botID)
desc := fmt.Sprintf("%d or more mentions within 30 seconds: every channel is locked (%d locked", s.HeatLockdownMentions, locked)
if failed > 0 {
desc += fmt.Sprintf(", %d failed", failed)
}
desc += "). Unlock with `/unlock server` once it's under control."
if err != nil {
desc = "A mention flood was detected but locking the server failed: " + clip(err.Error(), 200)
}
e.send(ctx, guildID, s, alert{ping: true, critical: true, embed: &discordgo.MessageEmbed{Title: "🔒 Mention flood: server locked", Description: desc, Color: colorAlert}})
}
// heatPunish deletes the message and applies the action. Alerts are capped
// per minute so a spam wave can't flood the alert channel.
func (e *Engine) heatPunish(ctx context.Context, m *discordgo.MessageCreate, s *securityrepo.Settings, reason string, timeout time.Duration, action string) {
if err := e.d.ChannelMessageDelete(m.ChannelID, m.ID, discordgo.WithAuditLogReason("Velox heat: "+reason)); err != nil {
log.Printf("security: deleting a hot message in guild %s: %v", m.GuildID, err)
}
var err error
if action == securityrepo.ActionTimeout {
if m.Author.ID == e.botID || e.IsOwner(m.GuildID, m.Author.ID) {
return
}
until := e.now().Add(timeout)
err = e.d.GuildMemberTimeout(m.GuildID, m.Author.ID, &until, discordgo.WithAuditLogReason(clip("Velox heat: "+reason, 400)))
} else {
err = e.punish(m.GuildID, m.Author, action, "heat: "+reason)
}
if err != nil {
log.Printf("security: heat action on %s in guild %s: %v", m.Author.ID, m.GuildID, err)
return
}
what := action
if action == securityrepo.ActionTimeout {
what = "timed out for " + humanize(timeout)
}
e.event(ctx, m.GuildID, securityrepo.EventHeat, e.botID, m.Author.ID, what+": "+reason)
g := e.heatFor(m.GuildID)
now := e.now()
g.mu.Lock()
cut := 0
for cut < len(g.alertTimes) && now.Sub(g.alertTimes[cut]) > time.Minute {
cut++
}
g.alertTimes = g.alertTimes[cut:]
allowed := len(g.alertTimes) < heatAlertsPerMin
muted := 0
if allowed {
g.alertTimes = append(g.alertTimes, now)
muted, g.alertsMuted = g.alertsMuted, 0
} else {
g.alertsMuted++
}
g.mu.Unlock()
if !allowed {
return
}
desc := fmt.Sprintf("<@%s> (%s) was %s: %s. Their message was deleted.", m.Author.ID, safeName(m.Author), what, reason)
if muted > 0 {
desc += fmt.Sprintf("\n(%d more since the last alert weren't posted, to keep this channel readable.)", muted)
}
e.send(ctx, m.GuildID, s, alert{embed: &discordgo.MessageEmbed{Title: "Heat", Description: desc, Color: colorWarn}})
}
func humanize(d time.Duration) string {
switch {
case d >= 24*time.Hour:
return fmt.Sprintf("%.0f days", d.Hours()/24)
case d >= time.Hour:
return fmt.Sprintf("%.0f hours", d.Hours())
default:
return fmt.Sprintf("%.0f minutes", math.Max(1, d.Minutes()))
}
}
+176
View File
@@ -0,0 +1,176 @@
package security
import (
"context"
"errors"
"fmt"
"log"
"regexp"
"strings"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Invite links in a name: discord.gg/x, discord.com/invite/x, dsc.gg/x and
// the common lookalikes. Matched case-insensitively against the lowercased
// name; the pattern is fixed, never built from user input.
var inviteRe = regexp.MustCompile(`(discord(app)?\.(gg|com/invite|io|me|li)|dsc\.gg|\.gg/)`)
var severity = map[string]int{
securityrepo.ActionOff: 0, securityrepo.ActionAlert: 1, securityrepo.ActionTimeout: 2,
securityrepo.ActionKick: 3, securityrepo.ActionBan: 4,
}
// GateResult is what the Join Gate decided about one member.
type GateResult struct {
Action string // off, timeout, kick, ban
Reason string
}
// CheckJoin runs the Join Gate filters on a new member. When several match,
// the strictest action wins. Bots are only checked for being unverified;
// bots added by untrusted members are handled from the audit log.
func CheckJoin(s *securityrepo.Settings, u *discordgo.User, now time.Time) GateResult {
var res GateResult
consider := func(action, reason string) {
if severity[action] > severity[res.Action] {
res = GateResult{Action: action, Reason: reason}
}
}
res.Action = securityrepo.ActionOff
if !s.JoinGateEnabled || u == nil {
return res
}
if u.Bot {
if u.PublicFlags&discordgo.UserFlagVerifiedBot == 0 {
consider(s.UnverifiedBot, "unverified bot")
}
return res
}
names := strings.ToLower(u.Username + " " + u.GlobalName)
if inviteRe.MatchString(names) {
consider(s.AdvertisingName, "invite link in name")
}
if s.UsernameFilter != securityrepo.ActionOff {
for _, p := range s.UsernamePatterns {
p = strings.ToLower(strings.TrimSpace(p))
if p != "" && strings.Contains(names, p) {
consider(s.UsernameFilter, "blocked word in name")
break
}
}
}
if created, err := discordgo.SnowflakeTimestamp(u.ID); err == nil {
if now.Sub(created) < time.Duration(s.MinAccountAgeDays)*24*time.Hour {
consider(s.NewAccount, fmt.Sprintf("account younger than %d days", s.MinAccountAgeDays))
}
}
if u.Avatar == "" {
consider(s.NoAvatar, "no avatar")
}
return res
}
// errProtected is returned when asked to punish the owner or the bot.
var errProtected = errors.New("the owner and the bot are never punished")
// punish applies a member action. It never touches the owner or the bot.
func (e *Engine) punish(guildID string, u *discordgo.User, action, reason string) error {
if u == nil || u.ID == e.botID || e.IsOwner(guildID, u.ID) {
return errProtected
}
reason = clip("Velox security: "+reason, 400)
switch action {
case securityrepo.ActionTimeout:
until := e.now().Add(timeoutJoin)
return e.d.GuildMemberTimeout(guildID, u.ID, &until, discordgo.WithAuditLogReason(reason))
case securityrepo.ActionKick:
return e.d.GuildMemberDeleteWithReason(guildID, u.ID, reason)
case securityrepo.ActionBan:
return e.d.GuildBanCreateWithReason(guildID, u.ID, reason, 1)
}
return errors.New("no action " + action)
}
// OnMemberJoin runs every check for a new member: the join lock, the Join
// Gate and raid detection. It reports whether the member was removed, so
// welcome messages and default roles can be skipped.
func (e *Engine) OnMemberJoin(ctx context.Context, guildID string, m *discordgo.Member) (removed bool) {
if m == nil || m.User == nil {
return false
}
s, _, err := e.load(ctx, guildID)
if err != nil {
log.Printf("security: settings for guild %s: %v", guildID, err)
return false
}
// 1. Join lockdown: everyone new is kicked or banned.
if s.JoinLock == securityrepo.ActionKick || s.JoinLock == securityrepo.ActionBan {
if err := e.punish(guildID, m.User, s.JoinLock, "joins are locked"); err != nil {
if !errors.Is(err, errProtected) {
log.Printf("security: join lock in guild %s: %v", guildID, err)
}
return false
}
e.raidJoin(ctx, guildID, s, m, true)
return true
}
// 2. Join Gate. A bot's verified flag isn't always in the join event, so
// it's read from Discord; if that fails the bot is left alone rather than
// risk kicking a verified one.
u := m.User
if u.Bot && s.JoinGateEnabled && s.UnverifiedBot != securityrepo.ActionOff {
fresh, err := e.d.User(u.ID)
if err != nil {
log.Printf("security: looking up bot %s: %v", u.ID, err)
a := e.raidJoin(ctx, guildID, s, m, false)
return a == securityrepo.ActionKick || a == securityrepo.ActionBan
}
u = fresh
}
gate := CheckJoin(s, u, e.now())
if gate.Action != securityrepo.ActionOff {
if err := e.punish(guildID, m.User, gate.Action, gate.Reason); err != nil {
if !errors.Is(err, errProtected) {
log.Printf("security: join gate in guild %s: %v", guildID, err)
}
} else {
removed = gate.Action == securityrepo.ActionKick || gate.Action == securityrepo.ActionBan
// During a raid the summary lists everyone; one alert per
// raider would flood the channel and Discord's rate limit.
if !e.RaidActive(guildID) {
e.event(ctx, guildID, securityrepo.EventJoinGate, e.botID, m.User.ID, gate.Action+": "+gate.Reason)
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
Title: "Join Gate",
Description: fmt.Sprintf("%s <@%s> (%s): %s.", actionPast(gate.Action), m.User.ID, safeName(m.User), gate.Reason),
Color: colorWarn,
}})
}
}
}
// 3. Raid detection, which counts every join, gated or not.
if a := e.raidJoin(ctx, guildID, s, m, removed); a == securityrepo.ActionKick || a == securityrepo.ActionBan {
removed = true
}
return removed
}
func actionPast(a string) string {
switch a {
case securityrepo.ActionTimeout:
return "Timed out"
case securityrepo.ActionKick:
return "Kicked"
case securityrepo.ActionBan:
return "Banned"
}
return "Flagged"
}
+90
View File
@@ -0,0 +1,90 @@
package security
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"velox-bot/internal/db/repos/securityrepo"
"github.com/jackc/pgx/v5"
)
// StartCommandListener carries out the dashboard's requests
// (security_commands rows announced with NOTIFY). The dashboard API checks
// that the requester is the owner or trusted before inserting; the payload
// is still validated here.
func (e *Engine) StartCommandListener(ctx context.Context, connString string) error {
conn, err := pgx.Connect(ctx, connString)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, "LISTEN security_commands"); err != nil {
return fmt.Errorf("listen: %w", err)
}
e.ProcessCommands(ctx)
for {
if _, err := conn.WaitForNotification(ctx); err != nil {
return fmt.Errorf("waiting for notifications: %w", err)
}
e.ProcessCommands(ctx)
}
}
// ProcessCommands runs every pending command once and marks it done, even
// when it fails (the error is logged; retrying would only fail again).
func (e *Engine) ProcessCommands(ctx context.Context) {
cmds, err := e.st.ListUnprocessedCommands(ctx)
if err != nil {
log.Printf("security: listing dashboard commands: %v", err)
return
}
for _, c := range cmds {
if err := e.runCommand(ctx, c); err != nil {
log.Printf("security: dashboard command %d (%s) for guild %d: %v", c.ID, c.CommandType, c.GuildID, err)
}
if err := e.st.MarkCommandProcessed(ctx, c.ID); err != nil {
log.Printf("security: marking command %d done: %v", c.ID, err)
}
}
}
func (e *Engine) runCommand(ctx context.Context, c securityrepo.Command) error {
guildID, by := id(c.GuildID), id(c.RequestedBy)
switch c.CommandType {
case "end_raid":
return e.EndRaid(ctx, guildID, by)
case "unlock_server":
_, _, err := e.UnlockServer(ctx, guildID, by)
return err
case "unlock_joins":
return e.SetJoinLock(ctx, guildID, securityrepo.ActionOff, by)
case "snapshot":
_, _, err := e.TakeSnapshot(ctx, guildID, by)
return err
case "restore":
// Recreating many channels takes minutes under Discord's rate
// limits; don't hold up the other commands meanwhile.
go func() {
if _, err := e.Restore(context.Background(), guildID, by); err != nil {
log.Printf("security: restore for guild %s from the dashboard: %v", guildID, err)
}
}()
return nil
case "release_quarantine":
var p struct {
UserID string `json:"user_id"`
}
if err := json.Unmarshal(c.Payload, &p); err != nil {
return fmt.Errorf("bad payload: %w", err)
}
if _, err := strconv.ParseUint(p.UserID, 10, 64); err != nil {
return fmt.Errorf("bad user id %q", p.UserID)
}
return e.Release(ctx, guildID, p.UserID, by)
}
return fmt.Errorf("unknown command %q", c.CommandType)
}
+166
View File
@@ -0,0 +1,166 @@
package security
import (
"context"
"fmt"
"log"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// LockBits are what a lockdown denies @everyone: talking, threads, reactions.
const LockBits = discordgo.PermissionSendMessages | discordgo.PermissionSendMessagesInThreads |
discordgo.PermissionCreatePublicThreads | discordgo.PermissionCreatePrivateThreads | discordgo.PermissionAddReactions
// lockable reports whether a channel type has a chat to lock.
func lockable(t discordgo.ChannelType) bool {
switch t {
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice, discordgo.ChannelTypeGuildMedia:
return true
}
return false
}
func everyoneOverwrite(ch *discordgo.Channel, guildID string) (allow, deny int64, found bool) {
for _, o := range ch.PermissionOverwrites {
if o.ID == guildID && o.Type == discordgo.PermissionOverwriteTypeRole {
return o.Allow, o.Deny, true
}
}
return 0, 0, false
}
// LockChannel stops @everyone from talking in a channel, remembering the
// overwrite it had so UnlockChannel can put it back.
func (e *Engine) LockChannel(ctx context.Context, guildID, channelID, by string, serverWide bool) error {
ch, err := e.d.Channel(channelID)
if err != nil {
return fmt.Errorf("looking up the channel: %w", err)
}
// Never act on a channel of another server, whatever ID was passed in.
if ch.GuildID != guildID {
return UserError("That channel isn't in this server.")
}
if !lockable(ch.Type) {
return UserError("That channel has no chat to lock.")
}
allow, deny, had := everyoneOverwrite(ch, guildID)
if err := e.st.SaveLockdown(ctx, securityrepo.Lockdown{
GuildID: parseID(guildID), ChannelID: parseID(channelID), HadOverwrite: had,
PreviousAllow: allow, PreviousDeny: deny, ServerWide: serverWide, LockedBy: parseID(by),
}); err != nil {
return err
}
if err := e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow&^LockBits, deny|LockBits,
discordgo.WithAuditLogReason("Velox lockdown")); err != nil {
_ = e.st.DeleteLockdown(ctx, parseID(guildID), parseID(channelID))
return fmt.Errorf("locking: %w", err)
}
if !serverWide {
e.event(ctx, guildID, securityrepo.EventLocked, by, channelID, "channel")
}
return nil
}
// UnlockChannel restores the lock bits of @everyone's overwrite to what they
// were before. Anything else changed in the overwrite during the lockdown is
// kept. Unlocking relaxes protection, so callers must check the actor first.
func (e *Engine) UnlockChannel(ctx context.Context, guildID, channelID, by string) error {
rec, err := e.st.GetLockdown(ctx, parseID(guildID), parseID(channelID))
if err != nil {
return err
}
if rec == nil {
return UserError("That channel isn't locked by Velox.")
}
ch, err := e.d.Channel(channelID)
if err != nil {
// The channel is gone: nothing to unlock.
return e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID)
}
allow, deny, _ := everyoneOverwrite(ch, guildID)
allow = allow&^LockBits | rec.PreviousAllow&LockBits
deny = deny&^LockBits | rec.PreviousDeny&LockBits
reason := discordgo.WithAuditLogReason("Velox unlock")
if allow == 0 && deny == 0 && !rec.HadOverwrite {
err = e.d.ChannelPermissionDelete(channelID, guildID, reason)
} else {
err = e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow, deny, reason)
}
if err != nil {
return fmt.Errorf("unlocking: %w", err)
}
if err := e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID); err != nil {
return err
}
if !rec.ServerWide {
e.event(ctx, guildID, securityrepo.EventUnlocked, by, channelID, "channel")
}
return nil
}
// LockServer locks every channel with a chat.
func (e *Engine) LockServer(ctx context.Context, guildID, by string) (locked, failed int, err error) {
chans, err := e.d.GuildChannels(guildID)
if err != nil {
return 0, 0, fmt.Errorf("listing channels: %w", err)
}
for _, ch := range chans {
if !lockable(ch.Type) {
continue
}
if err := e.LockChannel(ctx, guildID, ch.ID, by, true); err != nil {
log.Printf("security: locking channel %s in guild %s: %v", ch.ID, guildID, err)
failed++
continue
}
locked++
}
e.event(ctx, guildID, securityrepo.EventLocked, by, "", fmt.Sprintf("server: %d channels locked, %d failed", locked, failed))
return locked, failed, nil
}
// UnlockServer unlocks every channel the last server lockdown locked.
func (e *Engine) UnlockServer(ctx context.Context, guildID, by string) (unlocked, failed int, err error) {
recs, err := e.st.ListLockdowns(ctx, parseID(guildID), true)
if err != nil {
return 0, 0, err
}
if len(recs) == 0 {
return 0, 0, UserError("The server isn't locked by Velox.")
}
for _, r := range recs {
if err := e.UnlockChannel(ctx, guildID, id(r.ChannelID), by); err != nil {
log.Printf("security: unlocking channel %d in guild %s: %v", r.ChannelID, guildID, err)
failed++
continue
}
unlocked++
}
e.event(ctx, guildID, securityrepo.EventUnlocked, by, "", fmt.Sprintf("server: %d channels unlocked, %d failed", unlocked, failed))
return unlocked, failed, nil
}
// SetJoinLock turns the join lockdown on ("kick" or "ban") or off. Turning it
// off relaxes protection, so callers must check the actor first.
func (e *Engine) SetJoinLock(ctx context.Context, guildID, mode, by string) error {
switch mode {
case securityrepo.ActionOff, securityrepo.ActionKick, securityrepo.ActionBan:
default:
return UserError("Joins can be locked with kick or ban.")
}
if err := e.st.SetJoinLock(ctx, parseID(guildID), mode); err != nil {
return err
}
e.Invalidate(guildID)
kind := securityrepo.EventLocked
if mode == securityrepo.ActionOff {
kind = securityrepo.EventUnlocked
}
e.event(ctx, guildID, kind, by, "", "joins: "+mode)
return nil
}
+446
View File
@@ -0,0 +1,446 @@
package security
import (
"context"
"fmt"
"log"
"strconv"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// nukeCounter counts destructive actions per server, person and kind over a
// sliding window, and remembers whom it just punished so one nuke attempt
// is answered once.
type nukeCounter struct {
mu sync.Mutex
hits map[string][]time.Time
punished map[string]time.Time
}
const (
maxHitsPerKey = 200
punishCooldown = time.Minute
)
func newNukeCounter() *nukeCounter {
return &nukeCounter{hits: map[string][]time.Time{}, punished: map[string]time.Time{}}
}
// add records a hit and returns how many fall inside the window.
func (c *nukeCounter) add(key string, now time.Time, window time.Duration) int {
c.mu.Lock()
defer c.mu.Unlock()
h := append(c.hits[key], now)
cut := 0
for cut < len(h) && now.Sub(h[cut]) > window {
cut++
}
h = h[cut:]
if len(h) > maxHitsPerKey {
h = h[len(h)-maxHitsPerKey:]
}
c.hits[key] = h
// Opportunistic cleanup so the map can't grow forever.
if len(c.hits) > 5000 {
for k, v := range c.hits {
if len(v) == 0 || now.Sub(v[len(v)-1]) > window {
delete(c.hits, k)
}
}
}
return len(h)
}
// claim reports whether this person may be punished now (not punished in the
// last minute), and marks them.
func (c *nukeCounter) claim(key string, now time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
if t, ok := c.punished[key]; ok && now.Sub(t) < punishCooldown {
return false
}
c.punished[key] = now
if len(c.punished) > 5000 {
for k, t := range c.punished {
if now.Sub(t) > punishCooldown {
delete(c.punished, k)
}
}
}
return true
}
func changeKey(c *discordgo.AuditLogChange) string {
if c == nil || c.Key == nil {
return ""
}
return string(*c.Key)
}
func permValue(v any) int64 {
switch x := v.(type) {
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
case float64:
return int64(x)
}
return 0
}
// Classify maps an audit log entry to a limit key, or flags it as always
// forbidden for untrusted members (instant). rolePerms looks up a role's
// permissions for role grants.
func Classify(entry *discordgo.AuditLogEntry, rolePerms func(roleID string) int64) (key string, instant bool, what string) {
if entry == nil || entry.ActionType == nil {
return "", false, ""
}
switch *entry.ActionType {
case discordgo.AuditLogActionChannelCreate:
return securityrepo.LimitChannelCreate, false, "created channels"
case discordgo.AuditLogActionChannelDelete:
return securityrepo.LimitChannelDelete, false, "deleted channels"
case discordgo.AuditLogActionRoleDelete:
return securityrepo.LimitRoleDelete, false, "deleted roles"
case discordgo.AuditLogActionMemberBanAdd:
return securityrepo.LimitBan, false, "banned members"
case discordgo.AuditLogActionMemberKick:
return securityrepo.LimitKick, false, "kicked members"
case discordgo.AuditLogActionWebhookCreate:
return securityrepo.LimitWebhookCreate, false, "created webhooks"
case discordgo.AuditLogActionMemberPrune:
return "", true, "pruned members"
case discordgo.AuditLogActionRoleCreate:
for _, c := range entry.Changes {
if changeKey(c) == "permissions" && permValue(c.NewValue)&DangerousPerms != 0 {
return "", true, "created a role with dangerous permissions"
}
}
return securityrepo.LimitRoleCreate, false, "created roles"
case discordgo.AuditLogActionRoleUpdate:
for _, c := range entry.Changes {
if changeKey(c) == "permissions" {
added := permValue(c.NewValue) &^ permValue(c.OldValue)
if added&DangerousPerms != 0 {
return "", true, "gave a role dangerous permissions"
}
}
}
case discordgo.AuditLogActionMemberRoleUpdate:
for _, c := range entry.Changes {
if changeKey(c) != "$add" {
continue
}
list, _ := c.NewValue.([]any)
for _, item := range list {
if r, ok := item.(map[string]any); ok {
if rid, _ := r["id"].(string); rid != "" && rolePerms != nil && rolePerms(rid)&DangerousPerms != 0 {
return "", true, "gave someone a role with dangerous permissions"
}
}
}
}
case discordgo.AuditLogActionGuildUpdate:
for _, c := range entry.Changes {
if changeKey(c) == "vanity_url_code" {
return "", true, "changed the vanity URL"
}
}
}
return "", false, ""
}
// OnAuditLogEntry watches what members do: bots added by untrusted members
// (Join Gate) and destructive actions over the limits (anti-nuke).
func (e *Engine) OnAuditLogEntry(ctx context.Context, guildID string, entry *discordgo.AuditLogEntry) {
if entry == nil || entry.ActionType == nil || entry.UserID == "" {
return
}
s, _, err := e.load(ctx, guildID)
if err != nil {
log.Printf("security: settings for guild %s: %v", guildID, err)
return
}
if *entry.ActionType == discordgo.AuditLogActionBotAdd {
e.checkBotAdd(ctx, guildID, s, entry)
return
}
if !s.AntiNukeEnabled || !worthChecking(entry) {
return
}
// Role grants need care: someone could give themselves (or an alt) a
// trusted role to slip past everything below. Roles added by this very
// entry never count towards the actor's own trust, and an untrusted
// member granting a trusted role is stopped and the role taken back.
added := addedRoles(entry)
var actorRoles []string
if m, err := e.d.GuildMember(guildID, entry.UserID); err == nil {
actorRoles = m.Roles
if entry.TargetID == entry.UserID {
actorRoles = without(actorRoles, added)
}
}
if e.IsTrusted(ctx, guildID, entry.UserID, nonNil(actorRoles)) {
return
}
key, instant, what := Classify(entry, func(roleID string) int64 { return e.rolePerms(guildID, roleID) })
if granted := e.trustedAmong(ctx, guildID, added); len(granted) > 0 {
instant, what = true, "gave someone a trusted role"
e.revokeRoles(guildID, entry.TargetID, granted)
}
if *entry.ActionType == discordgo.AuditLogActionRoleUpdate && e.isBotRole(guildID, entry.TargetID) {
instant, what = true, "changed Velox's own role"
}
if key == "" && !instant {
return
}
who := guildID + "|" + entry.UserID
if !instant {
limit, ok := s.AntiNukeLimits[key]
if !ok {
limit = securityrepo.DefaultLimits()[key]
}
n := e.nukes.add(who+"|"+key, e.now(), time.Duration(s.AntiNukeWindowSeconds)*time.Second)
if n <= limit {
return
}
what = fmt.Sprintf("%s %d times in %d seconds (limit %d)", what, n, s.AntiNukeWindowSeconds, limit)
}
if !e.nukes.claim(who, e.now()) {
return
}
e.stopNuke(ctx, guildID, s, entry.UserID, what)
}
func (e *Engine) rolePerms(guildID, roleID string) int64 {
for _, r := range e.guildRoles(guildID) {
if r.ID == roleID {
return r.Permissions
}
}
return 0
}
// worthChecking skips audit log entries that can never trigger anti-nuke
// before anything is looked up on Discord. A busy server writes many entries
// (message deletes, nickname changes...), and lookups for each would eat
// into the rate limit the bot needs most during an attack.
func worthChecking(entry *discordgo.AuditLogEntry) bool {
switch *entry.ActionType {
case discordgo.AuditLogActionChannelCreate, discordgo.AuditLogActionChannelDelete,
discordgo.AuditLogActionRoleCreate, discordgo.AuditLogActionRoleDelete, discordgo.AuditLogActionRoleUpdate,
discordgo.AuditLogActionMemberBanAdd, discordgo.AuditLogActionMemberKick, discordgo.AuditLogActionMemberPrune,
discordgo.AuditLogActionWebhookCreate:
return true
case discordgo.AuditLogActionMemberRoleUpdate:
return len(addedRoles(entry)) > 0 // removing roles is harmless
case discordgo.AuditLogActionGuildUpdate:
for _, c := range entry.Changes {
if changeKey(c) == "vanity_url_code" {
return true
}
}
}
return false
}
const roleCacheTTL = 10 * time.Second
type cachedRoles struct {
roles []*discordgo.Role
at time.Time
}
// guildRoles returns the guild's roles, cached briefly. Staleness can't hide
// an escalation: creating or editing a role with dangerous permissions is
// caught from its own audit log entry.
func (e *Engine) guildRoles(guildID string) []*discordgo.Role {
e.mu.Lock()
c, ok := e.roleCache[guildID]
e.mu.Unlock()
if ok && e.now().Sub(c.at) < roleCacheTTL {
return c.roles
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return nil
}
e.mu.Lock()
if e.roleCache == nil {
e.roleCache = map[string]cachedRoles{}
}
e.roleCache[guildID] = cachedRoles{roles: roles, at: e.now()}
e.mu.Unlock()
return roles
}
func (e *Engine) stopNuke(ctx context.Context, guildID string, s *securityrepo.Settings, userID, what string) {
reason := "anti-nuke: " + what
e.event(ctx, guildID, securityrepo.EventNuke, userID, userID, what)
desc := fmt.Sprintf("<@%s> %s.", userID, what)
if err := e.Quarantine(ctx, guildID, userID, reason); err != nil {
log.Printf("security: quarantining %s in guild %s: %v", userID, guildID, err)
desc += "\n⚠️ I couldn't fully quarantine them: " + clip(err.Error(), 300) +
"\nMove the Velox role above their highest role and give it Manage Roles, Moderate Members and Kick Members."
} else {
desc += "\nThey were quarantined: roles removed (saved) and timed out. Release them with `/security release` if this was a mistake."
}
if s.AntiNukeLockServer {
locked, failed, err := e.LockServer(ctx, guildID, e.botID)
switch {
case err != nil:
desc += "\n⚠️ Locking the server failed: " + clip(err.Error(), 200)
default:
desc += fmt.Sprintf("\nThe server is locked (%d channels", locked)
if failed > 0 {
desc += fmt.Sprintf(", %d couldn't be locked", failed)
}
desc += "). Unlock it with `/unlock server`."
}
}
if s.AntiNukeAutoRestore {
desc += "\nRestoring deleted roles and channels from the latest snapshot; a report follows."
go func() {
// Give the attack a moment to stop, then rebuild.
time.Sleep(5 * time.Second)
if _, err := e.Restore(context.Background(), guildID, e.botID); err != nil {
log.Printf("security: auto restore in guild %s: %v", guildID, err)
}
}()
}
e.send(ctx, guildID, s, alert{
ping: true, pingOwner: true, critical: true,
embed: &discordgo.MessageEmbed{
Title: "🛡️ Nuke attempt stopped", Description: desc, Color: colorAlert,
Timestamp: e.now().UTC().Format(time.RFC3339),
},
})
}
// checkBotAdd removes a bot that someone other than the owner or a trusted
// member added, when the Join Gate is on.
func (e *Engine) checkBotAdd(ctx context.Context, guildID string, s *securityrepo.Settings, entry *discordgo.AuditLogEntry) {
if !s.JoinGateEnabled || s.UnauthorizedBot == securityrepo.ActionOff || entry.TargetID == "" {
return
}
if e.IsTrusted(ctx, guildID, entry.UserID, nil) {
return
}
bot := &discordgo.User{ID: entry.TargetID}
if err := e.punish(guildID, bot, s.UnauthorizedBot, fmt.Sprintf("bot added by %s, who isn't trusted", entry.UserID)); err != nil {
log.Printf("security: removing unauthorized bot %s in guild %s: %v", entry.TargetID, guildID, err)
return
}
e.event(ctx, guildID, securityrepo.EventUnauthorizedBot, entry.UserID, entry.TargetID, s.UnauthorizedBot)
e.send(ctx, guildID, s, alert{
ping: true, critical: true,
embed: &discordgo.MessageEmbed{
Title: "Bot removed",
Description: fmt.Sprintf("<@%s> added the bot <@%s> but isn't on the trusted list, so the bot was %s.", entry.UserID, entry.TargetID, map[string]string{securityrepo.ActionKick: "kicked", securityrepo.ActionBan: "banned"}[s.UnauthorizedBot]),
Color: colorWarn,
},
})
}
// addedRoles lists the role IDs a member role update added.
func addedRoles(entry *discordgo.AuditLogEntry) []string {
if entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionMemberRoleUpdate {
return nil
}
var out []string
for _, c := range entry.Changes {
if changeKey(c) != "$add" {
continue
}
list, _ := c.NewValue.([]any)
for _, item := range list {
if r, ok := item.(map[string]any); ok {
if rid, _ := r["id"].(string); rid != "" {
out = append(out, rid)
}
}
}
}
return out
}
func without(list, remove []string) []string {
drop := map[string]bool{}
for _, r := range remove {
drop[r] = true
}
out := []string{}
for _, r := range list {
if !drop[r] {
out = append(out, r)
}
}
return out
}
// nonNil keeps an empty role list from meaning "look the member up".
func nonNil(roles []string) []string {
if roles == nil {
return []string{}
}
return roles
}
// trustedAmong returns which of the roles are on the trusted list.
func (e *Engine) trustedAmong(ctx context.Context, guildID string, roles []string) []string {
if len(roles) == 0 {
return nil
}
_, trusted, err := e.load(ctx, guildID)
if err != nil {
return nil
}
var out []string
for _, r := range roles {
for _, t := range trusted {
if t.Kind == "role" && id(t.TargetID) == r {
out = append(out, r)
}
}
}
return out
}
// revokeRoles takes roles back from a member (best effort; never the owner).
func (e *Engine) revokeRoles(guildID, userID string, roles []string) {
if userID == "" || e.IsOwner(guildID, userID) {
return
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
return
}
next := without(m.Roles, roles)
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &next},
discordgo.WithAuditLogReason("Velox anti-nuke: trusted role granted by someone untrusted")); err != nil {
log.Printf("security: taking back trusted roles from %s in guild %s: %v", userID, guildID, err)
}
}
// isBotRole reports whether roleID is one of Velox's own roles.
func (e *Engine) isBotRole(guildID, roleID string) bool {
m, err := e.d.GuildMember(guildID, e.botID)
if err != nil {
return false
}
for _, r := range m.Roles {
if r == roleID {
return true
}
}
return false
}
+187
View File
@@ -0,0 +1,187 @@
package security
import (
"context"
"errors"
"fmt"
"log"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// usableQuarantineRole returns the configured quarantine role if it is safe
// to hand out right now: it exists, isn't @everyone or managed by an
// integration, and has none of the dangerous permissions. Checked at use, so
// a role that gained permissions since it was configured is never assigned.
func usableQuarantineRole(s *securityrepo.Settings, guildID string, roles []*discordgo.Role) string {
if s.QuarantineRoleID == 0 || id(s.QuarantineRoleID) == guildID {
return ""
}
for _, r := range roles {
if r.ID == id(s.QuarantineRoleID) {
if r.Managed || r.Permissions&DangerousPerms != 0 {
return ""
}
return r.ID
}
}
return ""
}
// Quarantine neutralises a member: every role it can remove is taken away
// (and saved, so it can be given back), the quarantine role is added if one
// is configured, and the member is timed out for the longest Discord allows.
// Bots are kicked instead, since their own integration role can't be
// removed. The owner and the bot itself are never touched.
func (e *Engine) Quarantine(ctx context.Context, guildID, userID, reason string) error {
if userID == "" || userID == e.botID || e.IsOwner(guildID, userID) {
return errors.New("refusing to quarantine the owner or the bot")
}
s, _, err := e.load(ctx, guildID)
if err != nil {
return err
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
return fmt.Errorf("looking up the member: %w", err)
}
if m.User != nil && m.User.Bot {
return e.d.GuildMemberDeleteWithReason(guildID, userID, clip("Velox anti-nuke: "+reason, 400))
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return fmt.Errorf("listing roles: %w", err)
}
managed := map[string]bool{}
for _, r := range roles {
if r.Managed {
managed[r.ID] = true
}
}
var keep []string
var previous []int64
for _, r := range m.Roles {
if managed[r] {
keep = append(keep, r) // can't be removed (e.g. a Nitro booster role)
} else {
previous = append(previous, parseID(r))
}
}
qRole := usableQuarantineRole(s, guildID, roles)
withRole := keep
if qRole != "" {
withRole = append(append([]string(nil), keep...), qRole)
}
// Save first: if Discord fails half-way, the roles are still on record.
if err := e.st.SaveQuarantine(ctx, securityrepo.Quarantine{
GuildID: parseID(guildID), UserID: parseID(userID), PreviousRoleIDs: previous, Reason: clip(reason, 300),
}); err != nil {
return fmt.Errorf("saving the quarantine: %w", err)
}
audit := discordgo.WithAuditLogReason(clip("Velox anti-nuke: "+reason, 400))
var errs []error
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &withRole}, audit); err != nil {
// A quarantine role Velox can't assign (e.g. moved above its own
// role) must not stop the roles from being removed: retry without it.
if qRole == "" {
errs = append(errs, fmt.Errorf("removing roles: %w", err))
} else if _, err2 := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &keep}, audit); err2 != nil {
errs = append(errs, fmt.Errorf("removing roles: %w", err2))
} else {
log.Printf("security: quarantine role %s couldn't be assigned in guild %s (%v); roles were still removed", qRole, guildID, err)
}
}
until := e.now().Add(timeoutQuarantine)
if err := e.d.GuildMemberTimeout(guildID, userID, &until, audit); err != nil {
errs = append(errs, fmt.Errorf("timing out: %w", err))
}
e.event(ctx, guildID, securityrepo.EventQuarantined, e.botID, userID, reason)
return errors.Join(errs...)
}
// Release undoes a quarantine: the saved roles that still exist are given
// back, the quarantine role and the timeout are removed. Releasing relaxes
// protection, so callers must check the actor first.
func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error {
rec, err := e.st.GetQuarantine(ctx, parseID(guildID), parseID(userID))
if err != nil {
return err
}
if rec == nil {
return UserError("That member isn't quarantined.")
}
s, _, err := e.load(ctx, guildID)
if err != nil {
return err
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
// They left: there's nothing to give back.
if derr := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); derr != nil {
return derr
}
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, "member had left")
return nil
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return fmt.Errorf("listing roles: %w", err)
}
exists := map[string]bool{}
for _, r := range roles {
if !r.Managed && r.ID != guildID {
exists[r.ID] = true
}
}
qRole := ""
if s.QuarantineRoleID != 0 {
qRole = id(s.QuarantineRoleID)
}
seen := map[string]bool{}
var next []string
add := func(r string) {
if !seen[r] && r != qRole {
seen[r] = true
next = append(next, r)
}
}
for _, r := range m.Roles {
add(r)
}
for _, r := range rec.PreviousRoleIDs {
if exists[id(r)] {
add(id(r))
}
}
audit := discordgo.WithAuditLogReason("Velox security: quarantine released")
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &next}, audit); err != nil {
return fmt.Errorf("giving the roles back: %w", err)
}
if err := e.d.GuildMemberTimeout(guildID, userID, nil, audit); err != nil {
log.Printf("security: removing the timeout of %s in guild %s: %v", userID, guildID, err)
}
if err := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); err != nil {
return err
}
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, fmt.Sprintf("%d roles given back", len(rec.PreviousRoleIDs)))
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
Title: "Quarantine released",
Description: fmt.Sprintf("<@%s> was released by <@%s> and got their roles back.", userID, by),
Color: colorOK,
Timestamp: e.now().UTC().Format(time.RFC3339),
}})
return nil
}
// Quarantines lists who is quarantined in a guild.
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
return e.st.ListQuarantines(ctx, parseID(guildID))
}
+333
View File
@@ -0,0 +1,333 @@
package security
import (
"context"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Memory bounds per server, so a huge raid can't exhaust the bot.
const (
maxRecentJoins = 500
maxCaught = 20000
)
// CustomIDEndRaid is the alert's "End raid" button.
const CustomIDEndRaid = "security:endraid"
type joinRec struct {
user *discordgo.User
at time.Time
handled bool // already removed by the join lock or Join Gate
}
type caughtRec struct {
userID, name, action string
}
type raid struct {
mu sync.Mutex
joins []joinRec
active bool
started time.Time
lastJoin time.Time
caught []caughtRec
caughtTotal int
pausedInvites bool
}
func (e *Engine) raidFor(guildID string) *raid {
e.mu.Lock()
defer e.mu.Unlock()
r, ok := e.raids[guildID]
if !ok {
r = &raid{}
e.raids[guildID] = r
}
return r
}
// RaidActive reports whether raid mode is on in a guild.
func (e *Engine) RaidActive(guildID string) bool {
e.mu.Lock()
r, ok := e.raids[guildID]
e.mu.Unlock()
if !ok {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
// raidJoin records a join and, during a raid, acts on the raiders. It returns
// the action taken on this member ("" when none).
func (e *Engine) raidJoin(ctx context.Context, guildID string, s *securityrepo.Settings, m *discordgo.Member, handled bool) string {
if !s.AntiRaidEnabled {
return ""
}
now := e.now()
window := time.Duration(s.RaidJoinSeconds) * time.Second
r := e.raidFor(guildID)
r.mu.Lock()
r.joins = append(r.joins, joinRec{user: m.User, at: now, handled: handled})
cut := 0
for cut < len(r.joins) && now.Sub(r.joins[cut].at) > window {
cut++
}
r.joins = r.joins[cut:]
if len(r.joins) > maxRecentJoins {
r.joins = r.joins[len(r.joins)-maxRecentJoins:]
}
r.lastJoin = now
var targets []joinRec
starting := false
switch {
case !r.active && len(r.joins) >= s.RaidJoinCount:
// Raid starts: everyone in the burst that triggered it is a raider.
starting = true
r.active, r.started, r.caught, r.caughtTotal, r.pausedInvites = true, now, nil, 0, false
targets = append(targets, r.joins...)
case r.active:
targets = []joinRec{{user: m.User, at: now, handled: handled}}
}
r.mu.Unlock()
if starting {
e.startRaid(ctx, guildID, s, len(targets))
}
action := s.RaidAction
thisMember := ""
for _, t := range targets {
did := action
if t.handled || action == securityrepo.ActionAlert {
did = "flagged"
} else if err := e.punish(guildID, t.user, action, "raid"); errors.Is(err, errProtected) {
did = "skipped"
} else if err != nil {
log.Printf("security: raid action on %s in guild %s: %v", t.user.ID, guildID, err)
did = "failed"
}
r.mu.Lock()
r.caughtTotal++
keep := len(r.caught) < maxCaught
if keep {
r.caught = append(r.caught, caughtRec{userID: t.user.ID, name: safeName(t.user), action: did})
}
r.mu.Unlock()
if keep {
if err := e.st.AddRaidCaught(ctx, parseID(guildID), securityrepo.Caught{UserID: parseID(t.user.ID), Action: did, Name: safeName(t.user)}); err != nil {
log.Printf("security: recording raider %s in guild %s: %v", t.user.ID, guildID, err)
}
}
if t.user.ID == m.User.ID && did == action {
thisMember = action
}
}
if len(targets) > 0 {
e.persistRaid(ctx, guildID, r)
}
return thisMember
}
// persistRaid writes the raid's state so a restart can resume it.
func (e *Engine) persistRaid(ctx context.Context, guildID string, r *raid) {
r.mu.Lock()
x := securityrepo.Raid{GuildID: parseID(guildID), StartedAt: r.started, LastJoinAt: r.lastJoin, InvitesPaused: r.pausedInvites, CaughtTotal: r.caughtTotal}
active := r.active
r.mu.Unlock()
if !active {
return
}
if err := e.st.SaveRaid(ctx, x); err != nil {
log.Printf("security: saving raid state for guild %s: %v", guildID, err)
}
}
// ResumeRaids reloads raids that were in progress when the bot stopped, so
// raid mode stays on, its quiet timer keeps counting from the last join, and
// paused invites are resumed when it ends.
func (e *Engine) ResumeRaids(ctx context.Context) {
raids, err := e.st.ListRaids(ctx)
if err != nil {
log.Printf("security: loading raids in progress: %v", err)
return
}
for _, x := range raids {
r := e.raidFor(id(x.GuildID))
r.mu.Lock()
r.active, r.started, r.lastJoin, r.pausedInvites, r.caughtTotal = true, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal
r.mu.Unlock()
}
}
func (e *Engine) startRaid(ctx context.Context, guildID string, s *securityrepo.Settings, joins int) {
e.event(ctx, guildID, securityrepo.EventRaidStarted, e.botID, "", fmt.Sprintf("%d joins in %ds", joins, s.RaidJoinSeconds))
paused := false
if s.RaidPauseInvites {
if err := e.setInvitesPaused(guildID, true); err != nil {
log.Printf("security: pausing invites in guild %s: %v", guildID, err)
} else {
paused = true
r := e.raidFor(guildID)
r.mu.Lock()
r.pausedInvites = true
r.mu.Unlock()
}
}
e.persistRaid(ctx, guildID, e.raidFor(guildID))
what := map[string]string{
securityrepo.ActionAlert: "nobody is being removed (alert only)", securityrepo.ActionTimeout: "raiders are being timed out",
securityrepo.ActionKick: "raiders are being kicked", securityrepo.ActionBan: "raiders are being banned",
}[s.RaidAction]
desc := fmt.Sprintf("%d members joined within %d seconds. Raid mode is on: %s.", joins, s.RaidJoinSeconds, what)
if paused {
desc += "\nInvites are paused until the raid ends."
}
desc += fmt.Sprintf("\nIt ends after %d minutes without a join, or with the button below (owner or trusted only).", s.RaidQuietMinutes)
e.send(ctx, guildID, s, alert{
ping: true, critical: true,
embed: &discordgo.MessageEmbed{
Title: "🚨 Raid detected", Description: desc, Color: colorAlert,
Timestamp: e.now().UTC().Format(time.RFC3339),
},
components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "End raid", Style: discordgo.SecondaryButton, CustomID: CustomIDEndRaid},
}}},
})
}
// EndRaid turns raid mode off. by is who ended it ("" for the quiet timer).
// Ending a raid relaxes protection, so callers must check the actor first.
func (e *Engine) EndRaid(ctx context.Context, guildID, by string) error {
e.mu.Lock()
r, ok := e.raids[guildID]
e.mu.Unlock()
if !ok {
return UserError("There's no raid going on.")
}
r.mu.Lock()
if !r.active {
r.mu.Unlock()
return UserError("There's no raid going on.")
}
started, caught, total, paused := r.started, r.caught, r.caughtTotal, r.pausedInvites
r.active, r.caught, r.caughtTotal, r.pausedInvites, r.joins = false, nil, 0, false, nil
r.mu.Unlock()
if paused {
if err := e.setInvitesPaused(guildID, false); err != nil {
log.Printf("security: resuming invites in guild %s: %v", guildID, err)
}
}
how := "after a quiet period"
if by != "" {
how = "by <@" + by + ">"
}
e.event(ctx, guildID, securityrepo.EventRaidEnded, by, "", fmt.Sprintf("%d raiders caught", total))
// The database has the full list even across a restart; memory is the
// fallback if it can't be read.
if saved, err := e.st.ListRaidCaught(ctx, parseID(guildID), maxCaught); err == nil && len(saved) >= len(caught) {
caught = caught[:0]
for _, c := range saved {
caught = append(caught, caughtRec{userID: id(c.UserID), action: c.Action, name: c.Name})
}
}
if err := e.st.DeleteRaid(ctx, parseID(guildID)); err != nil {
log.Printf("security: clearing raid state for guild %s: %v", guildID, err)
}
var list strings.Builder
fmt.Fprintf(&list, "Raid from %s to %s (UTC)\n\n", started.UTC().Format("2006-01-02 15:04:05"), e.now().UTC().Format("15:04:05"))
for _, c := range caught {
fmt.Fprintf(&list, "%s\t%s\t%s\n", c.userID, c.action, c.name)
}
if total > len(caught) {
fmt.Fprintf(&list, "... and %d more\n", total-len(caught))
}
desc := fmt.Sprintf("Raid mode ended %s. %d accounts were caught; the list is attached.", how, total)
if paused {
desc += "\nInvites are open again."
}
if s, _, err := e.load(ctx, guildID); err == nil {
e.send(ctx, guildID, s, alert{
critical: true,
embed: &discordgo.MessageEmbed{Title: "Raid over", Description: desc, Color: colorOK},
file: textFile("raid-"+started.UTC().Format("20060102-1504")+".txt", list.String()),
})
}
return nil
}
// WatchRaids ends raids after their quiet period and frees memory for
// servers that have gone quiet. It runs until ctx ends.
func (e *Engine) WatchRaids(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
e.checkRaids(ctx)
}
}
}
func (e *Engine) checkRaids(ctx context.Context) {
e.mu.Lock()
guilds := make([]string, 0, len(e.raids))
for g := range e.raids {
guilds = append(guilds, g)
}
e.mu.Unlock()
now := e.now()
for _, g := range guilds {
s, _, err := e.load(ctx, g)
if err != nil {
continue
}
r := e.raidFor(g)
r.mu.Lock()
active, last := r.active, r.lastJoin
r.mu.Unlock()
switch {
case active && now.Sub(last) >= time.Duration(s.RaidQuietMinutes)*time.Minute:
_ = e.EndRaid(ctx, g, "")
case !active && now.Sub(last) > 10*time.Minute:
e.mu.Lock()
delete(e.raids, g)
e.mu.Unlock()
}
}
}
// setInvitesPaused uses Discord's built-in "pause invites" (guild incident
// actions). Paused for up to 24 hours, Discord's maximum; resumed when the
// raid ends.
func (e *Engine) setInvitesPaused(guildID string, paused bool) error {
body := map[string]any{"invites_disabled_until": nil}
if paused {
body["invites_disabled_until"] = e.now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
}
url := discordgo.EndpointGuild(guildID) + "/incident-actions"
_, err := e.d.RequestWithBucketID("PUT", url, body, url)
return err
}
+322
View File
@@ -0,0 +1,322 @@
package security
import (
"context"
"encoding/json"
"fmt"
"log"
"sort"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Snapshots record a guild's roles and channels (names, settings,
// permissions, channel permission overwrites) so a nuke can be undone.
// Messages and who had which role can't be captured or brought back; Discord
// offers no way to.
const (
snapshotEvery = 6 * time.Hour
snapshotCheckGap = 30 * time.Minute
)
type snapRole struct {
ID string `json:"id"`
Name string `json:"name"`
Color int `json:"color"`
Hoist bool `json:"hoist"`
Mentionable bool `json:"mentionable"`
Permissions int64 `json:"permissions"`
Position int `json:"position"`
}
type snapOverwrite struct {
ID string `json:"id"`
Type int `json:"type"`
Allow int64 `json:"allow"`
Deny int64 `json:"deny"`
}
type snapChannel struct {
ID string `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Topic string `json:"topic,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Position int `json:"position"`
NSFW bool `json:"nsfw,omitempty"`
RateLimit int `json:"rate_limit,omitempty"`
Bitrate int `json:"bitrate,omitempty"`
UserLimit int `json:"user_limit,omitempty"`
Overwrites []snapOverwrite `json:"overwrites,omitempty"`
}
type snapshotData struct {
Roles []snapRole `json:"roles"`
Channels []snapChannel `json:"channels"`
}
// restorable channel types: no threads, which belong to their parent.
func restorable(t discordgo.ChannelType) bool {
switch t {
case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildCategory,
discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildStageVoice, discordgo.ChannelTypeGuildForum:
return true
}
return false
}
// TakeSnapshot records the guild's current roles and channels.
func (e *Engine) TakeSnapshot(ctx context.Context, guildID, by string) (roles, channels int, err error) {
rs, err := e.d.GuildRoles(guildID)
if err != nil {
return 0, 0, fmt.Errorf("listing roles: %w", err)
}
cs, err := e.d.GuildChannels(guildID)
if err != nil {
return 0, 0, fmt.Errorf("listing channels: %w", err)
}
var data snapshotData
for _, r := range rs {
if r.Managed {
continue // integration roles come back with their bot
}
data.Roles = append(data.Roles, snapRole{ID: r.ID, Name: r.Name, Color: r.Color, Hoist: r.Hoist,
Mentionable: r.Mentionable, Permissions: r.Permissions, Position: r.Position})
}
for _, c := range cs {
if !restorable(c.Type) {
continue
}
sc := snapChannel{ID: c.ID, Name: c.Name, Type: int(c.Type), Topic: c.Topic, ParentID: c.ParentID, Position: c.Position,
NSFW: c.NSFW, RateLimit: c.RateLimitPerUser, Bitrate: c.Bitrate, UserLimit: c.UserLimit}
for _, o := range c.PermissionOverwrites {
sc.Overwrites = append(sc.Overwrites, snapOverwrite{ID: o.ID, Type: int(o.Type), Allow: o.Allow, Deny: o.Deny})
}
data.Channels = append(data.Channels, sc)
}
raw, err := json.Marshal(data)
if err != nil {
return 0, 0, err
}
if err := e.st.SaveSnapshot(ctx, securityrepo.Snapshot{GuildID: parseID(guildID), Data: raw, Roles: len(data.Roles), Channels: len(data.Channels)}); err != nil {
return 0, 0, err
}
if by != "" {
e.event(ctx, guildID, securityrepo.EventSnapshot, by, "", fmt.Sprintf("%d roles, %d channels", len(data.Roles), len(data.Channels)))
}
return len(data.Roles), len(data.Channels), nil
}
// WatchSnapshots takes a snapshot of every guild with anti-nuke on whose
// newest one is older than six hours. It runs until ctx ends.
func (e *Engine) WatchSnapshots(ctx context.Context) {
run := func() {
due, err := e.st.GuildsDueForSnapshot(ctx, snapshotEvery)
if err != nil {
log.Printf("security: finding guilds due a snapshot: %v", err)
return
}
for _, g := range due {
if _, _, err := e.TakeSnapshot(ctx, id(g), ""); err != nil {
log.Printf("security: snapshot of guild %d: %v", g, err)
}
}
}
run()
t := time.NewTicker(snapshotCheckGap)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
// RestoreResult says what a restore did.
type RestoreResult struct {
RolesCreated, RolesReverted, ChannelsCreated, Failed int
SnapshotAt time.Time
}
// Restore brings the guild back towards its latest snapshot:
//
// - roles that are gone are recreated (same name, colour, permissions);
// - roles that gained Administrator, Manage Server, Manage Roles or Manage
// Webhooks since the snapshot get their snapshot permissions back;
// - channels that are gone are recreated, categories first, with their
// permission overwrites pointing at the recreated roles.
//
// Nothing is deleted: channels and roles made since the snapshot stay, since
// some may be legitimate. Restoring rewrites the server, so callers must check
// the actor is the owner or trusted. One restore per guild runs at a time.
func (e *Engine) Restore(ctx context.Context, guildID, by string) (*RestoreResult, error) {
e.mu.Lock()
if e.restoring[guildID] {
e.mu.Unlock()
return nil, UserError("A restore is already running for this server.")
}
e.restoring[guildID] = true
e.mu.Unlock()
defer func() {
e.mu.Lock()
delete(e.restoring, guildID)
e.mu.Unlock()
}()
snap, err := e.st.LatestSnapshot(ctx, parseID(guildID))
if err != nil {
return nil, err
}
if snap == nil {
return nil, UserError("There's no snapshot to restore from yet. Turn anti-nuke on (snapshots are taken every 6 hours) or take one with `/security snapshot`.")
}
var data snapshotData
if err := json.Unmarshal(snap.Data, &data); err != nil {
return nil, fmt.Errorf("reading the snapshot: %w", err)
}
res := &RestoreResult{SnapshotAt: snap.CreatedAt}
reason := discordgo.WithAuditLogReason("Velox restore from snapshot")
// Roles.
current, err := e.d.GuildRoles(guildID)
if err != nil {
return nil, fmt.Errorf("listing roles: %w", err)
}
now := map[string]*discordgo.Role{}
for _, r := range current {
now[r.ID] = r
}
roleMap := map[string]string{} // snapshot role ID -> current role ID
sort.Slice(data.Roles, func(i, j int) bool { return data.Roles[i].Position < data.Roles[j].Position })
for _, sr := range data.Roles {
if cur, ok := now[sr.ID]; ok {
roleMap[sr.ID] = sr.ID
if added := cur.Permissions &^ sr.Permissions; added&DangerousPerms != 0 && !cur.Managed {
perms := sr.Permissions
if _, err := e.d.GuildRoleEdit(guildID, sr.ID, &discordgo.RoleParams{Permissions: &perms}, reason); err != nil {
log.Printf("security: restore: reverting role %s in guild %s: %v", sr.ID, guildID, err)
res.Failed++
} else {
res.RolesReverted++
}
}
continue
}
if sr.ID == guildID {
continue // @everyone always exists
}
color, hoist, mention, perms := sr.Color, sr.Hoist, sr.Mentionable, sr.Permissions
r, err := e.d.GuildRoleCreate(guildID, &discordgo.RoleParams{Name: sr.Name, Color: &color, Hoist: &hoist, Mentionable: &mention, Permissions: &perms}, reason)
if err != nil {
log.Printf("security: restore: recreating role %q in guild %s: %v", sr.Name, guildID, err)
res.Failed++
continue
}
roleMap[sr.ID] = r.ID
res.RolesCreated++
}
// Channels: categories first so the others can be put back in them.
chans, err := e.d.GuildChannels(guildID)
if err != nil {
return res, fmt.Errorf("listing channels: %w", err)
}
exists := map[string]bool{}
for _, c := range chans {
exists[c.ID] = true
}
chanMap := map[string]string{}
for _, c := range chans {
chanMap[c.ID] = c.ID
}
sort.SliceStable(data.Channels, func(i, j int) bool {
ci := data.Channels[i].Type == int(discordgo.ChannelTypeGuildCategory)
cj := data.Channels[j].Type == int(discordgo.ChannelTypeGuildCategory)
if ci != cj {
return ci
}
return data.Channels[i].Position < data.Channels[j].Position
})
for _, sc := range data.Channels {
if exists[sc.ID] {
continue
}
var overwrites []*discordgo.PermissionOverwrite
for _, o := range sc.Overwrites {
target := o.ID
if discordgo.PermissionOverwriteType(o.Type) == discordgo.PermissionOverwriteTypeRole {
mapped, ok := roleMap[o.ID]
if !ok {
continue // the role couldn't be brought back
}
target = mapped
}
overwrites = append(overwrites, &discordgo.PermissionOverwrite{ID: target, Type: discordgo.PermissionOverwriteType(o.Type), Allow: o.Allow, Deny: o.Deny})
}
parent := ""
if sc.ParentID != "" {
parent = chanMap[sc.ParentID] // "" if the category couldn't be restored
}
c, err := e.d.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: sc.Name, Type: discordgo.ChannelType(sc.Type), Topic: sc.Topic, Bitrate: sc.Bitrate, UserLimit: sc.UserLimit,
RateLimitPerUser: sc.RateLimit, Position: sc.Position, PermissionOverwrites: overwrites, ParentID: parent, NSFW: sc.NSFW,
}, reason)
if err != nil {
log.Printf("security: restore: recreating channel %q in guild %s: %v", sc.Name, guildID, err)
res.Failed++
continue
}
chanMap[sc.ID] = c.ID
res.ChannelsCreated++
}
// Point the snapshot at the recreated roles and channels, so restoring
// again doesn't create them a second time. The content stays the
// snapshot's: nothing made since (or by an attacker) is added.
if res.RolesCreated+res.ChannelsCreated > 0 {
for i := range data.Roles {
if nid, ok := roleMap[data.Roles[i].ID]; ok {
data.Roles[i].ID = nid
}
}
for i := range data.Channels {
c := &data.Channels[i]
if nid, ok := chanMap[c.ID]; ok {
c.ID = nid
}
if nid, ok := chanMap[c.ParentID]; ok && c.ParentID != "" {
c.ParentID = nid
}
for j := range c.Overwrites {
if c.Overwrites[j].Type == int(discordgo.PermissionOverwriteTypeRole) {
if nid, ok := roleMap[c.Overwrites[j].ID]; ok {
c.Overwrites[j].ID = nid
}
}
}
}
if raw, err := json.Marshal(data); err == nil {
if err := e.st.SaveSnapshot(ctx, securityrepo.Snapshot{GuildID: parseID(guildID), Data: raw, Roles: snap.Roles, Channels: snap.Channels}); err != nil {
log.Printf("security: saving the remapped snapshot for guild %s: %v", guildID, err)
}
}
}
e.event(ctx, guildID, securityrepo.EventRestored, by, "", fmt.Sprintf("%d roles recreated, %d reverted, %d channels recreated, %d failed",
res.RolesCreated, res.RolesReverted, res.ChannelsCreated, res.Failed))
if s, _, err := e.load(ctx, guildID); err == nil {
e.send(ctx, guildID, s, alert{critical: true, embed: &discordgo.MessageEmbed{
Title: "♻️ Server restored from snapshot",
Description: fmt.Sprintf("Snapshot from <t:%d:f>. Recreated %d roles and %d channels, reverted dangerous permissions on %d roles. %d steps failed.\nMembers need their recreated roles given back by hand; Discord doesn't let bots see who had them.",
res.SnapshotAt.Unix(), res.RolesCreated, res.ChannelsCreated, res.RolesReverted, res.Failed),
Color: colorOK,
}})
}
return res, nil
}
+354
View File
@@ -0,0 +1,354 @@
// Package security is the bot's anti-raid and anti-nuke system, modelled on
// Wick:
//
// - Join Gate: checks on every join (no avatar, new account, invite links
// or blocked words in the name, unverified bots), each with its own
// action, plus kicking bots added by anyone who isn't trusted.
// - Raid mode: a burst of joins starts it; raiders are timed out, kicked or
// banned, invites can be paused, staff are alerted, and it ends after a
// quiet period or when staff end it.
// - Anti-nuke: destructive actions (deleting channels or roles, mass bans
// and kicks, webhooks, dangerous permissions) are counted per person from
// the audit log; anyone who isn't the owner or trusted and goes over a
// limit is quarantined: roles removed and saved, timed out.
// - Lockdown: lock a channel, every channel, or joins, and unlock exactly
// back to how they were.
//
// Safety rules every entry point follows:
//
// - Turning protection off, changing limits, releasing a quarantine and
// unlocking need the server owner or someone on the trusted list.
// Editing the trusted list needs the owner. Discord's command
// permissions are never relied on, since admins can change them.
// - The owner and the bot itself are never punished.
// - Configured roles are checked when used, not just when saved: a
// quarantine role that gained dangerous permissions is not assigned.
// - Messages only ever ping the configured alert role, and user-controlled
// text (names) is escaped.
// - Memory per server is bounded, and settings are cached briefly so a
// raid of thousands of joins doesn't turn into thousands of queries.
package security
import (
"context"
"strconv"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Store is the subset of securityrepo.Repo the system uses.
type Store interface {
GetSettings(ctx context.Context, guildID int64) (*securityrepo.Settings, error)
SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
SetJoinLock(ctx context.Context, guildID int64, mode string) error
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
SaveQuarantine(ctx context.Context, q securityrepo.Quarantine) error
GetQuarantine(ctx context.Context, guildID, userID int64) (*securityrepo.Quarantine, error)
ListQuarantines(ctx context.Context, guildID int64) ([]securityrepo.Quarantine, error)
DeleteQuarantine(ctx context.Context, guildID, userID int64) error
SaveLockdown(ctx context.Context, l securityrepo.Lockdown) error
GetLockdown(ctx context.Context, guildID, channelID int64) (*securityrepo.Lockdown, error)
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
DeleteLockdown(ctx context.Context, guildID, channelID int64) error
AddEvent(ctx context.Context, e securityrepo.Event) error
ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error)
MarkCommandProcessed(ctx context.Context, id int64) error
SaveRaid(ctx context.Context, x securityrepo.Raid) error
ListRaids(ctx context.Context) ([]securityrepo.Raid, error)
DeleteRaid(ctx context.Context, guildID int64) error
AddRaidCaught(ctx context.Context, guildID int64, c securityrepo.Caught) error
ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]securityrepo.Caught, error)
SaveSnapshot(ctx context.Context, s securityrepo.Snapshot) error
LatestSnapshot(ctx context.Context, guildID int64) (*securityrepo.Snapshot, error)
GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error)
}
// Discord is the subset of *discordgo.Session the system uses.
type Discord interface {
Guild(guildID string, options ...discordgo.RequestOption) (*discordgo.Guild, error)
UserChannelCreate(recipientID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
User(userID string, options ...discordgo.RequestOption) (*discordgo.User, error)
GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error)
GuildChannels(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Channel, error)
GuildMember(guildID, userID string, options ...discordgo.RequestOption) (*discordgo.Member, error)
GuildMemberEdit(guildID, userID string, data *discordgo.GuildMemberParams, options ...discordgo.RequestOption) (*discordgo.Member, error)
GuildMemberTimeout(guildID, userID string, until *time.Time, options ...discordgo.RequestOption) error
GuildMemberDeleteWithReason(guildID, userID, reason string, options ...discordgo.RequestOption) error
GuildBanCreateWithReason(guildID, userID, reason string, days int, options ...discordgo.RequestOption) error
Channel(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
ChannelPermissionSet(channelID, targetID string, targetType discordgo.PermissionOverwriteType, allow, deny int64, options ...discordgo.RequestOption) error
ChannelPermissionDelete(channelID, targetID string, options ...discordgo.RequestOption) error
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
RequestWithBucketID(method, urlStr string, data interface{}, bucketID string, options ...discordgo.RequestOption) ([]byte, error)
ChannelMessageDelete(channelID, messageID string, options ...discordgo.RequestOption) error
GuildRoleCreate(guildID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
GuildRoleEdit(guildID, roleID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
GuildChannelCreateComplex(guildID string, data discordgo.GuildChannelCreateData, options ...discordgo.RequestOption) (*discordgo.Channel, error)
}
// UserError is shown to the person who acted, as written.
type UserError string
func (e UserError) Error() string { return string(e) }
const (
ErrNotTrusted = UserError("Only the server owner or someone on the security trusted list can do that.")
ErrNotOwner = UserError("Only the server owner can change the trusted list.")
)
// Dangerous permissions: what a nuker needs, and what no quarantine or alert
// role may have.
const DangerousPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild |
discordgo.PermissionManageRoles | discordgo.PermissionManageWebhooks
const (
timeoutJoin = 24 * time.Hour
timeoutQuarantine = 28*24*time.Hour - time.Hour // Discord's maximum is 28 days
settingsTTL = 15 * time.Second
ownerTTL = 10 * time.Minute
)
// Engine holds the system's state. One per bot.
type Engine struct {
d Discord
st Store
botID string
now func() time.Time
mu sync.Mutex
settings map[string]cachedSettings
owners map[string]cachedOwner
raids map[string]*raid
nukes *nukeCounter
roleCache map[string]cachedRoles
heat map[string]*guildHeat
restoring map[string]bool
// syncDMs sends alert DMs before returning (tests); normally they go out
// in the background so an alert never waits on them.
syncDMs bool
// ChannelPerms reports a member's permissions in a channel from the
// gateway cache (no REST call), used to exempt moderators from heat.
// Nil means nobody is exempt by permission.
ChannelPerms func(userID, channelID string) (int64, error)
}
type cachedSettings struct {
s *securityrepo.Settings
trusted []securityrepo.Trusted
at time.Time
}
type cachedOwner struct {
id string
name string
at time.Time
}
func New(d Discord, st Store, botID string) *Engine {
return &Engine{
d: d, st: st, botID: botID, now: time.Now,
settings: map[string]cachedSettings{},
owners: map[string]cachedOwner{},
raids: map[string]*raid{},
nukes: newNukeCounter(),
heat: map[string]*guildHeat{},
restoring: map[string]bool{},
}
}
var (
defaultEngine *Engine
pendingStore Store
)
// Configure sets the store; Start creates the engine once the bot knows its
// own user ID (after the gateway connects).
func Configure(st Store) { pendingStore = st }
// Start creates the default engine for a connected session. It returns nil
// when no store was configured.
func Start(d Discord, botID string) *Engine {
if pendingStore == nil {
return nil
}
defaultEngine = New(d, pendingStore, botID)
return defaultEngine
}
func Init(e *Engine) { defaultEngine = e }
func Default() *Engine { return defaultEngine }
func parseID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
func id(n int64) string { return strconv.FormatInt(n, 10) }
// load returns the guild's settings and trusted list, cached briefly.
func (e *Engine) load(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
e.mu.Lock()
c, ok := e.settings[guildID]
e.mu.Unlock()
if ok && e.now().Sub(c.at) < settingsTTL {
return c.s, c.trusted, nil
}
g := parseID(guildID)
s, err := e.st.GetSettings(ctx, g)
if err != nil {
return nil, nil, err
}
trusted, err := e.st.ListTrusted(ctx, g)
if err != nil {
return nil, nil, err
}
e.mu.Lock()
e.settings[guildID] = cachedSettings{s: s, trusted: trusted, at: e.now()}
e.mu.Unlock()
return s, trusted, nil
}
// Invalidate drops the cached settings after a change, so it applies at once.
func (e *Engine) Invalidate(guildID string) {
e.mu.Lock()
delete(e.settings, guildID)
e.mu.Unlock()
}
// Settings returns the current settings (for status displays).
func (e *Engine) Settings(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
return e.load(ctx, guildID)
}
// OwnerID returns the guild owner's ID, cached.
func (e *Engine) OwnerID(guildID string) (string, error) {
e.mu.Lock()
c, ok := e.owners[guildID]
e.mu.Unlock()
if ok && e.now().Sub(c.at) < ownerTTL {
return c.id, nil
}
g, err := e.d.Guild(guildID)
if err != nil {
return "", err
}
e.mu.Lock()
e.owners[guildID] = cachedOwner{id: g.OwnerID, name: g.Name, at: e.now()}
e.mu.Unlock()
return g.OwnerID, nil
}
// guildName returns the guild's name (cached with the owner), or "".
func (e *Engine) guildName(guildID string) string {
if _, err := e.OwnerID(guildID); err != nil {
return ""
}
e.mu.Lock()
defer e.mu.Unlock()
return e.owners[guildID].name
}
// trustedBy reports whether a user (with their role IDs) is on the list.
func trustedBy(list []securityrepo.Trusted, userID string, roles []string) bool {
uid := parseID(userID)
for _, t := range list {
switch t.Kind {
case "user":
if t.TargetID == uid {
return true
}
case "role":
for _, r := range roles {
if parseID(r) == t.TargetID {
return true
}
}
}
}
return false
}
// IsTrusted reports whether a user is exempt from anti-nuke and may change
// security settings: the owner, the bot itself, or someone on the trusted
// list (directly or by role). When roles is nil the member is looked up.
// Any lookup failure counts as not trusted.
func (e *Engine) IsTrusted(ctx context.Context, guildID, userID string, roles []string) bool {
if userID == "" {
return false
}
if userID == e.botID {
return true
}
if owner, err := e.OwnerID(guildID); err == nil && owner == userID {
return true
}
_, trusted, err := e.load(ctx, guildID)
if err != nil {
return false
}
if roles == nil {
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
return trustedBy(trusted, userID, nil)
}
roles = m.Roles
}
return trustedBy(trusted, userID, roles)
}
// IsOwner reports whether the user owns the guild. Lookup failures are "no".
func (e *Engine) IsOwner(guildID, userID string) bool {
owner, err := e.OwnerID(guildID)
return err == nil && owner != "" && owner == userID
}
// RequireTrusted returns ErrNotTrusted unless the actor is owner or trusted.
func (e *Engine) RequireTrusted(ctx context.Context, guildID string, actor *discordgo.Member) error {
if actor == nil || actor.User == nil || !e.IsTrusted(ctx, guildID, actor.User.ID, actor.Roles) {
return ErrNotTrusted
}
return nil
}
func (e *Engine) event(ctx context.Context, guildID string, kind string, actor, target string, detail string) {
_ = e.st.AddEvent(ctx, securityrepo.Event{
GuildID: parseID(guildID), Kind: kind, ActorID: parseID(actor), TargetID: parseID(target), Detail: clip(detail, 1000),
})
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// safeName renders a user-controlled name for an embed: markdown escaped and
// mentions defused (allowed mentions are restricted too, this is belt and
// braces).
func safeName(u *discordgo.User) string {
if u == nil {
return "unknown"
}
name := u.Username
if u.GlobalName != "" && u.GlobalName != u.Username {
name = u.GlobalName + " (" + u.Username + ")"
}
r := strings.NewReplacer(
`\`, `\\`, `*`, `\*`, `_`, `\_`, "`", "\\`", `~`, `\~`, `|`, `\|`, `>`, `\>`, `#`, `\#`,
`[`, `\[`, `]`, `\]`, `(`, `\(`, `)`, `\)`, `@`, "@​", `<`, "<​",
)
return clip(r.Replace(name), 80)
}
+594
View File
@@ -0,0 +1,594 @@
package security
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
var ctx = context.Background()
// testEngine is a guild (id 100) owned by user 1, with Velox as user 999,
// alerts going to channel 500 and pinging role 201.
func testEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore, *time.Time) {
t.Helper()
d, st := newDiscord(), newStore()
e := New(d, st, "999")
e.syncDMs = true
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
e.now = func() time.Time { return now }
s := st.set(100)
s.AlertChannelID, s.AlertRoleID = 500, 201
return e, d, st, &now
}
func refresh(e *Engine) { e.Invalidate(guild) }
// snowflake for an account created at t.
func snowflakeAt(t time.Time, n int) string {
ms := t.UnixMilli() - 1420070400000
return fmt.Sprint(ms<<22 | int64(n))
}
func entry(action discordgo.AuditLogAction, user, target string, changes ...*discordgo.AuditLogChange) *discordgo.AuditLogEntry {
return &discordgo.AuditLogEntry{ActionType: &action, UserID: user, TargetID: target, Changes: changes}
}
func change(key discordgo.AuditLogChangeKey, old, new any) *discordgo.AuditLogChange {
return &discordgo.AuditLogChange{Key: &key, OldValue: old, NewValue: new}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// ---- Join Gate ----
func TestCheckJoin(t *testing.T) {
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
s := securityrepo.Defaults(1)
s.JoinGateEnabled = true
s.NoAvatar, s.NewAccount, s.AdvertisingName = securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan
s.UsernameFilter, s.UsernamePatterns = securityrepo.ActionKick, []string{" FreeNitro "}
s.UnverifiedBot = securityrepo.ActionKick
old := snowflakeAt(now.AddDate(-1, 0, 0), 1)
young := snowflakeAt(now.Add(-48*time.Hour), 2)
for _, c := range []struct {
name string
u *discordgo.User
want string
}{
{"clean", &discordgo.User{ID: old, Username: "ana", Avatar: "x"}, "off"},
{"no avatar", &discordgo.User{ID: old, Username: "ana"}, "timeout"},
{"new account beats no avatar", &discordgo.User{ID: young, Username: "ana"}, "kick"},
{"invite in display name", &discordgo.User{ID: old, Username: "ana", GlobalName: "join DISCORD.GG/abc", Avatar: "x"}, "ban"},
{"blocked word, case-insensitive", &discordgo.User{ID: old, Username: "freenitro_giveaway", Avatar: "x"}, "kick"},
{"verified bot passes", &discordgo.User{ID: young, Bot: true, PublicFlags: discordgo.UserFlagVerifiedBot}, "off"},
{"unverified bot", &discordgo.User{ID: young, Bot: true}, "kick"},
} {
if got := CheckJoin(s, c.u, now).Action; got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
}
s.JoinGateEnabled = false
if CheckJoin(s, &discordgo.User{ID: young}, now).Action != "off" {
t.Error("a disabled Join Gate must not act")
}
}
func TestJoinGateAndJoinLock(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.JoinGateEnabled, s.NewAccount = true, securityrepo.ActionKick
refresh(e)
young := d.addMember(snowflakeAt(now.Add(-time.Hour), 5))
if removed := e.OnMemberJoin(ctx, guild, young); !removed || !contains(d.kicked, young.User.ID) {
t.Errorf("new account should be kicked: removed=%v kicked=%v", removed, d.kicked)
}
if d.sentCount() != 1 || !contains(st.eventKinds(), securityrepo.EventJoinGate) {
t.Error("the action should be alerted and recorded")
}
st.set(100).JoinLock = securityrepo.ActionBan
refresh(e)
m := d.addMember(snowflakeAt(now.AddDate(-2, 0, 0), 6))
if !e.OnMemberJoin(ctx, guild, m) || !contains(d.banned, m.User.ID) {
t.Error("a join lock bans everyone new")
}
}
func TestOwnerAndBotAreNeverPunished(t *testing.T) {
e, d, st, _ := testEngine(t)
st.set(100).JoinLock = securityrepo.ActionBan
refresh(e)
if e.OnMemberJoin(ctx, guild, d.members["1"]) || len(d.banned) != 0 {
t.Error("the owner must never be banned")
}
if err := e.Quarantine(ctx, guild, "1", "x"); err == nil {
t.Error("the owner must never be quarantined")
}
if err := e.Quarantine(ctx, guild, "999", "x"); err == nil {
t.Error("the bot must never quarantine itself")
}
}
// ---- Raids ----
func TestRaidModeStartsActsAndEnds(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidJoinSeconds, s.RaidAction, s.RaidQuietMinutes = true, 3, 10, securityrepo.ActionKick, 5
refresh(e)
old := now.AddDate(-1, 0, 0)
join := func(n int) *discordgo.Member {
m := d.addMember(snowflakeAt(old, n))
e.OnMemberJoin(ctx, guild, m)
return m
}
a := join(1)
*now = now.Add(2 * time.Second)
join(2)
if len(d.kicked) != 0 || e.RaidActive(guild) {
t.Fatal("two joins are not a raid")
}
*now = now.Add(2 * time.Second)
join(3)
if !e.RaidActive(guild) || len(d.kicked) != 3 || !contains(d.kicked, a.User.ID) {
t.Fatalf("the third join starts a raid and the whole burst is kicked: %v", d.kicked)
}
if len(d.requests) != 1 || !strings.Contains(d.requests[0], "/incident-actions") || !strings.Contains(d.requests[0], "2026-10-21") {
t.Errorf("invites should be paused for 24h: %v", d.requests)
}
alert := d.channelSends()[0]
if alert.Content != "<@&201>" || strings.Join(alert.AllowedMentions.Roles, ",") != "201" || len(alert.AllowedMentions.Users) != 0 {
t.Errorf("a raid pings only the alert role: %+v", alert)
}
// During the raid every join is acted on.
*now = now.Add(time.Minute)
join(4)
if len(d.kicked) != 4 {
t.Errorf("joins during a raid are kicked: %v", d.kicked)
}
// Quiet for 5 minutes: it ends, invites resume, the list is posted.
*now = now.Add(6 * time.Minute)
e.checkRaids(ctx)
if e.RaidActive(guild) {
t.Fatal("the raid should end after the quiet period")
}
if len(d.requests) != 2 || !strings.Contains(d.requests[1], "invites_disabled_until:<nil>") {
t.Errorf("invites should be resumed: %v", d.requests)
}
var list string
for name, content := range d.files {
if strings.HasPrefix(name, "raid-") {
list = content
}
}
if strings.Count(list, "\tkick\t") != 4 {
t.Errorf("the summary should list the 4 kicked accounts:\n%s", list)
}
if err := e.EndRaid(ctx, guild, "1"); err == nil {
t.Error("ending twice should say there's no raid")
}
}
func TestNoAlertFloodDuringARaid(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.JoinGateEnabled, s.NoAvatar = true, securityrepo.ActionKick
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidPauseInvites = true, 3, securityrepo.ActionKick, false
refresh(e)
for i := 0; i < 50; i++ {
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), i)}}) // no avatar
}
if n := d.sentCount(); n > 4 {
t.Errorf("a raid of 50 should produce a handful of alerts, got %d", n)
}
}
func TestRaidAlertOnlyTouchesNobody(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidPauseInvites = true, 3, securityrepo.ActionAlert, false
refresh(e)
for i := 0; i < 5; i++ {
e.OnMemberJoin(ctx, guild, d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), i)))
}
if !e.RaidActive(guild) || len(d.kicked)+len(d.banned) != 0 || len(d.requests) != 0 {
t.Error("alert-only raid mode must not remove anyone or pause invites")
}
}
func TestRaidMemoryIsBounded(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction = true, 3, securityrepo.ActionAlert
refresh(e)
for i := 0; i < maxRecentJoins+300; i++ {
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), i), Avatar: "a"}})
}
r := e.raidFor(guild)
if len(r.joins) > maxRecentJoins {
t.Errorf("recent joins not capped: %d", len(r.joins))
}
_ = d
}
// ---- Anti-nuke ----
func nukeEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore) {
e, d, st, _ := testEngine(t)
st.set(100).AntiNukeEnabled = true
refresh(e)
d.addMember("50", "200", "202", "204") // a rogue admin with a booster role
return e, d, st
}
func TestNukeOverTheLimitIsQuarantined(t *testing.T) {
e, d, st := nukeEngine(t)
// Limit is 2 channel deletes per 10s.
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c1"))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c2"))
if _, ok := st.quarantines[key(100, 50)]; ok {
t.Fatal("two deletes are within the limit")
}
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c3"))
q, ok := st.quarantines[key(100, 50)]
if !ok || fmt.Sprint(q.PreviousRoleIDs) != "[200 202]" {
t.Fatalf("roles should be saved (not the managed one): %+v", q)
}
if got := strings.Join(d.members["50"].Roles, ","); got != "204" {
t.Errorf("only the unremovable booster role should remain, got %s", got)
}
if d.timeouts["50"] == nil {
t.Error("the nuker should be timed out")
}
sends := d.channelSends()
last := sends[len(sends)-1]
if !strings.Contains(last.Embeds[0].Title, "Nuke attempt stopped") || !contains(last.AllowedMentions.Users, "1") {
t.Errorf("the owner should be pinged: %+v", last)
}
// The next delete within a minute doesn't trigger a second response.
before := len(d.sent)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c4"))
if len(d.sent) != before {
t.Error("one attempt should be answered once")
}
}
func TestTrustedOwnerAndBotAreExempt(t *testing.T) {
e, d, st := nukeEngine(t)
d.addMember("60", "201")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 50, Kind: "user"}, {TargetID: 201, Kind: "role"}}
refresh(e)
for _, who := range []string{"50", "60", "1", "999"} {
for i := 0; i < 5; i++ {
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberBanAdd, who, "x"))
}
}
if len(st.quarantines) != 0 {
t.Errorf("trusted users, trusted roles, the owner and the bot are exempt: %v", st.quarantines)
}
}
func TestInstantTriggers(t *testing.T) {
for name, ent := range map[string]*discordgo.AuditLogEntry{
"prune": entry(discordgo.AuditLogActionMemberPrune, "50", ""),
"vanity": entry(discordgo.AuditLogActionGuildUpdate, "50", guild, change("vanity_url_code", "old", "scam")),
"perms": entry(discordgo.AuditLogActionRoleUpdate, "50", "202", change("permissions", "0", fmt.Sprint(discordgo.PermissionAdministrator))),
"grant": entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$add", nil, []any{map[string]any{"id": "200"}})),
"newrole": entry(discordgo.AuditLogActionRoleCreate, "50", "x", change("permissions", nil, fmt.Sprint(discordgo.PermissionManageRoles))),
"botrole": entry(discordgo.AuditLogActionRoleUpdate, "50", "299", change("name", "Velox", "x")),
} {
t.Run(name, func(t *testing.T) {
e, d, st := nukeEngine(t)
d.addMember("70")
e.OnAuditLogEntry(ctx, guild, ent)
if _, ok := st.quarantines[key(100, 50)]; !ok {
t.Errorf("%s should quarantine at once", name)
}
})
}
}
func TestHarmlessChangesAreIgnored(t *testing.T) {
e, d, st := nukeEngine(t)
d.addMember("70")
// Renaming a role, removing permissions, giving a plain role.
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionRoleUpdate, "50", "202", change("permissions", fmt.Sprint(discordgo.PermissionAdministrator), "0")))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$add", nil, []any{map[string]any{"id": "202"}})))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelUpdate, "50", "c"))
if len(st.quarantines) != 0 {
t.Error("harmless changes must not trigger anything")
}
}
func TestHarmlessEntriesCostNoLookups(t *testing.T) {
e, d, _ := nukeEngine(t)
d.lookups = 0
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMessageDelete, "50", "x"))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberUpdate, "50", "x"))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$remove", nil, []any{map[string]any{"id": "202"}})))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionGuildUpdate, "50", guild, change("name", "a", "b")))
if d.lookups != 0 {
t.Errorf("harmless entries made %d Discord lookups", d.lookups)
}
}
func TestSelfGrantedTrustedRoleIsNotABypass(t *testing.T) {
e, d, st := nukeEngine(t)
st.trusted[100] = []securityrepo.Trusted{{TargetID: 201, Kind: "role"}}
refresh(e)
// The rogue admin gives themselves the trusted role...
d.members["50"].Roles = append(d.members["50"].Roles, "201")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "50", change("$add", nil, []any{map[string]any{"id": "201"}})))
if _, ok := st.quarantines[key(100, 50)]; !ok {
t.Fatal("granting yourself a trusted role must be stopped, not count as trust")
}
if contains(d.members["50"].Roles, "201") {
t.Error("the trusted role should be gone")
}
}
func TestTrustedRoleGrantedToAnAltIsTakenBack(t *testing.T) {
e, d, st := nukeEngine(t)
st.trusted[100] = []securityrepo.Trusted{{TargetID: 201, Kind: "role"}}
refresh(e)
d.addMember("71", "201")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "71", change("$add", nil, []any{map[string]any{"id": "201"}})))
if _, ok := st.quarantines[key(100, 50)]; !ok {
t.Error("the granter should be quarantined")
}
if contains(d.members["71"].Roles, "201") {
t.Error("the alt should lose the trusted role")
}
}
func TestNukingBotIsKicked(t *testing.T) {
e, d, _ := nukeEngine(t)
d.members["80"] = &discordgo.Member{User: &discordgo.User{ID: "80", Bot: true}}
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "80", ""))
if !contains(d.kicked, "80") {
t.Error("a nuking bot should be kicked")
}
}
func TestAntiNukeOffDoesNothing(t *testing.T) {
e, _, st := nukeEngine(t)
st.set(100).AntiNukeEnabled = false
refresh(e)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
if len(st.quarantines) != 0 {
t.Error("anti-nuke off must not act")
}
}
func TestUnauthorizedBotAdd(t *testing.T) {
e, d, st, _ := testEngine(t)
st.set(100).JoinGateEnabled = true
refresh(e)
d.addMember("50", "200")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionBotAdd, "50", "81"))
if !contains(d.kicked, "81") {
t.Error("a bot added by someone untrusted should be kicked")
}
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionBotAdd, "1", "82"))
if contains(d.kicked, "82") {
t.Error("the owner may add bots")
}
}
func TestDangerousQuarantineRoleIsNotAssigned(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).QuarantineRoleID = 200 // misconfigured: the Admin role
refresh(e)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
if contains(d.members["50"].Roles, "200") {
t.Error("a quarantine role with dangerous permissions must never be assigned")
}
st.set(100).QuarantineRoleID = 203
refresh(e)
d.addMember("51", "201")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "51", ""))
if !contains(d.members["51"].Roles, "203") {
t.Error("a safe quarantine role should be assigned")
}
}
func TestQuarantineRoleVeloxCantAssignDoesNotBlockQuarantine(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).QuarantineRoleID = 203
refresh(e)
d.rejectRole = "203" // e.g. moved above Velox's role after it was configured
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
if got := strings.Join(d.members["50"].Roles, ","); got != "204" {
t.Errorf("roles must still be removed without the quarantine role, got %s", got)
}
if d.timeouts["50"] == nil {
t.Error("and the member still timed out")
}
}
func TestReleaseGivesRolesBack(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).QuarantineRoleID = 203
refresh(e)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
// The Mod role was deleted meanwhile... no wait, Member (202) was.
d.roles = append(d.roles[:3], d.roles[4:]...)
if err := e.Release(ctx, guild, "50", "1"); err != nil {
t.Fatal(err)
}
got := strings.Join(d.members["50"].Roles, ",")
if got != "204,200" {
t.Errorf("roles after release = %s (want the booster role kept, Admin back, deleted Member skipped, quarantine role gone)", got)
}
if d.timeouts["50"] != nil {
t.Error("the timeout should be removed")
}
if _, ok := st.quarantines[key(100, 50)]; ok {
t.Error("the quarantine record should be gone")
}
if err := e.Release(ctx, guild, "50", "1"); err == nil {
t.Error("releasing twice should fail")
}
}
// ---- Lockdown ----
func TestLockAndUnlockRestoreExactly(t *testing.T) {
e, d, _, _ := testEngine(t)
d.channels["300"] = &discordgo.Channel{ID: "300", GuildID: guild, Type: discordgo.ChannelTypeGuildText,
PermissionOverwrites: []*discordgo.PermissionOverwrite{{ID: guild, Type: discordgo.PermissionOverwriteTypeRole,
Allow: discordgo.PermissionAddReactions, Deny: discordgo.PermissionAttachFiles}}}
d.channels["301"] = &discordgo.Channel{ID: "301", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
d.channels["302"] = &discordgo.Channel{ID: "302", GuildID: guild, Type: discordgo.ChannelTypeGuildCategory}
d.channels["400"] = &discordgo.Channel{ID: "400", GuildID: "other", Type: discordgo.ChannelTypeGuildText}
if err := e.LockChannel(ctx, guild, "400", "1", false); err == nil {
t.Fatal("a channel of another server must never be touched")
}
locked, failed, err := e.LockServer(ctx, guild, "1")
if err != nil || locked != 2 || failed != 0 {
t.Fatalf("locked=%d failed=%d err=%v (categories are skipped)", locked, failed, err)
}
o := d.channels["300"].PermissionOverwrites[0]
if o.Deny&discordgo.PermissionSendMessages == 0 || o.Allow&discordgo.PermissionAddReactions != 0 || o.Deny&discordgo.PermissionAttachFiles == 0 {
t.Errorf("lock should deny chat and keep other bits: %+v", o)
}
// Someone changes an unrelated bit during the lockdown; it must survive.
o.Allow |= discordgo.PermissionEmbedLinks
// Locking again must not overwrite the saved "before" state.
_ = e.LockChannel(ctx, guild, "300", "1", false)
if _, _, err := e.UnlockServer(ctx, guild, "1"); err != nil {
t.Fatal(err)
}
o = d.channels["300"].PermissionOverwrites[0]
if o.Allow != discordgo.PermissionAddReactions|discordgo.PermissionEmbedLinks || o.Deny != discordgo.PermissionAttachFiles {
t.Errorf("unlock should restore the original lock bits and keep the new one: allow=%d deny=%d", o.Allow, o.Deny)
}
if len(d.channels["301"].PermissionOverwrites) != 0 {
t.Error("a channel that had no overwrite should have none again")
}
if _, _, err := e.UnlockServer(ctx, guild, "1"); err == nil {
t.Error("unlocking an unlocked server should say so")
}
}
// ---- Configuration rules ----
func TestConfigurationPermissions(t *testing.T) {
e, d, st, _ := testEngine(t)
admin := d.addMember("50", "200")
owner := d.members["1"]
trustedUser := d.addMember("60")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
refresh(e)
// Turning on: Manage Server is enough. Turning off: owner or trusted only.
if err := e.SetFeature(ctx, guild, admin, discordgo.PermissionManageGuild, securityrepo.FeatureAntiNuke, true); err != nil {
t.Errorf("an admin may turn anti-nuke on: %v", err)
}
if err := e.SetFeature(ctx, guild, admin, discordgo.PermissionAdministrator, securityrepo.FeatureAntiNuke, false); !errors.Is(err, ErrNotTrusted) {
t.Errorf("an untrusted admin must not turn anti-nuke off: %v", err)
}
if err := e.SetFeature(ctx, guild, trustedUser, 0, securityrepo.FeatureAntiNuke, false); err != nil {
t.Errorf("a trusted user may: %v", err)
}
if err := e.SetFeature(ctx, guild, d.addMember("61"), 0, securityrepo.FeatureJoinGate, true); err == nil {
t.Error("a regular member must not turn things on")
}
if err := e.SetFeature(ctx, guild, nil, discordgo.PermissionAdministrator, securityrepo.FeatureJoinGate, true); err == nil {
t.Error("no actor, no change")
}
// Alerts: trusted only, never @everyone, only this server's channels.
d.channels["500"] = &discordgo.Channel{ID: "500", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
d.channels["501"] = &discordgo.Channel{ID: "501", GuildID: "other", Type: discordgo.ChannelTypeGuildText}
if err := e.SetAlerts(ctx, guild, admin, "500", "201"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("untrusted alert change: %v", err)
}
if err := e.SetAlerts(ctx, guild, owner, "500", guild); err == nil {
t.Error("alerts must never ping @everyone")
}
if err := e.SetAlerts(ctx, guild, owner, "501", ""); err == nil {
t.Error("another server's channel must be refused")
}
if err := e.SetAlerts(ctx, guild, owner, "500", "201"); err != nil {
t.Error(err)
}
// Quarantine role: safe roles below Velox only.
for role, ok := range map[string]bool{"200": false, guild: false, "204": false, "299": false, "203": true} {
err := e.SetQuarantineRole(ctx, guild, owner, role)
if (err == nil) != ok {
t.Errorf("quarantine role %s: err=%v", role, err)
}
}
// Trust: owner only, never @everyone, bounded.
if err := e.Trust(ctx, guild, trustedUser, "user", "50"); !errors.Is(err, ErrNotOwner) {
t.Errorf("trusted users must not spread trust: %v", err)
}
if err := e.Trust(ctx, guild, owner, "role", guild); err == nil {
t.Error("trusting @everyone must be refused")
}
if err := e.Trust(ctx, guild, owner, "user", "404"); err == nil {
t.Error("trusting someone not in the server must be refused")
}
if err := e.Trust(ctx, guild, owner, "role", "201"); err != nil {
t.Error(err)
}
if err := e.Untrust(ctx, guild, admin, "201"); !errors.Is(err, ErrNotOwner) {
t.Errorf("only the owner untrusts: %v", err)
}
}
func TestDashboardCommands(t *testing.T) {
e, d, st := nukeEngine(t)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
st.cmds = []securityrepo.Command{
{ID: 1, GuildID: 100, CommandType: "release_quarantine", Payload: []byte(`{"user_id":"50; DROP"}`), RequestedBy: 1},
{ID: 2, GuildID: 100, CommandType: "release_quarantine", Payload: []byte(`{"user_id":"50"}`), RequestedBy: 1},
{ID: 3, GuildID: 100, CommandType: "nope", RequestedBy: 1},
}
e.ProcessCommands(ctx)
if _, ok := st.quarantines[key(100, 50)]; ok {
t.Error("the valid release should have run")
}
if len(st.done) != 3 {
t.Errorf("every command must be marked done: %v", st.done)
}
_ = d
}
func TestSafeNameDefusesMentionsAndMarkdown(t *testing.T) {
got := safeName(&discordgo.User{Username: "x", GlobalName: "@everyone **hi** <@&1> [link](http://a)"})
if strings.Contains(got, "@everyone") || strings.Contains(got, "<@&") || strings.Contains(got, "**") || strings.Contains(got, "](") {
t.Errorf("unsafe name rendering: %q", got)
}
}
+6
View File
@@ -0,0 +1,6 @@
package security
import "github.com/bwmarrin/discordgo"
// The real session must provide everything the system calls.
var _ Discord = (*discordgo.Session)(nil)
+247
View File
@@ -0,0 +1,247 @@
package tickets
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// fakeStore is an in-memory ticketrepo with the same rules as the SQL.
type fakeStore struct {
mu sync.Mutex
settings map[int64]*ticketrepo.Settings
cats []ticketrepo.Category
tickets []*ticketrepo.Ticket
next map[int64]int
cmds []ticketrepo.Command
done []int64
}
func newStore() *fakeStore {
return &fakeStore{settings: map[int64]*ticketrepo.Settings{}, next: map[int64]int{}}
}
func (f *fakeStore) configure(guild, channel, logCh int64) {
f.settings[guild] = &ticketrepo.Settings{
GuildID: guild, Enabled: true, ChannelID: channel, LogChannelID: logCh,
PanelTitle: ticketrepo.DefaultPanelTitle, PanelDescription: ticketrepo.DefaultPanelDescription,
MaxOpenPerUser: 1, Configured: true,
}
}
func (f *fakeStore) addCat(c ticketrepo.Category) ticketrepo.Category {
c.ID = int64(len(f.cats) + 1)
f.cats = append(f.cats, c)
return c
}
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*ticketrepo.Settings, error) {
if s, ok := f.settings[g]; ok {
cp := *s
return &cp, nil
}
return &ticketrepo.Settings{GuildID: g, Enabled: true, PanelTitle: ticketrepo.DefaultPanelTitle, MaxOpenPerUser: 1}, nil
}
func (f *fakeStore) SetPanelMessage(_ context.Context, g, m int64) error {
f.settings[g].PanelMessageID = m
return nil
}
func (f *fakeStore) NextNumber(_ context.Context, g int64) (int, error) {
f.next[g]++
return f.next[g], nil
}
func (f *fakeStore) ListCategories(_ context.Context, g int64) ([]ticketrepo.Category, error) {
var out []ticketrepo.Category
for _, c := range f.cats {
if c.GuildID == g {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) GetCategory(_ context.Context, g, id int64) (*ticketrepo.Category, error) {
for _, c := range f.cats {
if c.GuildID == g && c.ID == id {
cp := c
return &cp, nil
}
}
return nil, nil
}
func (f *fakeStore) CountOpenByOpener(_ context.Context, g, u int64) (int, error) {
n := 0
for _, t := range f.tickets {
if t.GuildID == g && t.OpenerID == u && t.Status == ticketrepo.StatusOpen {
n++
}
}
return n, nil
}
func (f *fakeStore) CreateTicket(_ context.Context, t ticketrepo.Ticket) (int64, error) {
t.ID = int64(len(f.tickets) + 1)
t.Status = ticketrepo.StatusOpen
t.CreatedAt = time.Now().Add(-90 * time.Minute)
f.tickets = append(f.tickets, &t)
return t.ID, nil
}
func (f *fakeStore) byThread(th int64) *ticketrepo.Ticket {
for _, t := range f.tickets {
if t.ThreadID == th {
return t
}
}
return nil
}
func (f *fakeStore) GetTicketByThread(_ context.Context, th int64) (*ticketrepo.Ticket, error) {
if t := f.byThread(th); t != nil {
cp := *t
return &cp, nil
}
return nil, nil
}
func (f *fakeStore) Claim(_ context.Context, th, staff int64) (int64, error) {
t := f.byThread(th)
if t == nil || t.Status != ticketrepo.StatusOpen {
return 0, nil
}
if t.ClaimedBy == 0 {
t.ClaimedBy = staff
}
return t.ClaimedBy, nil
}
func (f *fakeStore) Close(_ context.Context, th, by int64, reason string) (bool, error) {
t := f.byThread(th)
if t == nil || t.Status != ticketrepo.StatusOpen {
return false, nil
}
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, by, reason, time.Now()
return true, nil
}
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]ticketrepo.Command, error) {
var out []ticketrepo.Command
for _, c := range f.cmds {
done := false
for _, d := range f.done {
done = done || d == c.ID
}
if !done {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
f.done = append(f.done, id)
return nil
}
// fakeDiscord records what the flows ask Discord to do.
type fakeDiscord struct {
mu sync.Mutex
nextID int
threads []*discordgo.ThreadStart
members map[string][]string // thread -> user IDs
sent map[string][]*discordgo.MessageSend
edits []*discordgo.MessageEdit
chEdits map[string]*discordgo.ChannelEdit
deleted []string
files map[string]string // file name -> contents
history map[string][]*discordgo.Message
messages map[string]*discordgo.Message // by "channel/message"
failSend error
failEdit error
failFetch error
}
func newDiscord() *fakeDiscord {
return &fakeDiscord{
nextID: 1000, members: map[string][]string{}, sent: map[string][]*discordgo.MessageSend{},
chEdits: map[string]*discordgo.ChannelEdit{}, files: map[string]string{},
history: map[string][]*discordgo.Message{}, messages: map[string]*discordgo.Message{},
}
}
func (f *fakeDiscord) newID() string {
f.nextID++
return fmt.Sprint(f.nextID)
}
func (f *fakeDiscord) ThreadStartComplex(ch string, data *discordgo.ThreadStart, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.threads = append(f.threads, data)
return &discordgo.Channel{ID: f.newID(), ParentID: ch, Name: data.Name, Type: data.Type}, nil
}
func (f *fakeDiscord) ThreadMemberAdd(th, u string, _ ...discordgo.RequestOption) error {
f.members[th] = append(f.members[th], u)
return nil
}
func (f *fakeDiscord) ThreadMemberRemove(th, u string, _ ...discordgo.RequestOption) error {
var keep []string
for _, m := range f.members[th] {
if m != u {
keep = append(keep, m)
}
}
f.members[th] = keep
return nil
}
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
if f.failSend != nil {
return nil, f.failSend
}
for _, file := range data.Files {
raw, _ := io.ReadAll(file.Reader)
f.files[file.Name] = string(raw)
}
f.sent[ch] = append(f.sent[ch], data)
m := &discordgo.Message{ID: f.newID(), ChannelID: ch, Content: data.Content, Embeds: data.Embeds, Components: data.Components}
f.messages[ch+"/"+m.ID] = m
return m, nil
}
func (f *fakeDiscord) ChannelMessageEditComplex(e *discordgo.MessageEdit, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
if f.failEdit != nil {
return nil, f.failEdit
}
f.edits = append(f.edits, e)
return &discordgo.Message{ID: e.ID, ChannelID: e.Channel}, nil
}
func (f *fakeDiscord) ChannelMessage(ch, m string, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
if msg, ok := f.messages[ch+"/"+m]; ok {
return msg, nil
}
return nil, errors.New("unknown message")
}
func (f *fakeDiscord) ChannelMessages(ch string, limit int, before, _, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) {
if f.failFetch != nil {
return nil, f.failFetch
}
// history is stored newest first, like Discord returns it.
all := f.history[ch]
start := 0
if before != "" {
for i, m := range all {
if m.ID == before {
start = i + 1
}
}
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], nil
}
func (f *fakeDiscord) ChannelEditComplex(ch string, data *discordgo.ChannelEdit, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.chEdits[ch] = data
return &discordgo.Channel{ID: ch}, nil
}
func (f *fakeDiscord) ChannelDelete(ch string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.deleted = append(f.deleted, ch)
return &discordgo.Channel{ID: ch}, nil
}
+63
View File
@@ -0,0 +1,63 @@
package tickets
import (
"context"
"errors"
"fmt"
"log"
"github.com/jackc/pgx/v5"
)
// StartCommandListener waits for the dashboard's requests (ticket_commands
// rows, announced with NOTIFY) and carries them out. It returns when the
// connection fails or ctx ends.
func StartCommandListener(ctx context.Context, connString string, d Discord, st Store) error {
conn, err := pgx.Connect(ctx, connString)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, "LISTEN ticket_commands"); err != nil {
return fmt.Errorf("listen: %w", err)
}
// Anything queued while the bot was down.
ProcessCommands(ctx, d, st)
for {
if _, err := conn.WaitForNotification(ctx); err != nil {
return fmt.Errorf("waiting for notifications: %w", err)
}
ProcessCommands(ctx, d, st)
}
}
// ProcessCommands carries out every pending command once. A command that
// fails is still marked done: retrying a panel post that failed for missing
// permissions would only fail again, and the error is logged.
func ProcessCommands(ctx context.Context, d Discord, st Store) {
cmds, err := st.ListUnprocessedCommands(ctx)
if err != nil {
log.Printf("tickets: listing dashboard commands: %v", err)
return
}
for _, c := range cmds {
switch c.CommandType {
case "post_panel":
if _, err := PostPanel(ctx, d, st, c.GuildID); err != nil {
var ue UserError
if errors.As(err, &ue) {
log.Printf("tickets: posting the panel for guild %d from the dashboard: %s", c.GuildID, ue)
} else {
log.Printf("tickets: posting the panel for guild %d from the dashboard: %v", c.GuildID, err)
}
}
default:
log.Printf("tickets: unknown dashboard command %q (%d)", c.CommandType, c.ID)
}
if err := st.MarkCommandProcessed(ctx, c.ID); err != nil {
log.Printf("tickets: marking command %d done: %v", c.ID, err)
}
}
}
@@ -0,0 +1,71 @@
//go:build integration
package tickets
import (
"context"
"database/sql"
"os"
"testing"
"time"
"velox-bot/internal/db/repos/ticketrepo"
_ "github.com/jackc/pgx/v5/stdlib"
)
// The dashboard's request reaches the bot through the real trigger and
// LISTEN/NOTIFY:
//
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/tickets
func TestListenerPostsPanelOnNotify(t *testing.T) {
dsn := os.Getenv("TICKETS_TEST_DB")
if dsn == "" {
t.Skip("TICKETS_TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
const g = 515151
for _, q := range []string{`DELETE FROM ticket_commands`, `DELETE FROM ticket_categories WHERE guild_id = $1`, `DELETE FROM ticket_settings WHERE guild_id = $1`} {
if q == `DELETE FROM ticket_commands` {
_, err = db.Exec(q)
} else {
_, err = db.Exec(q, g)
}
if err != nil {
t.Fatal(err)
}
}
repo := ticketrepo.NewRepo(db)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := repo.SetChannels(ctx, g, 10, 0); err != nil {
t.Fatal(err)
}
if _, err := repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support"}); err != nil {
t.Fatal(err)
}
d := newDiscord()
go func() { _ = StartCommandListener(ctx, dsn, d, repo) }()
time.Sleep(500 * time.Millisecond) // let LISTEN start
if _, err := db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 1)`, g); err != nil {
t.Fatal(err)
}
for ctx.Err() == nil {
s, _ := repo.GetSettings(ctx, g)
if s.PanelMessageID != 0 {
cmds, _ := repo.ListUnprocessedCommands(ctx)
if len(cmds) != 0 {
t.Errorf("command not marked processed: %+v", cmds)
}
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("the panel was never posted")
}
+271
View File
@@ -0,0 +1,271 @@
package tickets
import (
"bytes"
"context"
"fmt"
"log"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// Actor is whoever pressed the button or ran the command, with their
// permissions in the ticket thread.
type Actor struct {
Member *discordgo.Member
Perms int64
}
func (a Actor) userID() string {
if a.Member == nil || a.Member.User == nil {
return ""
}
return a.Member.User.ID
}
// load finds the open ticket behind a thread and its category (nil when the
// category was deleted since).
func load(ctx context.Context, st Store, threadID string) (*ticketrepo.Ticket, *ticketrepo.Category, error) {
t, err := st.GetTicketByThread(ctx, mustID(threadID))
if err != nil {
return nil, nil, err
}
if t == nil {
return nil, nil, ErrNotATicket
}
if t.Status != ticketrepo.StatusOpen {
return nil, nil, ErrClosed
}
var cat *ticketrepo.Category
if t.CategoryID != 0 {
if cat, err = st.GetCategory(ctx, t.GuildID, t.CategoryID); err != nil {
return nil, nil, err
}
}
return t, cat, nil
}
// updateIntro edits the ticket's first message to show who claimed it and
// whether it is closed. It keeps the original text (the details are only in
// that message) and is best effort: a deleted first message doesn't stop the
// ticket from being claimed or closed.
func updateIntro(d Discord, t *ticketrepo.Ticket) {
if t.IntroMessage == 0 {
return
}
msg, err := d.ChannelMessage(id(t.ThreadID), id(t.IntroMessage))
if err != nil || len(msg.Embeds) == 0 {
return
}
fresh := IntroEmbed(t, "")
e := *msg.Embeds[0]
e.Color, e.Footer = fresh.Color, fresh.Footer
e.Fields = fresh.Fields
embeds := []*discordgo.MessageEmbed{&e}
comps := IntroComponents(t.ClaimedBy != 0, t.Status == ticketrepo.StatusClosed)
if _, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: id(t.ThreadID), ID: id(t.IntroMessage), Embeds: &embeds, Components: &comps,
}); err != nil {
log.Printf("tickets: updating the first message of ticket %d: %v", t.ID, err)
}
}
// Claim marks the actor as the ticket's handler. Only staff can claim, and a
// claimed ticket stays with its first claimer.
func Claim(ctx context.Context, d Discord, st Store, threadID string, a Actor) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if !IsStaff(a.Member, a.Perms, cat) {
return ErrNotStaff
}
me := mustID(a.userID())
holder, err := st.Claim(ctx, t.ThreadID, me)
if err != nil {
return err
}
if holder == 0 {
return ErrClosed
}
if holder != me {
return UserError(fmt.Sprintf("<@%d> is already handling this ticket.", holder))
}
t.ClaimedBy = me
updateIntro(d, t)
_, err = d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
Content: fmt.Sprintf("<@%d> is handling this ticket.", me),
AllowedMentions: &discordgo.MessageAllowedMentions{},
})
return err
}
// CheckCanClose runs before the close form is shown.
func CheckCanClose(ctx context.Context, st Store, threadID string, a Actor) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
return ErrNotInvolved
}
return nil
}
// Close closes the ticket: a note in the thread, a summary with the
// transcript in the log channel, the first message's buttons disabled, and
// the thread locked and archived. The opener or staff can close.
func Close(ctx context.Context, d Discord, st Store, threadID string, a Actor, reason string) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
me := mustID(a.userID())
if me != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
return ErrNotInvolved
}
reason = clip(reason, maxReason)
// The transcript is taken before anything else changes in the thread.
transcript, count, terr := Transcript(d, t)
closed, err := st.Close(ctx, t.ThreadID, me, reason)
if err != nil {
return err
}
if !closed {
return ErrClosed // someone else closed it a moment ago
}
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, me, reason, time.Now()
note := fmt.Sprintf("🔒 Closed by <@%d>.", me)
if reason != "" {
note += " Reason: " + reason
}
if _, err := d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
Content: note, AllowedMentions: &discordgo.MessageAllowedMentions{},
}); err != nil {
log.Printf("tickets: posting the close note in ticket %d: %v", t.ID, err)
}
if terr != nil {
log.Printf("tickets: transcript of ticket %d: %v", t.ID, terr)
}
postLog(ctx, d, st, t, transcript, count)
updateIntro(d, t)
yes := true
if _, err := d.ChannelEditComplex(id(t.ThreadID), &discordgo.ChannelEdit{Locked: &yes, Archived: &yes}); err != nil {
return fmt.Errorf("locking the thread: %w", err)
}
return nil
}
// postLog sends the closed ticket's summary and transcript to the log
// channel, if the guild has one.
func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, transcript []byte, count int) {
s, err := st.GetSettings(ctx, t.GuildID)
if err != nil || s.LogChannelID == 0 {
return
}
claimed := "Nobody"
if t.ClaimedBy != 0 {
claimed = "<@" + id(t.ClaimedBy) + ">"
}
reason := t.CloseReason
if reason == "" {
reason = "No reason given"
}
embed := &discordgo.MessageEmbed{
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
Description: "**" + clip(t.Subject, maxSubject) + "**",
Color: accent,
Fields: []*discordgo.MessageEmbedField{
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
{Name: "Claimed by", Value: claimed, Inline: true},
{Name: "Closed by", Value: "<@" + id(t.ClosedBy) + ">", Inline: true},
{Name: "Reason", Value: clip(reason, 1024)},
{Name: "Thread", Value: "<#" + id(t.ThreadID) + ">", Inline: true},
{Name: "Messages", Value: fmt.Sprint(count), Inline: true},
{Name: "Open for", Value: humanDuration(t.ClosedAt.Sub(t.CreatedAt)), Inline: true},
},
Timestamp: t.ClosedAt.UTC().Format(time.RFC3339),
}
send := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{embed}, AllowedMentions: &discordgo.MessageAllowedMentions{}}
if len(transcript) > 0 {
send.Files = []*discordgo.File{{
Name: fmt.Sprintf("ticket-%04d.txt", t.Number),
ContentType: "text/plain; charset=utf-8",
Reader: bytes.NewReader(transcript),
}}
}
if _, err := d.ChannelMessageSendComplex(id(s.LogChannelID), send); err != nil {
log.Printf("tickets: posting ticket %d to the log channel: %v", t.ID, err)
}
}
func humanDuration(d time.Duration) string {
switch {
case d <= 0:
return "-"
case d < time.Hour:
return fmt.Sprintf("%d min", int(d.Minutes()+0.5))
case d < 48*time.Hour:
return fmt.Sprintf("%.1f h", d.Hours())
default:
return fmt.Sprintf("%.1f days", d.Hours()/24)
}
}
// AddMember brings someone into a ticket. The opener and staff can add.
func AddMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
return ErrNotInvolved
}
if err := d.ThreadMemberAdd(threadID, userID); err != nil {
return fmt.Errorf("adding to the thread: %w", err)
}
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
Content: fmt.Sprintf("<@%s> added <@%s> to this ticket.", a.userID(), userID),
AllowedMentions: &discordgo.MessageAllowedMentions{},
})
return err
}
// RemoveMember takes someone out of a ticket. Only staff can, and never the
// opener.
func RemoveMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if !IsStaff(a.Member, a.Perms, cat) {
return ErrNotStaff
}
if mustID(userID) == t.OpenerID {
return UserError("The person who opened the ticket can't be removed. Close the ticket instead.")
}
if err := d.ThreadMemberRemove(threadID, userID); err != nil {
return fmt.Errorf("removing from the thread: %w", err)
}
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
Content: fmt.Sprintf("<@%s> removed <@%s> from this ticket.", a.userID(), userID),
AllowedMentions: &discordgo.MessageAllowedMentions{},
})
return err
}
// ThreadDeleted closes the ticket of a thread someone deleted by hand, so it
// stops counting towards the opener's limit.
func ThreadDeleted(ctx context.Context, st Store, threadID string) {
if _, err := st.Close(ctx, mustID(threadID), 0, "Thread deleted"); err != nil {
log.Printf("tickets: closing the ticket of deleted thread %s: %v", threadID, err)
}
}
+170
View File
@@ -0,0 +1,170 @@
package tickets
import (
"context"
"fmt"
"log"
"strconv"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// CheckCanOpen runs before the form is shown, so a member who can't open a
// ticket finds out before typing anything.
func CheckCanOpen(ctx context.Context, st Store, guildID, userID, categoryID int64) (*ticketrepo.Settings, *ticketrepo.Category, error) {
s, err := st.GetSettings(ctx, guildID)
if err != nil {
return nil, nil, err
}
if !s.Configured || s.ChannelID == 0 {
return nil, nil, ErrNotSetUp
}
if !s.Enabled {
return nil, nil, ErrDisabled
}
cat, err := st.GetCategory(ctx, guildID, categoryID)
if err != nil {
return nil, nil, err
}
if cat == nil {
return nil, nil, ErrUnknownCategory
}
open, err := st.CountOpenByOpener(ctx, guildID, userID)
if err != nil {
return nil, nil, err
}
if open >= s.MaxOpenPerUser {
if s.MaxOpenPerUser == 1 {
return nil, nil, UserError("You already have an open ticket. Close it before opening another one.")
}
return nil, nil, UserError(fmt.Sprintf("You already have %d open tickets, the most this server allows. Close one before opening another.", open))
}
return s, cat, nil
}
type OpenRequest struct {
GuildID int64
CategoryID int64
User *discordgo.User
Subject string
Details string
}
// IntroComponents are the Claim and Close buttons under a ticket's first
// message, disabled as the ticket moves on.
func IntroComponents(claimed, closed bool) []discordgo.MessageComponent {
claimLabel := "Claim"
if claimed {
claimLabel = "Claimed"
}
return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: claimLabel, Style: discordgo.SuccessButton, CustomID: idClaim, Disabled: claimed || closed, Emoji: &discordgo.ComponentEmoji{Name: "🙋"}},
discordgo.Button{Label: "Close", Style: discordgo.DangerButton, CustomID: idClose, Disabled: closed, Emoji: &discordgo.ComponentEmoji{Name: "🔒"}},
}}}
}
// IntroEmbed describes the ticket at the top of its thread.
func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
desc := "**" + clip(t.Subject, maxSubject) + "**"
if details != "" {
desc += "\n\n" + clip(details, maxDetails)
}
claimed := "Nobody yet"
if t.ClaimedBy != 0 {
claimed = "<@" + id(t.ClaimedBy) + ">"
}
e := &discordgo.MessageEmbed{
Title: fmt.Sprintf("Ticket #%04d · %s", t.Number, clip(t.CategoryName, 60)),
Description: desc,
Color: accent,
Fields: []*discordgo.MessageEmbedField{
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
{Name: "Claimed by", Value: claimed, Inline: true},
},
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
}
if t.Status == ticketrepo.StatusClosed {
e.Footer.Text = "This ticket is closed."
}
return e
}
// Open creates the ticket: a private thread in the ticket channel with the
// opener added, a first message that mentions the category's staff role
// (which adds that role's members to the thread), and the database row.
func Open(ctx context.Context, d Discord, st Store, req OpenRequest) (*discordgo.Channel, error) {
s, cat, err := CheckCanOpen(ctx, st, req.GuildID, mustID(req.User.ID), req.CategoryID)
if err != nil {
return nil, err
}
number, err := st.NextNumber(ctx, req.GuildID)
if err != nil {
return nil, err
}
name := req.User.GlobalName
if name == "" {
name = req.User.Username
}
thread, err := d.ThreadStartComplex(id(s.ChannelID), &discordgo.ThreadStart{
Name: ThreadName(cat.Name, number, name),
Type: discordgo.ChannelTypeGuildPrivateThread,
AutoArchiveDuration: autoArchiveWeek,
Invitable: false, // only staff can add people
})
if err != nil {
return nil, fmt.Errorf("creating the thread: %w", err)
}
// From here on a failure leaves an empty thread behind unless it's removed.
fail := func(err error) (*discordgo.Channel, error) {
if _, derr := d.ChannelDelete(thread.ID); derr != nil {
log.Printf("tickets: removing thread %s after a failed open: %v", thread.ID, derr)
}
return nil, err
}
if err := d.ThreadMemberAdd(thread.ID, req.User.ID); err != nil {
return fail(fmt.Errorf("adding the opener: %w", err))
}
t := &ticketrepo.Ticket{
GuildID: req.GuildID,
Number: number,
CategoryID: cat.ID,
CategoryName: cat.Name,
OpenerID: mustID(req.User.ID),
Subject: clip(req.Subject, maxSubject),
Status: ticketrepo.StatusOpen,
}
content := "<@" + req.User.ID + ">"
mentions := &discordgo.MessageAllowedMentions{Users: []string{req.User.ID}}
if cat.StaffRoleID != 0 {
content += " <@&" + id(cat.StaffRoleID) + ">"
mentions.Roles = []string{id(cat.StaffRoleID)}
}
intro, err := d.ChannelMessageSendComplex(thread.ID, &discordgo.MessageSend{
Content: content,
Embeds: []*discordgo.MessageEmbed{IntroEmbed(t, req.Details)},
Components: IntroComponents(false, false),
AllowedMentions: mentions,
})
if err != nil {
return fail(fmt.Errorf("posting the first message: %w", err))
}
t.ThreadID = mustID(thread.ID)
t.IntroMessage = mustID(intro.ID)
if _, err := st.CreateTicket(ctx, *t); err != nil {
return fail(fmt.Errorf("saving the ticket: %w", err))
}
return thread, nil
}
// mustID parses a snowflake that came from Discord; those are always valid.
func mustID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
+176
View File
@@ -0,0 +1,176 @@
package tickets
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// Custom IDs. Everything starts with "tickets:" so the interaction router can
// hand it to this package.
const (
idOpenPrefix = "tickets:open:" // older panels: one button per category, + category ID
idSelect = "tickets:select" // panel dropdown
idCreate = "tickets:create" // panel "Create ticket" button
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
idClaim = "tickets:claim"
idClose = "tickets:close"
idCloseForm = "tickets:closeform"
fieldSubject = "subject"
fieldDetails = "details"
fieldReason = "reason"
)
var customEmojiRe = regexp.MustCompile(`^<(a?):(\w+):(\d+)>$`)
// parseEmoji accepts a Unicode emoji or a custom one written as <:name:id>.
func parseEmoji(s string) *discordgo.ComponentEmoji {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if m := customEmojiRe.FindStringSubmatch(s); m != nil {
return &discordgo.ComponentEmoji{Name: m[2], ID: m[3], Animated: m[1] == "a"}
}
return &discordgo.ComponentEmoji{Name: s}
}
// BuildPanel is the message members open tickets from: an embed with the
// guild's text and each category's description, a dropdown to pick the
// category, and a Create ticket button that opens the form for that pick.
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
embed := &discordgo.MessageEmbed{
Title: clip(s.PanelTitle, 256),
Description: clip(s.PanelDescription, 4000),
Color: accent,
}
if len(cats) > maxCategories {
cats = cats[:maxCategories]
}
opts := make([]discordgo.SelectMenuOption, 0, len(cats))
for _, c := range cats {
opts = append(opts, discordgo.SelectMenuOption{
Label: clip(c.Name, 100),
Value: id(c.ID),
Description: clip(c.Description, 100),
Emoji: parseEmoji(c.Emoji),
})
if c.Description != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
}
}
return embed, []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Create ticket", Style: discordgo.PrimaryButton, CustomID: idCreate, Emoji: &discordgo.ComponentEmoji{Name: "🎫"}},
}},
}
}
// PostPanel posts the panel in the ticket channel, or updates the one it
// posted before, and returns where it ended up.
func PostPanel(ctx context.Context, d Discord, st Store, guildID int64) (*discordgo.Message, error) {
s, err := st.GetSettings(ctx, guildID)
if err != nil {
return nil, err
}
if s.ChannelID == 0 {
return nil, ErrNotSetUp
}
cats, err := st.ListCategories(ctx, guildID)
if err != nil {
return nil, err
}
if len(cats) == 0 {
return nil, ErrNoCategories
}
embed, comps := BuildPanel(s, cats)
if s.PanelMessageID != 0 {
embeds := []*discordgo.MessageEmbed{embed}
msg, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: id(s.ChannelID),
ID: id(s.PanelMessageID),
Embeds: &embeds,
Components: &comps,
})
if err == nil {
return msg, nil
}
// The old panel was deleted or the channel changed: post a new one.
}
msg, err := d.ChannelMessageSendComplex(id(s.ChannelID), &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: comps,
})
if err != nil {
return nil, fmt.Errorf("posting the panel: %w", err)
}
if mid, err := strconv.ParseInt(msg.ID, 10, 64); err == nil {
if err := st.SetPanelMessage(ctx, guildID, mid); err != nil {
return msg, fmt.Errorf("saving the panel message: %w", err)
}
}
return msg, nil
}
// OpenForm is the short form shown after a member picks a category.
func OpenForm(cat *ticketrepo.Category) *discordgo.InteractionResponse {
return &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: idOpenFormPrefix + id(cat.ID),
Title: clip("Open a ticket: "+cat.Name, 45),
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldSubject,
Label: "What do you need help with?",
Style: discordgo.TextInputShort,
Placeholder: "A short summary",
Required: true,
MinLength: 3,
MaxLength: maxSubject,
}}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldDetails,
Label: "Details",
Style: discordgo.TextInputParagraph,
Placeholder: "Anything the staff should know (optional)",
Required: false,
MaxLength: maxDetails,
}}},
},
},
}
}
// CloseForm asks for an optional reason before closing.
func CloseForm() *discordgo.InteractionResponse {
return &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: idCloseForm,
Title: "Close this ticket",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldReason,
Label: "Reason (optional)",
Style: discordgo.TextInputParagraph,
Required: false,
MaxLength: maxReason,
}}},
},
},
}
}
+59
View File
@@ -0,0 +1,59 @@
package tickets
import (
"sync"
"time"
)
// Discord doesn't send a dropdown's value along with a button click, so the
// panel's category pick is remembered here between the two interactions:
// per guild and member, for a short while, in memory. A bot restart forgets
// it, which only means picking the category again.
const (
pickTTL = 15 * time.Minute
maxPicks = 10000 // a ceiling, so a flood of picks can't grow this forever
)
type pickKey struct{ guild, user int64 }
type pick struct {
category int64
at time.Time
}
var (
picksMu sync.Mutex
picks = map[pickKey]pick{}
now = time.Now
)
// RememberPick records the category a member chose in the panel dropdown.
func RememberPick(guildID, userID, categoryID int64) {
picksMu.Lock()
defer picksMu.Unlock()
t := now()
k := pickKey{guildID, userID}
if _, ok := picks[k]; !ok && len(picks) >= maxPicks {
for k, p := range picks {
if t.Sub(p.at) > pickTTL {
delete(picks, k)
}
}
if len(picks) >= maxPicks {
return
}
}
picks[k] = pick{categoryID, t}
}
// PickedCategory returns the category a member chose, if they did recently.
func PickedCategory(guildID, userID int64) (int64, bool) {
picksMu.Lock()
defer picksMu.Unlock()
p, ok := picks[pickKey{guildID, userID}]
if !ok || now().Sub(p.at) > pickTTL {
delete(picks, pickKey{guildID, userID})
return 0, false
}
return p.category, true
}
+199
View File
@@ -0,0 +1,199 @@
// Package tickets is the support ticket system: a panel message with a
// category dropdown and a Create ticket button, a short form, and a private
// thread per ticket that staff can claim and anyone involved can close.
// Closing locks and archives the thread and posts a transcript to the log
// channel.
//
// The Discord and database calls go through small interfaces so the flows
// can be tested without either; the slash command and button handlers in
// internal/commands/tickets only translate interactions into these calls.
package tickets
import (
"context"
"fmt"
"strings"
"unicode"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// Store is the subset of ticketrepo.Repo the flows use.
type Store interface {
GetSettings(ctx context.Context, guildID int64) (*ticketrepo.Settings, error)
SetPanelMessage(ctx context.Context, guildID, messageID int64) error
NextNumber(ctx context.Context, guildID int64) (int, error)
ListCategories(ctx context.Context, guildID int64) ([]ticketrepo.Category, error)
GetCategory(ctx context.Context, guildID, id int64) (*ticketrepo.Category, error)
CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error)
CreateTicket(ctx context.Context, t ticketrepo.Ticket) (int64, error)
GetTicketByThread(ctx context.Context, threadID int64) (*ticketrepo.Ticket, error)
Claim(ctx context.Context, threadID, staffID int64) (int64, error)
Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error)
ListUnprocessedCommands(ctx context.Context) ([]ticketrepo.Command, error)
MarkCommandProcessed(ctx context.Context, id int64) error
}
// Discord is the subset of *discordgo.Session the flows use.
type Discord interface {
ThreadStartComplex(channelID string, data *discordgo.ThreadStart, options ...discordgo.RequestOption) (*discordgo.Channel, error)
ThreadMemberAdd(threadID, memberID string, options ...discordgo.RequestOption) error
ThreadMemberRemove(threadID, memberID string, options ...discordgo.RequestOption) error
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
ChannelMessageEditComplex(m *discordgo.MessageEdit, options ...discordgo.RequestOption) (*discordgo.Message, error)
ChannelMessage(channelID, messageID string, options ...discordgo.RequestOption) (*discordgo.Message, error)
ChannelMessages(channelID string, limit int, beforeID, afterID, aroundID string, options ...discordgo.RequestOption) ([]*discordgo.Message, error)
ChannelEditComplex(channelID string, data *discordgo.ChannelEdit, options ...discordgo.RequestOption) (*discordgo.Channel, error)
ChannelDelete(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
}
// UserError is a problem worth showing the person who clicked, as written.
// Anything else is logged and shown as a generic failure.
type UserError string
func (e UserError) Error() string { return string(e) }
const (
ErrNotSetUp = UserError("Tickets aren't set up on this server yet. An admin can run `/tickets setup`.")
ErrDisabled = UserError("Tickets are turned off on this server right now.")
ErrNoCategories = UserError("There are no ticket categories yet. Add one with `/tickets category add` or on the dashboard.")
ErrUnknownCategory = UserError("That ticket category no longer exists. Ask an admin to repost the ticket panel.")
ErrNotATicket = UserError("This isn't a ticket thread.")
ErrClosed = UserError("This ticket is already closed.")
ErrNotStaff = UserError("Only the staff handling this kind of ticket can do that.")
ErrNotInvolved = UserError("Only the person who opened this ticket or the staff can do that.")
)
var (
defaultStore Store
// Every ticket embed (panel, ticket, log) is orange, so support stands
// apart from the bot's other messages.
accent = 0xf97316
)
func Init(s Store) { defaultStore = s }
func Default() Store { return defaultStore }
// Limits Discord puts on what the bot sends.
const (
maxThreadName = 100
maxSubject = 100
maxDetails = 1000
maxReason = 300
maxCategories = 25
autoArchiveWeek = 10080
)
func id(v int64) string { return fmt.Sprintf("%d", v) }
// IsStaff reports whether a member may claim, close and manage tickets of
// this category: anyone with the category's staff role, or with Manage
// Threads / Manage Server / Administrator (perms is the member's permission
// set in the ticket's channel, as Discord sends it with the interaction).
func IsStaff(member *discordgo.Member, perms int64, cat *ticketrepo.Category) bool {
const staffPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild | discordgo.PermissionManageThreads
if perms&staffPerms != 0 {
return true
}
if member == nil || cat == nil || cat.StaffRoleID == 0 {
return false
}
want := id(cat.StaffRoleID)
for _, r := range member.Roles {
if r == want {
return true
}
}
return false
}
// slug turns a name into lowercase ASCII words joined by dashes, for thread
// names that read well in the channel's thread list.
func slug(s string, max int) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(s) {
switch {
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
if dash && b.Len() > 0 {
b.WriteByte('-')
}
b.WriteRune(r)
dash = false
default:
dash = true
}
if b.Len() >= max {
break
}
}
out := b.String()
if len(out) > max {
out = out[:max]
}
return strings.Trim(out, "-")
}
// ThreadName is "support-0042-ana": the category, the ticket number and who
// opened it.
func ThreadName(category string, number int, opener string) string {
cat := slug(category, 30)
if cat == "" {
cat = "ticket"
}
name := fmt.Sprintf("%s-%04d", cat, number)
if u := slug(opener, 32); u != "" {
name += "-" + u
}
if len(name) > maxThreadName {
name = name[:maxThreadName]
}
return name
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// Permissions the bot needs, by channel.
const (
NeedTicketChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks |
discordgo.PermissionCreatePrivateThreads | discordgo.PermissionSendMessagesInThreads | discordgo.PermissionManageThreads
NeedLogChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks |
discordgo.PermissionAttachFiles
)
var permNames = []struct {
bit int64
name string
}{
{discordgo.PermissionViewChannel, "View Channel"},
{discordgo.PermissionSendMessages, "Send Messages"},
{discordgo.PermissionEmbedLinks, "Embed Links"},
{discordgo.PermissionAttachFiles, "Attach Files"},
{discordgo.PermissionCreatePrivateThreads, "Create Private Threads"},
{discordgo.PermissionSendMessagesInThreads, "Send Messages in Threads"},
{discordgo.PermissionManageThreads, "Manage Threads"},
}
// MissingPerms names the permissions in need that have lacks. Administrator
// counts as having everything.
func MissingPerms(have, need int64) []string {
if have&discordgo.PermissionAdministrator != 0 {
return nil
}
var out []string
for _, p := range permNames {
if need&p.bit != 0 && have&p.bit == 0 {
out = append(out, p.name)
}
}
return out
}
+399
View File
@@ -0,0 +1,399 @@
package tickets
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
const (
guild = int64(1)
channel = int64(10)
logCh = int64(20)
staffRl = int64(30)
)
var ctx = context.Background()
func member(userID string, roles ...string) *discordgo.Member {
return &discordgo.Member{User: &discordgo.User{ID: userID, Username: "user" + userID}, Roles: roles}
}
func TestThreadName(t *testing.T) {
for _, c := range []struct{ cat, user, want string }{
{"Support", "Ana", "support-0042-ana"},
{"Ban Appeal!", "João Silva", "ban-appeal-0042-jo-o-silva"},
{"🎫", "〈ぬべ〉", "ticket-0042"},
{strings.Repeat("x", 80), strings.Repeat("y", 80), strings.Repeat("x", 30) + "-0042-" + strings.Repeat("y", 32)},
} {
if got := ThreadName(c.cat, 42, c.user); got != c.want {
t.Errorf("ThreadName(%q, %q) = %q, want %q", c.cat, c.user, got, c.want)
}
}
}
func TestIsStaff(t *testing.T) {
cat := &ticketrepo.Category{StaffRoleID: staffRl}
if !IsStaff(member("1", "30"), 0, cat) {
t.Error("the staff role should count")
}
if IsStaff(member("1", "31"), 0, cat) {
t.Error("another role must not count")
}
for _, p := range []int64{discordgo.PermissionManageThreads, discordgo.PermissionManageGuild, discordgo.PermissionAdministrator} {
if !IsStaff(member("1"), p, nil) {
t.Errorf("permission %d should count, even with the category deleted", p)
}
}
if IsStaff(member("1"), discordgo.PermissionSendMessages, &ticketrepo.Category{}) {
t.Error("a category without a staff role must not make everyone staff")
}
}
func TestMissingPerms(t *testing.T) {
got := MissingPerms(discordgo.PermissionViewChannel|discordgo.PermissionSendMessages, NeedLogChannel)
if strings.Join(got, ",") != "Embed Links,Attach Files" {
t.Errorf("missing = %v", got)
}
if MissingPerms(discordgo.PermissionAdministrator, NeedTicketChannel) != nil {
t.Error("Administrator has everything")
}
}
func TestBuildPanel(t *testing.T) {
s := &ticketrepo.Settings{PanelTitle: "Help", PanelDescription: "Pick one"}
cats := []ticketrepo.Category{
{ID: 1, Name: "Support", Emoji: "🛟", Description: "General help"},
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
}
embed, comps := BuildPanel(s, cats)
if embed.Title != "Help" || embed.Color != 0xf97316 {
t.Errorf("panel embed wrong: %+v", embed)
}
if len(comps) != 2 {
t.Fatalf("want a dropdown row and a button row, got %d rows", len(comps))
}
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
if menu.CustomID != "tickets:select" || len(menu.Options) != 2 || menu.Options[0].Value != "1" || menu.Options[0].Emoji.Name != "🛟" {
t.Errorf("dropdown wrong: %+v", menu)
}
if e := menu.Options[1].Emoji; e.ID != "123" || e.Name != "party" || !e.Animated {
t.Errorf("custom emoji not parsed: %+v", e)
}
button := comps[1].(discordgo.ActionsRow).Components[0].(discordgo.Button)
if button.CustomID != "tickets:create" || button.Label != "Create ticket" {
t.Errorf("create button wrong: %+v", button)
}
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
}
var many []ticketrepo.Category
for i := 1; i <= 30; i++ {
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
}
_, comps = BuildPanel(s, many)
if n := len(comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu).Options); n != 25 {
t.Errorf("a dropdown holds at most 25 options, got %d", n)
}
}
func TestPicks(t *testing.T) {
clock := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return clock }
t.Cleanup(func() { now = time.Now; picks = map[pickKey]pick{} })
if _, ok := PickedCategory(guild, 5); ok {
t.Error("nothing picked yet")
}
RememberPick(guild, 5, 3)
RememberPick(guild, 5, 4)
RememberPick(guild, 6, 9)
if c, ok := PickedCategory(guild, 5); !ok || c != 4 {
t.Errorf("the latest pick wins: %d %v", c, ok)
}
if _, ok := PickedCategory(guild+1, 5); ok {
t.Error("picks are per guild")
}
clock = clock.Add(pickTTL + time.Second)
if _, ok := PickedCategory(guild, 5); ok {
t.Error("a pick expires")
}
// Full: new members can't add more, but expired ones make room.
picks = map[pickKey]pick{}
for i := 0; i < maxPicks; i++ {
picks[pickKey{guild + 100, int64(i)}] = pick{1, clock}
}
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); ok {
t.Error("a full map takes no new members")
}
RememberPick(guild+100, 0, 2)
if c, _ := PickedCategory(guild+100, 0); c != 2 {
t.Error("someone already in the map can still change their pick")
}
clock = clock.Add(pickTTL + time.Second)
RememberPick(guild, 7, 1)
if _, ok := PickedCategory(guild, 7); !ok {
t.Error("expired picks should be cleared to make room")
}
}
func TestPostPanel(t *testing.T) {
st, d := newStore(), newDiscord()
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNotSetUp) {
t.Errorf("err = %v, want not set up", err)
}
st.configure(guild, channel, logCh)
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNoCategories) {
t.Errorf("err = %v, want no categories", err)
}
st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support"})
msg, err := PostPanel(ctx, d, st, guild)
if err != nil || len(d.sent["10"]) != 1 || st.settings[guild].PanelMessageID != mustID(msg.ID) {
t.Fatalf("first post: err=%v sent=%d saved=%d", err, len(d.sent["10"]), st.settings[guild].PanelMessageID)
}
// A second post updates the same message.
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.edits) != 1 || len(d.sent["10"]) != 1 {
t.Errorf("repost should edit: err=%v edits=%d sent=%d", err, len(d.edits), len(d.sent["10"]))
}
// If the old panel is gone, a new one is posted and remembered.
d.failEdit = errors.New("unknown message")
old := st.settings[guild].PanelMessageID
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.sent["10"]) != 2 || st.settings[guild].PanelMessageID == old {
t.Errorf("deleted panel should be replaced: err=%v sent=%d", err, len(d.sent["10"]))
}
}
func setup() (*fakeStore, *fakeDiscord, ticketrepo.Category) {
st, d := newStore(), newDiscord()
st.configure(guild, channel, logCh)
cat := st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support", StaffRoleID: staffRl})
return st, d, cat
}
func open(t *testing.T, st *fakeStore, d *fakeDiscord, cat ticketrepo.Category, userID string) *discordgo.Channel {
t.Helper()
th, err := Open(ctx, d, st, OpenRequest{
GuildID: guild, CategoryID: cat.ID,
User: &discordgo.User{ID: userID, Username: "ana", GlobalName: "Ana"},
Subject: "Can't join voice", Details: "It says no permission",
})
if err != nil {
t.Fatal(err)
}
return th
}
func TestCheckCanOpen(t *testing.T) {
st := newStore()
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrNotSetUp) {
t.Errorf("err = %v", err)
}
st.configure(guild, channel, logCh)
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 99); !errors.Is(err, ErrUnknownCategory) {
t.Errorf("err = %v", err)
}
st.settings[guild].Enabled = false
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrDisabled) {
t.Errorf("err = %v", err)
}
}
func TestOpen(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
start := d.threads[0]
if start.Type != discordgo.ChannelTypeGuildPrivateThread || start.Invitable || start.Name != "support-0001-ana" {
t.Errorf("thread wrong: %+v", start)
}
if strings.Join(d.members[th.ID], ",") != "500" {
t.Errorf("opener not added: %v", d.members[th.ID])
}
intro := d.sent[th.ID][0]
if intro.Content != "<@500> <@&30>" || strings.Join(intro.AllowedMentions.Roles, ",") != "30" {
t.Errorf("the staff role must be mentioned so its members join: %+v", intro)
}
if !strings.Contains(intro.Embeds[0].Description, "It says no permission") || intro.Embeds[0].Title != "Ticket #0001 · Support" {
t.Errorf("intro embed wrong: %+v", intro.Embeds[0])
}
tk := st.tickets[0]
if tk.Number != 1 || tk.ThreadID != mustID(th.ID) || tk.OpenerID != 500 || tk.IntroMessage == 0 || tk.Subject != "Can't join voice" {
t.Errorf("ticket row wrong: %+v", tk)
}
// The limit applies to the second ticket.
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "again"})
var ue UserError
if !errors.As(err, &ue) || !strings.Contains(string(ue), "already have an open ticket") {
t.Errorf("err = %v", err)
}
}
func TestOpenCleansUpOnFailure(t *testing.T) {
st, d, cat := setup()
d.failSend = errors.New("missing access")
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "help"})
if err == nil || len(d.deleted) != 1 || len(st.tickets) != 0 {
t.Errorf("a half-made ticket must be removed: err=%v deleted=%v tickets=%d", err, d.deleted, len(st.tickets))
}
}
func TestClaim(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("600")}); !errors.Is(err, ErrNotStaff) {
t.Errorf("non-staff claim: %v", err)
}
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("700", "30")}); err != nil {
t.Fatal(err)
}
if st.tickets[0].ClaimedBy != 700 {
t.Errorf("claim not saved")
}
if len(d.edits) != 1 {
t.Fatalf("the first message should be updated")
}
e := (*d.edits[0].Embeds)[0]
if e.Fields[1].Value != "<@700>" || !strings.Contains(e.Description, "It says no permission") {
t.Errorf("updated intro lost details or claim: %+v", e)
}
if b := (*d.edits[0].Components)[0].(discordgo.ActionsRow).Components[0].(discordgo.Button); !b.Disabled || b.Label != "Claimed" {
t.Errorf("claim button should be disabled: %+v", b)
}
err := Claim(ctx, d, st, th.ID, Actor{Member: member("701"), Perms: discordgo.PermissionManageThreads})
if err == nil || !strings.Contains(err.Error(), "<@700> is already handling") {
t.Errorf("second claim: %v", err)
}
if err := Claim(ctx, d, st, "999", Actor{Member: member("700", "30")}); !errors.Is(err, ErrNotATicket) {
t.Errorf("claim outside a ticket: %v", err)
}
}
func TestClose(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
ts := time.Date(2026, 10, 20, 18, 0, 0, 0, time.UTC)
d.history[th.ID] = []*discordgo.Message{ // newest first
{ID: "3", Content: "thanks, works now", Author: &discordgo.User{Username: "ana"}, Timestamp: ts.Add(2 * time.Minute)},
{ID: "2", Content: "try rejoining", Author: &discordgo.User{Username: "mod", GlobalName: "Mod"}, Timestamp: ts.Add(time.Minute),
Attachments: []*discordgo.MessageAttachment{{Filename: "steps.png", URL: "https://cdn/x.png"}}},
{ID: "1", Author: &discordgo.User{Username: "velox", Bot: true}, Embeds: []*discordgo.MessageEmbed{{Title: "Ticket #0001 · Support"}}, Timestamp: ts},
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("600")}, ""); !errors.Is(err, ErrNotInvolved) {
t.Errorf("an outsider must not close: %v", err)
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, "Solved"); err != nil {
t.Fatal(err)
}
tk := st.tickets[0]
if tk.Status != ticketrepo.StatusClosed || tk.ClosedBy != 500 || tk.CloseReason != "Solved" {
t.Errorf("ticket not closed: %+v", tk)
}
if e := d.chEdits[th.ID]; e == nil || !*e.Locked || !*e.Archived {
t.Errorf("thread should be locked and archived: %+v", e)
}
logged := d.sent["20"]
if len(logged) != 1 || !strings.Contains(logged[0].Embeds[0].Title, "Ticket #0001 closed") {
t.Fatalf("log channel post missing: %+v", logged)
}
transcript := d.files["ticket-0001.txt"]
i1, i2, i3 := strings.Index(transcript, "[embed] Ticket #0001"), strings.Index(transcript, "try rejoining"), strings.Index(transcript, "thanks, works now")
if i1 < 0 || i2 < i1 || i3 < i2 {
t.Errorf("transcript must be oldest first:\n%s", transcript)
}
if !strings.Contains(transcript, "Mod (mod)") || !strings.Contains(transcript, "[file] steps.png https://cdn/x.png") || !strings.Contains(transcript, "velox [bot]") {
t.Errorf("transcript details missing:\n%s", transcript)
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, ""); !errors.Is(err, ErrClosed) {
t.Errorf("closing twice: %v", err)
}
// A closed ticket no longer counts towards the limit.
open(t, st, d, cat, "500")
}
func TestTranscriptPages(t *testing.T) {
d := newDiscord()
var hist []*discordgo.Message
for i := 250; i >= 1; i-- {
hist = append(hist, &discordgo.Message{ID: fmt.Sprint(i), Content: fmt.Sprint("msg ", i), Author: &discordgo.User{Username: "u"}})
}
d.history["5"] = hist
out, n, err := Transcript(d, &ticketrepo.Ticket{ThreadID: 5, Number: 1})
if err != nil || n != 250 {
t.Fatalf("n=%d err=%v", n, err)
}
if strings.Index(string(out), "msg 1\n") > strings.Index(string(out), "msg 250\n") {
t.Error("pages must be stitched oldest first")
}
d.failFetch = errors.New("boom")
out, _, err = Transcript(d, &ticketrepo.Ticket{ThreadID: 5})
if err == nil || !strings.Contains(string(out), "only part of the thread") {
t.Error("a failed read should still produce a transcript that says so")
}
}
func TestAddAndRemoveMember(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("600")}, "601"); !errors.Is(err, ErrNotInvolved) {
t.Errorf("outsider add: %v", err)
}
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); err != nil {
t.Errorf("the opener can add: %v", err)
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); !errors.Is(err, ErrNotStaff) {
t.Errorf("only staff remove: %v", err)
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "500"); err == nil {
t.Error("the opener can't be removed")
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "601"); err != nil {
t.Error(err)
}
if strings.Join(d.members[th.ID], ",") != "500" {
t.Errorf("members = %v", d.members[th.ID])
}
}
func TestThreadDeletedFreesTheLimit(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
ThreadDeleted(ctx, st, th.ID)
if st.tickets[0].Status != ticketrepo.StatusClosed || st.tickets[0].CloseReason != "Thread deleted" {
t.Errorf("ticket = %+v", st.tickets[0])
}
open(t, st, d, cat, "500")
}
func TestProcessCommands(t *testing.T) {
st, d, _ := setup()
st.cmds = []ticketrepo.Command{{ID: 1, GuildID: guild, CommandType: "post_panel"}, {ID: 2, GuildID: 99, CommandType: "post_panel"}, {ID: 3, CommandType: "nope"}}
ProcessCommands(ctx, d, st)
if len(d.sent["10"]) != 1 {
t.Errorf("panel not posted")
}
if len(st.done) != 3 {
t.Errorf("every command must be marked done, even failed ones: %v", st.done)
}
}
+88
View File
@@ -0,0 +1,88 @@
package tickets
import (
"fmt"
"strings"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// maxTranscriptMessages bounds how far back a transcript reads: 50 pages of
// 100, far beyond any normal ticket.
const maxTranscriptMessages = 5000
// Transcript renders the whole thread as plain text, oldest first, and
// reports how many messages it holds. Attachments and embeds are listed so
// the log keeps their links and titles.
func Transcript(d Discord, t *ticketrepo.Ticket) ([]byte, int, error) {
var msgs []*discordgo.Message
before := ""
var fetchErr error
for len(msgs) < maxTranscriptMessages {
page, err := d.ChannelMessages(id(t.ThreadID), 100, before, "", "")
if err != nil {
fetchErr = err
break
}
msgs = append(msgs, page...)
if len(page) < 100 {
break
}
before = page[len(page)-1].ID
}
var b strings.Builder
fmt.Fprintf(&b, "Ticket #%04d · %s\n", t.Number, t.CategoryName)
fmt.Fprintf(&b, "Subject: %s\n", t.Subject)
fmt.Fprintf(&b, "Opened by user %d on %s\n", t.OpenerID, t.CreatedAt.UTC().Format("2006-01-02 15:04 MST"))
if fetchErr != nil {
fmt.Fprintf(&b, "Note: only part of the thread could be read (%v)\n", fetchErr)
}
b.WriteString(strings.Repeat("-", 60) + "\n\n")
// Discord returns newest first.
for i := len(msgs) - 1; i >= 0; i-- {
writeMessage(&b, msgs[i])
}
return []byte(b.String()), len(msgs), fetchErr
}
func writeMessage(b *strings.Builder, m *discordgo.Message) {
name := "unknown"
if m.Author != nil {
name = m.Author.Username
if m.Author.GlobalName != "" {
name = m.Author.GlobalName + " (" + m.Author.Username + ")"
}
if m.Author.Bot {
name += " [bot]"
}
}
fmt.Fprintf(b, "[%s] %s\n", m.Timestamp.UTC().Format("2006-01-02 15:04"), name)
if content := strings.TrimSpace(m.Content); content != "" {
for _, line := range strings.Split(content, "\n") {
b.WriteString(" " + line + "\n")
}
}
for _, e := range m.Embeds {
if e == nil {
continue
}
title := e.Title
if title == "" {
title = clip(e.Description, 120)
}
if title != "" {
b.WriteString(" [embed] " + strings.ReplaceAll(title, "\n", " ") + "\n")
}
}
for _, a := range m.Attachments {
if a != nil {
b.WriteString(" [file] " + a.Filename + " " + a.URL + "\n")
}
}
b.WriteString("\n")
}
+22 -2
View File
@@ -8,24 +8,35 @@ import (
"syscall"
"velox-bot/internal/bot"
"velox-bot/internal/commands"
cmdtickets "velox-bot/internal/commands/tickets"
"velox-bot/internal/config"
"velox-bot/internal/db"
"velox-bot/internal/db/repos/defaultrolerepo"
"velox-bot/internal/db/repos/levelrepo"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/repos/projectsrepo"
"velox-bot/internal/db/repos/rpsrepo"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/repos/securityrepo"
"velox-bot/internal/db/repos/settingsrepo"
"velox-bot/internal/db/repos/ticketrepo"
"velox-bot/internal/db/repos/twitchrepo"
"velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/musicsettings"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
"velox-bot/internal/db/services/schedule"
"velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome"
"velox-bot/internal/security"
)
func main() {
@@ -44,22 +55,31 @@ func main() {
levelRepo := levelrepo.NewRepo(db)
settingsRepo := settingsrepo.NewRepo(db)
musicRepo := musicrepo.NewRepo(db)
rpsRepo := rpsrepo.NewRepo(db)
projectsRepo := projectsrepo.NewRepo(db)
scheduleRepo := schedulerepo.NewRepo(db)
userSettingsRepo := usersettingsrepo.NewRepo(db)
welcomeRepo := welcomerepo.NewRepo(db)
twitchRepo := twitchrepo.NewRepo(db)
defaultRoleRepo := defaultrolerepo.NewRepo(db)
cmdtickets.Init(ticketrepo.NewRepo(db))
security.Configure(securityrepo.NewRepo(db))
levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo)
logSettingsService := logsettings.New(settingsRepo)
meetingService := meeting.New(settingsRepo)
scheduleService := schedule.New(scheduleRepo)
userSettingsService := usersettings.New(userSettingsRepo)
projectsService := projects.New(projectsRepo)
rpsService := rps.New(rpsRepo)
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService)
welcomeService := welcome.New(welcomeRepo)
defaultRoleService := defaultrole.New(defaultRoleRepo)
musicSettingsService := musicsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost)
if err != nil {
log.Fatalf("Error creating bot: %v", err)
return
+264 -4
View File
@@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS twitch (
CREATE TABLE IF NOT EXISTS levelsettings (
guild_id BIGINT PRIMARY KEY,
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
role BIGINT,
levelreq INT,
message TEXT
);
@@ -47,7 +45,8 @@ CREATE TABLE IF NOT EXISTS levelup (
CREATE TABLE IF NOT EXISTS twitch_config (
guild_id BIGINT PRIMARY KEY,
twitch_channel_id BIGINT
twitch_channel_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS rps (
@@ -112,7 +111,14 @@ CREATE TABLE IF NOT EXISTS schedules (
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
description TEXT,
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Reschedule negotiation: a proposed new time awaiting the counterpart's
-- response, kept separate from scheduled_at until accepted.
proposed_at TIMESTAMPTZ,
proposed_by BIGINT,
-- Snapshot of status before entering reschedule_requested, so a decline
-- knows whether to revert to 'pending' or 'accepted'.
pre_reschedule_status TEXT
);
CREATE TABLE IF NOT EXISTS schedule_messages (
@@ -127,3 +133,257 @@ CREATE TABLE IF NOT EXISTS user_settings (
user_id BIGINT PRIMARY KEY,
timezone TEXT NOT NULL
);
--
-- Music (dashboard queue view/control - see velox-dashboard-api's
-- 000010_music migration, this file mirrors it)
--
CREATE TABLE IF NOT EXISTS music_settings (
guild_id BIGINT PRIMARY KEY,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS music_now_playing (
guild_id BIGINT PRIMARY KEY,
track_title TEXT NOT NULL,
track_url TEXT NOT NULL,
duration_seconds INT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
paused BOOLEAN NOT NULL DEFAULT FALSE,
volume INT NOT NULL DEFAULT 100,
repeat_song BOOLEAN NOT NULL DEFAULT FALSE,
repeat_queue BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS music_queue (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
position INT NOT NULL,
track_title TEXT NOT NULL,
track_url TEXT NOT NULL,
requested_by BIGINT NOT NULL,
added_at TIMESTAMPTZ DEFAULT NOW(),
client_id UUID NOT NULL DEFAULT gen_random_uuid(),
UNIQUE (guild_id, client_id)
);
CREATE TABLE IF NOT EXISTS music_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue', 'add_song', 'stop')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Tickets (mirror of velox-dashboard-api migration 000016_tickets)
CREATE TABLE IF NOT EXISTS ticket_settings (
guild_id BIGINT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
channel_id BIGINT,
log_channel_id BIGINT,
panel_message_id BIGINT,
panel_title TEXT NOT NULL DEFAULT 'Need help?',
panel_description TEXT NOT NULL DEFAULT 'Pick a category below to open a private ticket with the staff team.',
max_open_per_user INT NOT NULL DEFAULT 1 CHECK (max_open_per_user BETWEEN 1 AND 10),
next_number INT NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS ticket_categories (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 40),
emoji TEXT,
description TEXT NOT NULL DEFAULT '',
staff_role_id BIGINT,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tickets (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
number INT NOT NULL,
category_id BIGINT REFERENCES ticket_categories (id) ON DELETE SET NULL,
category_name TEXT NOT NULL,
thread_id BIGINT NOT NULL UNIQUE,
intro_message_id BIGINT,
opener_id BIGINT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
claimed_by BIGINT,
closed_by BIGINT,
close_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ,
UNIQUE (guild_id, number)
);
CREATE TABLE IF NOT EXISTS ticket_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('post_panel')),
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Security: anti-raid and anti-nuke (mirror of velox-dashboard-api migration 000017_security)
CREATE TABLE IF NOT EXISTS security_settings (
guild_id BIGINT PRIMARY KEY,
-- Where alerts go, and who gets pinged for raids and nukes.
alert_channel_id BIGINT,
alert_role_id BIGINT,
-- Join Gate: checks run on every join, each with its own action.
joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE,
joingate_no_avatar TEXT NOT NULL DEFAULT 'off' CHECK (joingate_no_avatar IN ('off', 'timeout', 'kick', 'ban')),
joingate_new_account TEXT NOT NULL DEFAULT 'off' CHECK (joingate_new_account IN ('off', 'timeout', 'kick', 'ban')),
joingate_min_account_age_days INT NOT NULL DEFAULT 7 CHECK (joingate_min_account_age_days BETWEEN 1 AND 365),
joingate_advertising_name TEXT NOT NULL DEFAULT 'off' CHECK (joingate_advertising_name IN ('off', 'timeout', 'kick', 'ban')),
joingate_username_filter TEXT NOT NULL DEFAULT 'off' CHECK (joingate_username_filter IN ('off', 'timeout', 'kick', 'ban')),
joingate_username_patterns TEXT[] NOT NULL DEFAULT '{}',
joingate_unverified_bot TEXT NOT NULL DEFAULT 'off' CHECK (joingate_unverified_bot IN ('off', 'kick', 'ban')),
-- A bot added by someone who isn't the owner or trusted.
joingate_unauthorized_bot TEXT NOT NULL DEFAULT 'kick' CHECK (joingate_unauthorized_bot IN ('off', 'kick', 'ban')),
-- Raid mode: raid_join_count joins within raid_join_seconds starts it; it
-- ends after raid_quiet_minutes without a join, or when staff end it.
antiraid_enabled BOOLEAN NOT NULL DEFAULT FALSE,
raid_join_count INT NOT NULL DEFAULT 10 CHECK (raid_join_count BETWEEN 3 AND 100),
raid_join_seconds INT NOT NULL DEFAULT 10 CHECK (raid_join_seconds BETWEEN 5 AND 300),
raid_action TEXT NOT NULL DEFAULT 'kick' CHECK (raid_action IN ('alert', 'timeout', 'kick', 'ban')),
raid_pause_invites BOOLEAN NOT NULL DEFAULT TRUE,
raid_quiet_minutes INT NOT NULL DEFAULT 10 CHECK (raid_quiet_minutes BETWEEN 1 AND 120),
-- Anti-nuke: anyone who isn't the owner or trusted and goes over a limit
-- within antinuke_window_seconds is quarantined. Limits are per action
-- (channel_delete, channel_create, role_delete, role_create, ban, kick,
-- webhook_create); a limit of 0 means the action is never allowed.
antinuke_enabled BOOLEAN NOT NULL DEFAULT FALSE,
antinuke_window_seconds INT NOT NULL DEFAULT 10 CHECK (antinuke_window_seconds BETWEEN 5 AND 300),
antinuke_limits JSONB NOT NULL DEFAULT '{"channel_delete": 2, "channel_create": 5, "role_delete": 2, "role_create": 5, "ban": 3, "kick": 5, "webhook_create": 3}',
-- Also lock every channel when a nuke is stopped.
antinuke_lock_server BOOLEAN NOT NULL DEFAULT FALSE,
quarantine_role_id BIGINT,
-- Restore: snapshots of roles and channels are taken every few hours; a
-- restore recreates what's missing. Automatic after a stopped nuke when
-- antinuke_auto_restore is on, otherwise by the owner or trusted.
antinuke_auto_restore BOOLEAN NOT NULL DEFAULT FALSE,
-- Heat: every message adds heat to its author (more for repeats,
-- mentions, emoji walls, links, blocked words; an invite link maxes it),
-- and heat cools down over time. Reaching the maximum deletes the message
-- and applies heat_action. heat_strictness scales how fast heat builds.
heat_enabled BOOLEAN NOT NULL DEFAULT FALSE,
heat_strictness TEXT NOT NULL DEFAULT 'medium' CHECK (heat_strictness IN ('low', 'medium', 'high')),
heat_action TEXT NOT NULL DEFAULT 'timeout' CHECK (heat_action IN ('timeout', 'kick', 'ban')),
heat_timeout_minutes INT NOT NULL DEFAULT 10 CHECK (heat_timeout_minutes BETWEEN 1 AND 10080),
-- Each new offence within a day doubles the timeout (capped at 28 days).
heat_escalate BOOLEAN NOT NULL DEFAULT TRUE,
heat_blocked_words TEXT[] NOT NULL DEFAULT '{}',
-- Heat panic: this many members maxing out within a minute times out
-- anyone who joined in the last day as soon as they speak, for 10
-- minutes. 0 turns it off.
heat_panic_members INT NOT NULL DEFAULT 3 CHECK (heat_panic_members BETWEEN 0 AND 50),
-- Mention flood: this many mentions by non-exempt members within 30
-- seconds locks every channel. 0 turns it off.
heat_lockdown_mentions INT NOT NULL DEFAULT 0 CHECK (heat_lockdown_mentions BETWEEN 0 AND 1000),
-- Join lockdown (/lockdown joins): every new member is kicked or banned.
join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban'))
);
-- Users and roles exempt from anti-nuke, who may also manage its settings.
-- The server owner is always trusted and never listed here.
CREATE TABLE IF NOT EXISTS security_trusted (
guild_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('user', 'role')),
added_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, target_id)
);
-- Members currently quarantined, with the roles they had, so releasing them
-- gives every role back.
CREATE TABLE IF NOT EXISTS security_quarantines (
guild_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
previous_role_ids BIGINT[] NOT NULL DEFAULT '{}',
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, user_id)
);
-- Channels the bot locked, with @everyone's permission overwrite from before,
-- so unlocking puts back exactly what was there.
CREATE TABLE IF NOT EXISTS security_lockdowns (
guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
had_overwrite BOOLEAN NOT NULL,
previous_allow BIGINT NOT NULL DEFAULT 0,
previous_deny BIGINT NOT NULL DEFAULT 0,
server_wide BOOLEAN NOT NULL DEFAULT FALSE,
locked_by BIGINT NOT NULL,
locked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, channel_id)
);
-- A raid in progress, so raid mode survives a bot restart: the quiet timer
-- carries on and the summary still lists everyone caught.
CREATE TABLE IF NOT EXISTS security_raids (
guild_id BIGINT PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL,
last_join_at TIMESTAMPTZ NOT NULL,
invites_paused BOOLEAN NOT NULL DEFAULT FALSE,
caught_total INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS security_raid_caught (
guild_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
action TEXT NOT NULL,
name TEXT NOT NULL,
caught_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, user_id)
);
-- Snapshots of a guild's roles and channels for restoring after a nuke. The
-- bot keeps the last three per guild.
CREATE TABLE IF NOT EXISTS security_snapshots (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
data JSONB NOT NULL,
roles INT NOT NULL,
channels INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS security_snapshots_guild_idx ON security_snapshots (guild_id, created_at DESC);
-- What the system did, for the dashboard's history.
CREATE TABLE IF NOT EXISTS security_events (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
kind TEXT NOT NULL,
actor_id BIGINT,
target_id BIGINT,
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS security_events_guild_idx ON security_events (guild_id, created_at DESC);
-- Dashboard -> bot requests, same pattern as music_commands.
CREATE TABLE IF NOT EXISTS security_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('end_raid', 'release_quarantine', 'unlock_server', 'unlock_joins', 'snapshot', 'restore')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);