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