diff --git a/Bot/Commands/reactionrole.go b/Bot/Commands/reactionrole.go index fb65a98..1633aaa 100644 --- a/Bot/Commands/reactionrole.go +++ b/Bot/Commands/reactionrole.go @@ -1,7 +1,10 @@ package commands import ( + "encoding/json" "fmt" + "log" + "os" "strings" helpers "ion606_bot/Bot/Helpers" @@ -9,17 +12,50 @@ import ( "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 { - p := int64(discordgo.PermissionManageRoles) + fmt.Println("checking perms") + + p := int64(discordgo.PermissionManageRoles | discordgo.PermissionAll) return &p } -// ReactionRoleCommand defines the reaction role slash command with options for count and style. +// 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.", + Description: "Setup a reaction role message using buttons. Provide a number (max 10) for role-button pairs", DefaultMemberPermissions: manageRolesPerm(), Options: []*discordgo.ApplicationCommandOption{ { @@ -54,6 +90,12 @@ var ReactionRoleCommand = &discordgo.ApplicationCommand{ }, }, }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "message_id", + Description: "Optional: Message ID of the message to edit and add buttons to.", + Required: false, + }, }, } @@ -79,6 +121,16 @@ func memberHasManageRoles(s *discordgo.Session, guildID string, member *discordg 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 { @@ -87,13 +139,12 @@ func memberHasManageRoles(s *discordgo.Session, guildID string, member *discordg } } } - return perms&discordgo.PermissionManageRoles != 0, nil + + return (perms&discordgo.PermissionManageRoles != 0 || perms&discordgo.PermissionAdministrator != 0), nil } -// HandleReactionRole shows a modal with inputs for each role/button pair. -// It verifies that the invoking member has the Manage Roles permission. func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { - // Check that the invoking member has the Manage Roles permission. + // 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 @@ -103,6 +154,7 @@ func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { 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, @@ -126,6 +178,15 @@ func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { } } + 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++ { @@ -137,7 +198,6 @@ func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { Placeholder: "Enter Role ID", Required: true, } - // Input for Button Label. labelInput := discordgo.TextInput{ CustomID: fmt.Sprintf("role_label_%d", idx), @@ -149,17 +209,26 @@ func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { modalComponents = append(modalComponents, discordgo.ActionsRow{ Components: []discordgo.MessageComponent{&roleInput}, }) - modalComponents = append(modalComponents, discordgo.ActionsRow{ Components: []discordgo.MessageComponent{&labelInput}, }) } - // Append the style to the modal's custom ID. + // 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", - // Append the style value after a colon so it is available on submit. - CustomID: fmt.Sprintf("reactionrole_modal:%s", style), + Title: "Reaction Role Setup", + CustomID: customID, Components: modalComponents, } @@ -167,22 +236,37 @@ func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { 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 and uses the style passed via the modal custom ID. +// 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" - if len(parts) == 2 { - styleStr = parts[1] + + 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 @@ -194,7 +278,6 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio var err error guildRoles, err = s.GuildRoles(i.GuildID) if err != nil { - // Send an ephemeral error message. s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ @@ -209,15 +292,11 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio // For each pair, extract the Role ID and label. for idx := 1; idx <= count; idx++ { var roleID, label string - - // Iterate over the action rows. 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 { @@ -231,7 +310,7 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio } } - // Check for cancellation request. + // Allow a cancellation action. if strings.ToLower(roleID) == "cancel" { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, @@ -243,7 +322,7 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio return } - // Verify the given role exists in the guild. + // Verify that the given role exists. roleExists := false if i.GuildID != "" { for _, r := range guildRoles { @@ -253,7 +332,6 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio } } } else { - // If not in a guild, we cannot verify role existence. roleExists = true } @@ -279,7 +357,7 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio } } - // Discord supports up to 5 buttons per row. + // Arrange buttons into rows (up to 5 per row). var components []discordgo.MessageComponent for j := 0; j < len(buttons); j += 5 { end := j + 5 @@ -292,15 +370,70 @@ func HandleReactionRoleModalSubmit(s *discordgo.Session, i *discordgo.Interactio components = append(components, row) } - 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)) + // 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)) + } } } diff --git a/Bot/Helpers/helpers.go b/Bot/Helpers/helpers.go new file mode 100644 index 0000000..93d9688 --- /dev/null +++ b/Bot/Helpers/helpers.go @@ -0,0 +1,9 @@ +package helpers + +import ( + "fmt" +) + +func CreateMessageLink(guildID, channelID, messageID string) string { + return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, channelID, messageID) +} diff --git a/Bot/bot.go b/Bot/bot.go index 4cfdfa9..c44bb41 100644 --- a/Bot/bot.go +++ b/Bot/bot.go @@ -1,7 +1,6 @@ package bot import ( - "fmt" "log" "os" "os/signal" @@ -36,6 +35,12 @@ func Run() { log.Fatal("Error creating Discord session: ", err) } + // Load reaction role target map from file. + err = commands.LoadReactionRoleTarget("reactionRoleTarget.json") + if err != nil { + log.Printf("No reaction role target config loaded: %v", err) + } + // add event handlers for messages and interactions. discord.AddHandler(newMessage) discord.AddHandler(handleInteractionCreate) @@ -47,11 +52,19 @@ func Run() { RegisterCommands(discord, "") - fmt.Println("Bot running....") + log.Println("Bot running....") + + // Wait for interrupt signal. c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) <-c + // Save reaction role target map to file on shutdown. + err = commands.SaveReactionRoleTarget("reactionRoleTarget.json") + if err != nil { + log.Println("Error saving reaction role target:", err) + } + err = discord.Close() if err != nil { log.Println("Error closing Discord session: ", err)