diff --git a/server/src/chat/transport/matrix/config/loading.rs b/server/src/chat/transport/matrix/config/loading.rs new file mode 100644 index 00000000..dabacd10 --- /dev/null +++ b/server/src/chat/transport/matrix/config/loading.rs @@ -0,0 +1,223 @@ +//! BotConfig::load implementation and the save_ambient_rooms helper. + +use std::path::Path; + +use super::BotConfig; + +impl BotConfig { + /// Load bot configuration from `.huskies/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(".huskies").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.transport == "whatsapp" { + if config.whatsapp_provider == "twilio" { + // Validate Twilio-specific fields. + if config + .twilio_account_sid + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ + twilio_account_sid" + ); + return None; + } + if config + .twilio_auth_token + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ + twilio_auth_token" + ); + return None; + } + if config + .twilio_whatsapp_number + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ + twilio_whatsapp_number" + ); + return None; + } + } else { + // Validate Meta (default) WhatsApp fields. + if config + .whatsapp_phone_number_id + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: transport=\"whatsapp\" requires \ + whatsapp_phone_number_id" + ); + return None; + } + if config + .whatsapp_access_token + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: transport=\"whatsapp\" requires \ + whatsapp_access_token" + ); + return None; + } + if config + .whatsapp_verify_token + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: transport=\"whatsapp\" requires \ + whatsapp_verify_token" + ); + return None; + } + } + } else if config.transport == "slack" { + // Validate Slack-specific fields. + if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: transport=\"slack\" requires \ + slack_bot_token" + ); + return None; + } + if config + .slack_signing_secret + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: transport=\"slack\" requires \ + slack_signing_secret" + ); + return None; + } + if config.slack_channel_ids.is_empty() { + eprintln!( + "[bot] bot.toml: transport=\"slack\" requires \ + at least one slack_channel_ids entry" + ); + return None; + } + } else if config.transport == "discord" { + // Validate Discord-specific fields. + if config + .discord_bot_token + .as_ref() + .is_none_or(|s| s.is_empty()) + { + eprintln!( + "[bot] bot.toml: transport=\"discord\" requires \ + discord_bot_token" + ); + return None; + } + if config.discord_channel_ids.is_empty() { + eprintln!( + "[bot] bot.toml: transport=\"discord\" requires \ + at least one discord_channel_ids entry" + ); + return None; + } + } else { + // Default transport is Matrix — validate Matrix-specific fields. + if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver"); + return None; + } + if config.username.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!("[bot] bot.toml: transport=\"matrix\" requires username"); + return None; + } + if config.password.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!("[bot] bot.toml: transport=\"matrix\" requires password"); + return None; + } + 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 + } +} + +/// 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(".huskies").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}"), + } +} + diff --git a/server/src/chat/transport/matrix/config.rs b/server/src/chat/transport/matrix/config/mod.rs similarity index 80% rename from server/src/chat/transport/matrix/config.rs rename to server/src/chat/transport/matrix/config/mod.rs index 0dc7a10c..cb6185d8 100644 --- a/server/src/chat/transport/matrix/config.rs +++ b/server/src/chat/transport/matrix/config/mod.rs @@ -184,224 +184,11 @@ fn default_whatsapp_provider() -> String { "meta".to_string() } -impl BotConfig { - /// Load bot configuration from `.huskies/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(".huskies").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.transport == "whatsapp" { - if config.whatsapp_provider == "twilio" { - // Validate Twilio-specific fields. - if config - .twilio_account_sid - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ - twilio_account_sid" - ); - return None; - } - if config - .twilio_auth_token - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ - twilio_auth_token" - ); - return None; - } - if config - .twilio_whatsapp_number - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ - twilio_whatsapp_number" - ); - return None; - } - } else { - // Validate Meta (default) WhatsApp fields. - if config - .whatsapp_phone_number_id - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: transport=\"whatsapp\" requires \ - whatsapp_phone_number_id" - ); - return None; - } - if config - .whatsapp_access_token - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: transport=\"whatsapp\" requires \ - whatsapp_access_token" - ); - return None; - } - if config - .whatsapp_verify_token - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: transport=\"whatsapp\" requires \ - whatsapp_verify_token" - ); - return None; - } - } - } else if config.transport == "slack" { - // Validate Slack-specific fields. - if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!( - "[bot] bot.toml: transport=\"slack\" requires \ - slack_bot_token" - ); - return None; - } - if config - .slack_signing_secret - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: transport=\"slack\" requires \ - slack_signing_secret" - ); - return None; - } - if config.slack_channel_ids.is_empty() { - eprintln!( - "[bot] bot.toml: transport=\"slack\" requires \ - at least one slack_channel_ids entry" - ); - return None; - } - } else if config.transport == "discord" { - // Validate Discord-specific fields. - if config - .discord_bot_token - .as_ref() - .is_none_or(|s| s.is_empty()) - { - eprintln!( - "[bot] bot.toml: transport=\"discord\" requires \ - discord_bot_token" - ); - return None; - } - if config.discord_channel_ids.is_empty() { - eprintln!( - "[bot] bot.toml: transport=\"discord\" requires \ - at least one discord_channel_ids entry" - ); - return None; - } - } else { - // Default transport is Matrix — validate Matrix-specific fields. - if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver"); - return None; - } - if config.username.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!("[bot] bot.toml: transport=\"matrix\" requires username"); - return None; - } - if config.password.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!("[bot] bot.toml: transport=\"matrix\" requires password"); - return None; - } - 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) - } +mod loading; - /// 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 - } -} +pub use loading::save_ambient_rooms; -/// 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(".huskies").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::*; use std::fs;