Skip to main content

zeroclaw_tools/
pushover.rs

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        // Keep support for inline comments in unquoted values:
37        // KEY=value # comment
38        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}