From 4434636a6727521b62d923cd8f56d6547eb25071 Mon Sep 17 00:00:00 2001 From: Jens Gryspeert Date: Mon, 12 Jan 2026 17:22:55 +0100 Subject: [PATCH 1/2] Implement packages exp and text --- go.mod | 6 +++++- go.sum | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 1379df4..c19e3ed 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/7cav/cavbot2 -go 1.23.4 +go 1.24.0 + +toolchain go1.24.3 require ( github.com/bwmarrin/discordgo v0.28.1 @@ -11,6 +13,8 @@ require ( require ( github.com/gorilla/websocket v1.5.3 // indirect golang.org/x/crypto v0.31.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 golang.org/x/net v0.33.0 // indirect golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.33.0 ) diff --git a/go.sum b/go.sum index b8f56b0..f3d82cd 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= @@ -18,6 +20,8 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 1271756d3aa2ffde0289a21ae44d4ff2e45927f1 Mon Sep 17 00:00:00 2001 From: Jens Gryspeert Date: Mon, 12 Jan 2026 17:23:34 +0100 Subject: [PATCH 2/2] Rewrite warden command with internal and external flag and optimalisation of code --- commands/warden.go | 762 ++++++++++++++++++++++++--------------------- 1 file changed, 412 insertions(+), 350 deletions(-) diff --git a/commands/warden.go b/commands/warden.go index 960f876..bcc7b7a 100644 --- a/commands/warden.go +++ b/commands/warden.go @@ -1,385 +1,447 @@ package commands import ( - "fmt" - "strings" - "github.com/7cav/cavbot2/utils" - "github.com/bwmarrin/discordgo" + "fmt" + "strings" + + "slices" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/7cav/cavbot2/utils" + "github.com/bwmarrin/discordgo" ) -const ROLE_NAME string = "Warden Verified" +const wardenRoleBaseName = "Verified Warden" + +var ( + 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.ApplicationCommandOptionSubCommand, - Name: "add", - Description: "Add '" + ROLE_NAME + "' role to a user", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "discordname", - Description: "Discord username or nickname to match (partial allowed)", - Required: true, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "remove", - Description: "Remove '" + ROLE_NAME + "' role from a user", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "discordname", - Description: "Discord username or nickname to match (partial allowed)", - Required: true, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "bulkadd", - Description: "Add '" + ROLE_NAME + "' role to a list of users", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "userlist", - Description: "Comma-separated list of Discord usernames or nicknames to match (partial allowed)", - Required: true, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "purge", - Description: "Remove '" + ROLE_NAME + "' role from all users", - Options: []*discordgo.ApplicationCommandOption{}, - }, - }, - }, - 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) { - data := interaction.ApplicationCommandData() - if len(data.Options) == 0 { - utils.HandleError(session, interaction, "❌ Invalid warden command") - return - } - - sub := data.Options[0] - - if len(sub.Options) == 0 && sub.Name != "purge" { - utils.HandleError(session, interaction, "❌ Missing discordname argument") - return - } - - guildID := interaction.GuildID - - if guildID == "" { - utils.HandleError(session, interaction, "❌ GUILD_ID not configured") - return - } - - query := "" - if sub.Name != "purge" { - query = sub.Options[0].StringValue() - } - switch sub.Name { - case "add": - handleAddCommand(session, interaction, sub, guildID, query) - case "bulkadd": - handleBulkAddCommand(session, interaction, sub, guildID, query) - case "remove": - handleRemoveCommand(session, interaction, sub, guildID, query) - case "purge": - handlePurgeCommand(session, interaction, guildID) - default: - utils.HandleError(session, interaction, "❌ Unknown subcommand") - } + 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 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))) +} + +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 handleAddCommand( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - sub *discordgo.ApplicationCommandInteractionDataOption, - guildID string, - query string, -) { - member := retrieveMemberByName(session, guildID, query) - if member == nil { - return - } - - roleID := findRoleIDByName(session, interaction, guildID, ROLE_NAME) - if roleID == "" { - utils.HandleError(session, interaction, "❌ 'Warden Verified' role not found in guild") - return - } - - addRoleForQuery(session, interaction, guildID, query, roleID) - - err := session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("✅ Added '%s' role to %s#%s", ROLE_NAME,member.User.Username, member.User.Discriminator), - Flags: discordgo.MessageFlagsEphemeral, - }, - }) - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to send confirmation: %v", err)) - return - } - utils.Info("Warden role assigned", "user", member.User.ID) +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 handleRemoveCommand( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - sub *discordgo.ApplicationCommandInteractionDataOption, - guildID string, - query string, -) { - member := retrieveMemberByName(session, guildID, query) - if member == nil { - return - } - - roleID := findRoleIDByName(session, interaction, guildID, ROLE_NAME) - if roleID == "" { - utils.HandleError(session, interaction, "❌ 'Warden Verified' role not found in guild") - return - } - - removeRoleForQuery(session, interaction, guildID, query, roleID) - - err := session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("✅ Removed '%s' role from %s#%s", ROLE_NAME, member.User.Username, member.User.Discriminator), - Flags: discordgo.MessageFlagsEphemeral, - }, - }) - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to send confirmation: %v", err)) - return - } - utils.Info("Warden role removed", "user", member.User.ID) +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 + ) + + 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 handleBulkAddCommand( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - sub *discordgo.ApplicationCommandInteractionDataOption, - guildID string, - query string, -) { - queries := strings.Split(query, ",") - - roleID := findRoleIDByName(session, interaction, guildID, ROLE_NAME) - if roleID == "" { - return - } - - err := session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, - }) - - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge bulk add: %v", err)) - return - } - - var results []string - for _, q := range queries { - q = strings.TrimSpace(q) - utils.Info("Processing bulk add", "query", q) - if q == "" { - continue - } - results = append(results, addRoleForQuery(session, interaction, guildID, q, roleID)) - } - - content := strings.Join(results, "\n") - _, err = session.InteractionResponseEdit(interaction.Interaction, &discordgo.WebhookEdit{ - Content: &content, - }) - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to send bulk add summary: %v", err)) - return - } +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), + } + } } -func handlePurgeCommand( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - guildID string, -) { - roleID := findRoleIDByName(session, interaction, guildID, ROLE_NAME) - if roleID == "" { - return - } - - if err := session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, - }); err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to acknowledge purge: %v", err)) - return - } - - var ( - after string - removed int - results []string - ) - - for { - members, err := session.GuildMembers(guildID, after, 1000) - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to retrieve guild members: %v", err)) - return - } - if len(members) == 0 { - break - } - - for _, m := range members { - if m.User == nil { - continue - } - - if memberHasRole(m, roleID) { - results = append(results, removeRoleForQuery(session, interaction, guildID, m.User.Username, roleID)) - removed++ - } - } - - after = members[len(members)-1].User.ID - if len(members) < 1000 { - break - } - } - - content := strings.Join(results, "\n") - if content == "" { - content = "✅ Purge complete: no members had the role." - } - - _, err := session.InteractionResponseEdit(interaction.Interaction, &discordgo.WebhookEdit{ - Content: &content, - }) - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to send purge summary: %v", err)) - return - } +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 memberHasRole(m *discordgo.Member, roleID string) bool { - for _, rid := range m.Roles { - if rid == roleID { - return 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 +} + +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, + }, + }) +} + +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 addRoleForQuery( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - guildID string, - query string, - roleID string, -) string { - member := retrieveMemberByName(session, guildID, query) - if member == nil { - return fmt.Sprintf("❌ No member found matching '%s'", query) - } - - err := session.GuildMemberRoleAdd(guildID, member.User.ID, roleID) - if err != nil { - utils.Error("Failed to add role in bulk", "user", member.User.ID, "error", err) - return fmt.Sprintf("❌ Failed to add role to %s#%s: %v", member.User.Username, member.User.Discriminator, err) - } - - utils.Info("Warden role assigned (bulk)", "user", member.User.ID) - return fmt.Sprintf("✅ Added '%s' role to %s#%s", ROLE_NAME, member.User.Username, member.User.Discriminator) +func memberHasRole(member *discordgo.Member, roleID string) bool { + return slices.Contains(member.Roles, roleID) } -func removeRoleForQuery( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - guildID string, - query string, - roleID string, -) string { - member := retrieveMemberByName(session, guildID, query) - if member == nil { - return fmt.Sprintf("❌ No member found matching '%s'", query) - } - - err := session.GuildMemberRoleRemove(guildID, member.User.ID, roleID) - if err != nil { - utils.Error("Failed to remove role in bulk", "user", member.User.ID, "error", err) - return fmt.Sprintf("❌ Failed to remove role from %s#%s: %v", member.User.Username, member.User.Discriminator, err) - } - - utils.Info("Warden role removed ", "user", member.User.ID) - return fmt.Sprintf("✅ Removed '%s' role from %s#%s", ROLE_NAME, member.User.Username, member.User.Discriminator) +func formatUser(member *discordgo.Member) string { + 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) + } + + return member.User.Username } -func retrieveMemberByName( - session *discordgo.Session, - guildID string, - query string, -) (*discordgo.Member) { - if strings.HasPrefix(query, "<@") && strings.HasSuffix(query, ">") { - userID := strings.TrimSuffix(strings.TrimPrefix(query, "<@"), ">") - member, _ := session.GuildMember(guildID, userID) +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) + } +} - if member != nil { - return member - } - } +func findGuildRoleIDByName(session *discordgo.Session, guildID, roleName string) (string, error) { + roles, err := session.GuildRoles(guildID) + if err != nil { + return "", err + } - members, err := session.GuildMembersSearch(guildID, query, 10) + for _, role := range roles { + if role != nil && role.Name == roleName { + return role.ID, nil + } + } - if err != nil { - utils.Error("Failed to search members (silent)", "error", err) - return nil - } + return "", nil +} - if len(members) != 1 { - 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 +} - return members[0] +func joinOrFallback(lines []string, fallback string) string { + joined := strings.Join(lines, "\n") + if strings.TrimSpace(joined) == "" { + return fallback + } + return joined } -func findRoleIDByName( - session *discordgo.Session, - interaction *discordgo.InteractionCreate, - guildID string, - roleName string, -) (string) { - roles, err := session.GuildRoles(guildID) - - if err != nil { - utils.HandleError(session, interaction, fmt.Sprintf("❌ Failed to retrieve guild roles: %v", err)) - return "" - } - - for _, role := range roles { - if role.Name == roleName { - return role.ID - } - } - - return "" +func isSnowflakeID(value string) bool { + if len(value) < 15 { + return false + } + for _, runeValue := range value { + if runeValue < '0' || runeValue > '9' { + return false + } + } + return true }