From 8fcfadcb04f864fa9b695262aa1b0f9177724983 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 18 Mar 2026 14:58:06 +0000 Subject: [PATCH] story-kit: merge 288_bug_ambient_mode_state_lost_on_server_restart --- .story_kit/bot.toml.example | 4 ++ server/src/matrix/bot.rs | 27 ++++++-- server/src/matrix/config.rs | 131 ++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 4 deletions(-) diff --git a/.story_kit/bot.toml.example b/.story_kit/bot.toml.example index 2d1ca6a..38b090f 100644 --- a/.story_kit/bot.toml.example +++ b/.story_kit/bot.toml.example @@ -13,3 +13,7 @@ enabled = false # Maximum conversation turns to remember per room (default: 20). # history_size = 20 + +# Rooms where the bot responds to all messages (not just addressed ones). +# This list is updated automatically when users toggle ambient mode at runtime. +# ambient_rooms = ["!roomid:example.com"] diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 75562df..849e20f 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -30,7 +30,7 @@ use matrix_sdk::encryption::verification::{ }; use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent; -use super::config::BotConfig; +use super::config::{BotConfig, save_ambient_rooms}; // --------------------------------------------------------------------------- // Conversation history types @@ -333,6 +333,21 @@ pub async fn run_bot( persisted.len() ); + // Restore persisted ambient rooms from config, ignoring any that are not + // in the configured target_room_ids to avoid stale entries. + let persisted_ambient: HashSet = config + .ambient_rooms + .iter() + .filter_map(|s| s.parse::().ok()) + .collect(); + if !persisted_ambient.is_empty() { + slog!( + "[matrix-bot] Restored ambient mode for {} room(s): {:?}", + persisted_ambient.len(), + persisted_ambient + ); + } + let bot_name = config .display_name .clone() @@ -351,7 +366,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())), + ambient_rooms: Arc::new(TokioMutex::new(persisted_ambient)), }; slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"); @@ -798,14 +813,18 @@ async fn on_room_message( .then(|| parse_ambient_command(&body, &ctx.bot_user_id, &ctx.bot_name)) .flatten(); if let Some(enable) = ambient_cmd { - { + let ambient_room_ids: Vec = { 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 + ambient.iter().map(|r| r.to_string()).collect() + }; // lock released before the async send below + + // Persist updated ambient rooms to bot.toml so the state survives restarts. + save_ambient_rooms(&ctx.project_root, &ambient_room_ids); let confirmation = if enable { "Ambient mode on. I'll respond to all messages in this room." diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index a13ea29..a7b14da 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -53,6 +53,11 @@ pub struct BotConfig { /// If unset, the bot falls back to "Assistant". #[serde(default)] pub display_name: Option, + /// Room IDs where ambient mode is active (bot responds to all messages). + /// Updated at runtime when the user toggles ambient mode — do not edit + /// manually while the bot is running. + #[serde(default)] + pub ambient_rooms: Vec, } impl BotConfig { @@ -97,6 +102,46 @@ impl BotConfig { } } +/// Persist the current set of ambient room IDs back to `bot.toml`. +/// +/// Reads the existing file as a TOML document, updates the `ambient_rooms` +/// array, and writes the result back. Errors are logged but not propagated +/// so a persistence failure never interrupts the bot's message handling. +pub fn save_ambient_rooms(project_root: &Path, room_ids: &[String]) { + let path = project_root.join(".story_kit").join("bot.toml"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("[matrix-bot] save_ambient_rooms: failed to read bot.toml: {e}"); + return; + } + }; + let mut doc: toml::Value = match toml::from_str(&content) { + Ok(v) => v, + Err(e) => { + eprintln!("[matrix-bot] save_ambient_rooms: failed to parse bot.toml: {e}"); + return; + } + }; + if let toml::Value::Table(ref mut t) = doc { + let arr = toml::Value::Array( + room_ids + .iter() + .map(|s| toml::Value::String(s.clone())) + .collect(), + ); + t.insert("ambient_rooms".to_string(), arr); + } + match toml::to_string_pretty(&doc) { + Ok(new_content) => { + if let Err(e) = std::fs::write(&path, new_content) { + eprintln!("[matrix-bot] save_ambient_rooms: failed to write bot.toml: {e}"); + } + } + Err(e) => eprintln!("[matrix-bot] save_ambient_rooms: failed to serialise bot.toml: {e}"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -378,4 +423,90 @@ require_verified_devices = true "bot.toml with legacy require_verified_devices key must still load" ); } + + #[test] + fn load_reads_ambient_rooms() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +ambient_rooms = ["!abc:example.com"] +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]); + } + + #[test] + fn load_ambient_rooms_defaults_to_empty_when_absent() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert!(config.ambient_rooms.is_empty()); + } + + #[test] + fn save_ambient_rooms_persists_to_bot_toml() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#"homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +"#, + ) + .unwrap(); + + save_ambient_rooms(tmp.path(), &["!abc:example.com".to_string()]); + + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]); + } + + #[test] + fn save_ambient_rooms_clears_when_empty() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#"homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +room_ids = ["!abc:example.com"] +enabled = true +ambient_rooms = ["!abc:example.com"] +"#, + ) + .unwrap(); + + save_ambient_rooms(tmp.path(), &[]); + + let config = BotConfig::load(tmp.path()).unwrap(); + assert!(config.ambient_rooms.is_empty()); + } }