diff --git a/crates/rustmail/src/handlers/guild_interaction_handler.rs b/crates/rustmail/src/handlers/guild_interaction_handler.rs index 42d0dca..b7f19e6 100644 --- a/crates/rustmail/src/handlers/guild_interaction_handler.rs +++ b/crates/rustmail/src/handlers/guild_interaction_handler.rs @@ -1,9 +1,12 @@ 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, is_staff_user, +}; use serenity::all::{Context, EventHandler, Interaction, Permissions}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -119,6 +122,34 @@ impl EventHandler for InteractionHandler { } } Interaction::Command(command) => { + // 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()); + 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 + { + 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( 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 { 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..cd3b984 --- /dev/null +++ b/crates/rustmail/src/utils/command/staff_access.rs @@ -0,0 +1,68 @@ +use crate::prelude::config::*; +use serenity::all::{ChannelId, Context, GuildId, UserId}; + +/// 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 { + guild_id.get() == config.bot.get_staff_guild_id() && is_staff_user(ctx, config, user_id).await +} + +/// 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 config.bot.is_dual_mode() + || 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() +} diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 088e103..5b462c5 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -373,8 +373,14 @@ 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. 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*. +- 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