From 72e453b018c84ded425a188b65de651ea37389d6 Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:24:50 +0200 Subject: [PATCH 1/7] feat(utils): add is_staff_member access check --- crates/rustmail/src/utils/command/mod.rs | 2 + .../src/utils/command/staff_access.rs | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 crates/rustmail/src/utils/command/staff_access.rs diff --git a/crates/rustmail/src/utils/command/mod.rs b/crates/rustmail/src/utils/command/mod.rs index eeb21a4..a4c276f 100644 --- a/crates/rustmail/src/utils/command/mod.rs +++ b/crates/rustmail/src/utils/command/mod.rs @@ -1,8 +1,10 @@ pub mod category; pub mod defer_response; pub mod extract_reply_content; +pub mod staff_access; pub mod wrap_command; pub use category::*; pub use defer_response::*; pub use extract_reply_content::*; +pub use staff_access::*; diff --git a/crates/rustmail/src/utils/command/staff_access.rs b/crates/rustmail/src/utils/command/staff_access.rs new file mode 100644 index 0000000..015d6e6 --- /dev/null +++ b/crates/rustmail/src/utils/command/staff_access.rs @@ -0,0 +1,63 @@ +use crate::prelude::config::*; +use serenity::all::{ChannelId, Context, GuildId, UserId}; + +/// Whether `user_id` may use staff commands in `guild_id`. +/// +/// Dual mode: everyone in the staff guild is staff, nobody in the community guild is. +/// Single mode: staff are the members who can see the inbox category (which is also who can see +/// the tickets), plus the configured super admins. +pub async fn is_staff_member( + ctx: &Context, + config: &Config, + guild_id: GuildId, + user_id: UserId, +) -> bool { + if guild_id.get() != config.bot.get_staff_guild_id() { + return false; + } + + if config.bot.is_dual_mode() || config.bot.panel_super_admin_users.contains(&user_id.get()) { + return true; + } + + let member = match guild_id.member(ctx, user_id).await { + Ok(m) => m, + Err(_) => return false, + }; + + if member + .roles + .iter() + .any(|role_id| config.bot.panel_super_admin_roles.contains(&role_id.get())) + { + return true; + } + + let inbox_category_id = ChannelId::new(config.thread.inbox_category_id); + + let cached = ctx.cache.guild(guild_id).and_then(|guild| { + guild + .channels + .get(&inbox_category_id) + .map(|category| guild.user_permissions_in(category, &member)) + }); + + let permissions = match cached { + Some(p) => p, + None => { + let category = match inbox_category_id.to_channel(ctx).await { + Ok(channel) => match channel.guild() { + Some(c) => c, + None => return false, + }, + Err(_) => return false, + }; + match guild_id.to_partial_guild(ctx).await { + Ok(guild) => guild.user_permissions_in(&category, &member), + Err(_) => return false, + } + } + }; + + permissions.view_channel() +} From d45b3d4c9f6e5fb24bc33ac866793b4fdc571eac Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:25:07 +0200 Subject: [PATCH 2/7] fix(commands): ignore text commands from non-staff members --- crates/rustmail/src/handlers/guild_messages_handler.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/rustmail/src/handlers/guild_messages_handler.rs b/crates/rustmail/src/handlers/guild_messages_handler.rs index f6bacd8..77b3cde 100644 --- a/crates/rustmail/src/handlers/guild_messages_handler.rs +++ b/crates/rustmail/src/handlers/guild_messages_handler.rs @@ -291,6 +291,14 @@ impl EventHandler for GuildMessagesHandler { command_name = &message_content[self.config.command.prefix.len()..i]; } + // Text commands are staff-only, like the slash commands (which are not even + // registered in the community guild). Ignore everyone else silently. + if let Some(guild_id) = msg.guild_id + && !is_staff_member(&ctx, &self.config, guild_id, msg.author.id).await + { + return; + } + if self.maintenance_mode.load(Ordering::Relaxed) && !is_user_maintenance_exempt(&ctx, &msg, &self.config).await { From 11ec4739db340ffa2babea1d270c13509b6b0040 Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:25:08 +0200 Subject: [PATCH 3/7] fix(commands): deny staff slash commands to non-staff members --- .../src/handlers/guild_interaction_handler.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/rustmail/src/handlers/guild_interaction_handler.rs b/crates/rustmail/src/handlers/guild_interaction_handler.rs index 42d0dca..d462180 100644 --- a/crates/rustmail/src/handlers/guild_interaction_handler.rs +++ b/crates/rustmail/src/handlers/guild_interaction_handler.rs @@ -1,9 +1,10 @@ use crate::prelude::commands::*; use crate::prelude::config::*; +use crate::prelude::errors::*; use crate::prelude::features::*; use crate::prelude::modules::*; use crate::prelude::types::*; -use crate::utils::{MessageBuilder, defer_response}; +use crate::utils::{MessageBuilder, defer_response, defer_response_ephemeral, is_staff_member}; use serenity::all::{Context, EventHandler, Interaction, Permissions}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -119,6 +120,26 @@ impl EventHandler for InteractionHandler { } } Interaction::Command(command) => { + // In single mode every slash command is registered in the shared guild, so + // staff-only commands need a runtime check there. + let community_command = self + .registry + .get(command.data.name.as_str()) + .is_some_and(|c| c.as_community().is_some()); + if !community_command + && let Some(guild_id) = command.guild_id + && !is_staff_member(&ctx, &self.config, guild_id, command.user.id).await + { + if defer_response_ephemeral(&ctx, &command).await.is_ok() + && let Some(error_handler) = &self.config.error_handler + { + let _ = error_handler + .reply_to_command_with_error(&ctx, &command, &permission_denied()) + .await; + } + return; + } + if self.maintenance_mode.load(Ordering::Relaxed) && let Some(guild_id) = command.guild_id && !is_interaction_user_maintenance_exempt( From acf85323d6c85e83311edda54416a69e356260ef Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:25:09 +0200 Subject: [PATCH 4/7] docs(commands): document staff-only command access --- docs/guides/commands.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 088e103..4393cb5 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -373,8 +373,12 @@ Combinations are supported: `1d12h` = 1 day and 12 hours. ## Command Permissions -Commands are available to users with access to ticket channels. The bot respects Discord's permission system: - -- Only staff with channel access can use ticket commands -- Super admins (configured in `config.toml`) have full access -- Additional permissions can be configured through the panel +Commands (text and slash) are reserved to staff: + +- **Dual-server mode**: commands only work in the staff server. Slash commands are not registered in the community + server, and text commands sent there are ignored. +- **Single-server mode**: staff are the members who can see the inbox category (`inbox_category_id`), which is also + who can see the tickets. Other members are ignored by text commands and get a "permission denied" reply to slash + commands. To also hide slash commands from them, restrict the bot's commands in *Server Settings → Integrations*. +- Super admins (`panel_super_admin_users` / `panel_super_admin_roles` in `config.toml`) always count as staff. +- Additional panel permissions can be configured through the panel From c17f52d5629533e7cdc4c5ac0286e4d926e16a48 Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:26:51 +0200 Subject: [PATCH 5/7] refactor(utils): split guild-independent is_staff_user out of is_staff_member --- .../src/utils/command/staff_access.rs | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/rustmail/src/utils/command/staff_access.rs b/crates/rustmail/src/utils/command/staff_access.rs index 015d6e6..cd3b984 100644 --- a/crates/rustmail/src/utils/command/staff_access.rs +++ b/crates/rustmail/src/utils/command/staff_access.rs @@ -1,34 +1,39 @@ use crate::prelude::config::*; use serenity::all::{ChannelId, Context, GuildId, UserId}; -/// Whether `user_id` may use staff commands in `guild_id`. -/// -/// Dual mode: everyone in the staff guild is staff, nobody in the community guild is. -/// Single mode: staff are the members who can see the inbox category (which is also who can see -/// the tickets), plus the configured super admins. +/// Whether `user_id` may use staff commands in `guild_id`: the staff guild only, and only for +/// staff (see [`is_staff_user`]). pub async fn is_staff_member( ctx: &Context, config: &Config, guild_id: GuildId, user_id: UserId, ) -> bool { - if guild_id.get() != config.bot.get_staff_guild_id() { - return false; - } + guild_id.get() == config.bot.get_staff_guild_id() && is_staff_user(ctx, config, user_id).await +} - if config.bot.is_dual_mode() || config.bot.panel_super_admin_users.contains(&user_id.get()) { +/// Whether `user_id` is staff, wherever the command was invoked from. +/// +/// Dual mode: staff are the members of the staff guild. +/// Single mode: staff are the members who can see the inbox category (which is also who can see +/// the tickets). Configured super admins are always staff. +pub async fn is_staff_user(ctx: &Context, config: &Config, user_id: UserId) -> bool { + if config.bot.panel_super_admin_users.contains(&user_id.get()) { return true; } + let guild_id = GuildId::new(config.bot.get_staff_guild_id()); + let member = match guild_id.member(ctx, user_id).await { Ok(m) => m, Err(_) => return false, }; - if member - .roles - .iter() - .any(|role_id| config.bot.panel_super_admin_roles.contains(&role_id.get())) + if config.bot.is_dual_mode() + || member + .roles + .iter() + .any(|role_id| config.bot.panel_super_admin_roles.contains(&role_id.get())) { return true; } From 858962b0a142fc0dfd918799339cf6a5df559164 Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:27:33 +0200 Subject: [PATCH 6/7] fix(new_thread): restrict the user context menu to staff --- .../src/handlers/guild_interaction_handler.rs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/rustmail/src/handlers/guild_interaction_handler.rs b/crates/rustmail/src/handlers/guild_interaction_handler.rs index d462180..b7f19e6 100644 --- a/crates/rustmail/src/handlers/guild_interaction_handler.rs +++ b/crates/rustmail/src/handlers/guild_interaction_handler.rs @@ -4,7 +4,9 @@ use crate::prelude::errors::*; use crate::prelude::features::*; use crate::prelude::modules::*; use crate::prelude::types::*; -use crate::utils::{MessageBuilder, defer_response, defer_response_ephemeral, is_staff_member}; +use crate::utils::{ + MessageBuilder, defer_response, defer_response_ephemeral, is_staff_member, is_staff_user, +}; use serenity::all::{Context, EventHandler, Interaction, Permissions}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -120,16 +122,24 @@ impl EventHandler for InteractionHandler { } } Interaction::Command(command) => { - // In single mode every slash command is registered in the shared guild, so - // staff-only commands need a runtime check there. + // Every command is staff-only. Discord registration alone doesn't enforce it: + // in single mode everything lives in the shared guild, and community commands + // are visible to every community member. Community commands may be run from + // either guild, the others only from the staff guild. let community_command = self .registry .get(command.data.name.as_str()) .is_some_and(|c| c.as_community().is_some()); - if !community_command - && let Some(guild_id) = command.guild_id - && !is_staff_member(&ctx, &self.config, guild_id, command.user.id).await - { + let is_staff = match command.guild_id { + Some(_) if community_command => { + is_staff_user(&ctx, &self.config, command.user.id).await + } + Some(guild_id) => { + is_staff_member(&ctx, &self.config, guild_id, command.user.id).await + } + None => true, + }; + if !is_staff { if defer_response_ephemeral(&ctx, &command).await.is_ok() && let Some(error_handler) = &self.config.error_handler { From 7bf918c6212493e4bbb08cabcfbb8eac977f15f6 Mon Sep 17 00:00:00 2001 From: Akinator31 <99099121+Akinator31@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:27:43 +0200 Subject: [PATCH 7/7] docs(commands): document staff-only new_thread context menu --- docs/guides/commands.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 4393cb5..5b462c5 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -376,7 +376,9 @@ Combinations are supported: `1d12h` = 1 day and 12 hours. Commands (text and slash) are reserved to staff: - **Dual-server mode**: commands only work in the staff server. Slash commands are not registered in the community - server, and text commands sent there are ignored. + server, and text commands sent there are ignored. The one exception is the *New Thread* user context menu + (right-click a member → Apps), which is available in the community server too, but only for members of the staff + server. - **Single-server mode**: staff are the members who can see the inbox category (`inbox_category_id`), which is also who can see the tickets. Other members are ignored by text commands and get a "permission denied" reply to slash commands. To also hide slash commands from them, restrict the bot's commands in *Server Settings → Integrations*.