1use std::time::Duration;
53
54use serde::Serialize;
55use tracing::warn;
56use zeph_config::NotificationsConfig;
57
58use crate::agent::agent_supervisor::{BackgroundSupervisor, TaskClass};
59use crate::redact::scrub_content;
60
61#[non_exhaustive]
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum TurnExitStatus {
67 Success,
69 Error,
71}
72
73#[derive(Debug, Clone)]
79pub struct TurnSummary {
80 pub duration_ms: u64,
82 pub preview: String,
84 pub tool_calls: u32,
90 pub llm_requests: u32,
93 pub exit_status: TurnExitStatus,
95}
96
97#[derive(Clone)]
108pub struct Notifier {
109 cfg: NotificationsConfig,
110 http: reqwest::Client,
111}
112
113impl Notifier {
114 #[must_use]
123 pub fn new(cfg: NotificationsConfig) -> Self {
124 let http = reqwest::Client::builder()
125 .connect_timeout(Duration::from_secs(5))
126 .timeout(Duration::from_secs(5))
127 .build()
128 .unwrap_or_default();
129 let mut cfg = cfg;
130 if cfg
131 .webhook_url
132 .as_deref()
133 .is_some_and(|url| !validate_webhook_url(url, cfg.webhook_allow_insecure))
134 {
135 cfg.webhook_url = None;
136 }
137 Self { cfg, http }
138 }
139
140 #[must_use]
152 pub fn should_fire(&self, summary: &TurnSummary) -> bool {
153 if !self.cfg.enabled {
154 return false;
155 }
156 if summary.llm_requests == 0 && summary.exit_status == TurnExitStatus::Success {
159 return false;
160 }
161 match summary.exit_status {
162 TurnExitStatus::Error => true,
164 TurnExitStatus::Success => {
165 if self.cfg.only_on_error {
166 return false;
167 }
168 summary.duration_ms >= self.cfg.min_turn_duration_ms
170 }
171 }
172 }
173
174 pub(crate) fn fire(&self, summary: &TurnSummary, supervisor: &mut BackgroundSupervisor) {
181 let cfg = self.cfg.clone();
182 let http = self.http.clone();
183 let summary = summary.clone();
184
185 supervisor.spawn(TaskClass::Telemetry, "notify_turn_complete", async move {
186 fire_all_channels(&cfg, &http, &summary).await;
187 });
188 }
189
190 pub async fn fire_test(&self) -> Result<(), NotifyTestError> {
201 if !self.cfg.enabled {
202 return Err(NotifyTestError::MasterSwitchDisabled);
203 }
204
205 let macos_enabled = self.cfg.macos_native;
206 let webhook_enabled = self.cfg.webhook_url.is_some() && self.cfg.webhook_topic.is_some();
207
208 if !macos_enabled && !webhook_enabled {
209 return Err(NotifyTestError::AllDisabled);
210 }
211
212 let summary = TurnSummary {
213 duration_ms: 0,
214 preview: "Zeph is working".to_owned(),
215 tool_calls: 0,
216 llm_requests: 1,
217 exit_status: TurnExitStatus::Success,
218 };
219
220 #[cfg(target_os = "macos")]
221 if macos_enabled {
222 fire_macos_native(&self.cfg.title, "Zeph is working")
223 .await
224 .map_err(|e| NotifyTestError::MacOsFailed(e.to_string()))?;
225 }
226
227 if let (Some(url), Some(topic)) = (&self.cfg.webhook_url, &self.cfg.webhook_topic) {
228 fire_webhook(&self.http, url, &self.cfg.title, topic, &summary)
229 .await
230 .map_err(|e| NotifyTestError::WebhookFailed(e.to_string()))?;
231 }
232
233 Ok(())
234 }
235}
236
237#[non_exhaustive]
238#[derive(Debug, thiserror::Error)]
240pub enum NotifyTestError {
241 #[error("notifications are disabled (set notifications.enabled = true to enable)")]
243 MasterSwitchDisabled,
244 #[error("all notification channels are disabled")]
246 AllDisabled,
247 #[error("macOS notification failed: {0}")]
249 MacOsFailed(String),
250 #[error("webhook notification failed: {0}")]
252 WebhookFailed(String),
253}
254
255async fn fire_all_channels(
259 cfg: &NotificationsConfig,
260 http: &reqwest::Client,
261 summary: &TurnSummary,
262) {
263 let title = &cfg.title;
264
265 #[cfg(target_os = "macos")]
266 {
267 let message = build_notification_message(summary);
268 if cfg.macos_native
269 && let Err(e) = fire_macos_native(title, &message).await
270 {
271 warn!(error = %e, "macOS notification failed");
272 }
273 }
274
275 if let (Some(url), Some(topic)) = (&cfg.webhook_url, &cfg.webhook_topic)
276 && let Err(e) = fire_webhook(http, url, title, topic, summary).await
277 {
278 warn!(error = %e, "webhook notification failed");
279 }
280}
281
282fn build_notification_message(summary: &TurnSummary) -> String {
284 let status = if summary.exit_status == TurnExitStatus::Error {
285 "Error"
286 } else {
287 "Done"
288 };
289
290 let header = if summary.tool_calls > 0 {
291 let noun = if summary.tool_calls == 1 {
292 "tool call"
293 } else {
294 "tool calls"
295 };
296 format!(
297 "{status} — {dur}ms, {calls} {noun}",
298 dur = summary.duration_ms,
299 calls = summary.tool_calls,
300 )
301 } else {
302 format!("{status} — {dur}ms", dur = summary.duration_ms)
303 };
304
305 let safe_preview = scrub_content(&summary.preview);
307
308 if safe_preview.is_empty() {
309 header
310 } else {
311 format!("{header}\n{safe_preview}")
312 }
313}
314
315#[must_use]
333pub fn sanitize_applescript_payload(s: &str, max: usize) -> String {
334 let cleaned: String = s
338 .chars()
339 .map(|c| {
340 if c.is_control() || c == '\u{2028}' || c == '\u{2029}' {
341 ' '
342 } else {
343 c
344 }
345 })
346 .collect();
347 let char_count = cleaned.chars().count();
349 let truncated: String = if char_count > max {
350 let end = cleaned
351 .char_indices()
352 .nth(max)
353 .map_or(cleaned.len(), |(i, _)| i);
354 let mut t = cleaned[..end].to_owned();
355 t.push('…');
356 t
357 } else {
358 cleaned
359 };
360 truncated.replace(['\\', '"'], "")
364}
365
366fn validate_webhook_url(url: &str, allow_insecure: bool) -> bool {
372 match url.parse::<reqwest::Url>() {
373 Ok(parsed) => {
374 if parsed.scheme() == "https" {
375 return true;
376 }
377 if allow_insecure && parsed.scheme() == "http" {
378 warn!(
379 "webhook_url uses insecure HTTP scheme; set webhook_allow_insecure=false for production"
380 );
381 return true;
382 }
383 warn!(
384 scheme = parsed.scheme(),
385 "webhook_url has non-HTTP(S) scheme — channel disabled"
386 );
387 false
388 }
389 Err(e) => {
390 warn!(error = %e, "webhook_url is not a valid URL — channel disabled");
391 false
392 }
393 }
394}
395
396#[cfg(target_os = "macos")]
398async fn fire_macos_native(
399 title: &str,
400 message: &str,
401) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
402 use tokio::io::AsyncWriteExt as _;
403 use tokio::process::Command;
404
405 let safe_title = sanitize_applescript_payload(title, 120);
406 let safe_message = sanitize_applescript_payload(message, 240);
407
408 let script = format!(r#"display notification "{safe_message}" with title "{safe_title}""#);
409
410 let mut child = Command::new("osascript")
411 .stdin(std::process::Stdio::piped())
412 .stdout(std::process::Stdio::null())
413 .stderr(std::process::Stdio::null())
414 .spawn()?;
415
416 if let Some(mut stdin) = child.stdin.take() {
417 stdin.write_all(script.as_bytes()).await?;
418 stdin.shutdown().await?;
419 }
420
421 let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
423
424 Ok(())
425}
426
427#[derive(Serialize)]
431struct NtfyWebhookBody<'a> {
432 topic: &'a str,
433 title: &'a str,
434 message: &'a str,
435 tags: Vec<&'a str>,
436 priority: u8,
438}
439
440async fn fire_webhook(
442 client: &reqwest::Client,
443 url: &str,
444 title: &str,
445 topic: &str,
446 summary: &TurnSummary,
447) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
448 let message = build_notification_message(summary);
449 let (tags, priority) = if summary.exit_status == TurnExitStatus::Error {
450 (vec!["zeph", "error"], 4u8)
451 } else {
452 (vec!["zeph", "turn-complete"], 3u8)
453 };
454
455 let body = NtfyWebhookBody {
456 topic,
457 title,
458 message: &message,
459 tags,
460 priority,
461 };
462
463 tokio::time::timeout(Duration::from_secs(5), client.post(url).json(&body).send())
465 .await??
466 .error_for_status()?;
467
468 Ok(())
469}
470
471#[cfg(test)]
474mod tests {
475 use super::*;
476 use zeph_config::NotificationsConfig;
477
478 fn make_notifier(cfg: NotificationsConfig) -> Notifier {
479 Notifier::new(cfg)
480 }
481
482 fn success_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
483 TurnSummary {
484 duration_ms,
485 preview: "All done.".to_owned(),
486 tool_calls: 0,
487 llm_requests,
488 exit_status: TurnExitStatus::Success,
489 }
490 }
491
492 fn error_summary(duration_ms: u64, llm_requests: u32) -> TurnSummary {
493 TurnSummary {
494 duration_ms,
495 preview: "Error occurred.".to_owned(),
496 tool_calls: 0,
497 llm_requests,
498 exit_status: TurnExitStatus::Error,
499 }
500 }
501
502 #[test]
505 fn should_fire_disabled_master_switch() {
506 let n = make_notifier(NotificationsConfig {
507 enabled: false,
508 ..Default::default()
509 });
510 assert!(!n.should_fire(&success_summary(5000, 1)));
511 }
512
513 #[test]
514 fn should_fire_zero_llm_success_skipped() {
515 let n = make_notifier(NotificationsConfig {
516 enabled: true,
517 ..Default::default()
518 });
519 assert!(!n.should_fire(&success_summary(0, 0)));
521 }
522
523 #[test]
524 fn should_fire_zero_llm_error_fires() {
525 let n = make_notifier(NotificationsConfig {
527 enabled: true,
528 ..Default::default()
529 });
530 assert!(n.should_fire(&error_summary(0, 0)));
531 }
532
533 #[test]
534 fn should_fire_only_on_error_skips_success() {
535 let n = make_notifier(NotificationsConfig {
536 enabled: true,
537 only_on_error: true,
538 ..Default::default()
539 });
540 assert!(!n.should_fire(&success_summary(5000, 1)));
541 }
542
543 #[test]
544 fn should_fire_only_on_error_fires_on_error() {
545 let n = make_notifier(NotificationsConfig {
546 enabled: true,
547 only_on_error: true,
548 ..Default::default()
549 });
550 assert!(n.should_fire(&error_summary(100, 1)));
551 }
552
553 #[test]
554 fn should_fire_duration_gate_success_below_threshold() {
555 let n = make_notifier(NotificationsConfig {
556 enabled: true,
557 min_turn_duration_ms: 3000,
558 ..Default::default()
559 });
560 assert!(!n.should_fire(&success_summary(2999, 1)));
561 }
562
563 #[test]
564 fn should_fire_duration_gate_success_at_threshold() {
565 let n = make_notifier(NotificationsConfig {
566 enabled: true,
567 min_turn_duration_ms: 3000,
568 ..Default::default()
569 });
570 assert!(n.should_fire(&success_summary(3000, 1)));
571 }
572
573 #[test]
574 fn should_fire_error_bypasses_duration_gate() {
575 let n = make_notifier(NotificationsConfig {
577 enabled: true,
578 min_turn_duration_ms: 3000,
579 ..Default::default()
580 });
581 assert!(n.should_fire(&error_summary(100, 1)));
582 }
583
584 #[test]
587 fn sanitize_control_chars_replaced_with_space() {
588 let result = sanitize_applescript_payload("Hello\nWorld", 200);
589 assert!(!result.contains('\n'));
591 assert!(result.contains("Hello World"));
592 }
593
594 #[test]
595 fn sanitize_quotes_stripped() {
596 let result = sanitize_applescript_payload(r#"say "hi""#, 200);
598 assert!(!result.contains('"'));
599 assert_eq!(result, "say hi");
600 }
601
602 #[test]
603 fn sanitize_backslash_stripped() {
604 let result = sanitize_applescript_payload(r"C:\Users\foo", 200);
606 assert_eq!(result, "C:Usersfoo");
607 }
608
609 #[test]
610 fn sanitize_truncation_appends_ellipsis() {
611 let long = "a".repeat(300);
612 let result = sanitize_applescript_payload(&long, 200);
613 assert!(result.ends_with('…'));
614 assert_eq!(result.chars().count(), 201);
616 }
617
618 #[test]
619 fn sanitize_no_truncation_when_short() {
620 let result = sanitize_applescript_payload("short", 200);
621 assert_eq!(result, "short");
622 }
623
624 #[test]
625 fn sanitize_injection_attempt() {
626 let payload = r#""; display dialog "gotcha"; ""#;
628 let result = sanitize_applescript_payload(payload, 200);
629 assert!(!result.contains('"'));
631 }
632
633 #[test]
634 fn sanitize_applescript_payload_empty() {
635 assert_eq!(sanitize_applescript_payload("", 200), "");
636 }
637
638 #[test]
639 fn sanitize_tab_replaced() {
640 let result = sanitize_applescript_payload("a\tb", 200);
641 assert_eq!(result, "a b");
642 }
643
644 #[test]
645 fn sanitize_line_separators() {
646 let s = "hello\u{2028}world\u{2029}end";
647 let result = sanitize_applescript_payload(s, 200);
648 assert!(!result.contains('\u{2028}'));
649 assert!(!result.contains('\u{2029}'));
650 assert_eq!(result, "hello world end");
651 }
652
653 #[test]
656 fn notification_message_success() {
657 let summary = success_summary(1234, 1);
658 let msg = build_notification_message(&summary);
659 assert!(msg.starts_with("Done"));
660 assert!(msg.contains("1234ms"));
661 }
662
663 #[test]
664 fn notification_message_error() {
665 let summary = error_summary(500, 1);
666 let msg = build_notification_message(&summary);
667 assert!(msg.starts_with("Error"));
668 }
669
670 #[test]
671 fn notification_message_includes_tool_calls_when_nonzero() {
672 let summary = TurnSummary {
673 duration_ms: 1234,
674 preview: "All done.".to_owned(),
675 tool_calls: 3,
676 llm_requests: 1,
677 exit_status: TurnExitStatus::Success,
678 };
679 let msg = build_notification_message(&summary);
680 assert!(
681 msg.contains("3 tool calls"),
682 "message should mention the tool-call count: {msg}"
683 );
684 }
685
686 #[test]
687 fn notification_message_singular_tool_call() {
688 let summary = TurnSummary {
689 duration_ms: 1234,
690 preview: "All done.".to_owned(),
691 tool_calls: 1,
692 llm_requests: 1,
693 exit_status: TurnExitStatus::Success,
694 };
695 let msg = build_notification_message(&summary);
696 assert!(
697 msg.contains("1 tool call") && !msg.contains("1 tool calls"),
698 "singular count should use singular noun: {msg}"
699 );
700 }
701
702 #[test]
703 fn notification_message_omits_tool_calls_when_zero() {
704 let summary = success_summary(1234, 1);
706 let msg = build_notification_message(&summary);
707 assert!(!msg.contains("tool call"), "message should be: {msg}");
708 }
709
710 #[test]
711 fn notification_message_redacts_secrets() {
712 let summary = TurnSummary {
713 duration_ms: 100,
714 preview: "Done. Key: sk-abc123xyz".to_owned(),
715 tool_calls: 0,
716 llm_requests: 1,
717 exit_status: TurnExitStatus::Success,
718 };
719 let msg = build_notification_message(&summary);
720 assert!(!msg.contains("sk-abc123xyz"), "secret must be redacted");
721 assert!(
722 msg.contains("[REDACTED]"),
723 "should contain redaction marker"
724 );
725 }
726}