From 3ef1aa20d088484f378851353a70632175b2e514 Mon Sep 17 00:00:00 2001 From: "NG (Graham)" Date: Fri, 31 Oct 2025 21:02:10 -0400 Subject: [PATCH] Add some basic commands for sysadmins and permission system for them --- assets/robocraft/config.json | 33 +++++-- rc_auth/src/robocraft/intercom/mod.rs | 9 ++ rc_auth/src/robocraft/intercom/services.rs | 26 ++++-- .../src/robocraft/intercom/user_registry.rs | 26 ++++-- rc_chat_room/src/state/chat/config.rs | 87 +++++++++++++++++-- rc_core/src/persist/chat.rs | 83 +++++++++++++++++- rc_core/src/persist/mod.rs | 2 +- rc_core/src/persist/user/common.rs | 4 + rc_core/src/persist/user/intercom.rs | 20 +++++ rc_core/src/persist/user/traits.rs | 2 + rc_services_room/src/events/handler.rs | 6 ++ .../src/events/maintenance_mode.rs | 18 ++++ rc_services_room/src/events/mod.rs | 3 + 13 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 rc_services_room/src/events/maintenance_mode.rs diff --git a/assets/robocraft/config.json b/assets/robocraft/config.json index 707d3f3..351af06 100644 --- a/assets/robocraft/config.json +++ b/assets/robocraft/config.json @@ -14454,21 +14454,24 @@ "op": { "type": "BuiltIn", "built_in": "OnlineUsers" - } + }, + "permission": "Player" }, { "regex": "\\?users", "op": { "type": "BuiltIn", "built_in": "TotalUsers" - } + }, + "permission": "Player" }, { "regex": "\\?version", "op": { "type": "BuiltIn", "built_in": "Version" - } + }, + "permission": "Player" }, { "regex": "\\?banner", @@ -14476,14 +14479,34 @@ "type": "BuiltIn", "built_in": "Intercom", "intercom": "DevMessage" - } + }, + "permission": "Player" + }, + { + "regex": "\\?maintenance", + "op": { + "type": "BuiltIn", + "built_in": "Intercom", + "intercom": "Maintenance" + }, + "permission": "Developer" + }, + { + "regex": "\\?broadcast", + "op": { + "type": "BuiltIn", + "built_in": "Intercom", + "intercom": "DevBroadcast" + }, + "permission": "Developer" }, { "regex": "\\?help", "op": { "type": "BuiltIn", "built_in": "Help" - } + }, + "permission": "Player" } ] }, diff --git a/rc_auth/src/robocraft/intercom/mod.rs b/rc_auth/src/robocraft/intercom/mod.rs index 0603417..d95e68f 100644 --- a/rc_auth/src/robocraft/intercom/mod.rs +++ b/rc_auth/src/robocraft/intercom/mod.rs @@ -6,3 +6,12 @@ pub use services::{services_ws, service_msg}; mod user_registry; pub use user_registry::Users; + +enum IntercomOp { + Message(oj_rc_core::persist::user::intercom::IntercomWebServiceUserMessage), + Info(IntercomInfo), +} + +enum IntercomInfo { + Close, +} diff --git a/rc_auth/src/robocraft/intercom/services.rs b/rc_auth/src/robocraft/intercom/services.rs index bd5f515..bfbbd9d 100644 --- a/rc_auth/src/robocraft/intercom/services.rs +++ b/rc_auth/src/robocraft/intercom/services.rs @@ -18,13 +18,29 @@ pub async fn services_ws(req: HttpRequest, stream: Payload, auth: Data { + if let Err(e) = session.text(serde_json::to_string(&msg).unwrap()).await { + log::warn!("Failed to send services intercom to user {}: {}", name, e); + break; + } + }, + super::IntercomOp::Info(info) => { + match info { + super::IntercomInfo::Close => { + is_ok = true; + break; + }, + } + } } + + } + if !is_ok { + reg.remove_service(name.clone()).await; } - reg.remove_service(name.clone()).await; rx.close(); session.close(Some(actix_ws::CloseReason { code: actix_ws::CloseCode::Normal, diff --git a/rc_auth/src/robocraft/intercom/user_registry.rs b/rc_auth/src/robocraft/intercom/user_registry.rs index 0bbb14d..eac6ab4 100644 --- a/rc_auth/src/robocraft/intercom/user_registry.rs +++ b/rc_auth/src/robocraft/intercom/user_registry.rs @@ -1,7 +1,5 @@ -use oj_rc_core::persist::user::intercom::IntercomWebServiceUserMessage; - pub struct Users { - service_listeners: tokio::sync::RwLock>>, + service_listeners: tokio::sync::RwLock>>, } impl Users { @@ -11,11 +9,12 @@ impl Users { } } - pub async fn register_service(&self, public_id: String, sender: tokio::sync::mpsc::Sender) { + pub(super) async fn register_service(&self, public_id: String, sender: tokio::sync::mpsc::Sender) { let mut write_lock = self.service_listeners.write().await; if let Some(old_sender) = write_lock.insert(public_id.clone(), sender) { if !old_sender.is_closed() { log::warn!("Replaced web services intercom channel for user {} (why duplicate!?)", public_id); + old_sender.send(super::IntercomOp::Info(super::IntercomInfo::Close)).await.unwrap_or_default() } } } @@ -29,13 +28,22 @@ impl Users { pub async fn broadcast_service_message(&self, msg: oj_rc_core::persist::user::intercom::IntercomWebServiceMessage) { let read_lock = self.service_listeners.read().await; - for public_id in msg.public_ids { - if let Some(tx) = read_lock.get(&public_id) { - if let Err(e) = tx.send(msg.data.clone()).await { + if msg.everyone { + if !msg.public_ids.is_empty() { return; } // invalid + for (public_id, tx) in read_lock.iter() { + if let Err(e) = tx.send(super::IntercomOp::Message(msg.data.clone())).await { log::error!("Failed to send web service intercom message to {}: {}", public_id, e); } - } else { - log::warn!("Not sending web service intercom message to user {}; no listener found", public_id); + } + } else { + for public_id in msg.public_ids { + if let Some(tx) = read_lock.get(&public_id) { + if let Err(e) = tx.send(super::IntercomOp::Message(msg.data.clone())).await { + log::error!("Failed to send web service intercom message to {}: {}", public_id, e); + } + } else { + log::warn!("Not sending web service intercom message to user {}; no listener found", public_id); + } } } } diff --git a/rc_chat_room/src/state/chat/config.rs b/rc_chat_room/src/state/chat/config.rs index 5717ba7..de2e376 100644 --- a/rc_chat_room/src/state/chat/config.rs +++ b/rc_chat_room/src/state/chat/config.rs @@ -55,19 +55,26 @@ impl ChatSystemConfig { pub struct ChatCommand { regex: regex::Regex, op: ChatOperation, + perms: ExecutePermission, } impl ChatCommand { fn compile_command(command: oj_rc_core::persist::ChatCommand) -> Result { Ok(Self { regex: regex::RegexBuilder::new(&command.regex).build()?, - op: ChatOperation::from_persist(command.op) + op: ChatOperation::from_persist(command.op), + perms: ExecutePermission::from_persist(command.permission), }) } async fn perform_if_match<'b, 'c>(&self, text: &str, ctx: CommandContext<'b, 'c>) -> Option { if let Some(cap) = self.regex.captures(text) { - Some(self.op.perform_command(text, cap, ctx).await) + if self.perms.has_perms(ctx.user) { + Some(self.op.perform_command(text, cap, ctx).await) + } else { + log::warn!("User {} tried to run command {} without sufficient permissions", ctx.user.public_id(), self.regex); + None + } } else { None } @@ -161,12 +168,15 @@ impl BuiltIn { } Self::Help => { use core::fmt::Write; + let force_all = text.trim().split(' ').any(|word| word == "--all"); let mut msg = String::new(); for command in ctx.chat_system.chat_config().commands.iter() { - let raw_re = command.regex.to_string(); - let pretty_name = Self::prettify_re(&raw_re); - if let Err(e) = write!(msg, "\n{}: {}", pretty_name, command.op.help_str()) { - log::warn!("Failed to construct help for command `{}`: {}", pretty_name, e); + if command.perms.has_perms(ctx.user) || force_all { + let raw_re = command.regex.to_string(); + let pretty_name = Self::prettify_re(&raw_re); + if let Err(e) = write!(msg, "\n{}: {}", pretty_name, command.op.help_str()) { + log::warn!("Failed to construct help for command `{}`: {}", pretty_name, e); + } } } msg @@ -187,12 +197,16 @@ impl BuiltIn { enum Intercom { DevMessage, + DevBroadcast, + Maintenance, } impl Intercom { fn from_persist(intercom: oj_rc_core::persist::IntercomChatOperation) -> Self { match intercom { oj_rc_core::persist::IntercomChatOperation::DevMessage => Self::DevMessage, + oj_rc_core::persist::IntercomChatOperation::DevBroadcast => Self::DevBroadcast, + oj_rc_core::persist::IntercomChatOperation::Maintenance => Self::Maintenance, } } @@ -208,6 +222,32 @@ impl Intercom { vec![pub_id.to_owned()], ).await; format!("Sent dev message to {}", pub_id) + }, + Self::DevBroadcast => { + if let Some(message) = text.trim().split_once(' ').map(|x| x.1.to_owned()) { + ctx.user.show_dev_message( + oj_rc_core::persist::user::intercom::IntercomDevMessage { + message, + duration: 60, + }, + vec![], + ).await; + format!("Sent dev broadcast to everyone") + } else { + format!("Missing dev message, did not send") + } + }, + Self::Maintenance => { + if let Some(message) = text.trim().split_once(' ').map(|x| x.1.to_owned()) { + ctx.user.enter_maintenance( + oj_rc_core::persist::user::intercom::IntercomMaintenanceMessage { message }, + vec![], + ).await; + format!("Sent maintenance message") + } else { + format!("Missing maintenance message, did not send") + } + } } @@ -216,6 +256,41 @@ impl Intercom { fn do_help(&self) -> String { match self { Self::DevMessage => "Show dev message to yourself".to_owned(), + Self::DevBroadcast => "Show dev message to everyone".to_owned(), + Self::Maintenance => "Broadcast maintenance mode to everyone".to_owned(), + } + } +} + +enum ExecutePermission { + Player, + Moderator, + Administrator, + Developer, + Royal, + None, +} + +impl ExecutePermission { + fn from_persist(perm: oj_rc_core::persist::ChatPermission) -> Self { + match perm { + oj_rc_core::persist::ChatPermission::Player => Self::Player, + oj_rc_core::persist::ChatPermission::Moderator => Self::Moderator, + oj_rc_core::persist::ChatPermission::Administrator => Self::Administrator, + oj_rc_core::persist::ChatPermission::Developer => Self::Developer, + oj_rc_core::persist::ChatPermission::Royal => Self::Royal, + oj_rc_core::persist::ChatPermission::None => Self::None, + } + } + + fn has_perms(&self, user: &dyn oj_rc_core::persist::user::CommonUser) -> bool { + match self { + Self::Player => true, + Self::Moderator => user.is_mod() || user.is_admin() || user.is_dev() || user.is_royal(), + Self::Administrator => user.is_admin() || user.is_dev() || user.is_royal(), + Self::Developer => user.is_dev() || user.is_royal(), + Self::Royal => user.is_royal(), + Self::None => false, } } } diff --git a/rc_core/src/persist/chat.rs b/rc_core/src/persist/chat.rs index 656efd3..bf8c09a 100644 --- a/rc_core/src/persist/chat.rs +++ b/rc_core/src/persist/chat.rs @@ -15,9 +15,38 @@ pub struct ChatConfig { impl super::config::SelfValidator for ChatConfig { type Context = crate::ConfigImpl; - fn validate(&self, _info: &mut super::config::ValidationInfo, _ctx: &Self::Context) -> bool { - // TODO - true + fn validate(&self, info: &mut super::config::ValidationInfo, _ctx: &Self::Context) -> bool { + let mut is_ok = true; + if !self.public_channels.contains(&self.command_channel) { + info.warn(super::config::ValidationMessage { + path: vec!["public_channels".to_owned()], + message: "Chat command channel is not a public channel".to_owned(), + }); + } + if !self.public_channels.contains(&self.default_channel) { + info.warn(super::config::ValidationMessage { + path: vec!["public_channels".to_owned()], + message: "Chat default channel is not a public channel".to_owned(), + }); + } + if self.command_channel.is_empty() { + info.error(super::config::ValidationMessage { + path: vec!["command_channel".to_owned()], + message: "Chat command channel should not be empty".to_owned(), + }); + is_ok = false; + } + for (i, cmd) in self.commands.iter().enumerate() { + is_ok &= cmd.validate_in(info, self, &format!("commands[{}]", i)); + } + if self.default_channel.is_empty() { + info.error(super::config::ValidationMessage { + path: vec!["default_channel".to_owned()], + message: "Chat default channel should not be empty".to_owned(), + }); + is_ok = false; + } + is_ok } } @@ -25,6 +54,52 @@ impl super::config::SelfValidator for ChatConfig { pub struct ChatCommand { pub regex: String, pub op: ChatOperation, + pub permission: ChatPermission, +} + +impl super::config::SelfValidator for ChatCommand { + type Context = ChatConfig; + fn validate(&self, info: &mut super::config::ValidationInfo, ctx: &Self::Context) -> bool { + let mut is_ok = true; + let regex_count = ctx.commands.iter().filter(|other| self.regex == other.regex).count(); + if regex_count != 1 { + info.error(super::config::ValidationMessage { + path: vec!["regex".to_owned()], + message: format!("Only one chat command can use an identical regex pattern {}; found {}", self.regex, regex_count), + }); + is_ok = false; + } + // TODO validate regex + + // recommended commands to only allow with elevated permissions + if matches!( + self.op, + ChatOperation::BuiltIn(BuiltInChatOperation::Intercom(IntercomChatOperation::DevBroadcast)) + | ChatOperation::BuiltIn(BuiltInChatOperation::Intercom(IntercomChatOperation::Maintenance)) + ) { + if !matches!(self.permission, ChatPermission::Administrator | ChatPermission::Developer | ChatPermission::Royal) { + info.warn(crate::persist::config::ValidationMessage { + path: vec!["permission".to_owned()], + message: format!("Chat command {:?} is recommended to require Administrator, Developer, or Royal permissions; found {:?}", self.op, self.permission), + }); + } + } + is_ok + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum ChatPermission { + Player, + #[serde(alias = "Mod")] + Moderator, + #[serde(alias = "Admin")] + Administrator, + #[serde(alias = "Dev")] + Developer, + #[serde(alias = "Special")] + Royal, + None, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -50,6 +125,8 @@ pub enum BuiltInChatOperation { #[serde(tag = "intercom")] pub enum IntercomChatOperation { DevMessage, + DevBroadcast, + Maintenance, } diff --git a/rc_core/src/persist/mod.rs b/rc_core/src/persist/mod.rs index a6ba94e..4373162 100644 --- a/rc_core/src/persist/mod.rs +++ b/rc_core/src/persist/mod.rs @@ -30,7 +30,7 @@ mod settings; pub use settings::{Settings, QueueMode}; mod chat; -pub use chat::{ChatConfig, ChatCommand, ChatOperation, BuiltInChatOperation, IntercomChatOperation}; +pub use chat::{ChatConfig, ChatCommand, ChatOperation, BuiltInChatOperation, IntercomChatOperation, ChatPermission}; mod vehicle_factory; pub use vehicle_factory::{FactoryConfig, AdapterSettings, ArcFactorySettings}; diff --git a/rc_core/src/persist/user/common.rs b/rc_core/src/persist/user/common.rs index c1d296e..0fff64b 100644 --- a/rc_core/src/persist/user/common.rs +++ b/rc_core/src/persist/user/common.rs @@ -18,6 +18,10 @@ impl super::CommonUser for UserData { self.perms.developer } + fn is_royal(&self) -> bool { + self.perms.royalty + } + fn is_banned(&self) -> bool { self.perms.banned } diff --git a/rc_core/src/persist/user/intercom.rs b/rc_core/src/persist/user/intercom.rs index 2477692..8157be3 100644 --- a/rc_core/src/persist/user/intercom.rs +++ b/rc_core/src/persist/user/intercom.rs @@ -65,23 +65,38 @@ impl super::IntercomUser for super::account_json::UserData { let data = IntercomWebServiceMessage { public_ids: to, data: IntercomWebServiceUserMessage::DevMessage(msg), + everyone: false, }; if let Err(e) = self.post_to_intercom(&data, ".oj_services", "messages").await { log::error!("Failed to send intercom dev message: {}", e); } } + + async fn enter_maintenance(&self, msg: IntercomMaintenanceMessage, to: Vec) { + let send_to_everyone = to.is_empty(); + let data = IntercomWebServiceMessage { + public_ids: to, + data: IntercomWebServiceUserMessage::Maintenance(msg), + everyone: send_to_everyone, + }; + if let Err(e) = self.post_to_intercom(&data, ".oj_services", "messages").await { + log::error!("Failed to send intercom maintenance mode message: {}", e); + } + } } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct IntercomWebServiceMessage { pub public_ids: Vec, pub data: IntercomWebServiceUserMessage, + pub everyone: bool, } #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(tag = "type")] pub enum IntercomWebServiceUserMessage { DevMessage(IntercomDevMessage), + Maintenance(IntercomMaintenanceMessage), } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -90,6 +105,11 @@ pub struct IntercomDevMessage { pub duration: u32, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct IntercomMaintenanceMessage { + pub message: String, +} + pub fn generate_token(salt: &[u8], key: &[u8]) -> String { use sha2::{Digest, Sha512}; let mut hasher = Sha512::new(); diff --git a/rc_core/src/persist/user/traits.rs b/rc_core/src/persist/user/traits.rs index fd892e8..27c8224 100644 --- a/rc_core/src/persist/user/traits.rs +++ b/rc_core/src/persist/user/traits.rs @@ -338,6 +338,7 @@ pub trait IntercomUser { async fn save_custom_avatar(&self, image: Vec) -> Result<(), polariton_server::operations::SimpleOpError>; async fn webservice_listener(&self) -> Result, polariton_server::operations::SimpleOpError>; async fn show_dev_message(&self, msg: super::intercom::IntercomDevMessage, to: Vec); + async fn enter_maintenance(&self, msg: super::intercom::IntercomMaintenanceMessage, to: Vec); } pub struct IntercomListener { @@ -373,5 +374,6 @@ pub trait CommonUser: Send + Sync { fn is_mod(&self) -> bool; fn is_admin(&self) -> bool; fn is_dev(&self) -> bool; + fn is_royal(&self) -> bool; fn is_banned(&self) -> bool; } diff --git a/rc_services_room/src/events/handler.rs b/rc_services_room/src/events/handler.rs index ecb6d9d..e7aed0a 100644 --- a/rc_services_room/src/events/handler.rs +++ b/rc_services_room/src/events/handler.rs @@ -39,6 +39,12 @@ impl IntercomHandler { }; emitter.emit(event); }, + IntercomWebServiceUserMessage::Maintenance(msg) => { + let event = super::MaintenanceMode { + message: msg.message, + }; + emitter.emit(event); + } } } else { break; diff --git a/rc_services_room/src/events/maintenance_mode.rs b/rc_services_room/src/events/maintenance_mode.rs new file mode 100644 index 0000000..0314184 --- /dev/null +++ b/rc_services_room/src/events/maintenance_mode.rs @@ -0,0 +1,18 @@ +pub struct MaintenanceMode { + pub message: String, +} + +impl polariton_server::events::IntoEvent for MaintenanceMode { + const CHANNEL: u8 = 0; + const ENCRYPT: bool = true; + const RELIABLE: bool = true; + + fn into_event(self) -> polariton::operation::Event { + let mut params = polariton::operation::ParameterTable::with_capacity(1); + params.insert(19, polariton::operation::Typed::Str(self.message.clone().into())); + polariton::operation::Event { + code: 3, + params, + } + } +} diff --git a/rc_services_room/src/events/mod.rs b/rc_services_room/src/events/mod.rs index 766f6da..f296372 100644 --- a/rc_services_room/src/events/mod.rs +++ b/rc_services_room/src/events/mod.rs @@ -3,3 +3,6 @@ pub use handler::IntercomHandler; mod dev_message; pub use dev_message::DevMessage; + +mod maintenance_mode; +pub use maintenance_mode::MaintenanceMode;