Files
self-bot/Bot/Commands/reactionrole.go
T
2025-04-08 10:59:37 -04:00

531 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package commands
import (
"encoding/json"
"fmt"
"log"
"os"
"strings"
helpers "ion606_bot/Bot/Helpers"
"github.com/bwmarrin/discordgo"
)
// reactionRoleTarget maps an interactions 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
// Extract count (required - type Integer)
countOpt := helpers.GetOption(options, "count")
if countOpt == nil {
helpers.HandleError(s, i, fmt.Errorf("missing count option"))
return
}
count := int(countOpt.IntValue())
// Determine style based on the "style" option; default to "primary"
style := "primary"
if opt := helpers.GetOption(options, "style"); opt != nil {
if val := opt.StringValue(); val != "" {
style = val
}
}
// Extract target message ID from the "message_id" option if available.
targetMsgID := ""
if opt := helpers.GetOption(options, "message_id"); opt != nil {
if val := opt.StringValue(); val != "" {
targetMsgID = val
// Store the target message ID keyed by the user's ID.
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)
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>"
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))
}
}