Skip to main content

edda_notify/
lib.rs

1use std::time::Duration;
2
3use serde::Deserialize;
4
5// ── Config ──
6
7/// Notification channel configuration — stored in `.edda/config.json` under key `notify_channels`.
8#[derive(Deserialize, Clone, Debug)]
9#[serde(tag = "type")]
10pub enum Channel {
11    #[serde(rename = "ntfy")]
12    Ntfy { url: String, events: Vec<String> },
13    #[serde(rename = "webhook")]
14    Webhook { url: String, events: Vec<String> },
15    #[serde(rename = "telegram")]
16    Telegram {
17        bot_token: String,
18        chat_id: String,
19        events: Vec<String>,
20    },
21}
22
23impl Channel {
24    fn events(&self) -> &[String] {
25        match self {
26            Channel::Ntfy { events, .. } => events,
27            Channel::Webhook { events, .. } => events,
28            Channel::Telegram { events, .. } => events,
29        }
30    }
31
32    pub fn display_name(&self) -> String {
33        match self {
34            Channel::Ntfy { url, .. } => format!("ntfy({})", url),
35            Channel::Webhook { url, .. } => format!("webhook({})", url),
36            Channel::Telegram { chat_id, .. } => format!("telegram(chat:{})", chat_id),
37        }
38    }
39
40    fn matches(&self, event: &NotifyEvent) -> bool {
41        let name = event.event_name();
42        self.events().iter().any(|e| e == name || e == "*")
43    }
44}
45
46/// Top-level notify configuration.
47#[derive(Deserialize, Clone, Debug, Default)]
48pub struct NotifyConfig {
49    pub channels: Vec<Channel>,
50}
51
52impl NotifyConfig {
53    /// Load from `.edda/config.json` key `notify_channels`.
54    /// Returns empty config if key is missing or unparseable.
55    pub fn load(paths: &edda_ledger::EddaPaths) -> Self {
56        let path = &paths.config_json;
57        let content = match std::fs::read_to_string(path) {
58            Ok(c) => c,
59            Err(_) => return Self::default(),
60        };
61        let val: serde_json::Value = match serde_json::from_str(&content) {
62            Ok(v) => v,
63            Err(_) => return Self::default(),
64        };
65        let channels_val = match val.get("notify_channels") {
66            Some(v) => v.clone(),
67            None => return Self::default(),
68        };
69        let channels: Vec<Channel> = match serde_json::from_value(channels_val) {
70            Ok(c) => c,
71            Err(_) => return Self::default(),
72        };
73        Self { channels }
74    }
75}
76
77// ── Notification Events ──
78
79/// Notification event types mapped from edda domain events.
80pub enum NotifyEvent {
81    ApprovalPending {
82        draft_id: String,
83        title: String,
84        stage_id: String,
85        role: String,
86    },
87    PhaseChange {
88        session_id: String,
89        from: String,
90        to: String,
91        issue: Option<u64>,
92    },
93    SessionEnd {
94        session_id: String,
95        outcome: String,
96        duration_minutes: u64,
97        summary: String,
98    },
99    Anomaly {
100        signal_type: String,
101        count: usize,
102        detail: String,
103    },
104    RequestPending {
105        from_label: String,
106        to_label: String,
107        message: String,
108    },
109    TaskAssigned {
110        task_id: u64,
111        title: String,
112        assignee: String,
113    },
114}
115
116impl NotifyEvent {
117    pub fn event_name(&self) -> &'static str {
118        match self {
119            NotifyEvent::ApprovalPending { .. } => "approval_pending",
120            NotifyEvent::PhaseChange { .. } => "phase_change",
121            NotifyEvent::SessionEnd { .. } => "session_end",
122            NotifyEvent::Anomaly { .. } => "anomaly",
123            NotifyEvent::RequestPending { .. } => "request_pending",
124            NotifyEvent::TaskAssigned { .. } => "task_assigned",
125        }
126    }
127
128    fn to_json(&self) -> serde_json::Value {
129        match self {
130            NotifyEvent::ApprovalPending {
131                draft_id,
132                title,
133                stage_id,
134                role,
135            } => serde_json::json!({
136                "draft_id": draft_id,
137                "title": title,
138                "stage_id": stage_id,
139                "role": role,
140            }),
141            NotifyEvent::PhaseChange {
142                session_id,
143                from,
144                to,
145                issue,
146            } => serde_json::json!({
147                "session_id": session_id,
148                "from": from,
149                "to": to,
150                "issue": issue,
151            }),
152            NotifyEvent::SessionEnd {
153                session_id,
154                outcome,
155                duration_minutes,
156                summary,
157            } => serde_json::json!({
158                "session_id": session_id,
159                "outcome": outcome,
160                "duration_minutes": duration_minutes,
161                "summary": summary,
162            }),
163            NotifyEvent::Anomaly {
164                signal_type,
165                count,
166                detail,
167            } => serde_json::json!({
168                "signal_type": signal_type,
169                "count": count,
170                "detail": detail,
171            }),
172            NotifyEvent::RequestPending {
173                from_label,
174                to_label,
175                message,
176            } => serde_json::json!({
177                "from_label": from_label,
178                "to_label": to_label,
179                "message": message,
180            }),
181            NotifyEvent::TaskAssigned {
182                task_id,
183                title,
184                assignee,
185            } => serde_json::json!({
186                "task_id": task_id,
187                "title": title,
188                "assignee": assignee,
189            }),
190        }
191    }
192}
193
194// ── Dispatch ──
195
196const TIMEOUT: Duration = Duration::from_secs(5);
197
198fn make_agent() -> ureq::Agent {
199    ureq::Agent::config_builder()
200        .timeout_global(Some(TIMEOUT))
201        .build()
202        .new_agent()
203}
204
205/// Send notifications to all channels matching this event.
206/// Errors are logged to stderr but never propagated.
207pub fn dispatch(config: &NotifyConfig, event: &NotifyEvent) {
208    let agent = make_agent();
209    for channel in &config.channels {
210        if !channel.matches(event) {
211            continue;
212        }
213        let name = channel.display_name();
214        if let Err(e) = send(&agent, channel, event) {
215            tracing::warn!(channel = %name, error = %e, "notification send failed");
216        }
217    }
218}
219
220/// Send a test notification to all configured channels.
221/// Returns per-channel results for CLI display.
222pub fn test_channels(config: &NotifyConfig) -> Vec<(String, Result<(), String>)> {
223    let test_event = NotifyEvent::SessionEnd {
224        session_id: "test".to_string(),
225        outcome: "test".to_string(),
226        duration_minutes: 0,
227        summary: "edda notify test — if you see this, notifications are working!".to_string(),
228    };
229    let agent = make_agent();
230    config
231        .channels
232        .iter()
233        .map(|ch| {
234            let name = ch.display_name();
235            let result = send(&agent, ch, &test_event).map_err(|e| e.to_string());
236            (name, result)
237        })
238        .collect()
239}
240
241fn send(agent: &ureq::Agent, channel: &Channel, event: &NotifyEvent) -> anyhow::Result<()> {
242    match channel {
243        Channel::Ntfy { url, .. } => send_ntfy(agent, url, event),
244        Channel::Webhook { url, .. } => send_webhook(agent, url, event),
245        Channel::Telegram {
246            bot_token, chat_id, ..
247        } => send_telegram(agent, bot_token, chat_id, event),
248    }
249}
250
251// ── ntfy ──
252
253fn send_ntfy(agent: &ureq::Agent, url: &str, event: &NotifyEvent) -> anyhow::Result<()> {
254    let (title, body, priority) = format_ntfy(event);
255    agent
256        .post(url)
257        .header("Title", &title)
258        .header("Priority", &priority)
259        .send(&body)?;
260    Ok(())
261}
262
263fn format_ntfy(event: &NotifyEvent) -> (String, String, String) {
264    match event {
265        NotifyEvent::ApprovalPending {
266            title,
267            role,
268            draft_id,
269            ..
270        } => (
271            format!("Approval needed: {title}"),
272            format!("Draft {draft_id} requires {role} approval"),
273            "high".to_string(),
274        ),
275        NotifyEvent::PhaseChange {
276            from, to, issue, ..
277        } => {
278            let issue_str = issue.map_or(String::new(), |i| format!(" (#{i})"));
279            (
280                format!("Phase: {from} -> {to}{issue_str}"),
281                format!("Agent transitioned from {from} to {to}"),
282                "default".to_string(),
283            )
284        }
285        NotifyEvent::SessionEnd {
286            outcome, summary, ..
287        } => (
288            format!("Session ended: {outcome}"),
289            if summary.is_empty() {
290                "Agent session completed".to_string()
291            } else {
292                summary.clone()
293            },
294            "low".to_string(),
295        ),
296        NotifyEvent::Anomaly {
297            signal_type,
298            count,
299            detail,
300        } => (
301            format!("Anomaly: {signal_type} x{count}"),
302            detail.clone(),
303            "urgent".to_string(),
304        ),
305        NotifyEvent::RequestPending {
306            from_label,
307            to_label,
308            message,
309        } => (
310            format!("Request for {to_label} from {from_label}"),
311            message.clone(),
312            "high".to_string(),
313        ),
314        NotifyEvent::TaskAssigned {
315            task_id,
316            title,
317            assignee,
318        } => (
319            format!("Task assigned: {title}"),
320            format!("#{task_id} assigned to {assignee}"),
321            "default".to_string(),
322        ),
323    }
324}
325
326// ── Webhook (generic JSON POST) ──
327
328fn send_webhook(agent: &ureq::Agent, url: &str, event: &NotifyEvent) -> anyhow::Result<()> {
329    let payload = format_webhook(event);
330    agent
331        .post(url)
332        .header("Content-Type", "application/json")
333        .send(payload.to_string())?;
334    Ok(())
335}
336
337fn format_webhook(event: &NotifyEvent) -> serde_json::Value {
338    serde_json::json!({
339        "event_type": event.event_name(),
340        "data": event.to_json(),
341    })
342}
343
344// ── Telegram ──
345
346fn send_telegram(
347    agent: &ureq::Agent,
348    bot_token: &str,
349    chat_id: &str,
350    event: &NotifyEvent,
351) -> anyhow::Result<()> {
352    let text = format_telegram(event);
353    let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
354    let body = serde_json::json!({
355        "chat_id": chat_id,
356        "text": text,
357        "parse_mode": "HTML",
358    });
359    agent
360        .post(&url)
361        .header("Content-Type", "application/json")
362        .send(body.to_string())?;
363    Ok(())
364}
365
366fn format_telegram(event: &NotifyEvent) -> String {
367    match event {
368        NotifyEvent::ApprovalPending {
369            title,
370            role,
371            draft_id,
372            ..
373        } => {
374            let t = escape_html(title);
375            let d = escape_html(draft_id);
376            let r = escape_html(role);
377            format!(
378                "<b>Approval needed</b>\n{t}\nDraft <code>{d}</code> requires <i>{r}</i> approval"
379            )
380        }
381        NotifyEvent::PhaseChange {
382            from, to, issue, ..
383        } => {
384            let issue_str = issue.map_or(String::new(), |i| format!(" (#{})", i));
385            let f = escape_html(from);
386            let t = escape_html(to);
387            format!("<b>Phase change</b>{issue_str}\n{f} \u{2192} {t}")
388        }
389        NotifyEvent::SessionEnd {
390            outcome, summary, ..
391        } => {
392            let o = escape_html(outcome);
393            if summary.is_empty() {
394                format!("<b>Session ended</b>: {o}")
395            } else {
396                let s = escape_html(summary);
397                format!("<b>Session ended</b>: {o}\n{s}")
398            }
399        }
400        NotifyEvent::Anomaly {
401            signal_type,
402            count,
403            detail,
404        } => {
405            let st = escape_html(signal_type);
406            let d = escape_html(detail);
407            format!("<b>Anomaly detected</b>\n{st} x{count}\n{d}")
408        }
409        NotifyEvent::RequestPending {
410            from_label,
411            to_label,
412            message,
413        } => format!(
414            "<b>Request pending</b>\n{} → {}\n{}",
415            escape_html(from_label),
416            escape_html(to_label),
417            escape_html(message)
418        ),
419        NotifyEvent::TaskAssigned {
420            task_id,
421            title,
422            assignee,
423        } => format!(
424            "<b>Task assigned</b>\n#{} {}\n{}",
425            task_id,
426            escape_html(title),
427            escape_html(assignee)
428        ),
429    }
430}
431
432fn escape_html(s: &str) -> String {
433    s.replace('&', "&amp;")
434        .replace('<', "&lt;")
435        .replace('>', "&gt;")
436}
437
438// ── Tests ──
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn config_deserialize_ntfy() {
446        let json =
447            r#"[{"type":"ntfy","url":"https://ntfy.sh/test","events":["approval_pending"]}]"#;
448        let channels: Vec<Channel> = serde_json::from_str(json).unwrap();
449        assert_eq!(channels.len(), 1);
450        assert!(
451            matches!(&channels[0], Channel::Ntfy { url, events } if url == "https://ntfy.sh/test" && events == &["approval_pending"])
452        );
453    }
454
455    #[test]
456    fn config_deserialize_all_types() {
457        let json = r#"[
458            {"type":"ntfy","url":"https://ntfy.sh/t","events":["approval_pending"]},
459            {"type":"webhook","url":"https://hooks.slack.com/xxx","events":["phase_change"]},
460            {"type":"telegram","bot_token":"123:ABC","chat_id":"456","events":["session_end"]}
461        ]"#;
462        let channels: Vec<Channel> = serde_json::from_str(json).unwrap();
463        assert_eq!(channels.len(), 3);
464        assert!(matches!(&channels[0], Channel::Ntfy { .. }));
465        assert!(matches!(&channels[1], Channel::Webhook { .. }));
466        assert!(matches!(&channels[2], Channel::Telegram { .. }));
467    }
468
469    #[test]
470    fn config_load_missing_file() {
471        let paths = edda_ledger::EddaPaths::discover(std::path::Path::new("/nonexistent"));
472        let config = NotifyConfig::load(&paths);
473        assert!(config.channels.is_empty());
474    }
475
476    #[test]
477    fn event_matches_channel() {
478        let ch: Channel = serde_json::from_value(serde_json::json!({
479            "type": "ntfy",
480            "url": "https://ntfy.sh/test",
481            "events": ["approval_pending", "anomaly"]
482        }))
483        .unwrap();
484
485        let approval = NotifyEvent::ApprovalPending {
486            draft_id: "d1".into(),
487            title: "t".into(),
488            stage_id: "s1".into(),
489            role: "reviewer".into(),
490        };
491        assert!(ch.matches(&approval));
492
493        let phase = NotifyEvent::PhaseChange {
494            session_id: "s1".into(),
495            from: "Research".into(),
496            to: "Plan".into(),
497            issue: None,
498        };
499        assert!(!ch.matches(&phase));
500    }
501
502    #[test]
503    fn coordination_events_have_stable_names_and_payloads() {
504        let request = NotifyEvent::RequestPending {
505            from_label: "auth".into(),
506            to_label: "billing".into(),
507            message: "need invoice type".into(),
508        };
509        assert_eq!(request.event_name(), "request_pending");
510        assert_eq!(request.to_json()["to_label"], "billing");
511
512        let task = NotifyEvent::TaskAssigned {
513            task_id: 11,
514            title: "Fix coordination".into(),
515            assignee: "coord-worker".into(),
516        };
517        assert_eq!(task.event_name(), "task_assigned");
518        assert_eq!(task.to_json()["task_id"], 11);
519    }
520
521    #[test]
522    fn wildcard_matches_all() {
523        let ch: Channel = serde_json::from_value(serde_json::json!({
524            "type": "webhook",
525            "url": "https://example.com/hook",
526            "events": ["*"]
527        }))
528        .unwrap();
529
530        let event = NotifyEvent::SessionEnd {
531            session_id: "s1".into(),
532            outcome: "completed".into(),
533            duration_minutes: 30,
534            summary: String::new(),
535        };
536        assert!(ch.matches(&event));
537    }
538
539    #[test]
540    fn format_ntfy_approval_pending() {
541        let event = NotifyEvent::ApprovalPending {
542            draft_id: "drf_123".into(),
543            title: "Add auth module".into(),
544            stage_id: "stage_1".into(),
545            role: "tech-lead".into(),
546        };
547        let (title, body, priority) = format_ntfy(&event);
548        assert!(title.contains("Approval needed"));
549        assert!(title.contains("Add auth module"));
550        assert!(body.contains("drf_123"));
551        assert!(body.contains("tech-lead"));
552        assert_eq!(priority, "high");
553    }
554
555    #[test]
556    fn format_ntfy_phase_change() {
557        let event = NotifyEvent::PhaseChange {
558            session_id: "s1".into(),
559            from: "Research".into(),
560            to: "Implement".into(),
561            issue: Some(42),
562        };
563        let (title, body, priority) = format_ntfy(&event);
564        assert!(title.contains("Research -> Implement"));
565        assert!(title.contains("#42"));
566        assert!(body.contains("Research"));
567        assert_eq!(priority, "default");
568    }
569
570    #[test]
571    fn format_webhook_payload() {
572        let event = NotifyEvent::ApprovalPending {
573            draft_id: "drf_1".into(),
574            title: "Fix bug".into(),
575            stage_id: "s1".into(),
576            role: "reviewer".into(),
577        };
578        let payload = format_webhook(&event);
579        assert_eq!(payload["event_type"], "approval_pending");
580        assert_eq!(payload["data"]["draft_id"], "drf_1");
581        assert_eq!(payload["data"]["title"], "Fix bug");
582    }
583
584    #[test]
585    fn format_telegram_approval() {
586        let event = NotifyEvent::ApprovalPending {
587            draft_id: "drf_1".into(),
588            title: "Deploy v2".into(),
589            stage_id: "s1".into(),
590            role: "ops".into(),
591        };
592        let text = format_telegram(&event);
593        assert!(text.contains("<b>Approval needed</b>"));
594        assert!(text.contains("Deploy v2"));
595        assert!(text.contains("<code>drf_1</code>"));
596        assert!(text.contains("<i>ops</i>"));
597    }
598
599    #[test]
600    fn format_telegram_escapes_html() {
601        let event = NotifyEvent::ApprovalPending {
602            draft_id: "d1".into(),
603            title: "Fix <script> & stuff".into(),
604            stage_id: "s1".into(),
605            role: "dev".into(),
606        };
607        let text = format_telegram(&event);
608        assert!(text.contains("Fix &lt;script&gt; &amp; stuff"));
609    }
610}