Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1be60e201 | ||
|
|
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 | ||
|
|
825dca34f0 | ||
|
|
d399122cab | ||
|
|
0f49c79567 | ||
|
|
8ba26f8bb9 | ||
|
|
2bce787c05 |
@@ -0,0 +1,58 @@
|
||||
#:schema https://json.schemastore.org/any.json
|
||||
|
||||
env_files = [".env"]
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/bot"
|
||||
cmd = "go build -o ./tmp/bot ."
|
||||
delay = 1000
|
||||
entrypoint = ["./tmp/bot"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
ignore_dangerous_root_dir = false
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "2s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = true
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
app_start_timeout = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
.env
|
||||
.cursor
|
||||
.cursor
|
||||
tmp/
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Build stage
|
||||
FROM golang:1.22-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,8 @@
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
start: up
|
||||
air
|
||||
@@ -0,0 +1,129 @@
|
||||
# Velox Bot
|
||||
|
||||
A Discord bot written in Go with a PostgreSQL backing store.
|
||||
|
||||
It currently includes:
|
||||
|
||||
- Fun commands (`/ping`, `/joke`, `/dice`, `/rps`, …)
|
||||
- Leveling system (opt-in per server via `/slvl toggle`)
|
||||
- Meeting rooms (voice lobby → auto-created temporary voice channels)
|
||||
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
|
||||
- Projects (list/create projects + helpers)
|
||||
- Music (Lavalink-powered `/play`, `/queue`, `/volume`)
|
||||
- Twitch live notifications (per-guild `/config` setup)
|
||||
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
|
||||
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
|
||||
- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions)
|
||||
|
||||
When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go (see `go.mod`)
|
||||
- PostgreSQL 16+ (or use the provided `docker-compose.yml`)
|
||||
- (Optional) [Air](https://github.com/air-verse/air) for live reload during development
|
||||
- (Optional) Lavalink if you want music commands
|
||||
|
||||
## Configuration
|
||||
|
||||
The app loads environment variables from `.env` (see `internal/config/config.go`).
|
||||
|
||||
Required:
|
||||
|
||||
- `BOT_TOKEN`: Discord bot token
|
||||
- `APP_ID`: Discord application ID
|
||||
- `GUILD_ID`: Target guild/server ID (commands are registered per-guild)
|
||||
- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`
|
||||
|
||||
Optional (music):
|
||||
|
||||
- `LAVALINK_HOST`: Lavalink WebSocket/HTTP host (depends on your Lavalink setup)
|
||||
- `LAVALINK_PASSWORD`: Lavalink password
|
||||
|
||||
Optional (Twitch live notifications):
|
||||
|
||||
- `TWITCH_CLIENT_ID`: Twitch Client ID used to query live status
|
||||
|
||||
## Database setup
|
||||
|
||||
If you use the compose file:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then apply the schema:
|
||||
|
||||
```bash
|
||||
psql "postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable" -f schema.sql
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Plain Go:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
With live reload (Air):
|
||||
|
||||
```bash
|
||||
air
|
||||
```
|
||||
|
||||
If you use `just`, a small workflow is included:
|
||||
|
||||
```bash
|
||||
just up # start postgres
|
||||
just start # start postgres + run air
|
||||
just down # stop postgres
|
||||
```
|
||||
|
||||
## Commands (high-level)
|
||||
|
||||
- **Fun**
|
||||
- `/ping`, `/joke [type]`, `/coinflip`, `/dice <expr>`
|
||||
- `/rps <hand>`, `/rpsstats`, `/rpsleaderboard` (server only)
|
||||
- **Help**
|
||||
- `/help` (sends a DM with command reference)
|
||||
- **Leveling**
|
||||
- `/slvl ...` admin/config
|
||||
- `/rank`, `/leaderboard`, `/rewards`
|
||||
- **Meetings** (**Manage Server**)
|
||||
- `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite`
|
||||
- **Timezone**
|
||||
- `/timezone set`, `/timezone show`
|
||||
- **Scheduling**
|
||||
- `/schedule create`, `/schedule list`, `/schedule reschedule`
|
||||
- **Projects**
|
||||
- `/projects list`, `/projects create`, `/projects add-helper`
|
||||
- **Music** (requires **DJ** role)
|
||||
- `/play`, `/queue`, `/volume`
|
||||
- **Moderation** (focused + practical)
|
||||
- `/moderation purge amount:<1-100>` — Fast recent message cleanup (**Manage Messages**)
|
||||
- `/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Temporary member timeout (**Moderate Members**)
|
||||
- `/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)
|
||||
- `/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)
|
||||
- `/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)
|
||||
- `/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)
|
||||
- `/moderation slowmode channel:#channel seconds:<0-21600>` — Control channel flood (**Manage Channels**)
|
||||
- **Twitch live notifications** (**Manage Server**)
|
||||
- `/config addstreamer username:<twitch-name>` — Add a streamer for notifications
|
||||
- `/config removestreamer username:<twitch-name>` — Remove a streamer
|
||||
- `/config settwitchnotificationchannel channel:#channel` — Channel for live pings
|
||||
- **Welcome / onboarding** (**Manage Server**)
|
||||
- `/config setwelcomechannel channel:#channel` — Channel for welcome messages
|
||||
- `/config setwelcomemessage message:"..."` — Server welcome template (supports `{user}`, `{server}`)
|
||||
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
|
||||
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
|
||||
- `/config setdefaultrole role:@role` — Role automatically granted on join
|
||||
- **Logging** (**Manage Server**)
|
||||
- `/config setlogchannel channel:#channel` — Set where logs are sent
|
||||
- `/config enablelogging` — Enable event/moderation logs
|
||||
- `/config disablelogging` — Disable event/moderation logs
|
||||
|
||||
## Notes
|
||||
|
||||
- Slash commands are registered to the configured `GUILD_ID` on startup.
|
||||
- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music).
|
||||
@@ -0,0 +1,22 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
container_name: velox-bot
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
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
|
||||
- lavalink
|
||||
|
||||
networks:
|
||||
lavalink:
|
||||
external: true
|
||||
@@ -4,11 +4,23 @@ 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
|
||||
golang.org/x/image v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/disgoorg/json v1.2.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // 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
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
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/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=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+89
-6
@@ -3,6 +3,9 @@ package bot
|
||||
import (
|
||||
"log"
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/events"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
@@ -13,19 +16,41 @@ type Bot struct {
|
||||
GuildID string
|
||||
Commands []*discordgo.ApplicationCommand
|
||||
registeredCommands []*discordgo.ApplicationCommand
|
||||
Services *services.Services
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
}
|
||||
|
||||
func NewBot(token, appID, guildID string, cmds []*discordgo.ApplicationCommand) (*Bot, error) {
|
||||
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*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
|
||||
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||
discordgo.IntentsGuildBans |
|
||||
discordgo.IntentsGuildMembers |
|
||||
discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsMessageContent |
|
||||
discordgo.IntentsGuildVoiceStates |
|
||||
discordgo.IntentsGuildMessageReactions |
|
||||
discordgo.IntentsDirectMessages |
|
||||
discordgo.IntentsDirectMessageReactions
|
||||
|
||||
return &Bot{
|
||||
Session: session,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Session: session,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
Commands: cmds,
|
||||
Services: services,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -34,6 +59,8 @@ func (b *Bot) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
||||
|
||||
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
||||
for _, cmd := range b.Commands {
|
||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
||||
@@ -45,8 +72,64 @@ func (b *Bot) Start() error {
|
||||
}
|
||||
|
||||
b.Session.AddHandler(commands.HandleInteraction)
|
||||
return nil
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
events.HandleMessageCreate(s, m, b.Services)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
||||
events.HandleBotAddedOnGuild(s, m, b.Services)
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
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, 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)
|
||||
})
|
||||
|
||||
b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) {
|
||||
events.HandleMessageReactionAdd(s, r, b.Services)
|
||||
})
|
||||
|
||||
events.StartScheduleReminderLoop(b.Session, b.Services)
|
||||
events.StartTwitchLiveLoop(b.Session, b.Services)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) Close() error {
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"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"
|
||||
"velox-bot/internal/commands/timezone"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var AllCommands = []*discordgo.ApplicationCommand{
|
||||
fun.PingCommand,
|
||||
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,
|
||||
meeting.Meeting,
|
||||
moderation.Moderation,
|
||||
timezone.Timezone,
|
||||
schedule.Schedule,
|
||||
projects.Projects,
|
||||
config.Config,
|
||||
}
|
||||
|
||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||
"ping": fun.HandlePing,
|
||||
"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,
|
||||
"config": config.ConfigHandler,
|
||||
}
|
||||
|
||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
||||
h(s, i)
|
||||
switch i.Type {
|
||||
case discordgo.InteractionApplicationCommand:
|
||||
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
|
||||
h(s, i)
|
||||
}
|
||||
case discordgo.InteractionMessageComponent:
|
||||
data := i.MessageComponentData()
|
||||
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" {
|
||||
music.HandleComponent(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,13 +1,13 @@
|
||||
package fun
|
||||
package public
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
var PingCommand = &discordgo.ApplicationCommand{
|
||||
var Ping = &discordgo.ApplicationCommand{
|
||||
Name: "ping",
|
||||
Description: "Ping the bot",
|
||||
}
|
||||
|
||||
func HandlePing(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package help
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Help = &discordgo.ApplicationCommand{
|
||||
Name: "help",
|
||||
Description: "Get help with the bot",
|
||||
}
|
||||
|
||||
func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Content: "Check your DMs for help.",
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
},
|
||||
})
|
||||
|
||||
userID := ""
|
||||
|
||||
// check if command is run in a guild
|
||||
if i.Member != nil {
|
||||
userID = i.Member.User.ID
|
||||
} else {
|
||||
userID = i.Interaction.User.ID
|
||||
}
|
||||
|
||||
dmChannel, err := s.UserChannelCreate(userID)
|
||||
if err != nil {
|
||||
log.Printf("Error creating DM channel: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
author := discordgo.MessageEmbedAuthor{
|
||||
Name: "Velox Bot",
|
||||
URL: "https://gitea.fernandovideira.com/FernandoJVideira/velox-bot",
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Velox Bot Help",
|
||||
Description: "Command reference. Some commands require server permissions or roles.",
|
||||
Color: 0xFFA500,
|
||||
Fields: []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "Fun",
|
||||
Value: "" +
|
||||
"`/ping` — Pong!\n" +
|
||||
"`/joke [type]` — Random joke (optional category)\n" +
|
||||
"`/coinflip` — Flip a coin\n" +
|
||||
"`/dice <expr>` — Roll dice (e.g. `d20`, `2d20+1`, `2#d20+1`)\n" +
|
||||
"`/rps <hand>` — Rock Paper Scissors (server only)\n" +
|
||||
"`/rpsstats` — Your RPS score (server only)\n" +
|
||||
"`/rpsleaderboard` — Top RPS scores (server only)",
|
||||
},
|
||||
{
|
||||
Name: "Music (requires **DJ** role)",
|
||||
Value: "" +
|
||||
"`/play <query>` — Play a song (search or URL)\n" +
|
||||
"`/queue` — Show the current queue\n" +
|
||||
"`/volume <0-150>` — Set playback volume",
|
||||
},
|
||||
{
|
||||
Name: "Scheduling (1:1 sessions)",
|
||||
Value: "" +
|
||||
"`/schedule create user:@someone datetime:\"YYYY-MM-DD HH:MM\" [description]` — Create a session\n" +
|
||||
"`/schedule list` — List your upcoming sessions\n" +
|
||||
"`/schedule reschedule id:<id> datetime:\"YYYY-MM-DD HH:MM\"` — Request a new time\n\n" +
|
||||
"Invites are sent via DM; react with ✅ / ❌ / 🔁 to respond.",
|
||||
},
|
||||
{
|
||||
Name: "Timezone",
|
||||
Value: "" +
|
||||
"`/timezone set <IANA>` — Set your timezone (e.g. `Europe/Lisbon`)\n" +
|
||||
"`/timezone show` — Show your timezone",
|
||||
},
|
||||
{
|
||||
Name: "Projects",
|
||||
Value: "" +
|
||||
"`/projects list` — List active projects\n" +
|
||||
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
|
||||
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "Meetings (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\n" +
|
||||
"`/meeting lock` — Lock your meeting room\n" +
|
||||
"`/meeting lock-private` — Lock + hide your meeting room\n" +
|
||||
"`/meeting unlock` — Unlock your meeting room\n" +
|
||||
"`/meeting invite user:@someone` — Allow a user to join\n" +
|
||||
"`/meeting uninvite user:@someone` — Remove a user's permission",
|
||||
},
|
||||
{
|
||||
Name: "Moderation (focused tools)",
|
||||
Value: "" +
|
||||
"`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" +
|
||||
"`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" +
|
||||
"`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" +
|
||||
"`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" +
|
||||
"`/moderation unban user_id:<id> [reason]` — Unban by user ID (**Ban Members**)\n" +
|
||||
"`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" +
|
||||
"`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)",
|
||||
},
|
||||
{
|
||||
Name: "Leveling",
|
||||
Value: "" +
|
||||
"`/slvl toggle` — Enable/disable leveling (**Manage Server**)\n" +
|
||||
"`/slvl set user:@someone level:<n>` — Set user level (**Manage Server**)\n" +
|
||||
"`/slvl set-channel channel:#channel` — Level-up message channel (**Manage Server**)\n" +
|
||||
"`/slvl set-message message:\"...\"` — Custom level-up message (**Manage Server**)\n" +
|
||||
"`/slvl set-reward level:<n> role:@role` — Role rewards (**Manage Server**)\n" +
|
||||
"`/rank [user]` — Your rank card (server only)\n" +
|
||||
"`/leaderboard` — Top levels (server only)\n" +
|
||||
"`/rewards` — List configured rewards (server only)",
|
||||
},
|
||||
{
|
||||
Name: "Twitch live notifications (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config addstreamer username:<twitch-name>` — Add a streamer\n" +
|
||||
"`/config removestreamer username:<twitch-name>` — Remove a streamer\n" +
|
||||
"`/config settwitchnotificationchannel channel:#channel` — Set notification channel",
|
||||
},
|
||||
{
|
||||
Name: "Welcome / onboarding (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config setwelcomechannel channel:#channel` — Channel for welcome messages\n" +
|
||||
"`/config setwelcomemessage message:\"...\"` — Welcome text (supports `{user}`, `{server}`)\n" +
|
||||
"`/config setwelcomedm message:\"...\"` — DM template (supports `{user}`, `{server}`)\n" +
|
||||
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
|
||||
"`/config setdefaultrole role:@role` — Role granted to new members",
|
||||
},
|
||||
{
|
||||
Name: "Logging (**Manage Server**)",
|
||||
Value: "" +
|
||||
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
|
||||
"`/config enablelogging` — Enable event/moderation logging\n" +
|
||||
"`/config disablelogging` — Disable event/moderation logging",
|
||||
},
|
||||
{
|
||||
Name: "Other",
|
||||
Value: "`/help` — Show this help in DMs",
|
||||
},
|
||||
},
|
||||
Author: &author,
|
||||
}
|
||||
|
||||
s.ChannelMessageSendEmbed(dmChannel.ID, embed)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Slvl = &discordgo.ApplicationCommand{
|
||||
Name: "slvl",
|
||||
Description: "Leveling System",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
slvlToggleOption(),
|
||||
slvlSetLevelOption(),
|
||||
slvlSetChannelOption(),
|
||||
slvlSetMessageOption(),
|
||||
slvlSetRewardOption(),
|
||||
},
|
||||
}
|
||||
|
||||
func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
return
|
||||
}
|
||||
switch data.Options[0].Name {
|
||||
case "toggle":
|
||||
slvlToggleHandler(s, i)
|
||||
case "set":
|
||||
slvlSetLevelHandler(s, i)
|
||||
case "set-channel":
|
||||
slvlSetChannelHandler(s, i)
|
||||
case "set-message":
|
||||
slvlSetMessageHandler(s, i)
|
||||
case "set-reward":
|
||||
slvlSetRewardHandler(s, i)
|
||||
default:
|
||||
shared.RespondEphemeral(s, i, "Invalid subcommand.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetChannelOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-channel",
|
||||
Description: "Set the level up channel",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionChannel,
|
||||
Name: "channel",
|
||||
Description: "Channel for level-up messages",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetChannelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
channelOpt, ok := optMap["channel"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing channel option.")
|
||||
return
|
||||
}
|
||||
|
||||
ch := channelOpt.ChannelValue(s)
|
||||
if ch == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid channel.")
|
||||
return
|
||||
}
|
||||
|
||||
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid channel ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.LevelSettings.SetLevelUpChannel(context.Background(), guildID, channelID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set level up channel.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Level up channel set to "+ch.Mention()+".")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetLevelOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set",
|
||||
Description: "Set the level of a user",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "The user to set the level of",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "level",
|
||||
Description: "The level to set the user to",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetLevelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if services.Global == nil || services.Global.Level == nil {
|
||||
shared.RespondEphemeral(s, i, "Leveling service is not available.")
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
userOpt, ok := optMap["user"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing user option.")
|
||||
return
|
||||
}
|
||||
levelOpt, ok := optMap["level"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing level option.")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser := userOpt.UserValue(s)
|
||||
if targetUser == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
if targetUser.Bot {
|
||||
shared.RespondEphemeral(s, i, "You can't set a bot's level.")
|
||||
return
|
||||
}
|
||||
|
||||
newLevel := int(levelOpt.IntValue())
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(targetUser.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Level.SetLevel(context.Background(), guildID, userID, newLevel); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set level.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Level set to "+strconv.Itoa(newLevel)+" for "+targetUser.Mention()+".")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetMessageOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-message",
|
||||
Description: "Set the level up message",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "message",
|
||||
Description: "Level-up message. Variables: {user}, {level}",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetMessageHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
messageOpt, ok := optMap["message"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing message option.")
|
||||
return
|
||||
}
|
||||
|
||||
message := messageOpt.StringValue()
|
||||
if message == "" {
|
||||
message = "GG {user}, you reached level {level}!"
|
||||
}
|
||||
|
||||
if err := services.Global.LevelSettings.SetLevelUpMessage(context.Background(), guildID, message); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set level up message.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Level up message set.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlSetRewardOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-reward",
|
||||
Description: "Set role reward for a level",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "level",
|
||||
Description: "Level to reward",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionRole,
|
||||
Name: "role",
|
||||
Description: "Role to grant",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func slvlSetRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
levelOpt, ok := optMap["level"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing level option.")
|
||||
return
|
||||
}
|
||||
roleOpt, ok := optMap["role"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing role option.")
|
||||
return
|
||||
}
|
||||
|
||||
level := int(levelOpt.IntValue())
|
||||
role := roleOpt.RoleValue(s, i.GuildID)
|
||||
if role == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid role.")
|
||||
return
|
||||
}
|
||||
roleID, err := strconv.ParseInt(role.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid role ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.LevelSettings.SetRoleRewardForLevel(context.Background(), guildID, level, roleID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set reward.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Reward set: level "+strconv.Itoa(level)+" -> "+role.Mention())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func slvlToggleOption() *discordgo.ApplicationCommandOption {
|
||||
return &discordgo.ApplicationCommandOption{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "toggle",
|
||||
Description: "Toggle the leveling system",
|
||||
}
|
||||
}
|
||||
|
||||
func slvlToggleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
enabled, err := services.Global.LevelSettings.ToggleLevelSystem(context.Background(), guildID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to toggle leveling system.")
|
||||
return
|
||||
}
|
||||
status := "disabled"
|
||||
if enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
shared.RespondEphemeral(s, i, "Leveling system "+status+" for this server.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Leaderboard = &discordgo.ApplicationCommand{
|
||||
Name: "leaderboard",
|
||||
Description: "Top 10 levels in this server",
|
||||
}
|
||||
|
||||
func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelServices(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
top, err := services.Global.Level.TopLevels(ctx, 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("Level: **%d** | XP: **%d**", entry.Level, entry.XP),
|
||||
Inline: false,
|
||||
})
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Leaderboard",
|
||||
Description: fmt.Sprintf("These are the top %d users in the server", len(top)),
|
||||
Fields: fields,
|
||||
Color: 0xF59E0B,
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
"velox-bot/internal/rankcard"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Rank = &discordgo.ApplicationCommand{
|
||||
Name: "rank",
|
||||
Description: "Get your level card",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "The user to get the rank card of",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireLevelServices(s, i) {
|
||||
return
|
||||
}
|
||||
if i.GuildID != "" && !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target user: option or caller.
|
||||
target := i.User
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
target = i.Member.User
|
||||
}
|
||||
if len(i.ApplicationCommandData().Options) > 0 {
|
||||
if u := i.ApplicationCommandData().Options[0].UserValue(s); u != nil {
|
||||
target = u
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseInt(target.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
lvl, err := services.Global.Level.GetLevel(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load level.")
|
||||
return
|
||||
}
|
||||
|
||||
percent := float64(lvl.XP) / 100.0
|
||||
avatarURL := target.AvatarURL("256")
|
||||
|
||||
pngBytes, err := rankcard.RenderPNG(ctx, rankcard.Data{
|
||||
Name: target.Username,
|
||||
Level: lvl.Level,
|
||||
XP: lvl.XP,
|
||||
Percent: percent,
|
||||
Avatar: avatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to render rank card.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Files: []*discordgo.File{
|
||||
{
|
||||
Name: "levelcard.png",
|
||||
Reader: bytes.NewReader(pngBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"velox-bot/internal/commands/level/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Rewards = &discordgo.ApplicationCommand{
|
||||
Name: "rewards",
|
||||
Description: "View level rewards in this server",
|
||||
}
|
||||
|
||||
func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireGuild(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSettingsService(s, i) {
|
||||
return
|
||||
}
|
||||
if !shared.RequireLevelSystemEnabled(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rewards, err := services.Global.LevelSettings.ListRoleRewards(ctx, guildID, 25)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load rewards.")
|
||||
return
|
||||
}
|
||||
if len(rewards) == 0 {
|
||||
shared.RespondEphemeral(s, i, "No rewards configured yet.")
|
||||
return
|
||||
}
|
||||
|
||||
fields := make([]*discordgo.MessageEmbedField, 0, len(rewards))
|
||||
for _, r := range rewards {
|
||||
fields = append(fields, &discordgo.MessageEmbedField{
|
||||
Name: fmt.Sprintf("Level %d", r.Level),
|
||||
Value: fmt.Sprintf("<@&%d>", r.RoleID),
|
||||
Inline: true,
|
||||
})
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Level Rewards",
|
||||
Description: "Roles granted when you reach a level.",
|
||||
Fields: fields,
|
||||
Color: 0xF59E0B,
|
||||
}
|
||||
|
||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
Embeds: []*discordgo.MessageEmbed{embed},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/level/config"
|
||||
"velox-bot/internal/commands/level/public"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// Commands
|
||||
var (
|
||||
Rank *discordgo.ApplicationCommand = public.Rank
|
||||
Leaderboard *discordgo.ApplicationCommand = public.Leaderboard
|
||||
Rewards *discordgo.ApplicationCommand = public.Rewards
|
||||
Slvl *discordgo.ApplicationCommand = config.Slvl
|
||||
)
|
||||
|
||||
// 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 SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(s, i) }
|
||||
@@ -0,0 +1,95 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 leveling settings.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireLevelServices(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.Level == nil || services.Global.LevelSettings == nil {
|
||||
RespondEphemeral(s, i, "Leveling service is not available.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func RequireLevelSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if services.Global == nil || services.Global.LevelSettings == nil {
|
||||
RespondEphemeral(s, i, "Leveling 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 RequireLevelSystemEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
|
||||
if !RequireLevelSettingsService(s, i) {
|
||||
return false
|
||||
}
|
||||
guildID, ok := ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
enabled, err := services.Global.LevelSettings.IsLevelSystemEnabled(context.Background(), guildID)
|
||||
if err != nil {
|
||||
RespondEphemeral(s, i, "Failed to load leveling settings.")
|
||||
return false
|
||||
}
|
||||
if !enabled {
|
||||
RespondEphemeral(s, i, "Leveling system is disabled on this server. Use /slvl toggle to enable it.")
|
||||
return false
|
||||
}
|
||||
return 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,332 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/commands/meeting/shared"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var Meeting = &discordgo.ApplicationCommand{
|
||||
Name: "meeting",
|
||||
Description: "Meeting room settings",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "set-lobby",
|
||||
Description: "Set the voice lobby channel used to create meetings",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionChannel,
|
||||
Name: "channel",
|
||||
Description: "Voice channel lobby",
|
||||
Required: true,
|
||||
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 "set-lobby":
|
||||
handleSetLobby(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.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
optMap := shared.SubcommandOptionMap(i)
|
||||
channelOpt, ok := optMap["channel"]
|
||||
if !ok {
|
||||
shared.RespondEphemeral(s, i, "Missing channel option.")
|
||||
return
|
||||
}
|
||||
|
||||
ch := channelOpt.ChannelValue(s)
|
||||
if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice {
|
||||
shared.RespondEphemeral(s, i, "Invalid voice channel.")
|
||||
return
|
||||
}
|
||||
|
||||
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid channel ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to set meeting lobby.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".")
|
||||
}
|
||||
|
||||
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,130 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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) {
|
||||
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
|
||||
}
|
||||
|
||||
// If user provided plain text, turn it into a YouTube search
|
||||
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
|
||||
query = "ytsearch:" + query
|
||||
} else {
|
||||
query = normalizeYouTubeRadioURL(query)
|
||||
}
|
||||
|
||||
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
|
||||
if err != nil {
|
||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
||||
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
|
||||
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) }
|
||||
|
||||
// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like:
|
||||
// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID
|
||||
// It intentionally does NOT strip normal playlist URLs.
|
||||
func normalizeYouTubeRadioURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
host := strings.ToLower(u.Host)
|
||||
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
|
||||
return raw
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
v := q.Get("v")
|
||||
if v == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
list := q.Get("list")
|
||||
_, hasStartRadio := q["start_radio"]
|
||||
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
|
||||
return raw
|
||||
}
|
||||
|
||||
u.RawQuery = url.Values{"v": []string{v}}.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
|
||||
g, err := s.State.Guild(guildID)
|
||||
if err != nil {
|
||||
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,77 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"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 !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 (
|
||||
"fmt"
|
||||
"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) {
|
||||
_ = 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,20 @@
|
||||
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
|
||||
)
|
||||
|
||||
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) }
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "name",
|
||||
Description: "Project name",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "Short description (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
Name: "add-helper",
|
||||
Description: "Add a helper to an existing project",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "project-id",
|
||||
Description: "ID of the project",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to add as helper",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
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) + ">"
|
||||
}
|
||||
|
||||
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if i.Member == nil || i.Member.User == nil {
|
||||
shared.RespondEphemeral(s, i, "Missing member info.")
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var name, description string
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "name":
|
||||
name = o.StringValue()
|
||||
case "description":
|
||||
description = o.StringValue()
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create project.")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
|
||||
}
|
||||
|
||||
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
if !shared.RequireManageGuild(s, i) {
|
||||
return
|
||||
}
|
||||
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data := i.ApplicationCommandData()
|
||||
if len(data.Options) == 0 {
|
||||
shared.RespondEphemeral(s, i, "Missing options.")
|
||||
return
|
||||
}
|
||||
opt := data.Options[0]
|
||||
|
||||
var projectID int64
|
||||
var userID string
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "project-id":
|
||||
projectID = o.IntValue()
|
||||
case "user":
|
||||
if u := o.UserValue(s); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectID <= 0 {
|
||||
shared.RespondEphemeral(s, i, "Invalid project ID.")
|
||||
return
|
||||
}
|
||||
if userID == "" {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to load project.")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.")
|
||||
return
|
||||
}
|
||||
|
||||
userID64, err := strconv.ParseInt(userID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")")
|
||||
return
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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) }
|
||||
|
||||
@@ -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,439 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/commands/schedule/shared"
|
||||
"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",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionUser,
|
||||
Name: "user",
|
||||
Description: "User to invite",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "datetime",
|
||||
Description: "When (UTC), format: 2006-01-02 15:04",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionString,
|
||||
Name: "description",
|
||||
Description: "What is this session about? (optional)",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionSubCommand,
|
||||
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) {
|
||||
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 (
|
||||
targetUser *discordgo.User
|
||||
whenStr string
|
||||
desc string
|
||||
)
|
||||
for _, o := range opt.Options {
|
||||
switch o.Name {
|
||||
case "user":
|
||||
targetUser = o.UserValue(s)
|
||||
case "datetime":
|
||||
whenStr = o.StringValue()
|
||||
case "description":
|
||||
desc = o.StringValue()
|
||||
}
|
||||
}
|
||||
|
||||
if targetUser == nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user.")
|
||||
return
|
||||
}
|
||||
if targetUser.ID == i.Member.User.ID {
|
||||
shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.")
|
||||
return
|
||||
}
|
||||
|
||||
requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid user ID.")
|
||||
return
|
||||
}
|
||||
inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid target user ID.")
|
||||
return
|
||||
}
|
||||
|
||||
whenStr = strings.TrimSpace(whenStr)
|
||||
if whenStr == "" {
|
||||
shared.RespondEphemeral(s, i, "Datetime cannot be empty.")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve requester's timezone and parse input using explicit layout in that zone.
|
||||
loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
zone = "UTC"
|
||||
}
|
||||
// Expect layout "2006-01-02 15:04" in the user's timezone.
|
||||
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
ctx := context.Background()
|
||||
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to create schedule.")
|
||||
return
|
||||
}
|
||||
|
||||
// Send DM to invitee.
|
||||
dmCh, err := s.UserChannelCreate(targetUser.ID)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
|
||||
return
|
||||
}
|
||||
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "New session request",
|
||||
Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID),
|
||||
Color: 0x4caf50,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
utcStr := when.Format("2006-01-02 15:04")
|
||||
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID)
|
||||
if err != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: utcStr,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if desc != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: desc,
|
||||
})
|
||||
}
|
||||
|
||||
msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to DM the invited user.")
|
||||
return
|
||||
}
|
||||
|
||||
// Add reactions for interaction.
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji)
|
||||
}
|
||||
|
||||
// Store linkage between message and schedule.
|
||||
msgID64, _ := strconv.ParseInt(msg.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true)
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".")
|
||||
}
|
||||
|
||||
func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
guildID, ok := shared.ParseGuildID(s, i)
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 := time.ParseInLocation("2006-01-02 15:04", whenStr, loc)
|
||||
if err != nil {
|
||||
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").")
|
||||
return
|
||||
}
|
||||
when = when.In(time.UTC)
|
||||
|
||||
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil {
|
||||
shared.RespondEphemeral(s, i, "Failed to reschedule session.")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify participants via DM and send new request DM to invitee.
|
||||
whenFmt := when.Format("2006-01-02 15:04")
|
||||
|
||||
otherID := sch.InviteeID
|
||||
if userID == sch.InviteeID {
|
||||
otherID = sch.RequesterID
|
||||
}
|
||||
|
||||
// DM both about the change, including their local times if available.
|
||||
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} {
|
||||
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
|
||||
if err != nil {
|
||||
locU = time.UTC
|
||||
zoneU = "UTC"
|
||||
}
|
||||
localU := when.In(locU).Format("2006-01-02 15:04")
|
||||
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
|
||||
if sch.Description != "" {
|
||||
msg += "\nTopic: " + sch.Description
|
||||
}
|
||||
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = s.ChannelMessageSend(ch.ID, msg)
|
||||
}
|
||||
|
||||
// Send new "request" DM to invitee with reactions again.
|
||||
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
|
||||
inviteeUser, err := s.User(inviteeStr)
|
||||
if err == nil && inviteeUser != nil {
|
||||
dmCh, err := s.UserChannelCreate(inviteeStr)
|
||||
if err == nil {
|
||||
embed := &discordgo.MessageEmbed{
|
||||
Title: "Rescheduled session",
|
||||
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID),
|
||||
Color: 0xffc107,
|
||||
}
|
||||
// Show both UTC and invitee-local time if available.
|
||||
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID)
|
||||
if errTZ != nil {
|
||||
locInv = time.UTC
|
||||
zoneInv = "UTC"
|
||||
}
|
||||
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
|
||||
embed.Fields = []*discordgo.MessageEmbedField{
|
||||
{
|
||||
Name: "When (UTC)",
|
||||
Value: whenFmt,
|
||||
},
|
||||
{
|
||||
Name: "When (" + zoneInv + ")",
|
||||
Value: localInvStr,
|
||||
},
|
||||
}
|
||||
if sch.Description != "" {
|
||||
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
|
||||
Name: "Description",
|
||||
Value: sch.Description,
|
||||
})
|
||||
}
|
||||
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
|
||||
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.",
|
||||
Embed: embed,
|
||||
})
|
||||
if err == nil && msgObj != nil {
|
||||
for _, emoji := range []string{"✅", "❌", "🔁"} {
|
||||
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji)
|
||||
}
|
||||
msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64)
|
||||
chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64)
|
||||
_ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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) }
|
||||
|
||||
@@ -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 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,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
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
BotToken string
|
||||
AppID string
|
||||
GuildID string
|
||||
DBHost string
|
||||
LavalinkHost string
|
||||
LavalinkPass string
|
||||
TwitchClientID string
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
@@ -29,13 +33,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,
|
||||
BotToken: token,
|
||||
AppID: appID,
|
||||
GuildID: guildID,
|
||||
DBHost: dbHost,
|
||||
LavalinkHost: lavalinkHost,
|
||||
LavalinkPass: lavalinkPass,
|
||||
TwitchClientID: twitchClientID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
"velox-bot/internal/config"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func NewDB(config *config.Config) (*sql.DB, error) {
|
||||
pool, err := sql.Open("pgx", config.DBHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pool.SetMaxIdleConns(10)
|
||||
pool.SetMaxOpenConns(100)
|
||||
pool.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := pool.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pool, 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,94 @@
|
||||
package levelrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Level struct {
|
||||
GuildID int64
|
||||
UserID int64
|
||||
Level int
|
||||
XP int64
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) GetLevel(ctx context.Context, guildID, userID int64) (*Level, error) {
|
||||
const q = `
|
||||
SELECT level, xp
|
||||
FROM levels
|
||||
WHERE guild = $1 AND user_id = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, userID)
|
||||
var lvl Level
|
||||
lvl.GuildID = guildID
|
||||
lvl.UserID = userID
|
||||
switch err := row.Scan(&lvl.Level, &lvl.XP); err {
|
||||
case sql.ErrNoRows:
|
||||
// default new user
|
||||
lvl.Level = 0
|
||||
lvl.XP = 0
|
||||
return &lvl, nil
|
||||
case nil:
|
||||
return &lvl, nil
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
func (r *Repo) UpsertLevel(ctx context.Context, lvl *Level) error {
|
||||
const q = `
|
||||
INSERT INTO levels (guild, user_id, level, xp)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (guild, user_id)
|
||||
DO UPDATE SET level = EXCLUDED.level, xp = EXCLUDED.xp
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, lvl.GuildID, lvl.UserID, lvl.Level, lvl.XP)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevel(ctx context.Context, guildID, userID int64, level int) error {
|
||||
const q = `
|
||||
INSERT INTO levels (guild, user_id, level, xp)
|
||||
VALUES ($1, $2, $3, 0)
|
||||
ON CONFLICT (guild, user_id)
|
||||
DO UPDATE SET level = EXCLUDED.level, xp = 0
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, userID, level)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) TopLevels(ctx context.Context, guildID int64, limit int) ([]*Level, error) {
|
||||
const q = `
|
||||
SELECT user_id, level, xp
|
||||
FROM levels
|
||||
WHERE guild = $1
|
||||
ORDER BY level DESC, xp DESC, user_id ASC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*Level, 0, limit)
|
||||
for rows.Next() {
|
||||
lvl := &Level{GuildID: guildID}
|
||||
if err := rows.Scan(&lvl.UserID, &lvl.Level, &lvl.XP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, lvl)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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) 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
|
||||
}
|
||||
}
|
||||
|
||||
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,189 @@
|
||||
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
|
||||
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) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
const q = `
|
||||
INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (schedule_id, message_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) {
|
||||
const q = `
|
||||
SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at
|
||||
FROM schedules s
|
||||
JOIN schedule_messages m ON m.schedule_id = s.id
|
||||
WHERE m.message_id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, messageID)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sch, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
|
||||
const q = `
|
||||
UPDATE schedules
|
||||
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, status, description, reminder_sent, created_at
|
||||
FROM schedules
|
||||
WHERE id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, id)
|
||||
var sch Schedule
|
||||
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
|
||||
if err == 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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package settingsrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type LevelReward struct {
|
||||
GuildID int64
|
||||
Level int
|
||||
RoleID int64
|
||||
}
|
||||
|
||||
type LogSettings struct {
|
||||
GuildID int64
|
||||
ChannelID int64
|
||||
IsEnabled bool
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error {
|
||||
const q = `
|
||||
INSERT INTO meeting_settings (guild_id, lobby_channel_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) {
|
||||
const q = `
|
||||
SELECT lobby_channel_id
|
||||
FROM meeting_settings
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var ch sql.NullInt64
|
||||
switch err := row.Scan(&ch); err {
|
||||
case nil:
|
||||
if !ch.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return ch.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, 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)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET levelsys = EXCLUDED.levelsys
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
const q = `
|
||||
SELECT levelsys
|
||||
FROM levelsettings
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var enabled bool
|
||||
switch err := row.Scan(&enabled); err {
|
||||
case nil:
|
||||
return enabled, nil
|
||||
case sql.ErrNoRows:
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error) {
|
||||
const q = `
|
||||
SELECT levelup_channel_id
|
||||
FROM levelup
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var channelID sql.NullInt64
|
||||
switch err := row.Scan(&channelID); err {
|
||||
case nil:
|
||||
if !channelID.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return channelID.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error) {
|
||||
const q = `
|
||||
SELECT role
|
||||
FROM levelrewards
|
||||
WHERE guild_id = $1 AND levelreq = $2
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID, level)
|
||||
var role sql.NullInt64
|
||||
switch err := row.Scan(&role); err {
|
||||
case nil:
|
||||
if !role.Valid {
|
||||
return 0, false, nil
|
||||
}
|
||||
return role.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repo) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error {
|
||||
const q = `
|
||||
INSERT INTO levelrewards (guild_id, levelreq, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (guild_id, levelreq)
|
||||
DO UPDATE SET role = EXCLUDED.role
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, level, roleID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListRoleRewards(ctx context.Context, guildID int64, limit int) ([]*LevelReward, error) {
|
||||
const q = `
|
||||
SELECT levelreq, role
|
||||
FROM levelrewards
|
||||
WHERE guild_id = $1
|
||||
ORDER BY levelreq ASC
|
||||
LIMIT $2
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*LevelReward, 0, limit)
|
||||
for rows.Next() {
|
||||
rw := &LevelReward{GuildID: guildID}
|
||||
if err := rows.Scan(&rw.Level, &rw.RoleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rw)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error {
|
||||
const q = `
|
||||
INSERT INTO levelup (guild_id, levelup_channel_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET levelup_channel_id = EXCLUDED.levelup_channel_id
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error {
|
||||
const q = `
|
||||
UPDATE levelsettings
|
||||
SET message = $1
|
||||
WHERE guild_id = $2
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, message, guildID)
|
||||
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
|
||||
FROM levelsettings
|
||||
WHERE guild_id = $1
|
||||
`
|
||||
row := r.db.QueryRowContext(ctx, q, guildID)
|
||||
var message sql.NullString
|
||||
switch err := row.Scan(&message); err {
|
||||
case nil:
|
||||
if !message.Valid {
|
||||
return "GG {user}, you reached level {level}!", nil
|
||||
}
|
||||
return message.String, nil
|
||||
case sql.ErrNoRows:
|
||||
return "GG {user}, you reached level {level}!", nil
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID int64) error {
|
||||
const q = `
|
||||
INSERT INTO twitch_config (guild_id, twitch_channel_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (guild_id)
|
||||
DO UPDATE SET twitch_channel_id = EXCLUDED.twitch_channel_id
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
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,134 @@
|
||||
package level
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"velox-bot/internal/db/repos/levelrepo"
|
||||
)
|
||||
|
||||
type SettingsProvider interface {
|
||||
IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error)
|
||||
GetLevelupChannelID(ctx context.Context, guildID int64) (int64, bool, error)
|
||||
GetRoleRewardForLevel(ctx context.Context, guildID int64, level int) (roleID int64, ok bool, err error)
|
||||
SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error
|
||||
SetLevelUpMessage(ctx context.Context, guildID int64, message string) error
|
||||
GetLevelUpMessage(ctx context.Context, guildID int64) (string, error)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
LeveledUp bool
|
||||
NewLevel int
|
||||
RoleRewardID int64 // 0 if none
|
||||
LevelupMessage string // you can fill this later
|
||||
LevelupChannel int64 // 0 means "use current channel"
|
||||
UpdatedXP int64
|
||||
UpdatedLevel int
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
levels *levelrepo.Repo
|
||||
settings SettingsProvider
|
||||
rand *rand.Rand
|
||||
}
|
||||
|
||||
func New(levels *levelrepo.Repo, settings SettingsProvider) *Service {
|
||||
return &Service{
|
||||
levels: levels,
|
||||
settings: settings,
|
||||
rand: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMessage mirrors your Python gain_xp behavior, minus Discord specifics.
|
||||
func (s *Service) HandleMessage(ctx context.Context, guildID, userID int64) (*Result, error) {
|
||||
enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID)
|
||||
if err != nil || !enabled {
|
||||
return &Result{LeveledUp: false}, err
|
||||
}
|
||||
|
||||
lvl, err := s.levels.GetLevel(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// XP gain rules
|
||||
shouldGainXP := false
|
||||
if lvl.Level < 5 {
|
||||
shouldGainXP = true
|
||||
} else {
|
||||
// random.randint(1, level//4) == 1
|
||||
div := lvl.Level / 4
|
||||
if div < 1 {
|
||||
div = 1
|
||||
}
|
||||
if s.rand.Intn(div)+1 == 1 {
|
||||
shouldGainXP = true
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldGainXP {
|
||||
return &Result{
|
||||
LeveledUp: false,
|
||||
UpdatedXP: lvl.XP,
|
||||
UpdatedLevel: lvl.Level,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// This assumes your setXp() adds some fixed XP per hit; adapt amount as needed.
|
||||
xpPerMessage := s.rand.Intn(3) + 1
|
||||
lvl.XP += int64(xpPerMessage)
|
||||
|
||||
res := &Result{
|
||||
LeveledUp: false,
|
||||
UpdatedXP: lvl.XP,
|
||||
UpdatedLevel: lvl.Level,
|
||||
}
|
||||
|
||||
// Level-up rule: if xp >= 100 -> level++
|
||||
if lvl.XP >= 100 {
|
||||
lvl.Level++
|
||||
lvl.XP = 0 // or keep overflow, depending on Python behavior
|
||||
|
||||
res.LeveledUp = true
|
||||
res.NewLevel = lvl.Level
|
||||
res.UpdatedXP = lvl.XP
|
||||
res.UpdatedLevel = lvl.Level
|
||||
|
||||
roleID, ok, err := s.settings.GetRoleRewardForLevel(ctx, guildID, lvl.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
res.RoleRewardID = roleID
|
||||
}
|
||||
|
||||
chID, ok, err := s.settings.GetLevelupChannelID(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
res.LevelupChannel = chID
|
||||
}
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.levels.UpsertLevel(ctx, lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetLevel(ctx context.Context, guildID, userID int64, level int) error {
|
||||
return s.levels.SetLevel(ctx, guildID, userID, level)
|
||||
}
|
||||
|
||||
func (s *Service) GetLevel(ctx context.Context, guildID, userID int64) (*levelrepo.Level, error) {
|
||||
return s.levels.GetLevel(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
func (s *Service) TopLevels(ctx context.Context, guildID int64, limit int) ([]*levelrepo.Level, error) {
|
||||
return s.levels.TopLevels(ctx, guildID, limit)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package levelsettings
|
||||
|
||||
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) ToggleLevelSystem(ctx context.Context, guildID int64) (bool, error) {
|
||||
enabled, err := s.settings.IsLevelSystemEnabled(ctx, guildID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
newEnabled := !enabled
|
||||
if err := s.settings.SetLevelSystemEnabled(ctx, guildID, newEnabled); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return newEnabled, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetLevelSystemEnabled(ctx context.Context, guildID int64, enabled bool) error {
|
||||
return s.settings.SetLevelSystemEnabled(ctx, guildID, enabled)
|
||||
}
|
||||
|
||||
func (s *Service) SetLevelUpChannel(ctx context.Context, guildID int64, channelID int64) error {
|
||||
return s.settings.SetLevelUpChannel(ctx, guildID, channelID)
|
||||
}
|
||||
|
||||
func (s *Service) SetLevelUpMessage(ctx context.Context, guildID int64, message string) error {
|
||||
return s.settings.SetLevelUpMessage(ctx, guildID, message)
|
||||
}
|
||||
|
||||
func (s *Service) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
|
||||
return s.settings.GetLevelUpMessage(ctx, guildID)
|
||||
}
|
||||
|
||||
func (s *Service) SetRoleRewardForLevel(ctx context.Context, guildID int64, level int, roleID int64) error {
|
||||
return s.settings.SetRoleRewardForLevel(ctx, guildID, level, roleID)
|
||||
}
|
||||
|
||||
func (s *Service) ListRoleRewards(ctx context.Context, guildID int64, limit int) ([]*settingsrepo.LevelReward, error) {
|
||||
return s.settings.ListRoleRewards(ctx, guildID, limit)
|
||||
}
|
||||
|
||||
func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
|
||||
return s.settings.IsLevelSystemEnabled(ctx, guildID)
|
||||
}
|
||||
@@ -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,40 @@
|
||||
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}
|
||||
}
|
||||
|
||||
func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error {
|
||||
return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID)
|
||||
}
|
||||
|
||||
func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) {
|
||||
return s.settings.GetMeetingLobbyChannel(ctx, guildID)
|
||||
}
|
||||
|
||||
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 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) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
|
||||
return s.repo.ProjectExistsInGuild(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,53 @@
|
||||
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) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error {
|
||||
return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM)
|
||||
}
|
||||
|
||||
func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) {
|
||||
return s.repo.GetByMessageID(ctx, messageID)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
|
||||
return s.repo.UpdateStatus(ctx, scheduleID, status)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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/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
|
||||
}
|
||||
|
||||
var Global *Services
|
||||
|
||||
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service) *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,
|
||||
}
|
||||
Global = s
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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) 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
||||
CacheMessageFromCreate(m)
|
||||
|
||||
if m.Author.Bot || m.GuildID == "" {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
guildID := mustParseInt64(m.GuildID)
|
||||
userID := mustParseInt64(m.Author.ID)
|
||||
res, err := services.Level.HandleMessage(ctx, guildID, userID)
|
||||
if err != nil || !res.LeveledUp {
|
||||
return
|
||||
}
|
||||
// Decide where to send the level-up message
|
||||
targetChannelID := m.ChannelID
|
||||
if res.LevelupChannel != 0 {
|
||||
targetChannelID = strconv.FormatInt(res.LevelupChannel, 10)
|
||||
}
|
||||
content, err := services.LevelSettings.GetLevelUpMessage(ctx, guildID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
content = strings.ReplaceAll(content, "{user}", m.Author.Mention())
|
||||
content = strings.ReplaceAll(content, "{level}", strconv.Itoa(res.NewLevel))
|
||||
|
||||
s.ChannelMessageSend(targetChannelID, content)
|
||||
// Apply role reward if any
|
||||
if res.RoleRewardID != 0 {
|
||||
roleID := strconv.FormatInt(res.RoleRewardID, 10)
|
||||
if err := s.GuildMemberRoleAdd(m.GuildID, m.Author.ID, roleID); err != nil {
|
||||
log.Printf("Failed to add role reward: guild=%s user=%s role=%s err=%v", m.GuildID, m.Author.ID, roleID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseInt64(s string) int64 {
|
||||
id, _ := strconv.ParseInt(s, 10, 64)
|
||||
return id
|
||||
}
|
||||
@@ -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,116 @@
|
||||
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 the configured lobby voice channel, 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
|
||||
}
|
||||
|
||||
lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64)
|
||||
if err != nil || !ok || lobbyID == 0 {
|
||||
return
|
||||
}
|
||||
if vs.ChannelID != strconv.FormatInt(lobbyID, 10) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,95 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
|
||||
// ✅ accept, ❌ decline, 🔁 request reschedule.
|
||||
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
|
||||
if svc == nil || svc.Schedule == nil {
|
||||
return
|
||||
}
|
||||
if r == nil || r.UserID == "" || r.MessageID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore bot reactions.
|
||||
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
|
||||
return
|
||||
}
|
||||
|
||||
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
|
||||
if err != nil || msgID64 == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
|
||||
if err != nil || sch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only invitee can react to change status.
|
||||
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only pending can be transitioned by reaction.
|
||||
if sch.Status != schedulerepo.StatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
var newStatus schedulerepo.ScheduleStatus
|
||||
switch r.Emoji.Name {
|
||||
case "✅":
|
||||
newStatus = schedulerepo.StatusAccepted
|
||||
case "❌":
|
||||
newStatus = schedulerepo.StatusDeclined
|
||||
case "🔁":
|
||||
newStatus = schedulerepo.StatusRescheduleRequested
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
|
||||
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
statusText := map[schedulerepo.ScheduleStatus]string{
|
||||
schedulerepo.StatusAccepted: "accepted ✅",
|
||||
schedulerepo.StatusDeclined: "declined ❌",
|
||||
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
|
||||
}[newStatus]
|
||||
|
||||
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
|
||||
|
||||
// Notify requester with local time if configured.
|
||||
if svc.UserSettings != nil {
|
||||
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
|
||||
if err != nil {
|
||||
loc = sch.ScheduledAt.UTC().Location()
|
||||
zone = "UTC"
|
||||
}
|
||||
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
|
||||
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
|
||||
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
|
||||
|
||||
requesterID := strconv.FormatInt(sch.RequesterID, 10)
|
||||
dmCh, err := s.UserChannelCreate(requesterID)
|
||||
if err == nil {
|
||||
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
|
||||
log.Printf("schedule: failed to DM requester about status change: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,134 @@
|
||||
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)
|
||||
|
||||
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,108 @@
|
||||
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 {
|
||||
content = "Error: " + err.Error()
|
||||
}
|
||||
|
||||
// send ephemeral confirmation
|
||||
_, _ = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||
Content: content,
|
||||
Flags: discordgo.MessageFlagsEphemeral,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||
"github.com/disgoorg/snowflake/v2"
|
||||
)
|
||||
|
||||
type TrackEntry struct {
|
||||
Track lavalink.Track
|
||||
RequestedBy 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
|
||||
|
||||
mu sync.Mutex
|
||||
players map[string]*guildPlayer
|
||||
}
|
||||
|
||||
var manager *Manager
|
||||
|
||||
func Init(session *discordgo.Session, 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,
|
||||
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"
|
||||
}
|
||||
|
||||
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
||||
Name: "main",
|
||||
Address: u.Host,
|
||||
Password: password,
|
||||
Secure: secure,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add lavalink node: %w", err)
|
||||
}
|
||||
|
||||
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 EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester 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()
|
||||
}
|
||||
|
||||
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, false, 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]...)
|
||||
}
|
||||
|
||||
entries := make([]TrackEntry, len(loadedTracks))
|
||||
for idx, t := range loadedTracks {
|
||||
entries[idx] = TrackEntry{
|
||||
Track: t,
|
||||
RequestedBy: requester,
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return &firstEntry, shouldStart, 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 {
|
||||
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)
|
||||
}
|
||||
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())
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
defer manager.mu.Unlock()
|
||||
gp.RepeatSong = !gp.RepeatSong
|
||||
if gp.RepeatSong {
|
||||
gp.RepeatQueue = false
|
||||
}
|
||||
return gp.RepeatSong, gp.RepeatQueue
|
||||
}
|
||||
|
||||
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||
if manager == nil {
|
||||
return false, false
|
||||
}
|
||||
gp := getOrCreateGuildPlayer(guildID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
gp.RepeatQueue = !gp.RepeatQueue
|
||||
if gp.RepeatQueue {
|
||||
gp.RepeatSong = false
|
||||
}
|
||||
return gp.RepeatSong, gp.RepeatQueue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package music
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
func memberHasDJRoleForComponents(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,18 @@
|
||||
package music
|
||||
|
||||
import "github.com/bwmarrin/discordgo"
|
||||
|
||||
func updateNowPlayingButtons(channelID, messageID string, guildID string) {
|
||||
if manager == nil || channelID == "" || messageID == "" {
|
||||
return
|
||||
}
|
||||
repeatSong, repeatQueue := repeatFlags(guildID)
|
||||
paused, _ := pausedAndVolume(guildID)
|
||||
comps := nowPlayingComponents(false, repeatSong, repeatQueue, paused)
|
||||
_, _ = manager.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
|
||||
Channel: channelID,
|
||||
ID: messageID,
|
||||
Components: &comps,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package rankcard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/font/opentype"
|
||||
)
|
||||
|
||||
type Data struct {
|
||||
Name string
|
||||
Level int
|
||||
XP int64
|
||||
Percent float64 // 0..1 progress towards next level
|
||||
Avatar string // URL
|
||||
}
|
||||
|
||||
func RenderPNG(ctx context.Context, d Data) ([]byte, error) {
|
||||
const (
|
||||
w = 900
|
||||
h = 300
|
||||
px = 30
|
||||
|
||||
avatarSize = 150
|
||||
barX = 30
|
||||
barY = 220
|
||||
barW = 650
|
||||
barH = 40
|
||||
)
|
||||
|
||||
percent := clamp01(d.Percent)
|
||||
|
||||
dc := gg.NewContext(w, h)
|
||||
dc.SetHexColor("#141414")
|
||||
dc.Clear()
|
||||
|
||||
// Right white polygon
|
||||
dc.SetHexColor("#FFFFFF")
|
||||
dc.NewSubPath()
|
||||
dc.MoveTo(600, 0)
|
||||
dc.LineTo(750, 300)
|
||||
dc.LineTo(900, 300)
|
||||
dc.LineTo(900, 0)
|
||||
dc.ClosePath()
|
||||
dc.Fill()
|
||||
|
||||
// Avatar (circle clip)
|
||||
if img, err := fetchImage(ctx, d.Avatar); err == nil && img != nil {
|
||||
img = resizeImage(img, avatarSize, avatarSize)
|
||||
dc.Push()
|
||||
dc.DrawCircle(px+avatarSize/2, px+avatarSize/2, avatarSize/2)
|
||||
dc.Clip()
|
||||
dc.DrawImage(img, px, px)
|
||||
dc.Pop()
|
||||
// Defensive: ensure no clip leaks past avatar drawing.
|
||||
dc.ResetClip()
|
||||
}
|
||||
|
||||
// Progress bar background
|
||||
dc.SetHexColor("#FFFFFF")
|
||||
dc.DrawRoundedRectangle(barX, barY, barW, barH, 20)
|
||||
dc.Fill()
|
||||
|
||||
// Progress bar fill
|
||||
fillW := barW * percent
|
||||
if fillW > 0 {
|
||||
dc.SetHexColor("#282828")
|
||||
// Keep radius from looking weird on small widths
|
||||
r := math.Min(20, fillW/2)
|
||||
dc.DrawRoundedRectangle(barX, barY, fillW, barH, r)
|
||||
dc.Fill()
|
||||
}
|
||||
|
||||
// Text
|
||||
titleFace, smallFace, err := loadFontFaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc.SetHexColor("#FFFFFF")
|
||||
dc.SetFontFace(titleFace)
|
||||
dc.DrawStringAnchored(d.Name, 200, 60, 0, 0.5)
|
||||
|
||||
dc.SetLineWidth(2)
|
||||
dc.DrawLine(200, 100, 550, 100)
|
||||
dc.Stroke()
|
||||
|
||||
dc.SetFontFace(smallFace)
|
||||
dc.DrawStringAnchored(
|
||||
fmt.Sprintf("Level - %d | XP - %d | %d%%", d.Level, d.XP, int64(math.Round(percent*100))),
|
||||
200,
|
||||
145,
|
||||
0,
|
||||
0.5,
|
||||
)
|
||||
|
||||
// Watermark to verify latest render is running
|
||||
dc.SetFontFace(smallFace)
|
||||
dc.SetHexColor("#FFCC00")
|
||||
dc.DrawStringAnchored("velox-go rankcard", 890, 290, 1, 1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dc.Image()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func loadFontFaces() (title font.Face, small font.Face, err error) {
|
||||
f, err := opentype.Parse(goregular.TTF)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
title, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 40, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
small, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 30, DPI: 72, Hinting: font.HintingFull})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return title, small, nil
|
||||
}
|
||||
|
||||
func fetchImage(ctx context.Context, url string) (image.Image, error) {
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("empty avatar url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(resp.Body)
|
||||
return img, err
|
||||
}
|
||||
|
||||
func resizeImage(src image.Image, w, h int) image.Image {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -9,6 +9,28 @@ import (
|
||||
"velox-bot/internal/bot"
|
||||
"velox-bot/internal/commands"
|
||||
"velox-bot/internal/config"
|
||||
"velox-bot/internal/db"
|
||||
"velox-bot/internal/db/repos/levelrepo"
|
||||
"velox-bot/internal/db/repos/projectsrepo"
|
||||
"velox-bot/internal/db/repos/rpsrepo"
|
||||
"velox-bot/internal/db/repos/schedulerepo"
|
||||
"velox-bot/internal/db/repos/settingsrepo"
|
||||
"velox-bot/internal/db/repos/welcomerepo"
|
||||
"velox-bot/internal/db/repos/twitchrepo"
|
||||
"velox-bot/internal/db/repos/usersettingsrepo"
|
||||
"velox-bot/internal/db/repos/defaultrolerepo"
|
||||
"velox-bot/internal/db/services"
|
||||
"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/projects"
|
||||
"velox-bot/internal/db/services/rps"
|
||||
"velox-bot/internal/db/services/schedule"
|
||||
"velox-bot/internal/db/services/twitch"
|
||||
"velox-bot/internal/db/services/usersettings"
|
||||
"velox-bot/internal/db/services/welcome"
|
||||
"velox-bot/internal/db/services/defaultrole"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -18,7 +40,36 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands)
|
||||
db, err := db.NewDB(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating database: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
levelRepo := levelrepo.NewRepo(db)
|
||||
settingsRepo := settingsrepo.NewRepo(db)
|
||||
rpsRepo := rpsrepo.NewRepo(db)
|
||||
projectsRepo := projectsrepo.NewRepo(db)
|
||||
scheduleRepo := schedulerepo.NewRepo(db)
|
||||
userSettingsRepo := usersettingsrepo.NewRepo(db)
|
||||
welcomeRepo := welcomerepo.NewRepo(db)
|
||||
twitchRepo := twitchrepo.NewRepo(db)
|
||||
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||
levelService := level.New(levelRepo, settingsRepo)
|
||||
levelSettingsService := levelsettings.New(settingsRepo)
|
||||
logSettingsService := logsettings.New(settingsRepo)
|
||||
meetingService := meeting.New(settingsRepo)
|
||||
scheduleService := schedule.New(scheduleRepo)
|
||||
userSettingsService := usersettings.New(userSettingsRepo)
|
||||
projectsService := projects.New(projectsRepo)
|
||||
rpsService := rps.New(rpsRepo)
|
||||
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
||||
welcomeService := welcome.New(welcomeRepo)
|
||||
defaultRoleService := defaultrole.New(defaultRoleRepo)
|
||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService)
|
||||
|
||||
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating bot: %v", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 1) Ensure the new table exists
|
||||
CREATE TABLE IF NOT EXISTS levelrewards (
|
||||
guild_id BIGINT NOT NULL,
|
||||
levelreq INT NOT NULL,
|
||||
role BIGINT NOT NULL,
|
||||
PRIMARY KEY (guild_id, levelreq)
|
||||
);
|
||||
|
||||
-- 2) Backfill from legacy columns (only rows that have both values)
|
||||
INSERT INTO levelrewards (guild_id, levelreq, role)
|
||||
SELECT guild_id, levelreq, role
|
||||
FROM levelsettings
|
||||
WHERE role IS NOT NULL
|
||||
AND levelreq IS NOT NULL
|
||||
ON CONFLICT (guild_id, levelreq)
|
||||
DO UPDATE SET role = EXCLUDED.role;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*)
|
||||
FROM levelsettings
|
||||
WHERE role IS NOT NULL AND levelreq IS NOT NULL) AS legacy_reward_rows,
|
||||
(SELECT COUNT(*)
|
||||
FROM levelrewards) AS levelrewards_rows;
|
||||
|
||||
SELECT ls.guild_id, ls.levelreq, ls.role
|
||||
FROM levelsettings ls
|
||||
LEFT JOIN levelrewards lr
|
||||
ON lr.guild_id = ls.guild_id AND lr.levelreq = ls.levelreq
|
||||
WHERE ls.role IS NOT NULL
|
||||
AND ls.levelreq IS NOT NULL
|
||||
AND lr.guild_id IS NULL;
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
-- PostgreSQL schema for Velox
|
||||
-- Use BIGINT for Discord snowflakes, INT for smaller counters, BOOLEAN properly.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS levels (
|
||||
guild BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
level INT NOT NULL DEFAULT 0,
|
||||
xp BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (guild, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS twitch (
|
||||
twitch_user TEXT NOT NULL,
|
||||
guild_id BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'not live',
|
||||
PRIMARY KEY (twitch_user, guild_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS levelsettings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
levelsys BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
message TEXT
|
||||
);
|
||||
|
||||
-- Role rewards per level (supports multiple rewards per guild)
|
||||
CREATE TABLE IF NOT EXISTS levelrewards (
|
||||
guild_id BIGINT NOT NULL,
|
||||
levelreq INT NOT NULL,
|
||||
role BIGINT NOT NULL,
|
||||
PRIMARY KEY (guild_id, levelreq)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS welcome (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
welcome_channel_id BIGINT,
|
||||
welcome_message TEXT,
|
||||
welcome_dm TEXT,
|
||||
welcome_gif_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS levelup (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
levelup_channel_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS twitch_config (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
twitch_channel_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rps (
|
||||
guild_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
score INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (guild_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS defaultrole (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
role_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logsettings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
log_channel_id BIGINT,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Meeting rooms (voice lobby -> auto-created temporary voice channels)
|
||||
CREATE TABLE IF NOT EXISTS meeting_settings (
|
||||
guild_id BIGINT PRIMARY KEY,
|
||||
lobby_channel_id BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meeting_temp_channels (
|
||||
guild_id BIGINT NOT NULL,
|
||||
channel_id BIGINT NOT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (guild_id, channel_id)
|
||||
);
|
||||
|
||||
-- Projects & members
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
is_creator BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
--
|
||||
-- Scheduling (one-to-one sessions)
|
||||
--
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
guild_id BIGINT NOT NULL,
|
||||
requester_id BIGINT NOT NULL,
|
||||
invitee_id BIGINT NOT NULL,
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
|
||||
description TEXT,
|
||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedule_messages (
|
||||
schedule_id BIGINT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
|
||||
message_id BIGINT NOT NULL,
|
||||
channel_id BIGINT NOT NULL,
|
||||
is_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (schedule_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
timezone TEXT NOT NULL
|
||||
);
|
||||
Reference in New Issue
Block a user