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 crate::config;
12use serde::{Deserialize, Serialize};
13
14/// Notification level — controls which task events are broadcast.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum NotificationLevel {
18    /// Broadcast review-active and terminal completions only: `done`,
19    /// `in_review`, `approved`, `blocked`, `failed`. Intermediate transitions (`new`,
20    /// `routed`, `in_progress`, `needs_review`) are suppressed.
21    All,
22    /// Broadcast every status transition, including intermediate states.
23    Verbose,
24    /// Only broadcast errors (`blocked`, `failed`).
25    ErrorsOnly,
26    /// Disable notifications entirely.
27    None,
28}
29
30impl NotificationLevel {
31    /// Load from config key `notifications.level`, defaulting to `all`.
32    pub fn from_config() -> Self {
33        match config::get("notifications.level") {
34            Ok(val) => match val.as_str() {
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    pub fn format_with_project(&self, channel: &str) -> String {
198        let project_name = self
199            .repo
200            .as_deref()
201            .and_then(|r| r.split('/').next_back())
202            .unwrap_or("unknown");
203
204        let base = match channel {
205            "discord" => self.format_discord(),
206            "slack" => self.format_slack(),
207            _ => self.format_telegram(),
208        };
209
210        let project_name = if channel == "telegram" {
211            html_escape(project_name)
212        } else {
213            project_name.to_string()
214        };
215        format!("[{project_name}] {base}")
216    }
217}
218
219fn status_emoji(status: &str) -> &'static str {
220    match status {
221        "done" => "✅",
222        "in_review" => "🔍",
223        "approved" => "👍",
224        "in_progress" => "🔄",
225        "needs_review" => "⚠️",
226        "blocked" => "🚫",
227        "failed" => "❌",
228        _ => "📋",
229    }
230}
231
232/// Format seconds into human-readable duration.
233pub fn format_duration(seconds: f64) -> String {
234    if seconds < 60.0 {
235        format!("{seconds:.0}s")
236    } else if seconds < 3600.0 {
237        format!("{:.0}m {:.0}s", (seconds / 60.0).floor(), seconds % 60.0)
238    } else {
239        let hours = (seconds / 3600.0).floor();
240        let mins = ((seconds % 3600.0) / 60.0).floor();
241        format!("{hours:.0}h {mins:.0}m")
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn notification_level_should_notify_all_active_review_only() {
251        let level = NotificationLevel::All;
252        // Active states that warrant a ping — should notify
253        assert!(level.should_notify("done"));
254        assert!(level.should_notify("in_review"));
255        assert!(level.should_notify("blocked"));
256        assert!(level.should_notify("failed"));
257        // needs_review is suppressed: it's a redundant handoff signal that
258        // always pairs with a follow-up in_review/blocked/failed event.
259        assert!(!level.should_notify("needs_review"));
260        // Other intermediate states — should NOT notify
261        assert!(!level.should_notify("new"));
262        assert!(!level.should_notify("routed"));
263        assert!(!level.should_notify("in_progress"));
264    }
265
266    #[test]
267    fn notification_level_should_notify_verbose_fires_for_all() {
268        let level = NotificationLevel::Verbose;
269        assert!(level.should_notify("done"));
270        assert!(level.should_notify("needs_review"));
271        assert!(level.should_notify("blocked"));
272        assert!(level.should_notify("failed"));
273        assert!(level.should_notify("new"));
274        assert!(level.should_notify("routed"));
275        assert!(level.should_notify("in_progress"));
276        assert!(level.should_notify("in_review"));
277    }
278
279    #[test]
280    fn notification_level_should_notify_errors_only() {
281        let level = NotificationLevel::ErrorsOnly;
282        assert!(!level.should_notify("done"));
283        assert!(!level.should_notify("in_progress"));
284        assert!(!level.should_notify("in_review"));
285        assert!(!level.should_notify("needs_review"));
286        assert!(level.should_notify("blocked"));
287        assert!(level.should_notify("failed"));
288    }
289
290    #[test]
291    fn notification_level_should_notify_none() {
292        let level = NotificationLevel::None;
293        assert!(!level.should_notify("done"));
294        assert!(!level.should_notify("needs_review"));
295        assert!(!level.should_notify("in_review"));
296        assert!(!level.should_notify("blocked"));
297    }
298
299    #[test]
300    fn notification_level_default_is_all() {
301        // When config is missing, default to All
302        let level = NotificationLevel::from_config();
303        assert_eq!(level, NotificationLevel::All);
304    }
305
306    #[test]
307    fn format_duration_seconds() {
308        assert_eq!(format_duration(30.0), "30s");
309        assert_eq!(format_duration(0.0), "0s");
310        assert_eq!(format_duration(59.9), "60s");
311    }
312
313    #[test]
314    fn format_duration_minutes() {
315        assert_eq!(format_duration(90.0), "1m 30s");
316        assert_eq!(format_duration(3599.0), "59m 59s");
317    }
318
319    #[test]
320    fn format_duration_hours() {
321        assert_eq!(format_duration(3600.0), "1h 0m");
322        assert_eq!(format_duration(7200.0), "2h 0m");
323        assert_eq!(format_duration(5400.0), "1h 30m");
324    }
325
326    #[test]
327    fn format_telegram_done() {
328        let n = TaskNotification {
329            task_id: "42".to_string(),
330            title: "Fix auth bug".to_string(),
331            status: "done".to_string(),
332            agent: "claude".to_string(),
333            duration_seconds: 120.0,
334            summary: "Fixed the OAuth flow".to_string(),
335            repo: Some("owner/my-project".to_string()),
336            notify_target: None,
337            pr_number: None,
338        };
339        let msg = n.format_telegram();
340        assert!(msg.contains("✅"));
341        // Title-first layout: title is the headline, metadata line carries the id.
342        assert!(
343            msg.contains("<b>Fix auth bug</b>"),
344            "title should be the headline: {msg}"
345        );
346        assert!(msg.contains("#42"));
347        assert!(msg.contains("done"));
348        assert!(msg.contains("claude"));
349        assert!(msg.contains("2m 0s"));
350        assert!(msg.contains("Fixed the OAuth flow"));
351    }
352
353    #[test]
354    fn format_telegram_escapes_markdown() {
355        let n = TaskNotification {
356            task_id: "1".to_string(),
357            title: "Fix _underscores_ and *bold*".to_string(),
358            status: "done".to_string(),
359            agent: "claude".to_string(),
360            duration_seconds: 10.0,
361            summary: "Done".to_string(),
362            repo: None,
363            notify_target: None,
364            pr_number: None,
365        };
366        let msg = n.format_telegram();
367        // HTML mode: underscores and asterisks are literal characters; ensure
368        // HTML escaping did not mangle them and that title is present.
369        assert!(msg.contains("Fix _underscores_ and *bold*"));
370        // Ensure HTML tags are present for bold title
371        assert!(msg.contains("<b>Fix _underscores_ and *bold*</b>"));
372    }
373
374    #[test]
375    fn format_telegram_escapes_status_with_underscores() {
376        let n = TaskNotification {
377            task_id: "5".to_string(),
378            title: "Test task".to_string(),
379            status: "needs_review".to_string(),
380            agent: "claude".to_string(),
381            duration_seconds: 30.0,
382            summary: "Ready for review".to_string(),
383            repo: None,
384            notify_target: None,
385            pr_number: None,
386        };
387        let msg = n.format_telegram();
388        // In HTML mode underscores are literal; ensure status is present and
389        // included without backslashes.
390        assert!(msg.contains("needs_review"));
391    }
392
393    #[test]
394    fn format_telegram_escapes_agent_with_underscores() {
395        let n = TaskNotification {
396            task_id: "6".to_string(),
397            title: "Test".to_string(),
398            status: "done".to_string(),
399            agent: "my_agent".to_string(),
400            duration_seconds: 15.0,
401            summary: "Done".to_string(),
402            repo: None,
403            notify_target: None,
404            pr_number: None,
405        };
406        let msg = n.format_telegram();
407        // HTML mode: underscores are literal in text and agent is enclosed in
408        // <code> tags.
409        assert!(msg.contains("<code>my_agent</code>"));
410    }
411
412    #[test]
413    fn format_slack_done() {
414        let n = TaskNotification {
415            task_id: "7".to_string(),
416            title: "Refactor engine".to_string(),
417            status: "done".to_string(),
418            agent: "claude".to_string(),
419            duration_seconds: 60.0,
420            summary: "Decomposed mod.rs".to_string(),
421            repo: Some("org/refactor-repo".to_string()),
422            notify_target: None,
423            pr_number: None,
424        };
425        let msg = n.format_slack();
426        assert!(msg.contains("✅"));
427        assert!(
428            msg.contains("*Refactor engine*"),
429            "title should be the headline: {msg}"
430        );
431        assert!(msg.contains("#7"));
432        assert!(msg.contains("done"));
433        assert!(msg.contains("`claude`"));
434        assert!(msg.contains("1m 0s"));
435        assert!(msg.contains("Decomposed mod.rs"));
436    }
437
438    #[test]
439    fn format_discord_needs_review() {
440        let n = TaskNotification {
441            task_id: "99".to_string(),
442            title: "Deploy service".to_string(),
443            status: "needs_review".to_string(),
444            agent: "codex".to_string(),
445            duration_seconds: 1800.0,
446            summary: "Timed out waiting for tests".to_string(),
447            repo: Some("acme/deploy-svc".to_string()),
448            notify_target: None,
449            pr_number: None,
450        };
451        let msg = n.format_discord();
452        assert!(msg.contains("⚠️"));
453        assert!(
454            msg.contains("**Deploy service**"),
455            "title should be the headline: {msg}"
456        );
457        assert!(msg.contains("#99"));
458        assert!(msg.contains("needs_review"));
459        assert!(msg.contains("codex"));
460        assert!(msg.contains("30m 0s"));
461    }
462
463    #[test]
464    fn format_with_project_prepends_repo_name() {
465        let n = TaskNotification {
466            task_id: "10".to_string(),
467            title: "Add feature".to_string(),
468            status: "done".to_string(),
469            agent: "claude".to_string(),
470            duration_seconds: 45.0,
471            summary: "Feature added".to_string(),
472            repo: Some("acme/widgets".to_string()),
473            notify_target: None,
474            pr_number: None,
475        };
476        let msg = n.format_with_project("telegram");
477        assert!(msg.starts_with("[widgets] "));
478        assert!(msg.contains("#10"));
479        assert!(msg.contains("<b>Add feature</b>"));
480    }
481
482    #[test]
483    fn format_with_project_unknown_when_no_repo() {
484        let n = TaskNotification {
485            task_id: "11".to_string(),
486            title: "Task".to_string(),
487            status: "done".to_string(),
488            agent: "claude".to_string(),
489            duration_seconds: 5.0,
490            summary: "Done".to_string(),
491            repo: None,
492            notify_target: None,
493            pr_number: None,
494        };
495        let msg = n.format_with_project("discord");
496        assert!(msg.starts_with("[unknown] "));
497    }
498
499    #[test]
500    fn status_emoji_mapping() {
501        assert_eq!(status_emoji("done"), "✅");
502        assert_eq!(status_emoji("in_review"), "🔍");
503        assert_eq!(status_emoji("in_progress"), "🔄");
504        assert_eq!(status_emoji("needs_review"), "⚠️");
505        assert_eq!(status_emoji("blocked"), "🚫");
506        assert_eq!(status_emoji("failed"), "❌");
507        assert_eq!(status_emoji("unknown"), "📋");
508    }
509
510    #[test]
511    fn format_with_project_includes_repo_name() {
512        let n = TaskNotification {
513            task_id: "42".into(),
514            title: "Fix bug".into(),
515            status: "done".into(),
516            agent: "claude".into(),
517            duration_seconds: 90.0,
518            summary: "Fixed it".into(),
519            repo: Some("owner/myproject".into()),
520            notify_target: None,
521            pr_number: None,
522        };
523        let formatted = n.format_with_project("telegram");
524        assert!(
525            formatted.starts_with("[myproject]"),
526            "should start with project name: {formatted}"
527        );
528        assert!(formatted.contains("Fix bug"));
529    }
530
531    #[test]
532    fn format_with_project_no_repo_still_works() {
533        let n = TaskNotification {
534            task_id: "42".into(),
535            title: "Fix bug".into(),
536            status: "done".into(),
537            agent: "claude".into(),
538            duration_seconds: 90.0,
539            summary: "Fixed it".into(),
540            repo: None,
541            notify_target: None,
542            pr_number: None,
543        };
544        let formatted = n.format_with_project("telegram");
545        assert!(
546            formatted.starts_with("[unknown]"),
547            "should fallback: {formatted}"
548        );
549    }
550
551    #[test]
552    fn title_appears_before_metadata_in_telegram() {
553        let n = TaskNotification {
554            task_id: "internal:151694".to_string(),
555            title: "Morning briefing: daily focus plan".to_string(),
556            status: "done".to_string(),
557            agent: "opencode".to_string(),
558            duration_seconds: 1821.0,
559            summary: "Recap delivered".to_string(),
560            repo: Some("owner/repo".to_string()),
561            notify_target: None,
562            pr_number: None,
563        };
564        let msg = n.format_telegram();
565        let title_pos = msg.find("Morning briefing").expect("title present");
566        let id_pos = msg.find("#internal:151694").expect("id present");
567        assert!(
568            title_pos < id_pos,
569            "title must appear before task id: {msg}"
570        );
571    }
572
573    #[test]
574    fn title_appears_before_metadata_in_discord() {
575        let n = TaskNotification {
576            task_id: "internal:5".to_string(),
577            title: "Deploy svc".to_string(),
578            status: "done".to_string(),
579            agent: "codex".to_string(),
580            duration_seconds: 60.0,
581            summary: "ok".to_string(),
582            repo: Some("acme/svc".to_string()),
583            notify_target: None,
584            pr_number: None,
585        };
586        let msg = n.format_discord();
587        let title_pos = msg.find("Deploy svc").expect("title present");
588        let id_pos = msg.find("#internal:5").expect("id present");
589        assert!(title_pos < id_pos, "title before id: {msg}");
590    }
591
592    #[test]
593    fn format_with_project_uses_channel_format() {
594        let n = TaskNotification {
595            task_id: "5".into(),
596            title: "Deploy".into(),
597            status: "done".into(),
598            agent: "codex".into(),
599            duration_seconds: 60.0,
600            summary: "Deployed".into(),
601            repo: Some("acme/svc".into()),
602            notify_target: None,
603            pr_number: None,
604        };
605        let tg = n.format_with_project("telegram");
606        let dc = n.format_with_project("discord");
607        // Both start with project prefix
608        assert!(tg.starts_with("[svc]"));
609        assert!(dc.starts_with("[svc]"));
610        // Discord uses ** for bold, telegram uses HTML <b> tags — title is the headline now.
611        assert!(dc.contains("**Deploy**"));
612        assert!(tg.contains("<b>Deploy</b>"));
613        // Task id appears on the metadata line.
614        assert!(tg.contains("#5"));
615        assert!(dc.contains("#5"));
616    }
617
618    #[test]
619    fn telegram_appends_pr_link_when_pr_number_present() {
620        let n = TaskNotification {
621            task_id: "internal:151696".to_string(),
622            title: "Market intelligence".to_string(),
623            status: "done".to_string(),
624            agent: "claude".to_string(),
625            duration_seconds: 39.0,
626            summary: "Trending topics".to_string(),
627            repo: Some("owner/repo".to_string()),
628            notify_target: None,
629            pr_number: Some("4242".to_string()),
630        };
631        let msg = n.format_telegram();
632        assert!(
633            msg.contains("https://github.com/owner/repo/pull/4242"),
634            "expected PR link in {msg}"
635        );
636        assert!(msg.contains("View on GitHub"));
637    }
638
639    #[test]
640    fn telegram_falls_back_to_issue_link_for_external_task_without_pr() {
641        let n = TaskNotification {
642            task_id: "1234".to_string(),
643            title: "An issue".to_string(),
644            status: "needs_review".to_string(),
645            agent: "claude".to_string(),
646            duration_seconds: 10.0,
647            summary: "Awaiting review".to_string(),
648            repo: Some("owner/repo".to_string()),
649            notify_target: None,
650            pr_number: None,
651        };
652        let msg = n.format_telegram();
653        assert!(
654            msg.contains("https://github.com/owner/repo/issues/1234"),
655            "expected issue link in {msg}"
656        );
657    }
658
659    #[test]
660    fn no_link_for_internal_task_without_pr() {
661        let n = TaskNotification {
662            task_id: "internal:99".to_string(),
663            title: "Internal task".to_string(),
664            status: "done".to_string(),
665            agent: "claude".to_string(),
666            duration_seconds: 5.0,
667            summary: "Done".to_string(),
668            repo: Some("owner/repo".to_string()),
669            notify_target: None,
670            pr_number: None,
671        };
672        let msg = n.format_telegram();
673        assert!(!msg.contains("github.com"), "should not link: {msg}");
674    }
675
676    #[test]
677    fn discord_appends_pr_link() {
678        let n = TaskNotification {
679            task_id: "internal:7".to_string(),
680            title: "T".to_string(),
681            status: "done".to_string(),
682            agent: "claude".to_string(),
683            duration_seconds: 1.0,
684            summary: "S".to_string(),
685            repo: Some("owner/repo".to_string()),
686            notify_target: None,
687            pr_number: Some("9".to_string()),
688        };
689        let msg = n.format_discord();
690        assert!(msg.contains("[View on GitHub](https://github.com/owner/repo/pull/9)"));
691    }
692
693    #[test]
694    fn slack_appends_pr_link() {
695        let n = TaskNotification {
696            task_id: "internal:7".to_string(),
697            title: "T".to_string(),
698            status: "done".to_string(),
699            agent: "claude".to_string(),
700            duration_seconds: 1.0,
701            summary: "S".to_string(),
702            repo: Some("owner/repo".to_string()),
703            notify_target: None,
704            pr_number: Some("9".to_string()),
705        };
706        let msg = n.format_slack();
707        assert!(msg.contains("<https://github.com/owner/repo/pull/9|View on GitHub>"));
708    }
709}