From b50d007b40c32620b82814696f5fdc0071df0cf3 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 18 Mar 2026 12:10:04 +0000 Subject: [PATCH] story-kit: merge 282_story_matrix_bot_ambient_mode_toggle_via_chat_command --- server/src/matrix/bot.rs | 192 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 5 deletions(-) diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index f87e005..0663a2a 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -165,6 +165,10 @@ pub struct BotContext { /// The name the bot uses to refer to itself. Derived from `display_name` /// in bot.toml; defaults to "Assistant" when unset. pub bot_name: String, + /// Set of room IDs where ambient mode is active. In ambient mode the bot + /// responds to all messages rather than only addressed ones. This is + /// in-memory only — the state does not survive a bot restart. + pub ambient_rooms: Arc>>, } // --------------------------------------------------------------------------- @@ -347,6 +351,7 @@ pub async fn run_bot( pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())), permission_timeout_secs: config.permission_timeout_secs, bot_name, + ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())), }; slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"); @@ -463,6 +468,50 @@ fn contains_word(haystack: &str, needle: &str) -> bool { false } +/// Parse an ambient-mode toggle command from a message body. +/// +/// Recognises the following (case-insensitive) forms, with or without a +/// leading bot mention: +/// +/// - `@botname ambient on` / `@botname:server ambient on` +/// - `botname ambient on` +/// - `ambient on` +/// +/// and the `off` variants. +/// +/// Returns `Some(true)` for "ambient on", `Some(false)` for "ambient off", +/// and `None` when the body is not an ambient mode command. +pub fn parse_ambient_command( + body: &str, + bot_user_id: &OwnedUserId, + bot_name: &str, +) -> Option { + let lower = body.trim().to_ascii_lowercase(); + let display_lower = bot_name.to_ascii_lowercase(); + let localpart_lower = bot_user_id.localpart().to_ascii_lowercase(); + + // Strip a leading @mention (handles "@localpart" and "@localpart:server"). + let rest = if let Some(after_at) = lower.strip_prefix('@') { + // Skip everything up to the first whitespace (the full mention token). + let word_end = after_at + .find(char::is_whitespace) + .unwrap_or(after_at.len()); + after_at[word_end..].trim() + } else if let Some(after) = lower.strip_prefix(display_lower.as_str()) { + after.trim() + } else if let Some(after) = lower.strip_prefix(localpart_lower.as_str()) { + after.trim() + } else { + lower.as_str() + }; + + match rest { + "ambient on" => Some(true), + "ambient off" => Some(false), + _ => None, + } +} + /// Returns `true` if the message's `relates_to` field references an event that /// the bot previously sent (i.e. the message is a reply or thread-reply to a /// bot message). @@ -666,11 +715,14 @@ async fn on_room_message( _ => return, }; - // Only respond when the bot is directly addressed (mentioned by name/ID) - // or when the message is a reply to one of the bot's own messages. - if !mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id) - && !is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await - { + // Only respond when the bot is directly addressed (mentioned by name/ID), + // when the message is a reply to one of the bot's own messages, or when + // ambient mode is enabled for this room. + let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id) + || is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await; + let is_ambient = ctx.ambient_rooms.lock().await.contains(&incoming_room_id); + + if !is_addressed && !is_ambient { slog!( "[matrix-bot] Ignoring unaddressed message from {}", ev.sender @@ -739,6 +791,42 @@ async fn on_room_message( } } + // Check for ambient mode toggle commands. Commands are only recognised + // from addressed messages so they can't be accidentally triggered by + // ambient-mode traffic from other users. + let ambient_cmd = is_addressed + .then(|| parse_ambient_command(&body, &ctx.bot_user_id, &ctx.bot_name)) + .flatten(); + if let Some(enable) = ambient_cmd { + { + let mut ambient = ctx.ambient_rooms.lock().await; + if enable { + ambient.insert(incoming_room_id.clone()); + } else { + ambient.remove(&incoming_room_id); + } + } // lock released before the async send below + + let confirmation = if enable { + "Ambient mode on. I'll respond to all messages in this room." + } else { + "Ambient mode off. I'll only respond when mentioned." + }; + let html = markdown_to_html(confirmation); + if let Ok(resp) = room + .send(RoomMessageEventContent::text_html(confirmation, html)) + .await + { + ctx.bot_sent_event_ids.lock().await.insert(resp.event_id); + } + slog!( + "[matrix-bot] Ambient mode {} for room {}", + if enable { "enabled" } else { "disabled" }, + incoming_room_id + ); + return; + } + let sender = ev.sender.to_string(); let user_message = body; slog!("[matrix-bot] Message from {sender}: {user_message}"); @@ -1251,6 +1339,7 @@ mod tests { pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())), permission_timeout_secs: 120, bot_name: "Assistant".to_string(), + ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())), }; // Clone must work (required by Matrix SDK event handler injection). let _cloned = ctx.clone(); @@ -1730,4 +1819,97 @@ mod tests { assert_eq!(resolve_bot_name(None), "Assistant"); assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy"); } + + // -- parse_ambient_command ------------------------------------------------ + + #[test] + fn ambient_command_on_with_at_mention() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("@timmy ambient on", &uid, "Timmy"), Some(true)); + } + + #[test] + fn ambient_command_off_with_at_mention() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("@timmy ambient off", &uid, "Timmy"), Some(false)); + } + + #[test] + fn ambient_command_on_with_full_user_id() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!( + parse_ambient_command("@timmy:homeserver.local ambient on", &uid, "Timmy"), + Some(true) + ); + } + + #[test] + fn ambient_command_on_with_display_name() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("timmy ambient on", &uid, "Timmy"), Some(true)); + } + + #[test] + fn ambient_command_off_with_display_name() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("timmy ambient off", &uid, "Timmy"), Some(false)); + } + + #[test] + fn ambient_command_on_bare() { + // "ambient on" without any bot mention is also recognised. + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("ambient on", &uid, "Timmy"), Some(true)); + } + + #[test] + fn ambient_command_off_bare() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("ambient off", &uid, "Timmy"), Some(false)); + } + + #[test] + fn ambient_command_case_insensitive() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("@Timmy AMBIENT ON", &uid, "Timmy"), Some(true)); + assert_eq!(parse_ambient_command("TIMMY AMBIENT OFF", &uid, "Timmy"), Some(false)); + } + + #[test] + fn ambient_command_unrelated_message_returns_none() { + let uid = make_user_id("@timmy:homeserver.local"); + assert_eq!(parse_ambient_command("@timmy what is the status?", &uid, "Timmy"), None); + assert_eq!(parse_ambient_command("hello there", &uid, "Timmy"), None); + assert_eq!(parse_ambient_command("ambient", &uid, "Timmy"), None); + } + + // -- ambient mode state --------------------------------------------------- + + #[tokio::test] + async fn ambient_rooms_defaults_to_empty() { + let ambient_rooms: Arc>> = + Arc::new(TokioMutex::new(HashSet::new())); + let room_id: OwnedRoomId = "!room:example.com".parse().unwrap(); + assert!(!ambient_rooms.lock().await.contains(&room_id)); + } + + #[tokio::test] + async fn ambient_mode_can_be_toggled_per_room() { + let ambient_rooms: Arc>> = + Arc::new(TokioMutex::new(HashSet::new())); + let room_a: OwnedRoomId = "!room_a:example.com".parse().unwrap(); + let room_b: OwnedRoomId = "!room_b:example.com".parse().unwrap(); + + // Enable ambient mode for room_a only. + ambient_rooms.lock().await.insert(room_a.clone()); + + let guard = ambient_rooms.lock().await; + assert!(guard.contains(&room_a), "room_a should be in ambient mode"); + assert!(!guard.contains(&room_b), "room_b should NOT be in ambient mode"); + drop(guard); + + // Disable ambient mode for room_a. + ambient_rooms.lock().await.remove(&room_a); + assert!(!ambient_rooms.lock().await.contains(&room_a), "room_a ambient mode should be off"); + } }