use serde::Deserialize; use std::path::Path; fn default_history_size() -> usize { 20 } fn default_permission_timeout_secs() -> u64 { 120 } /// Configuration for the Matrix bot, read from `.story_kit/bot.toml`. #[derive(Deserialize, Clone, Debug)] pub struct BotConfig { /// Matrix homeserver URL, e.g. `https://matrix.example.com` pub homeserver: String, /// Bot user ID, e.g. `@storykit:example.com` pub username: String, /// Bot password pub password: String, /// Matrix room IDs to join, e.g. `["!roomid:example.com"]`. /// Use an array for multiple rooms; a single string is accepted via the /// deprecated `room_id` key for backwards compatibility. #[serde(default)] pub room_ids: Vec, /// Deprecated: use `room_ids` (list) instead. Still accepted so existing /// `bot.toml` files continue to work without modification. #[serde(default)] pub room_id: Option, /// Set to `true` to enable the bot (default: false) #[serde(default)] pub enabled: bool, /// Matrix user IDs allowed to interact with the bot. /// If empty or omitted, the bot ignores ALL messages (fail-closed). #[serde(default)] pub allowed_users: Vec, /// Maximum number of conversation turns (user + assistant pairs) to keep /// per room. When the history exceeds this limit the oldest messages are /// dropped. Defaults to 20. #[serde(default = "default_history_size")] pub history_size: usize, /// Timeout in seconds for permission prompts surfaced to the Matrix room. /// If the user does not respond within this window the permission is denied /// (fail-closed). Defaults to 120 seconds. #[serde(default = "default_permission_timeout_secs")] pub permission_timeout_secs: u64, /// Previously used to select an Anthropic model. Now ignored — the bot /// uses Claude Code which manages its own model selection. Kept for /// backwards compatibility so existing bot.toml files still parse. #[allow(dead_code)] pub model: Option, } impl BotConfig { /// Load bot configuration from `.story_kit/bot.toml`. /// /// Returns `None` if the file does not exist, fails to parse, has /// `enabled = false`, or specifies no room IDs. pub fn load(project_root: &Path) -> Option { let path = project_root.join(".story_kit").join("bot.toml"); if !path.exists() { return None; } let content = std::fs::read_to_string(&path) .map_err(|e| eprintln!("[matrix-bot] Failed to read bot.toml: {e}")) .ok()?; let mut config: BotConfig = toml::from_str(&content) .map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}")) .ok()?; if !config.enabled { return None; } // Merge deprecated `room_id` (single string) into `room_ids` (list). if let Some(single) = config.room_id.take() && !config.room_ids.contains(&single) { config.room_ids.push(single); } if config.room_ids.is_empty() { eprintln!( "[matrix-bot] bot.toml has no room_ids configured — \ add `room_ids = [\"!roomid:example.com\"]` to bot.toml" ); return None; } Some(config) } /// Returns all configured room IDs as a flat list. Combines `room_ids` /// and (after loading) any merged `room_id` value. pub fn effective_room_ids(&self) -> &[String] { &self.room_ids } } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn load_returns_none_when_file_missing() { let tmp = tempfile::tempdir().unwrap(); let result = BotConfig::load(tmp.path()); assert!(result.is_none()); } #[test] fn load_returns_none_when_disabled() { 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 = false "#, ) .unwrap(); let result = BotConfig::load(tmp.path()); assert!(result.is_none()); } #[test] fn load_returns_config_when_enabled_with_room_ids() { 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", "!def:example.com"] enabled = true "#, ) .unwrap(); let result = BotConfig::load(tmp.path()); assert!(result.is_some()); let config = result.unwrap(); assert_eq!(config.homeserver, "https://matrix.example.com"); assert_eq!(config.username, "@bot:example.com"); assert_eq!( config.effective_room_ids(), &["!abc:example.com", "!def:example.com"] ); assert!(config.model.is_none()); } #[test] fn load_merges_deprecated_room_id_into_room_ids() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); fs::create_dir_all(&sk).unwrap(); // Old-style single room_id key — should still work. fs::write( sk.join("bot.toml"), r#" homeserver = "https://matrix.example.com" username = "@bot:example.com" password = "secret" room_id = "!abc:example.com" enabled = true "#, ) .unwrap(); let config = BotConfig::load(tmp.path()).unwrap(); assert_eq!(config.effective_room_ids(), &["!abc:example.com"]); } #[test] fn load_returns_none_when_no_room_ids() { 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" enabled = true "#, ) .unwrap(); let result = BotConfig::load(tmp.path()); assert!(result.is_none()); } #[test] fn load_returns_none_when_toml_invalid() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); fs::create_dir_all(&sk).unwrap(); fs::write(sk.join("bot.toml"), "not valid toml {{{").unwrap(); let result = BotConfig::load(tmp.path()); assert!(result.is_none()); } #[test] fn load_respects_optional_model() { 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 model = "claude-sonnet-4-6" "#, ) .unwrap(); let config = BotConfig::load(tmp.path()).unwrap(); assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6")); } #[test] fn load_uses_default_history_size() { 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_eq!(config.history_size, 20); } #[test] fn load_respects_custom_history_size() { 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 history_size = 50 "#, ) .unwrap(); let config = BotConfig::load(tmp.path()).unwrap(); assert_eq!(config.history_size, 50); } #[test] fn load_uses_default_permission_timeout() { 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_eq!(config.permission_timeout_secs, 120); } #[test] fn load_respects_custom_permission_timeout() { 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 permission_timeout_secs = 60 "#, ) .unwrap(); let config = BotConfig::load(tmp.path()).unwrap(); assert_eq!(config.permission_timeout_secs, 60); } #[test] fn load_ignores_legacy_require_verified_devices_key() { // Old bot.toml files that still have `require_verified_devices = true` // must parse successfully — the field is simply ignored now that // verification is always enforced unconditionally. 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 require_verified_devices = true "#, ) .unwrap(); // Should still load successfully despite the unknown field. let config = BotConfig::load(tmp.path()); assert!( config.is_some(), "bot.toml with legacy require_verified_devices key must still load" ); } }