From 5cdf1b74f33cd6a5e0790974a2d07fa384f68566 Mon Sep 17 00:00:00 2001 From: harald Date: Wed, 25 Feb 2026 17:01:28 +0100 Subject: [PATCH] feat(tools): refactor pushover into conditional notify tool with Telegram fallback Replace the always-registered PushoverTool with a NotifyTool that auto-selects its backend at startup: Pushover if .env credentials exist, otherwise Telegram (using bot_token + first allowed_users entry as chat_id). If neither backend is available, the tool is not registered, saving a tool slot and avoiding agent confusion. Co-Authored-By: Claude Opus 4.6 --- src/channels/mod.rs | 11 +- src/config/schema.rs | 2 +- src/tools/mod.rs | 22 +- src/tools/notify.rs | 616 ++++++++++++++++++++++++++++++++++++++++++ src/tools/pushover.rs | 433 ----------------------------- 5 files changed, 638 insertions(+), 446 deletions(-) create mode 100644 src/tools/notify.rs delete mode 100644 src/tools/pushover.rs diff --git a/src/channels/mod.rs b/src/channels/mod.rs index bf27247..6e8f2f8 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1725,10 +1725,13 @@ pub async fn start_channels(config: Config) -> Result<()> { "schedule", "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.", )); - tool_descs.push(( - "pushover", - "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.", - )); + // notify tool is conditionally registered (Pushover or Telegram fallback) + if tools_registry.iter().any(|t| t.name() == "notify") { + tool_descs.push(( + "notify", + "Send a push notification (via Pushover or Telegram depending on configuration).", + )); + } if !config.agents.is_empty() { tool_descs.push(( "delegate", diff --git a/src/config/schema.rs b/src/config/schema.rs index 3b0141a..6bd6b0c 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -31,7 +31,7 @@ const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[ "tool.browser", "tool.composio", "tool.http_request", - "tool.pushover", + "tool.notify", "memory.embeddings", "tunnel.custom", ]; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index a472afc..ad5c659 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -20,7 +20,7 @@ pub mod memory_forget; pub mod memory_recall; pub mod memory_store; pub mod proxy_config; -pub mod pushover; +pub mod notify; pub mod schedule; pub mod schema; pub mod screenshot; @@ -50,7 +50,7 @@ pub use memory_forget::MemoryForgetTool; pub use memory_recall::MemoryRecallTool; pub use memory_store::MemoryStoreTool; pub use proxy_config::ProxyConfigTool; -pub use pushover::PushoverTool; +pub use notify::NotifyTool; pub use schedule::ScheduleTool; #[allow(unused_imports)] pub use schema::{CleaningStrategy, SchemaCleanr}; @@ -151,12 +151,16 @@ pub fn all_tools_with_runtime( security.clone(), workspace_dir.to_path_buf(), )), - Box::new(PushoverTool::new( - security.clone(), - workspace_dir.to_path_buf(), - )), ]; + if let Some(notify_tool) = NotifyTool::detect( + security.clone(), + workspace_dir, + root_config.channels_config.telegram.as_ref(), + ) { + tools.push(Box::new(notify_tool)); + } + if browser_config.enabled { // Add legacy browser_open tool for simple URL opening tools.push(Box::new(BrowserOpenTool::new( @@ -294,7 +298,8 @@ mod tests { let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(!names.contains(&"browser_open")); assert!(names.contains(&"schedule")); - assert!(names.contains(&"pushover")); + // notify tool is conditionally registered — not present without credentials + assert!(!names.contains(&"notify")); assert!(names.contains(&"proxy_config")); } @@ -333,7 +338,8 @@ mod tests { ); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(names.contains(&"browser_open")); - assert!(names.contains(&"pushover")); + // notify tool is conditionally registered — not present without credentials + assert!(!names.contains(&"notify")); assert!(names.contains(&"proxy_config")); } diff --git a/src/tools/notify.rs b/src/tools/notify.rs new file mode 100644 index 0000000..799851e --- /dev/null +++ b/src/tools/notify.rs @@ -0,0 +1,616 @@ +use super::traits::{Tool, ToolResult}; +use crate::config::TelegramConfig; +use crate::security::SecurityPolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json"; +const NOTIFY_REQUEST_TIMEOUT_SECS: u64 = 15; + +enum NotifyBackend { + Pushover { token: String, user_key: String }, + Telegram { bot_token: String, chat_id: String }, +} + +pub struct NotifyTool { + security: Arc, + backend: NotifyBackend, +} + +impl NotifyTool { + /// Detect the best available notification backend. + /// + /// Checks Pushover credentials first (from `.env`), then Telegram config. + /// Returns `None` if neither backend is available. + pub fn detect( + security: Arc, + workspace_dir: &std::path::Path, + telegram_config: Option<&TelegramConfig>, + ) -> Option { + // Try Pushover first + if let Some((token, user_key)) = Self::read_pushover_credentials(workspace_dir) { + return Some(Self { + security, + backend: NotifyBackend::Pushover { token, user_key }, + }); + } + + // Fall back to Telegram + if let Some(tg) = telegram_config { + if let Some(chat_id) = tg.allowed_users.first() { + if !tg.bot_token.is_empty() && !chat_id.is_empty() { + return Some(Self { + security, + backend: NotifyBackend::Telegram { + bot_token: tg.bot_token.clone(), + chat_id: chat_id.clone(), + }, + }); + } + } + } + + None + } + + fn parse_env_value(raw: &str) -> String { + let raw = raw.trim(); + + let unquoted = if raw.len() >= 2 + && ((raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\''))) + { + &raw[1..raw.len() - 1] + } else { + raw + }; + + // Keep support for inline comments in unquoted values: + // KEY=value # comment + unquoted.split_once(" #").map_or_else( + || unquoted.trim().to_string(), + |(value, _)| value.trim().to_string(), + ) + } + + fn read_pushover_credentials(workspace_dir: &std::path::Path) -> Option<(String, String)> { + let env_path = workspace_dir.join(".env"); + let content = std::fs::read_to_string(&env_path).ok()?; + + let mut token = None; + let mut user_key = None; + + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') || line.is_empty() { + continue; + } + let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line); + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = Self::parse_env_value(value); + + if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") { + token = Some(value); + } else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") { + user_key = Some(value); + } + } + } + + Some((token?, user_key?)) + } + + fn backend_label(&self) -> &str { + match &self.backend { + NotifyBackend::Pushover { .. } => "Pushover", + NotifyBackend::Telegram { .. } => "Telegram", + } + } + + async fn send_pushover( + token: &str, + user_key: &str, + message: &str, + title: Option<&str>, + priority: Option, + sound: Option<&str>, + ) -> anyhow::Result { + let mut form = reqwest::multipart::Form::new() + .text("token", token.to_owned()) + .text("user", user_key.to_owned()) + .text("message", message.to_owned()); + + if let Some(title) = title { + form = form.text("title", title.to_owned()); + } + if let Some(priority) = priority { + form = form.text("priority", priority.to_string()); + } + if let Some(sound) = sound { + form = form.text("sound", sound.to_owned()); + } + + let client = crate::config::build_runtime_proxy_client_with_timeouts( + "tool.notify", + NOTIFY_REQUEST_TIMEOUT_SECS, + 10, + ); + let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() { + return Ok(ToolResult { + success: false, + output: body, + error: Some(format!("Pushover API returned status {}", status)), + }); + } + + let api_status = serde_json::from_str::(&body) + .ok() + .and_then(|json| json.get("status").and_then(|value| value.as_i64())); + + if api_status == Some(1) { + Ok(ToolResult { + success: true, + output: format!("Notification sent via Pushover. Response: {}", body), + error: None, + }) + } else { + Ok(ToolResult { + success: false, + output: body, + error: Some("Pushover API returned an application-level error".into()), + }) + } + } + + async fn send_telegram( + bot_token: &str, + chat_id: &str, + message: &str, + title: Option<&str>, + ) -> anyhow::Result { + let text = match title { + Some(t) if !t.is_empty() => format!("*{}*\n{}", t, message), + _ => message.to_owned(), + }; + + let url = format!( + "https://api.telegram.org/bot{}/sendMessage", + bot_token + ); + + let client = crate::config::build_runtime_proxy_client_with_timeouts( + "tool.notify", + NOTIFY_REQUEST_TIMEOUT_SECS, + 10, + ); + let response = client + .post(&url) + .json(&json!({ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + })) + .send() + .await?; + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() { + return Ok(ToolResult { + success: false, + output: body, + error: Some(format!("Telegram API returned status {}", status)), + }); + } + + let ok = serde_json::from_str::(&body) + .ok() + .and_then(|json| json.get("ok").and_then(|v| v.as_bool())); + + if ok == Some(true) { + Ok(ToolResult { + success: true, + output: format!("Notification sent via Telegram. Response: {}", body), + error: None, + }) + } else { + Ok(ToolResult { + success: false, + output: body, + error: Some("Telegram API returned an application-level error".into()), + }) + } + } +} + +#[async_trait] +impl Tool for NotifyTool { + fn name(&self) -> &str { + "notify" + } + + fn description(&self) -> &str { + match &self.backend { + NotifyBackend::Pushover { .. } => { + "Send a push notification to your device via Pushover. Supports title, priority, and sound options." + } + NotifyBackend::Telegram { .. } => { + "Send a notification message via Telegram. Supports optional title (priority/sound are ignored)." + } + } + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The notification message to send" + }, + "title": { + "type": "string", + "description": "Optional notification title" + }, + "priority": { + "type": "integer", + "enum": [-2, -1, 0, 1, 2], + "description": "Message priority (Pushover only): -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)" + }, + "sound": { + "type": "string", + "description": "Notification sound override (Pushover only, e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)" + } + }, + "required": ["message"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + if !self.security.can_act() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Action blocked: autonomy is read-only".into()), + }); + } + + if !self.security.record_action() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Action blocked: rate limit exceeded".into()), + }); + } + + let message = args + .get("message") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))? + .to_string(); + + let title = args.get("title").and_then(|v| v.as_str()).map(String::from); + + let priority = match args.get("priority").and_then(|v| v.as_i64()) { + Some(value) if (-2..=2).contains(&value) => Some(value), + Some(value) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Invalid 'priority': {value}. Expected integer in range -2..=2" + )), + }) + } + None => None, + }; + + let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from); + + match &self.backend { + NotifyBackend::Pushover { token, user_key } => { + Self::send_pushover( + token, + user_key, + &message, + title.as_deref(), + priority, + sound.as_deref(), + ) + .await + } + NotifyBackend::Telegram { bot_token, chat_id } => { + Self::send_telegram(bot_token, chat_id, &message, title.as_deref()).await + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::AutonomyLevel; + use std::fs; + use tempfile::TempDir; + + fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc { + Arc::new(SecurityPolicy { + autonomy: level, + max_actions_per_hour, + workspace_dir: std::env::temp_dir(), + ..SecurityPolicy::default() + }) + } + + fn make_pushover_tool(security: Arc) -> NotifyTool { + NotifyTool { + security, + backend: NotifyBackend::Pushover { + token: "test_token".into(), + user_key: "test_user".into(), + }, + } + } + + fn make_telegram_tool(security: Arc) -> NotifyTool { + NotifyTool { + security, + backend: NotifyBackend::Telegram { + bot_token: "123:ABC".into(), + chat_id: "456".into(), + }, + } + } + + #[test] + fn notify_tool_name() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + assert_eq!(tool.name(), "notify"); + } + + #[test] + fn notify_tool_description_pushover() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + assert!(tool.description().contains("Pushover")); + } + + #[test] + fn notify_tool_description_telegram() { + let tool = make_telegram_tool(test_security(AutonomyLevel::Full, 100)); + assert!(tool.description().contains("Telegram")); + } + + #[test] + fn notify_tool_has_parameters_schema() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"].get("message").is_some()); + } + + #[test] + fn notify_tool_requires_message() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::Value::String("message".to_string()))); + } + + #[test] + fn credentials_parsed_from_env_file() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write( + &env_path, + "PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n", + ) + .unwrap(); + + let result = NotifyTool::read_pushover_credentials(tmp.path()); + assert!(result.is_some()); + let (token, user_key) = result.unwrap(); + assert_eq!(token, "testtoken123"); + assert_eq!(user_key, "userkey456"); + } + + #[test] + fn credentials_none_without_env_file() { + let tmp = TempDir::new().unwrap(); + let result = NotifyTool::read_pushover_credentials(tmp.path()); + assert!(result.is_none()); + } + + #[test] + fn credentials_none_without_token() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap(); + + let result = NotifyTool::read_pushover_credentials(tmp.path()); + assert!(result.is_none()); + } + + #[test] + fn credentials_none_without_user_key() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap(); + + let result = NotifyTool::read_pushover_credentials(tmp.path()); + assert!(result.is_none()); + } + + #[test] + fn credentials_ignore_comments() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap(); + + let result = NotifyTool::read_pushover_credentials(tmp.path()); + assert!(result.is_some()); + let (token, user_key) = result.unwrap(); + assert_eq!(token, "realtoken"); + assert_eq!(user_key, "realuser"); + } + + #[test] + fn notify_tool_supports_priority() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + let schema = tool.parameters_schema(); + assert!(schema["properties"].get("priority").is_some()); + } + + #[test] + fn notify_tool_supports_sound() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + let schema = tool.parameters_schema(); + assert!(schema["properties"].get("sound").is_some()); + } + + #[test] + fn credentials_support_export_and_quoted_values() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write( + &env_path, + "export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n", + ) + .unwrap(); + + let result = NotifyTool::read_pushover_credentials(tmp.path()); + assert!(result.is_some()); + let (token, user_key) = result.unwrap(); + assert_eq!(token, "quotedtoken"); + assert_eq!(user_key, "quoteduser"); + } + + #[tokio::test] + async fn execute_blocks_readonly_mode() { + let tool = make_pushover_tool(test_security(AutonomyLevel::ReadOnly, 100)); + + let result = tool.execute(json!({"message": "hello"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.unwrap().contains("read-only")); + } + + #[tokio::test] + async fn execute_blocks_rate_limit() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 0)); + + let result = tool.execute(json!({"message": "hello"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.unwrap().contains("rate limit")); + } + + #[tokio::test] + async fn execute_rejects_priority_out_of_range() { + let tool = make_pushover_tool(test_security(AutonomyLevel::Full, 100)); + + let result = tool + .execute(json!({"message": "hello", "priority": 5})) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.unwrap().contains("-2..=2")); + } + + #[test] + fn detect_returns_none_when_no_backend_available() { + let tmp = TempDir::new().unwrap(); + let security = test_security(AutonomyLevel::Full, 100); + + let result = NotifyTool::detect(security, tmp.path(), None); + assert!(result.is_none()); + } + + #[test] + fn detect_prefers_pushover_when_both_available() { + let tmp = TempDir::new().unwrap(); + let env_path = tmp.path().join(".env"); + fs::write( + &env_path, + "PUSHOVER_TOKEN=token\nPUSHOVER_USER_KEY=user\n", + ) + .unwrap(); + + let tg = TelegramConfig { + bot_token: "123:ABC".into(), + allowed_users: vec!["456".into()], + stream_mode: crate::config::StreamMode::Off, + draft_update_interval_ms: 1000, + mention_only: false, + }; + + let security = test_security(AutonomyLevel::Full, 100); + let tool = NotifyTool::detect(security, tmp.path(), Some(&tg)); + assert!(tool.is_some()); + assert_eq!(tool.unwrap().backend_label(), "Pushover"); + } + + #[test] + fn detect_falls_back_to_telegram_when_no_pushover_credentials() { + let tmp = TempDir::new().unwrap(); + let tg = TelegramConfig { + bot_token: "123:ABC".into(), + allowed_users: vec!["456".into()], + stream_mode: crate::config::StreamMode::Off, + draft_update_interval_ms: 1000, + mention_only: false, + }; + + let security = test_security(AutonomyLevel::Full, 100); + let tool = NotifyTool::detect(security, tmp.path(), Some(&tg)); + assert!(tool.is_some()); + assert_eq!(tool.unwrap().backend_label(), "Telegram"); + } + + #[test] + fn detect_returns_none_for_telegram_with_empty_allowed_users() { + let tmp = TempDir::new().unwrap(); + let tg = TelegramConfig { + bot_token: "123:ABC".into(), + allowed_users: vec![], + stream_mode: crate::config::StreamMode::Off, + draft_update_interval_ms: 1000, + mention_only: false, + }; + + let security = test_security(AutonomyLevel::Full, 100); + let result = NotifyTool::detect(security, tmp.path(), Some(&tg)); + assert!(result.is_none()); + } + + #[test] + fn telegram_backend_formats_message_with_title() { + // Verify the format logic used by send_telegram + let title = Some("Alert"); + let message = "Server is down"; + let text = match title { + Some(t) if !t.is_empty() => format!("*{}*\n{}", t, message), + _ => message.to_owned(), + }; + assert_eq!(text, "*Alert*\nServer is down"); + } + + #[test] + fn telegram_backend_formats_message_without_title() { + let title: Option<&str> = None; + let message = "Server is down"; + let text = match title { + Some(t) if !t.is_empty() => format!("*{}*\n{}", t, message), + _ => message.to_owned(), + }; + assert_eq!(text, "Server is down"); + } +} diff --git a/src/tools/pushover.rs b/src/tools/pushover.rs deleted file mode 100644 index 23d980b..0000000 --- a/src/tools/pushover.rs +++ /dev/null @@ -1,433 +0,0 @@ -use super::traits::{Tool, ToolResult}; -use crate::security::SecurityPolicy; -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use std::sync::Arc; - -const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json"; -const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15; - -pub struct PushoverTool { - security: Arc, - workspace_dir: PathBuf, -} - -impl PushoverTool { - pub fn new(security: Arc, workspace_dir: PathBuf) -> Self { - Self { - security, - workspace_dir, - } - } - - fn parse_env_value(raw: &str) -> String { - let raw = raw.trim(); - - let unquoted = if raw.len() >= 2 - && ((raw.starts_with('"') && raw.ends_with('"')) - || (raw.starts_with('\'') && raw.ends_with('\''))) - { - &raw[1..raw.len() - 1] - } else { - raw - }; - - // Keep support for inline comments in unquoted values: - // KEY=value # comment - unquoted.split_once(" #").map_or_else( - || unquoted.trim().to_string(), - |(value, _)| value.trim().to_string(), - ) - } - - fn get_credentials(&self) -> anyhow::Result<(String, String)> { - let env_path = self.workspace_dir.join(".env"); - let content = std::fs::read_to_string(&env_path) - .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", env_path.display(), e))?; - - let mut token = None; - let mut user_key = None; - - for line in content.lines() { - let line = line.trim(); - if line.starts_with('#') || line.is_empty() { - continue; - } - let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line); - if let Some((key, value)) = line.split_once('=') { - let key = key.trim(); - let value = Self::parse_env_value(value); - - if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") { - token = Some(value); - } else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") { - user_key = Some(value); - } - } - } - - let token = token.ok_or_else(|| anyhow::anyhow!("PUSHOVER_TOKEN not found in .env"))?; - let user_key = - user_key.ok_or_else(|| anyhow::anyhow!("PUSHOVER_USER_KEY not found in .env"))?; - - Ok((token, user_key)) - } -} - -#[async_trait] -impl Tool for PushoverTool { - fn name(&self) -> &str { - "pushover" - } - - fn description(&self) -> &str { - "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "The notification message to send" - }, - "title": { - "type": "string", - "description": "Optional notification title" - }, - "priority": { - "type": "integer", - "enum": [-2, -1, 0, 1, 2], - "description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)" - }, - "sound": { - "type": "string", - "description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)" - } - }, - "required": ["message"] - }) - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - if !self.security.can_act() { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some("Action blocked: autonomy is read-only".into()), - }); - } - - if !self.security.record_action() { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some("Action blocked: rate limit exceeded".into()), - }); - } - - let message = args - .get("message") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))? - .to_string(); - - let title = args.get("title").and_then(|v| v.as_str()).map(String::from); - - let priority = match args.get("priority").and_then(|v| v.as_i64()) { - Some(value) if (-2..=2).contains(&value) => Some(value), - Some(value) => { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some(format!( - "Invalid 'priority': {value}. Expected integer in range -2..=2" - )), - }) - } - None => None, - }; - - let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from); - - let (token, user_key) = self.get_credentials()?; - - let mut form = reqwest::multipart::Form::new() - .text("token", token) - .text("user", user_key) - .text("message", message); - - if let Some(title) = title { - form = form.text("title", title); - } - - if let Some(priority) = priority { - form = form.text("priority", priority.to_string()); - } - - if let Some(sound) = sound { - form = form.text("sound", sound); - } - - let client = crate::config::build_runtime_proxy_client_with_timeouts( - "tool.pushover", - PUSHOVER_REQUEST_TIMEOUT_SECS, - 10, - ); - let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?; - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - - if !status.is_success() { - return Ok(ToolResult { - success: false, - output: body, - error: Some(format!("Pushover API returned status {}", status)), - }); - } - - let api_status = serde_json::from_str::(&body) - .ok() - .and_then(|json| json.get("status").and_then(|value| value.as_i64())); - - if api_status == Some(1) { - Ok(ToolResult { - success: true, - output: format!( - "Pushover notification sent successfully. Response: {}", - body - ), - error: None, - }) - } else { - Ok(ToolResult { - success: false, - output: body, - error: Some("Pushover API returned an application-level error".into()), - }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::security::AutonomyLevel; - use std::fs; - use tempfile::TempDir; - - fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc { - Arc::new(SecurityPolicy { - autonomy: level, - max_actions_per_hour, - workspace_dir: std::env::temp_dir(), - ..SecurityPolicy::default() - }) - } - - #[test] - fn pushover_tool_name() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - assert_eq!(tool.name(), "pushover"); - } - - #[test] - fn pushover_tool_description() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - assert!(!tool.description().is_empty()); - } - - #[test] - fn pushover_tool_has_parameters_schema() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - assert_eq!(schema["type"], "object"); - assert!(schema["properties"].get("message").is_some()); - } - - #[test] - fn pushover_tool_requires_message() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&serde_json::Value::String("message".to_string()))); - } - - #[test] - fn credentials_parsed_from_env_file() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write( - &env_path, - "PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n", - ) - .unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials(); - - assert!(result.is_ok()); - let (token, user_key) = result.unwrap(); - assert_eq!(token, "testtoken123"); - assert_eq!(user_key, "userkey456"); - } - - #[test] - fn credentials_fail_without_env_file() { - let tmp = TempDir::new().unwrap(); - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials(); - - assert!(result.is_err()); - } - - #[test] - fn credentials_fail_without_token() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials(); - - assert!(result.is_err()); - } - - #[test] - fn credentials_fail_without_user_key() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials(); - - assert!(result.is_err()); - } - - #[test] - fn credentials_ignore_comments() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials(); - - assert!(result.is_ok()); - let (token, user_key) = result.unwrap(); - assert_eq!(token, "realtoken"); - assert_eq!(user_key, "realuser"); - } - - #[test] - fn pushover_tool_supports_priority() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - assert!(schema["properties"].get("priority").is_some()); - } - - #[test] - fn pushover_tool_supports_sound() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - assert!(schema["properties"].get("sound").is_some()); - } - - #[test] - fn credentials_support_export_and_quoted_values() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write( - &env_path, - "export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n", - ) - .unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials(); - - assert!(result.is_ok()); - let (token, user_key) = result.unwrap(); - assert_eq!(token, "quotedtoken"); - assert_eq!(user_key, "quoteduser"); - } - - #[tokio::test] - async fn execute_blocks_readonly_mode() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::ReadOnly, 100), - PathBuf::from("/tmp"), - ); - - let result = tool.execute(json!({"message": "hello"})).await.unwrap(); - assert!(!result.success); - assert!(result.error.unwrap().contains("read-only")); - } - - #[tokio::test] - async fn execute_blocks_rate_limit() { - let tool = PushoverTool::new(test_security(AutonomyLevel::Full, 0), PathBuf::from("/tmp")); - - let result = tool.execute(json!({"message": "hello"})).await.unwrap(); - assert!(!result.success); - assert!(result.error.unwrap().contains("rate limit")); - } - - #[tokio::test] - async fn execute_rejects_priority_out_of_range() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - - let result = tool - .execute(json!({"message": "hello", "priority": 5})) - .await - .unwrap(); - - assert!(!result.success); - assert!(result.error.unwrap().contains("-2..=2")); - } -}