Skip to main content

orch/channels/
notification.rs

1//! Notification types — task completion events broadcast to all channels.
2//!
3//! When a task completes, the engine constructs a `TaskNotification` and
4//! pushes it through the transport. A dispatcher in the engine loop sends
5//! the notification to all registered channels (Telegram, Discord, etc.).
6//!
7//! The notification level (`all`, `errors_only`, `none`) controls which
8//! events are broadcast. Configured via `notifications.level` in config.
9
10use super::html_escape;
11use serde::{Deserialize, Serialize};
12
13/// Notification level — controls which task events are broadcast.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum NotificationLevel {
17    /// Broadcast review-active and terminal completions only: `done`,
18    /// `in_review`, `approved`, `blocked`, `failed`. Intermediate transitions (`new`,
19    /// `routed`, `in_progress`, `needs_review`) are suppressed.
20    All,
21    /// Broadcast every status transition, including intermediate states.
22    Verbose,
23    /// Only broadcast errors (`blocked`, `failed`).
24    ErrorsOnly,
25    /// Disable notifications entirely.
26    None,
27}
28
29impl NotificationLevel {
30    /// Load from config key `notifications.level`, defaulting to `all`.
31    pub fn from_config() -> Self {
32        match crate::config::get("notifications.level") {
33            Ok(val) => match val.as_str() {
34                "verbose" => Self::Verbose,
35                "errors_only" => Self::ErrorsOnly,
36                "none" => Self::None,
37                _ => Self::All,
38            },
39            Err(_) => Self::All,
40        }
41    }
42
43    /// Whether a notification with this status should be sent.
44    ///
45    /// `needs_review` is intentionally excluded from `All` and `ErrorsOnly`:
46    /// it's the agent-handoff signal that always pairs with a follow-up
47    /// `in_review` (or `blocked`/`failed`) within seconds, so notifying on it
48    /// produces a redundant ping. The review-active states (`in_review`,
49    /// `blocked`, `failed`) are the ones humans actually act on.
50    pub fn should_notify(&self, status: &str) -> bool {
51        match self {
52            Self::All => matches!(
53                status,
54                "done" | "in_review" | "approved" | "blocked" | "failed"
55            ),
56            Self::Verbose => true,
57            Self::ErrorsOnly => matches!(status, "blocked" | "failed"),
58            Self::None => false,
59        }
60    }
61}
62
63/// A task completion notification to broadcast to all channels.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TaskNotification {
66    pub task_id: String,
67    pub title: String,
68    pub status: String,
69    pub agent: String,
70    pub duration_seconds: f64,
71    pub summary: String,
72    /// Repository (owner/repo) this task belongs to, for multi-project routing.
73    pub repo: Option<String>,
74    /// Optional override chat_id for Telegram (e.g. from job `notify_target`).
75    /// When set, the notification is sent directly to this chat_id instead of
76    /// the channel routing logic.
77    #[serde(default)]
78    pub notify_target: Option<String>,
79    /// PR number if a pull request is associated with the task.
80    #[serde(default)]
81    pub pr_number: Option<String>,
82}
83
84/// Build the GitHub URL associated with this task, preferring the PR link
85/// when available and falling back to the issue link for external tasks.
86fn github_link(repo: Option<&str>, task_id: &str, pr_number: Option<&str>) -> Option<String> {
87    let repo = repo?;
88    if let Some(pr) = pr_number {
89        return Some(format!("https://github.com/{repo}/pull/{pr}"));
90    }
91    // External task IDs are bare issue numbers; internal IDs start with "internal:".
92    if !task_id.starts_with("internal:") && task_id.chars().all(|c| c.is_ascii_digit()) {
93        return Some(format!("https://github.com/{repo}/issues/{task_id}"));
94    }
95    None
96}
97
98impl TaskNotification {
99    /// Format for Telegram (HTML).
100    ///
101    /// Use HTML parse mode to avoid fragile Markdown escaping issues. Values are
102    /// escaped for HTML entities (e.g. <, >, &) and simple tags are used for
103    /// bold/monospace formatting.
104    pub fn format_telegram(&self) -> String {
105        let emoji = status_emoji(&self.status);
106        let duration = format_duration(self.duration_seconds);
107        let link_suffix = match github_link(
108            self.repo.as_deref(),
109            &self.task_id,
110            self.pr_number.as_deref(),
111        ) {
112            Some(url) => format!("\n\n<a href=\"{}\">View on GitHub</a>", html_escape(&url)),
113            None => String::new(),
114        };
115
116        format!(
117            "{emoji} <b>{title}</b>\n\
118             {status} · <code>{agent}</code> · {duration} · #{task_id}\n\
119             \n\
120             {summary}{link_suffix}",
121            emoji = emoji,
122            title = html_escape(&self.title),
123            status = html_escape(&self.status),
124            agent = html_escape(&self.agent),
125            duration = duration,
126            task_id = html_escape(&self.task_id),
127            summary = html_escape(&self.summary),
128            link_suffix = link_suffix,
129        )
130    }
131
132    /// Format for Slack (mrkdwn).
133    ///
134    /// Slack uses its own "mrkdwn" dialect: *bold*, _italic_, `code`.
135    pub fn format_slack(&self) -> String {
136        let emoji = status_emoji(&self.status);
137        let duration = format_duration(self.duration_seconds);
138        let link_suffix = match github_link(
139            self.repo.as_deref(),
140            &self.task_id,
141            self.pr_number.as_deref(),
142        ) {
143            Some(url) => format!("\n\n<{url}|View on GitHub>"),
144            None => String::new(),
145        };
146
147        format!(
148            "{emoji} *{title}*\n\
149             {status} · `{agent}` · {duration} · #{task_id}\n\
150             \n\
151             {summary}{link_suffix}",
152            emoji = emoji,
153            title = self.title,
154            status = self.status,
155            agent = self.agent,
156            duration = duration,
157            task_id = self.task_id,
158            summary = self.summary,
159            link_suffix = link_suffix,
160        )
161    }
162
163    /// Format for Discord (Markdown with bold instead of Telegram-style).
164    pub fn format_discord(&self) -> String {
165        let emoji = status_emoji(&self.status);
166        let duration = format_duration(self.duration_seconds);
167        let link_suffix = match github_link(
168            self.repo.as_deref(),
169            &self.task_id,
170            self.pr_number.as_deref(),
171        ) {
172            Some(url) => format!("\n\n[View on GitHub]({url})"),
173            None => String::new(),
174        };
175
176        format!(
177            "{emoji} **{title}**\n\
178             {status} · `{agent}` · {duration} · #{task_id}\n\
179             \n\
180             {summary}{link_suffix}",
181            emoji = emoji,
182            title = self.title,
183            status = self.status,
184            agent = self.agent,
185            duration = duration,
186            task_id = self.task_id,
187            summary = self.summary,
188            link_suffix = link_suffix,
189        )
190    }
191
192    /// Format with project prefix (for General/subscribed channels).
193    ///
194    /// Prepends `[project_name]` to the channel-specific formatted message
195    /// so recipients of multi-project channels can identify the source.
196    pub fn format_with_project(&self, channel: &str) -> String {
197        let project_name = self
198            .repo
199            .as_deref()
200            .and_then(|r| r.split('/').next_back())
201            .unwrap_or("unknown");
202
203        let base = match channel {
204            "discord" => self.format_discord(),
205            "slack" => self.format_slack(),
206            _ => self.format_telegram(),
207        };
208
209        let project_name = if channel == "telegram" {
210            html_escape(project_name)
211        } else {
212            project_name.to_string()
213        };
214        format!("[{project_name}] {base}")
215    }
216}
217
218fn status_emoji(status: &str) -> &'static str {
219    match status {
220        "done" => "✅",
221        "in_review" => "🔍",
222        "approved" => "👍",
223        "in_progress" => "🔄",
224        "needs_review" => "⚠️",
225        "blocked" => "🚫",
226        "failed" => "❌",
227        _ => "📋",
228    }
229}
230
231/// Format seconds into human-readable duration.
232pub fn format_duration(seconds: f64) -> String {
233    if seconds < 60.0 {
234        format!("{seconds:.0}s")
235    } else if seconds < 3600.0 {
236        format!("{:.0}m {:.0}s", (seconds / 60.0).floor(), seconds % 60.0)
237    } else {
238        let hours = (seconds / 3600.0).floor();
239        let mins = ((seconds % 3600.0) / 60.0).floor();
240        format!("{hours:.0}h {mins:.0}m")
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn notification_level_should_notify_all_active_review_only() {
250        let level = NotificationLevel::All;
251        // Active states that warrant a ping — should notify
252        assert!(level.should_notify("done"));
253        assert!(level.should_notify("in_review"));
254        assert!(level.should_notify("blocked"));
255        assert!(level.should_notify("failed"));
256        // needs_review is suppressed: it's a redundant handoff signal that
257        // always pairs with a follow-up in_review/blocked/failed event.
258        assert!(!level.should_notify("needs_review"));
259        // Other intermediate states — should NOT notify
260        assert!(!level.should_notify("new"));
261        assert!(!level.should_notify("routed"));
262        assert!(!level.should_notify("in_progress"));
263    }
264
265    #[test]
266    fn notification_level_should_notify_verbose_fires_for_all() {
267        let level = NotificationLevel::Verbose;
268        assert!(level.should_notify("done"));
269        assert!(level.should_notify("needs_review"));
270        assert!(level.should_notify("blocked"));
271        assert!(level.should_notify("failed"));
272        assert!(level.should_notify("new"));
273        assert!(level.should_notify("routed"));
274        assert!(level.should_notify("in_progress"));
275        assert!(level.should_notify("in_review"));
276    }
277
278    #[test]
279    fn notification_level_should_notify_errors_only() {
280        let level = NotificationLevel::ErrorsOnly;
281        assert!(!level.should_notify("done"));
282        assert!(!level.should_notify("in_progress"));
283        assert!(!level.should_notify("in_review"));
284        assert!(!level.should_notify("needs_review"));
285        assert!(level.should_notify("blocked"));
286        assert!(level.should_notify("failed"));
287    }
288
289    #[test]
290    fn notification_level_should_notify_none() {
291        let level = NotificationLevel::None;
292        assert!(!level.should_notify("done"));
293        assert!(!level.should_notify("needs_review"));
294        assert!(!level.should_notify("in_review"));
295        assert!(!level.should_notify("blocked"));
296    }
297
298    #[test]
299    fn notification_level_default_is_all() {
300        // When config is missing, default to All
301        let level = NotificationLevel::from_config();
302        assert_eq!(level, NotificationLevel::All);
303    }
304
305    #[test]
306    fn format_duration_seconds() {
307        assert_eq!(format_duration(30.0), "30s");
308        assert_eq!(format_duration(0.0), "0s");
309        assert_eq!(format_duration(59.9), "60s");
310    }
311
312    #[test]
313    fn format_duration_minutes() {
314        assert_eq!(format_duration(90.0), "1m 30s");
315        assert_eq!(format_duration(3599.0), "59m 59s");
316    }
317
318    #[test]
319    fn format_duration_hours() {
320        assert_eq!(format_duration(3600.0), "1h 0m");
321        assert_eq!(format_duration(7200.0), "2h 0m");
322        assert_eq!(format_duration(5400.0), "1h 30m");
323    }
324
325    #[test]
326    fn format_telegram_done() {
327        let n = TaskNotification {
328            task_id: "42".to_string(),
329            title: "Fix auth bug".to_string(),
330            status: "done".to_string(),
331            agent: "claude".to_string(),
332            duration_seconds: 120.0,
333            summary: "Fixed the OAuth flow".to_string(),
334            repo: Some("owner/my-project".to_string()),
335            notify_target: None,
336            pr_number: None,
337        };
338        let msg = n.format_telegram();
339        assert!(msg.contains("✅"));
340        // Title-first layout: title is the headline, metadata line carries the id.
341        assert!(
342            msg.contains("<b>Fix auth bug</b>"),
343            "title should be the headline: {msg}"
344        );
345        assert!(msg.contains("#42"));
346        assert!(msg.contains("done"));
347        assert!(msg.contains("claude"));
348        assert!(msg.contains("2m 0s"));
349        assert!(msg.contains("Fixed the OAuth flow"));
350    }
351
352    #[test]
353    fn format_telegram_escapes_markdown() {
354        let n = TaskNotification {
355            task_id: "1".to_string(),
356            title: "Fix _underscores_ and *bold*".to_string(),
357            status: "done".to_string(),
358            agent: "claude".to_string(),
359            duration_seconds: 10.0,
360            summary: "Done".to_string(),
361            repo: None,
362            notify_target: None,
363            pr_number: None,
364        };
365        let msg = n.format_telegram();
366        // HTML mode: underscores and asterisks are literal characters; ensure
367        // HTML escaping did not mangle them and that title is present.
368        assert!(msg.contains("Fix _underscores_ and *bold*"));
369        // Ensure HTML tags are present for bold title
370        assert!(msg.contains("<b>Fix _underscores_ and *bold*</b>"));
371    }
372
373    #[test]
374    fn format_telegram_escapes_status_with_underscores() {
375        let n = TaskNotification {
376            task_id: "5".to_string(),
377            title: "Test task".to_string(),
378            status: "needs_review".to_string(),
379            agent: "claude".to_string(),
380            duration_seconds: 30.0,
381            summary: "Ready for review".to_string(),
382            repo: None,
383            notify_target: None,
384            pr_number: None,
385        };
386        let msg = n.format_telegram();
387        // In HTML mode underscores are literal; ensure status is present and
388        // included without backslashes.
389        assert!(msg.contains("needs_review"));
390    }
391
392    #[test]
393    fn format_telegram_escapes_agent_with_underscores() {
394        let n = TaskNotification {
395            task_id: "6".to_string(),
396            title: "Test".to_string(),
397            status: "done".to_string(),
398            agent: "my_agent".to_string(),
399            duration_seconds: 15.0,
400            summary: "Done".to_string(),
401            repo: None,
402            notify_target: None,
403            pr_number: None,
404        };
405        let msg = n.format_telegram();
406        // HTML mode: underscores are literal in text and agent is enclosed in
407        // <code> tags.
408        assert!(msg.contains("<code>my_agent</code>"));
409    }
410
411    #[test]
412    fn format_slack_done() {
413        let n = TaskNotification {
414            task_id: "7".to_string(),
415            title: "Refactor engine".to_string(),
416            status: "done".to_string(),
417            agent: "claude".to_string(),
418            duration_seconds: 60.0,
419            summary: "Decomposed mod.rs".to_string(),
420            repo: Some("org/refactor-repo".to_string()),
421            notify_target: None,
422            pr_number: None,
423        };
424        let msg = n.format_slack();
425        assert!(msg.contains("✅"));
426        assert!(
427            msg.contains("*Refactor engine*"),
428            "title should be the headline: {msg}"
429        );
430        assert!(msg.contains("#7"));
431        assert!(msg.contains("done"));
432        assert!(msg.contains("`claude`"));
433        assert!(msg.contains("1m 0s"));
434        assert!(msg.contains("Decomposed mod.rs"));
435    }
436
437    #[test]
438    fn format_discord_needs_review() {
439        let n = TaskNotification {
440            task_id: "99".to_string(),
441            title: "Deploy service".to_string(),
442            status: "needs_review".to_string(),
443            agent: "codex".to_string(),
444            duration_seconds: 1800.0,
445            summary: "Timed out waiting for tests".to_string(),
446            repo: Some("acme/deploy-svc".to_string()),
447            notify_target: None,
448            pr_number: None,
449        };
450        let msg = n.format_discord();
451        assert!(msg.contains("⚠️"));
452        assert!(
453            msg.contains("**Deploy service**"),
454            "title should be the headline: {msg}"
455        );
456        assert!(msg.contains("#99"));
457        assert!(msg.contains("needs_review"));
458        assert!(msg.contains("codex"));
459        assert!(msg.contains("30m 0s"));
460    }
461
462    #[test]
463    fn format_with_project_prepends_repo_name() {
464        let n = TaskNotification {
465            task_id: "10".to_string(),
466            title: "Add feature".to_string(),
467            status: "done".to_string(),
468            agent: "claude".to_string(),
469            duration_seconds: 45.0,
470            summary: "Feature added".to_string(),
471            repo: Some("acme/widgets".to_string()),
472            notify_target: None,
473            pr_number: None,
474        };
475        let msg = n.format_with_project("telegram");
476        assert!(msg.starts_with("[widgets] "));
477        assert!(msg.contains("#10"));
478        assert!(msg.contains("<b>Add feature</b>"));
479    }
480
481    #[test]
482    fn format_with_project_unknown_when_no_repo() {
483        let n = TaskNotification {
484            task_id: "11".to_string(),
485            title: "Task".to_string(),
486            status: "done".to_string(),
487            agent: "claude".to_string(),
488            duration_seconds: 5.0,
489            summary: "Done".to_string(),
490            repo: None,
491            notify_target: None,
492            pr_number: None,
493        };
494        let msg = n.format_with_project("discord");
495        assert!(msg.starts_with("[unknown] "));
496    }
497
498    #[test]
499    fn status_emoji_mapping() {
500        assert_eq!(status_emoji("done"), "✅");
501        assert_eq!(status_emoji("in_review"), "🔍");
502        assert_eq!(status_emoji("in_progress"), "🔄");
503        assert_eq!(status_emoji("needs_review"), "⚠️");
504        assert_eq!(status_emoji("blocked"), "🚫");
505        assert_eq!(status_emoji("failed"), "❌");
506        assert_eq!(status_emoji("unknown"), "📋");
507    }
508
509    #[test]
510    fn format_with_project_includes_repo_name() {
511        let n = TaskNotification {
512            task_id: "42".into(),
513            title: "Fix bug".into(),
514            status: "done".into(),
515            agent: "claude".into(),
516            duration_seconds: 90.0,
517            summary: "Fixed it".into(),
518            repo: Some("owner/myproject".into()),
519            notify_target: None,
520            pr_number: None,
521        };
522        let formatted = n.format_with_project("telegram");
523        assert!(
524            formatted.starts_with("[myproject]"),
525            "should start with project name: {formatted}"
526        );
527        assert!(formatted.contains("Fix bug"));
528    }
529
530    #[test]
531    fn format_with_project_no_repo_still_works() {
532        let n = TaskNotification {
533            task_id: "42".into(),
534            title: "Fix bug".into(),
535            status: "done".into(),
536            agent: "claude".into(),
537            duration_seconds: 90.0,
538            summary: "Fixed it".into(),
539            repo: None,
540            notify_target: None,
541            pr_number: None,
542        };
543        let formatted = n.format_with_project("telegram");
544        assert!(
545            formatted.starts_with("[unknown]"),
546            "should fallback: {formatted}"
547        );
548    }
549
550    #[test]
551    fn title_appears_before_metadata_in_telegram() {
552        let n = TaskNotification {
553            task_id: "internal:151694".to_string(),
554            title: "Morning briefing: daily focus plan".to_string(),
555            status: "done".to_string(),
556            agent: "opencode".to_string(),
557            duration_seconds: 1821.0,
558            summary: "Recap delivered".to_string(),
559            repo: Some("owner/repo".to_string()),
560            notify_target: None,
561            pr_number: None,
562        };
563        let msg = n.format_telegram();
564        let title_pos = msg.find("Morning briefing").expect("title present");
565        let id_pos = msg.find("#internal:151694").expect("id present");
566        assert!(
567            title_pos < id_pos,
568            "title must appear before task id: {msg}"
569        );
570    }
571
572    #[test]
573    fn title_appears_before_metadata_in_discord() {
574        let n = TaskNotification {
575            task_id: "internal:5".to_string(),
576            title: "Deploy svc".to_string(),
577            status: "done".to_string(),
578            agent: "codex".to_string(),
579            duration_seconds: 60.0,
580            summary: "ok".to_string(),
581            repo: Some("acme/svc".to_string()),
582            notify_target: None,
583            pr_number: None,
584        };
585        let msg = n.format_discord();
586        let title_pos = msg.find("Deploy svc").expect("title present");
587        let id_pos = msg.find("#internal:5").expect("id present");
588        assert!(title_pos < id_pos, "title before id: {msg}");
589    }
590
591    #[test]
592    fn format_with_project_uses_channel_format() {
593        let n = TaskNotification {
594            task_id: "5".into(),
595            title: "Deploy".into(),
596            status: "done".into(),
597            agent: "codex".into(),
598            duration_seconds: 60.0,
599            summary: "Deployed".into(),
600            repo: Some("acme/svc".into()),
601            notify_target: None,
602            pr_number: None,
603        };
604        let tg = n.format_with_project("telegram");
605        let dc = n.format_with_project("discord");
606        // Both start with project prefix
607        assert!(tg.starts_with("[svc]"));
608        assert!(dc.starts_with("[svc]"));
609        // Discord uses ** for bold, telegram uses HTML <b> tags — title is the headline now.
610        assert!(dc.contains("**Deploy**"));
611        assert!(tg.contains("<b>Deploy</b>"));
612        // Task id appears on the metadata line.
613        assert!(tg.contains("#5"));
614        assert!(dc.contains("#5"));
615    }
616
617    #[test]
618    fn telegram_appends_pr_link_when_pr_number_present() {
619        let n = TaskNotification {
620            task_id: "internal:151696".to_string(),
621            title: "Market intelligence".to_string(),
622            status: "done".to_string(),
623            agent: "claude".to_string(),
624            duration_seconds: 39.0,
625            summary: "Trending topics".to_string(),
626            repo: Some("owner/repo".to_string()),
627            notify_target: None,
628            pr_number: Some("4242".to_string()),
629        };
630        let msg = n.format_telegram();
631        assert!(
632            msg.contains("https://github.com/owner/repo/pull/4242"),
633            "expected PR link in {msg}"
634        );
635        assert!(msg.contains("View on GitHub"));
636    }
637
638    #[test]
639    fn telegram_falls_back_to_issue_link_for_external_task_without_pr() {
640        let n = TaskNotification {
641            task_id: "1234".to_string(),
642            title: "An issue".to_string(),
643            status: "needs_review".to_string(),
644            agent: "claude".to_string(),
645            duration_seconds: 10.0,
646            summary: "Awaiting review".to_string(),
647            repo: Some("owner/repo".to_string()),
648            notify_target: None,
649            pr_number: None,
650        };
651        let msg = n.format_telegram();
652        assert!(
653            msg.contains("https://github.com/owner/repo/issues/1234"),
654            "expected issue link in {msg}"
655        );
656    }
657
658    #[test]
659    fn no_link_for_internal_task_without_pr() {
660        let n = TaskNotification {
661            task_id: "internal:99".to_string(),
662            title: "Internal task".to_string(),
663            status: "done".to_string(),
664            agent: "claude".to_string(),
665            duration_seconds: 5.0,
666            summary: "Done".to_string(),
667            repo: Some("owner/repo".to_string()),
668            notify_target: None,
669            pr_number: None,
670        };
671        let msg = n.format_telegram();
672        assert!(!msg.contains("github.com"), "should not link: {msg}");
673    }
674
675    #[test]
676    fn discord_appends_pr_link() {
677        let n = TaskNotification {
678            task_id: "internal:7".to_string(),
679            title: "T".to_string(),
680            status: "done".to_string(),
681            agent: "claude".to_string(),
682            duration_seconds: 1.0,
683            summary: "S".to_string(),
684            repo: Some("owner/repo".to_string()),
685            notify_target: None,
686            pr_number: Some("9".to_string()),
687        };
688        let msg = n.format_discord();
689        assert!(msg.contains("[View on GitHub](https://github.com/owner/repo/pull/9)"));
690    }
691
692    #[test]
693    fn slack_appends_pr_link() {
694        let n = TaskNotification {
695            task_id: "internal:7".to_string(),
696            title: "T".to_string(),
697            status: "done".to_string(),
698            agent: "claude".to_string(),
699            duration_seconds: 1.0,
700            summary: "S".to_string(),
701            repo: Some("owner/repo".to_string()),
702            notify_target: None,
703            pr_number: Some("9".to_string()),
704        };
705        let msg = n.format_slack();
706        assert!(msg.contains("<https://github.com/owner/repo/pull/9|View on GitHub>"));
707    }
708}