From 1c096afb0cf84589672df09d1a914c96e71723b7 Mon Sep 17 00:00:00 2001 From: Jens Gryspeert Date: Mon, 12 Jan 2026 21:37:42 +0100 Subject: [PATCH 1/2] Optimise purge functionality by recreating the role and link back to associated channels --- commands/warden.go | 996 ++++++++++++++++++++++++++++----------------- 1 file changed, 614 insertions(+), 382 deletions(-) diff --git a/commands/warden.go b/commands/warden.go index edc6553..eba5d93 100644 --- a/commands/warden.go +++ b/commands/warden.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "sort" "strings" "slices" @@ -16,437 +17,668 @@ import ( const wardenRoleBaseName = "Verified Warden" var ( - wardenRoleScopes = []string{"internal", "external", "both"} - wardenSubcommands = []string{ - "add", - "remove", - "bulkadd", - // "purge", - } - - wardenTitleCaser = cases.Title(language.Und, cases.NoLower) + wardenRoleScopes = []string{"internal", "external", "both"} + wardenSubcommands = []string{ + "add", + "remove", + "bulkadd", + "purge", + } + + wardenTitleCaser = cases.Title(language.Und, cases.NoLower) ) func Warden() Command { - return Command{ - Definition: &discordgo.ApplicationCommand{ - Name: "warden", - Description: "Warden role management", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "command", - Description: "Choose between " + strings.Join(wardenSubcommands, ", "), - Required: true, - Choices: stringChoices(wardenSubcommands), - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "flag", - Description: "Internal/external scope, or both", - Required: true, - Choices: stringChoices(wardenRoleScopes), - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "discordname", - Description: "Mention/ID/partial name; for bulkadd use comma-separated list; optional for purge", - Required: false, - }, - }, - }, - Handler: handleWarden, - } + return Command{ + Definition: &discordgo.ApplicationCommand{ + Name: "warden", + Description: "Warden role management", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "command", + Description: "Choose between " + strings.Join(wardenSubcommands, ", "), + Required: true, + Choices: stringChoices(wardenSubcommands), + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "flag", + Description: "Internal/external scope, or both", + Required: true, + Choices: stringChoices(wardenRoleScopes), + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "discordname", + Description: "Mention/ID/partial name; for bulkadd use comma-separated list; optional for purge", + Required: false, + }, + }, + }, + Handler: handleWarden, + } } -func handleWarden(session *discordgo.Session, interaction *discordgo.InteractionCreate) { - commandData := interaction.ApplicationCommandData() - - subcommand, ok := getOptionString(commandData, "command") - if !ok || !slices.Contains(wardenSubcommands, subcommand) { - utils.HandleError(session, interaction, "❌ Invalid warden command; must be "+strings.Join(wardenSubcommands, ", ")) - return - } - - roleScope, ok := getOptionString(commandData, "flag") - if !ok || !slices.Contains(wardenRoleScopes, roleScope) { - utils.HandleError(session, interaction, "❌ Missing or invalid flag argument; must be 'internal', 'external', or 'both'") - return - } - - guildID := interaction.GuildID - if guildID == "" { - utils.HandleError(session, interaction, "❌ This command can only be used in a server (guild).") - return - } - - query, _ := getOptionString(commandData, "discordname") - query = strings.TrimSpace(query) - - if subcommand != "purge" && query == "" { - utils.HandleError(session, interaction, "❌ Missing discordname argument for this command") - return - } - - utils.Info("Warden command invoked", "command", subcommand, "query", query, "flag", roleScope) - - switch subcommand { - case "add": - handleWardenAdd(session, interaction, guildID, query, roleScope) - case "remove": - handleWardenRemove(session, interaction, guildID, query, roleScope) - case "bulkadd": - handleWardenBulkAdd(session, interaction, guildID, query, roleScope) - // case "purge": - // handleWardenPurge(session, interaction, guildID, roleScope) - default: - utils.HandleError(session, interaction, "❌ Unknown subcommand") - } +func handleWarden( + session *discordgo.Session, + interaction *discordgo.InteractionCreate, +) { + commandData := interaction.ApplicationCommandData() + + subcommand, ok := getOptionString(commandData, "command") + if !ok || !slices.Contains(wardenSubcommands, subcommand) { + utils.HandleError( + session, + interaction, + "❌ Invalid warden command; must be "+strings.Join(wardenSubcommands, ", "), + ) + return + } + + roleScope, ok := getOptionString(commandData, "flag") + if !ok || !slices.Contains(wardenRoleScopes, roleScope) { + utils.HandleError( + session, + interaction, + "❌ Missing or invalid flag argument; must be 'internal', 'external', or 'both'", + ) + return + } + + guildID := interaction.GuildID + if guildID == "" { + utils.HandleError(session, interaction, "❌ This command can only be used in a server (guild).") + return + } + + query, _ := getOptionString( + commandData, + "discordname", + ) + + query = strings.TrimSpace(query) + + if subcommand != "purge" && query == "" { + utils.HandleError(session, interaction, "❌ Missing discordname argument for this command") + return + } + + utils.Debug("Warden command invoked", "command", subcommand, "query", query, "flag", roleScope) + + switch subcommand { + case "add": + handleWardenAdd(session, interaction, guildID, query, roleScope) + case "remove": + handleWardenRemove(session, interaction, guildID, query, roleScope) + case "bulkadd": + handleWardenBulkAdd(session, interaction, guildID, query, roleScope) + case "purge": + handleWardenPurge(session, interaction, guildID, roleScope) + default: + utils.HandleError(session, interaction, "❌ Unknown subcommand") + } } func handleWardenAdd(session *discordgo.Session, interaction *discordgo.InteractionCreate, guildID, query, roleScope string) { - if err := deferEphemeral(session, interaction); err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge: %v", err)) - return - } - - member, err := findGuildMember(session, guildID, query) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - for index, roleID := range roleIDs { - roleName := roleNames[index] - if err := session.GuildMemberRoleAdd(guildID, member.User.ID, roleID); err != nil { - utils.Error("Failed to add warden role", "user", member.User.ID, "role", roleName, "error", err) - editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to add '%s' role to %s: %v", roleName, formatUser(member), err)) - return - } - } - - utils.Info("Warden role(s) added", "user", member.User.ID, "roles", strings.Join(roleNames, ", ")) - editEphemeral(session, interaction, fmt.Sprintf("✅ Added warden role(s) (%s) to %s", strings.Join(roleNames, ", "), formatUser(member))) + if err := deferEphemeral(session, interaction); err != nil { + utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge: %v", err)) + return + } + + member, err := findGuildMember(session, guildID, query) + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } + + roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } + + for index, roleID := range roleIDs { + roleName := roleNames[index] + if err := session.GuildMemberRoleAdd(guildID, member.User.ID, roleID); err != nil { + utils.Error("Failed to add warden role", "user", member.User.ID, "role", roleName, "error", err) + editEphemeral( + session, + interaction, + fmt.Sprintf( + "❌ Failed to add '%s' role to %s: %v", + roleName, + formatUser(member), + err, + ), + ) + return + } + } + + utils.Info("Warden role(s) added", "user", member.User.ID, "roles", strings.Join(roleNames, ", ")) + editEphemeral( + session, + interaction, + fmt.Sprintf( + "✅ Added warden role(s) (%s) to %s", + strings.Join(roleNames, ", "), + formatUser(member), + ), + ) } -func handleWardenRemove(session *discordgo.Session, interaction *discordgo.InteractionCreate, guildID, query, roleScope string) { - if err := deferEphemeral(session, interaction); err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge: %v", err)) - return - } - - member, err := findGuildMember(session, guildID, query) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - for index, roleID := range roleIDs { - roleName := roleNames[index] - if err := session.GuildMemberRoleRemove(guildID, member.User.ID, roleID); err != nil { - utils.Error("Failed to remove warden role", "user", member.User.ID, "role", roleName, "error", err) - editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to remove '%s' role from %s: %v", roleName, formatUser(member), err)) - return - } - } - - utils.Info("Warden role(s) removed", "user", member.User.ID, "roles", strings.Join(roleNames, ", ")) - editEphemeral(session, interaction, fmt.Sprintf("✅ Removed warden role(s) (%s) from %s", strings.Join(roleNames, ", "), formatUser(member))) +func handleWardenRemove( + session *discordgo.Session, + interaction *discordgo.InteractionCreate, + guildID string, + query string, + roleScope string, +) { + if err := deferEphemeral(session, interaction); err != nil { + utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge: %v", err)) + return + } + + member, err := findGuildMember(session, guildID, query) + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } + + roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } + + for index, roleID := range roleIDs { + roleName := roleNames[index] + if err := session.GuildMemberRoleRemove(guildID, member.User.ID, roleID); err != nil { + utils.Error("Failed to remove warden role", "user", member.User.ID, "role", roleName, "error", err) + editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to remove '%s' role from %s: %v", roleName, formatUser(member), err)) + return + } + } + + utils.Info("Warden role(s) removed", "user", member.User.ID, "roles", strings.Join(roleNames, ", ")) + editEphemeral(session, interaction, fmt.Sprintf("✅ Removed warden role(s) (%s) from %s", strings.Join(roleNames, ", "), formatUser(member))) } -func handleWardenBulkAdd(session *discordgo.Session, interaction *discordgo.InteractionCreate, guildID, query, roleScope string) { - if err := deferEphemeral(session, interaction); err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge bulk add: %v", err)) - return - } - - roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - requestedQueries := splitCommaSeparated(query) - if len(requestedQueries) == 0 { - editEphemeral(session, interaction, "⚠️ Nothing to do.") - return - } - - var results []string - for _, singleQuery := range requestedQueries { - member, memberErr := findGuildMember(session, guildID, singleQuery) - if memberErr != nil { - results = append(results, memberErr.Error()) - continue - } - - for index, roleID := range roleIDs { - roleName := roleNames[index] - if err := session.GuildMemberRoleAdd(guildID, member.User.ID, roleID); err != nil { - utils.Error("Failed to add warden role in bulk", "user", member.User.ID, "role", roleName, "error", err) - results = append(results, fmt.Sprintf("❌ Failed to add '%s' role to %s: %v", roleName, formatUser(member), err)) - continue - } - results = append(results, fmt.Sprintf("✅ Added '%s' role to %s", roleName, formatUser(member))) - } - } - - editEphemeral(session, interaction, joinOrFallback(results, "⚠️ Nothing to do.")) +func handleWardenBulkAdd( + session *discordgo.Session, + interaction *discordgo.InteractionCreate, + guildID string, + query string, + roleScope string, +) { + if err := deferEphemeral(session, interaction); err != nil { + utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge bulk add: %v", err)) + return + } + + roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } + + requestedQueries := splitCommaSeparated(query) + if len(requestedQueries) == 0 { + editEphemeral(session, interaction, "⚠️ Nothing to do.") + return + } + + var results []string + for _, singleQuery := range requestedQueries { + member, memberErr := findGuildMember(session, guildID, singleQuery) + if memberErr != nil { + results = append(results, memberErr.Error()) + continue + } + + for index, roleID := range roleIDs { + roleName := roleNames[index] + if err := session.GuildMemberRoleAdd(guildID, member.User.ID, roleID); err != nil { + utils.Error("Failed to add warden role in bulk", "user", member.User.ID, "role", roleName, "error", err) + results = append(results, fmt.Sprintf("❌ Failed to add '%s' role to %s: %v", roleName, formatUser(member), err)) + continue + } + results = append(results, fmt.Sprintf("✅ Added '%s' role to %s", roleName, formatUser(member))) + } + } + + editEphemeral(session, interaction, joinOrFallback(results, "⚠️ Nothing to do.")) } -func handleWardenPurge(session *discordgo.Session, interaction *discordgo.InteractionCreate, guildID, roleScope string) { - if err := deferEphemeral(session, interaction); err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge purge: %v", err)) - return - } - - roleIDs, roleNames, err := resolveWardenRoleIDs(session, guildID, roleScope) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - var ( - afterUserID string - removedAssignments int - results []string - ) +func handleWardenPurge( + session *discordgo.Session, + interaction *discordgo.InteractionCreate, + guildID string, + roleScope string, +) { + if err := deferEphemeral(session, interaction); err != nil { + utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge purge: %v", err)) + return + } + + roleIDsToRecreate, roleNamesToRecreate, err := resolveWardenRoleIDs(session, guildID, roleScope) + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } + + if len(roleIDsToRecreate) == 0 { + editEphemeral(session, interaction, "❌ No roles resolved for purge scope.") + return + } + + guildChannels, err := session.GuildChannels(guildID) + if err != nil { + editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to retrieve guild channels: %v", err)) + return + } + + var summaryLines []string + + for index, roleIDToRecreate := range roleIDsToRecreate { + roleName := roleNamesToRecreate[index] + + newRoleID, reappliedOverwriteCount, recreateErr := recreateRoleWithChannelOverwrites( + session, + guildID, + roleIDToRecreate, + guildChannels, + ) + + if recreateErr != nil { + utils.Error( + "Warden purge role recreation failed", + "guild", guildID, + "roleName", roleName, + "roleID", roleIDToRecreate, + "error", recreateErr, + ) + + summaryLines = append(summaryLines, fmt.Sprintf("❌ Failed to recreate '%s': %v", roleName, recreateErr)) + continue + } + + summaryLines = append( + summaryLines, + fmt.Sprintf( + "✅ Recreated '%s' (old: `%s`, new: `%s`), re-applied %d overwrite(s).", + roleName, + roleIDToRecreate, + newRoleID, + reappliedOverwriteCount, + ), + ) + } + + editEphemeral(session, interaction, joinOrFallback(summaryLines, "✅ Purge complete.")) +} - for { - members, err := session.GuildMembers(guildID, afterUserID, 1000) - if err != nil { - editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to retrieve guild members: %v", err)) - return - } - if len(members) == 0 { - break - } - - for _, member := range members { - if member == nil || member.User == nil { - continue - } - - for index, roleID := range roleIDs { - roleName := roleNames[index] - if !memberHasRole(member, roleID) { - continue - } - - if err := session.GuildMemberRoleRemove(guildID, member.User.ID, roleID); err != nil { - utils.Error("Failed to remove warden role during purge", "user", member.User.ID, "role", roleName, "error", err) - results = append(results, fmt.Sprintf("❌ Failed to remove '%s' role from %s: %v", roleName, formatUser(member), err)) - continue - } - - removedAssignments++ - results = append(results, fmt.Sprintf("✅ Removed '%s' role from %s", roleName, formatUser(member))) - } - } - - afterUserID = members[len(members)-1].User.ID - if len(members) < 1000 { - break - } - } - - if removedAssignments == 0 { - editEphemeral(session, interaction, "✅ Purge complete: no members had the role(s).") - return - } - - summary := fmt.Sprintf("✅ Purge complete: removed %d role assignment(s).\n\n%s", removedAssignments, joinOrFallback(results, "")) - editEphemeral(session, interaction, summary) +func recreateRoleWithChannelOverwrites( + session *discordgo.Session, + guildID string, + oldRoleID string, + guildChannels []*discordgo.Channel, +) (string, int, error) { + oldRole, err := fetchGuildRoleByID(session, guildID, oldRoleID) + if err != nil { + return "", 0, fmt.Errorf("fetch role: %w", err) + } + + channelOverwritesByChannelID := collectRoleOverwritesByChannelID(oldRoleID, guildChannels) + + if err := session.GuildRoleDelete(guildID, oldRoleID); err != nil { + return "", 0, fmt.Errorf("delete role: %w", err) + } + + newRole, err := session.GuildRoleCreate(guildID, &discordgo.RoleParams{ + Name: oldRole.Name, + Color: &oldRole.Color, + Hoist: &oldRole.Hoist, + Mentionable: &oldRole.Mentionable, + Permissions: &oldRole.Permissions, + }) + + if err != nil { + return "", 0, fmt.Errorf("create role: %w", err) + } + + if err := applyRoleProperties(session, guildID, newRole.ID, oldRole); err != nil { + return "", 0, fmt.Errorf("apply role properties: %w", err) + } + + _ = restoreRolePositionBestEffort(session, guildID, newRole.ID, oldRole.Position) + + reappliedOverwriteCount, err := reapplyRoleOverwrites( + session, + newRole.ID, + channelOverwritesByChannelID, + ) + + if err != nil { + return "", reappliedOverwriteCount, fmt.Errorf("reapply overwrites: %w", err) + } + + return newRole.ID, reappliedOverwriteCount, nil } +func fetchGuildRoleByID(session *discordgo.Session, guildID, roleID string) (*discordgo.Role, error) { + guildRoles, err := session.GuildRoles(guildID) + + if err != nil { + return nil, err + } + + for _, role := range guildRoles { + if role.ID == roleID { + return role, nil + } + } + + return nil, fmt.Errorf("role id %s not found", roleID) +} + +func collectRoleOverwritesByChannelID( + roleID string, + guildChannels []*discordgo.Channel, +) map[string]*discordgo.PermissionOverwrite { + channelOverwritesByChannelID := make(map[string]*discordgo.PermissionOverwrite, 32) + + for _, channel := range guildChannels { + for _, overwrite := range channel.PermissionOverwrites { + if overwrite.Type != discordgo.PermissionOverwriteTypeRole { + continue + } + if overwrite.ID != roleID { + continue + } + + overwriteCopy := *overwrite + channelOverwritesByChannelID[channel.ID] = &overwriteCopy + } + } + + return channelOverwritesByChannelID +} + +func applyRoleProperties( + session *discordgo.Session, + guildID string, + roleID string, + sourceRole *discordgo.Role, +) error { + _, err := session.GuildRoleEdit(guildID, roleID, &discordgo.RoleParams{ + Name: sourceRole.Name, + Color: &sourceRole.Color, + Hoist: &sourceRole.Hoist, + Mentionable: &sourceRole.Mentionable, + Permissions: &sourceRole.Permissions, + }) + return err +} + +func reapplyRoleOverwrites( + session *discordgo.Session, + newRoleID string, + channelOverwritesByChannelID map[string]*discordgo.PermissionOverwrite, +) (int, error) { + reappliedCount := 0 + + for channelID, overwrite := range channelOverwritesByChannelID { + if err := session.ChannelPermissionSet( + channelID, + newRoleID, + discordgo.PermissionOverwriteTypeRole, + overwrite.Allow, + overwrite.Deny, + ); err != nil { + return reappliedCount, fmt.Errorf("channel %s: %w", channelID, err) + } + + reappliedCount++ + } + + return reappliedCount, nil +} + + func resolveWardenRoleNames(roleScope string) []string { - switch roleScope { - case "both": - return []string{ - wardenRoleBaseName + " Internal", - wardenRoleBaseName + " External", - } - case "internal", "external": - return []string{ - wardenRoleBaseName + " " + wardenTitleCaser.String(roleScope), - } - default: - return []string{ - wardenRoleBaseName + " " + wardenTitleCaser.String(roleScope), - } - } + switch roleScope { + case "both": + return []string{ + wardenRoleBaseName + " Internal", + wardenRoleBaseName + " External", + } + case "internal", "external": + return []string{ + wardenRoleBaseName + " " + wardenTitleCaser.String(roleScope), + } + default: + return []string{ + wardenRoleBaseName + " " + wardenTitleCaser.String(roleScope), + } + } } -func resolveWardenRoleIDs(session *discordgo.Session, guildID, roleScope string) (roleIDs []string, roleNames []string, err error) { - roleNames = resolveWardenRoleNames(roleScope) - roleIDs = make([]string, 0, len(roleNames)) - - for _, roleName := range roleNames { - roleID, findErr := findGuildRoleIDByName(session, guildID, roleName) - if findErr != nil { - return nil, nil, fmt.Errorf("❌ Failed to retrieve guild roles: %v", findErr) - } - if roleID == "" { - return nil, nil, fmt.Errorf("❌ '%s' role not found in guild", roleName) - } - roleIDs = append(roleIDs, roleID) - } - - return roleIDs, roleNames, nil +func resolveWardenRoleIDs( + session *discordgo.Session, + guildID string, + roleScope string, +) (roleIDs []string, roleNames []string, err error) { + roleNames = resolveWardenRoleNames(roleScope) + roleIDs = make([]string, 0, len(roleNames)) + + for _, roleName := range roleNames { + roleID, findErr := findGuildRoleIDByName(session, guildID, roleName) + if findErr != nil { + return nil, nil, fmt.Errorf("❌ Failed to retrieve guild roles: %v", findErr) + } + if roleID == "" { + return nil, nil, fmt.Errorf("❌ '%s' role not found in guild", roleName) + } + roleIDs = append(roleIDs, roleID) + } + + return roleIDs, roleNames, nil } -func getOptionString(commandData discordgo.ApplicationCommandInteractionData, optionName string) (string, bool) { - for _, option := range commandData.Options { - if option != nil && option.Name == optionName { - return option.StringValue(), true - } - } - return "", false +func getOptionString( + commandData discordgo.ApplicationCommandInteractionData, + optionName string, +) (string, bool) { + for _, option := range commandData.Options { + if option != nil && option.Name == optionName { + return option.StringValue(), true + } + } + return "", false } func stringChoices(values []string) []*discordgo.ApplicationCommandOptionChoice { - choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(values)) - for _, value := range values { - choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ - Name: value, - Value: value, - }) - } - return choices + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(values)) + for _, value := range values { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: value, + Value: value, + }) + } + return choices } func deferEphemeral(session *discordgo.Session, interaction *discordgo.InteractionCreate) error { - return session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: discordgo.MessageFlagsEphemeral, - }, - }) + return session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + }, + }) } func editEphemeral(session *discordgo.Session, interaction *discordgo.InteractionCreate, content string) { - _, err := session.InteractionResponseEdit(interaction.Interaction, &discordgo.WebhookEdit{ - Content: &content, - }) - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to edit response: %v", err)) - } -} - -func memberHasRole(member *discordgo.Member, roleID string) bool { - return slices.Contains(member.Roles, roleID) + _, err := session.InteractionResponseEdit(interaction.Interaction, &discordgo.WebhookEdit{ + Content: &content, + }) + if err != nil { + utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to edit response: %v", err)) + } } func formatUser(member *discordgo.Member) string { - if member == nil || member.User == nil { - return "" - } + if member == nil || member.User == nil { + return "" + } - if member.User.Discriminator != "" && member.User.Discriminator != "0" { - return fmt.Sprintf("%s#%s", member.User.Username, member.User.Discriminator) - } + if member.User.Discriminator != "" && member.User.Discriminator != "0" { + return fmt.Sprintf("%s#%s", member.User.Username, member.User.Discriminator) + } - return member.User.Username + return member.User.Username } func findGuildMember(session *discordgo.Session, guildID, query string) (*discordgo.Member, error) { - trimmedQuery := strings.TrimSpace(query) - if trimmedQuery == "" { - return nil, fmt.Errorf("❌ Empty query") - } - - // Mentions: <@123>, <@!123> - if strings.HasPrefix(trimmedQuery, "<@") && strings.HasSuffix(trimmedQuery, ">") { - userID := strings.TrimSuffix(strings.TrimPrefix(trimmedQuery, "<@"), ">") - userID = strings.TrimPrefix(userID, "!") - if userID != "" { - member, err := session.GuildMember(guildID, userID) - if err == nil && member != nil { - return member, nil - } - } - } - - // Raw snowflake ID - if isSnowflakeID(trimmedQuery) { - member, err := session.GuildMember(guildID, trimmedQuery) - if err == nil && member != nil { - return member, nil - } - } - - // Name search - members, err := session.GuildMembersSearch(guildID, trimmedQuery, 10) - if err != nil { - utils.Error("Failed to search members", "error", err) - return nil, fmt.Errorf("❌ Failed to search members: %v", err) - } - - switch len(members) { - case 0: - return nil, fmt.Errorf("❌ No member found matching '%s'", query) - case 1: - return members[0], nil - default: - return nil, fmt.Errorf("❌ Too many matches for '%s' (be more specific, or use a mention/ID)", query) - } + trimmedQuery := strings.TrimSpace(query) + if trimmedQuery == "" { + return nil, fmt.Errorf("❌ Empty query") + } + + // Mentions: <@123>, <@!123> + if strings.HasPrefix(trimmedQuery, "<@") && strings.HasSuffix(trimmedQuery, ">") { + userID := strings.TrimSuffix(strings.TrimPrefix(trimmedQuery, "<@"), ">") + userID = strings.TrimPrefix(userID, "!") + if userID != "" { + member, err := session.GuildMember(guildID, userID) + if err == nil && member != nil { + return member, nil + } + } + } + + // Raw snowflake ID + if isSnowflakeID(trimmedQuery) { + member, err := session.GuildMember(guildID, trimmedQuery) + if err == nil && member != nil { + return member, nil + } + } + + // Name search + members, err := session.GuildMembersSearch(guildID, trimmedQuery, 10) + if err != nil { + utils.Error("Failed to search members", "error", err) + return nil, fmt.Errorf("❌ Failed to search members: %v", err) + } + + switch len(members) { + case 0: + return nil, fmt.Errorf("❌ No member found matching '%s'", query) + case 1: + return members[0], nil + default: + return nil, fmt.Errorf("❌ Too many matches for '%s' (be more specific, or use a mention/ID)", query) + } } func findGuildRoleIDByName(session *discordgo.Session, guildID, roleName string) (string, error) { - roles, err := session.GuildRoles(guildID) - if err != nil { - return "", err - } - - for _, role := range roles { - if role != nil && role.Name == roleName { - return role.ID, nil - } - } - - return "", nil + roles, err := session.GuildRoles(guildID) + if err != nil { + return "", err + } + + for _, role := range roles { + if role != nil && role.Name == roleName { + return role.ID, nil + } + } + + return "", nil } func splitCommaSeparated(value string) []string { - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - out = append(out, trimmed) - } - } - return out + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out } func joinOrFallback(lines []string, fallback string) string { - joined := strings.Join(lines, "\n") - if strings.TrimSpace(joined) == "" { - return fallback - } - return joined + joined := strings.Join(lines, "\n") + if strings.TrimSpace(joined) == "" { + return fallback + } + return joined } func isSnowflakeID(value string) bool { - if len(value) < 15 { - return false - } - for _, runeValue := range value { - if runeValue < '0' || runeValue > '9' { - return false - } - } - return true + if len(value) < 15 { + return false + } + for _, runeValue := range value { + if runeValue < '0' || runeValue > '9' { + return false + } + } + return true +} + +func restoreRolePositionBestEffort( + session *discordgo.Session, + guildID string, + roleID string, + targetPosition int, +) error { + guildRoles, err := session.GuildRoles(guildID) + if err != nil { + return err + } + + sort.Slice(guildRoles, func(i, j int) bool { + return guildRoles[i].Position < guildRoles[j].Position + }) + + var currentIndex = -1 + for index, role := range guildRoles { + if role.ID == roleID { + currentIndex = index + break + } + } + if currentIndex == -1 { + return nil + } + + roleToMove := guildRoles[currentIndex] + guildRoles = append(guildRoles[:currentIndex], guildRoles[currentIndex+1:]...) + + if targetPosition < 0 { + targetPosition = 0 + } + if targetPosition > len(guildRoles) { + targetPosition = len(guildRoles) + } + + guildRoles = append( + guildRoles[:targetPosition], + append([]*discordgo.Role{roleToMove}, guildRoles[targetPosition:]...)..., + ) + + reorderPayload := make([]*discordgo.Role, 0, len(guildRoles)) + for position, role := range guildRoles { + reorderPayload = append(reorderPayload, &discordgo.Role{ + ID: role.ID, + Position: position, + }) + } + + _, err = session.GuildRoleReorder(guildID, reorderPayload) + return err } From 4f65b32c0a3f143da9f46b85b712a68565a6f15d Mon Sep 17 00:00:00 2001 From: Jens Gryspeert Date: Mon, 19 Jan 2026 10:34:45 +0100 Subject: [PATCH 2/2] Modify purge command based on feedback for safety --- commands/warden.go | 262 +++++++++++++++++++-------------------------- 1 file changed, 109 insertions(+), 153 deletions(-) diff --git a/commands/warden.go b/commands/warden.go index eba5d93..f742e9e 100644 --- a/commands/warden.go +++ b/commands/warden.go @@ -2,10 +2,9 @@ package commands import ( "fmt" - "sort" - "strings" - "slices" + "strings" + "time" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -61,28 +60,28 @@ func Warden() Command { } func handleWarden( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, + session *discordgo.Session, + interaction *discordgo.InteractionCreate, ) { commandData := interaction.ApplicationCommandData() subcommand, ok := getOptionString(commandData, "command") if !ok || !slices.Contains(wardenSubcommands, subcommand) { utils.HandleError( - session, - interaction, - "❌ Invalid warden command; must be "+strings.Join(wardenSubcommands, ", "), - ) + session, + interaction, + "❌ Invalid warden command; must be "+strings.Join(wardenSubcommands, ", "), + ) return } roleScope, ok := getOptionString(commandData, "flag") if !ok || !slices.Contains(wardenRoleScopes, roleScope) { utils.HandleError( - session, - interaction, - "❌ Missing or invalid flag argument; must be 'internal', 'external', or 'both'", - ) + session, + interaction, + "❌ Missing or invalid flag argument; must be 'internal', 'external', or 'both'", + ) return } @@ -93,9 +92,9 @@ func handleWarden( } query, _ := getOptionString( - commandData, - "discordname", - ) + commandData, + "discordname", + ) query = strings.TrimSpace(query) @@ -143,37 +142,37 @@ func handleWardenAdd(session *discordgo.Session, interaction *discordgo.Interact if err := session.GuildMemberRoleAdd(guildID, member.User.ID, roleID); err != nil { utils.Error("Failed to add warden role", "user", member.User.ID, "role", roleName, "error", err) editEphemeral( - session, - interaction, - fmt.Sprintf( - "❌ Failed to add '%s' role to %s: %v", - roleName, - formatUser(member), - err, - ), - ) + session, + interaction, + fmt.Sprintf( + "❌ Failed to add '%s' role to %s: %v", + roleName, + formatUser(member), + err, + ), + ) return } } utils.Info("Warden role(s) added", "user", member.User.ID, "roles", strings.Join(roleNames, ", ")) editEphemeral( - session, - interaction, - fmt.Sprintf( - "✅ Added warden role(s) (%s) to %s", - strings.Join(roleNames, ", "), - formatUser(member), - ), - ) + session, + interaction, + fmt.Sprintf( + "✅ Added warden role(s) (%s) to %s", + strings.Join(roleNames, ", "), + formatUser(member), + ), + ) } func handleWardenRemove( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - guildID string, - query string, - roleScope string, + session *discordgo.Session, + interaction *discordgo.InteractionCreate, + guildID string, + query string, + roleScope string, ) { if err := deferEphemeral(session, interaction); err != nil { utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge: %v", err)) @@ -206,11 +205,11 @@ func handleWardenRemove( } func handleWardenBulkAdd( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - guildID string, - query string, - roleScope string, + session *discordgo.Session, + interaction *discordgo.InteractionCreate, + guildID string, + query string, + roleScope string, ) { if err := deferEphemeral(session, interaction); err != nil { utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge bulk add: %v", err)) @@ -262,61 +261,63 @@ func handleWardenPurge( return } + go func() { roleIDsToRecreate, roleNamesToRecreate, err := resolveWardenRoleIDs(session, guildID, roleScope) - if err != nil { - editEphemeral(session, interaction, err.Error()) - return - } - - if len(roleIDsToRecreate) == 0 { - editEphemeral(session, interaction, "❌ No roles resolved for purge scope.") - return - } + if err != nil { + editEphemeral(session, interaction, err.Error()) + return + } - guildChannels, err := session.GuildChannels(guildID) - if err != nil { - editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to retrieve guild channels: %v", err)) - return - } + if len(roleIDsToRecreate) == 0 { + editEphemeral(session, interaction, "❌ No roles resolved for purge scope.") + return + } - var summaryLines []string + guildChannels, err := session.GuildChannels(guildID) + if err != nil { + editEphemeral(session, interaction, fmt.Sprintf("❌ Failed to retrieve guild channels: %v", err)) + return + } - for index, roleIDToRecreate := range roleIDsToRecreate { - roleName := roleNamesToRecreate[index] + var summaryLines []string - newRoleID, reappliedOverwriteCount, recreateErr := recreateRoleWithChannelOverwrites( - session, - guildID, - roleIDToRecreate, - guildChannels, - ) + for index, roleIDToRecreate := range roleIDsToRecreate { + roleName := roleNamesToRecreate[index] - if recreateErr != nil { - utils.Error( - "Warden purge role recreation failed", - "guild", guildID, - "roleName", roleName, - "roleID", roleIDToRecreate, - "error", recreateErr, + newRoleID, reappliedOverwriteCount, recreateErr := recreateRoleWithChannelOverwrites( + session, + guildID, + roleIDToRecreate, + guildChannels, ) - summaryLines = append(summaryLines, fmt.Sprintf("❌ Failed to recreate '%s': %v", roleName, recreateErr)) - continue - } + if recreateErr != nil { + utils.Error( + "Warden purge role recreation failed", + "guild", guildID, + "roleName", roleName, + "roleID", roleIDToRecreate, + "error", recreateErr, + ) - summaryLines = append( - summaryLines, - fmt.Sprintf( - "✅ Recreated '%s' (old: `%s`, new: `%s`), re-applied %d overwrite(s).", - roleName, - roleIDToRecreate, - newRoleID, - reappliedOverwriteCount, - ), - ) - } + summaryLines = append(summaryLines, fmt.Sprintf("❌ Failed to recreate '%s': %v", roleName, recreateErr)) + continue + } + + summaryLines = append( + summaryLines, + fmt.Sprintf( + "✅ Recreated '%s' (old: `%s`, new: `%s`), re-applied %d overwrite(s).", + roleName, + roleIDToRecreate, + newRoleID, + reappliedOverwriteCount, + ), + ) + } - editEphemeral(session, interaction, joinOrFallback(summaryLines, "✅ Purge complete.")) + editEphemeral(session, interaction, joinOrFallback(summaryLines, "✅ Purge complete.")) + }() } func recreateRoleWithChannelOverwrites( @@ -332,10 +333,6 @@ func recreateRoleWithChannelOverwrites( channelOverwritesByChannelID := collectRoleOverwritesByChannelID(oldRoleID, guildChannels) - if err := session.GuildRoleDelete(guildID, oldRoleID); err != nil { - return "", 0, fmt.Errorf("delete role: %w", err) - } - newRole, err := session.GuildRoleCreate(guildID, &discordgo.RoleParams{ Name: oldRole.Name, Color: &oldRole.Color, @@ -352,8 +349,6 @@ func recreateRoleWithChannelOverwrites( return "", 0, fmt.Errorf("apply role properties: %w", err) } - _ = restoreRolePositionBestEffort(session, guildID, newRole.ID, oldRole.Position) - reappliedOverwriteCount, err := reapplyRoleOverwrites( session, newRole.ID, @@ -364,6 +359,10 @@ func recreateRoleWithChannelOverwrites( return "", reappliedOverwriteCount, fmt.Errorf("reapply overwrites: %w", err) } + if err := session.GuildRoleDelete(guildID, oldRoleID); err != nil { + return newRole.ID, reappliedOverwriteCount, fmt.Errorf("delete old role: %w", err) + } + return newRole.ID, reappliedOverwriteCount, nil } @@ -429,7 +428,16 @@ func reapplyRoleOverwrites( ) (int, error) { reappliedCount := 0 - for channelID, overwrite := range channelOverwritesByChannelID { + // Stable order (maps iterate randomly) + channelIDs := make([]string, 0, len(channelOverwritesByChannelID)) + for channelID := range channelOverwritesByChannelID { + channelIDs = append(channelIDs, channelID) + } + slices.Sort(channelIDs) + + for _, channelID := range channelIDs { + overwrite := channelOverwritesByChannelID[channelID] + if err := session.ChannelPermissionSet( channelID, newRoleID, @@ -441,6 +449,7 @@ func reapplyRoleOverwrites( } reappliedCount++ + time.Sleep(200 * time.Millisecond) } return reappliedCount, nil @@ -466,9 +475,9 @@ func resolveWardenRoleNames(roleScope string) []string { } func resolveWardenRoleIDs( - session *discordgo.Session, - guildID string, - roleScope string, + session *discordgo.Session, + guildID string, + roleScope string, ) (roleIDs []string, roleNames []string, err error) { roleNames = resolveWardenRoleNames(roleScope) roleIDs = make([]string, 0, len(roleNames)) @@ -488,8 +497,8 @@ func resolveWardenRoleIDs( } func getOptionString( - commandData discordgo.ApplicationCommandInteractionData, - optionName string, + commandData discordgo.ApplicationCommandInteractionData, + optionName string, ) (string, bool) { for _, option := range commandData.Options { if option != nil && option.Name == optionName { @@ -629,56 +638,3 @@ func isSnowflakeID(value string) bool { } return true } - -func restoreRolePositionBestEffort( - session *discordgo.Session, - guildID string, - roleID string, - targetPosition int, -) error { - guildRoles, err := session.GuildRoles(guildID) - if err != nil { - return err - } - - sort.Slice(guildRoles, func(i, j int) bool { - return guildRoles[i].Position < guildRoles[j].Position - }) - - var currentIndex = -1 - for index, role := range guildRoles { - if role.ID == roleID { - currentIndex = index - break - } - } - if currentIndex == -1 { - return nil - } - - roleToMove := guildRoles[currentIndex] - guildRoles = append(guildRoles[:currentIndex], guildRoles[currentIndex+1:]...) - - if targetPosition < 0 { - targetPosition = 0 - } - if targetPosition > len(guildRoles) { - targetPosition = len(guildRoles) - } - - guildRoles = append( - guildRoles[:targetPosition], - append([]*discordgo.Role{roleToMove}, guildRoles[targetPosition:]...)..., - ) - - reorderPayload := make([]*discordgo.Role, 0, len(guildRoles)) - for position, role := range guildRoles { - reorderPayload = append(reorderPayload, &discordgo.Role{ - ID: role.ID, - Position: position, - }) - } - - _, err = session.GuildRoleReorder(guildID, reorderPayload) - return err -}