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 <noreply@anthropic.com>
This commit is contained in:
harald 2026-02-25 17:01:28 +01:00
parent 6e8c799af5
commit 5cdf1b74f3
5 changed files with 638 additions and 446 deletions

View file

@ -1725,10 +1725,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
"schedule", "schedule",
"Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.", "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
)); ));
tool_descs.push(( // notify tool is conditionally registered (Pushover or Telegram fallback)
"pushover", if tools_registry.iter().any(|t| t.name() == "notify") {
"Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.", tool_descs.push((
)); "notify",
"Send a push notification (via Pushover or Telegram depending on configuration).",
));
}
if !config.agents.is_empty() { if !config.agents.is_empty() {
tool_descs.push(( tool_descs.push((
"delegate", "delegate",

View file

@ -31,7 +31,7 @@ const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
"tool.browser", "tool.browser",
"tool.composio", "tool.composio",
"tool.http_request", "tool.http_request",
"tool.pushover", "tool.notify",
"memory.embeddings", "memory.embeddings",
"tunnel.custom", "tunnel.custom",
]; ];

View file

@ -20,7 +20,7 @@ pub mod memory_forget;
pub mod memory_recall; pub mod memory_recall;
pub mod memory_store; pub mod memory_store;
pub mod proxy_config; pub mod proxy_config;
pub mod pushover; pub mod notify;
pub mod schedule; pub mod schedule;
pub mod schema; pub mod schema;
pub mod screenshot; pub mod screenshot;
@ -50,7 +50,7 @@ pub use memory_forget::MemoryForgetTool;
pub use memory_recall::MemoryRecallTool; pub use memory_recall::MemoryRecallTool;
pub use memory_store::MemoryStoreTool; pub use memory_store::MemoryStoreTool;
pub use proxy_config::ProxyConfigTool; pub use proxy_config::ProxyConfigTool;
pub use pushover::PushoverTool; pub use notify::NotifyTool;
pub use schedule::ScheduleTool; pub use schedule::ScheduleTool;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use schema::{CleaningStrategy, SchemaCleanr}; pub use schema::{CleaningStrategy, SchemaCleanr};
@ -151,12 +151,16 @@ pub fn all_tools_with_runtime(
security.clone(), security.clone(),
workspace_dir.to_path_buf(), 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 { if browser_config.enabled {
// Add legacy browser_open tool for simple URL opening // Add legacy browser_open tool for simple URL opening
tools.push(Box::new(BrowserOpenTool::new( tools.push(Box::new(BrowserOpenTool::new(
@ -294,7 +298,8 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"browser_open")); assert!(!names.contains(&"browser_open"));
assert!(names.contains(&"schedule")); 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")); assert!(names.contains(&"proxy_config"));
} }
@ -333,7 +338,8 @@ mod tests {
); );
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"browser_open")); 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")); assert!(names.contains(&"proxy_config"));
} }

616
src/tools/notify.rs Normal file
View file

@ -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<SecurityPolicy>,
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<SecurityPolicy>,
workspace_dir: &std::path::Path,
telegram_config: Option<&TelegramConfig>,
) -> Option<Self> {
// 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<i64>,
sound: Option<&str>,
) -> anyhow::Result<ToolResult> {
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::<serde_json::Value>(&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<ToolResult> {
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::<serde_json::Value>(&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<ToolResult> {
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<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy: level,
max_actions_per_hour,
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
})
}
fn make_pushover_tool(security: Arc<SecurityPolicy>) -> NotifyTool {
NotifyTool {
security,
backend: NotifyBackend::Pushover {
token: "test_token".into(),
user_key: "test_user".into(),
},
}
}
fn make_telegram_tool(security: Arc<SecurityPolicy>) -> 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");
}
}

View file

@ -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<SecurityPolicy>,
workspace_dir: PathBuf,
}
impl PushoverTool {
pub fn new(security: Arc<SecurityPolicy>, 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<ToolResult> {
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::<serde_json::Value>(&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<SecurityPolicy> {
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"));
}
}