From 5024af2789f324a5d53ad8f7da0efec91fab5d29 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Tue, 18 Aug 2026 19:22:21 +0100 Subject: [PATCH 1/4] feat: schedule rework --- docker-compose.yml | 8 +- internal/bot/bot.go | 9 +- internal/commands/commands.go | 13 +- internal/commands/music/public/queue.go | 1 - .../commands/schedule/public/components.go | 794 ++++++++++++++++++ internal/commands/schedule/public/schedule.go | 288 ++----- internal/commands/schedule/registry.go | 10 +- internal/commands/schedule/shared/shared.go | 19 +- internal/db/repos/schedulerepo/repo.go | 93 +- internal/db/services/schedule/service.go | 19 +- internal/events/schedule_reactions.go | 95 --- internal/music/manager.go | 31 +- schema.sql | 9 +- 13 files changed, 997 insertions(+), 392 deletions(-) create mode 100644 internal/commands/schedule/public/components.go delete mode 100644 internal/events/schedule_reactions.go diff --git a/docker-compose.yml b/docker-compose.yml index ba28e18..4e1069d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: db: image: postgres:16 @@ -33,8 +31,6 @@ services: condition: service_healthy networks: - default - - lavalink -networks: - lavalink: - external: true \ No newline at end of file +volumes: + velox_pgdata: diff --git a/internal/bot/bot.go b/internal/bot/bot.go index bedea5d..ff3f3b3 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -32,16 +32,13 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di // - MessageCreate (leveling): GuildMessages // - GuildMemberAdd (welcome messages): GuildMembers // - VoiceStateUpdate (meeting lobby): GuildVoiceStates - // - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildBans | discordgo.IntentsGuildMembers | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent | discordgo.IntentsGuildVoiceStates | - discordgo.IntentsGuildMessageReactions | - discordgo.IntentsDirectMessages | - discordgo.IntentsDirectMessageReactions + discordgo.IntentsDirectMessages return &Bot{ Session: session, @@ -122,10 +119,6 @@ func (b *Bot) Start() error { music.OnVoiceServerUpdate(ev) }) - b.Session.AddHandler(func(s *discordgo.Session, r *discordgo.MessageReactionAdd) { - events.HandleMessageReactionAdd(s, r, b.Services) - }) - events.StartScheduleReminderLoop(b.Session, b.Services) events.StartTwitchLiveLoop(b.Session, b.Services) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 16a972d..afd0727 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -1,6 +1,7 @@ package commands import ( + "strings" "velox-bot/internal/commands/config" "velox-bot/internal/commands/fun" "velox-bot/internal/commands/help" @@ -72,8 +73,18 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { } case discordgo.InteractionMessageComponent: data := i.MessageComponentData() - if len(data.CustomID) >= 6 && data.CustomID[:6] == "music:" { + switch { + case strings.HasPrefix(data.CustomID, "music:"): music.HandleComponent(s, i) + case strings.HasPrefix(data.CustomID, "schedule:"): + schedule.HandleComponent(s, i) + } + case discordgo.InteractionModalSubmit: + data := i.ModalSubmitData() + switch { + case strings.HasPrefix(data.CustomID, "schedule:"): + schedule.HandleModalSubmit(s, i) } } + } diff --git a/internal/commands/music/public/queue.go b/internal/commands/music/public/queue.go index 0281bd3..b6d30c1 100644 --- a/internal/commands/music/public/queue.go +++ b/internal/commands/music/public/queue.go @@ -74,4 +74,3 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { }, }) } - diff --git a/internal/commands/schedule/public/components.go b/internal/commands/schedule/public/components.go new file mode 100644 index 0000000..bd1bd0c --- /dev/null +++ b/internal/commands/schedule/public/components.go @@ -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 "" +} diff --git a/internal/commands/schedule/public/schedule.go b/internal/commands/schedule/public/schedule.go index e8a6c9f..3fd0f32 100644 --- a/internal/commands/schedule/public/schedule.go +++ b/internal/commands/schedule/public/schedule.go @@ -8,6 +8,7 @@ import ( "time" "velox-bot/internal/commands/schedule/shared" + "velox-bot/internal/db/repos/schedulerepo" "velox-bot/internal/db/services" "github.com/bwmarrin/discordgo" @@ -21,26 +22,6 @@ var Schedule = &discordgo.ApplicationCommand{ Type: discordgo.ApplicationCommandOptionSubCommand, Name: "create", Description: "Create a new session with a user", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionUser, - Name: "user", - Description: "User to invite", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "datetime", - Description: "When (UTC), format: 2006-01-02 15:04", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "description", - Description: "What is this session about? (optional)", - Required: false, - }, - }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, @@ -93,8 +74,7 @@ func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { } func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { - guildID, ok := shared.ParseGuildID(s, i) - if !ok { + if _, ok := shared.ParseGuildID(s, i); !ok { return } if i.Member == nil || i.Member.User == nil { @@ -102,133 +82,24 @@ func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - data := i.ApplicationCommandData() - if len(data.Options) == 0 { - shared.RespondEphemeral(s, i, "Missing options.") - return - } - opt := data.Options[0] - - var ( - targetUser *discordgo.User - whenStr string - desc string - ) - for _, o := range opt.Options { - switch o.Name { - case "user": - targetUser = o.UserValue(s) - case "datetime": - whenStr = o.StringValue() - case "description": - desc = o.StringValue() - } - } - - if targetUser == nil { - shared.RespondEphemeral(s, i, "Invalid user.") - return - } - if targetUser.ID == i.Member.User.ID { - shared.RespondEphemeral(s, i, "You cannot schedule a session with yourself.") - return - } - - requesterID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid user ID.") - return - } - inviteeID, err := strconv.ParseInt(targetUser.ID, 10, 64) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid target user ID.") - return - } - - whenStr = strings.TrimSpace(whenStr) - if whenStr == "" { - shared.RespondEphemeral(s, i, "Datetime cannot be empty.") - return - } - - // Resolve requester's timezone and parse input using explicit layout in that zone. - loc, zone, _, err := services.Global.UserSettings.GetTimezone(context.Background(), requesterID) - if err != nil { - loc = time.UTC - zone = "UTC" - } - // Expect layout "2006-01-02 15:04" in the user's timezone. - when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") - return - } - when = when.In(time.UTC) - - ctx := context.Background() - scheduleID, err := services.Global.Schedule.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, desc) - if err != nil { - shared.RespondEphemeral(s, i, "Failed to create schedule.") - return - } - - // Send DM to invitee. - dmCh, err := s.UserChannelCreate(targetUser.ID) - if err != nil { - shared.RespondEphemeral(s, i, "Failed to DM the invited user (are DMs disabled?).") - return - } - - embed := &discordgo.MessageEmbed{ - Title: "New session request", - Description: fmt.Sprintf("You have been invited to a session by <@%s>.", i.Member.User.ID), - Color: 0x4caf50, - } - // Show both UTC and invitee-local time if available. - utcStr := when.Format("2006-01-02 15:04") - locInv, zoneInv, _, err := services.Global.UserSettings.GetTimezone(context.Background(), inviteeID) - if err != nil { - locInv = time.UTC - zoneInv = "UTC" - } - localInvStr := when.In(locInv).Format("2006-01-02 15:04") - embed.Fields = []*discordgo.MessageEmbedField{ - { - Name: "When (UTC)", - Value: utcStr, + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Who do you want to schedule a session with?", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + MenuType: discordgo.UserSelectMenu, + CustomID: createUserSelectCustomID, + Placeholder: "Select a user", + }, + }, + }, + }, }, - { - Name: "When (" + zoneInv + ")", - Value: localInvStr, - }, - } - if desc != "" { - embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ - Name: "Description", - Value: desc, - }) - } - - msg, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ - Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request rescheduling.", - Embed: embed, }) - if err != nil { - shared.RespondEphemeral(s, i, "Failed to DM the invited user.") - return - } - - // Add reactions for interaction. - for _, emoji := range []string{"✅", "❌", "🔁"} { - _ = s.MessageReactionAdd(dmCh.ID, msg.ID, emoji) - } - - // Store linkage between message and schedule. - msgID64, _ := strconv.ParseInt(msg.ID, 10, 64) - chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) - _ = services.Global.Schedule.AddMessage(ctx, scheduleID, msgID64, chID64, true) - - shared.RespondEphemeral(s, i, "Session request created and sent to "+targetUser.Mention()+".") } func handleList(s *discordgo.Session, i *discordgo.InteractionCreate) { @@ -338,6 +209,11 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { return } + if sch.Status != schedulerepo.StatusPending && sch.Status != schedulerepo.StatusAccepted { + shared.RespondEphemeral(s, i, "This session can't be rescheduled right now.") + return + } + // Use rescheduler's timezone for parsing. loc, zone, _, err := services.Global.UserSettings.GetTimezone(ctx, userID) if err != nil { @@ -345,19 +221,18 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { zone = "UTC" } // Expect explicit layout "2006-01-02 15:04" in user's timezone. - when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, loc) + when, err := shared.ParseScheduleDatetime(whenStr, loc) if err != nil { - shared.RespondEphemeral(s, i, "Invalid datetime. Use `2006-01-02 15:04` in your timezone ("+zone+").") + shared.RespondEphemeral(s, i, err.Error()+" (use `2006-01-02 15:04` in your timezone: "+zone+")") return } when = when.In(time.UTC) - if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil { - shared.RespondEphemeral(s, i, "Failed to reschedule session.") + if err := services.Global.Schedule.ProposeReschedule(ctx, sch.ID, when, userID); err != nil { + shared.RespondEphemeral(s, i, "Failed to propose reschedule.") return } - // Notify participants via DM and send new request DM to invitee. whenFmt := when.Format("2006-01-02 15:04") otherID := sch.InviteeID @@ -365,75 +240,50 @@ func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { otherID = sch.RequesterID } - // DM both about the change, including their local times if available. - for _, uid := range []int64{sch.RequesterID, sch.InviteeID} { - locU, zoneU, _, err := services.Global.UserSettings.GetTimezone(ctx, uid) - if err != nil { - locU = time.UTC - zoneU = "UTC" - } - localU := when.In(locU).Format("2006-01-02 15:04") - msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC (%s %s) with <@%d>.", sch.ID, whenFmt, localU, zoneU, otherID) - if sch.Description != "" { - msg += "\nTopic: " + sch.Description - } - ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10)) - if err != nil { - continue - } - _, _ = s.ChannelMessageSend(ch.ID, msg) + dmCh, err := s.UserChannelCreate(strconv.FormatInt(otherID, 10)) + if err != nil { + shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant (are DMs disabled?).") + return } - // Send new "request" DM to invitee with reactions again. - inviteeStr := strconv.FormatInt(sch.InviteeID, 10) - inviteeUser, err := s.User(inviteeStr) - if err == nil && inviteeUser != nil { - dmCh, err := s.UserChannelCreate(inviteeStr) - if err == nil { - embed := &discordgo.MessageEmbed{ - Title: "Rescheduled session", - Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), - Color: 0xffc107, - } - // Show both UTC and invitee-local time if available. - locInv, zoneInv, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, sch.InviteeID) - if errTZ != nil { - locInv = time.UTC - zoneInv = "UTC" - } - localInvStr := when.In(locInv).Format("2006-01-02 15:04") - embed.Fields = []*discordgo.MessageEmbedField{ - { - Name: "When (UTC)", - Value: whenFmt, - }, - { - Name: "When (" + zoneInv + ")", - Value: localInvStr, - }, - } - if sch.Description != "" { - embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ - Name: "Description", - Value: sch.Description, - }) - } - msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ - Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.", - Embed: embed, - }) - if err == nil && msgObj != nil { - for _, emoji := range []string{"✅", "❌", "🔁"} { - _ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji) - } - msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64) - chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) - _ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true) - } - } + embed := &discordgo.MessageEmbed{ + Title: "Reschedule proposed", + Description: fmt.Sprintf("<@%s> has proposed a new time for your session.", i.Member.User.ID), + Color: 0x4caf50, + } + // Show both UTC and the other participant's local time if available. + locOther, zoneOther, _, errTZ := services.Global.UserSettings.GetTimezone(ctx, otherID) + if errTZ != nil { + locOther = time.UTC + zoneOther = "UTC" + } + localOtherStr := when.In(locOther).Format("2006-01-02 15:04") + embed.Fields = []*discordgo.MessageEmbedField{ + { + Name: "When (UTC)", + Value: whenFmt, + }, + { + Name: "When (" + zoneOther + ")", + Value: localOtherStr, + }, + } + if sch.Description != "" { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: sch.Description, + }) } - shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.") + _, err = s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ + Content: "A new time has been proposed for your session:", + Embed: embed, + Components: rescheduleProposalButtons(sch.ID), + }) + if err != nil { + shared.RespondEphemeral(s, i, "Proposal saved, but failed to DM the other participant.") + return + } + + shared.RespondEphemeral(s, i, "Proposed new time sent to <@"+strconv.FormatInt(otherID, 10)+">.") } - - diff --git a/internal/commands/schedule/registry.go b/internal/commands/schedule/registry.go index 63d7d13..c929cc2 100644 --- a/internal/commands/schedule/registry.go +++ b/internal/commands/schedule/registry.go @@ -10,5 +10,13 @@ var ( Schedule *discordgo.ApplicationCommand = public.Schedule ) -func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ScheduleHandler(s, i) } +func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.ScheduleHandler(s, i) +} +func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.HandleComponent(s, i) +} +func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.HandleModalSubmit(s, i) +} diff --git a/internal/commands/schedule/shared/shared.go b/internal/commands/schedule/shared/shared.go index ffc667e..833b20c 100644 --- a/internal/commands/schedule/shared/shared.go +++ b/internal/commands/schedule/shared/shared.go @@ -1,13 +1,22 @@ package shared import ( + "fmt" "strconv" + "time" "velox-bot/internal/db/services" "github.com/bwmarrin/discordgo" ) +var scheduleDatetimeLayouts = []string{ + "2006-01-02 15:04", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "01/02/2006 15:04", +} + func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, @@ -18,6 +27,15 @@ func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg }) } +func ParseScheduleDatetime(input string, loc *time.Location) (time.Time, error) { + for _, layout := range scheduleDatetimeLayouts { + if t, err := time.ParseInLocation(layout, input, loc); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("invalid schedule datetime format") +} + func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { if i.GuildID == "" { RespondEphemeral(s, i, "This command can only be used in a server.") @@ -42,4 +60,3 @@ func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, } return guildID, true } - diff --git a/internal/db/repos/schedulerepo/repo.go b/internal/db/repos/schedulerepo/repo.go index 6b628fb..30ab845 100644 --- a/internal/db/repos/schedulerepo/repo.go +++ b/internal/db/repos/schedulerepo/repo.go @@ -20,15 +20,18 @@ const ( ) type Schedule struct { - ID int64 - GuildID int64 - RequesterID int64 - InviteeID int64 - ScheduledAt time.Time - Status ScheduleStatus - Description string - ReminderSent bool - CreatedAt time.Time + ID int64 + GuildID int64 + RequesterID int64 + InviteeID int64 + ScheduledAt time.Time + ProposedAt *time.Time + ProposedBy *int64 + PreRescheduleStatus *ScheduleStatus + Status ScheduleStatus + Description string + ReminderSent bool + CreatedAt time.Time } func NewRepo(db *sql.DB) *Repo { @@ -48,35 +51,6 @@ func (r *Repo) CreateSchedule(ctx context.Context, guildID, requesterID, invitee return id, nil } -func (r *Repo) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error { - const q = ` - INSERT INTO schedule_messages (schedule_id, message_id, channel_id, is_dm) - VALUES ($1, $2, $3, $4) - ON CONFLICT (schedule_id, message_id) DO NOTHING - ` - _, err := r.db.ExecContext(ctx, q, scheduleID, messageID, channelID, isDM) - return err -} - -func (r *Repo) GetByMessageID(ctx context.Context, messageID int64) (*Schedule, error) { - const q = ` - SELECT s.id, s.guild_id, s.requester_id, s.invitee_id, s.scheduled_at, s.status, s.description, s.reminder_sent, s.created_at - FROM schedules s - JOIN schedule_messages m ON m.schedule_id = s.id - WHERE m.message_id = $1 - LIMIT 1 - ` - row := r.db.QueryRowContext(ctx, q, messageID) - var sch Schedule - if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, err - } - return &sch, nil -} - func (r *Repo) UpdateStatus(ctx context.Context, scheduleID int64, status ScheduleStatus) error { const q = ` UPDATE schedules @@ -119,13 +93,13 @@ func (r *Repo) ListForUser(ctx context.Context, guildID, userID int64, limit int func (r *Repo) GetByID(ctx context.Context, id int64) (*Schedule, error) { const q = ` - SELECT id, guild_id, requester_id, invitee_id, scheduled_at, status, description, reminder_sent, created_at + SELECT id, guild_id, requester_id, invitee_id, scheduled_at, proposed_at, proposed_by, pre_reschedule_status, status, description, reminder_sent, created_at FROM schedules WHERE id = $1 ` row := r.db.QueryRowContext(ctx, q, id) var sch Schedule - if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { + if err := row.Scan(&sch.ID, &sch.GuildID, &sch.RequesterID, &sch.InviteeID, &sch.ScheduledAt, &sch.ProposedAt, &sch.ProposedBy, &sch.PreRescheduleStatus, &sch.Status, &sch.Description, &sch.ReminderSent, &sch.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } @@ -186,4 +160,43 @@ func (r *Repo) MarkReminded(ctx context.Context, scheduleID int64) error { return err } +func (r *Repo) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error { + const q = ` + UPDATE schedules + SET proposed_at = $1, + proposed_by = $2, + pre_reschedule_status = status, + status = 'reschedule_requested' + WHERE id = $3 + ` + _, err := r.db.ExecContext(ctx, q, newTime, proposedBy, scheduleID) + return err +} +func (r *Repo) AcceptReschedule(ctx context.Context, scheduleID int64) error { + const q = ` + UPDATE schedules + SET scheduled_at = proposed_at, + proposed_at = NULL, + proposed_by = NULL, + pre_reschedule_status = NULL, + status = 'accepted', + reminder_sent = FALSE + WHERE id = $1 + ` + _, err := r.db.ExecContext(ctx, q, scheduleID) + return err +} + +func (r *Repo) DeclineReschedule(ctx context.Context, scheduleID int64) error { + const q = ` + UPDATE schedules + SET proposed_at = NULL, + proposed_by = NULL, + status = pre_reschedule_status, + pre_reschedule_status = NULL + WHERE id = $1 + ` + _, err := r.db.ExecContext(ctx, q, scheduleID) + return err +} diff --git a/internal/db/services/schedule/service.go b/internal/db/services/schedule/service.go index 6e74277..b7b60c8 100644 --- a/internal/db/services/schedule/service.go +++ b/internal/db/services/schedule/service.go @@ -19,14 +19,6 @@ func (s *Service) CreateSchedule(ctx context.Context, guildID, requesterID, invi return s.repo.CreateSchedule(ctx, guildID, requesterID, inviteeID, when, description) } -func (s *Service) AddMessage(ctx context.Context, scheduleID, messageID, channelID int64, isDM bool) error { - return s.repo.AddMessage(ctx, scheduleID, messageID, channelID, isDM) -} - -func (s *Service) GetByMessageID(ctx context.Context, messageID int64) (*schedulerepo.Schedule, error) { - return s.repo.GetByMessageID(ctx, messageID) -} - func (s *Service) UpdateStatus(ctx context.Context, scheduleID int64, status schedulerepo.ScheduleStatus) error { return s.repo.UpdateStatus(ctx, scheduleID, status) } @@ -51,3 +43,14 @@ func (s *Service) Reschedule(ctx context.Context, id int64, when time.Time) erro return s.repo.Reschedule(ctx, id, when) } +func (s *Service) ProposeReschedule(ctx context.Context, scheduleID int64, newTime time.Time, proposedBy int64) error { + return s.repo.ProposeReschedule(ctx, scheduleID, newTime, proposedBy) +} + +func (s *Service) AcceptReschedule(ctx context.Context, id int64) error { + return s.repo.AcceptReschedule(ctx, id) +} + +func (s *Service) DeclineReschedule(ctx context.Context, id int64) error { + return s.repo.DeclineReschedule(ctx, id) +} diff --git a/internal/events/schedule_reactions.go b/internal/events/schedule_reactions.go deleted file mode 100644 index f0aca0e..0000000 --- a/internal/events/schedule_reactions.go +++ /dev/null @@ -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) - } - } - } -} - diff --git a/internal/music/manager.go b/internal/music/manager.go index 1a4fce1..dd6b593 100644 --- a/internal/music/manager.go +++ b/internal/music/manager.go @@ -3,6 +3,7 @@ package music import ( "context" "fmt" + "log" "net/url" "sync" "time" @@ -67,18 +68,26 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) password = "youshallnotpass" } - _, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{ - Name: "main", - Address: u.Host, - Password: password, - Secure: secure, - }) - if err != nil { - return fmt.Errorf("add lavalink node: %w", err) - } + // Connecting to the Lavalink node is a blocking network call (and, on + // failure, disgolink retries indefinitely with backoff) — do it in the + // background so bot startup isn't held hostage by it. Music commands + // simply fail with "not initialized" via the manager==nil checks until + // this completes. + go func() { + _, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{ + Name: "main", + Address: u.Host, + Password: password, + Secure: secure, + }) + if err != nil { + log.Printf("music: failed to connect to lavalink node: %v", err) + return + } + manager = m + go idleDisconnectLoop() + }() - manager = m - go idleDisconnectLoop() return nil } diff --git a/schema.sql b/schema.sql index e5dfcde..ab1fd12 100644 --- a/schema.sql +++ b/schema.sql @@ -110,7 +110,14 @@ CREATE TABLE IF NOT EXISTS schedules ( status TEXT NOT NULL DEFAULT 'pending', -- pending, accepted, declined, reschedule_requested description TEXT, reminder_sent BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Reschedule negotiation: a proposed new time awaiting the counterpart's + -- response, kept separate from scheduled_at until accepted. + proposed_at TIMESTAMPTZ, + proposed_by BIGINT, + -- Snapshot of status before entering reschedule_requested, so a decline + -- knows whether to revert to 'pending' or 'accepted'. + pre_reschedule_status TEXT ); CREATE TABLE IF NOT EXISTS schedule_messages ( -- 2.54.0 From 01dbb98450dd68db6f0fc09d55de63f381e64c36 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Tue, 18 Aug 2026 21:20:31 +0100 Subject: [PATCH 2/4] Command rework --- internal/commands/commands.go | 4 + .../commands/projects/public/components.go | 222 +++++++++++++++ internal/commands/projects/public/projects.go | 261 ++++++++++-------- internal/commands/projects/registry.go | 6 + internal/db/repos/projectsrepo/repo.go | 60 ++++ internal/db/repos/twitchrepo/repo.go | 28 ++ internal/db/services/projects/service.go | 12 + internal/db/services/twitch/service.go | 8 + internal/events/twitch_live.go | 10 + internal/music/components.go | 5 +- schema.sql | 3 +- 11 files changed, 502 insertions(+), 117 deletions(-) create mode 100644 internal/commands/projects/public/components.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go index afd0727..13d5bb0 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -78,12 +78,16 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { 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) } } diff --git a/internal/commands/projects/public/components.go b/internal/commands/projects/public/components.go new file mode 100644 index 0000000..a36bd0d --- /dev/null +++ b/internal/commands/projects/public/components.go @@ -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 +} diff --git a/internal/commands/projects/public/projects.go b/internal/commands/projects/public/projects.go index cc2bc43..a4de2a3 100644 --- a/internal/commands/projects/public/projects.go +++ b/internal/commands/projects/public/projects.go @@ -24,39 +24,16 @@ var Projects = &discordgo.ApplicationCommand{ Type: discordgo.ApplicationCommandOptionSubCommand, Name: "create", Description: "Create a new active project", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "name", - Description: "Project name", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "description", - Description: "Short description (optional)", - Required: false, - }, - }, }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add-helper", Description: "Add a helper to an existing project", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "project-id", - Description: "ID of the project", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionUser, - Name: "user", - Description: "User to add as helper", - Required: true, - }, - }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "close", + Description: "Close (deactivate) a project", }, }, } @@ -79,6 +56,8 @@ func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { handleCreate(s, i) case "add-helper": handleAddHelper(s, i) + case "close": + handleClose(s, i) default: shared.RespondEphemeral(s, i, "Unknown subcommand.") } @@ -154,56 +133,53 @@ func mentionUser(id int64) string { return "<@" + strconv.FormatInt(id, 10) + ">" } +// handleCreate just opens the modal - the actual creation happens on +// submit, in HandleModalSubmit (components.go). func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { if !shared.RequireManageGuild(s, i) { return } - guildID, ok := shared.ParseGuildID(s, i) - if !ok { - return - } - if i.Member == nil || i.Member.User == nil { - shared.RespondEphemeral(s, i, "Missing member info.") - return - } - - data := i.ApplicationCommandData() - if len(data.Options) == 0 { - shared.RespondEphemeral(s, i, "Missing options.") - return - } - opt := data.Options[0] - - var name, description string - for _, o := range opt.Options { - switch o.Name { - case "name": - name = o.StringValue() - case "description": - description = o.StringValue() - } - } - if strings.TrimSpace(name) == "" { - shared.RespondEphemeral(s, i, "Project name cannot be empty.") - return - } - - creatorID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid user ID.") - return - } - - id, err := services.Global.Projects.CreateProject(context.Background(), guildID, creatorID, name, description) - if err != nil { - shared.RespondEphemeral(s, i, "Failed to create project.") - return - } - - shared.RespondEphemeral(s, i, "Created project **"+name+"** with ID `"+strconv.FormatInt(id, 10)+"`.") + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "projects:create_modal", + Title: "Create a project", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "name", + Label: "Project name", + Style: discordgo.TextInputShort, + Required: true, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "description", + Label: "Description (optional)", + Style: discordgo.TextInputParagraph, + Required: false, + }, + }, + }, + }, + }, + }) } +// handleAddHelper collects the user via Discord's native user-option (a +// modal can't hold one of these), then responds with a select menu of the +// guild's active projects instead of making the caller remember/type a +// numeric project ID - the actual add-helper call happens on selection, in +// HandleComponent (components.go). +// handleAddHelper is the first of two dropdown steps: pick a project here, +// then HandleComponent (components.go) follows up with a native Discord +// user-select to pick who helps, and performs the actual AddHelper call +// once both are known. func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) { if !shared.RequireManageGuild(s, i) { return @@ -214,55 +190,110 @@ func handleAddHelper(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - data := i.ApplicationCommandData() - if len(data.Options) == 0 { - shared.RespondEphemeral(s, i, "Missing options.") - return - } - opt := data.Options[0] - - var projectID int64 - var userID string - for _, o := range opt.Options { - switch o.Name { - case "project-id": - projectID = o.IntValue() - case "user": - if u := o.UserValue(s); u != nil { - userID = u.ID - } - } - } - if projectID <= 0 { - shared.RespondEphemeral(s, i, "Invalid project ID.") - return - } - if userID == "" { - shared.RespondEphemeral(s, i, "Invalid user.") - return - } - - exists, err := services.Global.Projects.ProjectExistsInGuild(context.Background(), guildID, projectID) + options, err := activeProjectOptions(context.Background(), guildID) if err != nil { - shared.RespondEphemeral(s, i, "Failed to load project.") + shared.RespondEphemeral(s, i, "Failed to load projects.") return } - if !exists { - shared.RespondEphemeral(s, i, "Project `"+strconv.FormatInt(projectID, 10)+"` not found in this server.") + if len(options) == 0 { + shared.RespondEphemeral(s, i, "There are no active projects to add a helper to.") return } - userID64, err := strconv.ParseInt(userID, 10, 64) - if err != nil { - shared.RespondEphemeral(s, i, "Invalid user ID.") - return - } - - if err := services.Global.Projects.AddHelper(context.Background(), projectID, userID64); err != nil { - shared.RespondEphemeral(s, i, "Failed to add helper to project. ("+err.Error()+")") - return - } - - shared.RespondEphemeral(s, i, "Added <@"+userID+"> as helper to project `"+strconv.FormatInt(projectID, 10)+"`.") + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Which project should get a new helper?", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "projects:add_helper_project_select", + Placeholder: "Select a project", + Options: options, + }, + }, + }, + }, + }, + }) } +// handleClose responds with a select menu of the guild's active projects - +// same reasoning as handleAddHelper, picking from a live list beats typing +// an ID. The actual deactivation happens on selection, in HandleComponent. +func handleClose(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + options, err := activeProjectOptions(context.Background(), guildID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load projects.") + return + } + if len(options) == 0 { + shared.RespondEphemeral(s, i, "There are no active projects to close.") + return + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Which project should be closed?", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "projects:close_select", + Placeholder: "Select a project", + Options: options, + }, + }, + }, + }, + }, + }) +} + +// activeProjectOptions builds select-menu options for the guild's active +// projects, truncating name/description to Discord's option limits +// (label: 100 chars, description: 100 chars). +func activeProjectOptions(ctx context.Context, guildID int64) ([]discordgo.SelectMenuOption, error) { + const limit = 25 // Discord's own cap on select menu options + items, err := services.Global.Projects.ListActive(ctx, guildID, limit) + if err != nil { + return nil, err + } + + options := make([]discordgo.SelectMenuOption, 0, len(items)) + for _, p := range items { + if p == nil { + continue + } + name := p.Name + if name == "" { + name = "Unnamed project" + } + options = append(options, discordgo.SelectMenuOption{ + Label: truncate(name, 100), + Value: strconv.FormatInt(p.ID, 10), + Description: truncate(p.Description, 100), + }) + } + return options, nil +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/internal/commands/projects/registry.go b/internal/commands/projects/registry.go index ff3f81f..a2915f0 100644 --- a/internal/commands/projects/registry.go +++ b/internal/commands/projects/registry.go @@ -12,3 +12,9 @@ var ( func ProjectsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.ProjectsHandler(s, i) } +func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { public.HandleComponent(s, i) } + +func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.HandleModalSubmit(s, i) +} + diff --git a/internal/db/repos/projectsrepo/repo.go b/internal/db/repos/projectsrepo/repo.go index 54383d2..aa786b1 100644 --- a/internal/db/repos/projectsrepo/repo.go +++ b/internal/db/repos/projectsrepo/repo.go @@ -61,6 +61,35 @@ func (r *Repo) AddHelper(ctx context.Context, projectID, userID int64) error { return err } +func (r *Repo) DeactivateProject(ctx context.Context, guildID, projectID int64) error { + const q = ` + UPDATE projects + SET is_active = FALSE + WHERE id = $1 AND guild_id = $2 + ` + _, err := r.db.ExecContext(ctx, q, projectID, guildID) + return err +} + +// GetProject fetches a single project, scoped to guildID so a project ID +// from one server can't be used to reach into another's data. +func (r *Repo) GetProject(ctx context.Context, guildID, projectID int64) (*Project, error) { + const q = ` + SELECT id, name, description, created_by + FROM projects + WHERE id = $1 AND guild_id = $2 + ` + row := r.db.QueryRowContext(ctx, q, projectID, guildID) + p := &Project{GuildID: guildID, IsActive: true} + if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return p, nil +} + func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) { const q = ` SELECT 1 @@ -79,6 +108,37 @@ func (r *Repo) ProjectExistsInGuild(ctx context.Context, guildID, projectID int6 } } +// ListActiveProjects is a lighter version of ListActiveProjectsWithMembers, +// without the member-join query - for callers (like building a select menu +// of projects to pick from) that only need name/id, not creator/helpers. +func (r *Repo) ListActiveProjects(ctx context.Context, guildID int64, limit int) ([]*Project, error) { + const q = ` + SELECT id, name, description, created_by + FROM projects + WHERE guild_id = $1 AND is_active = TRUE + ORDER BY created_at DESC + LIMIT $2 + ` + rows, err := r.db.QueryContext(ctx, q, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*Project + for rows.Next() { + p := &Project{GuildID: guildID, IsActive: true} + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedBy); err != nil { + return nil, err + } + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func (r *Repo) ListActiveProjectsWithMembers(ctx context.Context, guildID int64, limit int) ([]*ProjectWithMembers, error) { const qProjects = ` SELECT id, name, description, created_by diff --git a/internal/db/repos/twitchrepo/repo.go b/internal/db/repos/twitchrepo/repo.go index f68e441..835064d 100644 --- a/internal/db/repos/twitchrepo/repo.go +++ b/internal/db/repos/twitchrepo/repo.go @@ -113,3 +113,31 @@ func (r *Repo) SetNotificationChannel(ctx context.Context, guildID, channelID in return err } +// IsEnabled reports whether Twitch notifications are enabled for guildID. +// A guild with no twitch_config row yet 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 +} + diff --git a/internal/db/services/projects/service.go b/internal/db/services/projects/service.go index e1687c8..09ade7e 100644 --- a/internal/db/services/projects/service.go +++ b/internal/db/services/projects/service.go @@ -26,7 +26,19 @@ func (s *Service) ListActiveWithMembers(ctx context.Context, guildID int64, limi return s.repo.ListActiveProjectsWithMembers(ctx, guildID, limit) } +func (s *Service) ListActive(ctx context.Context, guildID int64, limit int) ([]*projectsrepo.Project, error) { + return s.repo.ListActiveProjects(ctx, guildID, limit) +} + func (s *Service) ProjectExistsInGuild(ctx context.Context, guildID, projectID int64) (bool, error) { return s.repo.ProjectExistsInGuild(ctx, guildID, projectID) } +func (s *Service) GetProject(ctx context.Context, guildID, projectID int64) (*projectsrepo.Project, error) { + return s.repo.GetProject(ctx, guildID, projectID) +} + +func (s *Service) DeactivateProject(ctx context.Context, guildID, projectID int64) error { + return s.repo.DeactivateProject(ctx, guildID, projectID) +} + diff --git a/internal/db/services/twitch/service.go b/internal/db/services/twitch/service.go index 8a2024c..f612c86 100644 --- a/internal/db/services/twitch/service.go +++ b/internal/db/services/twitch/service.go @@ -59,6 +59,14 @@ func (s *Service) SetNotificationChannel(ctx context.Context, guildID, channelID return s.repo.SetNotificationChannel(ctx, guildID, channelID) } +func (s *Service) IsEnabled(ctx context.Context, guildID int64) (bool, error) { + return s.repo.IsEnabled(ctx, guildID) +} + +func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error { + return s.repo.SetEnabled(ctx, guildID, enabled) +} + func (s *Service) IsUserStreaming(ctx context.Context, username string) (bool, error) { type gqlRequest struct { Query string `json:"query"` diff --git a/internal/events/twitch_live.go b/internal/events/twitch_live.go index 9f3ae7a..38af8ea 100644 --- a/internal/events/twitch_live.go +++ b/internal/events/twitch_live.go @@ -47,6 +47,16 @@ func StartTwitchLiveLoop(s *discordgo.Session, svc *services.Services) { for _, guildID := range guildIDs { log.Printf("twitch: processing guild %d", guildID) + enabled, err := svc.Twitch.IsEnabled(ctx, guildID) + if err != nil { + log.Printf("twitch: failed to check enabled state for guild %d: %v", guildID, err) + continue + } + if !enabled { + log.Printf("twitch: notifications disabled for guild %d", guildID) + continue + } + channelID, ok, err := svc.Twitch.GetNotificationChannel(ctx, guildID) if err != nil { log.Printf("twitch: failed to get channel for guild %d: %v", guildID, err) diff --git a/internal/music/components.go b/internal/music/components.go index 214b1e5..4e1bc51 100644 --- a/internal/music/components.go +++ b/internal/music/components.go @@ -97,7 +97,10 @@ func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { content = "Done." } if err != nil { - content = "Error: " + err.Error() + // err can carry a raw Lavalink/network failure here (Player.Update), + // not just our own "music manager not initialized" - don't echo + // that detail into the ephemeral reply. + content = "That didn't work. Please try again." } // send ephemeral confirmation diff --git a/schema.sql b/schema.sql index ab1fd12..a970a6d 100644 --- a/schema.sql +++ b/schema.sql @@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS levelup ( CREATE TABLE IF NOT EXISTS twitch_config ( guild_id BIGINT PRIMARY KEY, - twitch_channel_id BIGINT + twitch_channel_id BIGINT, + enabled BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE IF NOT EXISTS rps ( -- 2.54.0 From 5f4e89397145cba8ba74a528dccaaa19dab0980a Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Wed, 26 Aug 2026 15:05:06 +0100 Subject: [PATCH 3/4] chore .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 88f1524..3887097 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -.env +*.env .cursor tmp/ -- 2.54.0 From 691b50517563d7bb97a24c58f9c7710a0d4bb0c7 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat, 29 Aug 2026 03:49:52 +0100 Subject: [PATCH 4/4] 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. --- internal/commands/commands.go | 2 + internal/commands/music/public/play.go | 5 ++ internal/commands/music/public/queue.go | 4 ++ internal/commands/music/public/toggle.go | 63 +++++++++++++++++++ internal/commands/music/public/volume.go | 5 +- internal/commands/music/registry.go | 7 ++- internal/commands/music/shared/shared.go | 61 ++++++++++++++++++ internal/db/repos/settingsrepo/repo.go | 19 ++++++ internal/db/services/musicsettings/service.go | 32 ++++++++++ internal/db/services/services.go | 5 +- main.go | 4 +- schema.sql | 39 ++++++++++++ 12 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 internal/commands/music/public/toggle.go create mode 100644 internal/commands/music/shared/shared.go create mode 100644 internal/db/services/musicsettings/service.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 13d5bb0..422f762 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -33,6 +33,7 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdmusic.Play, cmdmusic.Queue, cmdmusic.Volume, + cmdmusic.Music, meeting.Meeting, moderation.Moderation, timezone.Timezone, @@ -62,6 +63,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "play": cmdmusic.PlayHandler, "queue": cmdmusic.QueueHandler, "volume": cmdmusic.VolumeHandler, + "music": cmdmusic.MusicHandler, "config": config.ConfigHandler, } diff --git a/internal/commands/music/public/play.go b/internal/commands/music/public/play.go index d4abaf4..921dd51 100644 --- a/internal/commands/music/public/play.go +++ b/internal/commands/music/public/play.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "strings" + "velox-bot/internal/commands/music/shared" "velox-bot/internal/music" "github.com/bwmarrin/discordgo" @@ -23,6 +24,10 @@ var Play = &discordgo.ApplicationCommand{ } func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireMusicEnabled(s, i) { + return + } + data := i.ApplicationCommandData() if len(data.Options) == 0 { return diff --git a/internal/commands/music/public/queue.go b/internal/commands/music/public/queue.go index b6d30c1..ce4026d 100644 --- a/internal/commands/music/public/queue.go +++ b/internal/commands/music/public/queue.go @@ -3,6 +3,7 @@ package public import ( "fmt" "strings" + "velox-bot/internal/commands/music/shared" "velox-bot/internal/music" "github.com/bwmarrin/discordgo" @@ -14,6 +15,9 @@ var Queue = &discordgo.ApplicationCommand{ } func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireMusicEnabled(s, i) { + return + } if !memberHasDJRole(s, i.GuildID, i.Member) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, diff --git a/internal/commands/music/public/toggle.go b/internal/commands/music/public/toggle.go new file mode 100644 index 0000000..0a61941 --- /dev/null +++ b/internal/commands/music/public/toggle.go @@ -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.") +} diff --git a/internal/commands/music/public/volume.go b/internal/commands/music/public/volume.go index 4221b17..12233c9 100644 --- a/internal/commands/music/public/volume.go +++ b/internal/commands/music/public/volume.go @@ -2,6 +2,7 @@ package public import ( "fmt" + "velox-bot/internal/commands/music/shared" "velox-bot/internal/music" "github.com/bwmarrin/discordgo" @@ -23,6 +24,9 @@ var Volume = &discordgo.ApplicationCommand{ } func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireMusicEnabled(s, i) { + return + } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ @@ -60,4 +64,3 @@ func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { } func ptrFloat(v float64) *float64 { return &v } - diff --git a/internal/commands/music/registry.go b/internal/commands/music/registry.go index fd22966..7728a77 100644 --- a/internal/commands/music/registry.go +++ b/internal/commands/music/registry.go @@ -7,9 +7,10 @@ import ( ) var ( - Play *discordgo.ApplicationCommand = public.Play - Queue *discordgo.ApplicationCommand = public.Queue + Play *discordgo.ApplicationCommand = public.Play + Queue *discordgo.ApplicationCommand = public.Queue Volume *discordgo.ApplicationCommand = public.Volume + Music *discordgo.ApplicationCommand = public.Music ) func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.PlayHandler(s, i) } @@ -17,4 +18,4 @@ func QueueHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.QueueHandler(s, i) } func VolumeHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.VolumeHandler(s, i) } - +func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.MusicHandler(s, i) } diff --git a/internal/commands/music/shared/shared.go b/internal/commands/music/shared/shared.go new file mode 100644 index 0000000..b9ea90b --- /dev/null +++ b/internal/commands/music/shared/shared.go @@ -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, + }, + }) +} diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index e895604..36cd8f1 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -340,3 +340,22 @@ func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, er return "", err } } + +func (r *Repo) IsMusicEnabled(ctx context.Context, guildID int64) (bool, error) { + const q = `SELECT is_enabled FROM music_settings WHERE guild_id = $1` + var enabled bool + err := r.db.QueryRowContext(ctx, q, guildID).Scan(&enabled) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return enabled, nil +} + +func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool) error { + const q = `INSERT INTO music_settings (guild_id, is_enabled) VALUES ($1, $2) ON CONFLICT (guild_id) DO UPDATE SET is_enabled = $2` + _, err := r.db.ExecContext(ctx, q, guildID, enabled) + return err +} diff --git a/internal/db/services/musicsettings/service.go b/internal/db/services/musicsettings/service.go new file mode 100644 index 0000000..798f772 --- /dev/null +++ b/internal/db/services/musicsettings/service.go @@ -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) +} diff --git a/internal/db/services/services.go b/internal/db/services/services.go index d4b21b7..abec50d 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -6,6 +6,7 @@ import ( "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/logsettings" "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/musicsettings" "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" "velox-bot/internal/db/services/schedule" @@ -26,11 +27,12 @@ type Services struct { Welcome *welcome.Service DefaultRole *defaultrole.Service LogSettings *logsettings.Service + MusicSettings *musicsettings.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service) *Services { +func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, @@ -43,6 +45,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee Welcome: welcomeSvc, DefaultRole: defaultRoleSvc, LogSettings: logSettingsSvc, + MusicSettings: musicSvc, } Global = s return s diff --git a/main.go b/main.go index c009b14..7de0ac5 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/logsettings" "velox-bot/internal/db/services/meeting" + "velox-bot/internal/db/services/musicsettings" "velox-bot/internal/db/services/projects" "velox-bot/internal/db/services/rps" "velox-bot/internal/db/services/schedule" @@ -67,7 +68,8 @@ func main() { twitchService := twitch.New(twitchRepo, config.TwitchClientID) welcomeService := welcome.New(welcomeRepo) defaultRoleService := defaultrole.New(defaultRoleRepo) - services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService) + 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) if err != nil { diff --git a/schema.sql b/schema.sql index a970a6d..db31c9d 100644 --- a/schema.sql +++ b/schema.sql @@ -133,3 +133,42 @@ CREATE TABLE IF NOT EXISTS user_settings ( user_id BIGINT PRIMARY KEY, timezone TEXT NOT NULL ); + +-- +-- Music (dashboard queue view/control - see velox-dashboard-api's +-- 000010_music migration, this file mirrors it) +-- +CREATE TABLE IF NOT EXISTS music_settings ( + guild_id BIGINT PRIMARY KEY, + is_enabled BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS music_now_playing ( + guild_id BIGINT PRIMARY KEY, + track_title TEXT NOT NULL, + track_url TEXT NOT NULL, + duration_seconds INT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + paused BOOLEAN NOT NULL DEFAULT FALSE, + volume INT NOT NULL DEFAULT 100 +); + +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 +); -- 2.54.0