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 "" }