1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use std::sync::Arc;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::policy::SecurityPolicy;
7
8const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json";
9const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15;
10
11pub struct PushoverTool {
12 security: Arc<SecurityPolicy>,
13 workspace_dir: PathBuf,
14}
15
16impl PushoverTool {
17 pub fn new(security: Arc<SecurityPolicy>, workspace_dir: PathBuf) -> Self {
18 Self {
19 security,
20 workspace_dir,
21 }
22 }
23
24 fn parse_env_value(raw: &str) -> String {
25 let raw = raw.trim();
26
27 let unquoted = if raw.len() >= 2
28 && ((raw.starts_with('"') && raw.ends_with('"'))
29 || (raw.starts_with('\'') && raw.ends_with('\'')))
30 {
31 &raw[1..raw.len() - 1]
32 } else {
33 raw
34 };
35
36 unquoted.split_once(" #").map_or_else(
39 || unquoted.trim().to_string(),
40 |(value, _)| value.trim().to_string(),
41 )
42 }
43
44 async fn get_credentials(&self) -> anyhow::Result<(String, String)> {
45 let env_path = self.workspace_dir.join(".env");
46 let content = tokio::fs::read_to_string(&env_path).await.map_err(|e| {
47 ::zeroclaw_log::record!(
48 ERROR,
49 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
50 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
51 .with_attrs(::serde_json::json!({
52 "path": env_path.display().to_string(),
53 "error": format!("{}", e),
54 })),
55 "pushover: failed to read .env"
56 );
57 anyhow::Error::msg(format!("Failed to read {}: {}", env_path.display(), e))
58 })?;
59
60 let mut token = None;
61 let mut user_key = None;
62
63 for line in content.lines() {
64 let line = line.trim();
65 if line.starts_with('#') || line.is_empty() {
66 continue;
67 }
68 let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
69 if let Some((key, value)) = line.split_once('=') {
70 let key = key.trim();
71 let value = Self::parse_env_value(value);
72
73 if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") {
74 token = Some(value);
75 } else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") {
76 user_key = Some(value);
77 }
78 }
79 }
80
81 let token = token.ok_or_else(|| {
82 ::zeroclaw_log::record!(
83 ERROR,
84 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
85 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
86 .with_attrs(::serde_json::json!({"missing": "PUSHOVER_TOKEN"})),
87 "pushover: PUSHOVER_TOKEN missing from .env"
88 );
89 anyhow::Error::msg("PUSHOVER_TOKEN not found in .env")
90 })?;
91 let user_key = user_key.ok_or_else(|| {
92 ::zeroclaw_log::record!(
93 ERROR,
94 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
95 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
96 .with_attrs(::serde_json::json!({"missing": "PUSHOVER_USER_KEY"})),
97 "pushover: PUSHOVER_USER_KEY missing from .env"
98 );
99 anyhow::Error::msg("PUSHOVER_USER_KEY not found in .env")
100 })?;
101
102 Ok((token, user_key))
103 }
104}
105
106#[async_trait]
107impl Tool for PushoverTool {
108 fn name(&self) -> &str {
109 "pushover"
110 }
111
112 fn description(&self) -> &str {
113 "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file."
114 }
115
116 fn parameters_schema(&self) -> serde_json::Value {
117 json!({
118 "type": "object",
119 "properties": {
120 "message": {
121 "type": "string",
122 "description": "The notification message to send"
123 },
124 "title": {
125 "type": "string",
126 "description": "Optional notification title"
127 },
128 "priority": {
129 "type": "integer",
130 "description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)"
131 },
132 "sound": {
133 "type": "string",
134 "description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)"
135 }
136 },
137 "required": ["message"]
138 })
139 }
140
141 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
142 if !self.security.can_act() {
143 return Ok(ToolResult {
144 success: false,
145 output: String::new(),
146 error: Some("Action blocked: autonomy is read-only".into()),
147 });
148 }
149
150 if !self.security.record_action() {
151 return Ok(ToolResult {
152 success: false,
153 output: String::new(),
154 error: Some("Action blocked: rate limit exceeded".into()),
155 });
156 }
157
158 let message = args
159 .get("message")
160 .and_then(|v| v.as_str())
161 .map(str::trim)
162 .filter(|v| !v.is_empty())
163 .ok_or_else(|| {
164 ::zeroclaw_log::record!(
165 WARN,
166 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
167 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
168 .with_attrs(::serde_json::json!({"param": "message"})),
169 "pushover: missing message parameter"
170 );
171 anyhow::Error::msg("Missing 'message' parameter")
172 })?
173 .to_string();
174
175 let title = args.get("title").and_then(|v| v.as_str()).map(String::from);
176
177 let priority = match args.get("priority").and_then(|v| v.as_i64()) {
178 Some(value) if (-2..=2).contains(&value) => Some(value),
179 Some(value) => {
180 return Ok(ToolResult {
181 success: false,
182 output: String::new(),
183 error: Some(format!(
184 "Invalid 'priority': {value}. Expected integer in range -2..=2"
185 )),
186 });
187 }
188 None => None,
189 };
190
191 let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from);
192
193 let (token, user_key) = self.get_credentials().await?;
194
195 let mut form = reqwest::multipart::Form::new()
196 .text("token", token)
197 .text("user", user_key)
198 .text("message", message);
199
200 if let Some(title) = title {
201 form = form.text("title", title);
202 }
203
204 if let Some(priority) = priority {
205 form = form.text("priority", priority.to_string());
206 }
207
208 if let Some(sound) = sound {
209 form = form.text("sound", sound);
210 }
211
212 let client = zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
213 "tool.pushover",
214 PUSHOVER_REQUEST_TIMEOUT_SECS,
215 10,
216 );
217 let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?;
218
219 let status = response.status();
220 let body = response.text().await.unwrap_or_default();
221
222 if !status.is_success() {
223 return Ok(ToolResult {
224 success: false,
225 output: body,
226 error: Some(format!("Pushover API returned status {}", status)),
227 });
228 }
229
230 let api_status = serde_json::from_str::<serde_json::Value>(&body)
231 .ok()
232 .and_then(|json| json.get("status").and_then(|value| value.as_i64()));
233
234 if api_status == Some(1) {
235 Ok(ToolResult {
236 success: true,
237 output: format!(
238 "Pushover notification sent successfully. Response: {}",
239 body
240 ),
241 error: None,
242 })
243 } else {
244 Ok(ToolResult {
245 success: false,
246 output: body,
247 error: Some("Pushover API returned an application-level error".into()),
248 })
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use std::fs;
257 use tempfile::TempDir;
258 use zeroclaw_config::autonomy::AutonomyLevel;
259
260 fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc<SecurityPolicy> {
261 Arc::new(SecurityPolicy {
262 autonomy: level,
263 max_actions_per_hour,
264 workspace_dir: std::env::temp_dir(),
265 ..SecurityPolicy::default()
266 })
267 }
268
269 #[test]
270 fn pushover_tool_name() {
271 let tool = PushoverTool::new(
272 test_security(AutonomyLevel::Full, 100),
273 PathBuf::from("/tmp"),
274 );
275 assert_eq!(tool.name(), "pushover");
276 }
277
278 #[test]
279 fn pushover_tool_description() {
280 let tool = PushoverTool::new(
281 test_security(AutonomyLevel::Full, 100),
282 PathBuf::from("/tmp"),
283 );
284 assert!(!tool.description().is_empty());
285 }
286
287 #[test]
288 fn pushover_tool_has_parameters_schema() {
289 let tool = PushoverTool::new(
290 test_security(AutonomyLevel::Full, 100),
291 PathBuf::from("/tmp"),
292 );
293 let schema = tool.parameters_schema();
294 assert_eq!(schema["type"], "object");
295 assert!(schema["properties"].get("message").is_some());
296 }
297
298 #[test]
299 fn pushover_tool_requires_message() {
300 let tool = PushoverTool::new(
301 test_security(AutonomyLevel::Full, 100),
302 PathBuf::from("/tmp"),
303 );
304 let schema = tool.parameters_schema();
305 let required = schema["required"].as_array().unwrap();
306 assert!(required.contains(&serde_json::Value::String("message".to_string())));
307 }
308
309 #[tokio::test]
310 async fn credentials_parsed_from_env_file() {
311 let tmp = TempDir::new().unwrap();
312 let env_path = tmp.path().join(".env");
313 fs::write(
314 &env_path,
315 "PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n",
316 )
317 .unwrap();
318
319 let tool = PushoverTool::new(
320 test_security(AutonomyLevel::Full, 100),
321 tmp.path().to_path_buf(),
322 );
323 let result = tool.get_credentials().await;
324
325 assert!(result.is_ok());
326 let (token, user_key) = result.unwrap();
327 assert_eq!(token, "testtoken123");
328 assert_eq!(user_key, "userkey456");
329 }
330
331 #[tokio::test]
332 async fn credentials_fail_without_env_file() {
333 let tmp = TempDir::new().unwrap();
334 let tool = PushoverTool::new(
335 test_security(AutonomyLevel::Full, 100),
336 tmp.path().to_path_buf(),
337 );
338 let result = tool.get_credentials().await;
339
340 assert!(result.is_err());
341 }
342
343 #[tokio::test]
344 async fn credentials_fail_without_token() {
345 let tmp = TempDir::new().unwrap();
346 let env_path = tmp.path().join(".env");
347 fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap();
348
349 let tool = PushoverTool::new(
350 test_security(AutonomyLevel::Full, 100),
351 tmp.path().to_path_buf(),
352 );
353 let result = tool.get_credentials().await;
354
355 assert!(result.is_err());
356 }
357
358 #[tokio::test]
359 async fn credentials_fail_without_user_key() {
360 let tmp = TempDir::new().unwrap();
361 let env_path = tmp.path().join(".env");
362 fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap();
363
364 let tool = PushoverTool::new(
365 test_security(AutonomyLevel::Full, 100),
366 tmp.path().to_path_buf(),
367 );
368 let result = tool.get_credentials().await;
369
370 assert!(result.is_err());
371 }
372
373 #[tokio::test]
374 async fn credentials_ignore_comments() {
375 let tmp = TempDir::new().unwrap();
376 let env_path = tmp.path().join(".env");
377 fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap();
378
379 let tool = PushoverTool::new(
380 test_security(AutonomyLevel::Full, 100),
381 tmp.path().to_path_buf(),
382 );
383 let result = tool.get_credentials().await;
384
385 assert!(result.is_ok());
386 let (token, user_key) = result.unwrap();
387 assert_eq!(token, "realtoken");
388 assert_eq!(user_key, "realuser");
389 }
390
391 #[test]
392 fn pushover_tool_supports_priority() {
393 let tool = PushoverTool::new(
394 test_security(AutonomyLevel::Full, 100),
395 PathBuf::from("/tmp"),
396 );
397 let schema = tool.parameters_schema();
398 assert!(schema["properties"].get("priority").is_some());
399 }
400
401 #[test]
402 fn pushover_tool_supports_sound() {
403 let tool = PushoverTool::new(
404 test_security(AutonomyLevel::Full, 100),
405 PathBuf::from("/tmp"),
406 );
407 let schema = tool.parameters_schema();
408 assert!(schema["properties"].get("sound").is_some());
409 }
410
411 #[tokio::test]
412 async fn credentials_support_export_and_quoted_values() {
413 let tmp = TempDir::new().unwrap();
414 let env_path = tmp.path().join(".env");
415 fs::write(
416 &env_path,
417 "export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n",
418 )
419 .unwrap();
420
421 let tool = PushoverTool::new(
422 test_security(AutonomyLevel::Full, 100),
423 tmp.path().to_path_buf(),
424 );
425 let result = tool.get_credentials().await;
426
427 assert!(result.is_ok());
428 let (token, user_key) = result.unwrap();
429 assert_eq!(token, "quotedtoken");
430 assert_eq!(user_key, "quoteduser");
431 }
432
433 #[tokio::test]
434 async fn execute_blocks_readonly_mode() {
435 let tool = PushoverTool::new(
436 test_security(AutonomyLevel::ReadOnly, 100),
437 PathBuf::from("/tmp"),
438 );
439
440 let result = tool.execute(json!({"message": "hello"})).await.unwrap();
441 assert!(!result.success);
442 assert!(result.error.unwrap().contains("read-only"));
443 }
444
445 #[tokio::test]
446 async fn execute_blocks_rate_limit() {
447 let tool = PushoverTool::new(test_security(AutonomyLevel::Full, 0), PathBuf::from("/tmp"));
448
449 let result = tool.execute(json!({"message": "hello"})).await.unwrap();
450 assert!(!result.success);
451 assert!(result.error.unwrap().contains("rate limit"));
452 }
453
454 #[tokio::test]
455 async fn execute_rejects_priority_out_of_range() {
456 let tool = PushoverTool::new(
457 test_security(AutonomyLevel::Full, 100),
458 PathBuf::from("/tmp"),
459 );
460
461 let result = tool
462 .execute(json!({"message": "hello", "priority": 5}))
463 .await
464 .unwrap();
465
466 assert!(!result.success);
467 assert!(result.error.unwrap().contains("-2..=2"));
468 }
469}