agentzero_tools/
pushover.rs1use 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#[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}