Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fefa74e045 | ||
|
|
1ee6280d91 | ||
|
|
a364435bab | ||
|
|
136377d81a | ||
|
|
0218676e2d | ||
|
|
f285367511 | ||
|
|
e10e1f7a86 | ||
|
|
e453a9ba94 | ||
|
|
95072fcd53 | ||
|
|
3f87e68a2c | ||
|
|
d294b3463b | ||
|
|
b1ea5f17e3 | ||
|
|
23d417ef27 | ||
|
|
52dfe859ee | ||
|
|
da66eb8dd5 | ||
|
|
fcc7c9c66a | ||
|
|
7a17c55774 | ||
|
|
39962457b3 | ||
|
|
539c0a69af | ||
|
|
d6687879dd | ||
|
|
e507225cae | ||
|
|
c7b960e14a | ||
|
|
691b505175 | ||
|
|
95ba434194 | ||
|
|
5f4e893971 | ||
|
|
01dbb98450 | ||
|
|
5024af2789 | ||
|
|
483dc666d1 | ||
|
|
3585fa0658 | ||
|
|
d1be60e201 | ||
|
|
26e1d9b094 | ||
|
|
461f8435c0 | ||
|
|
1b47bd13a3 | ||
|
|
035e383db2 | ||
|
|
2b14079e50 | ||
|
|
a92c4131db | ||
|
|
397a8a459a | ||
|
|
4080ce8aec | ||
|
|
e6aa5def96 | ||
|
|
6410619662 | ||
|
|
df656ca12d | ||
|
|
076e616b9c | ||
|
|
891f3f6ad2 | ||
|
|
ded31409e2 | ||
|
|
ae3d574c68 | ||
|
|
d7edfea45e | ||
|
|
db5d23c2ac | ||
|
|
a85ac23a6b | ||
|
|
d177114ddb | ||
|
|
10c2efef7d | ||
|
|
e33eea6d24 | ||
|
|
76aeca3de6 | ||
|
|
c4dd7302bc | ||
|
|
0b36bc9e93 | ||
|
|
dee5008c60 | ||
|
|
a664ae15fd |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
+8
-1
@@ -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
@@ -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"]
|
||||
@@ -0,0 +1,76 @@
|
||||
# Velox Bot
|
||||
|
||||
Velox is a Discord bot written in Go, backed by PostgreSQL. It covers server security (anti-raid and anti-nuke), support tickets, moderation, leveling, music, meeting rooms, scheduling, Twitch alerts and a few games. Every module is off until a server turns it on.
|
||||
|
||||
This repository is the bot. It has two companions that live in their own repositories:
|
||||
|
||||
- **velox-dashboard-api**: a Go REST API behind the web dashboard. It owns most of the database schema and runs the migrations.
|
||||
- **velox-dashboard-web**: the Vue dashboard and public website.
|
||||
|
||||
The bot and the API share one PostgreSQL database. They never call each other over HTTP. The dashboard writes settings and requests into tables, and the bot reads them (and is woken through Postgres `NOTIFY` where it has to react at once). See [Architecture](docs/architecture.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
This README is the front page. The rest of the documentation lives in [`docs/`](docs/), organised like a wiki:
|
||||
|
||||
| Page | What is in it |
|
||||
| --- | --- |
|
||||
| [Getting started](docs/getting-started.md) | Requirements, creating the Discord application, configuration, database, running locally |
|
||||
| [Deployment](docs/deployment.md) | Docker, Compose, upgrade order, backups |
|
||||
| [Architecture](docs/architecture.md) | How the code is laid out, how an interaction flows through it, background loops, the link to the dashboard |
|
||||
| [Features](docs/features.md) | What each module does and how it behaves |
|
||||
| [Command reference](docs/commands.md) | Every slash command, its options and who may run it |
|
||||
| [Database](docs/database.md) | Who owns the schema, the tables, the `NOTIFY` channels, how to change them |
|
||||
| [Development guide](docs/development.md) | How to add commands, subcommands, buttons, events, background jobs, settings and tables; testing; code style |
|
||||
| [Beta features](docs/beta-features.md) | How features are tried by a few testers before release |
|
||||
| [Troubleshooting](docs/troubleshooting.md) | Common problems and what they mean |
|
||||
|
||||
## Quick start
|
||||
|
||||
You need Go (the version in `go.mod`), PostgreSQL 16 or newer, and a Discord application with a bot token.
|
||||
|
||||
```bash
|
||||
git clone <this repository>
|
||||
cd velox-bot
|
||||
# create .env with BOT_TOKEN, APP_ID, GUILD_ID, DB_HOST and TWITCH_CLIENT_ID (see docs/getting-started.md)
|
||||
docker compose up -d db # PostgreSQL on localhost:5432
|
||||
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
||||
go run .
|
||||
```
|
||||
|
||||
On start the bot registers its slash commands in the server named by `GUILD_ID` (or globally if that is empty). The full walkthrough, including the Discord side, is in [Getting started](docs/getting-started.md).
|
||||
|
||||
## What it does
|
||||
|
||||
- **Security**: Join Gate, raid mode, anti-nuke with quarantine, lockdowns, snapshots and restore, and a spam "heat" system, modelled on Wick.
|
||||
- **Tickets**: a panel with categories, a private thread per ticket, claim and close, transcripts to a log channel.
|
||||
- **Moderation**: purge, timeout, kick, ban, unban, slowmode, plus server logs for edits, deletes, joins, leaves, role changes, bans and mod actions.
|
||||
- **Leveling**: XP per message, level cards, leaderboard, role rewards.
|
||||
- **Music**: Lavalink-powered playback with a queue, controlled from Discord or the dashboard.
|
||||
- **Meeting rooms**: join a lobby voice channel and get a temporary room you can lock and invite people to.
|
||||
- **Scheduling and projects**: 1:1 sessions with DM invites and reminders, and a simple project list.
|
||||
- **Twitch alerts, welcome messages, default role**: per-server setup through `/config`.
|
||||
- **Fun**: `/ping`, `/joke`, `/coinflip`, `/dice`, `/rps` with stats and a leaderboard.
|
||||
|
||||
When the bot joins a server for the first time, toggleable systems start **disabled** and the person who invited it gets a DM with setup instructions.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
main.go wiring: config, database, repos, services, bot
|
||||
internal/config environment variables
|
||||
internal/bot Discord session, intents, event registration, startup
|
||||
internal/commands slash commands, one package per feature, plus the dispatcher
|
||||
internal/events handlers for gateway events and background loops
|
||||
internal/db connection, repos (SQL), services (rules)
|
||||
internal/security anti-raid and anti-nuke engine
|
||||
internal/tickets ticket flows
|
||||
internal/music Lavalink player, queue, buttons
|
||||
internal/beta beta access gate
|
||||
internal/modlog, rankcard small shared helpers
|
||||
schema.sql mirror of the tables the bot reads and writes
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Start with the [Development guide](docs/development.md). In short: keep a command's definition and its handler in one package, keep SQL in a repo and rules in a service, and run `go build ./... && go vet ./... && go test ./...` before opening a pull request.
|
||||
+15
-2
@@ -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:
|
||||
@@ -0,0 +1,131 @@
|
||||
# Architecture
|
||||
|
||||
This page explains how the bot is put together, so you know where to look and where to make a change.
|
||||
|
||||
## The big picture
|
||||
|
||||
```
|
||||
Discord gateway (websocket)
|
||||
|
|
||||
discordgo session
|
||||
|
|
||||
+--------------------------+---------------------------+
|
||||
| | |
|
||||
interactions gateway events background loops
|
||||
(slash commands, (messages, joins, bans, (Twitch, reminders,
|
||||
buttons, modals) voice, audit log, ...) raid watch, snapshots)
|
||||
| | |
|
||||
internal/commands internal/events internal/events, security
|
||||
| | |
|
||||
+----------- services (rules) <------ repos (SQL) -----+
|
||||
|
|
||||
PostgreSQL <----> velox-dashboard-api <----> dashboard
|
||||
```
|
||||
|
||||
The bot is one Go process. It keeps one websocket connection to Discord and one connection pool to PostgreSQL. It has no HTTP server of its own.
|
||||
|
||||
## Startup
|
||||
|
||||
`main.go` wires everything in this order:
|
||||
|
||||
1. **Config**: `config.LoadConfig()` reads the environment (and `.env`).
|
||||
2. **Database**: `db.NewDB()` opens a `database/sql` pool using the pgx driver.
|
||||
3. **Repos**: one repo per area (`levelrepo`, `settingsrepo`, `ticketrepo`, ...). A repo is the only place that writes SQL.
|
||||
4. **Services**: small structs holding the rules for one area (`level`, `meeting`, `welcome`, ...), built on top of repos, collected in `services.Services`.
|
||||
5. **Singletons for the bigger modules**: tickets and security get their repo with `cmdtickets.Init(...)` and `security.Configure(...)`, and are reached later through `tickets.Default()` and `security.Default()`.
|
||||
6. **Bot**: `bot.NewBot()` creates the Discord session and chooses the gateway intents.
|
||||
7. **`bot.Start()`** opens the connection and then:
|
||||
- starts the music player (if Lavalink is configured) and its command listener,
|
||||
- starts the ticket and security engines and their command listeners (each reconnects every 10 seconds if its database connection drops),
|
||||
- registers every slash command in `GUILD_ID` (or globally),
|
||||
- registers the gateway event handlers,
|
||||
- starts the Twitch and schedule-reminder loops.
|
||||
8. **`bot.WatchBeta()`** reads the beta settings from the database and keeps them fresh every 30 seconds. See [Beta features](beta-features.md).
|
||||
9. The process waits for `SIGINT` or `SIGTERM`.
|
||||
|
||||
## Layers
|
||||
|
||||
The code follows one direction: commands and events call services, services call repos, repos run SQL.
|
||||
|
||||
| Layer | Where | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Commands | `internal/commands/<feature>` | Describe the slash command, check who is allowed, read the options, answer the user. No SQL. |
|
||||
| Events | `internal/events` | React to gateway events and run background loops. |
|
||||
| Engines | `internal/security`, `internal/tickets`, `internal/music` | The bigger modules, with their own state and flows. |
|
||||
| Services | `internal/db/services/<area>` | Rules and orchestration (for example the XP rules in `level`). |
|
||||
| Repos | `internal/db/repos/<area>repo` | SQL, and nothing else. |
|
||||
|
||||
Commands reach services through the package-level `services.Global`, set by `services.NewServices()`. A shared helper in each command package (for example `RequireMeetingService`) answers "service not available" if it is nil. Event handlers receive the services as an argument.
|
||||
|
||||
## How a slash command runs
|
||||
|
||||
1. Discord sends an interaction. discordgo calls `commands.HandleInteraction` (`internal/commands/commands.go`).
|
||||
2. For a slash command, the dispatcher looks the command's name up in the `handlers` map and calls the handler.
|
||||
3. The handler checks the context (server only? permissions?), finds the subcommand in `i.ApplicationCommandData().Options`, reads the options, calls a service, and responds with `s.InteractionRespond`.
|
||||
|
||||
Buttons, select menus and modals do not have a command name. They carry a **custom ID**, and the dispatcher sends them to a feature by its **prefix**: `music:`, `schedule:`, `projects:`, `tickets:` or `security:`. A feature owns everything after its prefix.
|
||||
|
||||
Some commands have a response that takes longer than three seconds (anything that calls Discord several times). Those answer with a deferred response first, then edit it when done. Search the code for `InteractionResponseDeferredChannelMessageWithSource` for examples.
|
||||
|
||||
## How a gateway event runs
|
||||
|
||||
`bot.Start()` registers one handler per event with `Session.AddHandler`. Most of them hand the event to a function in `internal/events`, together with the services. A few go to the security engine first, because it must decide before anything else happens:
|
||||
|
||||
- **Member joins**: the security engine runs first. If it removes the member (join lock, Join Gate, raid mode) there is no welcome message or default role. Otherwise the welcome, default role and join log run.
|
||||
- **Messages**: the security engine's heat check runs, then the leveling handler.
|
||||
- **Audit log entries**: only the security engine (anti-nuke, unauthorised bots).
|
||||
- **Voice state changes**: meeting rooms and the music player.
|
||||
|
||||
## Background loops
|
||||
|
||||
| Loop | Where | Interval | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| Twitch live check | `events/twitch_live.go` | 30 s | Checks configured streamers and posts when one goes live |
|
||||
| Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session |
|
||||
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period |
|
||||
| Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) |
|
||||
| Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are |
|
||||
| Command listeners | music, tickets, security | on `NOTIFY` | Carry out dashboard requests |
|
||||
|
||||
## The link to the dashboard
|
||||
|
||||
The bot and the dashboard API do not talk to each other directly. They meet in PostgreSQL, in two ways:
|
||||
|
||||
1. **Settings tables.** The dashboard writes settings (log channel, security thresholds, ticket categories, beta role). The bot reads them when it needs them. Hot paths cache them for a short time, so a raid of thousands of joins does not become thousands of queries.
|
||||
2. **Command tables with `NOTIFY`.** When the dashboard needs the bot to *do* something (skip a song, post the ticket panel, release a quarantined member), it inserts a row in `music_commands`, `ticket_commands` or `security_commands` and issues `NOTIFY` on that channel. The bot has a long-lived connection that runs `LISTEN`, picks up the row, validates it again and carries it out. Never trust a row just because it is in the table: the listeners re-check permissions themselves.
|
||||
|
||||
Both directions are described table by table in [Database](database.md).
|
||||
|
||||
## Gateway intents
|
||||
|
||||
Set in `internal/bot/bot.go`:
|
||||
|
||||
| Intent | Why |
|
||||
| --- | --- |
|
||||
| Guilds | slash commands, channel and role events |
|
||||
| Guild Moderation (bans, audit log) | ban logs, anti-nuke |
|
||||
| Guild Members (privileged) | joins, leaves, member updates |
|
||||
| Guild Messages and Message Content (privileged) | leveling, spam heat, message logs |
|
||||
| Guild Voice States | meeting rooms, music |
|
||||
| Direct Messages | direct messages sent to the bot |
|
||||
|
||||
If you add a feature that needs another event, add its intent here too, and enable it in the developer portal if it is privileged.
|
||||
|
||||
## Concurrency and safety notes
|
||||
|
||||
- discordgo calls event handlers on goroutines, so handlers must not share unsynchronised state. The engines use mutexes, and per-server memory is bounded.
|
||||
- Security-sensitive actions do not rely on Discord's command permissions, because server admins can change those. The handlers check the owner and the trusted list themselves (see the [Security](features.md#security) section).
|
||||
- User-controlled text (usernames, reasons) is escaped or kept out of mentions. Messages only ping roles that are configured for it.
|
||||
- Settings that decide who has access (the beta role) fail closed: if they cannot be read, nobody gets access.
|
||||
|
||||
## Where things live
|
||||
|
||||
| I want to find... | Look in |
|
||||
| --- | --- |
|
||||
| The definition and handler of `/foo` | `internal/commands/foo/public/` (the `registry.go` next to it re-exports it) |
|
||||
| The list of all commands and the dispatcher | `internal/commands/commands.go` |
|
||||
| What happens on a gateway event | `internal/bot/bot.go`, then `internal/events/` |
|
||||
| The SQL behind a feature | `internal/db/repos/<area>repo/repo.go` |
|
||||
| The rules behind a feature | `internal/db/services/<area>/` |
|
||||
| The table definitions | `schema.sql` and [Database](database.md) |
|
||||
| Environment variables | `internal/config/config.go` |
|
||||
@@ -0,0 +1,51 @@
|
||||
# Beta features
|
||||
|
||||
A beta feature is one that only a few chosen people can use while it is still being tried out. The same rule applies on the bot and on the dashboard: you can use beta features if you are **a member of the beta server and hold the Beta Tester role**.
|
||||
|
||||
## Who the testers are
|
||||
|
||||
- The **beta server** and the **Beta Tester role** are chosen by the bot's owner on the dashboard, under **Admin, Beta access**. Only the owner can see or change this page.
|
||||
- Giving someone the role in Discord makes them a tester. Taking it away removes their access: within about 30 seconds on the bot, and within about 30 seconds on the dashboard API.
|
||||
- Until a role is chosen, **nobody** has beta access.
|
||||
- The settings are stored in the `beta_settings` table (one row). The API writes it, and the bot polls it every 30 seconds. There is nothing to put in `.env`.
|
||||
|
||||
Access **fails closed**. If the settings cannot be read, if the role or the server is not set, if a person is not in the server, if a command is used in a DM, or if Discord cannot be reached, access is denied.
|
||||
|
||||
## On the bot
|
||||
|
||||
Code: `internal/beta` (the gate), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `registerBetaCommands`), `internal/commands/betacmd` (`/beta`).
|
||||
|
||||
- Beta commands are registered **only in the beta server**, so nobody else sees them in the command menu.
|
||||
- Each beta handler is wrapped in `beta.Gate`, which checks that the interaction came from the beta server and that the member has the role. If not, the user gets "This feature is in beta and isn't available to you yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
|
||||
- If the owner changes the beta server on the dashboard, the bot moves the beta commands to the new server within 30 seconds and removes them from the old one. Commands in the beta server that are neither regular nor beta commands are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
|
||||
- `/beta` shows the owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators.
|
||||
|
||||
### Add a beta command
|
||||
|
||||
Write the command like any other (see the [Development guide](development.md#add-a-simple-command)), but instead of adding it to `AllCommands` and `handlers`, register it from `commands.go`:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
RegisterBeta(mything.MyThing, mything.MyThingHandler)
|
||||
}
|
||||
```
|
||||
|
||||
`RegisterBeta` adds the definition to `BetaCommands` and the handler, wrapped in `beta.Gate`, to the dispatcher. Buttons and forms that belong to a beta command need the same care: check `beta.Guard(s, i)` at the top of their handlers, because a custom ID can be reached from an old message.
|
||||
|
||||
### Release it
|
||||
|
||||
Move the definition into `AllCommands` and the handler into the `handlers` map, and delete the `RegisterBeta` call. On the next start the command appears everywhere, and it is removed from the beta server's own list because it is now a regular command.
|
||||
|
||||
## On the API and dashboard
|
||||
|
||||
These live in the other two repositories.
|
||||
|
||||
- **API**: put the feature's routes under `/api/v1/beta`. That route group sits behind a `betaOnly` middleware, which asks Discord whether the signed-in user holds the role in the beta server (with a 30-second memory) and answers `403` otherwise. `GET /users/me` also returns `is_beta`, which the dashboard uses to decide what to show.
|
||||
- **Dashboard**: give a route `meta: { requiresBeta: true }` to keep its page away from non-testers, and use `is_beta` to hide its links. This is only about display. The API checks access on every request, so a page that was shown by mistake still cannot read or change anything.
|
||||
|
||||
## Why it works this way
|
||||
|
||||
- The role in Discord is the single list of testers, so there is no second list to keep in step with it.
|
||||
- Access is decided by the server (bot and API), never by the browser.
|
||||
- Beta commands do not exist outside the beta server, and the gate checks again when they run.
|
||||
- The settings are in the database, not in the environment, so the owner can change them without a deploy.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Command reference
|
||||
|
||||
Every slash command the bot registers. Options marked with `*` are required. "Needs" is what the handler checks, not just what Discord shows in its menu. Commands that need a server cannot be used in DMs.
|
||||
|
||||
This page is written by hand from the command definitions in `internal/commands/*/public`. If you change a command, update it here and in `/help` (`internal/commands/help/help.go`). The [Features](features.md) page explains the behaviour behind them.
|
||||
|
||||
- [Fun](#fun)
|
||||
- [Help](#help)
|
||||
- [Leveling](#leveling)
|
||||
- [Music](#music)
|
||||
- [Meeting rooms](#meeting-rooms)
|
||||
- [Moderation](#moderation)
|
||||
- [Timezone and scheduling](#timezone-and-scheduling)
|
||||
- [Projects](#projects)
|
||||
- [Server setup (`/config`)](#server-setup-config)
|
||||
- [Tickets](#tickets)
|
||||
- [Security](#security)
|
||||
- [Beta](#beta)
|
||||
|
||||
## Fun
|
||||
|
||||
Anyone can use these. `/rps`, `/rpsstats` and `/rpsleaderboard` work in servers only.
|
||||
|
||||
| Command | Options | What it does |
|
||||
| --- | --- | --- |
|
||||
| `/ping` | | Replies "Pong!" |
|
||||
| `/joke` | `type` | A random joke, optionally of a type |
|
||||
| `/coinflip` | | Heads or tails |
|
||||
| `/dice` | `expr`* | Rolls a dice expression: `d20`, `2d20+1`, `2#d20+1` |
|
||||
| `/rps` | `hand`* | Rock paper scissors against the bot |
|
||||
| `/rpsstats` | | Your rock-paper-scissors record |
|
||||
| `/rpsleaderboard` | | The server's leaderboard |
|
||||
|
||||
## Help
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `/help` | DMs you a command reference |
|
||||
|
||||
## Leveling
|
||||
|
||||
| Command | Options | Needs | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `/slvl toggle` | | Manage Server | Turns leveling on or off |
|
||||
| `/slvl set` | `user`*, `level`* | Manage Server, leveling on | Sets a member's level |
|
||||
| `/slvl set-channel` | `channel`* | Manage Server, leveling on | Where level-up messages go |
|
||||
| `/slvl set-message` | `message`* | Manage Server, leveling on | The level-up message. Variables: `{user}`, `{level}` |
|
||||
| `/slvl set-reward` | `level`*, `role`* | Manage Server, leveling on | Role granted at a level |
|
||||
| `/rank` | `user` | Leveling on | A rank card for you or another member |
|
||||
| `/leaderboard` | | Leveling on | Top 10 in the server |
|
||||
| `/rewards` | | Leveling on | The role rewards |
|
||||
|
||||
## Music
|
||||
|
||||
| Command | Options | Needs | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `/play` | `query`* | **DJ** role | Plays a song by name or URL |
|
||||
| `/queue` | | **DJ** role | Shows the queue |
|
||||
| `/volume` | `value`* (0 to 150) | **DJ** role | Sets the volume |
|
||||
| `/music toggle` | | Manage Server | Turns music on or off in this server |
|
||||
|
||||
Requires Lavalink. See [Getting started](getting-started.md#optional-music-with-lavalink).
|
||||
|
||||
## Meeting rooms
|
||||
|
||||
Every subcommand needs **Manage Server** and works in servers only.
|
||||
|
||||
| Command | Options | What it does |
|
||||
| --- | --- | --- |
|
||||
| `/meeting add-lobby` | `channel`* (voice) | Makes a voice channel a lobby (up to 10 per server) |
|
||||
| `/meeting remove-lobby` | `channel`* (voice) | Stops using it as a lobby |
|
||||
| `/meeting lobbies` | | Lists the lobbies |
|
||||
| `/meeting lock` | | Locks the room you are in |
|
||||
| `/meeting lock-private` | | Locks it and hides it |
|
||||
| `/meeting unlock` | | Unlocks it |
|
||||
| `/meeting invite` | `user`* | Lets a user into a locked room |
|
||||
| `/meeting uninvite` | `user`* | Removes that access |
|
||||
|
||||
## Moderation
|
||||
|
||||
Each subcommand checks the Discord permission in its "Needs" column.
|
||||
|
||||
| Command | Options | Needs | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `/moderation purge` | `amount`* (1 to 100) | Manage Messages | Deletes recent messages in the channel |
|
||||
| `/moderation timeout` | `user`*, `duration`* (`10m`, `1h`, `2h30m`, up to 28d), `reason` | Moderate Members | Times a member out |
|
||||
| `/moderation untimeout` | `user`*, `reason` | Moderate Members | Removes a timeout |
|
||||
| `/moderation kick` | `user`*, `reason` | Kick Members | Kicks a member |
|
||||
| `/moderation ban` | `user`*, `delete_days` (0 to 7), `reason` | Ban Members | Bans a member |
|
||||
| `/moderation unban` | `user_id`*, `reason` | Ban Members | Unbans by ID |
|
||||
| `/moderation slowmode` | `channel`*, `seconds`* (0 to 21600) | Manage Channels | Sets slowmode, 0 turns it off |
|
||||
|
||||
## Timezone and scheduling
|
||||
|
||||
Anyone can use these.
|
||||
|
||||
| Command | Options | What it does |
|
||||
| --- | --- | --- |
|
||||
| `/timezone set` | `zone`* (IANA name, such as `Europe/Lisbon`) | Saves your timezone |
|
||||
| `/timezone show` | | Shows it |
|
||||
| `/schedule create` | | Opens a form (date, time, description) to request a session with a member |
|
||||
| `/schedule list` | | Your upcoming sessions |
|
||||
| `/schedule reschedule` | `id`* (from `/schedule list`), `datetime`* (UTC, `2006-01-02 15:04`) | Proposes a new time |
|
||||
|
||||
## Projects
|
||||
|
||||
| Command | Needs | What it does |
|
||||
| --- | --- | --- |
|
||||
| `/projects list` | | Active projects with creators and helpers |
|
||||
| `/projects create` | Manage Server | Opens a form to create a project |
|
||||
| `/projects add-helper` | Manage Server | Adds a helper to a project |
|
||||
| `/projects close` | Manage Server | Closes a project |
|
||||
|
||||
## Server setup (`/config`)
|
||||
|
||||
Every subcommand needs **Manage Server**.
|
||||
|
||||
| Command | Options | What it does |
|
||||
| --- | --- | --- |
|
||||
| `/config setwelcomechannel` | `channel`* | Where welcome messages go |
|
||||
| `/config setwelcomemessage` | `message`* | Welcome template. `{user}` is a mention, `{server}` the server name |
|
||||
| `/config setwelcomedm` | `message`* | The welcome DM template |
|
||||
| `/config setwelcomegif` | `url`* | Image or GIF for the welcome embed |
|
||||
| `/config setdefaultrole` | `role`* | Role given on join |
|
||||
| `/config setlogchannel` | `channel`* | Where logs go |
|
||||
| `/config enablelogging` | | Turns logging on |
|
||||
| `/config disablelogging` | | Turns it off |
|
||||
| `/config addstreamer` | `username`* | Adds a Twitch streamer to watch |
|
||||
| `/config removestreamer` | `username`* | Removes one |
|
||||
| `/config settwitchnotificationchannel` | `channel`* | Where live alerts go |
|
||||
|
||||
## Tickets
|
||||
|
||||
| Command | Options | Needs | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `/tickets setup` | `channel`*, `log-channel`, `staff-role` | Manage Server | Where tickets open and where closed ones are logged. `staff-role` creates a Support category if there are none yet |
|
||||
| `/tickets category add` | `name`*, `staff-role`, `emoji`, `description` | Manage Server | Adds a category to the panel's dropdown |
|
||||
| `/tickets category remove` | `name`* | Manage Server | Removes a category |
|
||||
| `/tickets category list` | | Manage Server | Lists categories |
|
||||
| `/tickets panel` | | Manage Server | Posts the panel, or updates it after changing categories |
|
||||
| `/ticket add` | `user`* | The opener or staff | Adds someone to the ticket |
|
||||
| `/ticket remove` | `user`* | Staff | Removes someone |
|
||||
| `/ticket claim` | | Staff | Takes the ticket |
|
||||
| `/ticket close` | `reason` | The opener or staff | Closes it and posts a transcript |
|
||||
|
||||
## Security
|
||||
|
||||
All of these are hidden from members without Manage Server (and `/lockdown` from those without Manage Channels), but **hiding is not the check**. The handlers check again, and some need the **server owner or someone on the trusted list** whatever Discord permissions a person has.
|
||||
|
||||
In this table "Manage Server" also lets Administrators and trusted members through.
|
||||
|
||||
| Command | Options | Needs | What it does |
|
||||
| --- | --- | --- | --- |
|
||||
| `/security status` | | Manage Server | What is on, and who is trusted |
|
||||
| `/security toggle` | `feature`* (Join Gate, Raid mode, Anti-nuke, Heat), `on`* | Manage Server to turn on; owner or trusted to turn off | Turns a protection on or off |
|
||||
| `/security alerts` | `channel`*, `role` | Owner or trusted | Alert channel and the role to ping |
|
||||
| `/security quarantine-role` | `role` | Owner or trusted | Role given to quarantined members (empty for none) |
|
||||
| `/security trust add` | `user` or `role` | Owner only | Exempts a user or role from anti-nuke |
|
||||
| `/security trust remove` | `user` or `role` | Owner only | Removes that exemption |
|
||||
| `/security quarantined` | | Manage Server | Lists quarantined members |
|
||||
| `/security release` | `user`* | Owner or trusted | Releases a member and gives their roles back |
|
||||
| `/security end-raid` | | Owner or trusted | Ends raid mode now |
|
||||
| `/security snapshot` | | Manage Server | Saves roles and channels |
|
||||
| `/security restore` | | Owner or trusted | Recreates deleted roles and channels from the latest snapshot |
|
||||
| `/lockdown channel` | `channel` (default: this one) | Manage Channels | Locks a channel |
|
||||
| `/lockdown server` | | Manage Channels | Locks every channel |
|
||||
| `/lockdown joins` | `action`* (Kick or Ban) | Manage Channels | Kicks or bans new members until unlocked |
|
||||
| `/unlock channel` | `channel` | Owner or trusted | Unlocks a channel |
|
||||
| `/unlock server` | | Owner or trusted | Unlocks every channel |
|
||||
| `/unlock joins` | | Owner or trusted | Lets people join again |
|
||||
|
||||
## Beta
|
||||
|
||||
| Command | Needs | What it does |
|
||||
| --- | --- | --- |
|
||||
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only |
|
||||
|
||||
Beta features come and go. See [Beta features](beta-features.md).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Database
|
||||
|
||||
The bot uses PostgreSQL through `database/sql` with the pgx driver. All SQL lives in repos under `internal/db/repos`.
|
||||
|
||||
## Who owns the schema
|
||||
|
||||
The bot and the dashboard API share one database, so two files describe it:
|
||||
|
||||
- **`schema.sql`** in this repository: the tables the bot reads and writes. It is safe to run more than once (`CREATE TABLE IF NOT EXISTS`). It is enough to run the bot **on its own**.
|
||||
- **The API's migrations** (`velox-dashboard-api/cmd/db/migrations`): when the API is deployed, it creates and changes the tables, and runs its migrations on start. The bot never runs migrations.
|
||||
|
||||
Some tables are older and were created by the bot (levels, welcome, and so on). The API's migration `000005_bot_owned_tables` creates them for a fresh combined database. Newer modules (music, tickets, security, meeting lobbies, beta settings) were created by API migrations, and `schema.sql` carries a copy so the bot can run alone.
|
||||
|
||||
**The rule:** a table must have the same shape in `schema.sql` and in the API migrations. When you change one, change the other in the same release.
|
||||
|
||||
Two things only exist in the API's migrations, because the bot never inserts into those tables:
|
||||
|
||||
- the `NOTIFY` triggers on the three `*_commands` tables (see below),
|
||||
- the migration history.
|
||||
|
||||
## Tables
|
||||
|
||||
| Area | Tables | Notes |
|
||||
| --- | --- | --- |
|
||||
| Leveling | `levels`, `levelsettings`, `levelrewards`, `levelup` | XP and level per member, settings, role rewards, the level-up channel |
|
||||
| Welcome | `welcome`, `defaultrole` | Welcome channel, templates, GIF, default role |
|
||||
| Logging | `logsettings` | Log channel and on/off |
|
||||
| Twitch | `twitch`, `twitch_config` | Streamers and the notification channel |
|
||||
| Fun | `rps` | Rock-paper-scissors stats |
|
||||
| Meeting rooms | `meeting_lobbies`, `meeting_temp_channels`, `meeting_settings` | Lobbies (10 per server, enforced by a trigger), the temporary rooms and who created them. `meeting_settings.lobby_channel_id` is the old single lobby column and is no longer read |
|
||||
| Projects | `projects`, `project_members` | |
|
||||
| Scheduling | `schedules`, `schedule_messages`, `user_settings` | Sessions, the DM messages to update, and each member's timezone |
|
||||
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard |
|
||||
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | |
|
||||
| Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests |
|
||||
| Beta | `beta_settings` | One row: the beta server and the Beta Tester role. Written by the dashboard, read by the bot |
|
||||
|
||||
Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge.
|
||||
|
||||
## Dashboard to bot: settings and requests
|
||||
|
||||
There are two kinds of traffic from the dashboard, and the difference matters.
|
||||
|
||||
**Settings.** The dashboard writes a row, and the bot reads it when it next needs it. Nothing wakes the bot up. Hot paths cache settings for a short time. The bot's beta settings are polled every 30 seconds.
|
||||
|
||||
**Requests that need the bot to act.** `music_commands`, `ticket_commands` and `security_commands` work as a small queue:
|
||||
|
||||
1. The dashboard API inserts a row (`command_type`, a JSON `payload`, `requested_by`, `processed_at` empty).
|
||||
2. A trigger in the database runs `pg_notify('<table>', id)`.
|
||||
3. The bot holds one connection that ran `LISTEN <table>`. It wakes, loads **every** unprocessed row, carries each out, and sets `processed_at`.
|
||||
|
||||
Because the bot loads all unprocessed rows (also when it starts), nothing is lost if it was down when a request arrived. A command that fails is logged and marked processed anyway, since retrying would fail the same way.
|
||||
|
||||
Every listener **validates the request again**. The API checks that the requester is allowed, and the bot checks too, because it should not trust a row just because it exists. The listeners live in `internal/music/listener.go`, `internal/tickets/listener.go` and `internal/security/listener.go`, and each reconnects after 10 seconds if its connection drops.
|
||||
|
||||
## Changing the schema
|
||||
|
||||
1. Decide who owns the table (see above). New tables for the dashboard's modules are created by an API migration.
|
||||
2. Write the API migration, **and** update `schema.sql` to match.
|
||||
3. Add or change the repo method in `internal/db/repos/<area>repo`. Use `IF NOT EXISTS` and avoid destructive changes: the bot and the dashboard may be deployed a few minutes apart, so the old bot must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release.
|
||||
4. If the dashboard must make the bot do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener.
|
||||
5. Say in the release notes if the API, bot and dashboard have to deploy together.
|
||||
|
||||
## Connection settings
|
||||
|
||||
`internal/db/db.go` opens the pool with up to 100 open and 10 idle connections, and recycles them after an hour. The listeners use their own separate connections, because `LISTEN` needs one that stays open.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Deployment
|
||||
|
||||
## Docker
|
||||
|
||||
The `Dockerfile` is a two-stage build: it compiles a static binary with Go, then copies it into a small Alpine image (with CA certificates and timezone data, which `/timezone` and scheduling need).
|
||||
|
||||
```bash
|
||||
docker build -t velox-bot .
|
||||
docker run --env-file .env velox-bot
|
||||
```
|
||||
|
||||
The container reads its configuration from environment variables, exactly like a local run. See [Getting started](getting-started.md#2-configure-the-environment).
|
||||
|
||||
## Docker Compose
|
||||
|
||||
`docker-compose.yml` defines two services:
|
||||
|
||||
- `db`: PostgreSQL 16 with a named volume (`velox_pgdata`) and a health check.
|
||||
- `bot`: the bot, built from this directory, started after the database is healthy. It takes `.env` through `env_file`, and defaults `DB_HOST` to the `db` service and `LAVALINK_HOST` to `ws://lavalink:2333` if you have not set them.
|
||||
|
||||
```bash
|
||||
docker compose up -d # database and bot
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
Compose does not include Lavalink. Run it separately (or add a service) if you want music.
|
||||
|
||||
Change the default database password (`velox_pwd`) before exposing PostgreSQL to anything other than your own machine, and remove the published `5432` port if only the bot needs it.
|
||||
|
||||
## Running next to the dashboard
|
||||
|
||||
If you also run `velox-dashboard-api` and `velox-dashboard-web`:
|
||||
|
||||
1. They all use the **same database**. Give each the same PostgreSQL, and point `DB_HOST` (bot) and the API's `DB_ADDR` at it.
|
||||
2. The API runs the migrations when it starts. The bot does not run migrations.
|
||||
3. **Deploy order for changes that touch the database:** API first (it applies the migration), then the bot and the dashboard.
|
||||
|
||||
Some changes need the API, the bot and the dashboard to go out together. For example, when a table the dashboard writes changes shape, an old dashboard talking to a new API fails. The release notes for such a change say so.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build bot
|
||||
docker compose up -d bot
|
||||
```
|
||||
|
||||
Slash commands are re-registered on every start, so new and changed commands appear after a restart. Commands you removed from the code are not deleted from Discord automatically, except for the beta server (see [Beta features](beta-features.md)).
|
||||
|
||||
## Backups
|
||||
|
||||
All state is in PostgreSQL. Back it up with `pg_dump`:
|
||||
|
||||
```bash
|
||||
pg_dump "postgres://velox:velox_pwd@localhost:5432/velox" > velox-backup.sql
|
||||
```
|
||||
|
||||
Raid mode, quarantines, lockdowns and ticket state live in the database, so the bot picks them up again after a restart.
|
||||
|
||||
## Shutting down
|
||||
|
||||
Send `SIGINT` or `SIGTERM` (Docker does on `docker stop`). Slash commands stay registered in Discord while the bot is down, and are registered again (updated in place) on the next start.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Development guide
|
||||
|
||||
How to change the bot and how to add to it. Read [Architecture](architecture.md) first if you have not: it explains the layers this page keeps referring to.
|
||||
|
||||
- [Daily workflow](#daily-workflow)
|
||||
- [Add a simple command](#add-a-simple-command)
|
||||
- [Add a subcommand](#add-a-subcommand)
|
||||
- [Command options and permissions](#command-options-and-permissions)
|
||||
- [Buttons, select menus and forms](#buttons-select-menus-and-forms)
|
||||
- [Add a feature that stores settings](#add-a-feature-that-stores-settings)
|
||||
- [React to a gateway event](#react-to-a-gateway-event)
|
||||
- [Add a background job](#add-a-background-job)
|
||||
- [Add an environment variable](#add-an-environment-variable)
|
||||
- [Make a command beta](#make-a-command-beta)
|
||||
- [Testing](#testing)
|
||||
- [Style and safety rules](#style-and-safety-rules)
|
||||
- [Checklist before a pull request](#checklist-before-a-pull-request)
|
||||
|
||||
## Daily workflow
|
||||
|
||||
```bash
|
||||
docker compose up -d db # PostgreSQL
|
||||
air # rebuild and restart on every change
|
||||
```
|
||||
|
||||
Set `GUILD_ID` to a test server in `.env`, so commands update within seconds. Before pushing:
|
||||
|
||||
```bash
|
||||
gofmt -l .
|
||||
go build ./... && go vet ./... && go test ./...
|
||||
```
|
||||
|
||||
Some older files are not gofmt-clean. Format the files you touch, and leave the others alone so reviews stay readable.
|
||||
|
||||
## Add a simple command
|
||||
|
||||
A command is a definition (what Discord shows) plus a handler (what runs). Each feature has a package under `internal/commands/<feature>`, with this shape:
|
||||
|
||||
```
|
||||
internal/commands/fun/
|
||||
registry.go re-exports the commands and handlers of the feature
|
||||
public/ the definitions and handlers
|
||||
ping.go
|
||||
shared/ helpers used by several commands of the feature
|
||||
```
|
||||
|
||||
Example: a `/hello` command in the `fun` feature.
|
||||
|
||||
**1. The definition and handler**, in `internal/commands/fun/public/hello.go`:
|
||||
|
||||
```go
|
||||
package public
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
var Hello = &discordgo.ApplicationCommand{
|
||||
Name: "hello",
|
||||
Description: "Say hello",
|
||||
}
|
||||
|
||||
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{Content: "Hello!"},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**2. Re-export it**, in `internal/commands/fun/registry.go`:
|
||||
|
||||
```go
|
||||
var Hello *discordgo.ApplicationCommand = public.Hello
|
||||
|
||||
func HelloHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HelloHandler(s, i) }
|
||||
```
|
||||
|
||||
**3. Register it**, in `internal/commands/commands.go`: add `fun.Hello` to `AllCommands`, and `"hello": fun.HelloHandler` to the `handlers` map. The key is the command's name. A command that is in `AllCommands` but not in `handlers` is shown in Discord and then does nothing.
|
||||
|
||||
**4. Restart the bot.** It re-registers all commands on start.
|
||||
|
||||
**5. Document it:** add it to `/help` (`internal/commands/help/help.go`) and to the [Command reference](commands.md).
|
||||
|
||||
Names must be lowercase, up to 32 characters, and unique. Descriptions are required and limited to 100 characters.
|
||||
|
||||
For an entirely new feature, create a new package with the same shape, and import it in `commands.go`.
|
||||
|
||||
## Add a subcommand
|
||||
|
||||
A command with subcommands (like `/meeting`) lists them in `Options`, and its handler switches on the name of the first option. Add the subcommand to the definition:
|
||||
|
||||
```go
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "lobbies",
|
||||
Description: "List this server's meeting lobbies",
|
||||
},
|
||||
```
|
||||
|
||||
and a case to the handler's switch:
|
||||
|
||||
```go
|
||||
switch data.Options[0].Name {
|
||||
case "lobbies":
|
||||
handleListLobbies(s, i)
|
||||
// ...
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
```
|
||||
|
||||
Keep one `handleX` function per subcommand. For a group (`/tickets category add`), the first option is the group and its first option is the subcommand.
|
||||
|
||||
## Command options and permissions
|
||||
|
||||
**Options** go in the definition's `Options` (or in a subcommand's). Types are `String`, `Integer`, `Boolean`, `User`, `Channel`, `Role`, `Number`, and others. A channel option can be limited with `ChannelTypes`. A string can offer fixed `Choices`.
|
||||
|
||||
Inside a subcommand, `shared.SubcommandOptionMap(i)` (in the meeting, level and other `shared` packages) turns the options into a map by name:
|
||||
|
||||
```go
|
||||
opts := shared.SubcommandOptionMap(i)
|
||||
channel := opts["channel"].ChannelValue(s) // *discordgo.Channel
|
||||
```
|
||||
|
||||
**Where a command may be used.** `Contexts` lists where Discord offers it (`InteractionContextGuild`, `InteractionContextBotDM`, `InteractionContextPrivateChannel`). A server-only command should also refuse politely if `i.GuildID == ""`: the shared packages have `RequireGuild` for this.
|
||||
|
||||
**Who may use it.** There are two layers, and you need both:
|
||||
|
||||
1. `DefaultMemberPermissions` on the definition only decides who **sees** the command in Discord's menu. Server admins can change it in their settings, so it is not security.
|
||||
2. The handler must check again, using `i.Member.Permissions`. The shared packages have helpers such as `RequireManageGuild`. Moderation uses `hasPerm(i, discordgo.PermissionKickMembers)` and so on.
|
||||
|
||||
For anything where a mistake hurts (security settings, anything destructive), check the **owner or trusted list** as well, as `internal/commands/security` does with `allowedOrTrusted`. Do not rely on Discord's permissions alone.
|
||||
|
||||
**Responses.**
|
||||
|
||||
- Answer within three seconds. If a command takes longer, respond with `InteractionResponseDeferredChannelMessageWithSource` first, then edit the response with `InteractionResponseEdit`.
|
||||
- Use `Flags: discordgo.MessageFlagsEphemeral` for replies only the user should see (errors, settings). `shared.RespondEphemeral` does this.
|
||||
- When the message contains user-controlled text, set `AllowedMentions: &discordgo.MessageAllowedMentions{}` so it cannot ping anyone.
|
||||
|
||||
## Buttons, select menus and forms
|
||||
|
||||
Components and modals carry a **custom ID**, and the dispatcher routes them by prefix (`internal/commands/commands.go`, `HandleInteraction`). To add one to a feature:
|
||||
|
||||
1. Use the feature's prefix when you build the component: `CustomID: "projects:close:" + id`. Put whatever you need in the ID after the prefix, split with `:`.
|
||||
2. The dispatcher already sends `music:`, `schedule:`, `projects:`, `tickets:` and `security:` to that feature's `HandleComponent`, and `schedule:`, `projects:`, `tickets:` modals to its `HandleModalSubmit`.
|
||||
3. In `HandleComponent`, parse the rest of the ID, check permissions **again** (the button may be clicked by anyone who can see the message), and respond.
|
||||
|
||||
For a **new prefix**, add a case to the `switch` in `HandleInteraction`, and write `HandleComponent` and/or `HandleModalSubmit` in your feature.
|
||||
|
||||
Custom IDs are limited to 100 characters, and a modal can have up to five inputs.
|
||||
|
||||
Never trust the contents of a custom ID. A user cannot edit it, but old messages stay around with old IDs, and IDs may refer to things that no longer exist.
|
||||
|
||||
## Add a feature that stores settings
|
||||
|
||||
The usual path is table, repo, service, command. The meeting-room lobbies are a complete small example (`/meeting add-lobby`).
|
||||
|
||||
**1. The table.** Add it to `schema.sql`, and to an API migration if the dashboard will show it (see [Database](database.md#changing-the-schema)).
|
||||
|
||||
**2. The repo**, in `internal/db/repos/<area>repo/repo.go`. SQL only, returning plain Go values:
|
||||
|
||||
```go
|
||||
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at`, guildID)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Always pass values as parameters (`$1`), never build SQL with string formatting.
|
||||
|
||||
**3. The service**, in `internal/db/services/<area>/service.go`. It wraps the repo and holds the rules (limits, defaults, validation):
|
||||
|
||||
```go
|
||||
type Service struct{ settings *settingsrepo.Repo }
|
||||
|
||||
func New(settings *settingsrepo.Repo) *Service { return &Service{settings: settings} }
|
||||
```
|
||||
|
||||
**4. Wire it up.** Add a field to `services.Services` and the constructor in `internal/db/services/services.go`, and build the repo and service in `main.go` next to the others.
|
||||
|
||||
**5. Use it from the command**, through `services.Global`:
|
||||
|
||||
```go
|
||||
if services.Global == nil || services.Global.Meeting == nil {
|
||||
shared.RespondEphemeral(s, i, "Meeting service is not available.")
|
||||
return
|
||||
}
|
||||
added, err := services.Global.Meeting.AddLobby(ctx, guildID, channelID)
|
||||
```
|
||||
|
||||
**6. Enforce limits in the database when you can.** The 10-lobby limit is a trigger, so the bot and the dashboard cannot disagree and two simultaneous requests cannot both slip through.
|
||||
|
||||
**7. Default to off.** A module that does something to a server without being asked (posts messages, moderates, grants roles) must start disabled and be turned on by an admin.
|
||||
|
||||
## React to a gateway event
|
||||
|
||||
1. Write the function in `internal/events/`, taking the session, the event and the services:
|
||||
|
||||
```go
|
||||
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { ... }
|
||||
```
|
||||
|
||||
2. Register it in `bot.Start()` (`internal/bot/bot.go`):
|
||||
|
||||
```go
|
||||
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
|
||||
events.HandleChannelDelete(s, c, b.Services)
|
||||
})
|
||||
```
|
||||
|
||||
3. Check that the **intent** for that event is enabled in `NewBot`. Events from an intent you did not ask for never arrive. Privileged intents must also be enabled in the developer portal.
|
||||
|
||||
Handlers run concurrently on goroutines. Do not share unsynchronised state, and do not block: slow work belongs in its own goroutine.
|
||||
|
||||
If the event is something security cares about (joins, messages, audit log), look at how `bot.go` calls the security engine **before** your handler.
|
||||
|
||||
## Add a background job
|
||||
|
||||
Follow `StartScheduleReminderLoop` in `internal/events/schedule_reminders.go`: a function that returns immediately and starts a goroutine with a ticker.
|
||||
|
||||
```go
|
||||
func StartMyLoop(s *discordgo.Session, svc *services.Services) {
|
||||
if svc == nil || svc.Thing == nil || s == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(10 * time.Second) // let the bot finish starting
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
// check, act, log errors, never panic
|
||||
}
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
Call it at the end of `bot.Start()`. Log errors instead of returning them, and make one failing server not stop the loop for the others.
|
||||
|
||||
## Add an environment variable
|
||||
|
||||
1. Add a field to `Config` and read it in `LoadConfig()` (`internal/config/config.go`). Return an error if the bot cannot work without it.
|
||||
2. Document it in [Getting started](getting-started.md#2-configure-the-environment).
|
||||
3. Pass it where it is needed from `main.go`.
|
||||
|
||||
Never log a secret, and never put one in a message to a user.
|
||||
|
||||
Use an environment variable for deployment settings (tokens, hosts). Settings an admin or owner should change at runtime belong in the database and on the dashboard instead. That is how the beta settings work.
|
||||
|
||||
## Make a command beta
|
||||
|
||||
Register it with `commands.RegisterBeta` instead of adding it to `AllCommands` and `handlers`. See [Beta features](beta-features.md).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./... # unit tests, no database needed
|
||||
go test -race ./... # recommended before a pull request
|
||||
```
|
||||
|
||||
Where the code allows it, the Discord and database calls go through small interfaces, so flows can be tested with fakes. `internal/tickets` and `internal/security` are the models: look at their `fakes_test.go`.
|
||||
|
||||
Tests that need a real PostgreSQL are behind the `integration` build tag and skip themselves when their environment variable is missing:
|
||||
|
||||
| Package | Variable |
|
||||
| --- | --- |
|
||||
| `internal/db/repos/settingsrepo` | `TEST_DB` |
|
||||
| `internal/db/repos/ticketrepo`, `internal/tickets` | `TICKETS_TEST_DB` |
|
||||
| `internal/db/repos/securityrepo` | `SECURITY_TEST_DB` |
|
||||
|
||||
```bash
|
||||
TEST_DB="postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" \
|
||||
go test -tags integration ./internal/db/repos/settingsrepo
|
||||
```
|
||||
|
||||
Use a throwaway database for these, not one with real data. The tables must already exist (load `schema.sql`).
|
||||
|
||||
Good things to test: permission checks (does the wrong person get refused?), limits, anything that parses user input, and "what if the service or the database fails".
|
||||
|
||||
## Style and safety rules
|
||||
|
||||
- **One feature per package, SQL only in repos, rules in services.** Commands never contain SQL.
|
||||
- **Match the code around you**: naming, comment density, error handling. Comments explain *why*, not *what*.
|
||||
- **Fail closed.** If you cannot tell whether someone is allowed, they are not.
|
||||
- **Validate everything that comes from a user or the dashboard**: option values, custom IDs, `payload` JSON in command rows.
|
||||
- **Escape user text** in messages, and set `AllowedMentions` so it cannot ping.
|
||||
- **Bound everything per server**: caches, lists, loops. One server must not be able to use unlimited memory.
|
||||
- **Do not leak internals** to users: reply with a short message, log the detail.
|
||||
- **Idempotent writes**: use `ON CONFLICT` for "add" operations, so double clicks and retries are harmless.
|
||||
- Handle the bot being **restarted at any time**. State that must survive (raid mode, quarantines) lives in the database.
|
||||
|
||||
## Checklist before a pull request
|
||||
|
||||
- [ ] `gofmt` on the files you touched, `go build ./...`, `go vet ./...`, `go test -race ./...`
|
||||
- [ ] Commands: in `AllCommands` and `handlers`, permission check in the handler, ephemeral errors
|
||||
- [ ] New table: in `schema.sql` **and** in an API migration, with a note about deploy order
|
||||
- [ ] Module that acts on a server: off by default
|
||||
- [ ] `/help`, [Command reference](commands.md) and [Features](features.md) updated
|
||||
- [ ] No secrets, tokens or personal IDs in code, logs or tests
|
||||
@@ -0,0 +1,120 @@
|
||||
# Features
|
||||
|
||||
What each module does and how it behaves. For the exact options of every command, see the [Command reference](commands.md). To change how a module works, see the [Development guide](development.md).
|
||||
|
||||
Unless a section says otherwise, a module is **off** in a new server and a server admin turns it on.
|
||||
|
||||
## Security
|
||||
|
||||
Anti-raid and anti-nuke, modelled on Wick. Code: `internal/security`, commands in `internal/commands/security`.
|
||||
|
||||
**Join Gate** checks every new member: no avatar, account too new, invite links or blocked words in the name, unverified bots. Each check has its own action. It also removes bots added by anyone who is not trusted.
|
||||
|
||||
**Raid mode** starts when many members join in a short time. Raiders are timed out, kicked or banned, invites can be paused, staff are alerted, and raid mode ends after a quiet period or when staff end it with `/security end-raid`. It survives a bot restart: the quiet timer carries on and the summary still lists everyone caught.
|
||||
|
||||
**Anti-nuke** counts destructive actions per person from the audit log: deleting channels or roles, mass bans and kicks, creating webhooks, granting dangerous permissions. Someone who is not the owner or trusted and goes over a limit is **quarantined**: their roles are removed and saved, and they are timed out. `/security release` gives every role back.
|
||||
|
||||
**Lockdown** (`/lockdown`, `/unlock`) locks one channel, every channel, or joins. Unlocking puts back exactly the permission overwrite that was there before.
|
||||
|
||||
**Snapshots** save the server's roles and channels. They are taken every 6 hours while anti-nuke is on (the last three are kept) and on request with `/security snapshot`. `/security restore` recreates deleted roles and channels and reverts dangerous permission changes. It can also run automatically after a stopped nuke.
|
||||
|
||||
**Heat** is the anti-spam system. Each member has a score that rises with messages and cools over time. Spam, repeats, mass mentions and invite links max it, which deletes the message and times the member out (the timeout doubles for repeat offences). A wave of spammers turns on *heat panic* (newcomers are timed out on sight for 10 minutes), and a mention flood can lock the server. Members with Manage Messages and trusted members are exempt.
|
||||
|
||||
**Alerts** go to a channel you choose, and critical ones (raids, stopped nukes, restores, spam waves, removed bots) are also DMed to the owner and trusted users, because the alert channel may be the first thing a nuker deletes. One setting turns the DMs off.
|
||||
|
||||
Rules that hold everywhere in this module:
|
||||
|
||||
- Turning protection off, changing limits, releasing a quarantine and unlocking need the **server owner or someone on the trusted list**. Editing the trusted list needs the owner. Discord's own command permissions are never relied on, because admins can change them.
|
||||
- The owner and the bot are never punished.
|
||||
- Roles that are configured (such as the quarantine role) are checked when they are used, not only when saved. A quarantine role that gained dangerous permissions is not assigned.
|
||||
- Messages ping only the configured alert role, and names are escaped.
|
||||
|
||||
Thresholds, Join Gate filters and heat settings are set on the dashboard. The bot's role must be above the roles it protects against: it can only quarantine members whose highest role is below its own.
|
||||
|
||||
## Tickets
|
||||
|
||||
Support tickets in private threads. Code: `internal/tickets`, commands in `internal/commands/tickets`.
|
||||
|
||||
1. An admin runs `/tickets setup`, adds categories with `/tickets category add` (each with its own staff role), and posts the panel with `/tickets panel`.
|
||||
2. The panel has a category dropdown and a **Create ticket** button. A short form asks what the ticket is about.
|
||||
3. A **private thread** is created in the ticket channel. The opener and the category's staff role can see it.
|
||||
4. Staff can `/ticket claim`. Anyone involved can `/ticket add` someone, staff can `/ticket remove`, and the opener or staff can `/ticket close [reason]`.
|
||||
5. Closing locks and archives the thread and posts a **transcript** to the log channel.
|
||||
|
||||
The bot needs View Channel, Send Messages, Embed Links, Create Private Threads, Send Messages in Threads and Manage Threads in the ticket channel, and Attach Files in the log channel. Give the staff role Manage Threads there too, so staff see every ticket.
|
||||
|
||||
The flows go through small interfaces for Discord and the database, so they are tested without either. The slash commands and buttons only translate interactions into those calls.
|
||||
|
||||
## Moderation
|
||||
|
||||
`/moderation` with `purge`, `timeout`, `kick`, `ban`, `unban`, `untimeout` and `slowmode`. Each subcommand checks the matching Discord permission in the handler (Manage Messages, Moderate Members, Kick Members, Ban Members, Manage Channels). Durations accept `10m`, `1h`, `2h30m` and so on, up to 28 days. Actions are written to the log channel if logging is on.
|
||||
|
||||
## Logging
|
||||
|
||||
When enabled with `/config enablelogging` and a channel set with `/config setlogchannel`, the bot logs edited and deleted messages, joins and leaves, member and role updates, bans and unbans, and moderation actions. Code: `internal/events/logging.go` and `internal/modlog`.
|
||||
|
||||
## Leveling
|
||||
|
||||
Opt in per server with `/slvl toggle`. Code: `internal/db/services/level` (the rules) and `internal/events/event_handlers.go` (the message handler).
|
||||
|
||||
- Every message may give 1 to 3 XP. Below level 5 every message counts. From level 5 on, a message counts with a chance of 1 in `level / 4`, so leveling slows down.
|
||||
- 100 XP makes a level, and XP resets to 0.
|
||||
- A level-up posts the level-up message (set with `/slvl set-message`, variables `{user}` and `{level}`) in the channel chosen with `/slvl set-channel`, or in the channel of the message.
|
||||
- `/slvl set-reward` gives a role at a level. `/rank` draws a rank card (`internal/rankcard`), `/leaderboard` shows the top 10 and `/rewards` lists role rewards.
|
||||
|
||||
## Music
|
||||
|
||||
Lavalink-powered playback. Code: `internal/music`, commands in `internal/commands/music`.
|
||||
|
||||
- `/play` takes a name or a URL, `/queue` shows the queue, `/volume` sets 0 to 150 percent. These need a role called **DJ**.
|
||||
- The now-playing message has buttons for playback and repeat. Buttons also check for the DJ role.
|
||||
- `/music toggle` (Manage Server) turns music on or off in a server.
|
||||
- The dashboard shows the queue and can control playback. It does so through the `music_commands` table and a `NOTIFY` (see [Database](database.md)).
|
||||
- Music needs a Lavalink server. Without `LAVALINK_HOST` the rest of the bot works and music does not.
|
||||
|
||||
## Meeting rooms
|
||||
|
||||
Join a **lobby** voice channel and get your own temporary room. Code: `internal/events/meeting_voice.go`, commands in `internal/commands/meeting`.
|
||||
|
||||
- A server can have up to **10 lobbies** (`/meeting add-lobby`, `remove-lobby`, `lobbies`). The limit is enforced by a database trigger, so the bot and the dashboard obey the same rule.
|
||||
- When someone joins a lobby, the bot creates a voice channel in the same category with the lobby's user limit and bitrate, named after the lobby and the time, and moves the member in.
|
||||
- When the temporary room is empty, it is deleted.
|
||||
- If a lobby channel is deleted in Discord, it is dropped from the lobbies.
|
||||
- `/meeting lock`, `lock-private` (also hides the room), `unlock`, `invite` and `uninvite` act on the room you are in. Every `/meeting` subcommand, including these, currently needs **Manage Server**.
|
||||
|
||||
## Scheduling and timezones
|
||||
|
||||
1:1 sessions between two members. Code: `internal/commands/schedule`, `internal/db/services/schedule`, reminders in `internal/events/schedule_reminders.go`.
|
||||
|
||||
- Each member sets a timezone with `/timezone set` (an IANA name such as `Europe/Lisbon`).
|
||||
- `/schedule create` opens a form (date, time in your timezone, optional description) and DMs the other member a request with **Accept**, **Decline** and **Propose different time** buttons.
|
||||
- `/schedule list` shows your upcoming sessions, and `/schedule reschedule` proposes a new time (in UTC, `2006-01-02 15:04`). The other side can accept or decline the proposal.
|
||||
- Every minute, a loop DMs the participants of sessions starting within 10 minutes.
|
||||
|
||||
## Projects
|
||||
|
||||
A simple list of ongoing projects. `/projects create` opens a form (name, optional description), `/projects list` shows active projects with creators and helpers, `/projects add-helper` and `/projects close` manage them. Creating, adding helpers and closing need Manage Server.
|
||||
|
||||
## Welcome messages and default role
|
||||
|
||||
Set up with `/config` (Manage Server). Code: `internal/events/welcome.go`.
|
||||
|
||||
- A welcome message in a channel, and an optional DM, both templates where `{user}` becomes a mention and `{server}` the server name. An optional GIF or image URL goes in the welcome embed.
|
||||
- A default role is granted when a member joins.
|
||||
- Bots are skipped, and members the security engine removes get nothing.
|
||||
|
||||
## Twitch alerts
|
||||
|
||||
`/config addstreamer`, `removestreamer` and `settwitchnotificationchannel` (Manage Server). Every 30 seconds a loop checks each configured streamer and posts in the notification channel when one goes live. Code: `internal/events/twitch_live.go`. It needs `TWITCH_CLIENT_ID`.
|
||||
|
||||
## Onboarding
|
||||
|
||||
When the bot is added to a server, it applies safe defaults (modules off) and DMs setup instructions to the person who invited it, falling back to the server owner. Code: `internal/events/onboarding.go`.
|
||||
|
||||
## Fun
|
||||
|
||||
`/ping`, `/joke [type]`, `/coinflip`, `/dice` (expressions like `d20`, `2d20+1`, `2#d20+1`, parsed in `internal/diceexpr`), and `/rps` with `/rpsstats` and `/rpsleaderboard`.
|
||||
|
||||
## Help
|
||||
|
||||
`/help` DMs a command reference. The text is hand-written in `internal/commands/help/help.go`, so update it when you add or change a command.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Getting started
|
||||
|
||||
This page takes you from nothing to a bot running on your machine.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Go**: the version in `go.mod` (the Dockerfile builds with the same one).
|
||||
- **PostgreSQL 16 or newer**: the included `docker-compose.yml` starts one for you.
|
||||
- **A Discord application** with a bot user (see below).
|
||||
- Optional: [Air](https://github.com/air-verse/air) for live reload, [Just](https://github.com/casey/just) for shortcuts, and a [Lavalink](https://github.com/lavalink-devs/Lavalink) server if you want music.
|
||||
|
||||
## 1. Create the Discord application
|
||||
|
||||
1. Open the [Discord developer portal](https://discord.com/developers/applications) and create an application. Copy its **Application ID**: this is `APP_ID`.
|
||||
2. Under **Bot**, create the bot user and copy its token: this is `BOT_TOKEN`. Keep it secret.
|
||||
3. Under **Bot**, switch on these **privileged gateway intents**: **Server Members Intent** (welcome messages, security) and **Message Content Intent** (leveling and spam detection). The bot also uses the non-privileged Guilds, Guild Moderation (bans and the audit log), Guild Messages, Guild Voice States and Direct Messages intents. You do not need to enable those by hand.
|
||||
4. Invite the bot with the `bot` and `applications.commands` scopes. It needs these permissions, depending on which modules you use:
|
||||
|
||||
| Permission | Used by |
|
||||
| --- | --- |
|
||||
| View Channels, Send Messages, Embed Links, Attach Files | everything that posts, ticket transcripts |
|
||||
| Read Message History, Manage Messages | moderation purge, spam heat |
|
||||
| Manage Roles | default role, level rewards, quarantine |
|
||||
| Manage Channels | lockdown, slowmode, meeting rooms, snapshot restore |
|
||||
| Manage Server | pausing invites during a raid |
|
||||
| Kick Members, Ban Members, Moderate Members | moderation commands, Join Gate, raid mode, anti-nuke |
|
||||
| View Audit Log | anti-nuke, unauthorised bot detection |
|
||||
| Create Private Threads, Send Messages in Threads, Manage Threads | tickets |
|
||||
| Connect, Speak, Move Members | music, meeting rooms |
|
||||
|
||||
The bot's role must sit **above** the roles it manages or protects against. Discord does not let a bot change members whose highest role is higher than its own.
|
||||
|
||||
## 2. Configure the environment
|
||||
|
||||
The bot reads environment variables, and loads a `.env` file from the working directory if there is one (`internal/config/config.go`).
|
||||
|
||||
| Variable | Required | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | yes | The bot token. |
|
||||
| `APP_ID` | yes | The application ID. |
|
||||
| `GUILD_ID` | no | If set, slash commands are registered in this server only, and appear within seconds. If empty they are registered globally, which Discord can take up to an hour to show. Use a test server while developing. |
|
||||
| `DB_HOST` | yes | A pgx connection string, for example `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`. |
|
||||
| `TWITCH_CLIENT_ID` | yes | A Twitch client ID, used to check who is live. The bot refuses to start without it, even if you never use Twitch alerts. Any non-empty value works if you do not use them. |
|
||||
| `LAVALINK_HOST` | no | Lavalink address, for example `ws://localhost:2333`. Leave empty to run without music. |
|
||||
| `LAVALINK_PASSWORD` | no | Lavalink password. |
|
||||
|
||||
A minimal `.env` for local development:
|
||||
|
||||
```bash
|
||||
BOT_TOKEN=your-bot-token
|
||||
APP_ID=123456789012345678
|
||||
GUILD_ID=123456789012345678
|
||||
DB_HOST=postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable
|
||||
TWITCH_CLIENT_ID=unused
|
||||
```
|
||||
|
||||
`.env` is ignored by git. Never commit it.
|
||||
|
||||
Who the beta testers are is **not** an environment variable. It is set on the dashboard and stored in the database. See [Beta features](beta-features.md).
|
||||
|
||||
## 3. Set up the database
|
||||
|
||||
Start PostgreSQL with Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
Then load the schema:
|
||||
|
||||
```bash
|
||||
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
||||
```
|
||||
|
||||
`schema.sql` is written with `CREATE TABLE IF NOT EXISTS`, so running it again is safe. It is a mirror of the tables the bot uses. If you run the dashboard API as well, the API's migrations create and change these tables, and you do not need `schema.sql` at all. Read [Database](database.md) before changing a table.
|
||||
|
||||
## 4. Run
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
You should see the bot connect, and the slash commands appear in your test server. Type `/ping`.
|
||||
|
||||
With live reload:
|
||||
|
||||
```bash
|
||||
air
|
||||
```
|
||||
|
||||
With Just (`Justfile`):
|
||||
|
||||
```bash
|
||||
just up # start PostgreSQL
|
||||
just start # start PostgreSQL and run Air
|
||||
just down # stop PostgreSQL
|
||||
```
|
||||
|
||||
## 5. Turn modules on
|
||||
|
||||
Nothing is on by default. In your server:
|
||||
|
||||
- `/security toggle` to enable Join Gate, raid mode, anti-nuke or heat.
|
||||
- `/tickets setup`, then `/tickets category add` and `/tickets panel`.
|
||||
- `/slvl toggle` for leveling.
|
||||
- `/config setlogchannel` and `/config enablelogging` for logs.
|
||||
- `/music toggle` for music (needs Lavalink).
|
||||
|
||||
The [Features](features.md) page explains each module, and the [Command reference](commands.md) lists every option.
|
||||
|
||||
## Optional: music with Lavalink
|
||||
|
||||
Music needs a running Lavalink server and `LAVALINK_HOST` / `LAVALINK_PASSWORD`. Without them the bot starts normally and music simply does not work. Members need a role named exactly **DJ** to use `/play`, `/queue` and `/volume`.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Running it for real: [Deployment](deployment.md).
|
||||
- Changing or adding things: [Development guide](development.md).
|
||||
@@ -0,0 +1,57 @@
|
||||
# Troubleshooting
|
||||
|
||||
## The bot does not start
|
||||
|
||||
| Message | Cause |
|
||||
| --- | --- |
|
||||
| `BOT_TOKEN is not set`, `APP_ID is not set`, `DB_HOST is not set` | A required variable is missing. See [Getting started](getting-started.md#2-configure-the-environment). |
|
||||
| `TWITCH_CLIENT_ID is not set` | It is required even if you do not use Twitch alerts. Set any non-empty value. |
|
||||
| `Error creating database` | `DB_HOST` is wrong, or PostgreSQL is not running or not reachable. Try the connection string with `psql`. |
|
||||
| `Error starting bot` | The token is wrong, or a privileged intent is not enabled in the developer portal. |
|
||||
| `Disallowed intents` | Enable **Server Members Intent** and **Message Content Intent** in the developer portal, under Bot. |
|
||||
|
||||
## Slash commands do not appear
|
||||
|
||||
- With `GUILD_ID` set, commands appear in that server within seconds. If it is empty they are global and can take up to an hour.
|
||||
- The bot must be invited with the `applications.commands` scope.
|
||||
- If a command is defined but not in the `handlers` map in `internal/commands/commands.go`, it shows up and does nothing.
|
||||
- If the bot logs `Cannot create '<name>' command`, read the error. A name or description that is too long, or an invalid option, makes Discord reject that one command.
|
||||
- Commands that you removed from the code stay in Discord until you delete them (in the developer portal or through the API).
|
||||
|
||||
## A command says "You don't have permission"
|
||||
|
||||
Many commands need **Manage Server** (see the [Command reference](commands.md)), and the music commands need a role called exactly **DJ**. Security actions such as turning protection off need the **server owner or a trusted user**, whatever permissions you have.
|
||||
|
||||
## The bot cannot do something in a server
|
||||
|
||||
Most failures are about the bot's role:
|
||||
|
||||
- The bot's role must be **above** the roles it manages and the members it moderates or quarantines.
|
||||
- It needs the permissions listed in [Getting started](getting-started.md#1-create-the-discord-application). Check the channel's own permission overrides too: a channel can deny what the server role allows.
|
||||
- Security messages say "Check that Velox's role is high enough and has the permissions it needs" for this reason.
|
||||
|
||||
## Music does not play
|
||||
|
||||
- `LAVALINK_HOST` and `LAVALINK_PASSWORD` must match your Lavalink server, and Lavalink must be running and reachable from the bot (in Compose, use the service name, not `localhost`).
|
||||
- You need the **DJ** role.
|
||||
- A server admin can turn music off with `/music toggle`.
|
||||
|
||||
## Level-ups, logs or welcome messages do nothing
|
||||
|
||||
They are off until enabled: `/slvl toggle`, `/config enablelogging` with `/config setlogchannel`, and `/config setwelcomechannel`. Leveling and spam detection read message text, so the **Message Content Intent** must be on. Welcome messages and security need the **Server Members Intent**.
|
||||
|
||||
## Dashboard actions have no effect
|
||||
|
||||
The dashboard talks to the bot through the database (see [Database](database.md#dashboard-to-bot-settings-and-requests)).
|
||||
|
||||
- Check that the bot and the API use the **same database**.
|
||||
- For a request such as skipping a song or ending a raid, look in the matching `*_commands` table. A row with an empty `processed_at` is waiting. If many are waiting, the bot's listener is down: look for `command listener stopped` in its log. It reconnects every 10 seconds.
|
||||
- Settings are read when needed and may be cached briefly (the beta settings are polled every 30 seconds).
|
||||
|
||||
## Beta features are not available to a tester
|
||||
|
||||
Go through [Beta features](beta-features.md): is the Beta Tester role chosen on the dashboard? Is the person in the beta server, and do they have the role? Has 30 seconds passed? `/beta` (owner only) shows how access is set up.
|
||||
|
||||
## Getting more detail
|
||||
|
||||
The bot logs to standard output. Run `docker compose logs -f bot`, or just `go run .`, and reproduce the problem. Errors from Discord calls are logged with the server and channel IDs involved.
|
||||
@@ -4,6 +4,8 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/disgoorg/disgolink/v3 v3.1.0
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3
|
||||
github.com/fogleman/gg v1.3.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@@ -11,8 +13,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/disgoorg/json v1.2.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // 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
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
||||
@@ -3,12 +3,21 @@ github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disgoorg/disgolink/v3 v3.1.0 h1:IwhycUfvw87VbvvNSwBcdGMQNSFvNQ7/AQofmbp1NS8=
|
||||
github.com/disgoorg/disgolink/v3 v3.1.0/go.mod h1:UjHfrC4NT4vzibG3GyqtY5l3aMzFwfkU+B3RiW3AQQ8=
|
||||
github.com/disgoorg/json v1.2.0 h1:6e/j4BCfSHIvucG1cd7tJPAOp1RgnnMFSqkvZUtEd1Y=
|
||||
github.com/disgoorg/json v1.2.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA=
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro=
|
||||
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
|
||||
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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
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=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package beta gates features that are still being tried out. A beta feature
|
||||
// works only for members of the beta server who have the Beta Tester role:
|
||||
// the server comes from BETA_GUILD_ID and the role from BETA_ROLE_ID, both set
|
||||
// by the bot's owner in its environment, so nobody can change them from
|
||||
// Discord.
|
||||
//
|
||||
// Everything fails closed. With no role configured, or an ID that isn't a
|
||||
// Discord ID, nobody gets access.
|
||||
package beta
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Handler is a slash command handler.
|
||||
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
|
||||
|
||||
type settings struct{ guildID, roleID string }
|
||||
|
||||
var current atomic.Pointer[settings]
|
||||
|
||||
var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
|
||||
|
||||
// idOrEmpty keeps a Discord ID and drops anything else, so a typo in the
|
||||
// environment turns beta features off instead of matching something odd.
|
||||
func idOrEmpty(name, v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if !snowflake.MatchString(v) {
|
||||
log.Printf("beta: %s %q isn't a Discord ID; beta features stay locked", name, v)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Configure sets the beta server and the Beta Tester role. Call it once at
|
||||
// startup.
|
||||
func Configure(guildID, roleID string) {
|
||||
current.Store(&settings{
|
||||
guildID: idOrEmpty("BETA_GUILD_ID", guildID),
|
||||
roleID: idOrEmpty("BETA_ROLE_ID", roleID),
|
||||
})
|
||||
}
|
||||
|
||||
func get() settings {
|
||||
if s := current.Load(); s != nil {
|
||||
return *s
|
||||
}
|
||||
return settings{}
|
||||
}
|
||||
|
||||
// GuildID is the beta server, or "" when beta features are off.
|
||||
func GuildID() string { return get().guildID }
|
||||
|
||||
// Status describes where a member stands, for the /beta command.
|
||||
type Status struct {
|
||||
InBetaGuild bool // the interaction came from the beta server
|
||||
RoleConfigured bool // the owner has set a Beta Tester role
|
||||
HasRole bool // the member has it
|
||||
}
|
||||
|
||||
// StatusOf reports a member's standing in the guild they're acting in.
|
||||
func StatusOf(guildID string, member *discordgo.Member) Status {
|
||||
c := get()
|
||||
st := Status{
|
||||
InBetaGuild: c.guildID != "" && guildID == c.guildID,
|
||||
RoleConfigured: c.roleID != "",
|
||||
}
|
||||
if st.InBetaGuild && st.RoleConfigured && member != nil {
|
||||
for _, r := range member.Roles {
|
||||
if r == c.roleID {
|
||||
st.HasRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Allowed reports whether a member may use beta features in guildID: it has
|
||||
// to be the beta server, and the member has to have the Beta Tester role.
|
||||
// Use it for anything that isn't a command, such as an event handler.
|
||||
func Allowed(guildID string, member *discordgo.Member) bool {
|
||||
st := StatusOf(guildID, member)
|
||||
return st.InBetaGuild && st.RoleConfigured && st.HasRole
|
||||
}
|
||||
|
||||
// AllowedInteraction is Allowed for an interaction. A DM has no member, so it
|
||||
// never qualifies. The member and their roles come from Discord with the
|
||||
// interaction, not from anything the user typed.
|
||||
func AllowedInteraction(i *discordgo.InteractionCreate) bool {
|
||||
return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member)
|
||||
}
|
||||
|
||||
// respond is how a refusal is sent; tests replace it.
|
||||
var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||
if i.Type == discordgo.InteractionApplicationCommandAutocomplete {
|
||||
// Nothing to suggest, and a message isn't a valid reply here.
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
|
||||
Data: &discordgo.InteractionResponseData{Choices: []*discordgo.ApplicationCommandOptionChoice{}},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: msg,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const denied = "This feature is in beta and isn't available to you yet."
|
||||
|
||||
// Guard is for the start of a beta feature's handler, and for its component,
|
||||
// modal and autocomplete handlers too (the command's gate doesn't cover
|
||||
// those). It answers the user and returns false when they aren't allowed:
|
||||
//
|
||||
// if !beta.Guard(s, i) { return }
|
||||
func Guard(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if AllowedInteraction(i) {
|
||||
return true
|
||||
}
|
||||
respond(s, i, denied)
|
||||
return false
|
||||
}
|
||||
|
||||
// Gate wraps a command handler so it only runs for beta testers.
|
||||
func Gate(h Handler) Handler {
|
||||
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !Guard(s, i) {
|
||||
return
|
||||
}
|
||||
h(s, i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package beta
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
const (
|
||||
betaGuild = "1170934850157547600"
|
||||
testerRol = "555000000000000001"
|
||||
)
|
||||
|
||||
func member(roles ...string) *discordgo.Member { return &discordgo.Member{Roles: roles} }
|
||||
|
||||
func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate {
|
||||
return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}}
|
||||
}
|
||||
|
||||
func TestAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
guild, role string // configuration
|
||||
inGuild string
|
||||
member *discordgo.Member
|
||||
want bool
|
||||
inBeta, hasRole bool
|
||||
}{
|
||||
{"tester in the beta server", betaGuild, testerRol, betaGuild, member("1", testerRol), true, true, true},
|
||||
{"member without the role", betaGuild, testerRol, betaGuild, member("1", "2"), false, true, false},
|
||||
{"member with no roles at all", betaGuild, testerRol, betaGuild, member(), false, true, false},
|
||||
{"the role in another server doesn't count", betaGuild, testerRol, "999000000000000001", member(testerRol), false, false, false},
|
||||
{"a DM has no member", betaGuild, testerRol, "", nil, false, false, false},
|
||||
{"a nil member in the beta server", betaGuild, testerRol, betaGuild, nil, false, true, false},
|
||||
{"no role configured: nobody", betaGuild, "", betaGuild, member(testerRol, ""), false, true, false},
|
||||
{"no beta server configured: nobody", "", testerRol, betaGuild, member(testerRol), false, false, false},
|
||||
{"a role ID that isn't a number is dropped", betaGuild, "tester-role", betaGuild, member("tester-role"), false, true, false},
|
||||
{"a server ID that isn't a number is dropped", "my-server", testerRol, "my-server", member(testerRol), false, false, false},
|
||||
{"a role that only contains the ID isn't a match", betaGuild, testerRol, betaGuild, member(testerRol + "9"), false, true, false},
|
||||
{"stray spaces in the configuration are ignored", " " + betaGuild + " ", " " + testerRol + "\n", betaGuild, member(testerRol), true, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
Configure(tc.guild, tc.role)
|
||||
if got := Allowed(tc.inGuild, tc.member); got != tc.want {
|
||||
t.Errorf("Allowed = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got := AllowedInteraction(interaction(tc.inGuild, tc.member)); got != tc.want {
|
||||
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
|
||||
}
|
||||
st := StatusOf(tc.inGuild, tc.member)
|
||||
if st.InBetaGuild != tc.inBeta || st.HasRole != tc.hasRole {
|
||||
t.Errorf("StatusOf = %+v, want in beta server %v, has role %v", st, tc.inBeta, tc.hasRole)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredAtAll(t *testing.T) {
|
||||
current.Store(nil)
|
||||
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" {
|
||||
t.Error("before Configure nothing is allowed and there's no beta server")
|
||||
}
|
||||
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
|
||||
t.Error("a missing interaction is never allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
||||
Configure(betaGuild, testerRol)
|
||||
var answered []string
|
||||
orig := respond
|
||||
respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) {
|
||||
answered = append(answered, msg)
|
||||
}
|
||||
t.Cleanup(func() { respond = orig })
|
||||
|
||||
ran := 0
|
||||
h := Gate(func(*discordgo.Session, *discordgo.InteractionCreate) { ran++ })
|
||||
|
||||
h(nil, interaction(betaGuild, member(testerRol)))
|
||||
if ran != 1 || len(answered) != 0 {
|
||||
t.Fatalf("a tester runs the handler without being refused: ran=%d answered=%v", ran, answered)
|
||||
}
|
||||
|
||||
for name, i := range map[string]*discordgo.InteractionCreate{
|
||||
"no role": interaction(betaGuild, member("1")),
|
||||
"wrong server": interaction("999000000000000001", member(testerRol)),
|
||||
"DM": interaction("", nil),
|
||||
} {
|
||||
before := ran
|
||||
h(nil, i)
|
||||
if ran != before {
|
||||
t.Errorf("%s: the handler must not run", name)
|
||||
}
|
||||
}
|
||||
if len(answered) != 3 || answered[0] != denied {
|
||||
t.Errorf("each refusal is answered with the beta message: %v", answered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardOnItsOwn(t *testing.T) {
|
||||
Configure(betaGuild, testerRol)
|
||||
n := 0
|
||||
orig := respond
|
||||
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
|
||||
t.Cleanup(func() { respond = orig })
|
||||
|
||||
if !Guard(nil, interaction(betaGuild, member(testerRol))) || n != 0 {
|
||||
t.Error("a tester passes without a reply")
|
||||
}
|
||||
if Guard(nil, interaction(betaGuild, member())) || n != 1 {
|
||||
t.Error("someone else is refused with a reply")
|
||||
}
|
||||
}
|
||||
+235
-1
@@ -1,10 +1,17 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
"velox-bot/internal/beta"
|
||||
"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/security"
|
||||
"velox-bot/internal/tickets"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -16,20 +23,42 @@ type Bot struct {
|
||||
Commands []*discordgo.ApplicationCommand
|
||||
registeredCommands []*discordgo.ApplicationCommand
|
||||
Services *services.Services
|
||||
MusicRepo *musicrepo.Repo
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
betaGuild string // where beta commands are registered right now
|
||||
}
|
||||
|
||||
func NewBot(token, appID, guildID 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
|
||||
}
|
||||
|
||||
// Intents needed for:
|
||||
// - InteractionCreate (slash commands): Guilds
|
||||
// - MessageCreate (leveling): GuildMessages
|
||||
// - GuildMemberAdd (welcome messages): GuildMembers
|
||||
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||
discordgo.IntentsGuildBans |
|
||||
discordgo.IntentsGuildMembers |
|
||||
discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsMessageContent |
|
||||
discordgo.IntentsGuildVoiceStates |
|
||||
discordgo.IntentsDirectMessages
|
||||
|
||||
return &Bot{
|
||||
Session: session,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
MusicRepo: musicRepo,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -38,6 +67,44 @@ func (b *Bot) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = 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 {
|
||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
||||
@@ -50,13 +117,180 @@ func (b *Bot) Start() error {
|
||||
|
||||
b.Session.AddHandler(commands.HandleInteraction)
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
|
||||
events.HandleChannelDelete(s, c, b.Services)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
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)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, ev *discordgo.VoiceServerUpdate) {
|
||||
music.OnVoiceServerUpdate(ev)
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// betaPollEvery is how often the bot looks for a change to the beta settings
|
||||
// made on the dashboard.
|
||||
const betaPollEvery = 30 * time.Second
|
||||
|
||||
// WatchBeta keeps the bot's idea of who the beta testers are in step with the
|
||||
// settings the owner saves on the dashboard. When the beta server changes, the
|
||||
// beta commands move with it. If the settings can't be read, the last known
|
||||
// ones stay in force (and before the first read, beta stays locked).
|
||||
func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, err error)) {
|
||||
sync := func() {
|
||||
guildID, roleID, err := load(ctx)
|
||||
if err != nil {
|
||||
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
|
||||
return
|
||||
}
|
||||
beta.Configure(guildID, roleID)
|
||||
if now := beta.GuildID(); now != b.betaGuild {
|
||||
b.registerBetaCommands(b.betaGuild, now)
|
||||
b.betaGuild = now
|
||||
}
|
||||
}
|
||||
sync()
|
||||
go func() {
|
||||
t := time.NewTicker(betaPollEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// registerBetaCommands puts the beta commands in the beta server, and only
|
||||
// there, so nobody else sees them. Commands this bot left there that are no
|
||||
// longer beta (or no longer exist) are removed. When the beta server is also
|
||||
// the bot's main server, its regular commands are kept. If the beta server
|
||||
// changed, the beta commands are taken out of the old one.
|
||||
func (b *Bot) registerBetaCommands(previous, guildID string) {
|
||||
if previous != "" && previous != guildID {
|
||||
isBeta := map[string]bool{}
|
||||
for _, cmd := range commands.BetaCommands {
|
||||
isBeta[cmd.Name] = true
|
||||
}
|
||||
if old, err := b.Session.ApplicationCommands(b.AppID, previous); err != nil {
|
||||
log.Printf("beta: cannot list commands in the previous server: %v", err)
|
||||
} else {
|
||||
for _, cmd := range old {
|
||||
if isBeta[cmd.Name] {
|
||||
if err := b.Session.ApplicationCommandDelete(b.AppID, previous, cmd.ID); err != nil {
|
||||
log.Printf("beta: cannot remove '%v' from the previous server: %v", cmd.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if guildID == "" {
|
||||
return
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, cmd := range b.Commands {
|
||||
if b.GuildID == guildID {
|
||||
keep[cmd.Name] = true
|
||||
}
|
||||
}
|
||||
for _, cmd := range commands.BetaCommands {
|
||||
keep[cmd.Name] = true
|
||||
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
|
||||
log.Printf("beta: cannot create '%v' command: %v", cmd.Name, err)
|
||||
}
|
||||
}
|
||||
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
||||
if err != nil {
|
||||
log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err)
|
||||
return
|
||||
}
|
||||
for _, cmd := range existing {
|
||||
if keep[cmd.Name] {
|
||||
continue
|
||||
}
|
||||
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil {
|
||||
log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) Close() error {
|
||||
for _, cmd := range b.registeredCommands {
|
||||
if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package betacmd is the /beta command: it shows how beta access is set up.
|
||||
// It exists only in the beta server (see commands.BetaCommands), is hidden
|
||||
// from everyone but administrators in Discord's command picker, and only the
|
||||
// bot's owner can actually run it (the owner is looked up from the Discord
|
||||
// application, so there's nothing to configure).
|
||||
package betacmd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"velox-bot/internal/beta"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
noDM = false
|
||||
noPerms = int64(0) // hides the command from everyone but administrators
|
||||
ownerIDs sync.Map // user ID -> struct{}, filled once from the application
|
||||
loadOnce sync.Mutex
|
||||
loaded bool
|
||||
)
|
||||
|
||||
var Beta = &discordgo.ApplicationCommand{
|
||||
Name: "beta",
|
||||
Description: "Show how beta access is set up (bot owner only)",
|
||||
DMPermission: &noDM,
|
||||
DefaultMemberPermissions: &noPerms,
|
||||
}
|
||||
|
||||
// isOwner reports whether the user owns the bot's Discord application (or is
|
||||
// on its team). If the application can't be read, nobody is the owner.
|
||||
func isOwner(s *discordgo.Session, userID string) bool {
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
loadOnce.Lock()
|
||||
if !loaded {
|
||||
app, err := s.Application("@me")
|
||||
if err == nil && app != nil {
|
||||
if app.Owner != nil {
|
||||
ownerIDs.Store(app.Owner.ID, struct{}{})
|
||||
}
|
||||
if app.Team != nil {
|
||||
ownerIDs.Store(app.Team.OwnerID, struct{}{})
|
||||
for _, m := range app.Team.Members {
|
||||
if m != nil && m.User != nil {
|
||||
ownerIDs.Store(m.User.ID, struct{}{})
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
loadOnce.Unlock()
|
||||
_, ok := ownerIDs.Load(userID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
var userID string
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
userID = i.Member.User.ID
|
||||
}
|
||||
var msg string
|
||||
st := beta.StatusOf(i.GuildID, i.Member)
|
||||
switch {
|
||||
case !isOwner(s, userID):
|
||||
msg = "Only the bot's owner can use this command."
|
||||
case !st.InBetaGuild:
|
||||
msg = "Beta features aren't available here."
|
||||
case !st.RoleConfigured:
|
||||
msg = "No Beta Tester role is set up, so beta features are locked for everyone."
|
||||
case st.HasRole:
|
||||
msg = "You're a beta tester: beta features are unlocked for you in this server."
|
||||
default:
|
||||
msg = "You don't have the Beta Tester role, so beta features are locked for you."
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: msg,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
AllowedMentions: &discordgo.MessageAllowedMentions{},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,33 +1,134 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"velox-bot/internal/beta"
|
||||
"velox-bot/internal/commands/betacmd"
|
||||
"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"
|
||||
cmdsecurity "velox-bot/internal/commands/security"
|
||||
cmdtickets "velox-bot/internal/commands/tickets"
|
||||
"velox-bot/internal/commands/timezone"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var AllCommands = []*discordgo.ApplicationCommand{
|
||||
fun.Ping,
|
||||
fun.Joke,
|
||||
fun.Coinflip,
|
||||
fun.Dice,
|
||||
fun.Rps,
|
||||
fun.RpsStats,
|
||||
fun.RpsLeaderboard,
|
||||
help.Help,
|
||||
cmdlevel.Slvl,
|
||||
cmdlevel.Rank,
|
||||
cmdlevel.Leaderboard,
|
||||
cmdlevel.Rewards,
|
||||
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){
|
||||
"ping": fun.PingHandler,
|
||||
"joke": fun.JokeHandler,
|
||||
"coinflip": fun.CoinflipHandler,
|
||||
"dice": fun.DiceHandler,
|
||||
"rps": fun.RpsHandler,
|
||||
"rpsstats": fun.RpsStatsHandler,
|
||||
"rpsleaderboard": fun.RpsLeaderboardHandler,
|
||||
"help": help.HelpHandler,
|
||||
"slvl": cmdlevel.SlvlHandler,
|
||||
"rank": cmdlevel.RankHandler,
|
||||
"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,
|
||||
}
|
||||
|
||||
// BetaCommands are the beta features' slash commands. They're registered in
|
||||
// the beta server only, so nobody else even sees them, and each handler is
|
||||
// wrapped so it runs only for members with the Beta Tester role (see the beta
|
||||
// package). To put a feature in beta, add it with RegisterBeta instead of to
|
||||
// AllCommands and handlers; to release it, move it there.
|
||||
var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta}
|
||||
|
||||
func init() {
|
||||
// /beta itself is open to the whole beta server (see betacmd).
|
||||
handlers[betacmd.Beta.Name] = betacmd.Handler
|
||||
}
|
||||
|
||||
// RegisterBeta adds a slash command that only beta testers can use.
|
||||
func RegisterBeta(cmd *discordgo.ApplicationCommand, h func(s *discordgo.Session, i *discordgo.InteractionCreate)) {
|
||||
BetaCommands = append(BetaCommands, cmd)
|
||||
handlers[cmd.Name] = beta.Gate(h)
|
||||
}
|
||||
|
||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
switch i.Type {
|
||||
case discordgo.InteractionApplicationCommand:
|
||||
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
||||
h(s, i)
|
||||
}
|
||||
case discordgo.InteractionMessageComponent:
|
||||
data := i.MessageComponentData()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Config = &discordgo.ApplicationCommand{
|
||||
Name: "config",
|
||||
Description: "Server configuration",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "addstreamer",
|
||||
Description: "Add a Twitch streamer for live notifications",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "username",
|
||||
Description: "Twitch username (without https://)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "removestreamer",
|
||||
Description: "Remove a Twitch streamer from live notifications",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "username",
|
||||
Description: "Twitch username to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "settwitchnotificationchannel",
|
||||
Description: "Set the channel for Twitch live notifications",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionChannel,
|
||||
Name: "channel",
|
||||
Description: "Text channel for notifications",
|
||||
Required: true,
|
||||
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.GuildID == "" {
|
||||
respondEphemeral(s, i, "This command can only be used in a server.")
|
||||
return
|
||||
}
|
||||
if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 {
|
||||
respondEphemeral(s, i, "You need the **Manage Server** permission to use this.")
|
||||
return
|
||||
}
|
||||
if services.Global == nil {
|
||||
respondEphemeral(s, i, "Configuration services are not available.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
respondEphemeral(s, i, "Missing subcommand.")
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "addstreamer":
|
||||
log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||
handleAddStreamer(s, i)
|
||||
case "removestreamer":
|
||||
log.Printf("twitch: /config removestreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||
handleRemoveStreamer(s, i)
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleAddStreamer(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
respondEphemeral(s, i, "Invalid guild ID.")
|
||||
return
|
||||
}
|
||||
|
||||
opt := i.ApplicationCommandData().Options[0]
|
||||
var username string
|
||||
for _, o := range opt.Options {
|
||||
if o.Name == "username" {
|
||||
username = strings.TrimSpace(o.StringValue())
|
||||
}
|
||||
}
|
||||
username = strings.TrimPrefix(username, "https://www.twitch.tv/")
|
||||
username = strings.TrimPrefix(username, "http://www.twitch.tv/")
|
||||
username = strings.TrimPrefix(username, "twitch.tv/")
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
if username == "" {
|
||||
respondEphemeral(s, i, "Username cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Twitch.UpsertStreamer(context.Background(), guildID, username); err != nil {
|
||||
respondEphemeral(s, i, "Failed to add streamer.")
|
||||
return
|
||||
}
|
||||
|
||||
respondEphemeral(s, i, "Added `"+username+"` for Twitch live notifications.")
|
||||
}
|
||||
|
||||
func handleRemoveStreamer(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
respondEphemeral(s, i, "Invalid guild ID.")
|
||||
return
|
||||
}
|
||||
|
||||
opt := i.ApplicationCommandData().Options[0]
|
||||
var username string
|
||||
for _, o := range opt.Options {
|
||||
if o.Name == "username" {
|
||||
username = strings.TrimSpace(o.StringValue())
|
||||
}
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
respondEphemeral(s, i, "Username cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Twitch.RemoveStreamer(context.Background(), guildID, username); err != nil {
|
||||
respondEphemeral(s, i, "Failed to remove streamer.")
|
||||
return
|
||||
}
|
||||
|
||||
respondEphemeral(s, i, "Removed `"+username+"` from Twitch live notifications.")
|
||||
}
|
||||
|
||||
func handleSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
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.Twitch.SetNotificationChannel(context.Background(), guildID, chID); err != nil {
|
||||
respondEphemeral(s, i, "Failed to set notification channel.")
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: msg,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/config/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Config *discordgo.ApplicationCommand = public.Config
|
||||
)
|
||||
|
||||
func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ConfigHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"velox-bot/internal/commands/fun/shared"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Coinflip = &discordgo.ApplicationCommand{
|
||||
Name: "coinflip",
|
||||
Description: "Flip a coin",
|
||||
}
|
||||
|
||||
func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
num := rand.Intn(2)
|
||||
if num == 0 {
|
||||
shared.Respond(s, i, "Heads!")
|
||||
} else {
|
||||
shared.Respond(s, i, "Tails!")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/commands/fun/shared"
|
||||
"velox-bot/internal/diceexpr"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Dice = &discordgo.ApplicationCommand{
|
||||
Name: "dice",
|
||||
Description: "Roll dice expressions (e.g. d20, 2d20+1, 2#d20+1)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "expr",
|
||||
Description: "Dice expression to roll",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Contexts: &[]discordgo.InteractionContextType{
|
||||
discordgo.InteractionContextGuild,
|
||||
discordgo.InteractionContextBotDM,
|
||||
discordgo.InteractionContextPrivateChannel,
|
||||
},
|
||||
}
|
||||
|
||||
func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
expr := ""
|
||||
if opts := i.ApplicationCommandData().Options; len(opts) > 0 {
|
||||
expr = opts[0].StringValue()
|
||||
}
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
respondDiceFailure(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := diceexpr.EvalMany(expr, diceexpr.DefaultLimits())
|
||||
if err != nil {
|
||||
respondDiceFailure(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
out := diceexpr.FormatResult(res, diceexpr.DefaultFormatOptions())
|
||||
if len(out) > 1900 {
|
||||
// Keep within Discord message limits; trim safely.
|
||||
out = out[:1900] + "…"
|
||||
}
|
||||
shared.Respond(s, i, out)
|
||||
}
|
||||
|
||||
func respondDiceFailure(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.GuildID != "" {
|
||||
shared.RespondEphemeral(s, i, "Failure!")
|
||||
return
|
||||
}
|
||||
shared.Respond(s, i, "Failure!")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/fun/shared"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Joke = &discordgo.ApplicationCommand{
|
||||
Name: "joke",
|
||||
Description: "Get a random joke",
|
||||
Contexts: &[]discordgo.InteractionContextType{
|
||||
discordgo.InteractionContextGuild,
|
||||
discordgo.InteractionContextBotDM,
|
||||
discordgo.InteractionContextPrivateChannel,
|
||||
},
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "type",
|
||||
Description: "The type of joke to get",
|
||||
Required: false,
|
||||
Choices: []*discordgo.ApplicationCommandOptionChoice{
|
||||
{
|
||||
Name: "Programming",
|
||||
Value: "Programming",
|
||||
},
|
||||
{
|
||||
Name: "Misc",
|
||||
Value: "Miscellaneous",
|
||||
},
|
||||
{
|
||||
Name: "Dark",
|
||||
Value: "Dark",
|
||||
},
|
||||
{
|
||||
Name: "Pun",
|
||||
Value: "Pun",
|
||||
},
|
||||
{
|
||||
Name: "Spooky",
|
||||
Value: "Spooky",
|
||||
},
|
||||
{
|
||||
Name: "Christmas",
|
||||
Value: "Christmas",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func JokeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
jokeType := "Any"
|
||||
if opts := i.ApplicationCommandData().Options; len(opts) > 0 {
|
||||
if v := opts[0].StringValue(); v != "" {
|
||||
jokeType = v
|
||||
}
|
||||
}
|
||||
if !isValidJokeType(jokeType) {
|
||||
jokeType = "Any"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||
defer cancel()
|
||||
|
||||
content, err := fetchJoke(ctx, jokeType)
|
||||
if err != nil {
|
||||
if i.GuildID != "" {
|
||||
shared.RespondEphemeral(s, i, "Failed to get joke.")
|
||||
} else {
|
||||
shared.Respond(s, i, "Failed to get joke.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
shared.Respond(s, i, content)
|
||||
}
|
||||
|
||||
func isValidJokeType(t string) bool {
|
||||
switch t {
|
||||
case "Any", "Programming", "Misc", "Dark", "Pun", "Spooky", "Christmas":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type jokeAPIResponse struct {
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Joke string `json:"joke"`
|
||||
Setup string `json:"setup"`
|
||||
Delivery string `json:"delivery"`
|
||||
}
|
||||
|
||||
func fetchJoke(ctx context.Context, jokeType string) (string, error) {
|
||||
const baseURL = "https://v2.jokeapi.dev/joke"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s", baseURL, jokeType), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("joke api status: %s", resp.Status)
|
||||
}
|
||||
|
||||
var data jokeAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if data.Error {
|
||||
if data.Message != "" {
|
||||
return "", errors.New(data.Message)
|
||||
}
|
||||
return "", errors.New("joke api returned error")
|
||||
}
|
||||
|
||||
switch data.Type {
|
||||
case "single":
|
||||
if data.Joke == "" {
|
||||
return "", errors.New("empty joke")
|
||||
}
|
||||
return data.Joke, nil
|
||||
case "twopart":
|
||||
if data.Setup == "" && data.Delivery == "" {
|
||||
return "", errors.New("empty joke")
|
||||
}
|
||||
if data.Setup == "" {
|
||||
return data.Delivery, nil
|
||||
}
|
||||
if data.Delivery == "" {
|
||||
return data.Setup, nil
|
||||
}
|
||||
return data.Setup + "\n" + data.Delivery, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown joke type: %q", data.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package fun
|
||||
package public
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/fun/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
rpssvc "velox-bot/internal/db/services/rps"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Rps = &discordgo.ApplicationCommand{
|
||||
Name: "rps",
|
||||
Description: "Plays Rock Paper Scissors with you",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "hand",
|
||||
Description: "Choose between ✌️, ✋ or 🤜",
|
||||
Required: true,
|
||||
Choices: []*discordgo.ApplicationCommandOptionChoice{
|
||||
{Name: "✌️ - Scissors", Value: string(rpssvc.HandScissors)},
|
||||
{Name: "✋ - Paper", Value: string(rpssvc.HandPaper)},
|
||||
{Name: "🤜 - Rock", Value: string(rpssvc.HandRock)},
|
||||
},
|
||||
},
|
||||
},
|
||||
Contexts: &[]discordgo.InteractionContextType{
|
||||
discordgo.InteractionContextGuild,
|
||||
},
|
||||
}
|
||||
|
||||
func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.GuildID == "" {
|
||||
shared.Respond(s, i, "This command can only be used in a server.")
|
||||
return
|
||||
}
|
||||
if services.Global == nil || services.Global.RPS == nil {
|
||||
shared.RespondEphemeral(s, i, "RPS service is not available.")
|
||||
return
|
||||
}
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜")
|
||||
return
|
||||
}
|
||||
handStr := data.Options[0].StringValue()
|
||||
userHand, ok := rpssvc.ParseHand(handStr)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜")
|
||||
return
|
||||
}
|
||||
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid guild.")
|
||||
return
|
||||
}
|
||||
|
||||
user := i.User
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
user = i.Member.User
|
||||
}
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(user.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
out, err := services.Global.RPS.Play(context.Background(), guildID, userID, userHand)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to play RPS.")
|
||||
return
|
||||
}
|
||||
|
||||
color := 0xEF4444
|
||||
switch out.Result {
|
||||
case rpssvc.ResultWin:
|
||||
color = 0x22C55E
|
||||
case rpssvc.ResultTie:
|
||||
color = 0xF59E0B
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: string(out.Result),
|
||||
Description: user.Mention() + " vs bot",
|
||||
Color: color,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Your hand", Value: out.UserHand.String(), Inline: true},
|
||||
{Name: "Bot hand", Value: out.BotHand.String(), Inline: true},
|
||||
{Name: "Score", Value: strconv.Itoa(out.Score), Inline: true},
|
||||
},
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/fun/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var RpsLeaderboard = &discordgo.ApplicationCommand{
|
||||
Name: "rpsleaderboard",
|
||||
Description: "Shows the RPS leaderboard",
|
||||
Contexts: &[]discordgo.InteractionContextType{
|
||||
discordgo.InteractionContextGuild,
|
||||
},
|
||||
}
|
||||
|
||||
func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.GuildID == "" {
|
||||
shared.Respond(s, i, "This command can only be used in a server.")
|
||||
return
|
||||
}
|
||||
if services.Global == nil || services.Global.RPS == nil {
|
||||
shared.RespondEphemeral(s, i, "RPS service is not available.")
|
||||
return
|
||||
}
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid guild.")
|
||||
return
|
||||
}
|
||||
|
||||
top, err := services.Global.RPS.TopScores(context.Background(), guildID, 10)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load leaderboard.")
|
||||
return
|
||||
}
|
||||
if len(top) == 0 {
|
||||
shared.RespondEphemeral(s, i, "No leaderboard data yet.")
|
||||
return
|
||||
}
|
||||
|
||||
fields := make([]*discordgo.MessageEmbedField, 0, len(top))
|
||||
for idx, entry := range top {
|
||||
display := fmt.Sprintf("<@%d>", entry.UserID)
|
||||
if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" {
|
||||
display = u.Username
|
||||
}
|
||||
fields = append(fields, &discordgo.MessageEmbedField{
|
||||
Name: fmt.Sprintf("%d. %s", idx+1, display),
|
||||
Value: fmt.Sprintf("Score: **%d**", entry.Score),
|
||||
Inline: false,
|
||||
})
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "RPS Leaderboard",
|
||||
Description: fmt.Sprintf("Top %d players in this server", len(top)),
|
||||
Color: 0xA855F7,
|
||||
Fields: fields,
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/fun/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var RpsStats = &discordgo.ApplicationCommand{
|
||||
Name: "rpsstats",
|
||||
Description: "Shows your RPS stats",
|
||||
Contexts: &[]discordgo.InteractionContextType{
|
||||
discordgo.InteractionContextGuild,
|
||||
},
|
||||
}
|
||||
|
||||
func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if i.GuildID == "" {
|
||||
shared.Respond(s, i, "This command can only be used in a server.")
|
||||
return
|
||||
}
|
||||
if services.Global == nil || services.Global.RPS == nil {
|
||||
shared.RespondEphemeral(s, i, "RPS service is not available.")
|
||||
return
|
||||
}
|
||||
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid guild.")
|
||||
return
|
||||
}
|
||||
user := i.User
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
user = i.Member.User
|
||||
}
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(user.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
score, ok, err := services.Global.RPS.GetScore(context.Background(), guildID, userID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load stats.")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "You haven't played RPS yet!")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "RPS Stats",
|
||||
Description: user.Mention(),
|
||||
Color: 0x3B82F6,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Score", Value: strconv.Itoa(score), Inline: true},
|
||||
},
|
||||
}
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package fun
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/fun/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Commands
|
||||
var (
|
||||
Ping *discordgo.ApplicationCommand = public.Ping
|
||||
Joke *discordgo.ApplicationCommand = public.Joke
|
||||
Coinflip *discordgo.ApplicationCommand = public.Coinflip
|
||||
Dice *discordgo.ApplicationCommand = public.Dice
|
||||
Rps *discordgo.ApplicationCommand = public.Rps
|
||||
RpsStats *discordgo.ApplicationCommand = public.RpsStats
|
||||
RpsLeaderboard *discordgo.ApplicationCommand = public.RpsLeaderboard
|
||||
)
|
||||
|
||||
// Handlers
|
||||
func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PingHandler(s, i) }
|
||||
func JokeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.JokeHandler(s, i) }
|
||||
func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.CoinflipHandler(s, i)
|
||||
}
|
||||
func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.DiceHandler(s, i) }
|
||||
func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RpsHandler(s, i) }
|
||||
func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.RpsStatsHandler(s, i)
|
||||
}
|
||||
func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.RpsLeaderboardHandler(s, i)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func Respond(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: msg,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
+109
-13
@@ -42,32 +42,128 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Velox Bot Help",
|
||||
Description: "Here is the help for the bot.",
|
||||
Description: "Command reference. Some commands require server permissions or roles.",
|
||||
Color: 0xFFA500,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Fun Commands",
|
||||
Value: "Here are the fun commands for the bot.",
|
||||
Name: "Fun",
|
||||
Value: "" +
|
||||
"`/ping` — Pong!\n" +
|
||||
"`/joke [type]` — Random joke (optional category)\n" +
|
||||
"`/coinflip` — Flip a coin\n" +
|
||||
"`/dice <expr>` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" +
|
||||
"`/rps <hand>` — Rock Paper Scissors (server only)\n" +
|
||||
"`/rpsstats` — Your RPS score (server only)\n" +
|
||||
"`/rpsleaderboard` — Top RPS scores (server only)",
|
||||
},
|
||||
{
|
||||
Name: "/ping",
|
||||
Value: "Pong!",
|
||||
Name: "Music (requires **DJ** role)",
|
||||
Value: "" +
|
||||
"`/play <query>` — Play a song (search or URL)\n" +
|
||||
"`/queue` — Show the current queue\n" +
|
||||
"`/volume <0-150>` — Set playback volume",
|
||||
},
|
||||
{
|
||||
Name: "/help",
|
||||
Value: "Get help with the bot.",
|
||||
Name: "Scheduling (1:1 sessions)",
|
||||
Value: "" +
|
||||
"`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" +
|
||||
"`/schedule list` — List your upcoming sessions\n" +
|
||||
"`/schedule reschedule id:<id> datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" +
|
||||
"Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.",
|
||||
},
|
||||
{
|
||||
Name: "Leveling System",
|
||||
Value: `The leveling system is a system that allows users to gain XP and level up. The system is based on the amount of messages sent in a server. The more messages you send, the more XP you gain. The more XP you gain, the higher your level will be. The leveling system is disabled by default, however you can enable/disable it by using the /slvl toggle command. You can also configure the leveling system by using the /slvl command.`,
|
||||
Name: "Timezone",
|
||||
Value: "" +
|
||||
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
|
||||
"`/timezone show` — Show your timezone",
|
||||
},
|
||||
{
|
||||
Name: "/slvl toggle",
|
||||
Value: "Toggle the leveling system.",
|
||||
Name: "Projects",
|
||||
Value: "" +
|
||||
"`/projects list` — List active projects\n" +
|
||||
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
|
||||
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "/slvl set",
|
||||
Value: "Set the level of a user.",
|
||||
Name: "Tickets",
|
||||
Value: "" +
|
||||
"Open a ticket from the panel: you get a private thread with the staff.\n" +
|
||||
"`/ticket add user:@someone` — Add someone to your ticket\n" +
|
||||
"`/ticket close [reason]` — Close the ticket (opener or staff)\n" +
|
||||
"`/ticket claim`, `/ticket remove user:@someone` — Staff only\n" +
|
||||
"`/tickets setup`, `/tickets category add|remove|list`, `/tickets panel` — Set up (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "Security (anti-raid & anti-nuke)",
|
||||
Value: "" +
|
||||
"`/security status` — What's on and who is trusted\n" +
|
||||
"`/security toggle` — Join Gate, raid mode, anti-nuke on/off (off: owner or trusted)\n" +
|
||||
"`/security alerts`, `/security quarantine-role`, `/security release`, `/security end-raid` — Owner or trusted\n" +
|
||||
"`/security trust add|remove` — Owner only\n" +
|
||||
"`/security snapshot` — Save roles and channels; `/security restore` — Rebuild what a nuke deleted (owner or trusted)\n" +
|
||||
"`/lockdown channel|server|joins`, `/unlock …` (unlock: owner or trusted)",
|
||||
},
|
||||
{
|
||||
Name: "Meetings (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/meeting add-lobby channel:#voice` — Add a voice lobby that creates meetings (up to 10)\n" +
|
||||
"`/meeting remove-lobby channel:#voice` — Stop using a voice lobby\n" +
|
||||
"`/meeting lobbies` — List the voice lobbies\n" +
|
||||
"`/meeting lock` — Lock your meeting room\n" +
|
||||
"`/meeting lock-private` — Lock + hide your meeting room\n" +
|
||||
"`/meeting unlock` — Unlock your meeting room\n" +
|
||||
"`/meeting invite user:@someone` — Allow a user to join\n" +
|
||||
"`/meeting uninvite user:@someone` — Remove a user's permission",
|
||||
},
|
||||
{
|
||||
Name: "Moderation (focused tools)",
|
||||
Value: "" +
|
||||
"`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" +
|
||||
"`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" +
|
||||
"`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" +
|
||||
"`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" +
|
||||
"`/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)\n" +
|
||||
"`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" +
|
||||
"`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)",
|
||||
},
|
||||
{
|
||||
Name: "Leveling",
|
||||
Value: "" +
|
||||
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
|
||||
"`/slvl set user:@someone level:<n>` — Set user level (**Manage Server**)\n" +
|
||||
"`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" +
|
||||
"`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" +
|
||||
"`/slvl set-reward level:<n> role:@role` — Role rewards (**Manage Server**)\n" +
|
||||
"`/rank [user]` — Your rank card (server only)\n" +
|
||||
"`/leaderboard` — Top levels (server only)\n" +
|
||||
"`/rewards` — List configured rewards (server only)",
|
||||
},
|
||||
{
|
||||
Name: "Twitch live notifications (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config addstreamer username:<twitch-name>` — Add a streamer\n" +
|
||||
"`/config removestreamer username:<twitch-name>` — Remove a streamer\n" +
|
||||
"`/config settwitchnotificationchannel channel:#channel` — Set notification channel",
|
||||
},
|
||||
{
|
||||
Name: "Welcome / onboarding (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config setwelcomechannel channel:#channel` — Channel for welcome messages\n" +
|
||||
"`/config setwelcomemessage message:\"...\"` — Welcome text (supports `{user}`, `{server}`)\n" +
|
||||
"`/config setwelcomedm message:\"...\"` — DM template (supports `{user}`, `{server}`)\n" +
|
||||
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
|
||||
"`/config setdefaultrole role:@role` — Role granted to new members",
|
||||
},
|
||||
{
|
||||
Name: "Logging (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
|
||||
"`/config enablelogging` — Enable event/moderation logging\n" +
|
||||
"`/config disablelogging` — Disable event/moderation logging",
|
||||
},
|
||||
{
|
||||
Name: "Other",
|
||||
Value: "`/help` — Show this help in DMs",
|
||||
},
|
||||
},
|
||||
Author: &author,
|
||||
|
||||
@@ -17,7 +17,10 @@ var (
|
||||
|
||||
// Handlers
|
||||
func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RankHandler(s, i) }
|
||||
func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.LeaderboardHandler(s, i) }
|
||||
func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RewardsHandler(s, i) }
|
||||
func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.LeaderboardHandler(s, i)
|
||||
}
|
||||
func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.RewardsHandler(s, i)
|
||||
}
|
||||
func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/commands/meeting/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
meetingsvc "velox-bot/internal/db/services/meeting"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Meeting = &discordgo.ApplicationCommand{
|
||||
Name: "meeting",
|
||||
Description: "Meeting room settings",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "add-lobby",
|
||||
Description: "Add a voice lobby: joining it creates a meeting room",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionChannel,
|
||||
Name: "channel",
|
||||
Description: "Voice channel to use as a lobby",
|
||||
Required: true,
|
||||
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "remove-lobby",
|
||||
Description: "Stop using a voice channel as a lobby",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionChannel,
|
||||
Name: "channel",
|
||||
Description: "The lobby to remove",
|
||||
Required: true,
|
||||
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "lobbies",
|
||||
Description: "List this server's meeting lobbies",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "lock",
|
||||
Description: "Lock your current meeting room (block others from joining)",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "lock-private",
|
||||
Description: "Lock and hide your meeting room (block joining and visibility)",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "unlock",
|
||||
Description: "Unlock your current meeting room (allow others to join)",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "invite",
|
||||
Description: "Allow a user to join your locked meeting room",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to allow",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "uninvite",
|
||||
Description: "Remove a previously invited user's permission to join",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to remove",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) || !shared.RequireManageGuild(s, i) || !shared.RequireMeetingService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing subcommand.")
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "add-lobby":
|
||||
handleAddLobby(s, i)
|
||||
case "remove-lobby":
|
||||
handleRemoveLobby(s, i)
|
||||
case "lobbies":
|
||||
handleListLobbies(s, i)
|
||||
case "lock":
|
||||
handleLock(s, i)
|
||||
case "lock-private":
|
||||
handleLockPrivate(s, i)
|
||||
case "unlock":
|
||||
handleUnlock(s, i)
|
||||
case "invite":
|
||||
handleInvite(s, i)
|
||||
case "uninvite":
|
||||
handleUninvite(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
// lobbyChannelOption reads the "channel" option of add-lobby and
|
||||
// remove-lobby, answering the user itself if it's missing or not a voice
|
||||
// channel.
|
||||
func lobbyChannelOption(s *discordgo.Session, i *discordgo.InteractionCreate) (*discordgo.Channel, int64, bool) {
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
channelOpt, ok := optMap["channel"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing channel option.")
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
ch := channelOpt.ChannelValue(s)
|
||||
if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice {
|
||||
shared.RespondEphemeral(s, i, "Invalid voice channel.")
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid channel ID.")
|
||||
return nil, 0, false
|
||||
}
|
||||
return ch, channelID, true
|
||||
}
|
||||
|
||||
func handleAddLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, channelID, ok := lobbyChannelOption(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
added, err := services.Global.Meeting.AddLobby(context.Background(), guildID, channelID)
|
||||
switch {
|
||||
case errors.Is(err, meetingsvc.ErrLobbyLimit):
|
||||
shared.RespondEphemeral(s, i, fmt.Sprintf("This server already has %d lobbies. Remove one with `/meeting remove-lobby` first.", meetingsvc.MaxLobbies))
|
||||
case err != nil:
|
||||
shared.RespondEphemeral(s, i, "Failed to add the meeting lobby.")
|
||||
case !added:
|
||||
shared.RespondEphemeral(s, i, ch.Mention()+" is already a meeting lobby.")
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, ch.Mention()+" is now a meeting lobby.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, channelID, ok := lobbyChannelOption(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
removed, err := services.Global.Meeting.RemoveLobby(context.Background(), guildID, channelID)
|
||||
switch {
|
||||
case err != nil:
|
||||
shared.RespondEphemeral(s, i, "Failed to remove the meeting lobby.")
|
||||
case !removed:
|
||||
shared.RespondEphemeral(s, i, ch.Mention()+" isn't a meeting lobby.")
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, ch.Mention()+" is no longer a meeting lobby.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleListLobbies(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ids, err := services.Global.Meeting.ListLobbies(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to list the meeting lobbies.")
|
||||
return
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
shared.RespondEphemeral(s, i, "No meeting lobbies yet. Add one with `/meeting add-lobby`.")
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Meeting lobbies (%d of %d):\n", len(ids), meetingsvc.MaxLobbies)
|
||||
for _, id := range ids {
|
||||
fmt.Fprintf(&b, "<#%d>\n", id)
|
||||
}
|
||||
shared.RespondEphemeral(s, i, b.String())
|
||||
}
|
||||
|
||||
func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ch, chID64, guildID64, ok := requireOwnTempVoiceChannel(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Deny CONNECT for @everyone (guild ID).
|
||||
if err := s.ChannelPermissionSet(
|
||||
ch.ID,
|
||||
i.GuildID,
|
||||
discordgo.PermissionOverwriteTypeRole,
|
||||
0,
|
||||
discordgo.PermissionVoiceConnect,
|
||||
); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to lock the meeting room.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Locked "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.")
|
||||
|
||||
_ = chID64
|
||||
_ = guildID64
|
||||
}
|
||||
|
||||
func handleLockPrivate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ch, _, _, ok := requireOwnTempVoiceChannel(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Deny CONNECT and VIEW_CHANNEL for @everyone (guild ID).
|
||||
deny := int64(discordgo.PermissionVoiceConnect | discordgo.PermissionViewChannel)
|
||||
if err := s.ChannelPermissionSet(
|
||||
ch.ID,
|
||||
i.GuildID,
|
||||
discordgo.PermissionOverwriteTypeRole,
|
||||
0,
|
||||
deny,
|
||||
); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to lock the meeting room.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Locked (private) "+ch.Mention()+". Use `/meeting invite user:@someone` to allow specific people.")
|
||||
}
|
||||
|
||||
func handleUnlock(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ch, _, _, ok := requireOwnTempVoiceChannel(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the @everyone overwrite so defaults apply again.
|
||||
if err := s.ChannelPermissionDelete(ch.ID, i.GuildID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to unlock the meeting room.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Unlocked "+ch.Mention()+".")
|
||||
}
|
||||
|
||||
func handleInvite(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ch, _, _, ok := requireOwnTempVoiceChannel(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
userOpt, ok := optMap["user"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing user option.")
|
||||
return
|
||||
}
|
||||
u := userOpt.UserValue(s)
|
||||
if u == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Allow CONNECT for the invited user.
|
||||
if err := s.ChannelPermissionSet(
|
||||
ch.ID,
|
||||
u.ID,
|
||||
discordgo.PermissionOverwriteTypeMember,
|
||||
discordgo.PermissionVoiceConnect,
|
||||
0,
|
||||
); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to invite user.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Invited "+u.Mention()+" to "+ch.Mention()+".")
|
||||
}
|
||||
|
||||
func handleUninvite(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ch, _, _, ok := requireOwnTempVoiceChannel(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
userOpt, ok := optMap["user"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing user option.")
|
||||
return
|
||||
}
|
||||
u := userOpt.UserValue(s)
|
||||
if u == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove any member-specific overwrite for this user.
|
||||
if err := s.ChannelPermissionDelete(ch.ID, u.ID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to remove invite.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Removed "+u.Mention()+" from "+ch.Mention()+".")
|
||||
}
|
||||
|
||||
func requireOwnTempVoiceChannel(s *discordgo.Session, i *discordgo.InteractionCreate) (channel *discordgo.Channel, channelID64 int64, guildID64 int64, ok bool) {
|
||||
guildID64, ok = shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
voiceChannelID, found := findMemberVoiceChannelID(s, i.GuildID, i.Member.User.ID)
|
||||
if !found || voiceChannelID == "" {
|
||||
shared.RespondEphemeral(s, i, "You must be in your meeting voice channel to use this.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
ch, err := s.Channel(voiceChannelID)
|
||||
if err != nil || ch == nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your voice channel.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
if ch.Type != discordgo.ChannelTypeGuildVoice {
|
||||
shared.RespondEphemeral(s, i, "This only works in a voice channel.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
channelID64, err = strconv.ParseInt(ch.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid channel ID.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
isTemp, err := services.Global.Meeting.IsTempChannel(context.Background(), guildID64, channelID64)
|
||||
if err != nil || !isTemp {
|
||||
shared.RespondEphemeral(s, i, "This is not a bot-created meeting room.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
ownerID, ownerOK, err := services.Global.Meeting.GetTempChannelOwner(context.Background(), guildID64, channelID64)
|
||||
if err != nil || !ownerOK {
|
||||
shared.RespondEphemeral(s, i, "Failed to verify meeting room owner.")
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
invokerID64, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if invokerID64 == 0 || ownerID != invokerID64 {
|
||||
shared.RespondEphemeral(s, i, fmt.Sprintf("Only the room creator can do this. (creator: <@%d>)", ownerID))
|
||||
return nil, 0, 0, false
|
||||
}
|
||||
|
||||
return ch, channelID64, guildID64, true
|
||||
}
|
||||
|
||||
func findMemberVoiceChannelID(s *discordgo.Session, guildID, userID string) (string, bool) {
|
||||
// Prefer session state cache (discordgo tracks voice states there when IntentsGuildVoiceStates is enabled).
|
||||
if s != nil && s.State != nil {
|
||||
if vs, err := s.State.VoiceState(guildID, userID); err == nil && vs != nil {
|
||||
if vs.ChannelID != "" {
|
||||
return vs.ChannelID, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
// Fallback to cached guild object (State, not REST).
|
||||
if g, err := s.State.Guild(guildID); err == nil && g != nil {
|
||||
for _, st := range g.VoiceStates {
|
||||
if st != nil && st.UserID == userID {
|
||||
return st.ChannelID, st.ChannelID != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package meeting
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/meeting/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Meeting *discordgo.ApplicationCommand = public.Meeting
|
||||
)
|
||||
|
||||
func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MeetingHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
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 RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if i.GuildID == "" {
|
||||
RespondEphemeral(s, i, "This command can only be used in a server.")
|
||||
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 don't have permission to manage meeting settings.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireMeetingService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Meeting == nil {
|
||||
RespondEphemeral(s, i, "Meeting 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 SubcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption {
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return map[string]*discordgo.ApplicationCommandInteractionDataOption{}
|
||||
}
|
||||
opts := data.Options[0].Options
|
||||
m := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(opts))
|
||||
for _, opt := range opts {
|
||||
m[opt.Name] = opt
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package public
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
func memberHasDJRole(s *discordgo.Session, guildID string, m *discordgo.Member) bool {
|
||||
if s == nil || guildID == "" || m == nil {
|
||||
return false
|
||||
}
|
||||
if len(m.Roles) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
roles, err := s.GuildRoles(guildID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
djRoleID := ""
|
||||
for _, r := range roles {
|
||||
if r != nil && r.Name == "DJ" {
|
||||
djRoleID = r.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if djRoleID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rid := range m.Roles {
|
||||
if rid == djRoleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Play = &discordgo.ApplicationCommand{
|
||||
Name: "play",
|
||||
Description: "Play a song via Lavalink",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "query",
|
||||
Description: "Song name or URL",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireMusicEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
}
|
||||
query := data.Options[0].StringValue()
|
||||
|
||||
// Always acknowledge quickly to avoid "application did not respond".
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Searching...",
|
||||
},
|
||||
})
|
||||
|
||||
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("You need the **DJ** role to use music commands."),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
|
||||
if err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := entry.Track.Info.Title
|
||||
author := entry.Track.Info.Author
|
||||
length := entry.Track.Info.Length
|
||||
|
||||
if started {
|
||||
// manager will post now-playing message & manage invalidating old buttons
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||
})
|
||||
} else {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return new(v) }
|
||||
|
||||
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
|
||||
g, err := s.State.Guild(guildID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot find guild voice state")
|
||||
}
|
||||
for _, vs := range g.VoiceStates {
|
||||
if vs.UserID == userID {
|
||||
return vs, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("you must be in a voice channel")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Queue = &discordgo.ApplicationCommand{
|
||||
Name: "queue",
|
||||
Description: "Show the current music queue",
|
||||
}
|
||||
|
||||
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,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You need the **DJ** role to use music commands.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
current, rest := music.GetQueue(i.GuildID)
|
||||
|
||||
if current == nil {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "The queue is currently empty.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
nowPlaying := fmt.Sprintf("%s — %s", current.Track.Info.Title, current.Track.Info.Author)
|
||||
|
||||
var descBuilder strings.Builder
|
||||
if len(rest) > 0 {
|
||||
limit := len(rest)
|
||||
if limit > 10 {
|
||||
limit = 10
|
||||
}
|
||||
for idx, entry := range rest[:limit] {
|
||||
fmt.Fprintf(&descBuilder, "%d. %s — %s (requested by %s)\n", idx+1, entry.Track.Info.Title, entry.Track.Info.Author, entry.RequestedBy)
|
||||
}
|
||||
if len(rest) > limit {
|
||||
fmt.Fprintf(&descBuilder, "...and %d more.", len(rest)-limit)
|
||||
}
|
||||
} else {
|
||||
descBuilder.WriteString("Nothing else in the queue.")
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{
|
||||
Title: "Music Queue",
|
||||
Description: descBuilder.String(),
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Now Playing",
|
||||
Value: nowPlaying,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"velox-bot/internal/commands/music/shared"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Volume = &discordgo.ApplicationCommand{
|
||||
Name: "volume",
|
||||
Description: "Set playback volume (0-150)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "value",
|
||||
Description: "Volume percent (0-150)",
|
||||
Required: true,
|
||||
MinValue: ptrFloat(0),
|
||||
MaxValue: 150,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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{
|
||||
Content: "Updating volume...",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
|
||||
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("You need the **DJ** role to use music commands."),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr("Missing volume value."),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
vol := int(data.Options[0].IntValue())
|
||||
if err := music.SetVolume(i.GuildID, vol); err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Volume set to **%d%%**.", vol)),
|
||||
})
|
||||
}
|
||||
|
||||
func ptrFloat(v float64) *float64 { return &v }
|
||||
@@ -0,0 +1,21 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/music/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
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) }
|
||||
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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/commands/projects/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Projects = &discordgo.ApplicationCommand{
|
||||
Name: "projects",
|
||||
Description: "Manage and list ongoing projects",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "list",
|
||||
Description: "List active projects with creators and helpers",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new active project",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "add-helper",
|
||||
Description: "Add a helper to an existing project",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "close",
|
||||
Description: "Close (deactivate) a project",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) || !shared.RequireProjectsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleList(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "list":
|
||||
handleList(s, i)
|
||||
case "create":
|
||||
handleCreate(s, i)
|
||||
case "add-helper":
|
||||
handleAddHelper(s, i)
|
||||
case "close":
|
||||
handleClose(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
const limit = 25
|
||||
items, err := services.Global.Projects.ListActiveWithMembers(context.Background(), guildID, limit)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
shared.RespondEphemeral(s, i, "There are no active projects at the moment.")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Active projects",
|
||||
Description: "",
|
||||
Color: 0x00bcd4,
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item == nil || item.Project == nil {
|
||||
continue
|
||||
}
|
||||
p := item.Project
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = "Unnamed project"
|
||||
}
|
||||
creatorMention := mentionUser(item.Creator)
|
||||
|
||||
var helpersMentions []string
|
||||
for _, h := range item.Helpers {
|
||||
helpersMentions = append(helpersMentions, mentionUser(h))
|
||||
}
|
||||
helpersText := "None"
|
||||
if len(helpersMentions) > 0 {
|
||||
helpersText = strings.Join(helpersMentions, ", ")
|
||||
}
|
||||
|
||||
desc := "**Creator:** " + creatorMention + "\n" +
|
||||
"**Helpers:** " + helpersText
|
||||
if p.Description != "" {
|
||||
desc += "\n" + p.Description
|
||||
}
|
||||
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: name,
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func mentionUser(id int64) string {
|
||||
if id == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
_ = 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
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
options, err := activeProjectOptions(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load projects.")
|
||||
return
|
||||
}
|
||||
if len(options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "There are no active projects to add a helper to.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = 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, "Failed to load projects.")
|
||||
return
|
||||
}
|
||||
if len(options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "There are no active projects to close.")
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/projects/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Projects *discordgo.ApplicationCommand = public.Projects
|
||||
)
|
||||
|
||||
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,53 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
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 RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if i.GuildID == "" {
|
||||
RespondEphemeral(s, i, "This command can only be used in a server.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireProjectsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Projects == nil {
|
||||
RespondEphemeral(s, i, "Projects service is not available.")
|
||||
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 don't have permission to manage projects.")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/schedule/shared"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Schedule = &discordgo.ApplicationCommand{
|
||||
Name: "schedule",
|
||||
Description: "Schedule 1:1 sessions",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "create",
|
||||
Description: "Create a new session with a user",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "list",
|
||||
Description: "List your upcoming sessions",
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "reschedule",
|
||||
Description: "Reschedule an existing session",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "id",
|
||||
Description: "ID of the session (from /schedule list)",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "New datetime (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleList(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "create":
|
||||
handleCreate(s, i)
|
||||
case "list":
|
||||
handleList(s, i)
|
||||
case "reschedule":
|
||||
handleReschedule(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if _, ok := shared.ParseGuildID(s, i); !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
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
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
const limit = 10
|
||||
items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your sessions.")
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
shared.RespondEphemeral(s, i, "You have no upcoming sessions.")
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, sch := range items {
|
||||
role := "Requester"
|
||||
otherID := sch.InviteeID
|
||||
if sch.InviteeID == userID {
|
||||
role = "Invitee"
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
status := string(sch.Status)
|
||||
whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status)
|
||||
if sch.Description != "" {
|
||||
line += " " + sch.Description + "\n"
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, b.String())
|
||||
}
|
||||
|
||||
func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
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 (
|
||||
idVal int64
|
||||
whenStr string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "id":
|
||||
idVal = o.IntValue()
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
}
|
||||
}
|
||||
if idVal <= 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid session ID.")
|
||||
return
|
||||
}
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := services.Global.Schedule.GetByID(ctx, idVal)
|
||||
if err != nil || sch == nil {
|
||||
shared.RespondEphemeral(s, i, "Session not found.")
|
||||
return
|
||||
}
|
||||
if sch.GuildID != guildID {
|
||||
shared.RespondEphemeral(s, i, "This session belongs to another server.")
|
||||
return
|
||||
}
|
||||
if userID != sch.RequesterID && userID != sch.InviteeID {
|
||||
shared.RespondEphemeral(s, i, "You are not part of this session.")
|
||||
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 {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect explicit layout "2006-01-02 15:04" in user's timezone.
|
||||
when, err := shared.ParseScheduleDatetime(whenStr, loc)
|
||||
if err != nil {
|
||||
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.ProposeReschedule(ctx, sch.ID, when, userID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to propose reschedule.")
|
||||
return
|
||||
}
|
||||
|
||||
whenFmt := when.Format("2006-01-02 15:04")
|
||||
|
||||
otherID := sch.InviteeID
|
||||
if userID == sch.InviteeID {
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
|
||||
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
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 the other participant's local time if available.
|
||||
locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID)
|
||||
if errTZ != nil {
|
||||
locOther = time.UTC
|
||||
zoneOther = "UTC"
|
||||
}
|
||||
localOtherStr := when.In(locOther).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: whenFmt,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneOther + ")",
|
||||
Value: localOtherStr,
|
||||
},
|
||||
}
|
||||
if sch.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: sch.Description,
|
||||
})
|
||||
}
|
||||
|
||||
_, 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 {
|
||||
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/schedule/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Schedule *discordgo.ApplicationCommand = public.Schedule
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: msg,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireScheduleService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Schedule == nil {
|
||||
RespondEphemeral(s, i, "Scheduling 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/security/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Security *discordgo.ApplicationCommand = public.Security
|
||||
Lockdown *discordgo.ApplicationCommand = public.Lockdown
|
||||
Unlock *discordgo.ApplicationCommand = public.Unlock
|
||||
)
|
||||
|
||||
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.SecurityHandler(s, i)
|
||||
}
|
||||
|
||||
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.LockdownHandler(s, i)
|
||||
}
|
||||
|
||||
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.UnlockHandler(s, i)
|
||||
}
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleComponent(s, i)
|
||||
}
|
||||
@@ -0,0 +1,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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/tickets/public"
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Tickets *discordgo.ApplicationCommand = public.Tickets
|
||||
Ticket *discordgo.ApplicationCommand = public.Ticket
|
||||
)
|
||||
|
||||
func Init(r *ticketrepo.Repo) { public.Init(r) }
|
||||
|
||||
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.TicketsHandler(s, i)
|
||||
}
|
||||
|
||||
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TicketHandler(s, i) }
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleComponent(s, i)
|
||||
}
|
||||
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleModalSubmit(s, i)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/timezone/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Timezone = &discordgo.ApplicationCommand{
|
||||
Name: "timezone",
|
||||
Description: "Configure and view your timezone",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set",
|
||||
Description: "Set your timezone (IANA name, e.g., Europe/Lisbon)",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "zone",
|
||||
Description: "Timezone (e.g., Europe/Lisbon, America/New_York)",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "show",
|
||||
Description: "Show your current timezone",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireUserSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
handleShow(s, i)
|
||||
return
|
||||
}
|
||||
|
||||
switch data.Options[0].Name {
|
||||
case "set":
|
||||
handleSet(s, i)
|
||||
case "show":
|
||||
handleShow(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Unknown subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
userID, ok := shared.CurrentUserID(i)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var zone string
|
||||
for _, o := range opt.Options {
|
||||
if o.Name == "zone" {
|
||||
zone = o.StringValue()
|
||||
}
|
||||
}
|
||||
if zone == "" {
|
||||
shared.RespondEphemeral(s, i, "Timezone cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate via service (which uses time.LoadLocation).
|
||||
if err := services.Global.UserSettings.SetTimezone(context.Background(), userID, zone); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid timezone. Use an IANA name like `Europe/Lisbon`.")
|
||||
return
|
||||
}
|
||||
|
||||
loc, z, _, _ := services.Global.UserSettings.GetTimezone(context.Background(), userID)
|
||||
now := time.Now().In(loc).Format("2006-01-02 15:04")
|
||||
shared.RespondEphemeral(s, i, "Your timezone is now set to `"+z+"` (current local time: "+now+").")
|
||||
}
|
||||
|
||||
func handleShow(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
userID, ok := shared.CurrentUserID(i)
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Could not determine your user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
loc, z, userDefined, err := services.Global.UserSettings.GetTimezone(context.Background(), userID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load your timezone.")
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc).Format("2006-01-02 15:04")
|
||||
|
||||
if userDefined {
|
||||
shared.RespondEphemeral(s, i, "Your timezone is `"+z+"` (current local time: "+now+").")
|
||||
} else {
|
||||
shared.RespondEphemeral(s, i, "You don't have a timezone set. Using `UTC` (current time: "+now+"). Use `/timezone set` to configure one.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package timezone
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/timezone/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Timezone *discordgo.ApplicationCommand = public.Timezone
|
||||
)
|
||||
|
||||
func TimezoneHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TimezoneHandler(s, i) }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
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 RequireUserSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.UserSettings == nil {
|
||||
RespondEphemeral(s, i, "User settings service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func CurrentUserID(i *discordgo.InteractionCreate) (int64, bool) {
|
||||
var idStr string
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
idStr = i.Member.User.ID
|
||||
} else if i.User != nil {
|
||||
idStr = i.User.ID
|
||||
}
|
||||
if idStr == "" {
|
||||
return 0, false
|
||||
}
|
||||
uid, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ type Config struct {
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
TwitchClientID string
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
@@ -30,19 +32,27 @@ 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 == "" {
|
||||
return nil, fmt.Errorf("DB_HOST is not set")
|
||||
}
|
||||
|
||||
lavalinkHost := os.Getenv("LAVALINK_HOST")
|
||||
lavalinkPass := os.Getenv("LAVALINK_PASSWORD")
|
||||
|
||||
twitchClientID := os.Getenv("TWITCH_CLIENT_ID")
|
||||
if twitchClientID == "" {
|
||||
return nil, fmt.Errorf("TWITCH_CLIENT_ID is not set")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
BotToken: token,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
TwitchClientID: twitchClientID,
|
||||
}, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package projectsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
Name string
|
||||
Description string
|
||||
IsActive bool
|
||||
CreatedBy int64
|
||||
}
|
||||
|
||||
type ProjectWithMembers struct {
|
||||
Project *Project
|
||||
Creator int64
|
||||
Helpers []int64
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO projects (guild_id, name, description, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
if err := r.db.QueryRowContext(ctx, q, guildID, name, description, creatorID).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
const qMember = `
|
||||
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||
VALUES ($1, $2, TRUE)
|
||||
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||
`
|
||||
if _, err := r.db.ExecContext(ctx, qMember, id, creatorID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||
const q = `
|
||||
INSERT INTO project_members (project_id, user_id, is_creator)
|
||||
VALUES ($1, $2, FALSE)
|
||||
ON CONFLICT (project_id, user_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, projectID, userID)
|
||||
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
|
||||
FROM projects
|
||||
WHERE guild_id = $1 AND id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, projectID)
|
||||
var one int
|
||||
switch err := row.Scan(&one); err {
|
||||
case nil:
|
||||
return true, nil
|
||||
case sql.ErrNoRows:
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM projects
|
||||
WHERE guild_id = $1 AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, qProjects, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*ProjectWithMembers
|
||||
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, &ProjectWithMembers{
|
||||
Project: p,
|
||||
Creator: p.CreatedBy,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Load members per project.
|
||||
const qMembers = `
|
||||
SELECT project_id, user_id, is_creator
|
||||
FROM project_members
|
||||
WHERE project_id = ANY($1)
|
||||
`
|
||||
ids := make([]int64, len(out))
|
||||
for i, p := range out {
|
||||
ids[i] = p.Project.ID
|
||||
}
|
||||
|
||||
rowsM, err := r.db.QueryContext(ctx, qMembers, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rowsM.Close()
|
||||
|
||||
index := make(map[int64]*ProjectWithMembers, len(out))
|
||||
for _, p := range out {
|
||||
index[p.Project.ID] = p
|
||||
}
|
||||
|
||||
for rowsM.Next() {
|
||||
var pid, uid int64
|
||||
var isCreator bool
|
||||
if err := rowsM.Scan(&pid, &uid, &isCreator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, ok := index[pid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isCreator {
|
||||
item.Creator = uid
|
||||
} else {
|
||||
item.Helpers = append(item.Helpers, uid)
|
||||
}
|
||||
}
|
||||
if err := rowsM.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package rpsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) {
|
||||
var score int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT score
|
||||
FROM rps
|
||||
WHERE guild_id = $1 AND user_id = $2
|
||||
`, guildID, userID).Scan(&score)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return score, true, nil
|
||||
}
|
||||
|
||||
func (r *Repo) SetScore(ctx context.Context, guildID int64, userID int64, score int) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO rps (guild_id, user_id, score)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (guild_id, user_id)
|
||||
DO UPDATE SET score = EXCLUDED.score
|
||||
`, guildID, userID, score)
|
||||
return err
|
||||
}
|
||||
|
||||
type ScoreEntry struct {
|
||||
UserID int64
|
||||
Score int
|
||||
}
|
||||
|
||||
func (r *Repo) TopScores(ctx context.Context, guildID int64, limit int) ([]ScoreEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT user_id, score
|
||||
FROM rps
|
||||
WHERE guild_id = $1
|
||||
ORDER BY score DESC, user_id ASC
|
||||
LIMIT $2
|
||||
`, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]ScoreEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var e ScoreEntry
|
||||
if err := rows.Scan(&e.UserID, &e.Score); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package schedulerepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type ScheduleStatus string
|
||||
|
||||
const (
|
||||
StatusPending ScheduleStatus = "pending"
|
||||
StatusAccepted ScheduleStatus = "accepted"
|
||||
StatusDeclined ScheduleStatus = "declined"
|
||||
StatusRescheduleRequested ScheduleStatus = "reschedule_requested"
|
||||
)
|
||||
|
||||
type Schedule struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
RequesterID int64
|
||||
InviteeID int64
|
||||
ScheduledAt time.Time
|
||||
ProposedAt *time.Time
|
||||
ProposedBy *int64
|
||||
PreRescheduleStatus *ScheduleStatus
|
||||
Status ScheduleStatus
|
||||
Description string
|
||||
ReminderSent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO schedules (guild_id, requester_id, invitee_id, scheduled_at, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
if err := r.db.QueryRowContext(ctx, q, guildID, requesterID, inviteeID, when, description).Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, status, scheduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE guild_id = $1
|
||||
AND (requester_id = $2 OR invitee_id = $2)
|
||||
AND status IN ('pending', 'accepted', 'reschedule_requested')
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Schedule
|
||||
for rows.Next() {
|
||||
var sch Schedule
|
||||
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &sch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||
const q = `
|
||||
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.ProposedAt, &sch.ProposedBy, &sch.PreRescheduleStatus, &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) Reschedule(ctx context.Context, id int64, when time.Time) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET scheduled_at = $1,
|
||||
status = 'pending',
|
||||
reminder_sent = FALSE
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, when, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListDueForReminder returns sessions starting between now and now+ahead, not yet reminded.
|
||||
func (r *Repo) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*Schedule, error) {
|
||||
const q = `
|
||||
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE reminder_sent = FALSE
|
||||
AND status IN ('pending', 'accepted', 'reschedule_requested')
|
||||
AND scheduled_at > $1
|
||||
AND scheduled_at <= $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, now, now.Add(ahead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Schedule
|
||||
for rows.Next() {
|
||||
var sch Schedule
|
||||
if err := rows.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &sch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
SET reminder_sent = TRUE
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID)
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//go:build integration
|
||||
|
||||
// Runs against a real Postgres with the dashboard API's migrations applied
|
||||
// (the lobby limit is a trigger from migration 000020):
|
||||
//
|
||||
// TEST_DB=postgres://... go test -tags integration ./internal/db/repos/settingsrepo
|
||||
package settingsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func testRepo(t *testing.T) (*Repo, int64) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DB")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DB not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
const guild = 4242
|
||||
if _, err := db.Exec(`DELETE FROM meeting_lobbies WHERE guild_id IN ($1, $2)`, guild, guild+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewRepo(db), guild
|
||||
}
|
||||
|
||||
func TestMeetingLobbies(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if ids, err := r.ListMeetingLobbies(ctx, g); err != nil || len(ids) != 0 {
|
||||
t.Fatalf("a fresh server has no lobbies: %v %v", ids, err)
|
||||
}
|
||||
|
||||
for ch := int64(1); ch <= MaxMeetingLobbies; ch++ {
|
||||
added, err := r.AddMeetingLobby(ctx, g, ch)
|
||||
if err != nil || !added {
|
||||
t.Fatalf("adding lobby %d: added=%v err=%v", ch, added, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.AddMeetingLobby(ctx, g, 99); !errors.Is(err, ErrMeetingLobbyLimit) {
|
||||
t.Errorf("an 11th lobby must hit the limit, got %v", err)
|
||||
}
|
||||
if added, err := r.AddMeetingLobby(ctx, g, 3); err != nil || added {
|
||||
t.Errorf("adding an existing lobby is a no-op even at the limit: added=%v err=%v", added, err)
|
||||
}
|
||||
if added, err := r.AddMeetingLobby(ctx, g+1, 99); err != nil || !added {
|
||||
t.Errorf("the limit is per server: added=%v err=%v", added, err)
|
||||
}
|
||||
|
||||
if ok, _ := r.IsMeetingLobby(ctx, g, 7); !ok {
|
||||
t.Error("7 is a lobby")
|
||||
}
|
||||
if ok, _ := r.IsMeetingLobby(ctx, g, 99); ok {
|
||||
t.Error("99 isn't a lobby of this server (it is one of the other server's)")
|
||||
}
|
||||
|
||||
if removed, err := r.RemoveMeetingLobby(ctx, g, 7); err != nil || !removed {
|
||||
t.Errorf("removing 7: removed=%v err=%v", removed, err)
|
||||
}
|
||||
if removed, _ := r.RemoveMeetingLobby(ctx, g, 7); removed {
|
||||
t.Error("7 was already removed")
|
||||
}
|
||||
if ok, _ := r.IsMeetingLobby(ctx, g, 7); ok {
|
||||
t.Error("7 is no longer a lobby")
|
||||
}
|
||||
if added, err := r.AddMeetingLobby(ctx, g, 99); err != nil || !added {
|
||||
t.Errorf("removing one makes room: added=%v err=%v", added, err)
|
||||
}
|
||||
|
||||
ids, err := r.ListMeetingLobbies(ctx, g)
|
||||
if err != nil || len(ids) != MaxMeetingLobbies || ids[len(ids)-1] != 99 {
|
||||
t.Errorf("oldest first, newest last: %v %v", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two people adding a lobby at the same moment must not both fit under the limit.
|
||||
func TestMeetingLobbiesConcurrent(t *testing.T) {
|
||||
r, g := testRepo(t)
|
||||
ctx := context.Background()
|
||||
for ch := int64(1); ch < MaxMeetingLobbies; ch++ {
|
||||
if _, err := r.AddMeetingLobby(ctx, g, ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
succeeded := 0
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(ch int64) {
|
||||
defer wg.Done()
|
||||
if added, err := r.AddMeetingLobby(ctx, g, ch); err == nil && added {
|
||||
mu.Lock()
|
||||
succeeded++
|
||||
mu.Unlock()
|
||||
}
|
||||
}(int64(100 + i))
|
||||
}
|
||||
wg.Wait()
|
||||
if succeeded != 1 {
|
||||
t.Errorf("exactly one of the concurrent adds fits, %d did", succeeded)
|
||||
}
|
||||
if ids, _ := r.ListMeetingLobbies(ctx, g); len(ids) != MaxMeetingLobbies {
|
||||
t.Errorf("have %d lobbies, want %d", len(ids), MaxMeetingLobbies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBetaSettings(t *testing.T) {
|
||||
r, _ := testRepo(t)
|
||||
db := r.db
|
||||
ctx := context.Background()
|
||||
_, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_settings (
|
||||
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), guild_id BIGINT, role_id BIGINT, updated_by BIGINT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wasG, wasR sql.NullInt64
|
||||
hadRow := db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil
|
||||
t.Cleanup(func() {
|
||||
if hadRow {
|
||||
_, _ = db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR)
|
||||
} else {
|
||||
_, _ = db.ExecContext(ctx, `DELETE FROM beta_settings`)
|
||||
}
|
||||
})
|
||||
|
||||
_, err = db.ExecContext(ctx, `DELETE FROM beta_settings`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, role, err := r.GetBetaSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g != "" {
|
||||
t.Fatalf("g = %q, want empty", g)
|
||||
}
|
||||
if role != "" {
|
||||
t.Fatalf("role = %q, want empty", role)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 1170934850157547600)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, role, err = r.GetBetaSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g != "1170934850157547600" {
|
||||
t.Fatalf("g = %q, want %q", g, "1170934850157547600")
|
||||
}
|
||||
if role != "" {
|
||||
t.Fatalf("role = %q, want empty", role)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `UPDATE beta_settings SET role_id = 42`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, role, err = r.GetBetaSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role != "42" {
|
||||
t.Fatalf("role = %q, want %q", role, "42")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package settingsrepo
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
@@ -15,10 +19,145 @@ type LevelReward struct {
|
||||
RoleID int64
|
||||
}
|
||||
|
||||
type LogSettings struct {
|
||||
GuildID int64
|
||||
ChannelID int64
|
||||
IsEnabled bool
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
// MaxMeetingLobbies is how many lobby channels one server can have. The
|
||||
// database enforces it too (a trigger from the API's migration 000020), so
|
||||
// the command and the dashboard can't disagree.
|
||||
const MaxMeetingLobbies = 10
|
||||
|
||||
// ErrMeetingLobbyLimit means the server already has MaxMeetingLobbies.
|
||||
var ErrMeetingLobbyLimit = errors.New("meeting lobby limit reached")
|
||||
|
||||
// pgLimitExceeded is the error code the database raises for one lobby too many.
|
||||
const pgLimitExceeded = "54000"
|
||||
|
||||
// AddMeetingLobby makes channelID a lobby. added is false when it already
|
||||
// was one; going over the limit is ErrMeetingLobbyLimit.
|
||||
func (r *Repo) AddMeetingLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) {
|
||||
const q = `
|
||||
INSERT INTO meeting_lobbies (guild_id, channel_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`
|
||||
res, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgLimitExceeded {
|
||||
return false, ErrMeetingLobbyLimit
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// RemoveMeetingLobby takes a channel off the lobby list; removed is false
|
||||
// when it wasn't one.
|
||||
func (r *Repo) RemoveMeetingLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
|
||||
const q = `DELETE FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2`
|
||||
res, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ListMeetingLobbies returns a server's lobbies, oldest first.
|
||||
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||
const q = `SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at, channel_id`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsMeetingLobby reports whether channelID is one of the server's lobbies.
|
||||
// It runs whenever someone joins a voice channel, so it's one indexed lookup.
|
||||
func (r *Repo) IsMeetingLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||
const q = `SELECT EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2)`
|
||||
var ok bool
|
||||
err := r.db.QueryRowContext(ctx, q, guildID, channelID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
||||
const q = `
|
||||
INSERT INTO meeting_temp_channels (guild_id, channel_id, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (guild_id, channel_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, channelID, createdBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) RemoveMeetingTempChannel(ctx context.Context, guildID, channelID int64) error {
|
||||
const q = `
|
||||
DELETE FROM meeting_temp_channels
|
||||
WHERE guild_id = $1 AND channel_id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) IsMeetingTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||
const q = `
|
||||
SELECT 1
|
||||
FROM meeting_temp_channels
|
||||
WHERE guild_id = $1 AND channel_id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, channelID)
|
||||
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) GetMeetingTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) {
|
||||
const q = `
|
||||
SELECT created_by
|
||||
FROM meeting_temp_channels
|
||||
WHERE guild_id = $1 AND channel_id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, channelID)
|
||||
var cb sql.NullInt64
|
||||
switch err := row.Scan(&cb); err {
|
||||
case nil:
|
||||
if !cb.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return cb.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||
const q = `
|
||||
INSERT INTO levelsettings (guild_id, levelsys)
|
||||
@@ -150,6 +289,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
|
||||
@@ -170,3 +383,42 @@ 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
|
||||
}
|
||||
|
||||
// GetBetaSettings returns the beta server and Beta Tester role as set on the
|
||||
// dashboard. Either is "" when not set (or when there's no row yet).
|
||||
func (r *Repo) GetBetaSettings(ctx context.Context) (guildID, roleID string, err error) {
|
||||
var g, role sql.NullInt64
|
||||
err = r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id = TRUE`).Scan(&g, &role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if g.Valid {
|
||||
guildID = strconv.FormatInt(g.Int64, 10)
|
||||
}
|
||||
if role.Valid {
|
||||
roleID = strconv.FormatInt(role.Int64, 10)
|
||||
}
|
||||
return guildID, roleID, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package twitchrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) ListGuilds(ctx context.Context) ([]int64, error) {
|
||||
const q = `SELECT DISTINCT guild_id FROM twitch`
|
||||
rows, err := r.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var gid int64
|
||||
if err := rows.Scan(&gid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, gid)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) {
|
||||
const q = `SELECT twitch_user FROM twitch WHERE guild_id = $1`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []string
|
||||
for rows.Next() {
|
||||
var u string
|
||||
if err := rows.Scan(&u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) {
|
||||
const q = `SELECT status FROM twitch WHERE guild_id = $1 AND twitch_user = $2`
|
||||
var status string
|
||||
err := r.db.QueryRowContext(ctx, q, guildID, username).Scan(&status)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return status, true, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpsertStreamer(ctx context.Context, guildID int64, username string) error {
|
||||
const q = `
|
||||
INSERT INTO twitch (twitch_user, guild_id, status)
|
||||
VALUES ($1, $2, 'not live')
|
||||
ON CONFLICT (twitch_user, guild_id)
|
||||
DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, username, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) RemoveStreamer(ctx context.Context, guildID int64, username string) error {
|
||||
const q = `DELETE FROM twitch WHERE guild_id = $1 AND twitch_user = $2`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateStatus(ctx context.Context, guildID int64, username, status string) error {
|
||||
const q = `UPDATE twitch SET status = $1 WHERE guild_id = $2 AND twitch_user = $3`
|
||||
_, err := r.db.ExecContext(ctx, q, status, guildID, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) {
|
||||
const q = `SELECT twitch_channel_id FROM twitch_config WHERE guild_id = $1`
|
||||
var chID sql.NullInt64
|
||||
if err := r.db.QueryRowContext(ctx, q, guildID).Scan(&chID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if !chID.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return chID.Int64, true, nil
|
||||
}
|
||||
|
||||
// SetNotificationChannel sets where live alerts go. Setting it for the first
|
||||
// time turns alerts on (they start off); after that, the on/off switch is
|
||||
// left alone.
|
||||
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
||||
const q = `
|
||||
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
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package usersettingsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) SetTimezone(ctx context.Context, userID int64, zone string) error {
|
||||
const q = `
|
||||
INSERT INTO user_settings (user_id, timezone)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET timezone = EXCLUDED.timezone
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, userID, zone)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetTimezone(ctx context.Context, userID int64) (string, bool, error) {
|
||||
const q = `
|
||||
SELECT timezone
|
||||
FROM user_settings
|
||||
WHERE user_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, userID)
|
||||
var zone string
|
||||
switch err := row.Scan(&zone); err {
|
||||
case nil:
|
||||
return zone, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return "", false, nil
|
||||
default:
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,54 @@
|
||||
package meeting
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/db/repos/settingsrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
settings *settingsrepo.Repo
|
||||
}
|
||||
|
||||
func New(settings *settingsrepo.Repo) *Service {
|
||||
return &Service{settings: settings}
|
||||
}
|
||||
|
||||
// MaxLobbies is how many lobbies a server can have.
|
||||
const MaxLobbies = settingsrepo.MaxMeetingLobbies
|
||||
|
||||
// ErrLobbyLimit means the server already has MaxLobbies.
|
||||
var ErrLobbyLimit = settingsrepo.ErrMeetingLobbyLimit
|
||||
|
||||
func (s *Service) AddLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) {
|
||||
return s.settings.AddMeetingLobby(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
|
||||
return s.settings.RemoveMeetingLobby(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) ListLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||
return s.settings.ListMeetingLobbies(ctx, guildID)
|
||||
}
|
||||
|
||||
func (s *Service) IsLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||
return s.settings.IsMeetingLobby(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
||||
return s.settings.AddMeetingTempChannel(ctx, guildID, channelID, createdBy)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveTempChannel(ctx context.Context, guildID, channelID int64) error {
|
||||
return s.settings.RemoveMeetingTempChannel(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) IsTempChannel(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||
return s.settings.IsMeetingTempChannel(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) GetTempChannelOwner(ctx context.Context, guildID, channelID int64) (createdBy int64, ok bool, err error) {
|
||||
return s.settings.GetMeetingTempChannelOwner(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/db/repos/projectsrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *projectsrepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *projectsrepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(ctx context.Context, guildID, creatorID int64, name, description string) (int64, error) {
|
||||
return s.repo.CreateProject(ctx, guildID, creatorID, name, description)
|
||||
}
|
||||
|
||||
func (s *Service) AddHelper(ctx context.Context, projectID, userID int64) error {
|
||||
return s.repo.AddHelper(ctx, projectID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.ProjectWithMembers, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package rps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"velox-bot/internal/db/repos/rpsrepo"
|
||||
)
|
||||
|
||||
type Repo interface {
|
||||
GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error)
|
||||
SetScore(ctx context.Context, guildID int64, userID int64, score int) error
|
||||
TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repo
|
||||
}
|
||||
|
||||
func New(repo Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
type Hand string
|
||||
|
||||
const (
|
||||
HandScissors Hand = "✌️"
|
||||
HandPaper Hand = "✋"
|
||||
HandRock Hand = "🤜"
|
||||
)
|
||||
|
||||
func (h Hand) String() string { return string(h) }
|
||||
|
||||
func ParseHand(s string) (Hand, bool) {
|
||||
switch strings.TrimSpace(s) {
|
||||
case string(HandScissors):
|
||||
return HandScissors, true
|
||||
case string(HandPaper):
|
||||
return HandPaper, true
|
||||
case string(HandRock):
|
||||
return HandRock, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func RandomHand() (Hand, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(3))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch n.Int64() {
|
||||
case 0:
|
||||
return HandScissors, nil
|
||||
case 1:
|
||||
return HandPaper, nil
|
||||
default:
|
||||
return HandRock, nil
|
||||
}
|
||||
}
|
||||
|
||||
type GameResult string
|
||||
|
||||
const (
|
||||
ResultWin GameResult = "You won!"
|
||||
ResultLose GameResult = "You lost!"
|
||||
ResultTie GameResult = "It's a tie!"
|
||||
)
|
||||
|
||||
func DetermineResult(user Hand, bot Hand) GameResult {
|
||||
if user == bot {
|
||||
return ResultTie
|
||||
}
|
||||
switch user {
|
||||
case HandRock:
|
||||
if bot == HandScissors {
|
||||
return ResultWin
|
||||
}
|
||||
case HandPaper:
|
||||
if bot == HandRock {
|
||||
return ResultWin
|
||||
}
|
||||
case HandScissors:
|
||||
if bot == HandPaper {
|
||||
return ResultWin
|
||||
}
|
||||
}
|
||||
return ResultLose
|
||||
}
|
||||
|
||||
type PlayOutput struct {
|
||||
UserHand Hand
|
||||
BotHand Hand
|
||||
Result GameResult
|
||||
Score int
|
||||
}
|
||||
|
||||
func (s *Service) Play(ctx context.Context, guildID int64, userID int64, userHand Hand) (PlayOutput, error) {
|
||||
botHand, err := RandomHand()
|
||||
if err != nil {
|
||||
return PlayOutput{}, err
|
||||
}
|
||||
result := DetermineResult(userHand, botHand)
|
||||
|
||||
score, _, err := s.repo.GetScore(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return PlayOutput{}, err
|
||||
}
|
||||
if result == ResultWin {
|
||||
score++
|
||||
if err := s.repo.SetScore(ctx, guildID, userID, score); err != nil {
|
||||
return PlayOutput{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return PlayOutput{
|
||||
UserHand: userHand,
|
||||
BotHand: botHand,
|
||||
Result: result,
|
||||
Score: score,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) {
|
||||
return s.repo.GetScore(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) {
|
||||
return s.repo.TopScores(ctx, guildID, limit)
|
||||
}
|
||||
|
||||
var ErrGuildOnly = errors.New("guild only")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *schedulerepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *schedulerepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, inviteeID int64, when time.Time, description string) (int64, error) {
|
||||
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
|
||||
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
||||
}
|
||||
|
||||
func (s *Service) ListForUser(ctx context.Context, guildID, userID int64, limit int) ([]*schedulerepo.Schedule, error) {
|
||||
return s.repo.ListForUser(ctx, guildID, userID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ListDueForReminder(ctx context.Context, now time.Time, ahead time.Duration) ([]*schedulerepo.Schedule, error) {
|
||||
return s.repo.ListDueForReminder(ctx, now, ahead)
|
||||
}
|
||||
|
||||
func (s *Service) MarkReminded(ctx context.Context, scheduleID int64) error {
|
||||
return s.repo.MarkReminded(ctx, scheduleID)
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) error {
|
||||
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)
|
||||
}
|
||||
@@ -1,21 +1,51 @@
|
||||
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 {
|
||||
Level *level.Service
|
||||
LevelSettings *levelsettings.Service
|
||||
Meeting *meeting.Service
|
||||
Schedule *schedule.Service
|
||||
UserSettings *usersettings.Service
|
||||
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) *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,
|
||||
Meeting: meeting,
|
||||
Schedule: schedule,
|
||||
UserSettings: userSettings,
|
||||
Projects: projects,
|
||||
RPS: rps,
|
||||
Twitch: twitchSvc,
|
||||
Welcome: welcomeSvc,
|
||||
DefaultRole: defaultRoleSvc,
|
||||
LogSettings: logSettingsSvc,
|
||||
MusicSettings: musicSvc,
|
||||
}
|
||||
Global = s
|
||||
return s
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package twitch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/twitchrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *twitchrepo.Repo
|
||||
client *http.Client
|
||||
twitchClient string
|
||||
}
|
||||
|
||||
func New(repo *twitchrepo.Repo, twitchClientID string) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
twitchClient: twitchClientID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ListGuilds(ctx context.Context) ([]int64, error) {
|
||||
return s.repo.ListGuilds(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ListUsersForGuild(ctx context.Context, guildID int64) ([]string, error) {
|
||||
return s.repo.ListUsersForGuild(ctx, guildID)
|
||||
}
|
||||
|
||||
func (s *Service) GetStatus(ctx context.Context, guildID int64, username string) (string, bool, error) {
|
||||
return s.repo.GetStatus(ctx, guildID, username)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, guildID int64, username, status string) error {
|
||||
return s.repo.UpdateStatus(ctx, guildID, username, status)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertStreamer(ctx context.Context, guildID int64, username string) error {
|
||||
return s.repo.UpsertStreamer(ctx, guildID, username)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveStreamer(ctx context.Context, guildID int64, username string) error {
|
||||
return s.repo.RemoveStreamer(ctx, guildID, username)
|
||||
}
|
||||
|
||||
func (s *Service) GetNotificationChannel(ctx context.Context, guildID int64) (int64, bool, error) {
|
||||
return s.repo.GetNotificationChannel(ctx, guildID)
|
||||
}
|
||||
|
||||
func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
||||
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"`
|
||||
Variables map[string]any `json:"variables"`
|
||||
}
|
||||
type gqlResponse struct {
|
||||
Data struct {
|
||||
User *struct {
|
||||
Stream *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"stream"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`query { user(login: "%s") { stream { id } } }`, username)
|
||||
body, err := json.Marshal(gqlRequest{
|
||||
Query: q,
|
||||
Variables: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://gql.twitch.tv/gql", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Client-Id", s.twitchClient)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var respBody gqlResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if respBody.Data.User == nil {
|
||||
return false, nil
|
||||
}
|
||||
return respBody.Data.User.Stream != nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package usersettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *usersettingsrepo.Repo
|
||||
}
|
||||
|
||||
func New(repo *usersettingsrepo.Repo) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) SetTimezone(ctx context.Context, userID int64, zone string) error {
|
||||
// validate zone before storing
|
||||
if _, err := time.LoadLocation(zone); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.SetTimezone(ctx, userID, zone)
|
||||
}
|
||||
|
||||
// GetTimezone returns the user's location (or UTC) and the zone string and a flag indicating if it was user-defined.
|
||||
func (s *Service) GetTimezone(ctx context.Context, userID int64) (*time.Location, string, bool, error) {
|
||||
zone, ok, err := s.repo.GetTimezone(ctx, userID)
|
||||
if err != nil {
|
||||
return time.UTC, "UTC", false, err
|
||||
}
|
||||
if !ok || zone == "" {
|
||||
return time.UTC, "UTC", false, nil
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
// fallback to UTC but indicate not user-defined
|
||||
return time.UTC, "UTC", false, nil
|
||||
}
|
||||
return loc, zone, true, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
package diceexpr
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Limits struct {
|
||||
MaxExprLen int
|
||||
MaxRepeat int
|
||||
MaxDice int
|
||||
MaxSides int
|
||||
MaxAstDepth int
|
||||
MaxExplosionDepth int
|
||||
}
|
||||
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{
|
||||
MaxExprLen: 200,
|
||||
MaxRepeat: 25,
|
||||
MaxDice: 300,
|
||||
MaxSides: 1000000,
|
||||
MaxAstDepth: 64,
|
||||
MaxExplosionDepth: 100,
|
||||
}
|
||||
}
|
||||
|
||||
type EvalResult struct {
|
||||
Total int
|
||||
Trace *Trace
|
||||
}
|
||||
|
||||
type Trace struct {
|
||||
// Text is a normalized display of the (non-repeat) expression.
|
||||
Text string
|
||||
// Rolls includes all dice rolls (including dropped ones) in encounter order.
|
||||
Rolls []RollGroup
|
||||
}
|
||||
|
||||
type RollGroup struct {
|
||||
Count int
|
||||
Sides int
|
||||
// Dice is the per-die roll chain; each die may have multiple faces due to explosions.
|
||||
Dice []Die
|
||||
// KeepDrop is the modifier used (kh/kl/dh/dl). Empty if none.
|
||||
KeepDrop string
|
||||
KeepDropN int
|
||||
}
|
||||
|
||||
type Die struct {
|
||||
Faces []int
|
||||
Kept bool
|
||||
}
|
||||
|
||||
type MultiResult struct {
|
||||
Repeat int
|
||||
Items []EvalResult
|
||||
}
|
||||
|
||||
func EvalMany(input string, limits Limits) (MultiResult, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return MultiResult{}, errors.New("empty")
|
||||
}
|
||||
if limits.MaxExprLen > 0 && len(input) > limits.MaxExprLen {
|
||||
return MultiResult{}, errors.New("too long")
|
||||
}
|
||||
|
||||
repeat, expr := splitRepeat(input)
|
||||
if repeat < 1 {
|
||||
repeat = 1
|
||||
}
|
||||
if limits.MaxRepeat > 0 && repeat > limits.MaxRepeat {
|
||||
return MultiResult{}, fmt.Errorf("repeat too large")
|
||||
}
|
||||
|
||||
// Parse once, evaluate many times.
|
||||
p := newParser(expr, limits)
|
||||
ast, err := p.parse()
|
||||
if err != nil {
|
||||
return MultiResult{}, err
|
||||
}
|
||||
norm := formatExpr(ast)
|
||||
|
||||
out := MultiResult{Repeat: repeat, Items: make([]EvalResult, 0, repeat)}
|
||||
for range repeat {
|
||||
e := evaluator{limits: limits}
|
||||
total, trace, err := e.eval(ast)
|
||||
if err != nil {
|
||||
return MultiResult{}, err
|
||||
}
|
||||
trace.Text = norm
|
||||
out.Items = append(out.Items, EvalResult{Total: total, Trace: trace})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitRepeat(s string) (int, string) {
|
||||
// Accept "R#expr" where R is positive integer.
|
||||
// We only treat the first '#' as repeat separator if it occurs before any dice operator.
|
||||
// This keeps "d#" or other oddities from being interpreted as repeat.
|
||||
i := strings.IndexByte(s, '#')
|
||||
if i <= 0 {
|
||||
return 1, s
|
||||
}
|
||||
prefix := strings.TrimSpace(s[:i])
|
||||
if prefix == "" {
|
||||
return 1, s
|
||||
}
|
||||
for _, r := range prefix {
|
||||
if !unicode.IsDigit(r) {
|
||||
return 1, s
|
||||
}
|
||||
}
|
||||
n, err := strconv.Atoi(prefix)
|
||||
if err != nil || n <= 0 {
|
||||
return 1, s
|
||||
}
|
||||
return n, strings.TrimSpace(s[i+1:])
|
||||
}
|
||||
|
||||
// ---- Formatting ----
|
||||
|
||||
type FormatOptions struct {
|
||||
BoldMinMax bool
|
||||
}
|
||||
|
||||
func DefaultFormatOptions() FormatOptions {
|
||||
return FormatOptions{BoldMinMax: true}
|
||||
}
|
||||
|
||||
func FormatResult(r MultiResult, opts FormatOptions) string {
|
||||
lines := make([]string, 0, len(r.Items))
|
||||
for _, item := range r.Items {
|
||||
lines = append(lines, formatOne(item, opts))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatOne(item EvalResult, opts FormatOptions) string {
|
||||
// If there were multiple roll groups, we still show them in sequence, e.g. "[...][...] expr".
|
||||
parts := make([]string, 0, len(item.Trace.Rolls))
|
||||
for _, g := range item.Trace.Rolls {
|
||||
parts = append(parts, formatRollGroup(g, opts))
|
||||
}
|
||||
breakdown := strings.Join(parts, " ")
|
||||
if breakdown == "" {
|
||||
breakdown = "[]"
|
||||
}
|
||||
return fmt.Sprintf("%d \u2190 %s %s", item.Total, breakdown, item.Trace.Text)
|
||||
}
|
||||
|
||||
func formatRollGroup(g RollGroup, opts FormatOptions) string {
|
||||
items := make([]string, 0, len(g.Dice))
|
||||
for _, d := range g.Dice {
|
||||
items = append(items, formatDie(d, g.Sides, opts))
|
||||
}
|
||||
return "[" + strings.Join(items, ", ") + "]"
|
||||
}
|
||||
|
||||
func formatDie(d Die, sides int, opts FormatOptions) string {
|
||||
// Chain faces with '+' if exploded.
|
||||
faceParts := make([]string, 0, len(d.Faces))
|
||||
for _, v := range d.Faces {
|
||||
faceParts = append(faceParts, formatFace(v, sides, opts))
|
||||
}
|
||||
txt := strings.Join(faceParts, "+")
|
||||
if len(d.Faces) > 1 {
|
||||
// denote explosion occurred (kept compact)
|
||||
txt = txt + "!"
|
||||
}
|
||||
if !d.Kept {
|
||||
return "~~" + txt + "~~"
|
||||
}
|
||||
return txt
|
||||
}
|
||||
|
||||
func formatFace(v, sides int, opts FormatOptions) string {
|
||||
if !opts.BoldMinMax {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
if v == 1 || v == sides {
|
||||
return "**" + strconv.Itoa(v) + "**"
|
||||
}
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
// ---- AST / Parser / Evaluator ----
|
||||
|
||||
type nodeKind int
|
||||
|
||||
const (
|
||||
kindNumber nodeKind = iota
|
||||
kindUnary
|
||||
kindBinary
|
||||
kindDice
|
||||
)
|
||||
|
||||
type node struct {
|
||||
kind nodeKind
|
||||
|
||||
// number
|
||||
num int
|
||||
|
||||
// unary
|
||||
unOp byte
|
||||
unA *node
|
||||
|
||||
// binary
|
||||
binOp byte
|
||||
binL *node
|
||||
binR *node
|
||||
|
||||
// dice
|
||||
diceCount *node
|
||||
diceSides *node
|
||||
explode bool
|
||||
keepDrop string // kh/kl/dh/dl
|
||||
keepN *node
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
s string
|
||||
i int
|
||||
limits Limits
|
||||
depth int
|
||||
}
|
||||
|
||||
func newParser(s string, limits Limits) *parser {
|
||||
return &parser{s: strings.TrimSpace(s), limits: limits}
|
||||
}
|
||||
|
||||
func (p *parser) parse() (*node, error) {
|
||||
p.skipSpaces()
|
||||
n, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.i != len(p.s) {
|
||||
return nil, errors.New("trailing input")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseExpr() (*node, error) {
|
||||
// expr = term {( + | - ) term}
|
||||
left, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
if p.peek() != '+' && p.peek() != '-' {
|
||||
return left, nil
|
||||
}
|
||||
op := p.next()
|
||||
right, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &node{kind: kindBinary, binOp: op, binL: left, binR: right}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseTerm() (*node, error) {
|
||||
// term = factor {( * | / ) factor}
|
||||
left, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
if p.peek() != '*' && p.peek() != '/' {
|
||||
return left, nil
|
||||
}
|
||||
op := p.next()
|
||||
right, err := p.parseFactor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &node{kind: kindBinary, binOp: op, binL: left, binR: right}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseFactor() (*node, error) {
|
||||
// factor = unary
|
||||
return p.parseUnary()
|
||||
}
|
||||
|
||||
func (p *parser) parseUnary() (*node, error) {
|
||||
p.skipSpaces()
|
||||
if p.peek() == '+' || p.peek() == '-' {
|
||||
op := p.next()
|
||||
a, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &node{kind: kindUnary, unOp: op, unA: a}, nil
|
||||
}
|
||||
return p.parsePrimaryOrDice()
|
||||
}
|
||||
|
||||
func (p *parser) parsePrimaryOrDice() (*node, error) {
|
||||
p.skipSpaces()
|
||||
if p.peek() == '(' {
|
||||
p.next()
|
||||
p.depth++
|
||||
if p.limits.MaxAstDepth > 0 && p.depth > p.limits.MaxAstDepth {
|
||||
return nil, errors.New("expression too deep")
|
||||
}
|
||||
n, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.peek() != ')' {
|
||||
return nil, errors.New("missing )")
|
||||
}
|
||||
p.next()
|
||||
p.depth--
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Try parse leading number or dice with omitted count.
|
||||
start := p.i
|
||||
num, hasNum, err := p.tryParseInt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.peekLower() == 'd' {
|
||||
// dice: [count] d sides [keep/drop] [explode]
|
||||
var countNode *node
|
||||
if hasNum {
|
||||
countNode = &node{kind: kindNumber, num: num}
|
||||
} else {
|
||||
countNode = &node{kind: kindNumber, num: 1}
|
||||
}
|
||||
p.next() // d
|
||||
sidesNode, err := p.parseSides()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keepDrop, keepN, err := p.parseKeepDrop()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
explode := false
|
||||
p.skipSpaces()
|
||||
if p.peek() == '!' {
|
||||
explode = true
|
||||
p.next()
|
||||
}
|
||||
return &node{
|
||||
kind: kindDice,
|
||||
diceCount: countNode,
|
||||
diceSides: sidesNode,
|
||||
explode: explode,
|
||||
keepDrop: keepDrop,
|
||||
keepN: keepN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Not a dice; if we had a number, return number; else error.
|
||||
if hasNum {
|
||||
return &node{kind: kindNumber, num: num}, nil
|
||||
}
|
||||
p.i = start
|
||||
return nil, errors.New("expected number, dice, or (")
|
||||
}
|
||||
|
||||
func (p *parser) parseSides() (*node, error) {
|
||||
p.skipSpaces()
|
||||
if p.peek() == '%' {
|
||||
p.next()
|
||||
return &node{kind: kindNumber, num: 100}, nil
|
||||
}
|
||||
n, ok, err := p.tryParseInt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("expected sides")
|
||||
}
|
||||
return &node{kind: kindNumber, num: n}, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseKeepDrop() (string, *node, error) {
|
||||
p.skipSpaces()
|
||||
// khN klN dhN dlN
|
||||
if p.peekLower() != 'k' && p.peekLower() != 'd' {
|
||||
return "", nil, nil
|
||||
}
|
||||
c1 := p.peekLower()
|
||||
if c1 != 'k' && c1 != 'd' {
|
||||
return "", nil, nil
|
||||
}
|
||||
if p.i+1 >= len(p.s) {
|
||||
return "", nil, nil
|
||||
}
|
||||
c2 := byte(unicode.ToLower(rune(p.s[p.i+1])))
|
||||
if c2 != 'h' && c2 != 'l' {
|
||||
return "", nil, nil
|
||||
}
|
||||
op := string([]byte{c1, c2})
|
||||
p.i += 2
|
||||
n, ok, err := p.tryParseInt()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !ok || n <= 0 {
|
||||
return "", nil, errors.New("expected keep/drop count")
|
||||
}
|
||||
return op, &node{kind: kindNumber, num: n}, nil
|
||||
}
|
||||
|
||||
func (p *parser) tryParseInt() (int, bool, error) {
|
||||
p.skipSpaces()
|
||||
if p.i >= len(p.s) || !unicode.IsDigit(rune(p.s[p.i])) {
|
||||
return 0, false, nil
|
||||
}
|
||||
start := p.i
|
||||
for p.i < len(p.s) && unicode.IsDigit(rune(p.s[p.i])) {
|
||||
p.i++
|
||||
}
|
||||
v, err := strconv.Atoi(p.s[start:p.i])
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
func (p *parser) skipSpaces() {
|
||||
for p.i < len(p.s) && unicode.IsSpace(rune(p.s[p.i])) {
|
||||
p.i++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) peek() byte {
|
||||
if p.i >= len(p.s) {
|
||||
return 0
|
||||
}
|
||||
return p.s[p.i]
|
||||
}
|
||||
|
||||
func (p *parser) peekLower() byte {
|
||||
if p.i >= len(p.s) {
|
||||
return 0
|
||||
}
|
||||
return byte(unicode.ToLower(rune(p.s[p.i])))
|
||||
}
|
||||
|
||||
func (p *parser) next() byte {
|
||||
if p.i >= len(p.s) {
|
||||
return 0
|
||||
}
|
||||
b := p.s[p.i]
|
||||
p.i++
|
||||
return b
|
||||
}
|
||||
|
||||
func normalizeSpaces(s string) string {
|
||||
// Keep compact: remove spaces entirely, but preserve leading repeat already stripped outside.
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if !unicode.IsSpace(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatExpr renders a canonical expression with explicit dice counts and
|
||||
// spaces around binary operators.
|
||||
func formatExpr(n *node) string {
|
||||
return formatExprPrec(n, 0)
|
||||
}
|
||||
|
||||
func formatExprPrec(n *node, parentPrec int) string {
|
||||
switch n.kind {
|
||||
case kindNumber:
|
||||
return strconv.Itoa(n.num)
|
||||
case kindUnary:
|
||||
inner := formatExprPrec(n.unA, 3)
|
||||
if n.unOp == '+' {
|
||||
return inner
|
||||
}
|
||||
return string(n.unOp) + inner
|
||||
case kindDice:
|
||||
// count and sides are currently numbers, but we keep it generic.
|
||||
count := formatExprPrec(n.diceCount, 4)
|
||||
sides := formatExprPrec(n.diceSides, 4)
|
||||
out := count + "d" + sides
|
||||
if n.keepDrop != "" && n.keepN != nil {
|
||||
out += n.keepDrop + formatExprPrec(n.keepN, 4)
|
||||
}
|
||||
if n.explode {
|
||||
out += "!"
|
||||
}
|
||||
return out
|
||||
case kindBinary:
|
||||
prec := binPrec(n.binOp)
|
||||
l := formatExprPrec(n.binL, prec)
|
||||
r := formatExprPrec(n.binR, prec+1)
|
||||
out := l + " " + string(n.binOp) + " " + r
|
||||
if prec < parentPrec {
|
||||
return "(" + out + ")"
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func binPrec(op byte) int {
|
||||
switch op {
|
||||
case '+', '-':
|
||||
return 1
|
||||
case '*', '/':
|
||||
return 2
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
type evaluator struct {
|
||||
limits Limits
|
||||
dice int
|
||||
}
|
||||
|
||||
func (e *evaluator) eval(n *node) (int, *Trace, error) {
|
||||
t := &Trace{}
|
||||
v, err := e.evalNode(n, t)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return v, t, nil
|
||||
}
|
||||
|
||||
func (e *evaluator) evalNode(n *node, t *Trace) (int, error) {
|
||||
switch n.kind {
|
||||
case kindNumber:
|
||||
return n.num, nil
|
||||
case kindUnary:
|
||||
v, err := e.evalNode(n.unA, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch n.unOp {
|
||||
case '+':
|
||||
return v, nil
|
||||
case '-':
|
||||
return -v, nil
|
||||
default:
|
||||
return 0, errors.New("bad unary op")
|
||||
}
|
||||
case kindBinary:
|
||||
l, err := e.evalNode(n.binL, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r, err := e.evalNode(n.binR, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch n.binOp {
|
||||
case '+':
|
||||
return l + r, nil
|
||||
case '-':
|
||||
return l - r, nil
|
||||
case '*':
|
||||
return l * r, nil
|
||||
case '/':
|
||||
if r == 0 {
|
||||
return 0, errors.New("division by zero")
|
||||
}
|
||||
return l / r, nil
|
||||
default:
|
||||
return 0, errors.New("bad binary op")
|
||||
}
|
||||
case kindDice:
|
||||
return e.evalDice(n, t)
|
||||
default:
|
||||
return 0, errors.New("unknown node")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *evaluator) evalDice(n *node, t *Trace) (int, error) {
|
||||
count, err := e.evalNode(n.diceCount, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sides, err := e.evalNode(n.diceSides, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if count <= 0 || sides <= 0 {
|
||||
return 0, errors.New("invalid dice")
|
||||
}
|
||||
if e.limits.MaxDice > 0 && e.dice+count > e.limits.MaxDice {
|
||||
return 0, errors.New("too many dice")
|
||||
}
|
||||
if e.limits.MaxSides > 0 && sides > e.limits.MaxSides {
|
||||
return 0, errors.New("sides too large")
|
||||
}
|
||||
e.dice += count
|
||||
|
||||
keepDrop := n.keepDrop
|
||||
keepN := 0
|
||||
if n.keepN != nil {
|
||||
keepN, err = e.evalNode(n.keepN, t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if keepDrop != "" && keepN <= 0 {
|
||||
return 0, errors.New("invalid keep/drop")
|
||||
}
|
||||
if keepDrop != "" && keepN > count {
|
||||
keepN = count
|
||||
}
|
||||
|
||||
g := RollGroup{
|
||||
Count: count,
|
||||
Sides: sides,
|
||||
Dice: make([]Die, 0, count),
|
||||
KeepDrop: keepDrop,
|
||||
KeepDropN: keepN,
|
||||
}
|
||||
|
||||
type dieScore struct {
|
||||
idx int
|
||||
score int
|
||||
}
|
||||
scores := make([]dieScore, 0, count)
|
||||
|
||||
// Roll base dice.
|
||||
for i := 0; i < count; i++ {
|
||||
faces, err := rollExploding(sides, n.explode, e.limits.MaxExplosionDepth)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sum := 0
|
||||
for _, v := range faces {
|
||||
sum += v
|
||||
}
|
||||
g.Dice = append(g.Dice, Die{Faces: faces, Kept: true})
|
||||
scores = append(scores, dieScore{idx: i, score: sum})
|
||||
}
|
||||
|
||||
// Apply keep/drop to base dice by comparing chain sums.
|
||||
if keepDrop != "" {
|
||||
sort.SliceStable(scores, func(i, j int) bool { return scores[i].score < scores[j].score })
|
||||
|
||||
kept := make(map[int]bool, count)
|
||||
switch keepDrop {
|
||||
case "kh":
|
||||
for i := len(scores) - keepN; i < len(scores); i++ {
|
||||
if i >= 0 && i < len(scores) {
|
||||
kept[scores[i].idx] = true
|
||||
}
|
||||
}
|
||||
case "kl":
|
||||
for i := 0; i < keepN && i < len(scores); i++ {
|
||||
kept[scores[i].idx] = true
|
||||
}
|
||||
case "dh":
|
||||
// drop highest N => keep all except highest N
|
||||
for i := 0; i < len(scores)-keepN; i++ {
|
||||
if i >= 0 && i < len(scores) {
|
||||
kept[scores[i].idx] = true
|
||||
}
|
||||
}
|
||||
case "dl":
|
||||
// drop lowest N => keep highest count-N
|
||||
for i := keepN; i < len(scores); i++ {
|
||||
kept[scores[i].idx] = true
|
||||
}
|
||||
default:
|
||||
return 0, errors.New("unknown keep/drop")
|
||||
}
|
||||
for i := range g.Dice {
|
||||
g.Dice[i].Kept = kept[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Sum kept dice.
|
||||
total := 0
|
||||
for _, d := range g.Dice {
|
||||
if !d.Kept {
|
||||
continue
|
||||
}
|
||||
for _, v := range d.Faces {
|
||||
total += v
|
||||
}
|
||||
}
|
||||
|
||||
t.Rolls = append(t.Rolls, g)
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func rollExploding(sides int, explode bool, maxDepth int) ([]int, error) {
|
||||
v, err := roll1(sides)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []int{v}
|
||||
if !explode {
|
||||
return out, nil
|
||||
}
|
||||
depth := 0
|
||||
for v == sides {
|
||||
depth++
|
||||
if maxDepth > 0 && depth > maxDepth {
|
||||
return nil, errors.New("explosion depth limit")
|
||||
}
|
||||
v, err = roll1(sides)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func roll1(sides int) (int, error) {
|
||||
if sides <= 0 {
|
||||
return 0, errors.New("bad sides")
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(sides)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n.Int64()) + 1, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// HandleVoiceStateUpdate implements the "meeting lobby" behavior:
|
||||
// - When a user joins one of the server's lobby voice channels, create a temporary voice channel
|
||||
// with the same UserLimit and move the user into it.
|
||||
// - When a temporary channel becomes empty, delete it.
|
||||
func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) {
|
||||
if svc == nil || svc.Meeting == nil {
|
||||
return
|
||||
}
|
||||
if vs == nil || vs.VoiceState == nil {
|
||||
return
|
||||
}
|
||||
if vs.GuildID == "" || vs.UserID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
guildID64, _ := strconv.ParseInt(vs.GuildID, 10, 64)
|
||||
|
||||
// Clean up the channel the user left (if any)
|
||||
if vs.BeforeUpdate != nil && vs.BeforeUpdate.ChannelID != "" {
|
||||
tryCleanupTempChannel(s, ctx, svc, vs.GuildID, guildID64, vs.BeforeUpdate.ChannelID)
|
||||
}
|
||||
|
||||
// If not joining a channel (disconnect), nothing else to do.
|
||||
if vs.ChannelID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
joinedID, err := strconv.ParseInt(vs.ChannelID, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
isLobby, err := svc.Meeting.IsLobby(ctx, guildID64, joinedID)
|
||||
if err != nil || !isLobby {
|
||||
return
|
||||
}
|
||||
|
||||
lobbyCh, err := s.Channel(vs.ChannelID)
|
||||
if err != nil || lobbyCh == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create the new channel in the same category as the lobby, with same user limit.
|
||||
newName := fmt.Sprintf("%s • %s", lobbyCh.Name, time.Now().Format("15:04"))
|
||||
newCh, err := s.GuildChannelCreateComplex(vs.GuildID, discordgo.GuildChannelCreateData{
|
||||
Name: newName,
|
||||
Type: discordgo.ChannelTypeGuildVoice,
|
||||
ParentID: lobbyCh.ParentID,
|
||||
UserLimit: lobbyCh.UserLimit,
|
||||
Bitrate: lobbyCh.Bitrate,
|
||||
})
|
||||
if err != nil || newCh == nil {
|
||||
log.Printf("meeting: failed creating temp channel guild=%s user=%s err=%v", vs.GuildID, vs.UserID, err)
|
||||
return
|
||||
}
|
||||
|
||||
newChID64, _ := strconv.ParseInt(newCh.ID, 10, 64)
|
||||
_ = svc.Meeting.AddTempChannel(ctx, guildID64, newChID64, mustParseInt64(vs.UserID))
|
||||
|
||||
// Move the user into the new channel.
|
||||
targetID := newCh.ID
|
||||
if err := s.GuildMemberMove(vs.GuildID, vs.UserID, &targetID); err != nil {
|
||||
log.Printf("meeting: failed moving member guild=%s user=%s channel=%s err=%v", vs.GuildID, vs.UserID, newCh.ID, err)
|
||||
// If move fails, delete the channel to avoid junk.
|
||||
_, _ = s.ChannelDelete(newCh.ID)
|
||||
_ = svc.Meeting.RemoveTempChannel(ctx, guildID64, newChID64)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *services.Services, guildID string, guildID64 int64, channelID string) {
|
||||
chID64, err := strconv.ParseInt(channelID, 10, 64)
|
||||
if err != nil || chID64 == 0 {
|
||||
return
|
||||
}
|
||||
isTemp, err := svc.Meeting.IsTempChannel(ctx, guildID64, chID64)
|
||||
if err != nil || !isTemp {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if anyone is still in the channel.
|
||||
// IMPORTANT: don't use REST `s.Guild(...)` here; it doesn't include VoiceStates.
|
||||
// Use the session state cache (requires IntentsGuildVoiceStates).
|
||||
if s == nil || s.State == nil {
|
||||
return
|
||||
}
|
||||
g, err := s.State.Guild(guildID)
|
||||
if err != nil || g == nil {
|
||||
return
|
||||
}
|
||||
for _, st := range g.VoiceStates {
|
||||
if st != nil && st.ChannelID == channelID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.ChannelDelete(channelID); err != nil {
|
||||
// If already deleted, still remove from DB.
|
||||
log.Printf("meeting: failed deleting empty temp channel guild=%s channel=%s err=%v", guildID, channelID, err)
|
||||
}
|
||||
_ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64)
|
||||
}
|
||||
|
||||
// HandleChannelDelete drops a deleted voice channel from the meeting lobbies,
|
||||
// so it doesn't keep using up one of the server's slots.
|
||||
func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) {
|
||||
if svc == nil || svc.Meeting == nil || c == nil || c.Channel == nil || c.GuildID == "" {
|
||||
return
|
||||
}
|
||||
if c.Type != discordgo.ChannelTypeGuildVoice {
|
||||
return
|
||||
}
|
||||
guildID, err1 := strconv.ParseInt(c.GuildID, 10, 64)
|
||||
channelID, err2 := strconv.ParseInt(c.ID, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return
|
||||
}
|
||||
if _, err := svc.Meeting.RemoveLobby(context.Background(), guildID, channelID); err != nil {
|
||||
log.Printf("meeting: removing deleted lobby guild=%s channel=%s err=%v", c.GuildID, c.ID, err)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// StartScheduleReminderLoop runs a background loop that periodically checks for
|
||||
// upcoming sessions and sends DM reminders to all participants.
|
||||
func StartScheduleReminderLoop(s *discordgo.Session, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
interval = time.Minute // check every minute
|
||||
remindBefore = 10 * time.Minute // remind 10 minutes before
|
||||
initialDelay = 10 * time.Second
|
||||
)
|
||||
|
||||
go func() {
|
||||
// small delay so the bot can fully start
|
||||
time.Sleep(initialDelay)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
ctx := context.Background()
|
||||
|
||||
// Find all sessions that start in the next `remindBefore` window and haven't been reminded.
|
||||
schedules, err := svc.Schedule.ListDueForReminder(ctx, now, remindBefore)
|
||||
if err != nil {
|
||||
log.Printf("schedule: reminder query failed: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, sch := range schedules {
|
||||
if sch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
desc := sch.Description
|
||||
|
||||
// Notify requester with local time if available.
|
||||
if svc.UserSettings != nil {
|
||||
locReq, zoneReq, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
locReq = sch.ScheduledAt.UTC().Location()
|
||||
zoneReq = "UTC"
|
||||
}
|
||||
localReq := sch.ScheduledAt.In(locReq).Format("2006-01-02 15:04")
|
||||
base := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localReq, zoneReq, sch.InviteeID)
|
||||
if desc != "" {
|
||||
base += "\nTopic: " + desc
|
||||
}
|
||||
if err := dmUser(s, sch.RequesterID, base); err != nil {
|
||||
log.Printf("schedule: failed to DM requester reminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify invitee with their local time if available.
|
||||
if svc.UserSettings != nil {
|
||||
locInv, zoneInv, _, err := svc.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if err != nil {
|
||||
locInv = sch.ScheduledAt.UTC().Location()
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInv := sch.ScheduledAt.In(locInv).Format("2006-01-02 15:04")
|
||||
baseInvitee := fmt.Sprintf("Reminder: you have a scheduled session at %s UTC (%s %s) with <@%d>.", utcStr, localInv, zoneInv, sch.RequesterID)
|
||||
if desc != "" {
|
||||
baseInvitee += "\nTopic: " + desc
|
||||
}
|
||||
if err := dmUser(s, sch.InviteeID, baseInvitee); err != nil {
|
||||
log.Printf("schedule: failed to DM invitee reminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.Schedule.MarkReminded(ctx, sch.ID); err != nil {
|
||||
log.Printf("schedule: failed to mark reminder sent for id=%d: %v", sch.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func dmUser(s *discordgo.Session, userID int64, content string) error {
|
||||
if s == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
uid := strconv.FormatInt(userID, 10)
|
||||
ch, err := s.UserChannelCreate(uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.ChannelMessageSend(ch.ID, content)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// StartTwitchLiveLoop periodically checks configured Twitch streamers and
|
||||
// sends live notifications to the configured channel.
|
||||
func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) {
|
||||
if svc == nil || svc.Twitch == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
interval = 30 * time.Second
|
||||
initialDelay = 10 * time.Second
|
||||
)
|
||||
|
||||
go func() {
|
||||
time.Sleep(initialDelay)
|
||||
|
||||
log.Printf("twitch: starting live notification loop (interval=%s)", interval)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
ctx := context.Background()
|
||||
|
||||
guildIDs, err := svc.Twitch.ListGuilds(ctx)
|
||||
if err != nil {
|
||||
log.Printf("twitch: failed to list guilds: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(guildIDs) == 0 {
|
||||
log.Printf("twitch: no guilds with configured streamers")
|
||||
}
|
||||
|
||||
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)
|
||||
continue
|
||||
}
|
||||
if !ok || channelID == 0 {
|
||||
log.Printf("twitch: no notification channel configured for guild %d", guildID)
|
||||
continue
|
||||
}
|
||||
|
||||
users, err := svc.Twitch.ListUsersForGuild(ctx, guildID)
|
||||
if err != nil {
|
||||
log.Printf("twitch: failed to list users for guild %d: %v", guildID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
log.Printf("twitch: no streamers configured for guild %d", guildID)
|
||||
continue
|
||||
}
|
||||
|
||||
chIDStr := strconv.FormatInt(channelID, 10)
|
||||
|
||||
for _, username := range users {
|
||||
log.Printf("twitch: checking live status for guild %d, user %s", guildID, username)
|
||||
|
||||
isLive, err := svc.Twitch.IsUserStreaming(ctx, username)
|
||||
if err != nil {
|
||||
log.Printf("twitch: failed to check stream for %s: %v", username, err)
|
||||
continue
|
||||
}
|
||||
|
||||
status, ok, err := svc.Twitch.GetStatus(ctx, guildID, username)
|
||||
if err != nil {
|
||||
log.Printf("twitch: failed to get status for %s: %v", username, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
status = "not live"
|
||||
}
|
||||
|
||||
if isLive {
|
||||
if status == "not live" {
|
||||
log.Printf("twitch: %s went live in guild %d, sending notification to channel %s", username, guildID, chIDStr)
|
||||
|
||||
liveURL := "https://www.twitch.tv/" + username
|
||||
content := "@everyone"
|
||||
|
||||
previewURL := "https://static-cdn.jtvnw.net/previews-ttv/live_user_" + username + "-640x360.jpg"
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: ":red_circle: " + username + " is now LIVE on Twitch!",
|
||||
Description: "Click the link below to watch the stream.",
|
||||
URL: liveURL,
|
||||
Color: 0x9146FF, // Twitch purple
|
||||
Image: &discordgo.MessageEmbedImage{
|
||||
URL: previewURL,
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := s.ChannelMessageSendComplex(chIDStr, &discordgo.MessageSend{
|
||||
Content: content,
|
||||
Embed: embed,
|
||||
}); err != nil {
|
||||
log.Printf("twitch: failed to send notification for %s: %v", username, err)
|
||||
continue
|
||||
}
|
||||
if err := svc.Twitch.UpdateStatus(ctx, guildID, username, "live"); err != nil {
|
||||
log.Printf("twitch: failed to update status to live for %s in guild %d: %v", username, guildID, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if status != "not live" {
|
||||
log.Printf("twitch: %s is no longer live in guild %d, updating status", username, guildID)
|
||||
if err := svc.Twitch.UpdateStatus(ctx, guildID, username, "not live"); err != nil {
|
||||
log.Printf("twitch: failed to update status to not live for %s in guild %d: %v", username, guildID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
// DJ role is required for all music controls.
|
||||
if !memberHasDJRoleForComponents(s, i.GuildID, i.Member) {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "You need the **DJ** role to use music commands.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customID := i.MessageComponentData().CustomID
|
||||
parts := strings.Split(customID, ":")
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
action := parts[1]
|
||||
guildID := i.GuildID
|
||||
|
||||
// Ignore controls from old now-playing messages
|
||||
currentMsgID := CurrentNowPlayingMessageID(guildID)
|
||||
if currentMsgID != "" && i.Message != nil && i.Message.ID != currentMsgID {
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "These controls are outdated.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Acknowledge immediately to avoid "This interaction failed".
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredMessageUpdate,
|
||||
})
|
||||
|
||||
var err error
|
||||
content := ""
|
||||
|
||||
switch action {
|
||||
case "pause":
|
||||
err = Pause(guildID, true)
|
||||
content = "Paused."
|
||||
case "resume":
|
||||
err = Pause(guildID, false)
|
||||
content = "Resumed."
|
||||
case "toggle_pause":
|
||||
paused, _ := pausedAndVolume(guildID)
|
||||
err = Pause(guildID, !paused)
|
||||
if paused {
|
||||
content = "Resumed."
|
||||
} else {
|
||||
content = "Paused."
|
||||
}
|
||||
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||
case "skip":
|
||||
err = Skip(guildID)
|
||||
content = "Skipped."
|
||||
case "stop":
|
||||
err = Stop(guildID)
|
||||
content = "Stopped."
|
||||
case "repeat_song":
|
||||
rs, rq := ToggleRepeatSong(guildID)
|
||||
_ = rs
|
||||
_ = rq
|
||||
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||
if rs {
|
||||
content = "Repeat song enabled."
|
||||
} else {
|
||||
content = "Repeat song disabled."
|
||||
}
|
||||
case "repeat_queue":
|
||||
rs, rq := ToggleRepeatQueue(guildID)
|
||||
_ = rs
|
||||
_ = rq
|
||||
updateNowPlayingButtons(i.ChannelID, i.Message.ID, guildID)
|
||||
if rq {
|
||||
content = "Repeat queue enabled."
|
||||
} else {
|
||||
content = "Repeat queue disabled."
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
content = "Done."
|
||||
}
|
||||
if err != nil {
|
||||
// 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
|
||||
_, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||
Content: content,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
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 {
|
||||
Player disgolink.Player
|
||||
Queue []TrackEntry
|
||||
RepeatSong bool
|
||||
RepeatQueue bool
|
||||
Paused bool
|
||||
Volume int
|
||||
IdleSince time.Time
|
||||
TextChannelID string
|
||||
NowPlayingMsgID string
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
client disgolink.Client
|
||||
session *discordgo.Session
|
||||
repo *musicrepo.Repo
|
||||
|
||||
mu sync.Mutex
|
||||
players map[string]*guildPlayer
|
||||
}
|
||||
|
||||
var manager *Manager
|
||||
|
||||
func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
|
||||
if lavalinkHost == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, err := snowflake.Parse(appID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse app id for lavalink: %w", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
||||
session: session,
|
||||
repo: repo,
|
||||
players: make(map[string]*guildPlayer),
|
||||
}
|
||||
|
||||
u, err := url.Parse(lavalinkHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse lavalink host: %w", err)
|
||||
}
|
||||
|
||||
secure := u.Scheme == "https" || u.Scheme == "wss"
|
||||
password := lavalinkPass
|
||||
if password == "" {
|
||||
password = "youshallnotpass"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Printf("music: failed to connect to lavalink node: %v", err)
|
||||
return
|
||||
}
|
||||
manager = m
|
||||
go idleDisconnectLoop()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func idleDisconnectLoop() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if manager == nil || manager.session == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var toDisconnect []string
|
||||
|
||||
manager.mu.Lock()
|
||||
for guildID, gp := range manager.players {
|
||||
if gp == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're paused or have anything queued/playing, clear idle timer.
|
||||
if gp.Paused || len(gp.Queue) > 0 {
|
||||
gp.IdleSince = time.Time{}
|
||||
continue
|
||||
}
|
||||
|
||||
if gp.IdleSince.IsZero() {
|
||||
gp.IdleSince = now
|
||||
continue
|
||||
}
|
||||
|
||||
if now.Sub(gp.IdleSince) >= time.Minute {
|
||||
toDisconnect = append(toDisconnect, guildID)
|
||||
gp.IdleSince = time.Time{}
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
for _, guildID := range toDisconnect {
|
||||
// Best-effort: disconnect. Safe even if we're already disconnected.
|
||||
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
if vs.UserID != manager.session.State.User.ID {
|
||||
return
|
||||
}
|
||||
|
||||
guildID := snowflake.MustParse(vs.GuildID)
|
||||
var channelID *snowflake.ID
|
||||
if vs.ChannelID != "" {
|
||||
id := snowflake.MustParse(vs.ChannelID)
|
||||
channelID = &id
|
||||
}
|
||||
|
||||
manager.client.OnVoiceStateUpdate(
|
||||
context.Background(),
|
||||
guildID,
|
||||
channelID,
|
||||
vs.SessionID,
|
||||
)
|
||||
}
|
||||
|
||||
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
endpoint := ""
|
||||
if ev.Endpoint != "" {
|
||||
endpoint = ev.Endpoint
|
||||
}
|
||||
|
||||
manager.client.OnVoiceServerUpdate(
|
||||
context.Background(),
|
||||
snowflake.MustParse(ev.GuildID),
|
||||
ev.Token,
|
||||
endpoint,
|
||||
)
|
||||
}
|
||||
|
||||
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if gp, ok := manager.players[guildID]; ok {
|
||||
return gp
|
||||
}
|
||||
|
||||
player := manager.client.Player(snowflake.MustParse(guildID))
|
||||
gp := &guildPlayer{
|
||||
Player: player,
|
||||
Queue: make([]TrackEntry, 0),
|
||||
Volume: 100,
|
||||
}
|
||||
manager.players[guildID] = gp
|
||||
return gp
|
||||
}
|
||||
|
||||
func syncState(guidID string) {
|
||||
if manager == nil || manager.repo == nil {
|
||||
return
|
||||
}
|
||||
manager.mu.Lock()
|
||||
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(
|
||||
context.Background(),
|
||||
query,
|
||||
disgolink.NewResultHandler(
|
||||
func(track lavalink.Track) {
|
||||
loadedTracks = append(loadedTracks, track)
|
||||
},
|
||||
func(playlist lavalink.Playlist) {
|
||||
if len(playlist.Tracks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Respect Lavalink's selectedTrack when present by rotating the playlist.
|
||||
selected := playlist.Info.SelectedTrack
|
||||
if selected >= 0 && selected < len(playlist.Tracks) {
|
||||
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
|
||||
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
|
||||
return
|
||||
}
|
||||
|
||||
loadedTracks = append(loadedTracks, playlist.Tracks...)
|
||||
},
|
||||
func(tracks []lavalink.Track) {
|
||||
if len(tracks) > 0 {
|
||||
loadedTracks = append(loadedTracks, tracks[0])
|
||||
}
|
||||
},
|
||||
func() {
|
||||
},
|
||||
func(err error) {
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if len(loadedTracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found for query")
|
||||
}
|
||||
|
||||
// Some sources may return unplayable entries; pick the first with an encoded track.
|
||||
firstPlayableIdx := 0
|
||||
for idx := range loadedTracks {
|
||||
if loadedTracks[idx].Encoded != "" {
|
||||
firstPlayableIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
|
||||
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]
|
||||
|
||||
manager.mu.Lock()
|
||||
shouldStart := len(gp.Queue) == 0
|
||||
gp.Queue = append(gp.Queue, entries...)
|
||||
gp.IdleSince = time.Time{}
|
||||
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)
|
||||
}
|
||||
if gp.TextChannelID != "" {
|
||||
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if !event.Reason.MayStartNext() {
|
||||
return
|
||||
}
|
||||
|
||||
guildID := player.GuildID().String()
|
||||
|
||||
manager.mu.Lock()
|
||||
gp, ok := manager.players[guildID]
|
||||
if !ok || len(gp.Queue) == 0 {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// repeat current track
|
||||
if gp.RepeatSong {
|
||||
gp.Queue[0].StartedAt = time.Now()
|
||||
next := gp.Queue[0]
|
||||
textChannelID := gp.TextChannelID
|
||||
manager.mu.Unlock()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if len(gp.Queue) == 0 {
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
gp.NowPlayingMsgID = ""
|
||||
gp.IdleSince = time.Now()
|
||||
manager.mu.Unlock()
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
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()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||
return
|
||||
}
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
syncState(guildID)
|
||||
}
|
||||
|
||||
func Pause(guildID string, pause bool) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
gp.Paused = pause
|
||||
if pause {
|
||||
gp.IdleSince = time.Time{}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
syncState(guildID)
|
||||
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
||||
}
|
||||
|
||||
func Skip(guildID string) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
if len(gp.Queue) == 0 {
|
||||
manager.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
// skip always advances regardless of repeat song
|
||||
skipped := gp.Queue[0]
|
||||
gp.Queue = gp.Queue[1:]
|
||||
if gp.RepeatQueue {
|
||||
gp.Queue = append(gp.Queue, skipped)
|
||||
}
|
||||
|
||||
if len(gp.Queue) == 0 {
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
gp.NowPlayingMsgID = ""
|
||||
gp.IdleSince = time.Now()
|
||||
manager.mu.Unlock()
|
||||
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()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
||||
return err
|
||||
}
|
||||
if textChannelID != "" {
|
||||
postNowPlaying(guildID, textChannelID, next)
|
||||
}
|
||||
syncState(guildID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Stop(guildID string) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
gp.Queue = nil
|
||||
gp.Paused = false
|
||||
gp.NowPlayingMsgID = ""
|
||||
gp.IdleSince = time.Now()
|
||||
manager.mu.Unlock()
|
||||
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
||||
}
|
||||
syncState(guildID)
|
||||
|
||||
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
}
|
||||
|
||||
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
|
||||
if manager == nil {
|
||||
return nil, nil
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
if len(gp.Queue) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
current := gp.Queue[0]
|
||||
rest := make([]TrackEntry, len(gp.Queue)-1)
|
||||
copy(rest, gp.Queue[1:])
|
||||
return ¤t, rest
|
||||
}
|
||||
|
||||
func ToggleRepeatSong(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
gp.RepeatSong = !gp.RepeatSong
|
||||
if gp.RepeatSong {
|
||||
gp.RepeatQueue = false
|
||||
}
|
||||
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
return repeatSong, repeatQueue
|
||||
}
|
||||
|
||||
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
gp.RepeatQueue = !gp.RepeatQueue
|
||||
if gp.RepeatQueue {
|
||||
gp.RepeatSong = false
|
||||
}
|
||||
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
||||
manager.mu.Unlock()
|
||||
|
||||
syncState(guildID)
|
||||
return repeatSong, repeatQueue
|
||||
}
|
||||
|
||||
func repeatFlags(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
return gp.RepeatSong, gp.RepeatQueue
|
||||
}
|
||||
|
||||
func pausedAndVolume(guildID string) (bool, int) {
|
||||
if manager == nil {
|
||||
return false, 100
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
vol := gp.Volume
|
||||
if vol <= 0 {
|
||||
vol = 100
|
||||
}
|
||||
return gp.Paused, vol
|
||||
}
|
||||
|
||||
func SetVolume(guildID string, volume int) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("music manager not initialized")
|
||||
}
|
||||
if volume < 0 {
|
||||
volume = 0
|
||||
}
|
||||
if volume > 150 {
|
||||
volume = 150
|
||||
}
|
||||
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
gp.Volume = volume
|
||||
textChannelID := gp.TextChannelID
|
||||
nowPlayingMsgID := gp.NowPlayingMsgID
|
||||
manager.mu.Unlock()
|
||||
|
||||
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh the embed so the displayed volume matches.
|
||||
if textChannelID != "" && nowPlayingMsgID != "" {
|
||||
current, _ := GetQueue(guildID)
|
||||
if current != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func CurrentNowPlayingMessageID(guildID string) string {
|
||||
if manager == nil {
|
||||
return ""
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
return gp.NowPlayingMsgID
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||
"github.com/disgoorg/snowflake/v2"
|
||||
)
|
||||
|
||||
func nowPlayingComponents(disabled bool, repeatSong bool, repeatQueue bool, paused bool) []discordgo.MessageComponent {
|
||||
repeatSongStyle := discordgo.SecondaryButton
|
||||
if repeatSong {
|
||||
repeatSongStyle = discordgo.SuccessButton
|
||||
}
|
||||
repeatQueueStyle := discordgo.SecondaryButton
|
||||
if repeatQueue {
|
||||
repeatQueueStyle = discordgo.SuccessButton
|
||||
}
|
||||
|
||||
return []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.Button{
|
||||
Style: discordgo.SecondaryButton,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: map[bool]string{true: "▶️", false: "⏸️"}[paused]},
|
||||
CustomID: "music:toggle_pause",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: discordgo.SecondaryButton,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "⏭️"},
|
||||
CustomID: "music:skip",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: repeatSongStyle,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "🔂"},
|
||||
CustomID: "music:repeat_song",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: repeatQueueStyle,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "🔁"},
|
||||
CustomID: "music:repeat_queue",
|
||||
Disabled: disabled,
|
||||
},
|
||||
discordgo.Button{
|
||||
Style: discordgo.DangerButton,
|
||||
Emoji: &discordgo.ComponentEmoji{Name: "✖️"},
|
||||
CustomID: "music:stop",
|
||||
Disabled: disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func disableNowPlaying(textChannelID, messageID string) {
|
||||
if manager == nil || textChannelID == "" || messageID == "" {
|
||||
return
|
||||
}
|
||||
disabled := nowPlayingComponents(true, false, false, false)
|
||||
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: textChannelID,
|
||||
ID: messageID,
|
||||
Components: &disabled,
|
||||
})
|
||||
}
|
||||
|
||||
func postNowPlaying(guildID, textChannelID string, entry TrackEntry) {
|
||||
if manager == nil || textChannelID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
gp, ok := manager.players[guildID]
|
||||
var oldMsgID string
|
||||
if ok {
|
||||
oldMsgID = gp.NowPlayingMsgID
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
if oldMsgID != "" {
|
||||
disableNowPlaying(textChannelID, oldMsgID)
|
||||
}
|
||||
|
||||
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||
paused, volume := pausedAndVolume(guildID)
|
||||
|
||||
info := entry.Track.Info
|
||||
title := info.Title
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Author: &discordgo.MessageEmbedAuthor{
|
||||
Name: info.Author,
|
||||
IconURL: sourceIconURL(info.SourceName),
|
||||
},
|
||||
Title: title,
|
||||
Color: 0x2B2D31, // matches Discord dark-ish embed accent
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{Name: "Duration", Value: formatDuration(info.Length, info.IsStream), Inline: true},
|
||||
{Name: "Volume", Value: fmt.Sprintf("%d%%", volume), Inline: true},
|
||||
{Name: "Requested by", Value: entry.RequestedBy, Inline: true},
|
||||
},
|
||||
}
|
||||
|
||||
if info.URI != nil && *info.URI != "" {
|
||||
embed.URL = *info.URI
|
||||
}
|
||||
|
||||
if art := artworkURL(info); art != "" {
|
||||
embed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: art}
|
||||
}
|
||||
|
||||
msg, err := manager.session.ChannelMessageSendComplex(textChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
Components: nowPlayingComponents(false, repeatSong, repeatQueue, paused),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
// Avoid calling getOrCreateGuildPlayer while holding manager.mu (it also locks).
|
||||
gp = manager.players[guildID]
|
||||
if gp == nil {
|
||||
gp = &guildPlayer{
|
||||
Player: manager.client.Player(snowflake.MustParse(guildID)),
|
||||
Queue: make([]TrackEntry, 0),
|
||||
}
|
||||
manager.players[guildID] = gp
|
||||
}
|
||||
gp.TextChannelID = textChannelID
|
||||
gp.NowPlayingMsgID = msg.ID
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func artworkURL(info lavalink.TrackInfo) string {
|
||||
if info.ArtworkURL != nil && *info.ArtworkURL != "" {
|
||||
return *info.ArtworkURL
|
||||
}
|
||||
// Lavalink sets Identifier for YouTube videos; use standard thumbnail as fallback.
|
||||
if strings.EqualFold(info.SourceName, "youtube") && info.Identifier != "" {
|
||||
return "https://img.youtube.com/vi/" + info.Identifier + "/hqdefault.jpg"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatDuration(d lavalink.Duration, isStream bool) string {
|
||||
if isStream {
|
||||
return "Live"
|
||||
}
|
||||
secs := d.Seconds()
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
h := secs / 3600
|
||||
m := (secs % 3600) / 60
|
||||
s := secs % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
func sourceIconURL(source string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||
case "youtube":
|
||||
// Stable PNG favicon (Discord doesn't render .ico/.svg reliably in embeds)
|
||||
return "https://www.google.com/s2/favicons?sz=64&domain=youtube.com"
|
||||
case "soundcloud":
|
||||
return "https://www.google.com/s2/favicons?sz=64&domain=soundcloud.com"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user