package commands import ( "encoding/json" "fmt" "log" "os" "strings" helpers "ion606_bot/Bot/Helpers" "github.com/bwmarrin/discordgo" ) // reactionRoleTarget maps an interaction’s ID to its target message ID var reactionRoleTarget = make(map[string]string) // SaveReactionRoleTarget saves the reactionRoleTarget map to a JSON file. func SaveReactionRoleTarget(filename string) error { data, err := json.Marshal(reactionRoleTarget) if err != nil { return fmt.Errorf("failed to marshal reactionRoleTarget: %w", err) } if err := os.WriteFile(filename, data, 0644); err != nil { return fmt.Errorf("failed to write reactionRoleTarget file: %w", err) } return nil } // LoadReactionRoleTarget loads the reactionRoleTarget map from a JSON file. func LoadReactionRoleTarget(filename string) error { data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("failed to read reactionRoleTarget file: %w", err) } if err := json.Unmarshal(data, &reactionRoleTarget); err != nil { return fmt.Errorf("failed to unmarshal reactionRoleTarget: %w", err) } return nil } // Convert the manage roles permission into an int64 pointer for command registration. func manageRolesPerm() *int64 { fmt.Println("checking perms") p := int64(discordgo.PermissionManageRoles | discordgo.PermissionAll) return &p } // ReactionRoleCommand defines the reaction role slash command with options for count, style, and an optional message ID. // Only members with the "Manage Roles" permission may use this command. var ReactionRoleCommand = &discordgo.ApplicationCommand{ Name: "reactionrole", Description: "Setup a reaction role message using buttons. Provide a number (max 10) for role-button pairs", DefaultMemberPermissions: manageRolesPerm(), Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionInteger, Name: "count", Description: "Number of role-button pairs to setup (max 10)", Required: true, MinValue: func(v float64) *float64 { return &v }(1), MaxValue: func(v float64) float64 { return v }(10), }, { Type: discordgo.ApplicationCommandOptionString, Name: "style", Description: "Button style", Required: false, Choices: []*discordgo.ApplicationCommandOptionChoice{ { Name: "Blue", Value: "primary", }, { Name: "Gray", Value: "secondary", }, { Name: "Green", Value: "success", }, { Name: "Red", Value: "danger", }, }, }, { Type: discordgo.ApplicationCommandOptionString, Name: "message_id", Description: "Optional: Message ID of the message to edit and add buttons to.", Required: false, }, }, } // mapStyle converts a style string to a discordgo.ButtonStyle. func mapStyle(style string) discordgo.ButtonStyle { switch strings.ToLower(style) { case "primary", "blue": return discordgo.PrimaryButton case "secondary", "gray": return discordgo.SecondaryButton case "success", "green": return discordgo.SuccessButton case "danger", "red": return discordgo.DangerButton default: return discordgo.PrimaryButton } } // memberHasManageRoles checks if the given member has the Manage Roles permission. func memberHasManageRoles(s *discordgo.Session, guildID string, member *discordgo.Member) (bool, error) { roles, err := s.GuildRoles(guildID) if err != nil { return false, err } guild, err := s.Guild(guildID) if err != nil { return false, err } if member.User.ID == guild.OwnerID { return true, nil } var perms int64 = 0 for _, roleID := range member.Roles { for _, role := range roles { if role.ID == roleID { perms |= int64(role.Permissions) } } } return (perms&discordgo.PermissionManageRoles != 0 || perms&discordgo.PermissionAdministrator != 0), nil } func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { // Ensure the command is used in a guild and member data is present. if i.GuildID == "" || i.Member == nil { helpers.HandleError(s, i, fmt.Errorf("command must be used in a guild")) return } hasPerm, err := memberHasManageRoles(s, i.GuildID, i.Member) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to check user permissions: %w", err)) return } if !hasPerm { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Error: You need the Manage Roles permission to use this command.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } options := i.ApplicationCommandData().Options count := int(options[0].IntValue()) // Determine style based on the additional option; default to primary. style := "primary" if len(options) > 1 { styleOpt := options[1] if val := styleOpt.StringValue(); val != "" { style = val } } targetMsgID := "" if len(options) > 2 { opt := options[2] if val := opt.StringValue(); val != "" { targetMsgID = val reactionRoleTarget[i.Member.User.ID] = targetMsgID } } // Build modal components: for each pair, add two text inputs (each in its own action row). modalComponents := []discordgo.MessageComponent{} for idx := 1; idx <= count; idx++ { // Input for Role ID. roleInput := discordgo.TextInput{ CustomID: fmt.Sprintf("role_id_%d", idx), Label: fmt.Sprintf("Role ID for pair %d", idx), Style: discordgo.TextInputShort, Placeholder: "Enter Role ID", Required: true, } // Input for Button Label. labelInput := discordgo.TextInput{ CustomID: fmt.Sprintf("role_label_%d", idx), Label: fmt.Sprintf("Button label for pair %d", idx), Style: discordgo.TextInputShort, Placeholder: "Button Text", Required: true, } modalComponents = append(modalComponents, discordgo.ActionsRow{ Components: []discordgo.MessageComponent{&roleInput}, }) modalComponents = append(modalComponents, discordgo.ActionsRow{ Components: []discordgo.MessageComponent{&labelInput}, }) } // Build a custom ID that contains the style and, if provided, the target message ID. customID := "reactionrole_modal" if targetMsgID != "" { customID += fmt.Sprintf(":%s-%s", i.ChannelID, targetMsgID) } else { customID += ":NA" } if style != "primary" { customID += fmt.Sprintf(":%s", style) } modalData := &discordgo.InteractionResponseData{ Title: "Reaction Role Setup", CustomID: customID, Components: modalComponents, } err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseModal, Data: modalData, }) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to send modal: %w", err)) } } // HandleReactionRoleModalSubmit processes the modal submission, uses the style from the modal custom ID, // and if a target message ID is provided, edits that message to add the buttons. func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) { // Extract the style from the modal's CustomID. customID := i.ModalSubmitData().CustomID parts := strings.Split(customID, ":") styleStr := "primary" targetMsgID := "" targetChannelID := "" if len(parts) >= 3 { styleStr = parts[2] } if len(parts) >= 2 && parts[1] != "NA" { spl := strings.Split(parts[1], "-") targetChannelID = spl[0] targetMsgID = spl[1] } btnStyle := mapStyle(styleStr) log.Default().Println(targetMsgID, targetChannelID) data := i.ModalSubmitData() // There will be two action rows per pair. count := len(data.Components) / 2 var buttons []discordgo.MessageComponent // Retrieve guild roles to verify existence. var guildRoles []*discordgo.Role if i.GuildID != "" { var err error guildRoles, err = s.GuildRoles(i.GuildID) if err != nil { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Error fetching guild roles.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } } // For each pair, extract the Role ID and label. for idx := 1; idx <= count; idx++ { var roleID, label string for _, comp := range data.Components { row, ok := comp.(*discordgo.ActionsRow) if !ok { continue } for _, innerComp := range row.Components { textInput, ok := innerComp.(*discordgo.TextInput) if !ok { continue } if textInput.CustomID == fmt.Sprintf("role_id_%d", idx) { roleID = textInput.Value } else if textInput.CustomID == fmt.Sprintf("role_label_%d", idx) { label = textInput.Value } } } // Allow a cancellation action. if strings.ToLower(roleID) == "cancel" { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Reaction role setup has been cancelled.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Verify that the given role exists. roleExists := false if i.GuildID != "" { for _, r := range guildRoles { if r.ID == roleID { roleExists = true break } } } else { roleExists = true } if !roleExists { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Error: Role with ID `%s` does not exist in this server.", roleID), Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Create the button if valid. if roleID != "" && label != "" { btn := discordgo.Button{ Label: label, Style: btnStyle, CustomID: "rr:" + roleID, } buttons = append(buttons, btn) } } // Arrange buttons into rows (up to 5 per row). var components []discordgo.MessageComponent for j := 0; j < len(buttons); j += 5 { end := j + 5 if end > len(buttons) { end = len(buttons) } row := discordgo.ActionsRow{ Components: buttons[j:end], } components = append(components, row) } // If a target message ID was provided, edit that message. Otherwise, send a new message. if targetMsgID != "" { // First, get the existing message to preserve its content. originalMsg, err := s.ChannelMessage(targetChannelID, targetMsgID) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to fetch target message: %w", err)) return } // Edit the target message with the original content and new buttons. _, err = s.ChannelMessageEditComplex(&discordgo.MessageEdit{ Channel: targetChannelID, ID: targetMsgID, Content: &originalMsg.Content, // Preserve original content. Components: &components, }); if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to edit target message: %w", err)) return } // Acknowledge the update with an ephemeral confirmation. err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Successfully added reaction roles to [this message](%s).", helpers.CreateMessageLink(i.GuildID, i.ChannelID, targetMsgID)), Flags: discordgo.MessageFlagsEphemeral, }, }) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to send confirmation: %w", err)) } } else { // For new messages, first respond to the interaction. err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Click a button to receive the corresponding role:", Components: components, }, }) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to send reaction role message: %w", err)) return } // Get the message we just sent to provide a reference. msg, err := s.InteractionResponse(i.Interaction) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to get sent message: %w", err)) return } // Send a follow-up with the message link. _, err = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ Content: fmt.Sprintf("Reaction roles setup complete! [Jump to message](%s)", helpers.CreateMessageLink(i.GuildID, i.ChannelID, msg.ID)), Flags: discordgo.MessageFlagsEphemeral, }) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to send follow-up: %w", err)) } } } // botHasManageRoles checks if the bot has the Manage Roles permission in the guild. func botHasManageRoles(s *discordgo.Session, guildID string) (bool, error) { botMember, err := s.GuildMember(guildID, s.State.User.ID) if err != nil { return false, err } roles, err := s.GuildRoles(guildID) if err != nil { return false, err } // Combine permissions from all roles the bot has. var perms int64 = 0 for _, botRoleID := range botMember.Roles { for _, role := range roles { if role.ID == botRoleID { perms |= int64(role.Permissions) } } } // discordgo.PermissionManageRoles is 0x10000000 (268435456) if perms&discordgo.PermissionManageRoles != 0 { return true, nil } return false, nil } // HandleReactionRoleButton processes button clicks for reaction roles. func HandleReactionRoleButton(s *discordgo.Session, i *discordgo.InteractionCreate) { // Check if the bot has Manage Roles permission. if i.GuildID == "" { helpers.HandleError(s, i, fmt.Errorf("cannot assign roles in DMs")) return } hasPerm, err := botHasManageRoles(s, i.GuildID) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to check bot permissions: %w", err)) return } if !hasPerm { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "*Bot does not have the Manage Roles permission!*", Flags: discordgo.MessageFlagsEphemeral, }, }) return } customID := i.MessageComponentData().CustomID // Expect customID in the format "rr:" roleID := strings.TrimPrefix(customID, "rr:") if roleID == "" { helpers.HandleError(s, i, fmt.Errorf("invalid role ID")) return } member := i.Member if member == nil { helpers.HandleError(s, i, fmt.Errorf("member not found")) return } // Assign the role. if err := s.GuildMemberRoleAdd(i.GuildID, member.User.ID, roleID); err != nil { helpers.HandleError(s, i, fmt.Errorf("error assigning role: %w", err)) return } // Respond to acknowledge the action. err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Role successfully assigned!", Flags: discordgo.MessageFlagsEphemeral, }, }) if err != nil { helpers.HandleError(s, i, fmt.Errorf("failed to respond to button interaction: %w", err)) } }