From d949e53cf8da046a37c23ffd549db103d1ce5077 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Tue, 28 Apr 2026 21:32:11 +0200 Subject: [PATCH 1/6] feat(commands): add shared logic for role-based staff management --- .../rustmail/src/commands/add_staff/common.rs | 72 ++++++++++++++++++- .../src/commands/remove_staff/common.rs | 21 ++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/rustmail/src/commands/add_staff/common.rs b/crates/rustmail/src/commands/add_staff/common.rs index e2989a6b..98922689 100644 --- a/crates/rustmail/src/commands/add_staff/common.rs +++ b/crates/rustmail/src/commands/add_staff/common.rs @@ -1,10 +1,28 @@ use crate::prelude::config::*; use crate::prelude::errors::*; use serenity::all::{ - ChannelId, Context, Message, PermissionOverwrite, PermissionOverwriteType, UserId, + ChannelId, Context, GuildId, Message, PermissionOverwrite, PermissionOverwriteType, RoleId, + UserId, }; use serenity::model::Permissions; +pub const MAX_ROLE_MEMBERS_PER_ADD: usize = 50; + +pub enum AddTarget { + User(UserId), + Role(RoleId), +} + +pub enum AddTargetParse { + Explicit(AddTarget), + AmbiguousId(u64), +} + +pub struct AddRoleOutcome { + pub added: Vec, + pub failed: Vec, +} + pub async fn add_user_to_channel( ctx: &Context, channel_id: ChannelId, @@ -26,6 +44,58 @@ pub async fn add_user_to_channel( Ok(()) } +pub fn parse_add_target(raw: &str) -> Option { + let s = raw.trim(); + if let Some(inner) = s.strip_prefix("<@&").and_then(|s| s.strip_suffix('>')) { + return inner + .parse::() + .ok() + .map(|id| AddTargetParse::Explicit(AddTarget::Role(RoleId::new(id)))); + } + if let Some(inner) = s.strip_prefix("<@").and_then(|s| s.strip_suffix('>')) { + let inner = inner.strip_prefix('!').unwrap_or(inner); + return inner + .parse::() + .ok() + .map(|id| AddTargetParse::Explicit(AddTarget::User(UserId::new(id)))); + } + s.parse::().ok().map(AddTargetParse::AmbiguousId) +} + +pub async fn members_with_role( + ctx: &Context, + guild_id: GuildId, + role_id: RoleId, +) -> ModmailResult> { + let members = guild_id.members(&ctx.http, None, None).await.map_err(|_| { + ModmailError::Discord(DiscordError::ApiError( + "Failed to fetch guild members".to_string(), + )) + })?; + + Ok(members + .into_iter() + .filter(|m| m.roles.contains(&role_id)) + .map(|m| m.user.id) + .collect()) +} + +pub async fn add_role_members_to_channel( + ctx: &Context, + channel_id: ChannelId, + members: Vec, +) -> AddRoleOutcome { + let mut added = Vec::new(); + let mut failed = Vec::new(); + for user_id in members { + match add_user_to_channel(ctx, channel_id, user_id).await { + Ok(_) => added.push(user_id), + Err(_) => failed.push(user_id), + } + } + AddRoleOutcome { added, failed } +} + pub async fn extract_staff_id(msg: &Message, config: &Config) -> String { let content = msg.content.trim(); let prefix = &config.command.prefix; diff --git a/crates/rustmail/src/commands/remove_staff/common.rs b/crates/rustmail/src/commands/remove_staff/common.rs index c7bdbdc6..5a1f9c87 100644 --- a/crates/rustmail/src/commands/remove_staff/common.rs +++ b/crates/rustmail/src/commands/remove_staff/common.rs @@ -4,6 +4,11 @@ use serenity::all::{ ChannelId, Context, Message, PermissionOverwrite, PermissionOverwriteType, Permissions, UserId, }; +pub struct RemoveRoleOutcome { + pub removed: Vec, + pub failed: Vec, +} + pub async fn remove_user_from_channel( ctx: &Context, channel_id: ChannelId, @@ -25,6 +30,22 @@ pub async fn remove_user_from_channel( Ok(()) } +pub async fn remove_role_members_from_channel( + ctx: &Context, + channel_id: ChannelId, + members: Vec, +) -> RemoveRoleOutcome { + let mut removed = Vec::new(); + let mut failed = Vec::new(); + for user_id in members { + match remove_user_from_channel(ctx, channel_id, user_id).await { + Ok(_) => removed.push(user_id), + Err(_) => failed.push(user_id), + } + } + RemoveRoleOutcome { removed, failed } +} + pub async fn extract_remove_staff_id(msg: &Message, config: &Config) -> String { let content = msg.content.trim(); let prefix = &config.command.prefix; From 04fe36a00d4ea235f778937cdf8a437e3e32dd46 Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Tue, 28 Apr 2026 21:33:04 +0200 Subject: [PATCH 2/6] feat(commands): support role targets in add_staff command --- .../add_staff/slash_command/add_staff.rs | 189 +++++++++++++----- .../add_staff/text_command/add_staff.rs | 143 ++++++++++--- 2 files changed, 261 insertions(+), 71 deletions(-) diff --git a/crates/rustmail/src/commands/add_staff/slash_command/add_staff.rs b/crates/rustmail/src/commands/add_staff/slash_command/add_staff.rs index 03e1efdb..cbd2e68a 100644 --- a/crates/rustmail/src/commands/add_staff/slash_command/add_staff.rs +++ b/crates/rustmail/src/commands/add_staff/slash_command/add_staff.rs @@ -8,7 +8,7 @@ use crate::prelude::utils::*; use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, - CreateCommandOption, ResolvedOption, + CreateCommandOption, GuildId, ResolvedOption, RoleId, UserId, }; use std::collections::HashMap; use std::sync::Arc; @@ -41,9 +41,9 @@ impl RegistrableCommand for AddStaffCommand { ) .await; - let user_id_desc = get_translated_message( + let target_desc = get_translated_message( &config, - "slash_command.add_staff_user_id_argument", + "slash_command.add_staff_target_argument", None, None, None, @@ -53,7 +53,7 @@ impl RegistrableCommand for AddStaffCommand { vec![ CreateCommand::new(name).description(cmd_desc).add_option( - CreateCommandOption::new(CommandOptionType::User, "user_id", user_id_desc) + CreateCommandOption::new(CommandOptionType::Mentionable, "target", target_desc) .required(true), ), ] @@ -80,51 +80,144 @@ impl RegistrableCommand for AddStaffCommand { defer_response(&ctx, &command).await?; - let user_id = match command - .data - .options - .iter() - .find(|opt| opt.name == "user_id") - { - Some(opt) => match &opt.value { - CommandDataOptionValue::User(user_id) => *user_id, - _ => { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "user_id".to_string(), - ))); - } - }, - None => { - if let Some(user_id) = command.data.target_id { - user_id.to_user_id() - } else { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "user_id".to_string(), - ))); - } - } - }; - - if thread_exists_by_channel(command.channel_id, pool).await { - match add_user_to_channel(&ctx, command.channel_id, user_id).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("user".to_string(), format!("<@{}>", user_id)); - - let _ = MessageBuilder::system_message(&ctx, &config) - .translated_content("add_staff.add_success", Some(¶ms), None, None) - .await - .to_channel(command.channel_id) - .send_interaction_followup(&command, true) - .await; - - Ok(()) - } - Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), - } - } else { - Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + if !thread_exists_by_channel(command.channel_id, pool).await { + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); + } + + let target = resolve_target(&command)?; + + match target { + AddTarget::User(user_id) => add_single_user(&ctx, &config, &command, user_id).await, + AddTarget::Role(role_id) => add_role(&ctx, &config, &command, role_id).await, } }) } } + +fn resolve_target(command: &CommandInteraction) -> ModmailResult { + let opt = command + .data + .options + .iter() + .find(|opt| opt.name == "target") + .ok_or_else(|| { + ModmailError::Command(CommandError::InvalidArguments("target".to_string())) + })?; + + let id = match &opt.value { + CommandDataOptionValue::Mentionable(id) => id.get(), + CommandDataOptionValue::User(user_id) => return Ok(AddTarget::User(*user_id)), + CommandDataOptionValue::Role(role_id) => return Ok(AddTarget::Role(*role_id)), + _ => { + return Err(ModmailError::Command(CommandError::InvalidArguments( + "target".to_string(), + ))); + } + }; + + if command.data.resolved.users.contains_key(&UserId::new(id)) { + Ok(AddTarget::User(UserId::new(id))) + } else if command.data.resolved.roles.contains_key(&RoleId::new(id)) { + Ok(AddTarget::Role(RoleId::new(id))) + } else { + Err(ModmailError::Command(CommandError::InvalidArguments( + "target".to_string(), + ))) + } +} + +async fn add_single_user( + ctx: &Context, + config: &Config, + command: &CommandInteraction, + user_id: UserId, +) -> ModmailResult<()> { + add_user_to_channel(ctx, command.channel_id, user_id).await?; + + let mut params = HashMap::new(); + params.insert("user".to_string(), format!("<@{}>", user_id)); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.add_success", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + + Ok(()) +} + +async fn add_role( + ctx: &Context, + config: &Config, + command: &CommandInteraction, + role_id: RoleId, +) -> ModmailResult<()> { + let guild_id = command + .guild_id + .unwrap_or_else(|| GuildId::new(config.bot.get_staff_guild_id())); + + if role_id.get() == guild_id.get() { + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_everyone_forbidden", None, None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + return Ok(()); + } + + let role_mention = format!("<@&{}>", role_id); + let members = members_with_role(ctx, guild_id, role_id).await?; + + if members.is_empty() { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_no_members", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + return Ok(()); + } + + if members.len() > MAX_ROLE_MEMBERS_PER_ADD { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), members.len().to_string()); + params.insert("max".to_string(), MAX_ROLE_MEMBERS_PER_ADD.to_string()); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_too_many", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + return Ok(()); + } + + let total = members.len(); + let outcome = add_role_members_to_channel(ctx, command.channel_id, members).await; + + let key = if outcome.failed.is_empty() { + "add_staff.role_add_success" + } else { + "add_staff.role_add_partial" + }; + + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), outcome.added.len().to_string()); + params.insert("added".to_string(), outcome.added.len().to_string()); + params.insert("total".to_string(), total.to_string()); + params.insert("failed".to_string(), outcome.failed.len().to_string()); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content(key, Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + + Ok(()) +} diff --git a/crates/rustmail/src/commands/add_staff/text_command/add_staff.rs b/crates/rustmail/src/commands/add_staff/text_command/add_staff.rs index c4b40f2d..c15ee5d2 100644 --- a/crates/rustmail/src/commands/add_staff/text_command/add_staff.rs +++ b/crates/rustmail/src/commands/add_staff/text_command/add_staff.rs @@ -4,7 +4,7 @@ use crate::prelude::db::*; use crate::prelude::errors::*; use crate::prelude::handlers::*; use crate::prelude::utils::*; -use serenity::all::{Context, Message, UserId}; +use serenity::all::{Context, GuildId, Message, RoleId, UserId}; use std::collections::HashMap; use std::sync::Arc; @@ -19,35 +19,132 @@ pub async fn add_staff( .as_ref() .ok_or_else(database_connection_failed)?; - let user_id_str = extract_staff_id(&msg, config).await; + if !thread_exists_by_channel(msg.channel_id, pool).await { + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); + } + + let raw = extract_staff_id(&msg, config).await; - if user_id_str.is_empty() { + if raw.is_empty() { return Err(ModmailError::Command(CommandError::InvalidFormat)); } - let user_id = match user_id_str.parse::() { - Ok(id) => UserId::new(id), - Err(_) => return Err(ModmailError::Command(CommandError::InvalidFormat)), - }; - - if thread_exists_by_channel(msg.channel_id, pool).await { - match add_user_to_channel(&ctx, msg.channel_id, user_id).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("user".to_string(), format!("<@{}>", user_id)); + let parsed = + parse_add_target(&raw).ok_or_else(|| ModmailError::Command(CommandError::InvalidFormat))?; - let _ = MessageBuilder::system_message(&ctx, config) - .translated_content("add_staff.add_success", Some(¶ms), None, None) - .await - .to_channel(msg.channel_id) - .send(true) - .await; + let guild_id = msg + .guild_id + .unwrap_or_else(|| GuildId::new(config.bot.get_staff_guild_id())); - Ok(()) + let target = match parsed { + AddTargetParse::Explicit(t) => t, + AddTargetParse::AmbiguousId(id) => { + let guild = guild_id.to_partial_guild(&ctx.http).await.map_err(|_| { + ModmailError::Discord(DiscordError::ApiError("Guild not found".to_string())) + })?; + if guild.roles.contains_key(&RoleId::new(id)) { + AddTarget::Role(RoleId::new(id)) + } else { + AddTarget::User(UserId::new(id)) } - Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), } - } else { - Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + }; + + match target { + AddTarget::User(user_id) => add_single_user(&ctx, &msg, config, user_id).await, + AddTarget::Role(role_id) => add_role(&ctx, &msg, config, role_id, guild_id).await, } } + +async fn add_single_user( + ctx: &Context, + msg: &Message, + config: &Config, + user_id: UserId, +) -> ModmailResult<()> { + add_user_to_channel(ctx, msg.channel_id, user_id).await?; + + let mut params = HashMap::new(); + params.insert("user".to_string(), format!("<@{}>", user_id)); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.add_success", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + + Ok(()) +} + +async fn add_role( + ctx: &Context, + msg: &Message, + config: &Config, + role_id: RoleId, + guild_id: GuildId, +) -> ModmailResult<()> { + if role_id.get() == guild_id.get() { + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_everyone_forbidden", None, None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + return Ok(()); + } + + let role_mention = format!("<@&{}>", role_id); + let members = members_with_role(ctx, guild_id, role_id).await?; + + if members.is_empty() { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_no_members", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + return Ok(()); + } + + if members.len() > MAX_ROLE_MEMBERS_PER_ADD { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), members.len().to_string()); + params.insert("max".to_string(), MAX_ROLE_MEMBERS_PER_ADD.to_string()); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_too_many", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + return Ok(()); + } + + let total = members.len(); + let outcome = add_role_members_to_channel(ctx, msg.channel_id, members).await; + + let key = if outcome.failed.is_empty() { + "add_staff.role_add_success" + } else { + "add_staff.role_add_partial" + }; + + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), outcome.added.len().to_string()); + params.insert("added".to_string(), outcome.added.len().to_string()); + params.insert("total".to_string(), total.to_string()); + params.insert("failed".to_string(), outcome.failed.len().to_string()); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content(key, Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + + Ok(()) +} From d6d62d088b4e3973b842f5f23cfa4cf5aeaf3cdd Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Tue, 28 Apr 2026 21:33:27 +0200 Subject: [PATCH 3/6] feat(commands): support role targets in remove_staff command --- .../slash_command/remove_staff.rs | 198 +++++++++++++----- .../remove_staff/text_command/remove_staff.rs | 148 +++++++++++-- 2 files changed, 271 insertions(+), 75 deletions(-) diff --git a/crates/rustmail/src/commands/remove_staff/slash_command/remove_staff.rs b/crates/rustmail/src/commands/remove_staff/slash_command/remove_staff.rs index c5f08bbb..db0062e1 100644 --- a/crates/rustmail/src/commands/remove_staff/slash_command/remove_staff.rs +++ b/crates/rustmail/src/commands/remove_staff/slash_command/remove_staff.rs @@ -8,7 +8,7 @@ use crate::prelude::utils::*; use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, Context, - CreateCommand, CreateCommandOption, ResolvedOption, + CreateCommand, CreateCommandOption, GuildId, ResolvedOption, RoleId, UserId, }; use std::collections::HashMap; use std::sync::Arc; @@ -42,9 +42,9 @@ impl RegistrableCommand for RemoveStaffCommand { ) .await; - let user_id_desc = get_translated_message( + let target_desc = get_translated_message( &config, - "slash_command.remove_staff_user_id_argument", + "slash_command.remove_staff_target_argument", None, None, None, @@ -54,7 +54,7 @@ impl RegistrableCommand for RemoveStaffCommand { vec![ CreateCommand::new(name).description(cmd_desc).add_option( - CreateCommandOption::new(CommandOptionType::User, "user_id", user_id_desc) + CreateCommandOption::new(CommandOptionType::Mentionable, "target", target_desc) .required(true), ), CreateCommand::new(name).kind(CommandType::User), @@ -82,56 +82,150 @@ impl RegistrableCommand for RemoveStaffCommand { defer_response(&ctx, &command).await?; - let user_id = match command - .data - .options - .iter() - .find(|opt| opt.name == "user_id") - { - Some(opt) => match &opt.value { - CommandDataOptionValue::User(user_id) => *user_id, - _ => { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "user_id".to_string(), - ))); - } - }, - None => { - if let Some(user_id) = command.data.target_id { - user_id.to_user_id() - } else { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "user_id".to_string(), - ))); - } - } - }; - - if thread_exists_by_channel(command.channel_id, pool).await { - match remove_user_from_channel(&ctx, command.channel_id, user_id).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("user".to_string(), format!("<@{}>", user_id)); - - let _ = MessageBuilder::system_message(&ctx, &config) - .translated_content( - "add_staff.remove_success", - Some(¶ms), - None, - None, - ) - .await - .to_channel(command.channel_id) - .send_interaction_followup(&command, true) - .await; - - Ok(()) - } - Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), + if !thread_exists_by_channel(command.channel_id, pool).await { + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); + } + + let target = resolve_target(&command)?; + + match target { + AddTarget::User(user_id) => { + remove_single_user(&ctx, &config, &command, user_id).await } - } else { - Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + AddTarget::Role(role_id) => remove_role(&ctx, &config, &command, role_id).await, } }) } } + +fn resolve_target(command: &CommandInteraction) -> ModmailResult { + if let Some(opt) = command.data.options.iter().find(|opt| opt.name == "target") { + let id = match &opt.value { + CommandDataOptionValue::Mentionable(id) => id.get(), + CommandDataOptionValue::User(user_id) => return Ok(AddTarget::User(*user_id)), + CommandDataOptionValue::Role(role_id) => return Ok(AddTarget::Role(*role_id)), + _ => { + return Err(ModmailError::Command(CommandError::InvalidArguments( + "target".to_string(), + ))); + } + }; + + if command.data.resolved.users.contains_key(&UserId::new(id)) { + Ok(AddTarget::User(UserId::new(id))) + } else if command.data.resolved.roles.contains_key(&RoleId::new(id)) { + Ok(AddTarget::Role(RoleId::new(id))) + } else { + Err(ModmailError::Command(CommandError::InvalidArguments( + "target".to_string(), + ))) + } + } else if let Some(target_id) = command.data.target_id { + Ok(AddTarget::User(target_id.to_user_id())) + } else { + Err(ModmailError::Command(CommandError::InvalidArguments( + "target".to_string(), + ))) + } +} + +async fn remove_single_user( + ctx: &Context, + config: &Config, + command: &CommandInteraction, + user_id: UserId, +) -> ModmailResult<()> { + remove_user_from_channel(ctx, command.channel_id, user_id).await?; + + let mut params = HashMap::new(); + params.insert("user".to_string(), format!("<@{}>", user_id)); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.remove_success", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + + Ok(()) +} + +async fn remove_role( + ctx: &Context, + config: &Config, + command: &CommandInteraction, + role_id: RoleId, +) -> ModmailResult<()> { + let guild_id = command + .guild_id + .unwrap_or_else(|| GuildId::new(config.bot.get_staff_guild_id())); + + if role_id.get() == guild_id.get() { + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_everyone_forbidden_remove", None, None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + return Ok(()); + } + + let role_mention = format!("<@&{}>", role_id); + let members = members_with_role(ctx, guild_id, role_id).await?; + + if members.is_empty() { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content( + "add_staff.role_no_members_remove", + Some(¶ms), + None, + None, + ) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + return Ok(()); + } + + if members.len() > MAX_ROLE_MEMBERS_PER_ADD { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), members.len().to_string()); + params.insert("max".to_string(), MAX_ROLE_MEMBERS_PER_ADD.to_string()); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_too_many_remove", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + return Ok(()); + } + + let total = members.len(); + let outcome = remove_role_members_from_channel(ctx, command.channel_id, members).await; + + let key = if outcome.failed.is_empty() { + "add_staff.role_remove_success" + } else { + "add_staff.role_remove_partial" + }; + + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), outcome.removed.len().to_string()); + params.insert("removed".to_string(), outcome.removed.len().to_string()); + params.insert("total".to_string(), total.to_string()); + params.insert("failed".to_string(), outcome.failed.len().to_string()); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content(key, Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .send_interaction_followup(command, true) + .await; + + Ok(()) +} diff --git a/crates/rustmail/src/commands/remove_staff/text_command/remove_staff.rs b/crates/rustmail/src/commands/remove_staff/text_command/remove_staff.rs index af7cc5dd..3902a286 100644 --- a/crates/rustmail/src/commands/remove_staff/text_command/remove_staff.rs +++ b/crates/rustmail/src/commands/remove_staff/text_command/remove_staff.rs @@ -4,7 +4,7 @@ use crate::prelude::db::*; use crate::prelude::errors::*; use crate::prelude::handlers::*; use crate::prelude::utils::*; -use serenity::all::{Context, Message, UserId}; +use serenity::all::{Context, GuildId, Message, RoleId, UserId}; use std::collections::HashMap; use std::sync::Arc; @@ -19,35 +19,137 @@ pub async fn remove_staff( .as_ref() .ok_or_else(database_connection_failed)?; - let user_id_str = extract_remove_staff_id(&msg, config).await; + if !thread_exists_by_channel(msg.channel_id, pool).await { + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); + } + + let raw = extract_remove_staff_id(&msg, config).await; - if user_id_str.is_empty() { + if raw.is_empty() { return Err(ModmailError::Command(CommandError::InvalidFormat)); } - let user_id = match user_id_str.parse::() { - Ok(id) => UserId::new(id), - Err(_) => return Err(ModmailError::Command(CommandError::InvalidFormat)), - }; - - if thread_exists_by_channel(msg.channel_id, pool).await { - match remove_user_from_channel(&ctx, msg.channel_id, user_id).await { - Ok(_) => { - let mut params = HashMap::new(); - params.insert("user".to_string(), format!("<@{}>", user_id)); + let parsed = + parse_add_target(&raw).ok_or_else(|| ModmailError::Command(CommandError::InvalidFormat))?; - let _ = MessageBuilder::system_message(&ctx, config) - .translated_content("add_staff.remove_success", Some(¶ms), None, None) - .await - .to_channel(msg.channel_id) - .send(true) - .await; + let guild_id = msg + .guild_id + .unwrap_or_else(|| GuildId::new(config.bot.get_staff_guild_id())); - Ok(()) + let target = match parsed { + AddTargetParse::Explicit(t) => t, + AddTargetParse::AmbiguousId(id) => { + let guild = guild_id.to_partial_guild(&ctx.http).await.map_err(|_| { + ModmailError::Discord(DiscordError::ApiError("Guild not found".to_string())) + })?; + if guild.roles.contains_key(&RoleId::new(id)) { + AddTarget::Role(RoleId::new(id)) + } else { + AddTarget::User(UserId::new(id)) } - Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), } - } else { - Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + }; + + match target { + AddTarget::User(user_id) => remove_single_user(&ctx, &msg, config, user_id).await, + AddTarget::Role(role_id) => remove_role(&ctx, &msg, config, role_id, guild_id).await, } } + +async fn remove_single_user( + ctx: &Context, + msg: &Message, + config: &Config, + user_id: UserId, +) -> ModmailResult<()> { + remove_user_from_channel(ctx, msg.channel_id, user_id).await?; + + let mut params = HashMap::new(); + params.insert("user".to_string(), format!("<@{}>", user_id)); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.remove_success", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + + Ok(()) +} + +async fn remove_role( + ctx: &Context, + msg: &Message, + config: &Config, + role_id: RoleId, + guild_id: GuildId, +) -> ModmailResult<()> { + if role_id.get() == guild_id.get() { + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_everyone_forbidden_remove", None, None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + return Ok(()); + } + + let role_mention = format!("<@&{}>", role_id); + let members = members_with_role(ctx, guild_id, role_id).await?; + + if members.is_empty() { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content( + "add_staff.role_no_members_remove", + Some(¶ms), + None, + None, + ) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + return Ok(()); + } + + if members.len() > MAX_ROLE_MEMBERS_PER_ADD { + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), members.len().to_string()); + params.insert("max".to_string(), MAX_ROLE_MEMBERS_PER_ADD.to_string()); + let _ = MessageBuilder::system_message(ctx, config) + .translated_content("add_staff.role_too_many_remove", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + return Ok(()); + } + + let total = members.len(); + let outcome = remove_role_members_from_channel(ctx, msg.channel_id, members).await; + + let key = if outcome.failed.is_empty() { + "add_staff.role_remove_success" + } else { + "add_staff.role_remove_partial" + }; + + let mut params = HashMap::new(); + params.insert("role".to_string(), role_mention); + params.insert("count".to_string(), outcome.removed.len().to_string()); + params.insert("removed".to_string(), outcome.removed.len().to_string()); + params.insert("total".to_string(), total.to_string()); + params.insert("failed".to_string(), outcome.failed.len().to_string()); + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content(key, Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + + Ok(()) +} From 9ba19388908862b0324a589527c3ee4814f8204e Mon Sep 17 00:00:00 2001 From: Akinator31 Date: Tue, 28 Apr 2026 21:34:06 +0200 Subject: [PATCH 4/6] refactor(i18n): add translations and error messages for role management --- crates/rustmail/src/errors/dictionary.rs | 10 ++++ crates/rustmail/src/i18n/language/en.rs | 67 +++++++++++++++++++++--- crates/rustmail/src/i18n/language/fr.rs | 67 +++++++++++++++++++++--- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/crates/rustmail/src/errors/dictionary.rs b/crates/rustmail/src/errors/dictionary.rs index 94fae906..ce117d3c 100644 --- a/crates/rustmail/src/errors/dictionary.rs +++ b/crates/rustmail/src/errors/dictionary.rs @@ -189,6 +189,11 @@ impl DictionaryManager { ("discord.shard_manager_not_found".to_string(), None) } DiscordError::CategoryFull => ("discord.category_full".to_string(), None), + DiscordError::ApiError(msg) => { + let mut params = HashMap::new(); + params.insert("error".to_string(), msg.clone()); + ("discord.api_error".to_string(), Some(params)) + } _ => ("discord.api_error".to_string(), None), }, ModmailError::Command(cmd_err) => match cmd_err { @@ -292,6 +297,11 @@ impl DictionaryManager { params.insert("reminder_id".to_string(), reminder_id.clone()); ("reminder.already_complete".to_string(), Some(params)) } + CommandError::CommandFailed(msg) => { + let mut params = HashMap::new(); + params.insert("error".to_string(), msg.clone()); + ("command.command_failed".to_string(), Some(params)) + } _ => ("command.invalid_format".to_string(), None), }, ModmailError::Thread(thread_err) => match thread_err { diff --git a/crates/rustmail/src/i18n/language/en.rs b/crates/rustmail/src/i18n/language/en.rs index bd6f7b9a..49ef7686 100644 --- a/crates/rustmail/src/i18n/language/en.rs +++ b/crates/rustmail/src/i18n/language/en.rs @@ -40,7 +40,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "discord.api_error".to_string(), - DictionaryMessage::new("Discord API error") + DictionaryMessage::new("Discord API error: {error}") .with_description("An error occurred while communicating with Discord"), ); dict.messages.insert( @@ -65,6 +65,11 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { .with_description("The command syntax is incorrect") .with_help("Use `{prefix}help` to see the correct command format"), ); + dict.messages.insert( + "command.command_failed".to_string(), + DictionaryMessage::new("Command failed: {error}") + .with_description("The command could not be completed"), + ); dict.messages.insert( "command.missing_arguments".to_string(), DictionaryMessage::new("Missing required arguments") @@ -606,6 +611,54 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "add_staff.remove_success".to_string(), DictionaryMessage::new("The user {user} has been removed from the ticket successfully."), ); + dict.messages.insert( + "add_staff.role_add_success".to_string(), + DictionaryMessage::new("Added {count} member(s) of role {role} to the ticket."), + ); + dict.messages.insert( + "add_staff.role_add_partial".to_string(), + DictionaryMessage::new( + "Added {added}/{total} member(s) of role {role} to the ticket. {failed} could not be added.", + ), + ); + dict.messages.insert( + "add_staff.role_no_members".to_string(), + DictionaryMessage::new("Role {role} has no members to add."), + ); + dict.messages.insert( + "add_staff.role_too_many".to_string(), + DictionaryMessage::new( + "Role {role} has {count} members, which exceeds the limit of {max}. Add them individually.", + ), + ); + dict.messages.insert( + "add_staff.role_everyone_forbidden".to_string(), + DictionaryMessage::new("Cannot add @everyone to a ticket."), + ); + dict.messages.insert( + "add_staff.role_remove_success".to_string(), + DictionaryMessage::new("Removed {count} member(s) of role {role} from the ticket."), + ); + dict.messages.insert( + "add_staff.role_remove_partial".to_string(), + DictionaryMessage::new( + "Removed {removed}/{total} member(s) of role {role} from the ticket. {failed} could not be removed.", + ), + ); + dict.messages.insert( + "add_staff.role_no_members_remove".to_string(), + DictionaryMessage::new("Role {role} has no members to remove."), + ); + dict.messages.insert( + "add_staff.role_too_many_remove".to_string(), + DictionaryMessage::new( + "Role {role} has {count} members, which exceeds the limit of {max}. Remove them individually.", + ), + ); + dict.messages.insert( + "add_staff.role_everyone_forbidden_remove".to_string(), + DictionaryMessage::new("Cannot remove @everyone from a ticket."), + ); dict.messages.insert( "id.show_id".to_string(), DictionaryMessage::new("ID of {user} : {id}"), @@ -705,16 +758,16 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ), ); dict.messages.insert( - "slash_command.add_staff_user_id_argument".to_string(), - DictionaryMessage::new("The ID of the staff to add to the ticket"), + "slash_command.add_staff_target_argument".to_string(), + DictionaryMessage::new("The user or role to add to the ticket"), ); dict.messages.insert( "slash_command.remove_staff_command_description".to_string(), DictionaryMessage::new("Remove a staff member from the current ticket"), ); dict.messages.insert( - "slash_command.remove_staff_user_id_argument".to_string(), - DictionaryMessage::new("The ID of the staff to remove from the ticket"), + "slash_command.remove_staff_target_argument".to_string(), + DictionaryMessage::new("The user or role to remove from the ticket"), ); dict.messages.insert( "slash_command.alert_command_description".to_string(), @@ -920,7 +973,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "help.add_staff".to_string(), - DictionaryMessage::new("Adds a staff member to a ticket. To do so, use `!addmod ` or `!am ` inside a ticket."), + DictionaryMessage::new("Adds a staff member or every member of a role to a ticket. Use `!addmod ` or `!am ` inside a ticket."), ); dict.messages.insert( "help.alert".to_string(), @@ -993,7 +1046,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "help.remove_staff".to_string(), - DictionaryMessage::new("Removes a staff member from the current ticket. To remove a staff member, use `!delmod ` or `!dm ` inside the ticket."), + DictionaryMessage::new("Removes a staff member or every member of a role from the current ticket. Use `!delmod ` or `!dm ` inside the ticket."), ); dict.messages.insert( "help.reply".to_string(), diff --git a/crates/rustmail/src/i18n/language/fr.rs b/crates/rustmail/src/i18n/language/fr.rs index 33fb5e55..f63562ba 100644 --- a/crates/rustmail/src/i18n/language/fr.rs +++ b/crates/rustmail/src/i18n/language/fr.rs @@ -38,7 +38,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "discord.api_error".to_string(), - DictionaryMessage::new("Erreur de l'API Discord") + DictionaryMessage::new("Erreur de l'API Discord : {error}") .with_description("Une erreur s'est produite lors de la communication avec Discord"), ); dict.messages.insert( @@ -63,6 +63,11 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { .with_description("La syntaxe de la commande est incorrecte") .with_help("Utilisez `{prefix}help` pour voir le format correct de la commande"), ); + dict.messages.insert( + "command.command_failed".to_string(), + DictionaryMessage::new("La commande a échoué : {error}") + .with_description("La commande n'a pas pu être exécutée"), + ); dict.messages.insert( "command.missing_arguments".to_string(), DictionaryMessage::new("Arguments requis manquants") @@ -630,6 +635,54 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "add_staff.remove_success".to_string(), DictionaryMessage::new("L'utilisateur {user} a été retiré du ticket avec succès."), ); + dict.messages.insert( + "add_staff.role_add_success".to_string(), + DictionaryMessage::new("{count} membre(s) du rôle {role} ont été ajoutés au ticket."), + ); + dict.messages.insert( + "add_staff.role_add_partial".to_string(), + DictionaryMessage::new( + "{added}/{total} membre(s) du rôle {role} ont été ajoutés au ticket. {failed} n'ont pas pu être ajoutés.", + ), + ); + dict.messages.insert( + "add_staff.role_no_members".to_string(), + DictionaryMessage::new("Le rôle {role} n'a aucun membre à ajouter."), + ); + dict.messages.insert( + "add_staff.role_too_many".to_string(), + DictionaryMessage::new( + "Le rôle {role} compte {count} membres, ce qui dépasse la limite de {max}. Ajoutez-les individuellement.", + ), + ); + dict.messages.insert( + "add_staff.role_everyone_forbidden".to_string(), + DictionaryMessage::new("Impossible d'ajouter @everyone à un ticket."), + ); + dict.messages.insert( + "add_staff.role_remove_success".to_string(), + DictionaryMessage::new("{count} membre(s) du rôle {role} ont été retirés du ticket."), + ); + dict.messages.insert( + "add_staff.role_remove_partial".to_string(), + DictionaryMessage::new( + "{removed}/{total} membre(s) du rôle {role} ont été retirés du ticket. {failed} n'ont pas pu être retirés.", + ), + ); + dict.messages.insert( + "add_staff.role_no_members_remove".to_string(), + DictionaryMessage::new("Le rôle {role} n'a aucun membre à retirer."), + ); + dict.messages.insert( + "add_staff.role_too_many_remove".to_string(), + DictionaryMessage::new( + "Le rôle {role} compte {count} membres, ce qui dépasse la limite de {max}. Retirez-les individuellement.", + ), + ); + dict.messages.insert( + "add_staff.role_everyone_forbidden_remove".to_string(), + DictionaryMessage::new("Impossible de retirer @everyone d'un ticket."), + ); dict.messages.insert( "id.show_id".to_string(), DictionaryMessage::new("ID de {user} : {id}"), @@ -729,16 +782,16 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ), ); dict.messages.insert( - "slash_command.add_staff_user_id_argument".to_string(), - DictionaryMessage::new("L'ID du staff à ajouter au ticket"), + "slash_command.add_staff_target_argument".to_string(), + DictionaryMessage::new("L'utilisateur ou le rôle à ajouter au ticket"), ); dict.messages.insert( "slash_command.remove_staff_command_description".to_string(), DictionaryMessage::new("Retirer un membre du staff d'un ticket de support"), ); dict.messages.insert( - "slash_command.remove_staff_user_id_argument".to_string(), - DictionaryMessage::new("L'ID du staff à retirer du ticket"), + "slash_command.remove_staff_target_argument".to_string(), + DictionaryMessage::new("L'utilisateur ou le rôle à retirer du ticket"), ); dict.messages.insert( "slash_command.alert_command_description".to_string(), @@ -936,7 +989,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "help.add_staff".to_string(), - DictionaryMessage::new("Ajoute un membre du staff à un ticket. Pour ce faire, faites `!addmod ` ou `!am ` dans un ticket."), + DictionaryMessage::new("Ajoute un membre du staff ou tous les membres d'un rôle à un ticket. Faites `!addmod ` ou `!am ` dans un ticket."), ); dict.messages.insert( "help.alert".to_string(), @@ -1009,7 +1062,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "help.remove_staff".to_string(), - DictionaryMessage::new("Retire un membre du staff du ticket actuel. Pour retirer un staff, faites `!delmod ` ou `!dm ` dans le ticket."), + DictionaryMessage::new("Retire un membre du staff ou tous les membres d'un rôle du ticket actuel. Faites `!delmod ` ou `!dm ` dans le ticket."), ); dict.messages.insert( "help.reply".to_string(), From a2321878d8a134dacd53d3b1d486fc4fcd02d1e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:44:11 +0000 Subject: [PATCH 5/6] fix: paginate guild members fetch and populate error param in fallback arm Agent-Logs-Url: https://github.com/Rustmail/rustmail/sessions/458a29fb-da48-4a88-afea-b87c655158c6 Co-authored-by: Akinator31 <99099121+Akinator31@users.noreply.github.com> --- .../rustmail/src/commands/add_staff/common.rs | 42 ++++++++++++++----- crates/rustmail/src/errors/dictionary.rs | 6 ++- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/rustmail/src/commands/add_staff/common.rs b/crates/rustmail/src/commands/add_staff/common.rs index 98922689..b4d5f6ea 100644 --- a/crates/rustmail/src/commands/add_staff/common.rs +++ b/crates/rustmail/src/commands/add_staff/common.rs @@ -67,17 +67,37 @@ pub async fn members_with_role( guild_id: GuildId, role_id: RoleId, ) -> ModmailResult> { - let members = guild_id.members(&ctx.http, None, None).await.map_err(|_| { - ModmailError::Discord(DiscordError::ApiError( - "Failed to fetch guild members".to_string(), - )) - })?; - - Ok(members - .into_iter() - .filter(|m| m.roles.contains(&role_id)) - .map(|m| m.user.id) - .collect()) + let mut result = Vec::new(); + let mut after: Option = None; + let page_size: Option = Some(1000); + + loop { + let page = guild_id + .members(&ctx.http, page_size, after) + .await + .map_err(|_| { + ModmailError::Discord(DiscordError::ApiError( + "Failed to fetch guild members".to_string(), + )) + })?; + + let page_len = page.len(); + let last_id = page.last().map(|m| m.user.id); + + for member in page { + if member.roles.contains(&role_id) { + result.push(member.user.id); + } + } + + if page_len < 1000 { + break; + } + + after = last_id; + } + + Ok(result) } pub async fn add_role_members_to_channel( diff --git a/crates/rustmail/src/errors/dictionary.rs b/crates/rustmail/src/errors/dictionary.rs index ce117d3c..8ffb9939 100644 --- a/crates/rustmail/src/errors/dictionary.rs +++ b/crates/rustmail/src/errors/dictionary.rs @@ -194,7 +194,11 @@ impl DictionaryManager { params.insert("error".to_string(), msg.clone()); ("discord.api_error".to_string(), Some(params)) } - _ => ("discord.api_error".to_string(), None), + err => { + let mut params = HashMap::new(); + params.insert("error".to_string(), err.to_string()); + ("discord.api_error".to_string(), Some(params)) + } }, ModmailError::Command(cmd_err) => match cmd_err { CommandError::InvalidFormat => ("command.invalid_format".to_string(), None), From 0b0271ee43a7b6506474aba128ec0286acf77243 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:48:01 +0000 Subject: [PATCH 6/6] fix: use PAGE_LIMIT const and add early empty-page break in members_with_role Agent-Logs-Url: https://github.com/Rustmail/rustmail/sessions/458a29fb-da48-4a88-afea-b87c655158c6 Co-authored-by: Akinator31 <99099121+Akinator31@users.noreply.github.com> --- crates/rustmail/src/commands/add_staff/common.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/rustmail/src/commands/add_staff/common.rs b/crates/rustmail/src/commands/add_staff/common.rs index b4d5f6ea..dfe11822 100644 --- a/crates/rustmail/src/commands/add_staff/common.rs +++ b/crates/rustmail/src/commands/add_staff/common.rs @@ -67,13 +67,14 @@ pub async fn members_with_role( guild_id: GuildId, role_id: RoleId, ) -> ModmailResult> { + const PAGE_LIMIT: u64 = 1000; + let mut result = Vec::new(); let mut after: Option = None; - let page_size: Option = Some(1000); loop { let page = guild_id - .members(&ctx.http, page_size, after) + .members(&ctx.http, Some(PAGE_LIMIT), after) .await .map_err(|_| { ModmailError::Discord(DiscordError::ApiError( @@ -81,8 +82,12 @@ pub async fn members_with_role( )) })?; - let page_len = page.len(); + if page.is_empty() { + break; + } + let last_id = page.last().map(|m| m.user.id); + let page_len = page.len(); for member in page { if member.roles.contains(&role_id) { @@ -90,7 +95,7 @@ pub async fn members_with_role( } } - if page_len < 1000 { + if page_len < PAGE_LIMIT as usize { break; }