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