use serde::Deserialize; use std::path::Path; /// 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 ID to join, e.g. `!roomid:example.com` pub room_id: String, /// 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, /// 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, or has /// `enabled = false`. 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 config: BotConfig = toml::from_str(&content) .map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}")) .ok()?; if !config.enabled { return None; } Some(config) } } #[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_id = "!abc:example.com" enabled = false "#, ) .unwrap(); let result = BotConfig::load(tmp.path()); assert!(result.is_none()); } #[test] fn load_returns_config_when_enabled() { 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_id = "!abc: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.room_id, "!abc:example.com"); assert!(config.model.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_id = "!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")); } }