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] 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 (