Compare commits

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

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

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

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
.env
*.md
Dockerfile
docker-compose*.yml
.dockerignore
+1 -1
View File
@@ -1,3 +1,3 @@
.env *.env
.cursor .cursor
tmp/ tmp/
+21
View File
@@ -0,0 +1,21 @@
# Build stage
FROM golang:1.26.1-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /velox-bot .
# Run stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /velox-bot .
ENTRYPOINT ["./velox-bot"]
+18 -2
View File
@@ -10,8 +10,12 @@ It currently includes:
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule) - Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
- Projects (list/create projects + helpers) - Projects (list/create projects + helpers)
- Music (Lavalink-powered `/play`, `/queue`, `/volume`) - Music (Lavalink-powered `/play`, `/queue`, `/volume`)
- Twitch live notifications (per-guild `/config` setup) - Twitch live notifications (per-guild `/config` setup)
- Welcome messages, DMs, and default role assignment (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 ## Requirements
@@ -96,6 +100,14 @@ just down # stop postgres
- `/projects list`, `/projects create`, `/projects add-helper` - `/projects list`, `/projects create`, `/projects add-helper`
- **Music** (requires **DJ** role) - **Music** (requires **DJ** role)
- `/play`, `/queue`, `/volume` - `/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**) - **Twitch live notifications** (**Manage Server**)
- `/config addstreamer username:<twitch-name>` — Add a streamer for notifications - `/config addstreamer username:<twitch-name>` — Add a streamer for notifications
- `/config removestreamer username:<twitch-name>` — Remove a streamer - `/config removestreamer username:<twitch-name>` — Remove a streamer
@@ -106,6 +118,10 @@ just down # stop postgres
- `/config setwelcomedm message:"..."` — DM 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 setwelcomegif url:<image-url>` — GIF/image used in welcome embed
- `/config setdefaultrole role:@role` — Role automatically granted on join - `/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 ## Notes
+16 -3
View File
@@ -1,5 +1,3 @@
version: "3.9"
services: services:
db: db:
image: postgres:16 image: postgres:16
@@ -19,5 +17,20 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
bot:
build: .
container_name: velox-bot
restart: unless-stopped
env_file: .env
environment:
DB_HOST: ${DB_HOST:-postgres://velox:velox_pwd@db:5432/velox?sslmode=disable}
LAVALINK_HOST: ${LAVALINK_HOST:-ws://lavalink:2333}
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD:-youshallnotpass}
depends_on:
db:
condition: service_healthy
networks:
- default
volumes: volumes:
velox_pgdata: velox_pgdata:
+1
View File
@@ -15,6 +15,7 @@ require (
require ( require (
github.com/disgoorg/json v1.2.0 // indirect github.com/disgoorg/json v1.2.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+2
View File
@@ -13,6 +13,8 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+49 -10
View File
@@ -1,8 +1,10 @@
package bot package bot
import ( import (
"context"
"log" "log"
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/events" "velox-bot/internal/events"
"velox-bot/internal/music" "velox-bot/internal/music"
@@ -17,11 +19,13 @@ type Bot struct {
Commands []*discordgo.ApplicationCommand Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand registeredCommands []*discordgo.ApplicationCommand
Services *services.Services Services *services.Services
MusicRepo *musicrepo.Repo
DBHost string
LavalinkHost string LavalinkHost string
LavalinkPass string LavalinkPass string
} }
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) { func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
session, err := discordgo.New("Bot " + token) session, err := discordgo.New("Bot " + token)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -32,14 +36,13 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
// - MessageCreate (leveling): GuildMessages // - MessageCreate (leveling): GuildMessages
// - GuildMemberAdd (welcome messages): GuildMembers // - GuildMemberAdd (welcome messages): GuildMembers
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates // - VoiceStateUpdate (meeting lobby): GuildVoiceStates
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
session.Identify.Intents = discordgo.IntentsGuilds | session.Identify.Intents = discordgo.IntentsGuilds |
discordgo.IntentsGuildBans |
discordgo.IntentsGuildMembers | discordgo.IntentsGuildMembers |
discordgo.IntentsGuildMessages | discordgo.IntentsGuildMessages |
discordgo.IntentsMessageContent |
discordgo.IntentsGuildVoiceStates | discordgo.IntentsGuildVoiceStates |
discordgo.IntentsGuildMessageReactions | discordgo.IntentsDirectMessages
discordgo.IntentsDirectMessages |
discordgo.IntentsDirectMessageReactions
return &Bot{ return &Bot{
Session: session, Session: session,
@@ -47,6 +50,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
GuildID: guildID, GuildID: guildID,
Commands: cmds, Commands: cmds,
Services: services, Services: services,
MusicRepo: musicRepo,
DBHost: dbHost,
LavalinkHost: lavalinkHost, LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass, LavalinkPass: lavalinkPass,
}, nil }, nil
@@ -57,7 +62,13 @@ func (b *Bot) Start() error {
return err return err
} }
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass) _ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass)
go func() {
if err := music.StartCommandListener(context.Background(), b.DBHost, b.MusicRepo); err != nil {
log.Printf("music: command listener stopped: %v", err)
}
}()
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands)) b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
for _, cmd := range b.Commands { for _, cmd := range b.Commands {
@@ -76,7 +87,39 @@ func (b *Bot) Start() error {
}) })
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) { b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
events.HandleBotAddedOnGuild(s, m, b.Services)
// 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.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) { b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
@@ -88,10 +131,6 @@ func (b *Bot) Start() error {
music.OnVoiceServerUpdate(ev) 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.StartScheduleReminderLoop(b.Session, b.Services)
events.StartTwitchLiveLoop(b.Session, b.Services) events.StartTwitchLiveLoop(b.Session, b.Services)
+21 -1
View File
@@ -1,11 +1,13 @@
package commands package commands
import ( import (
"strings"
"velox-bot/internal/commands/config" "velox-bot/internal/commands/config"
"velox-bot/internal/commands/fun" "velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help" "velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level" cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting" "velox-bot/internal/commands/meeting"
"velox-bot/internal/commands/moderation"
cmdmusic "velox-bot/internal/commands/music" cmdmusic "velox-bot/internal/commands/music"
"velox-bot/internal/commands/projects" "velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule" "velox-bot/internal/commands/schedule"
@@ -31,7 +33,9 @@ var AllCommands = []*discordgo.ApplicationCommand{
cmdmusic.Play, cmdmusic.Play,
cmdmusic.Queue, cmdmusic.Queue,
cmdmusic.Volume, cmdmusic.Volume,
cmdmusic.Music,
meeting.Meeting, meeting.Meeting,
moderation.Moderation,
timezone.Timezone, timezone.Timezone,
schedule.Schedule, schedule.Schedule,
projects.Projects, projects.Projects,
@@ -52,12 +56,14 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"leaderboard": cmdlevel.LeaderboardHandler, "leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler, "rewards": cmdlevel.RewardsHandler,
"meeting": meeting.MeetingHandler, "meeting": meeting.MeetingHandler,
"moderation": moderation.ModerationHandler,
"timezone": timezone.TimezoneHandler, "timezone": timezone.TimezoneHandler,
"schedule": schedule.ScheduleHandler, "schedule": schedule.ScheduleHandler,
"projects": projects.ProjectsHandler, "projects": projects.ProjectsHandler,
"play": cmdmusic.PlayHandler, "play": cmdmusic.PlayHandler,
"queue": cmdmusic.QueueHandler, "queue": cmdmusic.QueueHandler,
"volume": cmdmusic.VolumeHandler, "volume": cmdmusic.VolumeHandler,
"music": cmdmusic.MusicHandler,
"config": config.ConfigHandler, "config": config.ConfigHandler,
} }
@@ -69,8 +75,22 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
case discordgo.InteractionMessageComponent: case discordgo.InteractionMessageComponent:
data := i.MessageComponentData() data := i.MessageComponentData()
if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" { switch {
case strings.HasPrefix(data.CustomID, "music:"):
music.HandleComponent(s, i) music.HandleComponent(s, i)
case strings.HasPrefix(data.CustomID, "schedule:"):
schedule.HandleComponent(s, i)
case strings.HasPrefix(data.CustomID, "projects:"):
projects.HandleComponent(s, i)
}
case discordgo.InteractionModalSubmit:
data := i.ModalSubmitData()
switch {
case strings.HasPrefix(data.CustomID, "schedule:"):
schedule.HandleModalSubmit(s, i)
case strings.HasPrefix(data.CustomID, "projects:"):
projects.HandleModalSubmit(s, i)
} }
} }
} }
+106
View File
@@ -121,6 +121,30 @@ var Config = &discordgo.ApplicationCommand{
}, },
}, },
}, },
{
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",
},
}, },
} }
@@ -169,6 +193,15 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
case "setdefaultrole": case "setdefaultrole":
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID) log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetDefaultRole(s, i) 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: default:
respondEphemeral(s, i, "Unknown subcommand.") respondEphemeral(s, i, "Unknown subcommand.")
} }
@@ -460,6 +493,79 @@ func handleSetDefaultRole(s *discordgo.Session, i *discordgo.InteractionCreate)
respondEphemeral(s, i, "Default role set to "+role.Mention()+".") 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) { func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
+18
View File
@@ -94,6 +94,17 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/meeting invite user:@someone` — Allow a user to join\n" + "`/meeting invite user:@someone` — Allow a user to join\n" +
"`/meeting uninvite user:@someone` — Remove a user's permission", "`/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", Name: "Leveling",
Value: "" + Value: "" +
@@ -122,6 +133,13 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" + "`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
"`/config setdefaultrole role:@role` — Role granted to new members", "`/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", Name: "Other",
Value: "`/help` — Show this help in DMs", Value: "`/help` — Show this help in DMs",
@@ -0,0 +1,578 @@
package public
import (
"fmt"
"strings"
"time"
"velox-bot/internal/db/services"
"velox-bot/internal/modlog"
"github.com/bwmarrin/discordgo"
)
var Moderation = &discordgo.ApplicationCommand{
Name: "moderation",
Description: "High-value moderation tools",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "purge",
Description: "Delete recent messages in this channel (max 100)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "amount",
Description: "How many recent messages to delete (1-100)",
Required: true,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "timeout",
Description: "Temporarily timeout a member",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to timeout",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "duration",
Description: "Duration like 10m, 1h, 2h30m (max 28d)",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "kick",
Description: "Kick a member from the server",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to kick",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "ban",
Description: "Ban a member from the server",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to ban",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "delete_days",
Description: "Delete message history (0-7 days)",
Required: false,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "unban",
Description: "Unban a user by ID",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "user_id",
Description: "User ID to unban",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "untimeout",
Description: "Remove timeout from a member",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to untimeout",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionString,
Name: "reason",
Description: "Optional reason",
Required: false,
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "slowmode",
Description: "Set channel slowmode (0 disables)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel",
Description: "Text channel to update",
Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "seconds",
Description: "Slowmode delay in seconds (0-21600)",
Required: true,
},
},
},
},
}
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.GuildID == "" {
respondEphemeral(s, i, "This command can only be used in a server.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
respondEphemeral(s, i, "Missing subcommand.")
return
}
switch data.Options[0].Name {
case "purge":
handlePurge(s, i)
case "timeout":
handleTimeout(s, i)
case "kick":
handleKick(s, i)
case "ban":
handleBan(s, i)
case "unban":
handleUnban(s, i)
case "untimeout":
handleUntimeout(s, i)
case "slowmode":
handleSlowmode(s, i)
default:
respondEphemeral(s, i, "Unknown subcommand.")
}
}
func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionManageMessages) {
respondEphemeral(s, i, "You need **Manage Messages** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var amount int64
for _, o := range opt.Options {
if o.Name == "amount" {
amount = o.IntValue()
break
}
}
if amount < 1 || amount > 100 {
respondEphemeral(s, i, "Amount must be between 1 and 100.")
return
}
msgs, err := s.ChannelMessages(i.ChannelID, int(amount), "", "", "")
if err != nil {
respondEphemeral(s, i, "Failed to fetch messages.")
return
}
if len(msgs) == 0 {
respondEphemeral(s, i, "No messages found to delete.")
return
}
tooOldCutoff := time.Now().Add(-14 * 24 * time.Hour)
ids := make([]string, 0, len(msgs))
for _, m := range msgs {
if m == nil {
continue
}
if m.Timestamp.Before(tooOldCutoff) {
continue
}
ids = append(ids, m.ID)
}
if len(ids) == 0 {
respondEphemeral(s, i, "No messages can be bulk deleted (older than 14 days).")
return
}
if len(ids) == 1 {
if err := s.ChannelMessageDelete(i.ChannelID, ids[0]); err != nil {
respondEphemeral(s, i, "Failed to delete message.")
return
}
respondEphemeral(s, i, "Deleted 1 message.")
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted 1 message in <#%s>.", i.ChannelID), "")
return
}
if err := s.ChannelMessagesBulkDelete(i.ChannelID, ids); err != nil {
respondEphemeral(s, i, "Failed to bulk delete messages.")
return
}
respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids)))
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted %d messages in <#%s>.", len(ids), i.ChannelID), "")
}
func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var durationInput string
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "duration":
durationInput = strings.TrimSpace(o.StringValue())
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
dur, err := time.ParseDuration(durationInput)
if err != nil || dur <= 0 {
respondEphemeral(s, i, "Invalid duration. Examples: `10m`, `1h`, `2h30m`.")
return
}
maxTimeout := 28 * 24 * time.Hour
if dur > maxTimeout {
respondEphemeral(s, i, "Duration cannot exceed 28 days.")
return
}
until := time.Now().UTC().Add(dur)
if err := s.GuildMemberTimeout(i.GuildID, target.ID, &until); err != nil {
respondEphemeral(s, i, "Failed to timeout user. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String()))
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason))
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), reason)
}
func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionKickMembers) {
respondEphemeral(s, i, "You need **Kick Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID {
respondEphemeral(s, i, "You cannot kick yourself.")
return
}
if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID {
respondEphemeral(s, i, "I can't kick myself.")
return
}
if err := s.GuildMemberDeleteWithReason(i.GuildID, target.ID, reason); err != nil {
respondEphemeral(s, i, "Failed to kick user. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, "Kicked "+target.Mention()+".")
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), "")
return
}
respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason)
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), reason)
}
func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var deleteDays int64
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "delete_days":
deleteDays = o.IntValue()
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
if deleteDays < 0 || deleteDays > 7 {
respondEphemeral(s, i, "delete_days must be between 0 and 7.")
return
}
if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID {
respondEphemeral(s, i, "You cannot ban yourself.")
return
}
if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID {
respondEphemeral(s, i, "I can't ban myself.")
return
}
if err := s.GuildBanCreateWithReason(i.GuildID, target.ID, reason, int(deleteDays)); err != nil {
respondEphemeral(s, i, "Failed to ban user. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays))
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason))
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), reason)
}
func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userID string
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user_id":
userID = strings.TrimSpace(o.StringValue())
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userID == "" {
respondEphemeral(s, i, "Missing user ID.")
return
}
if err := s.GuildBanDelete(i.GuildID, userID); err != nil {
respondEphemeral(s, i, "Failed to unban user. Make sure the user ID is valid and banned.")
return
}
if reason == "" {
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.")
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", "")
return
}
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason)
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", reason)
}
func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var userOpt *discordgo.ApplicationCommandInteractionDataOption
var reason string
for _, o := range opt.Options {
switch o.Name {
case "user":
userOpt = o
case "reason":
reason = strings.TrimSpace(o.StringValue())
}
}
if userOpt == nil {
respondEphemeral(s, i, "Missing user option.")
return
}
target := userOpt.UserValue(s)
if target == nil {
respondEphemeral(s, i, "Invalid user.")
return
}
if err := s.GuildMemberTimeout(i.GuildID, target.ID, nil); err != nil {
respondEphemeral(s, i, "Failed to remove timeout. Check role hierarchy and permissions.")
return
}
if reason == "" {
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".")
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), "")
return
}
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason)
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), reason)
}
func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionManageChannels) {
respondEphemeral(s, i, "You need **Manage Channels** for this command.")
return
}
opt := i.ApplicationCommandData().Options[0]
var chOpt *discordgo.ApplicationCommandInteractionDataOption
var seconds int64
for _, o := range opt.Options {
switch o.Name {
case "channel":
chOpt = o
case "seconds":
seconds = o.IntValue()
}
}
if chOpt == nil {
respondEphemeral(s, i, "Missing channel option.")
return
}
if seconds < 0 || seconds > 21600 {
respondEphemeral(s, i, "Seconds must be between 0 and 21600.")
return
}
ch := chOpt.ChannelValue(s)
if ch == nil {
respondEphemeral(s, i, "Invalid channel.")
return
}
secondsInt := int(seconds)
_, err := s.ChannelEditComplex(ch.ID, &discordgo.ChannelEdit{
RateLimitPerUser: &secondsInt,
})
if err != nil {
respondEphemeral(s, i, "Failed to update slowmode.")
return
}
if seconds == 0 {
respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".")
sendModerationActionLog(s, i, "Slowmode", "Channel: "+ch.Mention()+"\nSet to: `0s`", "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds))
sendModerationActionLog(s, i, "Slowmode", fmt.Sprintf("Channel: %s\nSet to: `%ds`", ch.Mention(), seconds), "")
}
func hasPerm(i *discordgo.InteractionCreate, perm int64) bool {
return i.Member != nil && (i.Member.Permissions&perm) == perm
}
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func sendModerationActionLog(s *discordgo.Session, i *discordgo.InteractionCreate, action, details, reason string) {
if services.Global == nil {
return
}
moderator := "Unknown"
if i != nil && i.Member != nil && i.Member.User != nil {
moderator = i.Member.User.Mention()
}
desc := "Moderator: " + moderator + "\n" + details
if strings.TrimSpace(reason) != "" {
desc += "\nReason: " + reason
}
embed := &discordgo.MessageEmbed{
Title: "Moderation Action: " + action,
Color: 0x5865F2,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
_ = modlog.Send(s, services.Global, i.GuildID, embed)
}
+16
View File
@@ -0,0 +1,16 @@
package moderation
import (
"velox-bot/internal/commands/moderation/public"
"github.com/bwmarrin/discordgo"
)
var (
Moderation *discordgo.ApplicationCommand = public.Moderation
)
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.ModerationHandler(s, i)
}
+6 -41
View File
@@ -2,8 +2,7 @@ package public
import ( import (
"fmt" "fmt"
"net/url" "velox-bot/internal/commands/music/shared"
"strings"
"velox-bot/internal/music" "velox-bot/internal/music"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@@ -23,6 +22,10 @@ var Play = &discordgo.ApplicationCommand{
} }
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireMusicEnabled(s, i) {
return
}
data := i.ApplicationCommandData() data := i.ApplicationCommandData()
if len(data.Options) == 0 { if len(data.Options) == 0 {
return return
@@ -44,13 +47,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return 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) vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
if err != nil { if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
@@ -59,7 +55,7 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username) entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username, i.Member.User.ID)
if err != nil { if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Error: %v", err)), Content: ptr(fmt.Sprintf("Error: %v", err)),
@@ -85,37 +81,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
func ptr[T any](v T) *T { return new(v) } 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) { func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
g, err := s.State.Guild(guildID) g, err := s.State.Guild(guildID)
if err != nil { if err != nil {
+4 -1
View File
@@ -3,6 +3,7 @@ package public
import ( import (
"fmt" "fmt"
"strings" "strings"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music" "velox-bot/internal/music"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@@ -14,6 +15,9 @@ var Queue = &discordgo.ApplicationCommand{
} }
func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireMusicEnabled(s, i) {
return
}
if !memberHasDJRole(s, i.GuildID, i.Member) { if !memberHasDJRole(s, i.GuildID, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -74,4 +78,3 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
}, },
}) })
} }
+63
View File
@@ -0,0 +1,63 @@
package public
import (
"context"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
var Music = &discordgo.ApplicationCommand{
Name: "music",
Description: "Music settings",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "toggle",
Description: "Turn the music player on or off for this server",
},
},
}
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.GuildID == "" {
shared.RespondEphemeral(s, i, "This command can only be used in a server.")
return
}
if !shared.RequireManageGuild(s, i) {
return
}
if !shared.RequireMusicService(s, i) {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
shared.RespondEphemeral(s, i, "Missing subcommand.")
return
}
switch data.Options[0].Name {
case "toggle":
handleToggle(s, i)
}
}
func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
enabled, err := services.Global.MusicSettings.ToggleMusic(context.Background(), guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to toggle music playback.")
return
}
status := "disabled"
if enabled {
status = "enabled"
}
shared.RespondEphemeral(s, i, "Music playback "+status+" for this server.")
}
+4 -1
View File
@@ -2,6 +2,7 @@ package public
import ( import (
"fmt" "fmt"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music" "velox-bot/internal/music"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@@ -23,6 +24,9 @@ var Volume = &discordgo.ApplicationCommand{
} }
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireMusicEnabled(s, i) {
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{
@@ -60,4 +64,3 @@ func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
func ptrFloat(v float64) *float64 { return &v } func ptrFloat(v float64) *float64 { return &v }
+4 -3
View File
@@ -7,9 +7,10 @@ import (
) )
var ( var (
Play *discordgo.ApplicationCommand = public.Play Play *discordgo.ApplicationCommand = public.Play
Queue *discordgo.ApplicationCommand = public.Queue Queue *discordgo.ApplicationCommand = public.Queue
Volume *discordgo.ApplicationCommand = public.Volume Volume *discordgo.ApplicationCommand = public.Volume
Music *discordgo.ApplicationCommand = public.Music
) )
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) } func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) }
@@ -17,4 +18,4 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.QueueHandler(s, i) public.QueueHandler(s, i)
} }
func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) } func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) }
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MusicHandler(s, i) }
+61
View File
@@ -0,0 +1,61 @@
package shared
import (
"context"
"strconv"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func RequireMusicEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
guildID, ok := ParseGuildID(s, i)
if !ok {
return false
}
enabled, err := services.Global.MusicSettings.IsMusicEnabled(context.Background(), guildID)
if err != nil {
RespondEphemeral(s, i, "Failed to load music settings.")
return false
}
if !enabled {
RespondEphemeral(s, i, "Music is disabled on this server. Use /music toggle to enable it.")
return false
}
return true
}
func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 {
RespondEphemeral(s, i, "You need the **Manage Server** permission to use this.")
return false
}
return true
}
func RequireMusicService(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if services.Global == nil || services.Global.MusicSettings == nil {
RespondEphemeral(s, i, "Music service is not available.")
return false
}
return true
}
func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) {
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
RespondEphemeral(s, i, "Invalid guild ID.")
return 0, false
}
return guildID, true
}
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
@@ -0,0 +1,222 @@
package public
import (
"context"
"strconv"
"strings"
"velox-bot/internal/commands/projects/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleComponent routes the select menus shown after /projects add-helper
// and /projects close.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.Split(i.MessageComponentData().CustomID, ":")
if len(parts) < 2 {
return
}
switch parts[1] {
case "add_helper_project_select":
handleAddHelperProjectSelect(s, i)
case "add_helper_user_select":
handleAddHelperUserSelect(s, i, parts)
case "close_select":
handleCloseSelect(s, i)
}
}
// handleAddHelperProjectSelect is step 1: a project was picked, now show a
// native Discord user-select to pick who helps - the actual AddHelper call
// happens in handleAddHelperUserSelect once both are known.
func handleAddHelperProjectSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
values := i.MessageComponentData().Values
if len(values) == 0 {
return
}
projectID := values[0]
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Who should help with this project?",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
MenuType: discordgo.UserSelectMenu,
CustomID: "projects:add_helper_user_select:" + projectID,
Placeholder: "Select a user",
},
},
},
},
},
})
}
// handleAddHelperUserSelect is step 2: a user was picked from the native
// select menu, so both the project (carried in the CustomID) and the user
// (the selected value) are now known - perform the actual AddHelper call.
func handleAddHelperUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate, parts []string) {
if !shared.RequireManageGuild(s, i) {
return
}
if len(parts) < 3 {
return
}
projectID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
values := i.MessageComponentData().Values
if len(values) == 0 {
return
}
userID := values[0]
userID64, err := strconv.ParseInt(userID, 10, 64)
if err != nil {
return
}
ctx := context.Background()
project, err := services.Global.Projects.GetProject(ctx, guildID, projectID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load project.")
return
}
if project == nil {
shared.RespondEphemeral(s, i, "That project no longer exists in this server.")
return
}
if err := services.Global.Projects.AddHelper(ctx, projectID, userID64); err != nil {
shared.RespondEphemeral(s, i, "Failed to add helper to project.")
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Added <@" + userID + "> as helper to project **" + project.Name + "**.",
Components: []discordgo.MessageComponent{},
},
})
}
func handleCloseSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
values := i.MessageComponentData().Values
if len(values) == 0 {
return
}
projectID, err := strconv.ParseInt(values[0], 10, 64)
if err != nil {
return
}
ctx := context.Background()
project, err := services.Global.Projects.GetProject(ctx, guildID, projectID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load project.")
return
}
if project == nil {
shared.RespondEphemeral(s, i, "That project no longer exists in this server.")
return
}
if err := services.Global.Projects.DeactivateProject(ctx, guildID, projectID); err != nil {
shared.RespondEphemeral(s, i, "Failed to close project.")
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Closed project **" + project.Name + "**.",
Components: []discordgo.MessageComponent{},
},
})
}
// HandleModalSubmit handles the /projects create modal.
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.Split(i.ModalSubmitData().CustomID, ":")
if len(parts) < 2 || parts[1] != "create_modal" {
return
}
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
if i.Member == nil || i.Member.User == nil {
shared.RespondEphemeral(s, i, "Missing member info.")
return
}
values := modalTextInputValues(i.ModalSubmitData().Components)
name := strings.TrimSpace(values["name"])
description := strings.TrimSpace(values["description"])
if name == "" {
shared.RespondEphemeral(s, i, "Project name cannot be empty.")
return
}
creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64)
if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.")
return
}
id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to create project.")
return
}
shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.")
}
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
values := make(map[string]string)
for _, c := range components {
row, ok := c.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, rc := range row.Components {
ti, ok := rc.(*discordgo.TextInput)
if !ok {
continue
}
values[ti.CustomID] = ti.Value
}
}
return values
}
+146 -115
View File
@@ -24,39 +24,16 @@ var Projects = &discordgo.ApplicationCommand{
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "create", Name: "create",
Description: "Create a new active project", 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, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add-helper", Name: "add-helper",
Description: "Add a helper to an existing project", Description: "Add a helper to an existing project",
Options: []*discordgo.ApplicationCommandOption{ },
{ {
Type: discordgo.ApplicationCommandOptionInteger, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "project-id", Name: "close",
Description: "ID of the project", Description: "Close (deactivate) a project",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "User to add as helper",
Required: true,
},
},
}, },
}, },
} }
@@ -79,6 +56,8 @@ func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleCreate(s, i) handleCreate(s, i)
case "add-helper": case "add-helper":
handleAddHelper(s, i) handleAddHelper(s, i)
case "close":
handleClose(s, i)
default: default:
shared.RespondEphemeral(s, i, "Unknown subcommand.") shared.RespondEphemeral(s, i, "Unknown subcommand.")
} }
@@ -154,56 +133,53 @@ func mentionUser(id int64) string {
return "<@" + strconv.FormatInt(id, 10) + ">" return "<@" + strconv.FormatInt(id, 10) + ">"
} }
// handleCreate just opens the modal - the actual creation happens on
// submit, in HandleModalSubmit (components.go).
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) { if !shared.RequireManageGuild(s, i) {
return return
} }
guildID, ok := shared.ParseGuildID(s, i) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
if !ok { Type: discordgo.InteractionResponseModal,
return Data: &discordgo.InteractionResponseData{
} CustomID: "projects:create_modal",
if i.Member == nil || i.Member.User == nil { Title: "Create a project",
shared.RespondEphemeral(s, i, "Missing member info.") Components: []discordgo.MessageComponent{
return discordgo.ActionsRow{
} Components: []discordgo.MessageComponent{
discordgo.TextInput{
data := i.ApplicationCommandData() CustomID: "name",
if len(data.Options) == 0 { Label: "Project name",
shared.RespondEphemeral(s, i, "Missing options.") Style: discordgo.TextInputShort,
return Required: true,
} },
opt := data.Options[0] },
},
var name, description string discordgo.ActionsRow{
for _, o := range opt.Options { Components: []discordgo.MessageComponent{
switch o.Name { discordgo.TextInput{
case "name": CustomID: "description",
name = o.StringValue() Label: "Description (optional)",
case "description": Style: discordgo.TextInputParagraph,
description = o.StringValue() Required: false,
} },
} },
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)+"`.")
} }
// handleAddHelper collects the user via Discord's native user-option (a
// modal can't hold one of these), then responds with a select menu of the
// guild's active projects instead of making the caller remember/type a
// numeric project ID - the actual add-helper call happens on selection, in
// HandleComponent (components.go).
// handleAddHelper is the first of two dropdown steps: pick a project here,
// then HandleComponent (components.go) follows up with a native Discord
// user-select to pick who helps, and performs the actual AddHelper call
// once both are known.
func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) { if !shared.RequireManageGuild(s, i) {
return return
@@ -214,55 +190,110 @@ func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
data := i.ApplicationCommandData() options, err := activeProjectOptions(context.Background(), guildID)
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 { if err != nil {
shared.RespondEphemeral(s, i, "Failed to load project.") shared.RespondEphemeral(s, i, "Failed to load projects.")
return return
} }
if !exists { if len(options) == 0 {
shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.") shared.RespondEphemeral(s, i, "There are no active projects to add a helper to.")
return return
} }
userID64, err := strconv.ParseInt(userID, 10, 64) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
if err != nil { Type: discordgo.InteractionResponseChannelMessageWithSource,
shared.RespondEphemeral(s, i, "Invalid user ID.") Data: &discordgo.InteractionResponseData{
return Content: "Which project should get a new helper?",
} Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil { discordgo.ActionsRow{
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")") Components: []discordgo.MessageComponent{
return discordgo.SelectMenu{
} CustomID: "projects:add_helper_project_select",
Placeholder: "Select a project",
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.") Options: options,
},
},
},
},
},
})
} }
// handleClose responds with a select menu of the guild's active projects -
// same reasoning as handleAddHelper, picking from a live list beats typing
// an ID. The actual deactivation happens on selection, in HandleComponent.
func handleClose(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
options, err := activeProjectOptions(context.Background(), guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load projects.")
return
}
if len(options) == 0 {
shared.RespondEphemeral(s, i, "There are no active projects to close.")
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Which project should be closed?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "projects:close_select",
Placeholder: "Select a project",
Options: options,
},
},
},
},
},
})
}
// activeProjectOptions builds select-menu options for the guild's active
// projects, truncating name/description to Discord's option limits
// (label: 100 chars, description: 100 chars).
func activeProjectOptions(ctx context.Context, guildID int64) ([]discordgo.SelectMenuOption, error) {
const limit = 25 // Discord's own cap on select menu options
items, err := services.Global.Projects.ListActive(ctx, guildID, limit)
if err != nil {
return nil, err
}
options := make([]discordgo.SelectMenuOption, 0, len(items))
for _, p := range items {
if p == nil {
continue
}
name := p.Name
if name == "" {
name = "Unnamed project"
}
options = append(options, discordgo.SelectMenuOption{
Label: truncate(name, 100),
Value: strconv.FormatInt(p.ID, 10),
Description: truncate(p.Description, 100),
})
}
return options, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
+6
View File
@@ -12,3 +12,9 @@ var (
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) } func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HandleComponent(s, i) }
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleModalSubmit(s, i)
}
@@ -0,0 +1,794 @@
package public
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/commands/schedule/shared"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func scheduleActionButtons(scheduleID int64) []discordgo.MessageComponent {
idStr := strconv.FormatInt(scheduleID, 10)
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Style: discordgo.SuccessButton,
CustomID: "schedule:accept:" + idStr,
},
discordgo.Button{
Label: "Decline",
Style: discordgo.DangerButton,
CustomID: "schedule:decline:" + idStr,
},
discordgo.Button{
Label: "Reschedule",
Style: discordgo.SecondaryButton,
CustomID: "schedule:reschedule:" + idStr,
},
},
},
}
}
func rescheduleProposalButtons(scheduleID int64) []discordgo.MessageComponent {
idStr := strconv.FormatInt(scheduleID, 10)
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Style: discordgo.SuccessButton,
CustomID: "schedule:accept_reschedule:" + idStr,
},
discordgo.Button{
Label: "Decline",
Style: discordgo.DangerButton,
CustomID: "schedule:decline_reschedule:" + idStr,
},
discordgo.Button{
Label: "Propose different time",
Style: discordgo.SecondaryButton,
CustomID: "schedule:reschedule:" + idStr,
},
},
},
}
}
const createUserSelectCustomID = "schedule:create_user_select"
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.MessageComponentData().CustomID == createUserSelectCustomID {
handleCreateUserSelect(s, i)
return
}
ctx := context.Background()
action, scheduleID := parseComponentCustomID(i.MessageComponentData().CustomID)
userIDStr := interactionUserID(i)
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error parsing user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
if err != nil || sch == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error retrieving schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
var status schedulerepo.ScheduleStatus
var message string
switch action {
case "accept":
if userID != sch.InviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule is no longer pending.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
status = schedulerepo.StatusAccepted
message = "✅ Accepted"
case "decline":
if userID != sch.InviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule is no longer pending.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
status = schedulerepo.StatusDeclined
message = "❌ Declined"
case "reschedule":
if userID != sch.InviteeID && userID != sch.RequesterID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule can't be rescheduled right now.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "schedule:reschedule_modal:" + strconv.FormatInt(scheduleID, 10),
Title: "Propose a new time",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "date",
Label: "Date (YYYY-MM-DD)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "2026-01-02",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "time",
Label: "Time (HH:MM, your timezone)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "15:04",
},
},
},
},
},
})
return
case "accept_reschedule":
if sch.ProposedBy == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule proposal to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusRescheduleRequested {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule request to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.AcceptReschedule(ctx, scheduleID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error accepting reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "✅ Reschedule accepted",
Components: []discordgo.MessageComponent{},
},
})
newTime := *sch.ProposedAt
utcStr := newTime.UTC().Format("2006-01-02 15:04")
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
if err != nil {
loc = time.UTC
zone = "UTC"
}
localStr := newTime.In(loc).Format("2006-01-02 15:04")
content := "Your proposed time for the session was accepted: " +
utcStr + " UTC (" + localStr + " " + zone + ")."
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM proposer about accepted reschedule: %v", err)
}
}
return
case "decline_reschedule":
if sch.ProposedBy == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule proposal to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusRescheduleRequested {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule request to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.DeclineReschedule(ctx, scheduleID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error declining reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "❌ Reschedule declined",
Components: []discordgo.MessageComponent{},
},
})
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
if err != nil {
loc = time.UTC
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your proposed new time was declined. The session remains scheduled for " +
utcStr + " UTC (" + localStr + " " + zone + ")."
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM proposer about declined reschedule: %v", err)
}
}
return
default:
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Unknown action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.UpdateStatus(ctx, scheduleID, status)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error updating schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: message,
Components: []discordgo.MessageComponent{},
},
})
statusText := map[schedulerepo.ScheduleStatus]string{
schedulerepo.StatusAccepted: "accepted ✅",
schedulerepo.StatusDeclined: "declined ❌",
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
}[status]
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
// Notify requester with local time if configured.
if services.Global.UserSettings != nil {
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
requesterID := strconv.FormatInt(sch.RequesterID, 10)
dmCh, err := s.UserChannelCreate(requesterID)
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM requester about status change: %v", err)
}
}
}
}
func handleCreateUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
values := i.MessageComponentData().Values
if len(values) == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "No user selected.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
targetUserID := values[0]
if targetUserID == interactionUserID(i) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You cannot schedule a session with yourself.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "schedule:create_modal:" + targetUserID,
Title: "Schedule a session",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "date",
Label: "Date (YYYY-MM-DD)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "2026-01-02",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "time",
Label: "Time (HH:MM, your timezone)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "15:04",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "description",
Label: "Description (optional)",
Style: discordgo.TextInputShort,
Required: false,
},
},
},
},
},
})
}
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
values := make(map[string]string)
for _, c := range components {
row, ok := c.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, rc := range row.Components {
ti, ok := rc.(*discordgo.TextInput)
if !ok {
continue
}
values[ti.CustomID] = ti.Value
}
}
return values
}
func handleCreateModalSubmit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, inviteeID int64) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
requesterID, err := strconv.ParseInt(interactionUserID(i), 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if requesterID == inviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You cannot schedule a session with yourself.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
values := modalTextInputValues(i.ModalSubmitData().Components)
dateStr := strings.TrimSpace(values["date"])
timeStr := strings.TrimSpace(values["time"])
desc := strings.TrimSpace(values["description"])
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, requesterID)
if err != nil {
loc = time.UTC
zone = "UTC"
}
when, err := shared.ParseScheduleDatetime(dateStr+" "+timeStr, loc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time, in your timezone (" + zone + ").",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
when = when.In(time.UTC)
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Failed to create schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
dmCh, err := s.UserChannelCreate(strconv.FormatInt(inviteeID, 10))
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session created, but failed to DM the invited user (are DMs disabled?).",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
embed := &discordgo.MessageEmbed{
Title: "New session request",
Description: fmt.Sprintf("You have been invited to a session by <@%d>.", requesterID),
Color: 0x4caf50,
}
utcStr := when.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(ctx, inviteeID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{Name: "When (UTC)", Value: utcStr},
{Name: "When (" + zoneInv + ")", Value: localInvStr},
}
if desc != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Description",
Value: desc,
})
}
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "Respond to this session request:",
Embed: embed,
Components: scheduleActionButtons(scheduleID),
})
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session created, but failed to DM the invited user.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session request created and sent to <@" + strconv.FormatInt(inviteeID, 10) + ">.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
action, idOrUserID := parseComponentCustomID(i.ModalSubmitData().CustomID)
if action == "create_modal" {
handleCreateModalSubmit(ctx, s, i, idOrUserID)
return
}
scheduleID := idOrUserID
if scheduleID == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid schedule ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
modalValues := modalTextInputValues(i.ModalSubmitData().Components)
datetimeInput := strings.TrimSpace(modalValues["date"]) + " " + strings.TrimSpace(modalValues["time"])
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
if err != nil || sch == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error retrieving schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
userIDStr := interactionUserID(i)
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error parsing user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if userID != sch.InviteeID && userID != sch.RequesterID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
loc, _, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
}
newTime, err := shared.ParseScheduleDatetime(datetimeInput, loc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
//Convert time to UTC before storing in the database
newTime = newTime.UTC()
err = services.Global.Schedule.ProposeReschedule(ctx, scheduleID, newTime, userID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error proposing reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
otherID := sch.RequesterID
if userID == sch.RequesterID {
otherID = sch.InviteeID
}
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
return
}
embed := &discordgo.MessageEmbed{
Title: "Reschedule Proposal",
Description: fmt.Sprintf("<@%d> has proposed a new time for your session request. Please respond.", userID),
Color: 0x4caf50,
}
// Show both UTC and invitee-local time if available.
utcStr := newTime.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), otherID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := newTime.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: utcStr,
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
},
}
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "A new time has been proposed for your session:",
Embed: embed,
Components: rescheduleProposalButtons(scheduleID),
})
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the other participant.")
return
}
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
}
func parseComponentCustomID(customID string) (string, int64) {
parts := strings.Split(customID, ":")
if len(parts) != 3 {
return "", 0
}
action := parts[1]
scheduleID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", 0
}
return action, scheduleID
}
func interactionUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
+69 -219
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"velox-bot/internal/commands/schedule/shared" "velox-bot/internal/commands/schedule/shared"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@@ -21,26 +22,6 @@ var Schedule = &discordgo.ApplicationCommand{
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "create", Name: "create",
Description: "Create a new session with a user", 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, Type: discordgo.ApplicationCommandOptionSubCommand,
@@ -93,8 +74,7 @@ func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
guildID, ok := shared.ParseGuildID(s, i) if _, ok := shared.ParseGuildID(s, i); !ok {
if !ok {
return return
} }
if i.Member == nil || i.Member.User == nil { if i.Member == nil || i.Member.User == nil {
@@ -102,133 +82,24 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
data := i.ApplicationCommandData() s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
if len(data.Options) == 0 { Type: discordgo.InteractionResponseChannelMessageWithSource,
shared.RespondEphemeral(s, i, "Missing options.") Data: &discordgo.InteractionResponseData{
return Content: "Who do you want to schedule a session with?",
} Flags: discordgo.MessageFlagsEphemeral,
opt := data.Options[0] Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
var ( Components: []discordgo.MessageComponent{
targetUser *discordgo.User discordgo.SelectMenu{
whenStr string MenuType: discordgo.UserSelectMenu,
desc string CustomID: createUserSelectCustomID,
) Placeholder: "Select a user",
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) { func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -338,6 +209,11 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
shared.RespondEphemeral(s, i, "This session can't be rescheduled right now.")
return
}
// Use rescheduler's timezone for parsing. // Use rescheduler's timezone for parsing.
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID) loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
if err != nil { if err != nil {
@@ -345,19 +221,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
zone = "UTC" zone = "UTC"
} }
// Expect explicit layout "2006-01-02 15:04" in user's timezone. // Expect explicit layout "2006-01-02 15:04" in user's timezone.
when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) when, err := shared.ParseScheduleDatetime(whenStr, loc)
if err != nil { if err != nil {
shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") shared.RespondEphemeral(s, i, err.Error()+" (use `2006-01-02 15:04` in your timezone: "+zone+")")
return return
} }
when = when.In(time.UTC) when = when.In(time.UTC)
if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil { if err := services.Global.Schedule.ProposeReschedule(ctx, sch.ID, when, userID); err != nil {
shared.RespondEphemeral(s, i, "Failed to reschedule session.") shared.RespondEphemeral(s, i, "Failed to propose reschedule.")
return return
} }
// Notify participants via DM and send new request DM to invitee.
whenFmt := when.Format("2006-01-02 15:04") whenFmt := when.Format("2006-01-02 15:04")
otherID := sch.InviteeID otherID := sch.InviteeID
@@ -365,75 +240,50 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
otherID = sch.RequesterID otherID = sch.RequesterID
} }
// DM both about the change, including their local times if available. dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
for _, uid := range []int64{sch.RequesterID, sch.InviteeID} { if err != nil {
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid) shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).")
if err != nil { return
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. embed := &discordgo.MessageEmbed{
inviteeStr := strconv.FormatInt(sch.InviteeID, 10) Title: "Reschedule proposed",
inviteeUser, err := s.User(inviteeStr) Description: fmt.Sprintf("<@%s> has proposed a new time for your session.", i.Member.User.ID),
if err == nil && inviteeUser != nil { Color: 0x4caf50,
dmCh, err := s.UserChannelCreate(inviteeStr) }
if err == nil { // Show both UTC and the other participant's local time if available.
embed := &discordgo.MessageEmbed{ locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID)
Title: "Rescheduled session", if errTZ != nil {
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), locOther = time.UTC
Color: 0xffc107, zoneOther = "UTC"
} }
// Show both UTC and invitee-local time if available. localOtherStr := when.In(locOther).Format("2006-01-02 15:04")
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID) embed.Fields = []*discordgo.MessageEmbedField{
if errTZ != nil { {
locInv = time.UTC Name: "When (UTC)",
zoneInv = "UTC" Value: whenFmt,
} },
localInvStr := when.In(locInv).Format("2006-01-02 15:04") {
embed.Fields = []*discordgo.MessageEmbedField{ Name: "When (" + zoneOther + ")",
{ Value: localOtherStr,
Name: "When (UTC)", },
Value: whenFmt, }
}, if sch.Description != "" {
{ embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "When (" + zoneInv + ")", Name: "Description",
Value: localInvStr, Value: sch.Description,
}, })
}
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.") _, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "A new time has been proposed for your session:",
Embed: embed,
Components: rescheduleProposalButtons(sch.ID),
})
if err != nil {
shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.")
return
}
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
} }
+9 -1
View File
@@ -10,5 +10,13 @@ var (
Schedule *discordgo.ApplicationCommand = public.Schedule Schedule *discordgo.ApplicationCommand = public.Schedule
) )
func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) } func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.ScheduleHandler(s, i)
}
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleComponent(s, i)
}
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleModalSubmit(s, i)
}
+18 -1
View File
@@ -1,13 +1,22 @@
package shared package shared
import ( import (
"fmt"
"strconv" "strconv"
"time"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
var scheduleDatetimeLayouts = []string{
"2006-01-02 15:04",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"01/02/2006 15:04",
}
func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
@@ -18,6 +27,15 @@ func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg
}) })
} }
func ParseScheduleDatetime(input string, loc *time.Location) (time.Time, error) {
for _, layout := range scheduleDatetimeLayouts {
if t, err := time.ParseInLocation(layout, input, loc); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("invalid schedule datetime format")
}
func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
if i.GuildID == "" { if i.GuildID == "" {
RespondEphemeral(s, i, "This command can only be used in a server.") RespondEphemeral(s, i, "This command can only be used in a server.")
@@ -42,4 +60,3 @@ func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64,
} }
return guildID, true return guildID, true
} }
+8 -12
View File
@@ -8,19 +8,18 @@ import (
) )
type Config struct { type Config struct {
BotToken string BotToken string
AppID string AppID string
GuildID string GuildID string
DBHost string DBHost string
LavalinkHost string LavalinkHost string
LavalinkPass string LavalinkPass string
TwitchClientID string TwitchClientID string
} }
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
if err := godotenv.Load(); err != nil { // Load .env if present (e.g. local dev). Ignore if missing (e.g. Docker injects env vars).
return nil, err _ = godotenv.Load()
}
token := os.Getenv("BOT_TOKEN") token := os.Getenv("BOT_TOKEN")
if token == "" { if token == "" {
@@ -33,9 +32,6 @@ func LoadConfig() (*Config, error) {
} }
guildID := os.Getenv("GUILD_ID") guildID := os.Getenv("GUILD_ID")
if guildID == "" {
return nil, fmt.Errorf("GUILD_ID is not set")
}
dbHost := os.Getenv("DB_HOST") dbHost := os.Getenv("DB_HOST")
if dbHost == "" { if dbHost == "" {
+144
View File
@@ -0,0 +1,144 @@
package musicrepo
import (
"context"
"database/sql"
"time"
)
type QueueEntry struct {
Title string
URL string
RequestedBy int64
// ClientID is the track's stable identity (set once by the manager
// when a track is first queued), used to upsert this row across
// syncs instead of assigning it a fresh DB id every single time.
ClientID string
}
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int, repeatSong, repeatQueue bool) error {
const q = `
INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume, repeat_song, repeat_queue)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (guild_id) DO UPDATE SET
track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5,
paused = $6, volume = $7, repeat_song = $8, repeat_queue = $9
`
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume, repeatSong, repeatQueue)
return err
}
func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
const q = `DELETE FROM music_now_playing WHERE guild_id = $1`
_, err := r.db.ExecContext(ctx, q, guildID)
return err
}
// ReplaceQueue syncs music_queue with the current in-memory queue by
// upserting each entry on its stable ClientID, rather than deleting and
// recreating every row on every call. That matters because music_queue.id
// is only an internal DB identity, remove/reorder commands target a
// track by ClientID, and that has to survive a track just sitting in the
// queue unchanged across syncs.
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
existing := make(map[string]bool)
rows, err := tx.QueryContext(ctx, `SELECT client_id FROM music_queue WHERE guild_id = $1`, guildID)
if err != nil {
return err
}
for rows.Next() {
var clientID string
if err := rows.Scan(&clientID); err != nil {
rows.Close()
return err
}
existing[clientID] = true
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
keep := make(map[string]bool, len(entries))
for i, entry := range entries {
keep[entry.ClientID] = true
const q = `
INSERT INTO music_queue (guild_id, position, track_title, track_url, requested_by, client_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (guild_id, client_id) DO UPDATE SET
position = EXCLUDED.position,
track_title = EXCLUDED.track_title,
track_url = EXCLUDED.track_url,
requested_by = EXCLUDED.requested_by
`
if _, err := tx.ExecContext(ctx, q, guildID, i, entry.Title, entry.URL, entry.RequestedBy, entry.ClientID); err != nil {
return err
}
}
for clientID := range existing {
if keep[clientID] {
continue
}
const q = `DELETE FROM music_queue WHERE guild_id = $1 AND client_id = $2`
if _, err := tx.ExecContext(ctx, q, guildID, clientID); err != nil {
return err
}
}
return tx.Commit()
}
type MusicCommand struct {
ID int64
GuildID int64
CommandType string
Payload []byte
RequestedBy int64
}
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]MusicCommand, error) {
const q = `SELECT id, guild_id, command_type, payload, requested_by FROM music_commands WHERE processed_at IS NULL ORDER BY id ASC`
rows, err := r.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var commands []MusicCommand
for rows.Next() {
var cmd MusicCommand
if err := rows.Scan(&cmd.ID, &cmd.GuildID, &cmd.CommandType, &cmd.Payload, &cmd.RequestedBy); err != nil {
return nil, err
}
commands = append(commands, cmd)
}
if err := rows.Err(); err != nil {
return nil, err
}
return commands, nil
}
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
const q = `UPDATE music_commands SET processed_at = NOW() WHERE id = $1`
_, err := r.db.ExecContext(ctx, q, id)
return err
}
+60
View File
@@ -61,6 +61,35 @@ func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error {
return err return err
} }
func (r *Repo) DeactivateProject(ctx context.Context, guildID, projectID int64) error {
const q = `
UPDATE projects
SET is_active = FALSE
WHERE id = $1 AND guild_id = $2
`
_, err := r.db.ExecContext(ctx, q, projectID, guildID)
return err
}
// GetProject fetches a single project, scoped to guildID so a project ID
// from one server can't be used to reach into another's data.
func (r *Repo) GetProject(ctx context.Context, guildID, projectID int64) (*Project, error) {
const q = `
SELECT id, name, description, created_by
FROM projects
WHERE id = $1 AND guild_id = $2
`
row := r.db.QueryRowContext(ctx, q, projectID, guildID)
p := &Project{GuildID: guildID, IsActive: true}
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return p, nil
}
func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) { func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
const q = ` const q = `
SELECT 1 SELECT 1
@@ -79,6 +108,37 @@ func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int6
} }
} }
// ListActiveProjects is a lighter version of ListActiveProjectsWithMembers,
// without the member-join query - for callers (like building a select menu
// of projects to pick from) that only need name/id, not creator/helpers.
func (r *Repo) ListActiveProjects(ctx context.Context, guildID int64, limit int) ([]*Project, error) {
const q = `
SELECT id, name, description, created_by
FROM projects
WHERE guild_id = $1 AND is_active = TRUE
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.QueryContext(ctx, q, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Project
for rows.Next() {
p := &Project{GuildID: guildID, IsActive: true}
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil {
return nil, err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) { func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) {
const qProjects = ` const qProjects = `
SELECT id, name, description, created_by SELECT id, name, description, created_by
+53 -40
View File
@@ -20,15 +20,18 @@ const (
) )
type Schedule struct { type Schedule struct {
ID int64 ID int64
GuildID int64 GuildID int64
RequesterID int64 RequesterID int64
InviteeID int64 InviteeID int64
ScheduledAt time.Time ScheduledAt time.Time
Status ScheduleStatus ProposedAt *time.Time
Description string ProposedBy *int64
ReminderSent bool PreRescheduleStatus *ScheduleStatus
CreatedAt time.Time Status ScheduleStatus
Description string
ReminderSent bool
CreatedAt time.Time
} }
func NewRepo(db *sql.DB) *Repo { func NewRepo(db *sql.DB) *Repo {
@@ -48,35 +51,6 @@ func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, invitee
return id, nil 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 { func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error {
const q = ` const q = `
UPDATE schedules UPDATE schedules
@@ -119,13 +93,13 @@ func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int
func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) { func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) {
const q = ` const q = `
SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at SELECT id, guild_id, requester_id, invitee_id, scheduled_at, proposed_at, proposed_by, pre_reschedule_status, status, description, reminder_sent, created_at
FROM schedules FROM schedules
WHERE id = $1 WHERE id = $1
` `
row := r.db.QueryRowContext(ctx, q, id) row := r.db.QueryRowContext(ctx, q, id)
var sch Schedule var sch Schedule
if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.ProposedAt, &sch.ProposedBy, &sch.PreRescheduleStatus, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -186,4 +160,43 @@ func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error {
return err return err
} }
func (r *Repo) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
const q = `
UPDATE schedules
SET proposed_at = $1,
proposed_by = $2,
pre_reschedule_status = status,
status = 'reschedule_requested'
WHERE id = $3
`
_, err := r.db.ExecContext(ctx, q, newTime, proposedBy, scheduleID)
return err
}
func (r *Repo) AcceptReschedule(ctx context.Context, scheduleID int64) error {
const q = `
UPDATE schedules
SET scheduled_at = proposed_at,
proposed_at = NULL,
proposed_by = NULL,
pre_reschedule_status = NULL,
status = 'accepted',
reminder_sent = FALSE
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, q, scheduleID)
return err
}
func (r *Repo) DeclineReschedule(ctx context.Context, scheduleID int64) error {
const q = `
UPDATE schedules
SET proposed_at = NULL,
proposed_by = NULL,
status = pre_reschedule_status,
pre_reschedule_status = NULL
WHERE id = $1
`
_, err := r.db.ExecContext(ctx, q, scheduleID)
return err
}
+99
View File
@@ -15,6 +15,12 @@ type LevelReward struct {
RoleID int64 RoleID int64
} }
type LogSettings struct {
GuildID int64
ChannelID int64
IsEnabled bool
}
func NewRepo(db *sql.DB) *Repo { func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db} return &Repo{db: db}
} }
@@ -240,6 +246,80 @@ func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message str
return err 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) { func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
const q = ` const q = `
SELECT message SELECT message
@@ -260,3 +340,22 @@ func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, er
return "", err return "", err
} }
} }
func (r *Repo) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) {
const q = `SELECT is_enabled FROM music_settings WHERE guild_id = $1`
var enabled bool
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return enabled, nil
}
func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `INSERT INTO music_settings (guild_id, is_enabled) VALUES ($1, $2) ON CONFLICT (guild_id) DO UPDATE SET is_enabled = $2`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
+28
View File
@@ -113,3 +113,31 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in
return err return err
} }
// IsEnabled reports whether Twitch notifications are enabled for guildID.
// A guild with no twitch_config row yet defaults to enabled, matching the
// column's own DEFAULT TRUE - "never configured" and "explicitly on" are
// the same state until someone actually flips it off.
func (r *Repo) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
const q = `SELECT enabled FROM twitch_config WHERE guild_id = $1`
var enabled bool
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return enabled, nil
}
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `
INSERT INTO twitch_config (guild_id, enabled)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET enabled = EXCLUDED.enabled
`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
@@ -0,0 +1,32 @@
package logsettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error {
return s.settings.SetLogChannel(ctx, guildID, channelID)
}
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
return s.settings.SetLogEnabled(ctx, guildID, enabled)
}
func (s *Service) GetSettings(ctx context.Context, guildID int64) (*settingsrepo.LogSettings, error) {
return s.settings.GetLogSettings(ctx, guildID)
}
func (s *Service) HasSettings(ctx context.Context, guildID int64) (bool, error) {
return s.settings.HasLogSettings(ctx, guildID)
}
@@ -0,0 +1,32 @@
package musicsettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
func (s *Service) ToggleMusic(ctx context.Context, guildID int64) (bool, error) {
enabled, err := s.settings.IsMusicEnabled(ctx, guildID)
if err != nil {
return false, err
}
newEnabled := !enabled
if err := s.settings.SetMusicEnabled(ctx, guildID, newEnabled); err != nil {
return false, err
}
return newEnabled, nil
}
func (s *Service) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsMusicEnabled(ctx, guildID)
}
+12
View File
@@ -26,7 +26,19 @@ func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limi
return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit) return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit)
} }
func (s *Service) ListActive(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.Project, error) {
return s.repo.ListActiveProjects(ctx, guildID, limit)
}
func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) { func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) {
return s.repo.ProjectExistsInGuild(ctx, guildID, projectID) return s.repo.ProjectExistsInGuild(ctx, guildID, projectID)
} }
func (s *Service) GetProject(ctx context.Context, guildID, projectID int64) (*projectsrepo.Project, error) {
return s.repo.GetProject(ctx, guildID, projectID)
}
func (s *Service) DeactivateProject(ctx context.Context, guildID, projectID int64) error {
return s.repo.DeactivateProject(ctx, guildID, projectID)
}
+11 -8
View File
@@ -19,14 +19,6 @@ func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, invi
return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description) 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 { func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error {
return s.repo.UpdateStatus(ctx, scheduleID, status) return s.repo.UpdateStatus(ctx, scheduleID, status)
} }
@@ -51,3 +43,14 @@ func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) erro
return s.repo.Reschedule(ctx, id, when) return s.repo.Reschedule(ctx, id, when)
} }
func (s *Service) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error {
return s.repo.ProposeReschedule(ctx, scheduleID, newTime, proposedBy)
}
func (s *Service) AcceptReschedule(ctx context.Context, id int64) error {
return s.repo.AcceptReschedule(ctx, id)
}
func (s *Service) DeclineReschedule(ctx context.Context, id int64) error {
return s.repo.DeclineReschedule(ctx, id)
}
+8 -2
View File
@@ -1,16 +1,18 @@
package services package services
import ( import (
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
"velox-bot/internal/db/services/meeting" "velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/musicsettings"
"velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps" "velox-bot/internal/db/services/rps"
"velox-bot/internal/db/services/schedule" "velox-bot/internal/db/services/schedule"
"velox-bot/internal/db/services/twitch" "velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings" "velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome" "velox-bot/internal/db/services/welcome"
"velox-bot/internal/db/services/defaultrole"
) )
type Services struct { type Services struct {
@@ -24,11 +26,13 @@ type Services struct {
Twitch *twitch.Service Twitch *twitch.Service
Welcome *welcome.Service Welcome *welcome.Service
DefaultRole *defaultrole.Service DefaultRole *defaultrole.Service
LogSettings *logsettings.Service
MusicSettings *musicsettings.Service
} }
var Global *Services var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service) *Services { func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service) *Services {
s := &Services{ s := &Services{
Level: level, Level: level,
LevelSettings: levelSettings, LevelSettings: levelSettings,
@@ -40,6 +44,8 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
Twitch: twitchSvc, Twitch: twitchSvc,
Welcome: welcomeSvc, Welcome: welcomeSvc,
DefaultRole: defaultRoleSvc, DefaultRole: defaultRoleSvc,
LogSettings: logSettingsSvc,
MusicSettings: musicSvc,
} }
Global = s Global = s
return s return s
+8
View File
@@ -59,6 +59,14 @@ func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID
return s.repo.SetNotificationChannel(ctx, guildID, channelID) return s.repo.SetNotificationChannel(ctx, guildID, channelID)
} }
func (s *Service) IsEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.repo.IsEnabled(ctx, guildID)
}
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
return s.repo.SetEnabled(ctx, guildID, enabled)
}
func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) { func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) {
type gqlRequest struct { type gqlRequest struct {
Query string `json:"query"` Query string `json:"query"`
+2
View File
@@ -11,6 +11,8 @@ import (
) )
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) { func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
CacheMessageFromCreate(m)
if m.Author.Bot || m.GuildID == "" { if m.Author.Bot || m.GuildID == "" {
return return
} }
+376
View File
@@ -0,0 +1,376 @@
package events
import (
"fmt"
"log"
"slices"
"strings"
"sync"
"time"
"velox-bot/internal/db/services"
"velox-bot/internal/modlog"
"github.com/bwmarrin/discordgo"
)
type cachedMessage struct {
Content string
AuthorID string
}
var messageCache sync.Map // map[messageID]cachedMessage
func HandleMessageUpdateLog(s *discordgo.Session, m *discordgo.MessageUpdate, svc *services.Services) {
if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil {
return
}
if m.Author != nil && m.Author.Bot {
return
}
before := ""
if m.BeforeUpdate != nil {
before = strings.TrimSpace(m.BeforeUpdate.Content)
}
if before == "" {
if prev, ok := getCachedMessage(m.ID); ok {
before = strings.TrimSpace(prev.Content)
}
}
after := strings.TrimSpace(m.Content)
if before == after {
return
}
jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", m.GuildID, m.ChannelID, m.ID)
authorMention := "Unknown"
if m.Author != nil {
authorMention = m.Author.Mention()
}
embed := &discordgo.MessageEmbed{
Title: "Message Edited",
Color: 0xF1C40F,
Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\n[Jump to message](%s)", authorMention, m.ChannelID, jumpURL),
Fields: []*discordgo.MessageEmbedField{
{Name: "Before", Value: truncateOrPlaceholder(before), Inline: false},
{Name: "After", Value: truncateOrPlaceholder(after), Inline: false},
},
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send message edit log: %v", err)
}
cacheMessage(m.ID, after, authorIDOrEmpty(m.Author))
}
func HandleMessageDeleteLog(s *discordgo.Session, m *discordgo.MessageDelete, svc *services.Services) {
if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil {
return
}
authorID := ""
authorLabel := "Unknown"
content := ""
if m.BeforeDelete != nil {
if m.BeforeDelete.Author != nil {
authorID = m.BeforeDelete.Author.ID
authorLabel = m.BeforeDelete.Author.Mention()
}
content = strings.TrimSpace(m.BeforeDelete.Content)
}
if content == "" {
if prev, ok := getCachedMessage(m.ID); ok {
content = strings.TrimSpace(prev.Content)
if authorID == "" && prev.AuthorID != "" {
authorID = prev.AuthorID
authorLabel = "<@" + prev.AuthorID + ">"
}
}
}
if authorID == "" && m.Author != nil {
authorID = m.Author.ID
authorLabel = m.Author.Mention()
}
if authorID == "" {
authorLabel = "Unknown"
}
embed := &discordgo.MessageEmbed{
Title: "Message Deleted",
Color: 0xE74C3C,
Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\nMessage ID: `%s`", authorLabel, m.ChannelID, m.ID),
Fields: []*discordgo.MessageEmbedField{
{Name: "Content", Value: truncateOrPlaceholder(content), Inline: false},
},
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send message delete log: %v", err)
}
deleteCachedMessage(m.ID)
}
func HandleGuildMemberAddLog(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
embed := &discordgo.MessageEmbed{
Title: "Member Joined",
Color: 0x2ECC71,
Description: fmt.Sprintf("%s joined the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID),
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send member join log: %v", err)
}
}
func HandleGuildMemberRemoveLog(s *discordgo.Session, m *discordgo.GuildMemberRemove, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
// Try to classify this leave as a kick via recent audit log.
kickEntry, kickMod := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberKick)
title := "Member Left"
color := 0x95A5A6
description := fmt.Sprintf("%s left the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if kickEntry != nil {
title = "Member Kicked"
color = 0xE67E22
description = fmt.Sprintf("%s was kicked.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if kickMod != nil {
description += "\nModerator: " + kickMod.Mention()
}
if strings.TrimSpace(kickEntry.Reason) != "" {
description += "\nReason: " + kickEntry.Reason
}
}
embed := &discordgo.MessageEmbed{
Title: title,
Color: color,
Description: description,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send member remove log: %v", err)
}
}
func HandleGuildMemberUpdateLog(s *discordgo.Session, m *discordgo.GuildMemberUpdate, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil || m.BeforeUpdate == nil {
return
}
added := diffRoleIDs(m.BeforeUpdate.Roles, m.Roles)
removed := diffRoleIDs(m.Roles, m.BeforeUpdate.Roles)
if len(added) == 0 && len(removed) == 0 {
return
}
fields := make([]*discordgo.MessageEmbedField, 0, 2)
if len(added) > 0 {
fields = append(fields, &discordgo.MessageEmbedField{
Name: "Roles Added",
Value: joinRoleMentions(added),
Inline: false,
})
}
if len(removed) > 0 {
fields = append(fields, &discordgo.MessageEmbedField{
Name: "Roles Removed",
Value: joinRoleMentions(removed),
Inline: false,
})
}
embed := &discordgo.MessageEmbed{
Title: "Member Roles Updated",
Color: 0x3498DB,
Description: fmt.Sprintf("Member: %s (`%s`)", m.User.Mention(), m.User.ID),
Fields: fields,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send member update log: %v", err)
}
}
func HandleGuildBanAddLog(s *discordgo.Session, m *discordgo.GuildBanAdd, svc *services.Services) {
if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanAdd)
desc := fmt.Sprintf("%s was banned.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if moderator != nil {
desc += "\nModerator: " + moderator.Mention()
}
if entry != nil && strings.TrimSpace(entry.Reason) != "" {
desc += "\nReason: " + entry.Reason
}
embed := &discordgo.MessageEmbed{
Title: "Member Banned",
Color: 0xC0392B,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send ban add log: %v", err)
}
}
func HandleGuildBanRemoveLog(s *discordgo.Session, m *discordgo.GuildBanRemove, svc *services.Services) {
if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil {
return
}
entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanRemove)
desc := fmt.Sprintf("%s was unbanned.\nUser ID: `%s`", m.User.Mention(), m.User.ID)
if moderator != nil {
desc += "\nModerator: " + moderator.Mention()
}
if entry != nil && strings.TrimSpace(entry.Reason) != "" {
desc += "\nReason: " + entry.Reason
}
embed := &discordgo.MessageEmbed{
Title: "Member Unbanned",
Color: 0x27AE60,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := modlog.Send(s, svc, m.GuildID, embed); err != nil {
log.Printf("log: failed to send ban remove log: %v", err)
}
}
func findRecentAuditEntryForTarget(s *discordgo.Session, guildID, targetID string, action discordgo.AuditLogAction) (*discordgo.AuditLogEntry, *discordgo.User) {
if s == nil || guildID == "" || targetID == "" {
return nil, nil
}
logs, err := s.GuildAuditLog(guildID, "", "", int(action), 10)
if err != nil || logs == nil {
return nil, nil
}
var chosen *discordgo.AuditLogEntry
now := time.Now().UTC()
for _, e := range logs.AuditLogEntries {
if e == nil || e.TargetID != targetID || e.ActionType == nil || *e.ActionType != action {
continue
}
ts, err := discordgo.SnowflakeTimestamp(e.ID)
if err != nil {
continue
}
// Keep only very recent entries to reduce false positives.
if now.Sub(ts) > 2*time.Minute {
continue
}
chosen = e
break
}
if chosen == nil {
return nil, nil
}
for _, u := range logs.Users {
if u != nil && u.ID == chosen.UserID {
return chosen, u
}
}
return chosen, nil
}
func truncateOrPlaceholder(s string) string {
v := strings.TrimSpace(s)
if v == "" {
return "*empty*"
}
if len(v) <= 1000 {
return v
}
return v[:997] + "..."
}
func diffRoleIDs(base, compare []string) []string {
out := make([]string, 0)
for _, id := range base {
if id == "" {
continue
}
if !slices.Contains(compare, id) {
out = append(out, id)
}
}
return out
}
func joinRoleMentions(roleIDs []string) string {
if len(roleIDs) == 0 {
return "*none*"
}
const max = 12
parts := make([]string, 0, len(roleIDs))
for idx, id := range roleIDs {
if idx >= max {
parts = append(parts, fmt.Sprintf("... and %d more", len(roleIDs)-max))
break
}
parts = append(parts, "<@&"+id+">")
}
return strings.Join(parts, ", ")
}
func CacheMessageFromCreate(m *discordgo.MessageCreate) {
if m == nil || m.Message == nil || m.ID == "" {
return
}
cacheMessage(m.ID, strings.TrimSpace(m.Content), authorIDOrEmpty(m.Author))
}
func cacheMessage(messageID, content, authorID string) {
if messageID == "" {
return
}
messageCache.Store(messageID, cachedMessage{
Content: content,
AuthorID: authorID,
})
}
func getCachedMessage(messageID string) (cachedMessage, bool) {
if messageID == "" {
return cachedMessage{}, false
}
raw, ok := messageCache.Load(messageID)
if !ok {
return cachedMessage{}, false
}
msg, ok := raw.(cachedMessage)
if !ok {
return cachedMessage{}, false
}
return msg, true
}
func deleteCachedMessage(messageID string) {
if messageID == "" {
return
}
messageCache.Delete(messageID)
}
func authorIDOrEmpty(u *discordgo.User) string {
if u == nil {
return ""
}
return u.ID
}
+163
View File
@@ -0,0 +1,163 @@
package events
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleBotAddedOnGuild applies safe defaults when this bot is added to a guild
// and DMs setup instructions to the inviter (or guild owner fallback).
func HandleBotAddedOnGuild(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || svc == nil {
return
}
if s.State == nil || s.State.User == nil {
return
}
if m.User.ID != s.State.User.ID {
return
}
guildID64, err := strconv.ParseInt(m.GuildID, 10, 64)
if err != nil {
return
}
// Treat an existing logsettings row as "already onboarded once".
alreadyOnboarded := false
if svc.LogSettings != nil {
exists, err := svc.LogSettings.HasSettings(context.Background(), guildID64)
if err == nil {
alreadyOnboarded = exists
}
}
if alreadyOnboarded {
return
}
// Explicitly disable toggleable systems by default.
if svc.LevelSettings != nil {
if err := svc.LevelSettings.SetLevelSystemEnabled(context.Background(), guildID64, false); err != nil {
log.Printf("onboarding: failed to set default level state for guild %s: %v", m.GuildID, err)
}
}
if svc.LogSettings != nil {
if err := svc.LogSettings.SetEnabled(context.Background(), guildID64, false); err != nil {
log.Printf("onboarding: failed to set default log state for guild %s: %v", m.GuildID, err)
}
}
recipientID := findRecentBotAdderID(s, m.GuildID)
if recipientID == "" {
recipientID = guildOwnerID(s, m.GuildID)
}
if recipientID == "" {
log.Printf("onboarding: unable to resolve DM recipient for guild %s", m.GuildID)
return
}
dmCh, err := s.UserChannelCreate(recipientID)
if err != nil {
log.Printf("onboarding: failed creating DM for user %s: %v", recipientID, err)
return
}
guildName := m.GuildID
if g, err := s.State.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
guildName = g.Name
} else if g, err := s.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
guildName = g.Name
}
embed := &discordgo.MessageEmbed{
Title: "Thanks for adding Velox Bot!",
Color: 0x5865F2,
Description: fmt.Sprintf(
"Server: **%s**\n\nFor safety, toggleable systems start **disabled by default**.\nUse these commands in your server to enable features:",
guildName,
),
Fields: []*discordgo.MessageEmbedField{
{
Name: "Level System",
Value: "`/slvl toggle`",
Inline: false,
},
{
Name: "Logging System",
Value: "" +
"`/config setlogchannel channel:#channel`\n" +
"`/config enablelogging`",
Inline: false,
},
{
Name: "Welcome System",
Value: "" +
"`/config setwelcomechannel channel:#channel`\n" +
"`/config setwelcomemessage message:\"...\"`\n" +
"`/config setwelcomedm message:\"...\"`",
Inline: false,
},
},
Footer: &discordgo.MessageEmbedFooter{
Text: "Tip: run /help in any server channel for the full command reference.",
},
Timestamp: time.Now().Format(time.RFC3339),
}
if _, err := s.ChannelMessageSendEmbed(dmCh.ID, embed); err != nil {
log.Printf("onboarding: failed sending setup DM to user %s: %v", recipientID, err)
}
}
func findRecentBotAdderID(s *discordgo.Session, guildID string) string {
if s == nil || s.State == nil || s.State.User == nil {
return ""
}
logs, err := s.GuildAuditLog(guildID, "", "", int(discordgo.AuditLogActionBotAdd), 10)
if err != nil || logs == nil {
return ""
}
botID := s.State.User.ID
now := time.Now().UTC()
for _, entry := range logs.AuditLogEntries {
if entry == nil || entry.TargetID != botID || entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionBotAdd {
continue
}
ts, err := discordgo.SnowflakeTimestamp(entry.ID)
if err != nil {
continue
}
if now.Sub(ts) > 10*time.Minute {
continue
}
if entry.UserID != "" {
return entry.UserID
}
}
return ""
}
func guildOwnerID(s *discordgo.Session, guildID string) string {
if s == nil || guildID == "" {
return ""
}
if s.State != nil {
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
return g.OwnerID
}
}
if g, err := s.Guild(guildID); err == nil && g != nil {
return g.OwnerID
}
return ""
}
-95
View File
@@ -1,95 +0,0 @@
package events
import (
"context"
"log"
"strconv"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleMessageReactionAdd reacts to emoji on schedule DM messages:
// ✅ accept, ❌ decline, 🔁 request reschedule.
func HandleMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd, svc *services.Services) {
if svc == nil || svc.Schedule == nil {
return
}
if r == nil || r.UserID == "" || r.MessageID == "" {
return
}
// Ignore bot reactions.
if r.Member != nil && r.Member.User != nil && r.Member.User.Bot {
return
}
msgID64, err := strconv.ParseInt(r.MessageID, 10, 64)
if err != nil || msgID64 == 0 {
return
}
ctx := context.Background()
sch, err := svc.Schedule.GetByMessageID(ctx, msgID64)
if err != nil || sch == nil {
return
}
// Only invitee can react to change status.
if r.UserID != strconv.FormatInt(sch.InviteeID, 10) {
return
}
// Only pending can be transitioned by reaction.
if sch.Status != schedulerepo.StatusPending {
return
}
var newStatus schedulerepo.ScheduleStatus
switch r.Emoji.Name {
case "✅":
newStatus = schedulerepo.StatusAccepted
case "❌":
newStatus = schedulerepo.StatusDeclined
case "🔁":
newStatus = schedulerepo.StatusRescheduleRequested
default:
return
}
if err := svc.Schedule.UpdateStatus(ctx, sch.ID, newStatus); err != nil {
log.Printf("schedule: failed to update status id=%d: %v", sch.ID, err)
return
}
statusText := map[schedulerepo.ScheduleStatus]string{
schedulerepo.StatusAccepted: "accepted ✅",
schedulerepo.StatusDeclined: "declined ❌",
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
}[newStatus]
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
// Notify requester with local time if configured.
if svc.UserSettings != nil {
loc, zone, _, err := svc.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
requesterID := strconv.FormatInt(sch.RequesterID, 10)
dmCh, err := s.UserChannelCreate(requesterID)
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM requester about status change: %v", err)
}
}
}
}
+10
View File
@@ -47,6 +47,16 @@ func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) {
for _, guildID := range guildIDs { for _, guildID := range guildIDs {
log.Printf("twitch: processing guild %d", guildID) log.Printf("twitch: processing guild %d", guildID)
enabled, err := svc.Twitch.IsEnabled(ctx, guildID)
if err != nil {
log.Printf("twitch: failed to check enabled state for guild %d: %v", guildID, err)
continue
}
if !enabled {
log.Printf("twitch: notifications disabled for guild %d", guildID)
continue
}
channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID) channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID)
if err != nil { if err != nil {
log.Printf("twitch: failed to get channel for guild %d: %v", guildID, err) log.Printf("twitch: failed to get channel for guild %d: %v", guildID, err)
+30
View File
@@ -0,0 +1,30 @@
package modlog
import (
"context"
"strconv"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func Send(s *discordgo.Session, svc *services.Services, guildID string, embed *discordgo.MessageEmbed) error {
if s == nil || svc == nil || svc.LogSettings == nil || embed == nil || guildID == "" {
return nil
}
gid, err := strconv.ParseInt(guildID, 10, 64)
if err != nil {
return nil
}
cfg, err := svc.LogSettings.GetSettings(context.Background(), gid)
if err != nil || cfg == nil || !cfg.IsEnabled || cfg.ChannelID == 0 {
return err
}
_, err = s.ChannelMessageSendEmbed(strconv.FormatInt(cfg.ChannelID, 10), embed)
return err
}
+4 -1
View File
@@ -97,7 +97,10 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
content = "Done." content = "Done."
} }
if err != nil { if err != nil {
content = "Error: " + err.Error() // err can carry a raw Lavalink/network failure here (Player.Update),
// not just our own "music manager not initialized" - don't echo
// that detail into the ephemeral reply.
content = "That didn't work. Please try again."
} }
// send ephemeral confirmation // send ephemeral confirmation
+133
View File
@@ -0,0 +1,133 @@
package music
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"velox-bot/internal/db/repos/musicrepo"
"github.com/jackc/pgx/v5"
)
func StartCommandListener(ctx context.Context, connString string, repo *musicrepo.Repo) error {
conn, err := pgx.Connect(ctx, connString)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close(ctx)
_, err = conn.Exec(ctx, "LISTEN music_commands")
if err != nil {
return fmt.Errorf("exec: %w", err)
}
for {
_, err := conn.WaitForNotification(ctx)
if err != nil {
return fmt.Errorf("waitfornotification: %w", err)
}
processCommands(ctx, repo)
}
}
func processCommands(ctx context.Context, repo *musicrepo.Repo) {
cmds, err := repo.ListUnprocessedCommands(ctx)
if err != nil {
log.Printf("music: failed to list unprocessed commands: %v", err)
return
}
for _, cmd := range cmds {
if err := dispatchCommand(cmd); err != nil {
log.Printf("music: command %d (%s) for guild %d failed: %v", cmd.ID, cmd.CommandType, cmd.GuildID, err)
}
if err := repo.MarkCommandProcessed(ctx, cmd.ID); err != nil {
log.Printf("music: failed to mark command %d processed: %v", cmd.ID, err)
}
}
}
func dispatchCommand(cmd musicrepo.MusicCommand) error {
guildID := strconv.FormatInt(cmd.GuildID, 10)
switch cmd.CommandType {
case "skip":
return Skip(guildID)
case "pause":
return Pause(guildID, true)
case "resume":
return Pause(guildID, false)
case "stop":
return Stop(guildID)
case "set_volume":
var payload struct {
Volume int `json:"volume"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
return SetVolume(guildID, payload.Volume)
case "remove_track":
var payload struct {
QueueID string `json:"queue_id"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
return RemoveFromQueue(guildID, payload.QueueID)
case "reorder":
var payload struct {
QueueID string `json:"queue_id"`
Position int `json:"position"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
return Reorder(guildID, payload.QueueID, payload.Position)
case "repeat_song":
ToggleRepeatSong(guildID)
return nil
case "repeat_queue":
ToggleRepeatQueue(guildID)
return nil
case "add_song":
var payload struct {
Query string `json:"query"`
VoiceChannelID string `json:"voice_channel_id"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
requesterID := strconv.FormatInt(cmd.RequestedBy, 10)
// A voice channel means "start fresh" (nothing was playing, the
// dashboard had the user pick one), otherwise this just adds to
// whatever's already going.
if payload.VoiceChannelID != "" {
// Voice channels have their own built-in text chat at the same
// channel ID, post the now-playing embed there so starting
// playback from the dashboard still shows up somewhere in
// Discord, same as starting it with /play would.
_, _, err := EnqueueAndPlay(guildID, payload.VoiceChannelID, payload.VoiceChannelID, payload.Query, "Dashboard", requesterID)
return err
}
_, err := AddToQueue(guildID, payload.Query, requesterID)
return err
default:
return fmt.Errorf("unknown command type %q", cmd.CommandType)
}
}
+323 -41
View File
@@ -3,36 +3,52 @@ package music
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"net/url" "net/url"
"strconv"
"strings"
"sync" "sync"
"time" "time"
"velox-bot/internal/db/repos/musicrepo"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/disgolink" "github.com/disgoorg/disgolink/v3/disgolink"
"github.com/disgoorg/disgolink/v3/lavalink" "github.com/disgoorg/disgolink/v3/lavalink"
"github.com/disgoorg/snowflake/v2" "github.com/disgoorg/snowflake/v2"
"github.com/google/uuid"
) )
type TrackEntry struct { type TrackEntry struct {
Track lavalink.Track Track lavalink.Track
RequestedBy string RequestedBy string
RequesterID string
StartedAt time.Time
// QueueID is generated once, when the track is first added to the
// queue, and never changes for the rest of its life there - it's
// what lets syncState upsert this track's row across syncs instead
// of deleting and recreating it (and its DB id) every single time,
// which is what remove/reorder commands need to reliably target a
// specific track.
QueueID string
} }
type guildPlayer struct { type guildPlayer struct {
Player disgolink.Player Player disgolink.Player
Queue []TrackEntry Queue []TrackEntry
RepeatSong bool RepeatSong bool
RepeatQueue bool RepeatQueue bool
Paused bool Paused bool
Volume int Volume int
IdleSince time.Time IdleSince time.Time
TextChannelID string TextChannelID string
NowPlayingMsgID string NowPlayingMsgID string
} }
type Manager struct { type Manager struct {
client disgolink.Client client disgolink.Client
session *discordgo.Session session *discordgo.Session
repo *musicrepo.Repo
mu sync.Mutex mu sync.Mutex
players map[string]*guildPlayer players map[string]*guildPlayer
@@ -40,7 +56,7 @@ type Manager struct {
var manager *Manager var manager *Manager
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error { func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
if lavalinkHost == "" { if lavalinkHost == "" {
return nil return nil
} }
@@ -53,6 +69,7 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
m := &Manager{ m := &Manager{
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)), client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
session: session, session: session,
repo: repo,
players: make(map[string]*guildPlayer), players: make(map[string]*guildPlayer),
} }
@@ -67,18 +84,26 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
password = "youshallnotpass" password = "youshallnotpass"
} }
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{ // Connecting to the Lavalink node is a blocking network call (and, on
Name: "main", // failure, disgolink retries indefinitely with backoff) — do it in the
Address: u.Host, // background so bot startup isn't held hostage by it. Music commands
Password: password, // simply fail with "not initialized" via the manager==nil checks until
Secure: secure, // this completes.
}) go func() {
if err != nil { _, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
return fmt.Errorf("add lavalink node: %w", err) Name: "main",
} Address: u.Host,
Password: password,
Secure: secure,
})
if err != nil {
log.Printf("music: failed to connect to lavalink node: %v", err)
return
}
manager = m
go idleDisconnectLoop()
}()
manager = m
go idleDisconnectLoop()
return nil return nil
} }
@@ -183,23 +208,126 @@ func getOrCreateGuildPlayer(guildID string) *guildPlayer {
return gp return gp
} }
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) { func syncState(guidID string) {
if manager == nil { if manager == nil || manager.repo == nil {
return nil, false, fmt.Errorf("music manager not initialized") return
} }
manager.mu.Lock()
// Join voice channel gp, ok := manager.players[guidID]
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil { if !ok {
return nil, false, fmt.Errorf("join voice channel: %w", err)
}
gp := getOrCreateGuildPlayer(guildID)
if textChannelID != "" {
manager.mu.Lock()
gp.TextChannelID = textChannelID
manager.mu.Unlock() manager.mu.Unlock()
return
} }
queue := make([]TrackEntry, len(gp.Queue))
copy(queue, gp.Queue)
paused := gp.Paused
volume := gp.Volume
repeatSong := gp.RepeatSong
repeatQueue := gp.RepeatQueue
manager.mu.Unlock()
gID, err := strconv.ParseInt(guidID, 10, 64)
if err != nil {
return
}
if len(queue) == 0 {
err := manager.repo.ClearNowPlaying(context.Background(), gID)
if err != nil {
return
}
err = manager.repo.ReplaceQueue(context.Background(), gID, nil)
if err != nil {
return
}
return
}
current := queue[0]
title := current.Track.Info.Title
var uri string
if current.Track.Info.URI != nil {
uri = *current.Track.Info.URI
}
length := int(current.Track.Info.Length / 1000)
startedAt := current.StartedAt
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
if err != nil {
return
}
rest := queue[1:]
var queueEntries []musicrepo.QueueEntry
for _, entry := range rest {
reqID, _ := strconv.ParseInt(entry.RequesterID, 10, 64)
var uri string
if entry.Track.Info.URI != nil {
uri = *entry.Track.Info.URI
}
queueEntries = append(queueEntries, musicrepo.QueueEntry{
Title: entry.Track.Info.Title,
URL: uri,
RequestedBy: reqID,
ClientID: entry.QueueID,
})
}
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
if err != nil {
return
}
}
// NormalizeQuery turns plain text into a YouTube search and strips
// auto-generated "radio/mix" params off a pasted YouTube URL
// (https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 becomes
// .../watch?v=ID), while leaving normal playlist URLs and non-YouTube
// links (SoundCloud, etc.) untouched. Every entry point that accepts a
// user-supplied query - /play and the dashboard's "add song" - should
// normalize through this before handing the query to Lavalink.
func NormalizeQuery(query string) string {
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
return "ytsearch:" + query
}
u, err := url.Parse(query)
if err != nil || u == nil {
return query
}
host := strings.ToLower(u.Host)
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
return query
}
q := u.Query()
v := q.Get("v")
if v == "" {
return query
}
list := q.Get("list")
_, hasStartRadio := q["start_radio"]
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
return query
}
u.RawQuery = url.Values{"v": []string{v}}.Encode()
u.Fragment = ""
return u.String()
}
// resolveTracks searches Lavalink for query (already normalized) and
// returns a playable track list - a single track, a search result's
// first hit, or a whole playlist (rotated to respect Lavalink's own
// selectedTrack when present).
func resolveTracks(query string) ([]lavalink.Track, error) {
var loadedTracks []lavalink.Track var loadedTracks []lavalink.Track
manager.client.BestNode().LoadTracksHandler( manager.client.BestNode().LoadTracksHandler(
@@ -237,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
) )
if len(loadedTracks) == 0 { if len(loadedTracks) == 0 {
return nil, false, fmt.Errorf("no tracks found for query") return nil, fmt.Errorf("no tracks found for query")
} }
// Some sources may return unplayable entries; pick the first with an encoded track. // Some sources may return unplayable entries; pick the first with an encoded track.
@@ -252,11 +380,38 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...) loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
} }
return loadedTracks, nil
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID string) (*TrackEntry, bool, error) {
if manager == nil {
return nil, false, fmt.Errorf("music manager not initialized")
}
// Join voice channel
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
return nil, false, fmt.Errorf("join voice channel: %w", err)
}
gp := getOrCreateGuildPlayer(guildID)
if textChannelID != "" {
manager.mu.Lock()
gp.TextChannelID = textChannelID
manager.mu.Unlock()
}
loadedTracks, err := resolveTracks(NormalizeQuery(query))
if err != nil {
return nil, false, err
}
entries := make([]TrackEntry, len(loadedTracks)) entries := make([]TrackEntry, len(loadedTracks))
for idx, t := range loadedTracks { for idx, t := range loadedTracks {
entries[idx] = TrackEntry{ entries[idx] = TrackEntry{
Track: t, Track: t,
RequestedBy: requester, RequestedBy: requester,
RequesterID: requesterID,
QueueID: uuid.New().String(),
} }
} }
firstEntry := entries[0] firstEntry := entries[0]
@@ -268,6 +423,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
manager.mu.Unlock() manager.mu.Unlock()
if shouldStart { if shouldStart {
gp.Queue[0].StartedAt = time.Now()
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil { if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
return nil, false, fmt.Errorf("start track: %w", err) return nil, false, fmt.Errorf("start track: %w", err)
} }
@@ -276,9 +433,52 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
} }
} }
syncState(guildID)
return &firstEntry, shouldStart, nil return &firstEntry, shouldStart, nil
} }
// AddToQueue adds a track to a guild's existing queue without joining a
// voice channel, unlike EnqueueAndPlay it can't start a brand new
// session (that needs a voice channel to join, which this has no way to
// pick), so it requires one to already be active.
func AddToQueue(guildID, query, requesterID string) (*TrackEntry, error) {
if manager == nil {
return nil, fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
alreadyActive := len(gp.Queue) > 0
manager.mu.Unlock()
if !alreadyActive {
return nil, fmt.Errorf("nothing is currently playing in this server")
}
loadedTracks, err := resolveTracks(NormalizeQuery(query))
if err != nil {
return nil, err
}
entries := make([]TrackEntry, len(loadedTracks))
for idx, t := range loadedTracks {
entries[idx] = TrackEntry{
Track: t,
RequestedBy: "Dashboard",
RequesterID: requesterID,
QueueID: uuid.New().String(),
}
}
manager.mu.Lock()
gp.Queue = append(gp.Queue, entries...)
manager.mu.Unlock()
syncState(guildID)
return &entries[0], nil
}
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) { func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if manager == nil { if manager == nil {
return return
@@ -298,6 +498,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
// repeat current track // repeat current track
if gp.RepeatSong { if gp.RepeatSong {
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0] next := gp.Queue[0]
textChannelID := gp.TextChannelID textChannelID := gp.TextChannelID
manager.mu.Unlock() manager.mu.Unlock()
@@ -308,12 +509,14 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" { if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next) postNowPlaying(guildID, textChannelID, next)
} }
syncState(guildID)
return return
} }
// pop current and optionally cycle it to end // pop current and optionally cycle it to end
finished := gp.Queue[0] finished := gp.Queue[0]
gp.Queue = gp.Queue[1:] gp.Queue = gp.Queue[1:]
if gp.RepeatQueue { if gp.RepeatQueue {
gp.Queue = append(gp.Queue, finished) gp.Queue = append(gp.Queue, finished)
} }
@@ -328,9 +531,11 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
disableNowPlaying(textChannelID, nowPlayingMsgID) disableNowPlaying(textChannelID, nowPlayingMsgID)
} }
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack()) _ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
syncState(guildID)
return return
} }
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0] next := gp.Queue[0]
textChannelID := gp.TextChannelID textChannelID := gp.TextChannelID
manager.mu.Unlock() manager.mu.Unlock()
@@ -341,6 +546,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" { if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next) postNowPlaying(guildID, textChannelID, next)
} }
syncState(guildID)
} }
func Pause(guildID string, pause bool) error { func Pause(guildID string, pause bool) error {
@@ -354,6 +560,7 @@ func Pause(guildID string, pause bool) error {
gp.IdleSince = time.Time{} gp.IdleSince = time.Time{}
} }
manager.mu.Unlock() manager.mu.Unlock()
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause)) return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
} }
@@ -384,9 +591,11 @@ func Skip(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" { if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID) disableNowPlaying(textChannelID, nowPlayingMsgID)
} }
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack()) return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
} }
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0] next := gp.Queue[0]
textChannelID := gp.TextChannelID textChannelID := gp.TextChannelID
manager.mu.Unlock() manager.mu.Unlock()
@@ -397,6 +606,7 @@ func Skip(guildID string) error {
if textChannelID != "" { if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next) postNowPlaying(guildID, textChannelID, next)
} }
syncState(guildID)
return nil return nil
} }
@@ -418,6 +628,7 @@ func Stop(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" { if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID) disableNowPlaying(textChannelID, nowPlayingMsgID)
} }
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack()) return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
} }
@@ -447,12 +658,15 @@ func ToggleRepeatSong(guildID string) (bool, bool) {
} }
gp := getOrCreateGuildPlayer(guildID) gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock() manager.mu.Lock()
defer manager.mu.Unlock()
gp.RepeatSong = !gp.RepeatSong gp.RepeatSong = !gp.RepeatSong
if gp.RepeatSong { if gp.RepeatSong {
gp.RepeatQueue = false gp.RepeatQueue = false
} }
return gp.RepeatSong, gp.RepeatQueue repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
manager.mu.Unlock()
syncState(guildID)
return repeatSong, repeatQueue
} }
func ToggleRepeatQueue(guildID string) (bool, bool) { func ToggleRepeatQueue(guildID string) (bool, bool) {
@@ -461,12 +675,15 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
} }
gp := getOrCreateGuildPlayer(guildID) gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock() manager.mu.Lock()
defer manager.mu.Unlock()
gp.RepeatQueue = !gp.RepeatQueue gp.RepeatQueue = !gp.RepeatQueue
if gp.RepeatQueue { if gp.RepeatQueue {
gp.RepeatSong = false gp.RepeatSong = false
} }
return gp.RepeatSong, gp.RepeatQueue repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
manager.mu.Unlock()
syncState(guildID)
return repeatSong, repeatQueue
} }
func repeatFlags(guildID string) (bool, bool) { func repeatFlags(guildID string) (bool, bool) {
@@ -522,6 +739,73 @@ func SetVolume(guildID string, volume int) error {
postNowPlaying(guildID, textChannelID, *current) postNowPlaying(guildID, textChannelID, *current)
} }
} }
syncState(guildID)
return nil
}
func RemoveFromQueue(guildID, queueID string) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
idx := -1
for i, q := range gp.Queue {
if q.QueueID == queueID {
idx = i
break
}
}
if idx == -1 {
manager.mu.Unlock()
return fmt.Errorf("queue not found")
}
if idx == 0 {
manager.mu.Unlock()
return Skip(guildID)
}
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
manager.mu.Unlock()
syncState(guildID)
return nil
}
func Reorder(guildID, queueID string, newPosition int) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
idx := -1
for i, q := range gp.Queue {
if q.QueueID == queueID {
idx = i
break
}
}
if idx == -1 {
manager.mu.Unlock()
return fmt.Errorf("queue not found")
}
if idx == 0 {
manager.mu.Unlock()
return Skip(guildID)
}
entry := gp.Queue[idx]
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
pos := max(1, min(newPosition, len(gp.Queue)))
gp.Queue = append(gp.Queue[:pos], append([]TrackEntry{entry}, gp.Queue[pos:]...)...)
manager.mu.Unlock()
syncState(guildID)
return nil return nil
} }
@@ -534,5 +818,3 @@ func CurrentNowPlayingMessageID(guildID string) string {
defer manager.mu.Unlock() defer manager.mu.Unlock()
return gp.NowPlayingMsgID return gp.NowPlayingMsgID
} }
+11 -5
View File
@@ -10,26 +10,29 @@ import (
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/config" "velox-bot/internal/config"
"velox-bot/internal/db" "velox-bot/internal/db"
"velox-bot/internal/db/repos/defaultrolerepo"
"velox-bot/internal/db/repos/levelrepo" "velox-bot/internal/db/repos/levelrepo"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/repos/projectsrepo" "velox-bot/internal/db/repos/projectsrepo"
"velox-bot/internal/db/repos/rpsrepo" "velox-bot/internal/db/repos/rpsrepo"
"velox-bot/internal/db/repos/schedulerepo" "velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/repos/settingsrepo" "velox-bot/internal/db/repos/settingsrepo"
"velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/repos/twitchrepo" "velox-bot/internal/db/repos/twitchrepo"
"velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/defaultrolerepo" "velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
"velox-bot/internal/db/services/meeting" "velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/musicsettings"
"velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps" "velox-bot/internal/db/services/rps"
"velox-bot/internal/db/services/schedule" "velox-bot/internal/db/services/schedule"
"velox-bot/internal/db/services/twitch" "velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings" "velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome" "velox-bot/internal/db/services/welcome"
"velox-bot/internal/db/services/defaultrole"
) )
func main() { func main() {
@@ -48,6 +51,7 @@ func main() {
levelRepo := levelrepo.NewRepo(db) levelRepo := levelrepo.NewRepo(db)
settingsRepo := settingsrepo.NewRepo(db) settingsRepo := settingsrepo.NewRepo(db)
musicRepo := musicrepo.NewRepo(db)
rpsRepo := rpsrepo.NewRepo(db) rpsRepo := rpsrepo.NewRepo(db)
projectsRepo := projectsrepo.NewRepo(db) projectsRepo := projectsrepo.NewRepo(db)
scheduleRepo := schedulerepo.NewRepo(db) scheduleRepo := schedulerepo.NewRepo(db)
@@ -57,6 +61,7 @@ func main() {
defaultRoleRepo := defaultrolerepo.NewRepo(db) defaultRoleRepo := defaultrolerepo.NewRepo(db)
levelService := level.New(levelRepo, settingsRepo) levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo) levelSettingsService := levelsettings.New(settingsRepo)
logSettingsService := logsettings.New(settingsRepo)
meetingService := meeting.New(settingsRepo) meetingService := meeting.New(settingsRepo)
scheduleService := schedule.New(scheduleRepo) scheduleService := schedule.New(scheduleRepo)
userSettingsService := usersettings.New(userSettingsRepo) userSettingsService := usersettings.New(userSettingsRepo)
@@ -65,9 +70,10 @@ func main() {
twitchService := twitch.New(twitchRepo, config.TwitchClientID) twitchService := twitch.New(twitchRepo, config.TwitchClientID)
welcomeService := welcome.New(welcomeRepo) welcomeService := welcome.New(welcomeRepo)
defaultRoleService := defaultrole.New(defaultRoleRepo) defaultRoleService := defaultrole.New(defaultRoleRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService) musicSettingsService := musicsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost)
if err != nil { if err != nil {
log.Fatalf("Error creating bot: %v", err) log.Fatalf("Error creating bot: %v", err)
return return
+53 -2
View File
@@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS levelup (
CREATE TABLE IF NOT EXISTS twitch_config ( CREATE TABLE IF NOT EXISTS twitch_config (
guild_id BIGINT PRIMARY KEY, guild_id BIGINT PRIMARY KEY,
twitch_channel_id BIGINT twitch_channel_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE
); );
CREATE TABLE IF NOT EXISTS rps ( CREATE TABLE IF NOT EXISTS rps (
@@ -110,7 +111,14 @@ CREATE TABLE IF NOT EXISTS schedules (
status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested
description TEXT, description TEXT,
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE, reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Reschedule negotiation: a proposed new time awaiting the counterpart's
-- response, kept separate from scheduled_at until accepted.
proposed_at TIMESTAMPTZ,
proposed_by BIGINT,
-- Snapshot of status before entering reschedule_requested, so a decline
-- knows whether to revert to 'pending' or 'accepted'.
pre_reschedule_status TEXT
); );
CREATE TABLE IF NOT EXISTS schedule_messages ( CREATE TABLE IF NOT EXISTS schedule_messages (
@@ -125,3 +133,46 @@ CREATE TABLE IF NOT EXISTS user_settings (
user_id BIGINT PRIMARY KEY, user_id BIGINT PRIMARY KEY,
timezone TEXT NOT NULL timezone TEXT NOT NULL
); );
--
-- Music (dashboard queue view/control - see velox-dashboard-api's
-- 000010_music migration, this file mirrors it)
--
CREATE TABLE IF NOT EXISTS music_settings (
guild_id BIGINT PRIMARY KEY,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS music_now_playing (
guild_id BIGINT PRIMARY KEY,
track_title TEXT NOT NULL,
track_url TEXT NOT NULL,
duration_seconds INT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
paused BOOLEAN NOT NULL DEFAULT FALSE,
volume INT NOT NULL DEFAULT 100,
repeat_song BOOLEAN NOT NULL DEFAULT FALSE,
repeat_queue BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS music_queue (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
position INT NOT NULL,
track_title TEXT NOT NULL,
track_url TEXT NOT NULL,
requested_by BIGINT NOT NULL,
added_at TIMESTAMPTZ DEFAULT NOW(),
client_id UUID NOT NULL DEFAULT gen_random_uuid(),
UNIQUE (guild_id, client_id)
);
CREATE TABLE IF NOT EXISTS music_commands (
id BIGSERIAL PRIMARY KEY,
guild_id BIGINT NOT NULL,
command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue', 'add_song', 'stop')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);