From d0b7db67653218210a645598faa50c7a11ada14c Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 15:53:39 +0000 Subject: [PATCH] huskies: merge 785 --- .../src/chat/transport/matrix/config/mod.rs | 1049 +---------------- .../transport/matrix/config/tests_core.rs | 430 +++++++ .../matrix/config/tests_transports.rs | 428 +++++++ .../src/chat/transport/matrix/config/types.rs | 184 +++ 4 files changed, 1049 insertions(+), 1042 deletions(-) create mode 100644 server/src/chat/transport/matrix/config/tests_core.rs create mode 100644 server/src/chat/transport/matrix/config/tests_transports.rs create mode 100644 server/src/chat/transport/matrix/config/types.rs diff --git a/server/src/chat/transport/matrix/config/mod.rs b/server/src/chat/transport/matrix/config/mod.rs index eedf4734..c235a850 100644 --- a/server/src/chat/transport/matrix/config/mod.rs +++ b/server/src/chat/transport/matrix/config/mod.rs @@ -1,1047 +1,12 @@ //! Matrix transport configuration — deserialization of `bot.toml` Matrix settings. -#![allow(unused_imports, dead_code)] -use serde::Deserialize; -use std::path::Path; - -fn default_history_size() -> usize { - 20 -} - -fn default_permission_timeout_secs() -> u64 { - 120 -} - -fn default_aggregated_notifications_poll_interval_secs() -> u64 { - 5 -} - -fn default_aggregated_notifications_enabled() -> bool { - true -} - -/// Configuration for the Matrix bot, read from `.huskies/bot.toml`. -#[derive(Deserialize, Clone, Debug)] -pub struct BotConfig { - /// Matrix homeserver URL, e.g. `https://matrix.example.com` - /// Only required when `transport = "matrix"` (the default). - #[serde(default)] - pub homeserver: Option, - /// Bot user ID, e.g. `@storykit:example.com` - /// Only required when `transport = "matrix"`. - #[serde(default)] - pub username: Option, - /// Bot password - /// Only required when `transport = "matrix"`. - #[serde(default)] - pub password: Option, - /// 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, - /// Display name the bot uses to identify itself in conversations. - /// 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, - /// Chat transport to use: `"matrix"` (default), `"whatsapp"`, `"slack"`, - /// or `"discord"`. - /// - /// Selects which [`ChatTransport`] implementation the bot uses for - /// sending and editing messages. Currently only read during bot - /// startup to select the transport; the field is kept for config - /// round-tripping. - #[serde(default = "default_transport")] - pub transport: String, - - // ── WhatsApp Business API fields ───────────────────────────────── - // These are only required when `transport = "whatsapp"`. - /// WhatsApp Business phone number ID from the Meta dashboard. - #[serde(default)] - pub whatsapp_phone_number_id: Option, - /// Long-lived access token for the WhatsApp Business API. - #[serde(default)] - pub whatsapp_access_token: Option, - /// Verify token used in the webhook handshake (you choose this value - /// and configure it in the Meta webhook settings). - #[serde(default)] - pub whatsapp_verify_token: Option, - /// Name of the approved Meta message template used for pipeline - /// notifications when the 24-hour messaging window has expired. - /// - /// The template must be registered in the Meta Business Manager before - /// use. Defaults to `"pipeline_notification"`. - #[serde(default)] - pub whatsapp_notification_template: Option, - /// Which WhatsApp provider to use: `"meta"` (default, direct Graph API) - /// or `"twilio"` (Twilio REST API as alternative to Meta). - /// - /// When `"twilio"`, the Twilio-specific fields below are required instead - /// of the Meta `whatsapp_phone_number_id` / `whatsapp_access_token` pair. - #[serde(default = "default_whatsapp_provider")] - pub whatsapp_provider: String, - - // ── Twilio WhatsApp fields ───────────────────────────────────────── - // Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`. - /// Twilio Account SID (starts with `AC`). - #[serde(default)] - pub twilio_account_sid: Option, - /// Twilio Auth Token. - #[serde(default)] - pub twilio_auth_token: Option, - /// Twilio WhatsApp sender number in E.164 format, e.g. `+14155551234`. - #[serde(default)] - pub twilio_whatsapp_number: Option, - - /// Phone numbers allowed to interact with the bot when using WhatsApp. - /// When non-empty, only listed numbers can send commands; all others are - /// silently ignored. When empty or absent, all numbers are allowed - /// (backwards compatible — open by default, unlike Matrix which is - /// fail-closed). - #[serde(default)] - pub whatsapp_allowed_phones: Vec, - - // ── Slack Bot API fields ───────────────────────────────────────── - // These are only required when `transport = "slack"`. - /// Slack Bot User OAuth Token (starts with `xoxb-`). - #[serde(default)] - pub slack_bot_token: Option, - /// Slack Signing Secret used to verify incoming webhook requests. - #[serde(default)] - pub slack_signing_secret: Option, - /// Slack channel IDs the bot should listen in. - #[serde(default)] - pub slack_channel_ids: Vec, - - // ── Discord Bot API fields ────────────────────────────────────── - // These are only required when `transport = "discord"`. - /// Discord bot token from the Discord Developer Portal. - #[serde(default)] - pub discord_bot_token: Option, - /// Discord channel IDs the bot should listen in. - #[serde(default)] - pub discord_channel_ids: Vec, - /// Discord user IDs allowed to interact with the bot. - /// When empty or absent, all users in configured channels are allowed. - #[serde(default)] - pub discord_allowed_users: Vec, - - /// How often (in seconds) the gateway polls each project server's - /// `/api/events` endpoint to aggregate cross-project notifications. - /// - /// Only used when the gateway's bot is enabled. Defaults to 5 seconds. - #[serde(default = "default_aggregated_notifications_poll_interval_secs")] - pub aggregated_notifications_poll_interval_secs: u64, - - /// Whether the gateway-level aggregated cross-project notification stream - /// is enabled. When `false`, the gateway will not poll per-project - /// servers for events even if the bot is otherwise enabled. - /// - /// Set this in the **gateway's** `bot.toml` (not in per-project configs). - /// Adding a new project to `projects.toml` never requires touching - /// per-project bot configs — the aggregated stream picks it up - /// automatically once this flag is `true` (the default). - /// - /// Defaults to `true`. - #[serde(default = "default_aggregated_notifications_enabled")] - pub aggregated_notifications_enabled: bool, -} - -fn default_transport() -> String { - "matrix".to_string() -} - -fn default_whatsapp_provider() -> String { - "meta".to_string() -} mod loading; +mod types; + +#[cfg(test)] +mod tests_core; +#[cfg(test)] +mod tests_transports; pub use loading::save_ambient_rooms; - -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(".huskies"); - 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(".huskies"); - 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.as_deref(), - Some("https://matrix.example.com") - ); - assert_eq!(config.username.as_deref(), Some("@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(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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_reads_display_name() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -display_name = "Timmy" -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert_eq!(config.display_name.as_deref(), Some("Timmy")); - } - - #[test] - fn load_display_name_defaults_to_none_when_absent() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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.display_name.is_none()); - } - - #[test] - fn load_uses_default_permission_timeout() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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" - ); - } - - #[test] - fn aggregated_notifications_enabled_defaults_to_true() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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.aggregated_notifications_enabled); - } - - #[test] - fn aggregated_notifications_enabled_can_be_set_to_false() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -aggregated_notifications_enabled = false -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert!(!config.aggregated_notifications_enabled); - } - - #[test] - fn load_reads_ambient_rooms() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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(".huskies"); - 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()); - } - - #[test] - fn load_transport_defaults_to_matrix() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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.transport, "matrix"); - } - - #[test] - fn load_transport_reads_custom_value() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_phone_number_id = "123456" -whatsapp_access_token = "EAAtoken" -whatsapp_verify_token = "my-verify" -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert_eq!(config.transport, "whatsapp"); - assert_eq!(config.whatsapp_phone_number_id.as_deref(), Some("123456")); - assert_eq!(config.whatsapp_access_token.as_deref(), Some("EAAtoken")); - assert_eq!(config.whatsapp_verify_token.as_deref(), Some("my-verify")); - } - - #[test] - fn load_whatsapp_returns_none_when_missing_phone_number_id() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_access_token = "EAAtoken" -whatsapp_verify_token = "my-verify" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_whatsapp_returns_none_when_missing_access_token() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_phone_number_id = "123456" -whatsapp_verify_token = "my-verify" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_whatsapp_returns_none_when_missing_verify_token() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_phone_number_id = "123456" -whatsapp_access_token = "EAAtoken" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - // ── Twilio config tests ───────────────────────────────────────────── - - #[test] - fn load_twilio_whatsapp_reads_config() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_provider = "twilio" -twilio_account_sid = "ACtest" -twilio_auth_token = "authtest" -twilio_whatsapp_number = "+14155551234" -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert_eq!(config.transport, "whatsapp"); - assert_eq!(config.whatsapp_provider, "twilio"); - assert_eq!(config.twilio_account_sid.as_deref(), Some("ACtest")); - assert_eq!(config.twilio_auth_token.as_deref(), Some("authtest")); - assert_eq!( - config.twilio_whatsapp_number.as_deref(), - Some("+14155551234") - ); - } - - #[test] - fn load_whatsapp_provider_defaults_to_meta() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_phone_number_id = "123456" -whatsapp_access_token = "EAAtoken" -whatsapp_verify_token = "my-verify" -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert_eq!(config.whatsapp_provider, "meta"); - } - - #[test] - fn load_twilio_returns_none_when_missing_account_sid() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_provider = "twilio" -twilio_auth_token = "authtest" -twilio_whatsapp_number = "+14155551234" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_twilio_returns_none_when_missing_auth_token() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_provider = "twilio" -twilio_account_sid = "ACtest" -twilio_whatsapp_number = "+14155551234" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_twilio_returns_none_when_missing_whatsapp_number() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "whatsapp" -whatsapp_provider = "twilio" -twilio_account_sid = "ACtest" -twilio_auth_token = "authtest" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - // ── Slack config tests ───────────────────────────────────────────── - - #[test] - fn load_slack_transport_reads_config() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "slack" -slack_bot_token = "xoxb-123" -slack_signing_secret = "secret123" -slack_channel_ids = ["C01ABCDEF"] -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert_eq!(config.transport, "slack"); - assert_eq!(config.slack_bot_token.as_deref(), Some("xoxb-123")); - assert_eq!(config.slack_signing_secret.as_deref(), Some("secret123")); - assert_eq!(config.slack_channel_ids, vec!["C01ABCDEF"]); - } - - #[test] - fn load_slack_returns_none_when_missing_bot_token() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "slack" -slack_signing_secret = "secret123" -slack_channel_ids = ["C01ABCDEF"] -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_slack_returns_none_when_missing_signing_secret() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "slack" -slack_bot_token = "xoxb-123" -slack_channel_ids = ["C01ABCDEF"] -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_slack_returns_none_when_missing_channel_ids() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - 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 -transport = "slack" -slack_bot_token = "xoxb-123" -slack_signing_secret = "secret123" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn whatsapp_allowed_phones_defaults_to_empty_when_absent() { - let config: BotConfig = toml::from_str( - r#" -enabled = true -transport = "whatsapp" -whatsapp_provider = "meta" -whatsapp_phone_number_id = "123" -whatsapp_access_token = "tok" -whatsapp_verify_token = "ver" -"#, - ) - .unwrap(); - assert!(config.whatsapp_allowed_phones.is_empty()); - } - - #[test] - fn whatsapp_allowed_phones_deserializes_list() { - let config: BotConfig = toml::from_str( - r#" -enabled = true -transport = "whatsapp" -whatsapp_provider = "meta" -whatsapp_phone_number_id = "123" -whatsapp_access_token = "tok" -whatsapp_verify_token = "ver" -whatsapp_allowed_phones = ["+15551234567", "+15559876543"] -"#, - ) - .unwrap(); - assert_eq!( - config.whatsapp_allowed_phones, - vec!["+15551234567", "+15559876543"] - ); - } - - // ── Discord config tests ────────────────────────────────────────── - - #[test] - fn load_discord_transport_reads_config() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - fs::write( - sk.join("bot.toml"), - r#" -enabled = true -transport = "discord" -discord_bot_token = "Bot.Token.Here" -discord_channel_ids = ["123456789012345678"] -"#, - ) - .unwrap(); - let config = BotConfig::load(tmp.path()).unwrap(); - assert_eq!(config.transport, "discord"); - assert_eq!(config.discord_bot_token.as_deref(), Some("Bot.Token.Here")); - assert_eq!(config.discord_channel_ids, vec!["123456789012345678"]); - } - - #[test] - fn load_discord_returns_none_when_missing_bot_token() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - fs::write( - sk.join("bot.toml"), - r#" -enabled = true -transport = "discord" -discord_channel_ids = ["123456789012345678"] -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn load_discord_returns_none_when_missing_channel_ids() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - fs::write( - sk.join("bot.toml"), - r#" -enabled = true -transport = "discord" -discord_bot_token = "Bot.Token.Here" -"#, - ) - .unwrap(); - assert!(BotConfig::load(tmp.path()).is_none()); - } - - #[test] - fn discord_allowed_users_defaults_to_empty_when_absent() { - let config: BotConfig = toml::from_str( - r#" -enabled = true -transport = "discord" -discord_bot_token = "Bot.Token.Here" -discord_channel_ids = ["123456789"] -"#, - ) - .unwrap(); - assert!(config.discord_allowed_users.is_empty()); - } - - #[test] - fn discord_allowed_users_deserializes_list() { - let config: BotConfig = toml::from_str( - r#" -enabled = true -transport = "discord" -discord_bot_token = "Bot.Token.Here" -discord_channel_ids = ["123456789"] -discord_allowed_users = ["111222333", "444555666"] -"#, - ) - .unwrap(); - assert_eq!(config.discord_allowed_users, vec!["111222333", "444555666"]); - } -} +pub use types::BotConfig; diff --git a/server/src/chat/transport/matrix/config/tests_core.rs b/server/src/chat/transport/matrix/config/tests_core.rs new file mode 100644 index 00000000..068fc137 --- /dev/null +++ b/server/src/chat/transport/matrix/config/tests_core.rs @@ -0,0 +1,430 @@ +//! Tests for core `BotConfig` loading: matrix transport, ambient rooms, history, timeouts. +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(".huskies"); + 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(".huskies"); + 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.as_deref(), + Some("https://matrix.example.com") + ); + assert_eq!(config.username.as_deref(), Some("@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(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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_reads_display_name() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +display_name = "Timmy" +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.display_name.as_deref(), Some("Timmy")); +} + +#[test] +fn load_display_name_defaults_to_none_when_absent() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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.display_name.is_none()); +} + +#[test] +fn load_uses_default_permission_timeout() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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" + ); +} + +#[test] +fn aggregated_notifications_enabled_defaults_to_true() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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.aggregated_notifications_enabled); +} + +#[test] +fn aggregated_notifications_enabled_can_be_set_to_false() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +aggregated_notifications_enabled = false +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert!(!config.aggregated_notifications_enabled); +} + +#[test] +fn load_reads_ambient_rooms() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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(".huskies"); + 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()); +} + +#[test] +fn load_transport_defaults_to_matrix() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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.transport, "matrix"); +} diff --git a/server/src/chat/transport/matrix/config/tests_transports.rs b/server/src/chat/transport/matrix/config/tests_transports.rs new file mode 100644 index 00000000..306dbc83 --- /dev/null +++ b/server/src/chat/transport/matrix/config/tests_transports.rs @@ -0,0 +1,428 @@ +//! Tests for transport-specific `BotConfig` loading: WhatsApp, Twilio, Slack, Discord. +use super::*; +use std::fs; + +#[test] +fn load_transport_reads_custom_value() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_phone_number_id = "123456" +whatsapp_access_token = "EAAtoken" +whatsapp_verify_token = "my-verify" +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.transport, "whatsapp"); + assert_eq!(config.whatsapp_phone_number_id.as_deref(), Some("123456")); + assert_eq!(config.whatsapp_access_token.as_deref(), Some("EAAtoken")); + assert_eq!(config.whatsapp_verify_token.as_deref(), Some("my-verify")); +} + +#[test] +fn load_whatsapp_returns_none_when_missing_phone_number_id() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_access_token = "EAAtoken" +whatsapp_verify_token = "my-verify" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_whatsapp_returns_none_when_missing_access_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_phone_number_id = "123456" +whatsapp_verify_token = "my-verify" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_whatsapp_returns_none_when_missing_verify_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_phone_number_id = "123456" +whatsapp_access_token = "EAAtoken" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +// ── Twilio config tests ───────────────────────────────────────────── + +#[test] +fn load_twilio_whatsapp_reads_config() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_account_sid = "ACtest" +twilio_auth_token = "authtest" +twilio_whatsapp_number = "+14155551234" +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.transport, "whatsapp"); + assert_eq!(config.whatsapp_provider, "twilio"); + assert_eq!(config.twilio_account_sid.as_deref(), Some("ACtest")); + assert_eq!(config.twilio_auth_token.as_deref(), Some("authtest")); + assert_eq!( + config.twilio_whatsapp_number.as_deref(), + Some("+14155551234") + ); +} + +#[test] +fn load_whatsapp_provider_defaults_to_meta() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_phone_number_id = "123456" +whatsapp_access_token = "EAAtoken" +whatsapp_verify_token = "my-verify" +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.whatsapp_provider, "meta"); +} + +#[test] +fn load_twilio_returns_none_when_missing_account_sid() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_auth_token = "authtest" +twilio_whatsapp_number = "+14155551234" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_twilio_returns_none_when_missing_auth_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_account_sid = "ACtest" +twilio_whatsapp_number = "+14155551234" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_twilio_returns_none_when_missing_whatsapp_number() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_account_sid = "ACtest" +twilio_auth_token = "authtest" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +// ── Slack config tests ───────────────────────────────────────────── + +#[test] +fn load_slack_transport_reads_config() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "slack" +slack_bot_token = "xoxb-123" +slack_signing_secret = "secret123" +slack_channel_ids = ["C01ABCDEF"] +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.transport, "slack"); + assert_eq!(config.slack_bot_token.as_deref(), Some("xoxb-123")); + assert_eq!(config.slack_signing_secret.as_deref(), Some("secret123")); + assert_eq!(config.slack_channel_ids, vec!["C01ABCDEF"]); +} + +#[test] +fn load_slack_returns_none_when_missing_bot_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "slack" +slack_signing_secret = "secret123" +slack_channel_ids = ["C01ABCDEF"] +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_slack_returns_none_when_missing_signing_secret() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "slack" +slack_bot_token = "xoxb-123" +slack_channel_ids = ["C01ABCDEF"] +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_slack_returns_none_when_missing_channel_ids() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + 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 +transport = "slack" +slack_bot_token = "xoxb-123" +slack_signing_secret = "secret123" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn whatsapp_allowed_phones_defaults_to_empty_when_absent() { + let config: BotConfig = toml::from_str( + r#" +enabled = true +transport = "whatsapp" +whatsapp_provider = "meta" +whatsapp_phone_number_id = "123" +whatsapp_access_token = "tok" +whatsapp_verify_token = "ver" +"#, + ) + .unwrap(); + assert!(config.whatsapp_allowed_phones.is_empty()); +} + +#[test] +fn whatsapp_allowed_phones_deserializes_list() { + let config: BotConfig = toml::from_str( + r#" +enabled = true +transport = "whatsapp" +whatsapp_provider = "meta" +whatsapp_phone_number_id = "123" +whatsapp_access_token = "tok" +whatsapp_verify_token = "ver" +whatsapp_allowed_phones = ["+15551234567", "+15559876543"] +"#, + ) + .unwrap(); + assert_eq!( + config.whatsapp_allowed_phones, + vec!["+15551234567", "+15559876543"] + ); +} + +// ── Discord config tests ────────────────────────────────────────── + +#[test] +fn load_discord_transport_reads_config() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +enabled = true +transport = "discord" +discord_bot_token = "Bot.Token.Here" +discord_channel_ids = ["123456789012345678"] +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.transport, "discord"); + assert_eq!(config.discord_bot_token.as_deref(), Some("Bot.Token.Here")); + assert_eq!(config.discord_channel_ids, vec!["123456789012345678"]); +} + +#[test] +fn load_discord_returns_none_when_missing_bot_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +enabled = true +transport = "discord" +discord_channel_ids = ["123456789012345678"] +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn load_discord_returns_none_when_missing_channel_ids() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +enabled = true +transport = "discord" +discord_bot_token = "Bot.Token.Here" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); +} + +#[test] +fn discord_allowed_users_defaults_to_empty_when_absent() { + let config: BotConfig = toml::from_str( + r#" +enabled = true +transport = "discord" +discord_bot_token = "Bot.Token.Here" +discord_channel_ids = ["123456789"] +"#, + ) + .unwrap(); + assert!(config.discord_allowed_users.is_empty()); +} + +#[test] +fn discord_allowed_users_deserializes_list() { + let config: BotConfig = toml::from_str( + r#" +enabled = true +transport = "discord" +discord_bot_token = "Bot.Token.Here" +discord_channel_ids = ["123456789"] +discord_allowed_users = ["111222333", "444555666"] +"#, + ) + .unwrap(); + assert_eq!(config.discord_allowed_users, vec!["111222333", "444555666"]); +} diff --git a/server/src/chat/transport/matrix/config/types.rs b/server/src/chat/transport/matrix/config/types.rs new file mode 100644 index 00000000..cdf896f1 --- /dev/null +++ b/server/src/chat/transport/matrix/config/types.rs @@ -0,0 +1,184 @@ +//! `BotConfig` struct and serde default functions for `bot.toml` deserialization. +use serde::Deserialize; + +pub(super) fn default_history_size() -> usize { + 20 +} + +pub(super) fn default_permission_timeout_secs() -> u64 { + 120 +} + +pub(super) fn default_aggregated_notifications_poll_interval_secs() -> u64 { + 5 +} + +pub(super) fn default_aggregated_notifications_enabled() -> bool { + true +} + +pub(super) fn default_transport() -> String { + "matrix".to_string() +} + +pub(super) fn default_whatsapp_provider() -> String { + "meta".to_string() +} + +/// Configuration for the Matrix bot, read from `.huskies/bot.toml`. +#[derive(Deserialize, Clone, Debug)] +pub struct BotConfig { + /// Matrix homeserver URL, e.g. `https://matrix.example.com` + /// Only required when `transport = "matrix"` (the default). + #[serde(default)] + pub homeserver: Option, + /// Bot user ID, e.g. `@storykit:example.com` + /// Only required when `transport = "matrix"`. + #[serde(default)] + pub username: Option, + /// Bot password + /// Only required when `transport = "matrix"`. + #[serde(default)] + pub password: Option, + /// 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, + /// Display name the bot uses to identify itself in conversations. + /// 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, + /// Chat transport to use: `"matrix"` (default), `"whatsapp"`, `"slack"`, + /// or `"discord"`. + /// + /// Selects which [`ChatTransport`] implementation the bot uses for + /// sending and editing messages. Currently only read during bot + /// startup to select the transport; the field is kept for config + /// round-tripping. + #[serde(default = "default_transport")] + pub transport: String, + + // ── WhatsApp Business API fields ───────────────────────────────── + // These are only required when `transport = "whatsapp"`. + /// WhatsApp Business phone number ID from the Meta dashboard. + #[serde(default)] + pub whatsapp_phone_number_id: Option, + /// Long-lived access token for the WhatsApp Business API. + #[serde(default)] + pub whatsapp_access_token: Option, + /// Verify token used in the webhook handshake (you choose this value + /// and configure it in the Meta webhook settings). + #[serde(default)] + pub whatsapp_verify_token: Option, + /// Name of the approved Meta message template used for pipeline + /// notifications when the 24-hour messaging window has expired. + /// + /// The template must be registered in the Meta Business Manager before + /// use. Defaults to `"pipeline_notification"`. + #[serde(default)] + pub whatsapp_notification_template: Option, + /// Which WhatsApp provider to use: `"meta"` (default, direct Graph API) + /// or `"twilio"` (Twilio REST API as alternative to Meta). + /// + /// When `"twilio"`, the Twilio-specific fields below are required instead + /// of the Meta `whatsapp_phone_number_id` / `whatsapp_access_token` pair. + #[serde(default = "default_whatsapp_provider")] + pub whatsapp_provider: String, + + // ── Twilio WhatsApp fields ───────────────────────────────────────── + // Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`. + /// Twilio Account SID (starts with `AC`). + #[serde(default)] + pub twilio_account_sid: Option, + /// Twilio Auth Token. + #[serde(default)] + pub twilio_auth_token: Option, + /// Twilio WhatsApp sender number in E.164 format, e.g. `+14155551234`. + #[serde(default)] + pub twilio_whatsapp_number: Option, + + /// Phone numbers allowed to interact with the bot when using WhatsApp. + /// When non-empty, only listed numbers can send commands; all others are + /// silently ignored. When empty or absent, all numbers are allowed + /// (backwards compatible — open by default, unlike Matrix which is + /// fail-closed). + #[serde(default)] + pub whatsapp_allowed_phones: Vec, + + // ── Slack Bot API fields ───────────────────────────────────────── + // These are only required when `transport = "slack"`. + /// Slack Bot User OAuth Token (starts with `xoxb-`). + #[serde(default)] + pub slack_bot_token: Option, + /// Slack Signing Secret used to verify incoming webhook requests. + #[serde(default)] + pub slack_signing_secret: Option, + /// Slack channel IDs the bot should listen in. + #[serde(default)] + pub slack_channel_ids: Vec, + + // ── Discord Bot API fields ────────────────────────────────────── + // These are only required when `transport = "discord"`. + /// Discord bot token from the Discord Developer Portal. + #[serde(default)] + pub discord_bot_token: Option, + /// Discord channel IDs the bot should listen in. + #[serde(default)] + pub discord_channel_ids: Vec, + /// Discord user IDs allowed to interact with the bot. + /// When empty or absent, all users in configured channels are allowed. + #[serde(default)] + pub discord_allowed_users: Vec, + + /// How often (in seconds) the gateway polls each project server's + /// `/api/events` endpoint to aggregate cross-project notifications. + /// + /// Only used when the gateway's bot is enabled. Defaults to 5 seconds. + #[serde(default = "default_aggregated_notifications_poll_interval_secs")] + pub aggregated_notifications_poll_interval_secs: u64, + + /// Whether the gateway-level aggregated cross-project notification stream + /// is enabled. When `false`, the gateway will not poll per-project + /// servers for events even if the bot is otherwise enabled. + /// + /// Set this in the **gateway's** `bot.toml` (not in per-project configs). + /// Adding a new project to `projects.toml` never requires touching + /// per-project bot configs — the aggregated stream picks it up + /// automatically once this flag is `true` (the default). + /// + /// Defaults to `true`. + #[serde(default = "default_aggregated_notifications_enabled")] + pub aggregated_notifications_enabled: bool, +}