From 333cfd5e19ddacd1b04e9feee8c9f14bbc4b19fb Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 27 Sep 2026 23:26:30 +0200 Subject: [PATCH] feat(admin): Describe admin commands as MSC4391 bot commands --- .../+msc4391-admin-commands.feature.md | 1 + src/admin/mod.rs | 3 + src/admin/msc4391.rs | 486 ++++++++++++++++++ src/admin/processor.rs | 14 +- src/router/run.rs | 6 +- src/service/admin/create.rs | 7 +- src/service/admin/mod.rs | 25 +- src/service/admin/msc4391.rs | 152 ++++++ src/service/rooms/timeline/append.rs | 21 +- src/service/rooms/timeline/build.rs | 23 +- 10 files changed, 697 insertions(+), 41 deletions(-) create mode 100644 changelog.d/+msc4391-admin-commands.feature.md create mode 100644 src/admin/msc4391.rs create mode 100644 src/service/admin/msc4391.rs diff --git a/changelog.d/+msc4391-admin-commands.feature.md b/changelog.d/+msc4391-admin-commands.feature.md new file mode 100644 index 000000000..b59491c52 --- /dev/null +++ b/changelog.d/+msc4391-admin-commands.feature.md @@ -0,0 +1 @@ +Admin commands are now described to clients as MSC4391 bot commands in the admin room, and can be invoked with structured arguments. Contributed by @eleboucher. diff --git a/src/admin/mod.rs b/src/admin/mod.rs index fd73c3f15..91e093202 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod admin; pub(crate) mod context; +mod msc4391; pub(crate) mod processor; mod tests; pub(crate) mod utils; @@ -41,6 +42,7 @@ /// Install the admin command processor pub async fn init(admin_service: &service::admin::Service) { _ = admin_service.complete.write().insert(processor::complete); + _ = admin_service.describe.write().insert(msc4391::descriptions); _ = admin_service .handle .write() @@ -52,4 +54,5 @@ pub async fn init(admin_service: &service::admin::Service) { pub async fn fini(admin_service: &service::admin::Service) { _ = admin_service.handle.write().await.take(); _ = admin_service.complete.write().take(); + _ = admin_service.describe.write().take(); } diff --git a/src/admin/msc4391.rs b/src/admin/msc4391.rs new file mode 100644 index 000000000..7346f4496 --- /dev/null +++ b/src/admin/msc4391.rs @@ -0,0 +1,486 @@ +//! MSC4391 in-room bot commands: describing the admin command tree, and +//! turning structured invocations into argv. + +use std::any::TypeId; + +use clap::{ArgAction, Command, CommandFactory, builder::ValueParser}; +use conduwuit::{Err, Result, err}; +use ruma::{ + OwnedEventId, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, +}; +use serde_json::{Map, Value, json}; + +use crate::admin::AdminCommand; + +/// Each leaf admin command's space-separated name and description content. +#[must_use] +pub(crate) fn descriptions() -> Vec<(String, Value)> { + let mut described = Vec::new(); + walk(&AdminCommand::command(), &mut Vec::new(), &mut described); + described +} + +fn walk(command: &Command, path: &mut Vec, described: &mut Vec<(String, Value)>) { + for sub in command.get_subcommands() { + if sub.is_hide_set() { + continue; + } + path.push(sub.get_name().to_owned()); + if sub.has_subcommands() { + walk(sub, path, described); + } else { + let name = path.join(" "); + described.push((name.clone(), describe(sub, path, &name))); + } + path.pop(); + } +} + +fn text(body: &impl ToString) -> Value { json!({ "m.text": [{ "body": body.to_string() }] }) } + +fn describe(command: &Command, path: &[String], name: &str) -> Value { + let parent = &path[..path.len().saturating_sub(1)]; + let aliases: Vec = command + .get_visible_aliases() + .map(|alias| [parent, &[alias.to_owned()]].concat().join(" ")) + .collect(); + let parameters: Vec = command + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .filter_map(parameter) + .collect(); + + let mut content = json!({ "command": name, "parameters": parameters }); + if let Some(about) = command.get_about() { + content["description"] = text(&about); + } + if !aliases.is_empty() { + content["aliases"] = json!(aliases); + } + content +} + +fn primitive(parser: &ValueParser) -> Value { + let id = parser.type_id(); + let is = |type_id: TypeId| id == type_id; + let primitive = |kind: &str| json!({ "schema_type": "primitive", "type": kind }); + + if is(TypeId::of::()) { + return json!({ + "schema_type": "union", + "variants": [primitive("room_id"), primitive("room_alias")], + }); + } + let kind = if is(TypeId::of::()) { + "boolean" + } else if [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ] + .into_iter() + .any(is) + { + "integer" + } else if is(TypeId::of::()) { + "user_id" + } else if is(TypeId::of::()) { + "room_id" + } else if is(TypeId::of::()) { + "room_alias" + } else if is(TypeId::of::()) { + "event_id" + } else if is(TypeId::of::()) { + "server_name" + } else { + "string" + }; + primitive(kind) +} + +fn parameter(arg: &clap::Arg) -> Option { + let many = arg + .get_num_args() + .is_some_and(|range| range.max_values() > 1); + let schema = match arg.get_action() { + | ArgAction::Help | ArgAction::HelpShort | ArgAction::HelpLong | ArgAction::Version => + return None, + | ArgAction::SetTrue | ArgAction::SetFalse => + json!({ "schema_type": "primitive", "type": "boolean" }), + | ArgAction::Count => json!({ "schema_type": "primitive", "type": "integer" }), + | action => { + let choices = arg.get_possible_values(); + let item = if choices.is_empty() { + primitive(arg.get_value_parser()) + } else { + json!({ + "schema_type": "union", + "variants": choices + .iter() + .filter(|choice| !choice.is_hide_set()) + .map(|choice| json!({ + "schema_type": "literal", + "literal_type": "string", + "value": choice.get_name(), + })) + .collect::>(), + }) + }; + if matches!(action, ArgAction::Append) || many { + json!({ "schema_type": "array", "items": item }) + } else { + item + } + }, + }; + + let mut parameter = json!({ "key": arg.get_id().as_str(), "schema": schema }); + if !arg.is_required_set() { + parameter["optional"] = json!(true); + } + if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) { + parameter["description"] = text(&help); + } + if let (Some(default), Some(kind)) = + (arg.get_default_values().first(), parameter["schema"]["type"].as_str()) + && kind != "boolean" + { + let default = default.to_string_lossy(); + parameter["fi.mau.default_value"] = match kind { + | "integer" => default + .parse::() + .map_or_else(|_| json!(default), |n| json!(n)), + | _ => json!(default), + }; + } + Some(parameter) +} + +/// The argv `AdminCommand::try_parse_from` expects for an +/// `org.matrix.msc4391.command` invocation. +pub(crate) fn argv(invocation: &Value) -> Result> { + let root = AdminCommand::command(); + let name = invocation + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| err!("The command has no name."))?; + let empty = Map::new(); + let arguments = match invocation.get("arguments") { + | None | Some(Value::Null) => &empty, + | Some(Value::Object(arguments)) => arguments, + | Some(_) => return Err!("The command's arguments are not an object."), + }; + + let mut argv = vec!["admin".to_owned()]; + let mut command = &root; + for word in name.split_whitespace() { + command = command + .find_subcommand(word) + .ok_or_else(|| err!("There is no `{name}` command."))?; + argv.push(command.get_name().to_owned()); + } + if command.has_subcommands() { + return Err!("`{name}` needs a subcommand."); + } + + for key in arguments.keys() { + if !command + .get_arguments() + .filter(|arg| parameter(arg).is_some()) + .any(|arg| arg.get_id() == key) + { + return Err!("`{name}` has no `{key}` argument."); + } + } + + let mut positionals = Vec::new(); + let mut skipped = None; + for arg in command.get_arguments() { + let key = arg.get_id().as_str(); + let value = match arguments.get(key) { + | None | Some(Value::Null) => { + if arg.is_positional() { + skipped.get_or_insert(key); + } + continue; + }, + | Some(value) => value, + }; + let schema = parameter(arg).expect("described arguments have schemas")["schema"].clone(); + let is_array = schema["schema_type"] == "array"; + let item_schema = if is_array { &schema["items"] } else { &schema }; + let values = match value { + | Value::Array(items) if is_array => items + .iter() + .map(|value| scalar(item_schema, value)) + .collect::>>()?, + | Value::Array(_) => return Err!("`{key}` does not accept an array."), + | _ if is_array => return Err!("`{key}` must be an array."), + | value => vec![scalar(item_schema, value)?], + }; + + if arg.is_positional() { + if let Some(skipped) = skipped { + return Err!("`{key}` can only be given together with `{skipped}`."); + } + positionals.extend(values); + continue; + } + + let flag = arg + .get_long() + .map(|long| format!("--{long}")) + .or_else(|| arg.get_short().map(|short| format!("-{short}"))) + .ok_or_else(|| err!("`{key}` cannot be passed by name."))?; + match arg.get_action() { + | ArgAction::SetTrue => match value { + | Value::Bool(true) => argv.push(flag), + | Value::Bool(false) => {}, + | _ => return Err!("`{key}` must be a boolean."), + }, + | ArgAction::SetFalse => match value { + | Value::Bool(false) => argv.push(flag), + | Value::Bool(true) => {}, + | _ => return Err!("`{key}` must be a boolean."), + }, + | ArgAction::Count => { + let count = value + .as_u64() + .ok_or_else(|| err!("`{key}` must be a count."))?; + argv.extend((0..count).map(|_| flag.clone())); + }, + | _ => argv.extend(values.into_iter().map(|value| format!("{flag}={value}"))), + } + } + + if !positionals.is_empty() { + argv.push("--".to_owned()); + argv.extend(positionals); + } + Ok(argv) +} + +fn scalar(schema: &Value, value: &Value) -> Result { + match schema["schema_type"].as_str() { + | Some("union") => schema["variants"] + .as_array() + .and_then(|variants| { + variants + .iter() + .find_map(|schema| scalar(schema, value).ok()) + }) + .ok_or_else(|| err!("An argument has a value of an unsupported type.")), + | Some("literal") if schema["value"] == *value => + scalar(&json!({ "schema_type": "primitive", "type": schema["literal_type"] }), value), + | Some("primitive") => match schema["type"].as_str() { + | Some("boolean") => value + .as_bool() + .map(|value| value.to_string()) + .ok_or_else(|| err!("An argument must be a boolean.")), + | Some("integer") => value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .map(|value| value.to_string()) + .ok_or_else(|| err!("An argument must be an integer.")), + | Some("room_id") => reference(value, "room_id", &["room_id", "id"]), + | Some("event_id") => reference(value, "event_id", &["event_id"]), + | Some(_) => value + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| err!("An argument must be a string.")), + | None => Err!("An argument has an invalid schema."), + }, + | _ => Err!("An argument has an invalid schema."), + } +} + +fn reference(value: &Value, kind: &str, fields: &[&str]) -> Result { + let reference = value + .as_object() + .ok_or_else(|| err!("A {kind} reference must be an object."))?; + if reference.get("type").and_then(Value::as_str) != Some(kind) { + return Err!("A reference must have type `{kind}`."); + } + fields + .iter() + .find_map(|field| reference.get(*field).and_then(Value::as_str)) + .map(ToOwned::to_owned) + .ok_or_else(|| err!("A {kind} reference has no id.")) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use clap::Parser; + use serde_json::{Value, json}; + + use super::{argv, descriptions}; + use crate::admin::AdminCommand; + + #[test] + fn every_leaf_command_is_described_once_with_unique_parameters() { + let described = descriptions(); + let names: HashSet<&str> = described.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names.len(), described.len()); + assert!(names.contains("users create")); + + for (name, content) in &described { + let keys: Vec<&str> = content["parameters"] + .as_array() + .unwrap() + .iter() + .map(|parameter| parameter["key"].as_str().unwrap()) + .collect(); + let unique: HashSet<&&str> = keys.iter().collect(); + assert_eq!(unique.len(), keys.len(), "{name} repeats a parameter key"); + } + } + + #[test] + fn arguments_are_typed_from_the_clap_definitions() { + let described = descriptions(); + let (_, reset) = described + .iter() + .find(|(name, _)| name == "users reset-password") + .unwrap(); + let parameters = reset["parameters"].as_array().unwrap(); + let find = |key: &str| { + parameters + .iter() + .find(|parameter| parameter["key"] == key) + .unwrap() + }; + + assert_eq!(find("logout")["schema"]["type"], "boolean"); + assert_eq!(find("logout")["optional"], true); + assert_eq!(find("username")["schema"]["type"], "string"); + assert!(find("username").get("optional").is_none()); + assert_eq!(find("password")["optional"], true); + } + + #[test] + fn a_structured_invocation_parses_as_the_typed_command() { + let argv = argv(&json!({ + "command": "users reset-password", + "arguments": { "logout": true, "username": "alice", "password": "-secret" }, + })) + .unwrap(); + + assert_eq!(argv, [ + "admin", + "users", + "reset-password", + "--logout", + "--", + "alice", + "-secret" + ]); + AdminCommand::try_parse_from(&argv).unwrap(); + } + + #[test] + fn a_room_reference_passes_its_id() { + let argv = argv(&json!({ + "command": "rooms moderation ban-room", + "arguments": { "room": { "type": "room_id", "room_id": "!room:example.org", "via": ["example.org"] } }, + })) + .unwrap(); + + assert_eq!(argv.last().map(String::as_str), Some("!room:example.org")); + AdminCommand::try_parse_from(&argv).unwrap(); + } + + #[test] + fn unknown_commands_and_arguments_are_refused() { + argv(&json!({ "command": "users nope" })).unwrap_err(); + argv(&json!({ "command": "users" })).unwrap_err(); + argv(&json!({ "command": "users create", "arguments": { "nope": 1 } })).unwrap_err(); + } + + #[test] + fn structured_arguments_match_their_schemas() { + argv(&json!({ + "command": "users reset-password", + "arguments": { "logout": "true", "username": "alice" }, + })) + .unwrap_err(); + argv(&json!({ + "command": "rooms moderation ban-room", + "arguments": { "room": "!room:example.org" }, + })) + .unwrap_err(); + } + + fn sample(schema: &Value) -> Value { + match schema["schema_type"].as_str().unwrap() { + | "array" => json!([sample(&schema["items"])]), + | "union" => sample(&schema["variants"][0]), + | "literal" => schema["value"].clone(), + | _ => match schema["type"].as_str().unwrap() { + | "boolean" => json!(true), + | "integer" => json!(1), + | "user_id" => json!("@alice:example.org"), + | "room_id" => json!({ "type": "room_id", "room_id": "!room:example.org" }), + | "room_alias" => json!("#room:example.org"), + | "event_id" => + json!({ "type": "event_id", "id": "!room:example.org", "event_id": "$event" }), + | "server_name" => json!("example.org"), + | _ => json!("mxc://example.org/media"), + }, + } + } + + /// Commands whose requirements are "one of these", which MSC4391 cannot + /// express: every argument is described as optional and clap reports + /// what is missing. + const ONE_OF: [&str; 2] = ["token issue", "media delete-url-preview"]; + + #[test] + fn every_described_command_accepts_its_required_arguments() { + let mut failures = Vec::new(); + for (name, content) in descriptions() { + if ONE_OF.contains(&name.as_str()) { + continue; + } + let arguments: serde_json::Map = content["parameters"] + .as_array() + .unwrap() + .iter() + .filter(|parameter| parameter.get("optional").is_none()) + .map(|parameter| { + (parameter["key"].as_str().unwrap().to_owned(), sample(¶meter["schema"])) + }) + .collect(); + let invocation = json!({ "command": name, "arguments": arguments }); + match argv(&invocation) { + | Ok(argv) => + if let Err(error) = AdminCommand::try_parse_from(&argv) { + failures.push(format!("{name}: {argv:?}: {error}")); + }, + | Err(error) => failures.push(format!("{name}: {error}")), + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + } + + #[test] + fn literal_schemas_include_their_type() { + for (_, content) in descriptions() { + for parameter in content["parameters"].as_array().unwrap() { + let schema = ¶meter["schema"]; + let variants = schema["variants"].as_array().into_iter().flatten(); + for variant in variants.filter(|variant| variant["schema_type"] == "literal") { + assert_eq!(variant["literal_type"], "string"); + } + } + } + } +} diff --git a/src/admin/processor.rs b/src/admin/processor.rs index ba935fc7d..1f47d8fb0 100644 --- a/src/admin/processor.rs +++ b/src/admin/processor.rs @@ -27,7 +27,7 @@ use tracing::Level; use tracing_subscriber::{EnvFilter, filter::LevelFilter}; -use crate::{admin, admin::AdminCommand, context::Context}; +use crate::{admin, admin::AdminCommand, context::Context, msc4391}; #[must_use] pub(super) fn complete(line: &str) -> String { complete_command(AdminCommand::command(), line) } @@ -174,6 +174,18 @@ fn parse<'a>( services: &Arc, input: &'a CommandInput, ) -> Result<(AdminCommand, Vec, Vec<&'a str>), CommandOutput> { + if let Some(invocation) = &input.structured { + let failed = |error: &dyn std::fmt::Display| { + reply( + RoomMessageEventContent::notice_plain(error.to_string()), + input.reply_id.as_deref(), + ) + }; + let argv = msc4391::argv(invocation).map_err(|error| failed(&error))?; + let command = AdminCommand::try_parse_from(&argv).map_err(|error| failed(&error))?; + return Ok((command, argv, Vec::new())); + } + let lines = input.command.lines().filter(|line| !line.trim().is_empty()); let command_line = lines.clone().next().expect("command missing first line"); let body = lines.skip(1).collect(); diff --git a/src/router/run.rs b/src/router/run.rs index bff30367a..20c36f735 100644 --- a/src/router/run.rs +++ b/src/router/run.rs @@ -8,7 +8,7 @@ }; use axum_server::{Address, Handle as ServerHandle}; -use conduwuit::{Error, Result, Server, debug, debug_error, debug_info, error, info}; +use conduwuit::{Error, Result, Server, debug, debug_error, debug_info, error, info, warn}; use futures::FutureExt; use service::Services; use tokio::{ @@ -44,6 +44,10 @@ pub(crate) async fn run(services: Arc) -> Result<()> { // panics. services.admin.startup_execute().await?; + if let Err(error) = services.admin.publish_command_descriptions().await { + warn!(?error, "Failed to publish admin command descriptions"); + } + // Print first-run banner if necessary. This needs to be done after the // startup admin commands are run in case one of them created the first // user. diff --git a/src/service/admin/create.rs b/src/service/admin/create.rs index 2febccaa4..26b9eea0f 100644 --- a/src/service/admin/create.rs +++ b/src/service/admin/create.rs @@ -1,4 +1,4 @@ -use conduwuit::{Result, info, pdu::PartialPdu}; +use conduwuit::{Result, info, pdu::PartialPdu, warn}; use futures::FutureExt; use ruma::{ Int, RoomId, @@ -232,6 +232,11 @@ pub async fn create_admin_room(services: &Services) -> Result { .rooms .alias .set_alias(&services.globals.admin_alias, &room_id, server_user)?; + drop(state_lock); + + if let Err(error) = services.admin.publish_command_descriptions().await { + warn!(?error, "Failed to publish admin command descriptions"); + } Ok(()) } diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs index 15dc33d50..6e5e3863f 100644 --- a/src/service/admin/mod.rs +++ b/src/service/admin/mod.rs @@ -2,6 +2,7 @@ mod create; mod execute; mod grant; +mod msc4391; use std::{ pin::Pin, @@ -16,6 +17,7 @@ pub use create::create_admin_room; use futures::{Future, FutureExt, StreamExt, TryFutureExt}; use loole::{Receiver, Sender}; +pub use msc4391::Describer; use ruma::{ OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UInt, UserId, api::client::discovery::discover_support::{Contact, ContactRole}, @@ -40,6 +42,7 @@ pub struct Service { channel: (Sender, Receiver), pub handle: RwLock>, pub complete: SyncRwLock>, + pub describe: SyncRwLock>, #[cfg(feature = "console")] pub console: Arc, } @@ -63,6 +66,8 @@ struct Services { #[derive(Debug)] pub struct CommandInput { pub command: String, + /// A structured MSC4391 invocation, which takes the place of `command`. + pub structured: Option, pub reply_id: Option, pub source: InvocationSource, pub sender: Option, @@ -133,6 +138,7 @@ fn build(args: crate::Args<'_>) -> Result> { channel: loole::bounded(COMMAND_QUEUE_LIMIT), handle: RwLock::new(None), complete: SyncRwLock::new(None), + describe: SyncRwLock::new(None), #[cfg(feature = "console")] console: console::Console::new(&args), })) @@ -309,7 +315,13 @@ pub fn command( ) -> Result<()> { self.channel .0 - .send(CommandInput { command, reply_id, source, sender: None }) + .send(CommandInput { + command, + structured: None, + reply_id, + source, + sender: None, + }) .map_err(|e| err!("Failed to enqueue admin command: {e:?}")) } @@ -327,6 +339,7 @@ pub fn command_with_sender( .0 .send(CommandInput { command, + structured: None, reply_id, source, sender: Some(sender), @@ -342,8 +355,14 @@ pub async fn command_in_place( reply_id: Option, source: InvocationSource, ) -> ProcessorResult { - self.process_command(CommandInput { command, reply_id, source, sender: None }) - .await + self.process_command(CommandInput { + command, + structured: None, + reply_id, + source, + sender: None, + }) + .await } /// Invokes the tab-completer to complete the command. When unavailable, diff --git a/src/service/admin/msc4391.rs b/src/service/admin/msc4391.rs new file mode 100644 index 000000000..29fc4ffc7 --- /dev/null +++ b/src/service/admin/msc4391.rs @@ -0,0 +1,152 @@ +//! MSC4391 in-room bot commands for the admin room. + +use std::collections::BTreeMap; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use conduwuit::{Event, Result, debug, err, info, pdu::PartialPdu}; +use futures::FutureExt; +use ruma::{ + OwnedEventId, UserId, + events::{Mentions, StateEventType}, +}; +use serde::Deserialize; +use serde_json::{Value, value::to_raw_value}; +use sha2::{Digest, Sha256}; + +use super::{CommandInput, InvocationSource, Service}; + +const COMMAND_DESCRIPTION_EVENT: &str = "org.matrix.msc4391.command_description"; + +/// Prototype of the command describer, supplied by the reloadable admin +/// module: each admin command's name and its description event content. +pub type Describer = fn() -> Vec<(String, Value)>; + +#[derive(Deserialize)] +struct CommandContent { + body: Option, + #[serde(rename = "org.matrix.msc4391.command")] + command: Option, + #[serde(rename = "m.mentions")] + mentions: Option, +} + +/// Padded base64 of SHA-256(command + sender), as MSC4391 requires. +fn state_key(command: &str, sender: &UserId) -> String { + let mut hasher = Sha256::new(); + hasher.update(command.as_bytes()); + hasher.update(sender.as_bytes()); + STANDARD.encode(hasher.finalize()) +} + +impl Service { + /// Syncs the admin room's command description state with the admin + /// command tree, sending only what changed. + pub async fn publish_command_descriptions(&self) -> Result { + let Some(describe) = *self.describe.read() else { + return Ok(()); + }; + let Ok(room_id) = self.get_admin_room().await else { + return Ok(()); + }; + let server_user = &self.services.globals.server_user; + let event_type = StateEventType::from(COMMAND_DESCRIPTION_EVENT); + + let wanted: BTreeMap = describe() + .into_iter() + .map(|(command, content)| (state_key(&command, server_user), content)) + .collect(); + let state_lock = self.services.state.mutex.lock(&room_id).await; + let mut sent = 0_usize; + + for (key, content) in &wanted { + let current: Option = self + .services + .state_accessor + .room_state_get_content(&room_id, &event_type, key.as_str()) + .await + .ok(); + if current.as_ref() == Some(content) { + continue; + } + + self.services + .timeline + .build_and_append_pdu( + PartialPdu { + event_type: COMMAND_DESCRIPTION_EVENT.into(), + content: to_raw_value(content)?, + state_key: Some(key.clone().into()), + ..PartialPdu::default() + }, + server_user, + Some(&room_id), + &state_lock, + ) + .boxed() + .await?; + sent = sent.saturating_add(1); + } + + if sent > 0 { + info!(count = sent, "Updated admin command descriptions"); + } else { + debug!("Admin command descriptions are up to date"); + } + Ok(()) + } + + /// Queues the admin command in a room message, either `!admin …` text or + /// an MSC4391 invocation addressed to the server user. + pub async fn handle_room_message(&self, event: &E, sent_locally: bool) -> Result + where + E: Event + Send + Sync, + { + let content: CommandContent = event.get_content()?; + let reply_id: Option = Some(event.event_id().into()); + + if let Some(invocation) = content.command + && self + .is_structured_admin_command(event, content.mentions.as_ref()) + .await + { + return self + .channel + .0 + .send(CommandInput { + command: content.body.unwrap_or_default(), + structured: Some(invocation), + reply_id, + source: InvocationSource::AdminRoom, + sender: Some(event.sender().to_owned()), + }) + .map_err(|e| err!("Failed to enqueue admin command: {e:?}")); + } + + let Some(body) = content.body else { + return Ok(()); + }; + if let Some(source) = self.is_admin_command(event, &body, sent_locally).await { + self.command_with_sender(body, reply_id, source, event.sender().to_owned())?; + } + Ok(()) + } + + async fn is_structured_admin_command(&self, event: &E, mentions: Option<&Mentions>) -> bool + where + E: Event + Send + Sync, + { + let server_user = &self.services.globals.server_user; + if !mentions.is_some_and(|mentions| mentions.user_ids.contains(server_user)) { + return false; + } + if event.sender() == server_user + && self.services.server.config.emergency_password.is_none() + { + return false; + } + let Some(room_id) = event.room_id() else { + return false; + }; + self.is_admin_room(room_id).await && self.user_is_admin(event.sender()).await + } +} diff --git a/src/service/rooms/timeline/append.rs b/src/service/rooms/timeline/append.rs index 9639e6733..abced5ff6 100644 --- a/src/service/rooms/timeline/append.rs +++ b/src/service/rooms/timeline/append.rs @@ -121,23 +121,10 @@ pub async fn append_incoming_pdu<'a>( .await?; // Process admin commands for federation events - if *pdu.kind() == TimelineEventType::RoomMessage { - let content: ExtractBody = pdu.get_content()?; - if let Some(body) = content.body { - if let Some(source) = self - .services - .admin - .is_admin_command(pdu, &body, false) - .await - { - self.services.admin.command_with_sender( - body, - Some(pdu.event_id().into()), - source, - pdu.sender.clone(), - )?; - } - } + if *pdu.kind() == TimelineEventType::RoomMessage + || *pdu.kind() == TimelineEventType::from("m.room.bot.command") + { + self.services.admin.handle_room_message(pdu, false).await?; } self.services.sync.wake_all_joined(&room_id).await; diff --git a/src/service/rooms/timeline/build.rs b/src/service/rooms/timeline/build.rs index 6e0a7282c..9263d8850 100644 --- a/src/service/rooms/timeline/build.rs +++ b/src/service/rooms/timeline/build.rs @@ -18,7 +18,7 @@ }, }; -use super::{ExtractBody, RoomMutexGuard}; +use super::RoomMutexGuard; impl super::Service { /// Creates a new persisted data unit and adds it to a room. This function @@ -141,23 +141,10 @@ pub async fn build_and_append_pdu( .await?; // Process admin commands for locally sent events - if *pdu.kind() == TimelineEventType::RoomMessage { - let content: ExtractBody = pdu.get_content()?; - if let Some(body) = content.body { - if let Some(source) = self - .services - .admin - .is_admin_command(&pdu, &body, true) - .await - { - self.services.admin.command_with_sender( - body, - Some(pdu.event_id().into()), - source, - pdu.sender.clone(), - )?; - } - } + if *pdu.kind() == TimelineEventType::RoomMessage + || *pdu.kind() == TimelineEventType::from("m.room.bot.command") + { + self.services.admin.handle_room_message(&pdu, true).await?; } // We set the room state after inserting the pdu, so that we never have