Skip to main content

agentzero_tools/
pushover.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::Deserialize;
5
6#[derive(Debug, Deserialize)]
7struct PushoverInput {
8    message: String,
9    #[serde(default)]
10    title: Option<String>,
11    #[serde(default)]
12    priority: Option<i8>,
13    #[serde(default)]
14    token: Option<String>,
15    #[serde(default)]
16    user: Option<String>,
17}
18
19/// Pushover push notification tool.
20///
21/// Sends push notifications via the Pushover API. Requires either:
22/// - `PUSHOVER_TOKEN` and `PUSHOVER_USER` environment variables, or
23/// - `token` and `user` fields in the input JSON.
24///
25/// Priority levels: -2 (lowest), -1, 0 (normal), 1 (high), 2 (emergency).
26#[derive(Debug, Default, Clone, Copy)]
27pub struct PushoverTool;
28
29#[async_trait]
30impl Tool for PushoverTool {
31    fn name(&self) -> &'static str {
32        "pushover"
33    }
34
35    fn description(&self) -> &'static str {
36        "Send push notifications via the Pushover service."
37    }
38
39    fn input_schema(&self) -> Option<serde_json::Value> {
40        Some(serde_json::json!({
41            "type": "object",
42            "required": ["message"],
43            "properties": {
44                "message": {"type": "string", "description": "Notification message"},
45                "title": {"type": "string", "description": "Optional notification title"},
46                "priority": {"type": "integer", "description": "Priority: -2 to 2"},
47                "token": {"type": "string", "description": "Pushover API token (or use PUSHOVER_TOKEN env)"},
48                "user": {"type": "string", "description": "Pushover user key (or use PUSHOVER_USER env)"}
49            }
50        }))
51    }
52
53    async fn execute(&self, input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
54        let req: PushoverInput =
55            serde_json::from_str(input).context("pushover expects JSON: {\"message\", ...}")?;
56
57        if req.message.trim().is_empty() {
58            return Err(anyhow!("message must not be empty"));
59        }
60
61        let token = req
62            .token
63            .or_else(|| std::env::var("PUSHOVER_TOKEN").ok())
64            .ok_or_else(|| anyhow!("PUSHOVER_TOKEN not set and no token provided"))?;
65
66        let user = req
67            .user
68            .or_else(|| std::env::var("PUSHOVER_USER").ok())
69            .ok_or_else(|| anyhow!("PUSHOVER_USER not set and no user provided"))?;
70
71        if token.trim().is_empty() {
72            return Err(anyhow!("token must not be empty"));
73        }
74        if user.trim().is_empty() {
75            return Err(anyhow!("user must not be empty"));
76        }
77
78        let priority = req.priority.unwrap_or(0);
79        if !(-2..=2).contains(&priority) {
80            return Err(anyhow!("priority must be between -2 and 2, got {priority}"));
81        }
82
83        let mut form = vec![
84            ("token", token),
85            ("user", user),
86            ("message", req.message.clone()),
87            ("priority", priority.to_string()),
88        ];
89        if let Some(ref title) = req.title {
90            form.push(("title", title.clone()));
91        }
92
93        let client = reqwest::Client::new();
94        let response = client
95            .post("https://api.pushover.net/1/messages.json")
96            .form(&form)
97            .send()
98            .await
99            .context("failed to reach Pushover API")?;
100
101        let status = response.status();
102        let body = response
103            .text()
104            .await
105            .unwrap_or_else(|_| "(no body)".to_string());
106
107        if status.is_success() {
108            Ok(ToolResult {
109                output: format!("notification sent: {body}"),
110            })
111        } else {
112            Err(anyhow!(
113                "Pushover API returned {}: {}",
114                status.as_u16(),
115                body
116            ))
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use agentzero_core::ToolContext;
125
126    fn test_ctx() -> ToolContext {
127        ToolContext::new("/tmp".to_string())
128    }
129
130    #[tokio::test]
131    async fn pushover_rejects_empty_message() {
132        let tool = PushoverTool;
133        let err = tool
134            .execute(r#"{"message": ""}"#, &test_ctx())
135            .await
136            .expect_err("empty message should fail");
137        assert!(err.to_string().contains("message must not be empty"));
138    }
139
140    #[tokio::test]
141    async fn pushover_rejects_missing_token() {
142        let had_token = std::env::var("PUSHOVER_TOKEN").ok();
143        let had_user = std::env::var("PUSHOVER_USER").ok();
144        std::env::remove_var("PUSHOVER_TOKEN");
145        std::env::remove_var("PUSHOVER_USER");
146
147        let tool = PushoverTool;
148        let err = tool
149            .execute(r#"{"message": "test"}"#, &test_ctx())
150            .await
151            .expect_err("missing token should fail");
152        assert!(err.to_string().contains("PUSHOVER_TOKEN"));
153
154        if let Some(t) = had_token {
155            std::env::set_var("PUSHOVER_TOKEN", t);
156        }
157        if let Some(u) = had_user {
158            std::env::set_var("PUSHOVER_USER", u);
159        }
160    }
161
162    #[tokio::test]
163    async fn pushover_rejects_invalid_priority() {
164        let tool = PushoverTool;
165        let err = tool
166            .execute(
167                r#"{"message": "test", "token": "tok", "user": "usr", "priority": 5}"#,
168                &test_ctx(),
169            )
170            .await
171            .expect_err("invalid priority should fail");
172        assert!(err.to_string().contains("priority must be between"));
173    }
174
175    #[tokio::test]
176    async fn pushover_rejects_empty_token() {
177        let tool = PushoverTool;
178        let err = tool
179            .execute(
180                r#"{"message": "test", "token": "", "user": "usr"}"#,
181                &test_ctx(),
182            )
183            .await
184            .expect_err("empty token should fail");
185        assert!(err.to_string().contains("token must not be empty"));
186    }
187}