package public import ( "context" "fmt" "strconv" "strings" "time" "velox-bot/internal/commands/schedule/shared" "velox-bot/internal/db/services" "github.com/bwmarrin/discordgo" ) var Schedule = &discordgo.ApplicationCommand{ Name: "schedule", Description: "Schedule 1:1 sessions", Options: []*discordgo.ApplicationCommandOption{ { 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, Name: "list", Description: "List your upcoming sessions", }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "reschedule", Description: "Reschedule an existing session", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionInteger, Name: "id", Description: "ID of the session (from /schedule list)", Required: true, }, { Type: discordgo.ApplicationCommandOptionString, Name: "datetime", Description: "New datetime (UTC), format: 2006-01-02 15:04", Required: true, }, }, }, }, } func ScheduleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { if !shared.RequireGuild(s, i) || !shared.RequireScheduleService(s, i) { return } data := i.ApplicationCommandData() if len(data.Options) == 0 { handleList(s, i) return } switch data.Options[0].Name { case "create": handleCreate(s, i) case "list": handleList(s, i) case "reschedule": handleReschedule(s, i) default: shared.RespondEphemeral(s, i, "Unknown subcommand.") } } func handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { 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 ( 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 } whenStr = strings.TrimSpace(whenStr) if whenStr == "" { shared.RespondEphemeral(s, i, "Datetime cannot be empty.") return } // Expect layout "2006-01-02 15:04" in UTC. when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, time.UTC) if err != nil { shared.RespondEphemeral(s, i, "Invalid datetime format. Use `2006-01-02 15:04` in UTC.") 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 } 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, Fields: []*discordgo.MessageEmbedField{ { Name: "When (UTC)", Value: when.Format("2006-01-02 15:04"), }, }, } 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) { 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 } userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) if err != nil { shared.RespondEphemeral(s, i, "Invalid user ID.") return } const limit = 10 items, err := services.Global.Schedule.ListForUser(context.Background(), guildID, userID, limit) if err != nil { shared.RespondEphemeral(s, i, "Failed to load your sessions.") return } if len(items) == 0 { shared.RespondEphemeral(s, i, "You have no upcoming sessions.") return } var b strings.Builder for _, sch := range items { role := "Requester" otherID := sch.InviteeID if sch.InviteeID == userID { role = "Invitee" otherID = sch.RequesterID } status := string(sch.Status) whenStr := sch.ScheduledAt.UTC().Format("2006-01-02 15:04") line := fmt.Sprintf("ID `%d` • %s with <@%d> at %s UTC (%s)\n", sch.ID, role, otherID, whenStr, status) if sch.Description != "" { line += " " + sch.Description + "\n" } b.WriteString(line) } shared.RespondEphemeral(s, i, b.String()) } func handleReschedule(s *discordgo.Session, i *discordgo.InteractionCreate) { 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 ( idVal int64 whenStr string ) for _, o := range opt.Options { switch o.Name { case "id": idVal = o.IntValue() case "datetime": whenStr = o.StringValue() } } if idVal <= 0 { shared.RespondEphemeral(s, i, "Invalid session ID.") return } whenStr = strings.TrimSpace(whenStr) if whenStr == "" { shared.RespondEphemeral(s, i, "Datetime cannot be empty.") return } when, err := time.ParseInLocation("2006-01-02 15:04", whenStr, time.UTC) if err != nil { shared.RespondEphemeral(s, i, "Invalid datetime format. Use `2006-01-02 15:04` in UTC.") return } userID, err := strconv.ParseInt(i.Member.User.ID, 10, 64) if err != nil { shared.RespondEphemeral(s, i, "Invalid user ID.") return } ctx := context.Background() sch, err := services.Global.Schedule.GetByID(ctx, idVal) if err != nil || sch == nil { shared.RespondEphemeral(s, i, "Session not found.") return } if sch.GuildID != guildID { shared.RespondEphemeral(s, i, "This session belongs to another server.") return } if userID != sch.RequesterID && userID != sch.InviteeID { shared.RespondEphemeral(s, i, "You are not part of this session.") return } if err := services.Global.Schedule.Reschedule(ctx, sch.ID, when); err != nil { shared.RespondEphemeral(s, i, "Failed to reschedule session.") return } // Notify participants via DM and send new request DM to invitee. whenFmt := when.Format("2006-01-02 15:04") otherID := sch.InviteeID if userID == sch.InviteeID { otherID = sch.RequesterID } // DM both about the change msg := fmt.Sprintf("Session `%d` has been rescheduled to %s UTC with <@%d>.", sch.ID, whenFmt, otherID) if sch.Description != "" { msg += "\nTopic: " + sch.Description } for _, uid := range []int64{sch.RequesterID, sch.InviteeID} { ch, err := s.UserChannelCreate(strconv.FormatInt(uid, 10)) if err != nil { continue } _, _ = s.ChannelMessageSend(ch.ID, msg) } // Send new "request" DM to invitee with reactions again. inviteeStr := strconv.FormatInt(sch.InviteeID, 10) inviteeUser, err := s.User(inviteeStr) if err == nil && inviteeUser != nil { dmCh, err := s.UserChannelCreate(inviteeStr) if err == nil { embed := &discordgo.MessageEmbed{ Title: "Rescheduled session", Description: fmt.Sprintf("Session `%d` has been rescheduled by <@%s>.", sch.ID, i.Member.User.ID), Color: 0xffc107, Fields: []*discordgo.MessageEmbedField{ { Name: "When (UTC)", Value: whenFmt, }, }, } if sch.Description != "" { embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ Name: "Description", Value: sch.Description, }) } msgObj, err := s.ChannelMessageSendComplex(dmCh.ID, &discordgo.MessageSend{ Content: "React with ✅ to accept, ❌ to decline, or 🔁 to request another reschedule.", Embed: embed, }) if err == nil && msgObj != nil { for _, emoji := range []string{"✅", "❌", "🔁"} { _ = s.MessageReactionAdd(dmCh.ID, msgObj.ID, emoji) } msgID64, _ := strconv.ParseInt(msgObj.ID, 10, 64) chID64, _ := strconv.ParseInt(dmCh.ID, 10, 64) _ = services.Global.Schedule.AddMessage(ctx, sch.ID, msgID64, chID64, true) } } } shared.RespondEphemeral(s, i, "Session rescheduled to "+whenFmt+" UTC.") }