Compare commits

...
Author SHA1 Message Date
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
46 changed files with 3361 additions and 481 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"]
+16
View File
@@ -12,6 +12,10 @@ It currently includes:
- 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
+15 -2
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:
+40 -10
View File
@@ -3,6 +3,7 @@ package bot
import ( import (
"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 +18,12 @@ type Bot struct {
Commands []*discordgo.ApplicationCommand Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand registeredCommands []*discordgo.ApplicationCommand
Services *services.Services Services *services.Services
MusicRepo *musicrepo.Repo
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) (*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 +34,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 +48,7 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
GuildID: guildID, GuildID: guildID,
Commands: cmds, Commands: cmds,
Services: services, Services: services,
MusicRepo: musicRepo,
LavalinkHost: lavalinkHost, LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass, LavalinkPass: lavalinkPass,
}, nil }, nil
@@ -57,7 +59,7 @@ 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)
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 +78,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 +122,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 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
"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,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
@@ -59,7 +64,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)),
+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 }
+2 -1
View File
@@ -10,6 +10,7 @@ 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
}
+138 -107
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,
Name: "project-id",
Description: "ID of the project",
Required: true,
}, },
{ {
Type: discordgo.ApplicationCommandOptionUser, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "user", Name: "close",
Description: "User to add as helper", Description: "Close (deactivate) a project",
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{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Which project should get a new helper?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "projects:add_helper_project_select",
Placeholder: "Select a project",
Options: options,
},
},
},
},
},
})
}
// handleClose responds with a select menu of the guild's active projects -
// same reasoning as handleAddHelper, picking from a live list beats typing
// an ID. The actual deactivation happens on selection, in HandleComponent.
func handleClose(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
options, err := activeProjectOptions(context.Background(), guildID)
if err != nil { if err != nil {
shared.RespondEphemeral(s, i, "Invalid user ID.") shared.RespondEphemeral(s, i, "Failed to load projects.")
return
}
if len(options) == 0 {
shared.RespondEphemeral(s, i, "There are no active projects to close.")
return return
} }
if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")") Type: discordgo.InteractionResponseChannelMessageWithSource,
return Data: &discordgo.InteractionResponseData{
Content: "Which project should be closed?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.SelectMenu{
CustomID: "projects:close_select",
Placeholder: "Select a project",
Options: options,
},
},
},
},
},
})
} }
shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.") // activeProjectOptions builds select-menu options for the guild's active
// projects, truncating name/description to Discord's option limits
// (label: 100 chars, description: 100 chars).
func activeProjectOptions(ctx context.Context, guildID int64) ([]discordgo.SelectMenuOption, error) {
const limit = 25 // Discord's own cap on select menu options
items, err := services.Global.Projects.ListActive(ctx, guildID, limit)
if err != nil {
return nil, err
} }
options := make([]discordgo.SelectMenuOption, 0, len(items))
for _, p := range items {
if p == nil {
continue
}
name := p.Name
if name == "" {
name = "Unnamed project"
}
options = append(options, discordgo.SelectMenuOption{
Label: truncate(name, 100),
Value: strconv.FormatInt(p.ID, 10),
Description: truncate(p.Description, 100),
})
}
return options, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
+6
View File
@@ -12,3 +12,9 @@ var (
func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) } func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) }
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HandleComponent(s, i) }
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
public.HandleModalSubmit(s, i)
}
@@ -0,0 +1,794 @@
package public
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/commands/schedule/shared"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func scheduleActionButtons(scheduleID int64) []discordgo.MessageComponent {
idStr := strconv.FormatInt(scheduleID, 10)
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Style: discordgo.SuccessButton,
CustomID: "schedule:accept:" + idStr,
},
discordgo.Button{
Label: "Decline",
Style: discordgo.DangerButton,
CustomID: "schedule:decline:" + idStr,
},
discordgo.Button{
Label: "Reschedule",
Style: discordgo.SecondaryButton,
CustomID: "schedule:reschedule:" + idStr,
},
},
},
}
}
func rescheduleProposalButtons(scheduleID int64) []discordgo.MessageComponent {
idStr := strconv.FormatInt(scheduleID, 10)
return []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Accept",
Style: discordgo.SuccessButton,
CustomID: "schedule:accept_reschedule:" + idStr,
},
discordgo.Button{
Label: "Decline",
Style: discordgo.DangerButton,
CustomID: "schedule:decline_reschedule:" + idStr,
},
discordgo.Button{
Label: "Propose different time",
Style: discordgo.SecondaryButton,
CustomID: "schedule:reschedule:" + idStr,
},
},
},
}
}
const createUserSelectCustomID = "schedule:create_user_select"
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.MessageComponentData().CustomID == createUserSelectCustomID {
handleCreateUserSelect(s, i)
return
}
ctx := context.Background()
action, scheduleID := parseComponentCustomID(i.MessageComponentData().CustomID)
userIDStr := interactionUserID(i)
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error parsing user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
if err != nil || sch == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error retrieving schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
var status schedulerepo.ScheduleStatus
var message string
switch action {
case "accept":
if userID != sch.InviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule is no longer pending.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
status = schedulerepo.StatusAccepted
message = "✅ Accepted"
case "decline":
if userID != sch.InviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule is no longer pending.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
status = schedulerepo.StatusDeclined
message = "❌ Declined"
case "reschedule":
if userID != sch.InviteeID && userID != sch.RequesterID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "This schedule can't be rescheduled right now.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "schedule:reschedule_modal:" + strconv.FormatInt(scheduleID, 10),
Title: "Propose a new time",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "date",
Label: "Date (YYYY-MM-DD)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "2026-01-02",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "time",
Label: "Time (HH:MM, your timezone)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "15:04",
},
},
},
},
},
})
return
case "accept_reschedule":
if sch.ProposedBy == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule proposal to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusRescheduleRequested {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule request to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.AcceptReschedule(ctx, scheduleID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error accepting reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "✅ Reschedule accepted",
Components: []discordgo.MessageComponent{},
},
})
newTime := *sch.ProposedAt
utcStr := newTime.UTC().Format("2006-01-02 15:04")
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
if err != nil {
loc = time.UTC
zone = "UTC"
}
localStr := newTime.In(loc).Format("2006-01-02 15:04")
content := "Your proposed time for the session was accepted: " +
utcStr + " UTC (" + localStr + " " + zone + ")."
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM proposer about accepted reschedule: %v", err)
}
}
return
case "decline_reschedule":
if sch.ProposedBy == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule proposal to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if (userID != sch.RequesterID && userID != sch.InviteeID) || userID == *sch.ProposedBy {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if sch.Status != schedulerepo.StatusRescheduleRequested {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "There is no reschedule request to accept.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.DeclineReschedule(ctx, scheduleID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error declining reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "❌ Reschedule declined",
Components: []discordgo.MessageComponent{},
},
})
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, *sch.ProposedBy)
if err != nil {
loc = time.UTC
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your proposed new time was declined. The session remains scheduled for " +
utcStr + " UTC (" + localStr + " " + zone + ")."
dmCh, err := s.UserChannelCreate(strconv.FormatInt(*sch.ProposedBy, 10))
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM proposer about declined reschedule: %v", err)
}
}
return
default:
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Unknown action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
err = services.Global.Schedule.UpdateStatus(ctx, scheduleID, status)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error updating schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: message,
Components: []discordgo.MessageComponent{},
},
})
statusText := map[schedulerepo.ScheduleStatus]string{
schedulerepo.StatusAccepted: "accepted ✅",
schedulerepo.StatusDeclined: "declined ❌",
schedulerepo.StatusRescheduleRequested: "requested rescheduling 🔁",
}[status]
utcStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04")
// Notify requester with local time if configured.
if services.Global.UserSettings != nil {
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, sch.RequesterID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
zone = "UTC"
}
localStr := sch.ScheduledAt.In(loc).Format("2006-01-02 15:04")
content := "Your session request with <@" + strconv.FormatInt(sch.InviteeID, 10) + "> for " +
utcStr + " UTC (" + localStr + " " + zone + ") has been " + statusText + "."
requesterID := strconv.FormatInt(sch.RequesterID, 10)
dmCh, err := s.UserChannelCreate(requesterID)
if err == nil {
if _, err := s.ChannelMessageSend(dmCh.ID, content); err != nil {
log.Printf("schedule: failed to DM requester about status change: %v", err)
}
}
}
}
func handleCreateUserSelect(s *discordgo.Session, i *discordgo.InteractionCreate) {
values := i.MessageComponentData().Values
if len(values) == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "No user selected.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
targetUserID := values[0]
if targetUserID == interactionUserID(i) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You cannot schedule a session with yourself.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "schedule:create_modal:" + targetUserID,
Title: "Schedule a session",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "date",
Label: "Date (YYYY-MM-DD)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "2026-01-02",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "time",
Label: "Time (HH:MM, your timezone)",
Style: discordgo.TextInputShort,
Required: true,
Placeholder: "15:04",
},
},
},
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "description",
Label: "Description (optional)",
Style: discordgo.TextInputShort,
Required: false,
},
},
},
},
},
})
}
func modalTextInputValues(components []discordgo.MessageComponent) map[string]string {
values := make(map[string]string)
for _, c := range components {
row, ok := c.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, rc := range row.Components {
ti, ok := rc.(*discordgo.TextInput)
if !ok {
continue
}
values[ti.CustomID] = ti.Value
}
}
return values
}
func handleCreateModalSubmit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, inviteeID int64) {
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
requesterID, err := strconv.ParseInt(interactionUserID(i), 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if requesterID == inviteeID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You cannot schedule a session with yourself.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
values := modalTextInputValues(i.ModalSubmitData().Components)
dateStr := strings.TrimSpace(values["date"])
timeStr := strings.TrimSpace(values["time"])
desc := strings.TrimSpace(values["description"])
loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, requesterID)
if err != nil {
loc = time.UTC
zone = "UTC"
}
when, err := shared.ParseScheduleDatetime(dateStr+" "+timeStr, loc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time, in your timezone (" + zone + ").",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
when = when.In(time.UTC)
scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Failed to create schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
dmCh, err := s.UserChannelCreate(strconv.FormatInt(inviteeID, 10))
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session created, but failed to DM the invited user (are DMs disabled?).",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
embed := &discordgo.MessageEmbed{
Title: "New session request",
Description: fmt.Sprintf("You have been invited to a session by <@%d>.", requesterID),
Color: 0x4caf50,
}
utcStr := when.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(ctx, inviteeID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := when.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{Name: "When (UTC)", Value: utcStr},
{Name: "When (" + zoneInv + ")", Value: localInvStr},
}
if desc != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "Description",
Value: desc,
})
}
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "Respond to this session request:",
Embed: embed,
Components: scheduleActionButtons(scheduleID),
})
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session created, but failed to DM the invited user.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Session request created and sent to <@" + strconv.FormatInt(inviteeID, 10) + ">.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
action, idOrUserID := parseComponentCustomID(i.ModalSubmitData().CustomID)
if action == "create_modal" {
handleCreateModalSubmit(ctx, s, i, idOrUserID)
return
}
scheduleID := idOrUserID
if scheduleID == 0 {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid schedule ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
modalValues := modalTextInputValues(i.ModalSubmitData().Components)
datetimeInput := strings.TrimSpace(modalValues["date"]) + " " + strings.TrimSpace(modalValues["time"])
sch, err := services.Global.Schedule.GetByID(ctx, scheduleID)
if err != nil || sch == nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error retrieving schedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
userIDStr := interactionUserID(i)
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error parsing user ID.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
if userID != sch.InviteeID && userID != sch.RequesterID {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to perform this action.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
loc, _, _, err := services.Global.UserSettings.GetTimezone(ctx, userID)
if err != nil {
loc = sch.ScheduledAt.UTC().Location()
}
newTime, err := shared.ParseScheduleDatetime(datetimeInput, loc)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Invalid date/time. Use YYYY-MM-DD for the date and HH:MM for the time.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
//Convert time to UTC before storing in the database
newTime = newTime.UTC()
err = services.Global.Schedule.ProposeReschedule(ctx, scheduleID, newTime, userID)
if err != nil {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Error proposing reschedule.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
otherID := sch.RequesterID
if userID == sch.RequesterID {
otherID = sch.InviteeID
}
dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10))
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).")
return
}
embed := &discordgo.MessageEmbed{
Title: "Reschedule Proposal",
Description: fmt.Sprintf("<@%d> has proposed a new time for your session request. Please respond.", userID),
Color: 0x4caf50,
}
// Show both UTC and invitee-local time if available.
utcStr := newTime.Format("2006-01-02 15:04")
locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), otherID)
if err != nil {
locInv = time.UTC
zoneInv = "UTC"
}
localInvStr := newTime.In(locInv).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{
{
Name: "When (UTC)",
Value: utcStr,
},
{
Name: "When (" + zoneInv + ")",
Value: localInvStr,
},
}
_, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "A new time has been proposed for your session:",
Embed: embed,
Components: rescheduleProposalButtons(scheduleID),
})
if err != nil {
shared.RespondEphemeral(s, i, "Failed to DM the other participant.")
return
}
shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.")
}
func parseComponentCustomID(customID string) (string, int64) {
parts := strings.Split(customID, ":")
if len(parts) != 3 {
return "", 0
}
action := parts[1]
scheduleID, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return "", 0
}
return action, scheduleID
}
func interactionUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
+47 -197
View File
@@ -8,6 +8,7 @@ import (
"time" "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,135 +82,26 @@ 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) {
guildID, ok := shared.ParseGuildID(s, i) guildID, ok := shared.ParseGuildID(s, i)
if !ok { if !ok {
@@ -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,51 +240,32 @@ 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} {
locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid)
if err != nil { if err != nil {
locU = time.UTC shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).")
zoneU = "UTC" return
}
localU := when.In(locU).Format("2006-01-02 15:04")
msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID)
if sch.Description != "" {
msg += "\nTopic: " + sch.Description
}
ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10))
if err != nil {
continue
}
_, _ = s.ChannelMessageSend(ch.ID, msg)
} }
// Send new "request" DM to invitee with reactions again.
inviteeStr := strconv.FormatInt(sch.InviteeID, 10)
inviteeUser, err := s.User(inviteeStr)
if err == nil && inviteeUser != nil {
dmCh, err := s.UserChannelCreate(inviteeStr)
if err == nil {
embed := &discordgo.MessageEmbed{ embed := &discordgo.MessageEmbed{
Title: "Rescheduled session", Title: "Reschedule proposed",
Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), Description: fmt.Sprintf("<@%s> has proposed a new time for your session.", i.Member.User.ID),
Color: 0xffc107, Color: 0x4caf50,
} }
// Show both UTC and invitee-local time if available. // Show both UTC and the other participant's local time if available.
locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID) locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID)
if errTZ != nil { if errTZ != nil {
locInv = time.UTC locOther = time.UTC
zoneInv = "UTC" zoneOther = "UTC"
} }
localInvStr := when.In(locInv).Format("2006-01-02 15:04") localOtherStr := when.In(locOther).Format("2006-01-02 15:04")
embed.Fields = []*discordgo.MessageEmbedField{ embed.Fields = []*discordgo.MessageEmbedField{
{ {
Name: "When (UTC)", Name: "When (UTC)",
Value: whenFmt, Value: whenFmt,
}, },
{ {
Name: "When (" + zoneInv + ")", Name: "When (" + zoneOther + ")",
Value: localInvStr, Value: localOtherStr,
}, },
} }
if sch.Description != "" { if sch.Description != "" {
@@ -418,22 +274,16 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) {
Value: sch.Description, Value: sch.Description,
}) })
} }
msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.", _, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{
Content: "A new time has been proposed for your session:",
Embed: embed, Embed: embed,
Components: rescheduleProposalButtons(sch.ID),
}) })
if err == nil && msgObj != nil { if err != nil {
for _, emoji := range []string{"✅", "❌", "🔁"} { shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.")
_ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji) return
}
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.") 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
} }
+2 -6
View File
@@ -18,9 +18,8 @@ type Config struct {
} }
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 == "" {
+57
View File
@@ -0,0 +1,57 @@
package musicrepo
import (
"context"
"database/sql"
"time"
)
type QueueEntry struct {
Title string
URL string
RequestedBy int64
}
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) error {
const q = `INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (guild_id) DO UPDATE SET track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5, paused = $6, volume = $7`
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume)
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
}
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()
const q = `DELETE FROM music_queue WHERE guild_id = $1`
_, err = tx.ExecContext(ctx, q, guildID)
if err != nil {
return err
}
for i, entry := range entries {
const q = `INSERT INTO music_queue (guild_id, track_title, track_url, requested_by, position) VALUES ($1, $2, $3, $4, $5)`
_, err = tx.ExecContext(ctx, q, guildID, entry.Title, entry.URL, entry.RequestedBy, i)
if err != nil {
return err
}
}
return tx.Commit()
}
+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
+44 -31
View File
@@ -25,6 +25,9 @@ type Schedule struct {
RequesterID int64 RequesterID int64
InviteeID int64 InviteeID int64
ScheduledAt time.Time ScheduledAt time.Time
ProposedAt *time.Time
ProposedBy *int64
PreRescheduleStatus *ScheduleStatus
Status ScheduleStatus Status ScheduleStatus
Description string Description string
ReminderSent bool ReminderSent bool
@@ -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
+110 -7
View File
@@ -3,9 +3,12 @@ package music
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"net/url" "net/url"
"strconv"
"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"
@@ -16,6 +19,8 @@ import (
type TrackEntry struct { type TrackEntry struct {
Track lavalink.Track Track lavalink.Track
RequestedBy string RequestedBy string
RequesterID string
StartedAt time.Time
} }
type guildPlayer struct { type guildPlayer struct {
@@ -33,6 +38,7 @@ type guildPlayer struct {
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 +46,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 +59,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 +74,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
// failure, disgolink retries indefinitely with backoff) — do it in the
// background so bot startup isn't held hostage by it. Music commands
// simply fail with "not initialized" via the manager==nil checks until
// this completes.
go func() {
_, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
Name: "main", Name: "main",
Address: u.Host, Address: u.Host,
Password: password, Password: password,
Secure: secure, Secure: secure,
}) })
if err != nil { if err != nil {
return fmt.Errorf("add lavalink node: %w", err) log.Printf("music: failed to connect to lavalink node: %v", err)
return
} }
manager = m manager = m
go idleDisconnectLoop() go idleDisconnectLoop()
}()
return nil return nil
} }
@@ -183,7 +198,80 @@ 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 || manager.repo == nil {
return
}
manager.mu.Lock()
gp, ok := manager.players[guidID]
if !ok {
manager.mu.Unlock()
return
}
queue := make([]TrackEntry, len(gp.Queue))
copy(queue, gp.Queue)
paused := gp.Paused
volume := gp.Volume
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)
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,
})
}
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
if err != nil {
return
}
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID string) (*TrackEntry, bool, error) {
if manager == nil { if manager == nil {
return nil, false, fmt.Errorf("music manager not initialized") return nil, false, fmt.Errorf("music manager not initialized")
} }
@@ -257,6 +345,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
entries[idx] = TrackEntry{ entries[idx] = TrackEntry{
Track: t, Track: t,
RequestedBy: requester, RequestedBy: requester,
RequesterID: requesterID,
} }
} }
firstEntry := entries[0] firstEntry := entries[0]
@@ -268,6 +357,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,6 +367,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
} }
} }
syncState(guildID)
return &firstEntry, shouldStart, nil return &firstEntry, shouldStart, nil
} }
@@ -298,6 +391,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 +402,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 +424,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 +439,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 +453,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 +484,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 +499,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 +521,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())
} }
@@ -522,6 +626,7 @@ func SetVolume(guildID string, volume int) error {
postNowPlaying(guildID, textChannelID, *current) postNowPlaying(guildID, textChannelID, *current)
} }
} }
syncState(guildID)
return nil return nil
} }
@@ -534,5 +639,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)
if err != nil { if err != nil {
log.Fatalf("Error creating bot: %v", err) log.Fatalf("Error creating bot: %v", err)
return return
+49 -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,42 @@ 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
);
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()
);
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')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);