From 4a4a1b8c9627e578497c8e0d0efadbd2c8fffe2f Mon Sep 17 00:00:00 2001 From: navsteruk Date: Tue, 31 Mar 2026 16:59:27 +0000 Subject: [PATCH 1/2] feat(matrix): add multi-room support with per-room config Add support for monitoring multiple Matrix rooms from a single channel instance, aligned with OpenClaw's per-room configuration approach. Changes: - Add MatrixRoomConfig struct with enabled and require_mention fields - Add optional rooms HashMap to MatrixConfig (backward compatible) - Activate the dormant multi-room filter in the listen() event handler - Add per-room mention detection using bot user ID and display name - Resolve bot display name on startup for mention matching - Fix channel name lookup to strip room ID suffix (was preventing replies from being sent back to Matrix) Config example: [channels_config.matrix.rooms] "!room1:matrix.org" = { enabled = true, require_mention = false } "!room2:matrix.org" = { enabled = true, require_mention = true } When rooms is absent, behavior is identical to before (single room_id). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/matrix.rs | 74 +++++++++++++++++++++++++++++++----- src/channels/mod.rs | 4 +- src/config/schema.rs | 18 +++++++++ src/integrations/registry.rs | 1 + src/onboard/wizard.rs | 1 + 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs index 38a29971608..1abccaa4e45 100644 --- a/src/channels/matrix.rs +++ b/src/channels/matrix.rs @@ -1,4 +1,5 @@ use crate::channels::traits::{Channel, ChannelMessage, SendMessage}; +use crate::config::schema::MatrixRoomConfig; use async_trait::async_trait; use matrix_sdk::{ authentication::matrix::MatrixSession, @@ -6,6 +7,7 @@ use matrix_sdk::{ ruma::{ events::reaction::ReactionEventContent, events::relation::{Annotation, InReplyTo, Thread}, + events::room::message::Relation, events::room::message::{ MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent, }, @@ -38,6 +40,8 @@ pub struct MatrixChannel { http_client: Client, reaction_events: Arc>>, voice_mode: Arc, + room_configs: HashMap, + bot_display_name: Arc>>, } impl std::fmt::Debug for MatrixChannel { @@ -138,6 +142,7 @@ impl MatrixChannel { owner_hint, device_id_hint, None, + HashMap::new(), ) } @@ -149,6 +154,7 @@ impl MatrixChannel { owner_hint: Option, device_id_hint: Option, zeroclaw_dir: Option, + room_configs: HashMap, ) -> Self { let homeserver = homeserver.trim_end_matches('/').to_string(); let access_token = access_token.trim().to_string(); @@ -172,6 +178,8 @@ impl MatrixChannel { http_client: Client::new(), reaction_events: Arc::new(RwLock::new(HashMap::new())), voice_mode: Arc::new(AtomicBool::new(false)), + room_configs, + bot_display_name: Arc::new(RwLock::new(None)), } } @@ -681,11 +689,29 @@ impl Channel for MatrixChannel { let _ = client.sync_once(SyncSettings::new()).await; - tracing::info!( - "Matrix channel listening on room {} (configured as {})...", - target_room_id, - self.room_id - ); + // Resolve bot display name for mention detection + if let Ok(Some(name)) = client.account().get_display_name().await { + *self.bot_display_name.write().await = Some(name); + } + + if self.room_configs.is_empty() { + tracing::info!( + "Matrix channel listening on room {} (configured as {})...", + target_room_id, + self.room_id + ); + } else { + let enabled: Vec<_> = self.room_configs.iter() + .filter(|(_, cfg)| cfg.enabled) + .map(|(id, _)| id.as_str()) + .collect(); + tracing::info!( + "Matrix channel listening on {} rooms (default: {}): {:?}", + enabled.len(), + self.room_id, + enabled + ); + } let recent_event_cache = Arc::new(Mutex::new(( std::collections::VecDeque::new(), @@ -700,6 +726,8 @@ impl Channel for MatrixChannel { let homeserver_for_handler = self.homeserver.clone(); let access_token_for_handler = self.access_token.clone(); let voice_mode_for_handler = Arc::clone(&self.voice_mode); + let room_configs_for_handler = self.room_configs.clone(); + let bot_display_name_for_handler = Arc::clone(&self.bot_display_name); client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| { let tx = tx_handler.clone(); @@ -710,13 +738,25 @@ impl Channel for MatrixChannel { let homeserver = homeserver_for_handler.clone(); let access_token = access_token_for_handler.clone(); let voice_mode = Arc::clone(&voice_mode_for_handler); + let room_configs = room_configs_for_handler.clone(); + let bot_display_name = Arc::clone(&bot_display_name_for_handler); async move { - if false - /* multi-room: room_id filter disabled */ - { - return; - } + // Multi-room: filter events by room config + let event_room_id = room.room_id().to_string(); + let room_config = if room_configs.is_empty() { + // Single-room mode: only accept the configured target room + if room.room_id() != target_room { + return; + } + None + } else { + // Multi-room mode: check room map + match room_configs.get(&event_room_id) { + Some(cfg) if cfg.enabled => Some(cfg.clone()), + _ => return, // room not in config or disabled + } + }; if event.sender == my_user_id { return; @@ -849,6 +889,20 @@ impl Channel for MatrixChannel { return; } + // Per-room mention check + if let Some(ref cfg) = room_config { + if cfg.require_mention { + let mentioned = body.contains(my_user_id.as_str()) + || bot_display_name.read().await + .as_ref() + .map(|name| body.to_lowercase().contains(&name.to_lowercase())) + .unwrap_or(false); + if !mentioned { + return; + } + } + } + let event_id = event.event_id.to_string(); { let mut guard = dedupe.lock().await; diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 610fe71515d..07755f297b6 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1683,7 +1683,8 @@ async fn process_channel_message( msg }; - let target_channel = ctx.channels_by_name.get(&msg.channel).cloned(); + let channel_name = msg.channel.split(':').next().unwrap_or(&msg.channel); + let target_channel = ctx.channels_by_name.get(channel_name).cloned(); if let Err(err) = maybe_apply_runtime_config_update(ctx.as_ref()).await { tracing::warn!("Failed to apply runtime config update: {err}"); } @@ -2940,6 +2941,7 @@ fn collect_configured_channels( mx.user_id.clone(), mx.device_id.clone(), config.config_path.parent().map(|path| path.to_path_buf()), + mx.rooms.clone(), )), }); } diff --git a/src/config/schema.rs b/src/config/schema.rs index c0f7f6d08fe..6270fd120af 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -3123,6 +3123,17 @@ impl ChannelConfig for IMessageConfig { } } +/// Per-room configuration for Matrix multi-room support. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MatrixRoomConfig { + /// Whether this room is enabled. Defaults to true. + #[serde(default = "default_true")] + pub enabled: bool, + /// If true, the bot only responds when mentioned. Defaults to false. + #[serde(default)] + pub require_mention: bool, +} + /// Matrix channel configuration. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MatrixConfig { @@ -3140,6 +3151,10 @@ pub struct MatrixConfig { pub room_id: String, /// Allowed Matrix user IDs. Empty = deny all. pub allowed_users: Vec, + /// Per-room overrides. Keys are room IDs (e.g. "!abc:matrix.org"). + /// When absent, only `room_id` is listened to (backward compatible). + #[serde(default)] + pub rooms: HashMap, } impl ChannelConfig for MatrixConfig { @@ -6240,6 +6255,7 @@ tool_dispatcher = "xml" device_id: Some("DEVICE123".into()), room_id: "!room123:matrix.org".into(), allowed_users: vec!["@user:matrix.org".into()], + rooms: HashMap::new(), }; let json = serde_json::to_string(&mc).unwrap(); let parsed: MatrixConfig = serde_json::from_str(&json).unwrap(); @@ -6260,6 +6276,7 @@ tool_dispatcher = "xml" device_id: None, room_id: "!abc:synapse.local".into(), allowed_users: vec!["@admin:synapse.local".into(), "*".into()], + rooms: HashMap::new(), }; let toml_str = toml::to_string(&mc).unwrap(); let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap(); @@ -6364,6 +6381,7 @@ allowed_users = ["@ops:matrix.org"] nostr: None, clawdtalk: None, message_timeout_secs: 300, + rooms: HashMap::new(), }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index 7a9d1fa1712..33d9189ca6b 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -865,6 +865,7 @@ mod tests { device_id: None, room_id: "!r:m".into(), allowed_users: vec![], + rooms: std::collections::HashMap::new(), }); let entries = all_integrations(); let mx = entries.iter().find(|e| e.name == "Matrix").unwrap(); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 9200ba57de7..c4ea01e2904 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -4011,6 +4011,7 @@ fn setup_channels() -> Result { device_id: detected_device_id, room_id, allowed_users, + rooms: std::collections::HashMap::new(), }); } ChannelMenuChoice::Signal => { From eec657c95b3daacc7536c4a16c1265a861d13113 Mon Sep 17 00:00:00 2001 From: navsteruk Date: Fri, 3 Apr 2026 23:05:44 +0000 Subject: [PATCH 2/2] feat(matrix): add per-room use_threads config option Adds a use_threads field to MatrixRoomConfig (default: true) that controls whether replies are sent as Matrix threads or inline in the main timeline. When set to false, tool call outputs and responses appear inline rather than in threaded side panels. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/matrix.rs | 19 +++++++++++++------ src/config/schema.rs | 3 +++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs index 1abccaa4e45..c7a215347a8 100644 --- a/src/channels/matrix.rs +++ b/src/channels/matrix.rs @@ -575,12 +575,19 @@ impl Channel for MatrixChannel { let mut content = RoomMessageEventContent::text_markdown(&message.content); - if let Some(ref thread_ts) = message.thread_ts { - if let Ok(thread_root) = thread_ts.parse::() { - content.relates_to = Some(Relation::Thread(Thread::plain( - thread_root.clone(), - thread_root, - ))); + let use_threads = self.room_configs + .get(&target_room_id) + .map(|cfg| cfg.use_threads) + .unwrap_or(true); + + if use_threads { + if let Some(ref thread_ts) = message.thread_ts { + if let Ok(thread_root) = thread_ts.parse::() { + content.relates_to = Some(Relation::Thread(Thread::plain( + thread_root.clone(), + thread_root, + ))); + } } } diff --git a/src/config/schema.rs b/src/config/schema.rs index 6270fd120af..7fc417a2915 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -3132,6 +3132,9 @@ pub struct MatrixRoomConfig { /// If true, the bot only responds when mentioned. Defaults to false. #[serde(default)] pub require_mention: bool, + /// If true, replies are sent as Matrix threads. Defaults to true. + #[serde(default = "default_true")] + pub use_threads: bool, } /// Matrix channel configuration.