diff --git a/Bot/Commands/reactionrole.go b/Bot/Commands/reactionrole.go new file mode 100644 index 0000000..683156c --- /dev/null +++ b/Bot/Commands/reactionrole.go @@ -0,0 +1,298 @@ +package commands + +import ( + "fmt" + "strings" + + helpers "ion606_bot/Bot/Helpers" + + "github.com/bwmarrin/discordgo" +) + +// ReactionRoleCommand defines the reaction role slash command with options for count and style. +var ReactionRoleCommand = &discordgo.ApplicationCommand{ + Name: "reactionrole", + Description: "Setup a reaction role message using buttons. Provide a number (max 10) for role-button pairs.", + 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", + }, + }, + }, + }, +} + +// 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 + } +} + +// HandleReactionRole shows a modal with inputs for each role/button pair. +// It appends the chosen style to the modal's custom ID so that it is available during modal submit. +func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) { + 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 + } + } + + // 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}, + }) + } + + // Append the style to the modal's custom ID. + 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), + 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 and uses the style passed via the modal custom ID. +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] + } + btnStyle := mapStyle(styleStr) + + 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 { + // Send an ephemeral error message. + 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 + + // 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 { + 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 + } + } + } + + // Check for cancellation request. + 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 the given role exists in the guild. + roleExists := false + if i.GuildID != "" { + for _, r := range guildRoles { + if r.ID == roleID { + roleExists = true + break + } + } + } else { + // If not in a guild, we cannot verify role existence. + 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) + } + } + + // Discord supports up to 5 buttons 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) + } + + 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)) + } +} + +// HandleReactionRoleButton processes button clicks for reaction roles. +func HandleReactionRoleButton(s *discordgo.Session, i *discordgo.InteractionCreate) { + 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 + } + + if i.GuildID == "" { + helpers.HandleError(s, i, fmt.Errorf("cannot assign roles in DMs")) + 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)) + } +} diff --git a/Bot/bot.go b/Bot/bot.go index 0958281..4cfdfa9 100644 --- a/Bot/bot.go +++ b/Bot/bot.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "strings" commands "ion606_bot/Bot/Commands" @@ -15,15 +16,16 @@ var BotToken string // commandHandlers maps command names to their interaction handler functions. var commandHandlers = map[string]func(*discordgo.Session, *discordgo.InteractionCreate){ - "meow": commands.HandleMeow, - "purr": commands.HandlePurr, - "boop": commands.HandleBoop, - "hug": commands.HandleHug, - "cuddle": commands.HandleCuddle, - "snuggle": commands.HandleSnuggle, - "catfact": commands.HandleCatfact, - "animalgif": commands.HandleAnimalGif, - "action": commands.HandleAction, + "meow": commands.HandleMeow, + "purr": commands.HandlePurr, + "boop": commands.HandleBoop, + "hug": commands.HandleHug, + "cuddle": commands.HandleCuddle, + "snuggle": commands.HandleSnuggle, + "catfact": commands.HandleCatfact, + "animalgif": commands.HandleAnimalGif, + "action": commands.HandleAction, + "reactionrole": commands.HandleReactionRole, } // Run starts the Discord bot session and listens for both message and slash command events. @@ -61,14 +63,32 @@ func newMessage(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author.ID == s.State.User.ID { return } - } func handleInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { - name := i.ApplicationCommandData().Name - if handler, found := commandHandlers[name]; found { - handler(s, i) - } else { - log.Printf("No handler found for command: %v", name) + // Check for modal submissions first. + if i.Type == discordgo.InteractionModalSubmit { + if strings.HasPrefix(i.ModalSubmitData().CustomID, "reactionrole_modal:") { + commands.HandleReactionRoleModalSubmit(s, i) + return + } + } + + // Then check for message components (buttons). + if i.Type == discordgo.InteractionMessageComponent { + if strings.HasPrefix(i.MessageComponentData().CustomID, "rr:") { + commands.HandleReactionRoleButton(s, i) + return + } + } + + // Finally, process application commands. + if i.Type == discordgo.InteractionApplicationCommand { + name := i.ApplicationCommandData().Name + if handler, found := commandHandlers[name]; found { + handler(s, i) + } else { + log.Printf("No handler found for command: %v", name) + } } } diff --git a/Bot/register.go b/Bot/register.go index 6a311bd..a6382c6 100644 --- a/Bot/register.go +++ b/Bot/register.go @@ -19,6 +19,7 @@ var commandsList = []*discordgo.ApplicationCommand{ Commands.CatfactCommand, Commands.AnimalGifCommand, Commands.ActionCommand, + Commands.ReactionRoleCommand, } func RegisterCommands(s *discordgo.Session, guildID string) { diff --git a/Makefile b/Makefile index e161012..eca3021 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ build: docker build -t ion606-bot . -run: +run: build docker run --rm -d --env-file .env ion606-bot stop: