feat(admin): Describe admin commands as MSC4391 bot commands

This commit is contained in:
Erwan Leboucher authored and Jacob Taylor committed 2026-10-05 07:41:01 -07:00
1 parent 01fd90c679
commit 333cfd5e19
10 files changed
+697 -41

No files matched your search

@@ -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.
+3
View File
@@ -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();
}
+486
View File
@@ -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<String>, 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<String> = command
.get_visible_aliases()
.map(|alias| [parent, &[alias.to_owned()]].concat().join(" "))
.collect();
let parameters: Vec<Value> = 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::<OwnedRoomOrAliasId>()) {
return json!({
"schema_type": "union",
"variants": [primitive("room_id"), primitive("room_alias")],
});
}
let kind = if is(TypeId::of::<bool>()) {
"boolean"
} else if [
TypeId::of::<u8>(),
TypeId::of::<u16>(),
TypeId::of::<u32>(),
TypeId::of::<u64>(),
TypeId::of::<usize>(),
TypeId::of::<i8>(),
TypeId::of::<i16>(),
TypeId::of::<i32>(),
TypeId::of::<i64>(),
TypeId::of::<isize>(),
]
.into_iter()
.any(is)
{
"integer"
} else if is(TypeId::of::<OwnedUserId>()) {
"user_id"
} else if is(TypeId::of::<OwnedRoomId>()) {
"room_id"
} else if is(TypeId::of::<OwnedRoomAliasId>()) {
"room_alias"
} else if is(TypeId::of::<OwnedEventId>()) {
"event_id"
} else if is(TypeId::of::<OwnedServerName>()) {
"server_name"
} else {
"string"
};
primitive(kind)
}
fn parameter(arg: &clap::Arg) -> Option<Value> {
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::<Vec<_>>(),
})
};
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::<i64>()
.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<Vec<String>> {
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::<Result<Vec<_>>>()?,
| 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<String> {
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<String> {
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<String, Value> = content["parameters"]
.as_array()
.unwrap()
.iter()
.filter(|parameter| parameter.get("optional").is_none())
.map(|parameter| {
(parameter["key"].as_str().unwrap().to_owned(), sample(&parameter["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 = &parameter["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");
}
}
}
}
}
+13 -1
View File
@@ -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<Services>,
input: &'a CommandInput,
) -> Result<(AdminCommand, Vec<String>, 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();
+5 -1
View File
@@ -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<Services>) -> 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.
+6 -1
View File
@@ -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(())
}
+22 -3
View File
@@ -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<CommandInput>, Receiver<CommandInput>),
pub handle: RwLock<Option<Processor>>,
pub complete: SyncRwLock<Option<Completer>>,
pub describe: SyncRwLock<Option<Describer>>,
#[cfg(feature = "console")]
pub console: Arc<console::Console>,
}
@@ -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<serde_json::Value>,
pub reply_id: Option<OwnedEventId>,
pub source: InvocationSource,
pub sender: Option<OwnedUserId>,
@@ -133,6 +138,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
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<OwnedEventId>,
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,
+152
View File
@@ -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<String>,
#[serde(rename = "org.matrix.msc4391.command")]
command: Option<Value>,
#[serde(rename = "m.mentions")]
mentions: Option<Mentions>,
}
/// 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<String, Value> = 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<Value> = 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<E>(&self, event: &E, sent_locally: bool) -> Result
where
E: Event + Send + Sync,
{
let content: CommandContent = event.get_content()?;
let reply_id: Option<OwnedEventId> = 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<E>(&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
}
}
+4 -17
View File
@@ -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;
+5 -18
View File
@@ -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