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