1use std::time::Duration;
48
49use serde::Serialize;
50use tracing::warn;
51use zeph_config::NotificationsConfig;
52
53use crate::agent::agent_supervisor::{BackgroundSupervisor, TaskClass};
54use crate::redact::scrub_content;
55
56#[non_exhaustive]
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TurnExitStatus {
62 Success,
64 Error,
66}
67
68#[derive(Debug, Clone)]
74pub struct TurnSummary {
75 pub duration_ms: u64,
77 pub preview: String,
79 pub tool_calls: u32,
81 pub llm_requests: u32,
84 pub exit_status: TurnExitStatus,
86}
87
88#[derive(Clone)]
99pub struct Notifier {
100 cfg: NotificationsConfig,
101 http: reqwest::Client,
102}
103
104impl Notifier {
105 #[must_use]
114 pub fn new(cfg: NotificationsConfig) -> Self {
115 let http = reqwest::Client::builder()
116 .connect_timeout(Duration::from_secs(5))
117 .timeout(Duration::from_secs(5))
118 .build()
119 .unwrap_or_default();
120 let mut cfg = cfg;
121 if cfg
122 .webhook_url
123 .as_deref()
124 .is_some_and(|url| !validate_webhook_url(url, cfg.webhook_allow_insecure))
125 {
126 cfg.webhook_url = None;
127 }
128 Self { cfg, http }
129 }
130
131 #[must_use]
140 pub fn should_fire(&self, summary: &TurnSummary) -> bool {
141 if !self.cfg.enabled {
142 return false;
143 }
144 if summary.llm_requests == 0 && summary.exit_status == TurnExitStatus::Success {
147 return false;
148 }
149 match summary.exit_status {
150 TurnExitStatus::Error => true,
152 TurnExitStatus::Success => {
153 if self.cfg.only_on_error {
154 return false;
155 }
156 summary.duration_ms >= self.cfg.min_turn_duration_ms
158 }
159 }
160 }
161
162 pub(crate) fn fire(&self, summary: &TurnSummary, supervisor: &mut BackgroundSupervisor) {
169 let cfg = self.cfg.clone();
170 let http = self.http.clone();
171 let summary = summary.clone();
172
173 supervisor.spawn(TaskClass::Telemetry, "notify_turn_complete", async move {
174 fire_all_channels(&cfg, &http, &summary).await;
175 });
176 }
177
178 pub async fn fire_test(&self) -> Result<(), NotifyTestError> {
189 if !self.cfg.enabled {
190 return Err(NotifyTestError::MasterSwitchDisabled);
191 }
192
193 let macos_enabled = self.cfg.macos_native;
194 let webhook_enabled = self.cfg.webhook_url.is_some() && self.cfg.webhook_topic.is_some();
195
196 if !macos_enabled && !webhook_enabled {
197 return Err(NotifyTestError::AllDisabled);
198 }
199
200 let summary = TurnSummary {
201 duration_ms: 0,
202 preview: "Zeph is working".to_owned(),
203 tool_calls: 0,
204 llm_requests: 1,
205 exit_status: TurnExitStatus::Success,
206 };
207
208 #[cfg(target_os = "macos")]
209 if macos_enabled {
210 fire_macos_native(&self.cfg.title, "Zeph is working")
211 .await
212 .map_err(|e| NotifyTestError::MacOsFailed(e.to_string()))?;
213 }
214
215 if let (Some(url), Some(topic)) = (&self.cfg.webhook_url, &self.cfg.webhook_topic) {
216 fire_webhook(&self.http, url, &self.cfg.title, topic, &summary)
217 .await
218 .map_err(|e| NotifyTestError::WebhookFailed(e.to_string()))?;
219 }
220
221 Ok(())
222 }
223}
224
225#[non_exhaustive]
226#[derive(Debug, thiserror::Error)]
228pub enum NotifyTestError {
229 #[error("notifications are disabled (set notifications.enabled = true to enable)")]
231 MasterSwitchDisabled,
232 #[error("all notification channels are disabled")]
234 AllDisabled,
235 #[error("macOS notification failed: {0}")]
237 MacOsFailed(String),
238 #[error("webhook notification failed: {0}")]
240 WebhookFailed(String),
241}
242
243async fn fire_all_channels(
247 cfg: &NotificationsConfig,
248 http: &reqwest::Client,
249 summary: &TurnSummary,
250) {
251 let title = &cfg.title;
252
253 #[cfg(target_os = "macos")]
254 {
255 let message = build_notification_message(summary);
256 if cfg.macos_native
257 && let Err(e) = fire_macos_native(title, &message).await
258 {
259 warn!(error = %e, "macOS notification failed");
260 }
261 }
262
263 if let (Some(url), Some(topic)) = (&cfg.webhook_url, &cfg.webhook_topic)
264 && let Err(e) = fire_webhook(http, url, title, topic, summary).await
265 {
266 warn!(error = %e, "webhook notification failed");
267 }
268}
269
270fn build_notification_message(summary: &TurnSummary) -> String {
272 let status = if summary.exit_status == TurnExitStatus::Error {
273 "Error"
274 } else {
275 "Done"
276 };
277
278 let safe_preview = scrub_content(&summary.preview);
280
281 if safe_preview.is_empty() {
282 format!("{status} — {dur}ms", dur = summary.duration_ms)
283 } else {
284 format!(
285 "{status} — {dur}ms\n{preview}",
286 dur = summary.duration_ms,
287 preview = safe_preview,
288 )
289 }
290}
291
292#[must_use]
310pub fn sanitize_applescript_payload(s: &str, max: usize) -> String {
311 let cleaned: String = s
315 .chars()
316 .map(|c| {
317 if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
318 ' '
319 } else {
320 c
321 }
322 })
323 .collect();
324 let char_count = cleaned.chars().count();
326 let truncated: String = if char_count > max {
327 let end = cleaned
328 .char_indices()
329 .nth(max)
330 .map_or(cleaned.len(), |(i, _)| i);
331 let mut t = cleaned[..end].to_owned();
332 t.push('…');
333 t
334 } else {
335 cleaned
336 };
337 truncated.replace(['\\', '"'], "")
341}
342
343fn validate_webhook_url(url: &str, allow_insecure: bool) -> bool {
349 match url.parse::<reqwest::Url>() {
350 Ok(parsed) => {
351 if parsed.scheme() == "https" {
352 return true;
353 }
354 if allow_insecure && parsed.scheme() == "http" {
355 warn!(
356 "webhook_url uses insecure HTTP scheme; set webhook_allow_insecure=false for production"
357 );
358 return true;
359 }
360 warn!(
361 scheme = parsed.scheme(),
362 "webhook_url has non-HTTP(S) scheme — channel disabled"
363 );
364 false
365 }
366 Err(e) => {
367 warn!(error = %e, "webhook_url is not a valid URL — channel disabled");
368 false
369 }
370 }
371}
372
373#[cfg(target_os = "macos")]
375async fn fire_macos_native(
376 title: &str,
377 message: &str,
378) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
379 use tokio::io::AsyncWriteExt as _;
380 use tokio::process::Command;
381
382 let safe_title = sanitize_applescript_payload(title, 120);
383 let safe_message = sanitize_applescript_payload(message, 240);
384
385 let script = format!(r#"display notification "{safe_message}" with title "{safe_title}""#);
386
387 let mut child = Command::new("osascript")
388 .stdin(std::process::Stdio::piped())
389 .stdout(std::process::Stdio::null())
390 .stderr(std::process::Stdio::null())
391 .spawn()?;
392
393 if let Some(mut stdin) = child.stdin.take() {
394 stdin.write_all(script.as_bytes()).await?;
395 stdin.shutdown().await?;
396 }
397
398 let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
400
401 Ok(())
402}
403
404#[derive(Serialize)]
408struct NtfyWebhookBody<'a> {
409 topic: &'a str,
410 title: &'a str,
411 message: &'a str,
412 tags: Vec<&'a str>,
413 priority: u8,
415}
416
417async fn fire_webhook(
419 client: &reqwest::Client,
420 url: &str,
421 title: &str,
422 topic: &str,
423 summary: &TurnSummary,
424) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
425 let message = build_notification_message(summary);
426 let (tags, priority) = if summary.exit_status == TurnExitStatus::Error {
427 (vec!["zeph", "error"], 4u8)
428 } else {
429 (vec!["zeph", "turn-complete"], 3u8)
430 };
431
432 let body = NtfyWebhookBody {
433 topic,
434 title,
435 message: &message,
436 tags,
437 priority,
438 };
439
440 tokio::time::timeout(Duration::from_secs(5), client.post(url).json(&body).send())
442 .await??
443 .error_for_status()?;
444
445 Ok(())
446}
447
448#[cfg(test)]
451mod tests {
452 use super::*;
453 use zeph_config::NotificationsConfig;
454
455 fn make_notifier(cfg: NotificationsConfig) -> Notifier {
456 Notifier::new(cfg)
457 }
458
459 fn success_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
460 TurnSummary {
461 duration_ms,
462 preview: "All done.".to_owned(),
463 tool_calls: 0,
464 llm_requests,
465 exit_status: TurnExitStatus::Success,
466 }
467 }
468
469 fn error_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
470 TurnSummary {
471 duration_ms,
472 preview: "Error occurred.".to_owned(),
473 tool_calls: 0,
474 llm_requests,
475 exit_status: TurnExitStatus::Error,
476 }
477 }
478
479 #[test]
482 fn should_fire_disabled_master_switch() {
483 let n = make_notifier(NotificationsConfig {
484 enabled: false,
485 ..Default::default()
486 });
487 assert!(!n.should_fire(&success_summary(5000, 1)));
488 }
489
490 #[test]
491 fn should_fire_zero_llm_success_skipped() {
492 let n = make_notifier(NotificationsConfig {
493 enabled: true,
494 ..Default::default()
495 });
496 assert!(!n.should_fire(&success_summary(0, 0)));
498 }
499
500 #[test]
501 fn should_fire_zero_llm_error_fires() {
502 let n = make_notifier(NotificationsConfig {
504 enabled: true,
505 ..Default::default()
506 });
507 assert!(n.should_fire(&error_summary(0, 0)));
508 }
509
510 #[test]
511 fn should_fire_only_on_error_skips_success() {
512 let n = make_notifier(NotificationsConfig {
513 enabled: true,
514 only_on_error: true,
515 ..Default::default()
516 });
517 assert!(!n.should_fire(&success_summary(5000, 1)));
518 }
519
520 #[test]
521 fn should_fire_only_on_error_fires_on_error() {
522 let n = make_notifier(NotificationsConfig {
523 enabled: true,
524 only_on_error: true,
525 ..Default::default()
526 });
527 assert!(n.should_fire(&error_summary(100, 1)));
528 }
529
530 #[test]
531 fn should_fire_duration_gate_success_below_threshold() {
532 let n = make_notifier(NotificationsConfig {
533 enabled: true,
534 min_turn_duration_ms: 3000,
535 ..Default::default()
536 });
537 assert!(!n.should_fire(&success_summary(2999, 1)));
538 }
539
540 #[test]
541 fn should_fire_duration_gate_success_at_threshold() {
542 let n = make_notifier(NotificationsConfig {
543 enabled: true,
544 min_turn_duration_ms: 3000,
545 ..Default::default()
546 });
547 assert!(n.should_fire(&success_summary(3000, 1)));
548 }
549
550 #[test]
551 fn should_fire_error_bypasses_duration_gate() {
552 let n = make_notifier(NotificationsConfig {
554 enabled: true,
555 min_turn_duration_ms: 3000,
556 ..Default::default()
557 });
558 assert!(n.should_fire(&error_summary(100, 1)));
559 }
560
561 #[test]
564 fn sanitize_control_chars_replaced_with_space() {
565 let result = sanitize_applescript_payload("Hello\nWorld", 200);
566 assert!(!result.contains('\n'));
568 assert!(result.contains("Hello World"));
569 }
570
571 #[test]
572 fn sanitize_quotes_stripped() {
573 let result = sanitize_applescript_payload(r#"say "hi""#, 200);
575 assert!(!result.contains('"'));
576 assert_eq!(result, "say hi");
577 }
578
579 #[test]
580 fn sanitize_backslash_stripped() {
581 let result = sanitize_applescript_payload(r"C:\Users\foo", 200);
583 assert_eq!(result, "C:Usersfoo");
584 }
585
586 #[test]
587 fn sanitize_truncation_appends_ellipsis() {
588 let long = "a".repeat(300);
589 let result = sanitize_applescript_payload(&long, 200);
590 assert!(result.ends_with('…'));
591 assert_eq!(result.chars().count(), 201);
593 }
594
595 #[test]
596 fn sanitize_no_truncation_when_short() {
597 let result = sanitize_applescript_payload("short", 200);
598 assert_eq!(result, "short");
599 }
600
601 #[test]
602 fn sanitize_injection_attempt() {
603 let payload = r#""; display dialog "gotcha"; ""#;
605 let result = sanitize_applescript_payload(payload, 200);
606 assert!(!result.contains('"'));
608 }
609
610 #[test]
611 fn sanitize_applescript_payload_empty() {
612 assert_eq!(sanitize_applescript_payload("", 200), "");
613 }
614
615 #[test]
616 fn sanitize_tab_replaced() {
617 let result = sanitize_applescript_payload("a\tb", 200);
618 assert_eq!(result, "a b");
619 }
620
621 #[test]
622 fn sanitize_line_separators() {
623 let s = "hello\u{2028}world\u{2029}end";
624 let result = sanitize_applescript_payload(s, 200);
625 assert!(!result.contains('\u{2028}'));
626 assert!(!result.contains('\u{2029}'));
627 assert_eq!(result, "hello world end");
628 }
629
630 #[test]
633 fn notification_message_success() {
634 let summary = success_summary(1234, 1);
635 let msg = build_notification_message(&summary);
636 assert!(msg.starts_with("Done"));
637 assert!(msg.contains("1234ms"));
638 }
639
640 #[test]
641 fn notification_message_error() {
642 let summary = error_summary(500, 1);
643 let msg = build_notification_message(&summary);
644 assert!(msg.starts_with("Error"));
645 }
646
647 #[test]
648 fn notification_message_redacts_secrets() {
649 let summary = TurnSummary {
650 duration_ms: 100,
651 preview: "Done. Key: sk-abc123xyz".to_owned(),
652 tool_calls: 0,
653 llm_requests: 1,
654 exit_status: TurnExitStatus::Success,
655 };
656 let msg = build_notification_message(&summary);
657 assert!(!msg.contains("sk-abc123xyz"), "secret must be redacted");
658 assert!(
659 msg.contains("[REDACTED]"),
660 "should contain redaction marker"
661 );
662 }
663}