package commands import ( "database/sql" "fmt" "log" "strings" "github.com/bwmarrin/discordgo" "ffxiv-bot/db" ) type Handler struct { DB *sql.DB GuildID string AdminRoleID string RosterRefresh func() TomestoneLookup func(firstName, lastName, server string) TomestoneFetchAll func(fetchAll bool) TomestoneFetchUser func(discordID string, fetchAll bool) } func (h *Handler) Register(s *discordgo.Session) error { applicationCommands := []*discordgo.ApplicationCommand{ { Name: "addchar", Description: "Add an in-game character to your profile", Options: []*discordgo.ApplicationCommandOption{ { Name: "first_name", Description: "Character first name", Type: discordgo.ApplicationCommandOptionString, Required: true, }, { Name: "last_name", Description: "Character last name", Type: discordgo.ApplicationCommandOptionString, Required: true, }, { Name: "server", Description: "Character server name", Type: discordgo.ApplicationCommandOptionString, Required: true, }, { Name: "user", Description: "Add character to another user's profile (admin only)", Type: discordgo.ApplicationCommandOptionUser, Required: false, }, }, }, { Name: "removechar", Description: "Remove an in-game character from your profile", Options: []*discordgo.ApplicationCommandOption{ { Name: "first_name", Description: "Character first name", Type: discordgo.ApplicationCommandOptionString, Required: true, }, { Name: "last_name", Description: "Character last name", Type: discordgo.ApplicationCommandOptionString, Required: true, }, { Name: "server", Description: "Character server name", Type: discordgo.ApplicationCommandOptionString, Required: true, }, { Name: "user", Description: "Remove character from another user's profile (admin only)", Type: discordgo.ApplicationCommandOptionUser, Required: false, }, }, }, { Name: "mychars", Description: "List your registered in-game characters", }, { Name: "roster", Description: "Manage the server roster", Options: []*discordgo.ApplicationCommandOption{ { Name: "refresh", Description: "Refresh the roster display", Type: discordgo.ApplicationCommandOptionSubCommand, }, { Name: "fetchprogression", Description: "Fetch progression data from tomestone.gg", Type: discordgo.ApplicationCommandOptionSubCommand, Options: []*discordgo.ApplicationCommandOption{ { Name: "user", Description: "Fetch progression for a specific user (admin only)", Type: discordgo.ApplicationCommandOptionUser, Required: false, }, }, }, }, }, } guildID := h.GuildID _, err := s.ApplicationCommandBulkOverwrite(s.State.User.ID, guildID, applicationCommands) if err != nil { return err } if guildID != "" { log.Printf("Registered %d commands for guild %s", len(applicationCommands), guildID) } else { log.Printf("Registered %d global commands (may take up to an hour to appear)", len(applicationCommands)) } return nil } func (h *Handler) HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { defer func() { if r := recover(); r != nil { log.Printf("[Commands] Panic in HandleInteraction: %v", r) } }() interaction := i.Interaction log.Printf("[Commands] Received interaction: type=%d, name=%s", interaction.Type, getInteractionName(i)) if interaction.Type != discordgo.InteractionApplicationCommand { return } cmdData, ok := interaction.Data.(discordgo.ApplicationCommandInteractionData) if !ok { log.Printf("[Commands] Failed to cast interaction data, type=%T", interaction.Data) return } if h.AdminRoleID != "" { member := interaction.Member if member == nil { if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "You must use this command in a server.", }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } if !memberHasRole(member, h.AdminRoleID) { switch cmdData.Name { case "roster": subcmd := getSubcommandName(&cmdData) if subcmd == "fetchprogression" && getSubcommandUserOption(&cmdData) == nil { break } if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "You must have the admin role to manage the roster.", }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return case "addchar", "removechar": if getUserOption(&cmdData) != nil { if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "You must have the admin role to manage another user's characters.", }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } } } } switch cmdData.Name { case "addchar": h.handleAddChar(s, interaction, &cmdData) case "removechar": h.handleRemoveChar(s, interaction, &cmdData) case "mychars": h.handleMyChars(s, interaction) case "roster": h.handleRoster(s, interaction, &cmdData) } log.Printf("[Commands] Finished handling interaction: name=%s", cmdData.Name) } func getInteractionName(i *discordgo.InteractionCreate) string { if i.Interaction == nil { return "unknown" } if cmdData, ok := i.Interaction.Data.(discordgo.ApplicationCommandInteractionData); ok { return cmdData.Name } return "unknown" } func (h *Handler) handleAddChar(s *discordgo.Session, interaction *discordgo.Interaction, data *discordgo.ApplicationCommandInteractionData) { firstName := getStringOption(data, "first_name") lastName := getStringOption(data, "last_name") server := getStringOption(data, "server") targetUser := getUserOption(data) var discordID string var forSelf bool if targetUser != nil { discordID = targetUser.ID forSelf = false } else { discordID = getInvokerID(interaction) forSelf = true } if err := h.DBAddChar(discordID, firstName, lastName, server); err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Character %s %s on %s is already registered.", firstName, lastName, server), }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Error adding character: %v", err), }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } var responseContent string if forSelf { responseContent = fmt.Sprintf("Added %s %s on %s to your profile.", firstName, lastName, server) } else { responseContent = fmt.Sprintf("Added %s %s on %s to <@%s>'s profile.", firstName, lastName, server, targetUser.ID) } if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: responseContent, }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } go func() { h.LookupTomestoneID(firstName, lastName, server) if h.RosterRefresh != nil { h.RosterRefresh() } }() } func (h *Handler) handleRemoveChar(s *discordgo.Session, interaction *discordgo.Interaction, data *discordgo.ApplicationCommandInteractionData) { firstName := getStringOption(data, "first_name") lastName := getStringOption(data, "last_name") server := getStringOption(data, "server") targetUser := getUserOption(data) var discordID string var forSelf bool if targetUser != nil { discordID = targetUser.ID forSelf = false } else { discordID = getInvokerID(interaction) forSelf = true } if err := h.DBRemoveChar(discordID, firstName, lastName, server); err != nil { if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Error removing character: %v", err), }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } var responseContent string if forSelf { responseContent = fmt.Sprintf("Removed %s %s on %s from your profile.", firstName, lastName, server) } else { responseContent = fmt.Sprintf("Removed %s %s on %s from <@%s>'s profile.", firstName, lastName, server, targetUser.ID) } if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: responseContent, }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } if h.RosterRefresh != nil { go h.RosterRefresh() } } func (h *Handler) handleMyChars(s *discordgo.Session, interaction *discordgo.Interaction) { discordID := getInvokerID(interaction) chars, err := h.DBGetChars(discordID) if err != nil { if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Error fetching characters: %v", err), }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } if len(chars) == 0 { if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "You have no registered characters.", }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } return } var lines []string for _, c := range chars { lines = append(lines, fmt.Sprintf("- %s %s on %s", c.FirstName, c.LastName, c.ServerName)) } if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Your registered characters:\n" + strings.Join(lines, "\n"), }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } } func (h *Handler) handleRoster(s *discordgo.Session, interaction *discordgo.Interaction, data *discordgo.ApplicationCommandInteractionData) { subcmd := "" for _, opt := range data.Options { if opt.Type == discordgo.ApplicationCommandOptionSubCommand { subcmd = opt.Name break } } switch subcmd { case "refresh": if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Refreshing the roster...", }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) return } go func() { h.RosterRefresh() if _, err := s.FollowupMessageCreate(interaction, true, &discordgo.WebhookParams{ Content: "Roster refreshed.", }); err != nil { log.Printf("[Commands] Error sending followup: %v", err) } }() case "fetchprogression": targetUser := getSubcommandUserOption(data) var content string if targetUser != nil { content = fmt.Sprintf("Fetching progression data for <@%s>...", targetUser.ID) } else { content = "Fetching your progression data from tomestone.gg..." } if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: content, }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) return } var discordID string if targetUser != nil { discordID = targetUser.ID } else { discordID = getInvokerID(interaction) } go func() { h.FetchProgressionForUser(discordID, false) if h.RosterRefresh != nil { h.RosterRefresh() } var followup string if targetUser != nil { followup = fmt.Sprintf("Progression data for <@%s> fetched and roster updated.", targetUser.ID) } else { followup = "Progression data fetched and roster updated." } if _, err := s.FollowupMessageCreate(interaction, true, &discordgo.WebhookParams{ Content: followup, }); err != nil { log.Printf("[Commands] Error sending followup: %v", err) } }() default: if err := s.InteractionRespond(interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Unknown roster subcommand. Use `/roster refresh` or `/roster fetchprogression`.", }, }); err != nil { log.Printf("[Commands] Error responding: %v", err) } } } func (h *Handler) DBAddChar(discordID, firstName, lastName, server string) error { return db.AddCharacter(h.DB, discordID, firstName, lastName, server) } func (h *Handler) DBRemoveChar(discordID, firstName, lastName, server string) error { return db.RemoveCharacter(h.DB, discordID, firstName, lastName, server) } func (h *Handler) DBGetChars(discordID string) ([]db.Character, error) { return db.GetCharactersByDiscordID(h.DB, discordID) } func (h *Handler) LookupTomestoneID(firstName, lastName, server string) { if h.TomestoneLookup != nil { h.TomestoneLookup(firstName, lastName, server) } } func (h *Handler) FetchAllProgression(fetchAll bool) { if h.TomestoneFetchAll != nil { h.TomestoneFetchAll(fetchAll) } } func (h *Handler) FetchProgressionForUser(discordID string, fetchAll bool) { if h.TomestoneFetchUser != nil { h.TomestoneFetchUser(discordID, fetchAll) } } func getStringOption(data *discordgo.ApplicationCommandInteractionData, name string) string { for _, opt := range data.Options { if opt.Name == name { return opt.StringValue() } } return "" } func getUserOption(data *discordgo.ApplicationCommandInteractionData) *discordgo.User { for _, opt := range data.Options { if opt.Name == "user" { return opt.UserValue(nil) } } return nil } func getSubcommandUserOption(data *discordgo.ApplicationCommandInteractionData) *discordgo.User { for _, opt := range data.Options { for _, subopt := range opt.Options { if subopt.Name == "user" { return subopt.UserValue(nil) } } } return nil } func getSubcommandName(data *discordgo.ApplicationCommandInteractionData) string { for _, opt := range data.Options { if opt.Type == discordgo.ApplicationCommandOptionSubCommand { return opt.Name } } return "" } func getInvokerID(interaction *discordgo.Interaction) string { if interaction.Member != nil && interaction.Member.User != nil { return interaction.Member.User.ID } if interaction.User != nil { return interaction.User.ID } return "" } func memberHasRole(member *discordgo.Member, roleID string) bool { for _, role := range member.Roles { if role == roleID { return true } } return false }