Files
self-bot/Bot/Commands/reactionrole.go
T

525 lines
15 KiB
Go
Raw Normal View History

2025-04-07 12:27:19 -04:00
package commands
import (
2025-04-07 16:16:27 -04:00
"encoding/json"
2025-04-07 12:27:19 -04:00
"fmt"
2025-04-07 16:16:27 -04:00
"log"
"os"
2025-04-07 12:27:19 -04:00
"strings"
helpers "ion606_bot/Bot/Helpers"
"github.com/bwmarrin/discordgo"
)
2025-04-07 16:16:27 -04:00
// 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
}
2025-04-07 14:21:04 -04:00
// Convert the manage roles permission into an int64 pointer for command registration.
func manageRolesPerm() *int64 {
2025-04-07 16:16:27 -04:00
fmt.Println("checking perms")
p := int64(discordgo.PermissionManageRoles | discordgo.PermissionAll)
2025-04-07 14:21:04 -04:00
return &p
}
2025-04-07 16:16:27 -04:00
// ReactionRoleCommand defines the reaction role slash command with options for count, style, and an optional message ID.
2025-04-07 14:21:04 -04:00
// Only members with the "Manage Roles" permission may use this command.
2025-04-07 12:27:19 -04:00
var ReactionRoleCommand = &discordgo.ApplicationCommand{
2025-04-07 14:21:04 -04:00
Name: "reactionrole",
2025-04-07 16:16:27 -04:00
Description: "Setup a reaction role message using buttons. Provide a number (max 10) for role-button pairs",
2025-04-07 14:21:04 -04:00
DefaultMemberPermissions: manageRolesPerm(),
2025-04-07 12:27:19 -04:00
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",
},
},
},
2025-04-07 16:16:27 -04:00
{
Type: discordgo.ApplicationCommandOptionString,
Name: "message_id",
Description: "Optional: Message ID of the message to edit and add buttons to.",
Required: false,
},
2025-04-07 12:27:19 -04:00
},
}
// 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
}
}
2025-04-07 14:21:04 -04:00
// 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
}
2025-04-07 16:16:27 -04:00
guild, err := s.Guild(guildID)
if err != nil {
return false, err
}
if member.User.ID == guild.OwnerID {
return true, nil
}
2025-04-07 14:21:04 -04:00
var perms int64 = 0
for _, roleID := range member.Roles {
for _, role := range roles {
if role.ID == roleID {
perms |= int64(role.Permissions)
}
}
}
2025-04-07 16:16:27 -04:00
return (perms&discordgo.PermissionManageRoles != 0 || perms&discordgo.PermissionAdministrator != 0), nil
2025-04-07 14:21:04 -04:00
}
2025-04-07 12:27:19 -04:00
func HandleReactionRole(s *discordgo.Session, i *discordgo.InteractionCreate) {
2025-04-07 16:16:27 -04:00
// Ensure the command is used in a guild and member data is present.
2025-04-07 14:21:04 -04:00
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
}
2025-04-07 16:16:27 -04:00
2025-04-07 14:21:04 -04:00
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
}
2025-04-07 12:27:19 -04:00
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
}
}
2025-04-07 16:16:27 -04:00
targetMsgID := ""
if len(options) > 2 {
opt := options[2]
if val := opt.StringValue(); val != "" {
targetMsgID = val
reactionRoleTarget[i.Member.User.ID] = targetMsgID
}
}
2025-04-07 12:27:19 -04:00
// 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},
})
}
2025-04-07 16:16:27 -04:00
// 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)
}
2025-04-07 12:27:19 -04:00
modalData := &discordgo.InteractionResponseData{
2025-04-07 16:16:27 -04:00
Title: "Reaction Role Setup",
CustomID: customID,
2025-04-07 12:27:19 -04:00
Components: modalComponents,
}
2025-04-07 14:21:04 -04:00
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
2025-04-07 12:27:19 -04:00
Type: discordgo.InteractionResponseModal,
Data: modalData,
})
2025-04-07 16:16:27 -04:00
2025-04-07 12:27:19 -04:00
if err != nil {
helpers.HandleError(s, i, fmt.Errorf("failed to send modal: %w", err))
}
}
2025-04-07 16:16:27 -04:00
// 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.
2025-04-07 12:27:19 -04:00
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"
2025-04-07 16:16:27 -04:00
targetMsgID := ""
targetChannelID := ""
if len(parts) >= 3 {
styleStr = parts[2]
2025-04-07 12:27:19 -04:00
}
2025-04-07 16:16:27 -04:00
if len(parts) >= 2 && parts[1] != "NA" {
spl := strings.Split(parts[1], "-")
targetChannelID = spl[0]
targetMsgID = spl[1]
}
2025-04-07 12:27:19 -04:00
btnStyle := mapStyle(styleStr)
2025-04-07 16:16:27 -04:00
log.Default().Println(targetMsgID, targetChannelID)
2025-04-07 12:27:19 -04:00
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
}
}
}
2025-04-07 16:16:27 -04:00
// Allow a cancellation action.
2025-04-07 12:27:19 -04:00
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
}
2025-04-07 16:16:27 -04:00
// Verify that the given role exists.
2025-04-07 12:27:19 -04:00
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)
}
}
2025-04-07 16:16:27 -04:00
// Arrange buttons into rows (up to 5 per row).
2025-04-07 12:27:19 -04:00
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)
}
2025-04-07 16:16:27 -04:00
// 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))
}
2025-04-07 12:27:19 -04:00
}
}
2025-04-07 12:31:43 -04:00
// 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
}
2025-04-07 12:27:19 -04:00
// HandleReactionRoleButton processes button clicks for reaction roles.
func HandleReactionRoleButton(s *discordgo.Session, i *discordgo.InteractionCreate) {
2025-04-07 12:31:43 -04:00
// 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
}
2025-04-07 14:21:04 -04:00
2025-04-07 12:31:43 -04:00
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
}
2025-04-07 12:27:19 -04:00
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.
2025-04-07 12:31:43 -04:00
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
2025-04-07 12:27:19 -04:00
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))
}
}