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}
105
106impl NotifyEvent {
107    pub fn event_name(&self) -> &'static str {
108        match self {
109            NotifyEvent::ApprovalPending { .. } => "approval_pending",
110            NotifyEvent::PhaseChange { .. } => "phase_change",
111            NotifyEvent::SessionEnd { .. } => "session_end",
112            NotifyEvent::Anomaly { .. } => "anomaly",
113        }
114    }
115
116    fn to_json(&self) -> serde_json::Value {
117        match self {
118            NotifyEvent::ApprovalPending {
119                draft_id,
120                title,
121                stage_id,
122                role,
123            } => serde_json::json!({
124                "draft_id": draft_id,
125                "title": title,
126                "stage_id": stage_id,
127                "role": role,
128            }),
129            NotifyEvent::PhaseChange {
130                session_id,
131                from,
132                to,
133                issue,
134            } => serde_json::json!({
135                "session_id": session_id,
136                "from": from,
137                "to": to,
138                "issue": issue,
139            }),
140            NotifyEvent::SessionEnd {
141                session_id,
142                outcome,
143                duration_minutes,
144                summary,
145            } => serde_json::json!({
146                "session_id": session_id,
147                "outcome": outcome,
148                "duration_minutes": duration_minutes,
149                "summary": summary,
150            }),
151            NotifyEvent::Anomaly {
152                signal_type,
153                count,
154                detail,
155            } => serde_json::json!({
156                "signal_type": signal_type,
157                "count": count,
158                "detail": detail,
159            }),
160        }
161    }
162}
163
164// ── Dispatch ──
165
166const TIMEOUT: Duration = Duration::from_secs(5);
167
168fn make_agent() -> ureq::Agent {
169    ureq::Agent::config_builder()
170        .timeout_global(Some(TIMEOUT))
171        .build()
172        .new_agent()
173}
174
175/// Send notifications to all channels matching this event.
176/// Errors are logged to stderr but never propagated.
177pub fn dispatch(config: &NotifyConfig, event: &NotifyEvent) {
178    let agent = make_agent();
179    for channel in &config.channels {
180        if !channel.matches(event) {
181            continue;
182        }
183        let name = channel.display_name();
184        if let Err(e) = send(&agent, channel, event) {
185            tracing::warn!(channel = %name, error = %e, "notification send failed");
186        }
187    }
188}
189
190/// Send a test notification to all configured channels.
191/// Returns per-channel results for CLI display.
192pub fn test_channels(config: &NotifyConfig) -> Vec<(String, Result<(), String>)> {
193    let test_event = NotifyEvent::SessionEnd {
194        session_id: "test".to_string(),
195        outcome: "test".to_string(),
196        duration_minutes: 0,
197        summary: "edda notify test — if you see this, notifications are working!".to_string(),
198    };
199    let agent = make_agent();
200    config
201        .channels
202        .iter()
203        .map(|ch| {
204            let name = ch.display_name();
205            let result = send(&agent, ch, &test_event).map_err(|e| e.to_string());
206            (name, result)
207        })
208        .collect()
209}
210
211fn send(agent: &ureq::Agent, channel: &Channel, event: &NotifyEvent) -> anyhow::Result<()> {
212    match channel {
213        Channel::Ntfy { url, .. } => send_ntfy(agent, url, event),
214        Channel::Webhook { url, .. } => send_webhook(agent, url, event),
215        Channel::Telegram {
216            bot_token, chat_id, ..
217        } => send_telegram(agent, bot_token, chat_id, event),
218    }
219}
220
221// ── ntfy ──
222
223fn send_ntfy(agent: &ureq::Agent, url: &str, event: &NotifyEvent) -> anyhow::Result<()> {
224    let (title, body, priority) = format_ntfy(event);
225    agent
226        .post(url)
227        .header("Title", &title)
228        .header("Priority", &priority)
229        .send(&body)?;
230    Ok(())
231}
232
233fn format_ntfy(event: &NotifyEvent) -> (String, String, String) {
234    match event {
235        NotifyEvent::ApprovalPending {
236            title,
237            role,
238            draft_id,
239            ..
240        } => (
241            format!("Approval needed: {title}"),
242            format!("Draft {draft_id} requires {role} approval"),
243            "high".to_string(),
244        ),
245        NotifyEvent::PhaseChange {
246            from, to, issue, ..
247        } => {
248            let issue_str = issue.map_or(String::new(), |i| format!(" (#{i})"));
249            (
250                format!("Phase: {from} -> {to}{issue_str}"),
251                format!("Agent transitioned from {from} to {to}"),
252                "default".to_string(),
253            )
254        }
255        NotifyEvent::SessionEnd {
256            outcome, summary, ..
257        } => (
258            format!("Session ended: {outcome}"),
259            if summary.is_empty() {
260                "Agent session completed".to_string()
261            } else {
262                summary.clone()
263            },
264            "low".to_string(),
265        ),
266        NotifyEvent::Anomaly {
267            signal_type,
268            count,
269            detail,
270        } => (
271            format!("Anomaly: {signal_type} x{count}"),
272            detail.clone(),
273            "urgent".to_string(),
274        ),
275    }
276}
277
278// ── Webhook (generic JSON POST) ──
279
280fn send_webhook(agent: &ureq::Agent, url: &str, event: &NotifyEvent) -> anyhow::Result<()> {
281    let payload = format_webhook(event);
282    agent
283        .post(url)
284        .header("Content-Type", "application/json")
285        .send(payload.to_string())?;
286    Ok(())
287}
288
289fn format_webhook(event: &NotifyEvent) -> serde_json::Value {
290    serde_json::json!({
291        "event_type": event.event_name(),
292        "data": event.to_json(),
293    })
294}
295
296// ── Telegram ──
297
298fn send_telegram(
299    agent: &ureq::Agent,
300    bot_token: &str,
301    chat_id: &str,
302    event: &NotifyEvent,
303) -> anyhow::Result<()> {
304    let text = format_telegram(event);
305    let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
306    let body = serde_json::json!({
307        "chat_id": chat_id,
308        "text": text,
309        "parse_mode": "HTML",
310    });
311    agent
312        .post(&url)
313        .header("Content-Type", "application/json")
314        .send(body.to_string())?;
315    Ok(())
316}
317
318fn format_telegram(event: &NotifyEvent) -> String {
319    match event {
320        NotifyEvent::ApprovalPending {
321            title,
322            role,
323            draft_id,
324            ..
325        } => {
326            let t = escape_html(title);
327            let d = escape_html(draft_id);
328            let r = escape_html(role);
329            format!(
330                "<b>Approval needed</b>\n{t}\nDraft <code>{d}</code> requires <i>{r}</i> approval"
331            )
332        }
333        NotifyEvent::PhaseChange {
334            from, to, issue, ..
335        } => {
336            let issue_str = issue.map_or(String::new(), |i| format!(" (#{})", i));
337            let f = escape_html(from);
338            let t = escape_html(to);
339            format!("<b>Phase change</b>{issue_str}\n{f} \u{2192} {t}")
340        }
341        NotifyEvent::SessionEnd {
342            outcome, summary, ..
343        } => {
344            let o = escape_html(outcome);
345            if summary.is_empty() {
346                format!("<b>Session ended</b>: {o}")
347            } else {
348                let s = escape_html(summary);
349                format!("<b>Session ended</b>: {o}\n{s}")
350            }
351        }
352        NotifyEvent::Anomaly {
353            signal_type,
354            count,
355            detail,
356        } => {
357            let st = escape_html(signal_type);
358            let d = escape_html(detail);
359            format!("<b>Anomaly detected</b>\n{st} x{count}\n{d}")
360        }
361    }
362}
363
364fn escape_html(s: &str) -> String {
365    s.replace('&', "&amp;")
366        .replace('<', "&lt;")
367        .replace('>', "&gt;")
368}
369
370// ── Tests ──
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn config_deserialize_ntfy() {
378        let json =
379            r#"[{"type":"ntfy","url":"https://ntfy.sh/test","events":["approval_pending"]}]"#;
380        let channels: Vec<Channel> = serde_json::from_str(json).unwrap();
381        assert_eq!(channels.len(), 1);
382        assert!(
383            matches!(&channels[0], Channel::Ntfy { url, events } if url == "https://ntfy.sh/test" && events == &["approval_pending"])
384        );
385    }
386
387    #[test]
388    fn config_deserialize_all_types() {
389        let json = r#"[
390            {"type":"ntfy","url":"https://ntfy.sh/t","events":["approval_pending"]},
391            {"type":"webhook","url":"https://hooks.slack.com/xxx","events":["phase_change"]},
392            {"type":"telegram","bot_token":"123:ABC","chat_id":"456","events":["session_end"]}
393        ]"#;
394        let channels: Vec<Channel> = serde_json::from_str(json).unwrap();
395        assert_eq!(channels.len(), 3);
396        assert!(matches!(&channels[0], Channel::Ntfy { .. }));
397        assert!(matches!(&channels[1], Channel::Webhook { .. }));
398        assert!(matches!(&channels[2], Channel::Telegram { .. }));
399    }
400
401    #[test]
402    fn config_load_missing_file() {
403        let paths = edda_ledger::EddaPaths::discover(std::path::Path::new("/nonexistent"));
404        let config = NotifyConfig::load(&paths);
405        assert!(config.channels.is_empty());
406    }
407
408    #[test]
409    fn event_matches_channel() {
410        let ch: Channel = serde_json::from_value(serde_json::json!({
411            "type": "ntfy",
412            "url": "https://ntfy.sh/test",
413            "events": ["approval_pending", "anomaly"]
414        }))
415        .unwrap();
416
417        let approval = NotifyEvent::ApprovalPending {
418            draft_id: "d1".into(),
419            title: "t".into(),
420            stage_id: "s1".into(),
421            role: "reviewer".into(),
422        };
423        assert!(ch.matches(&approval));
424
425        let phase = NotifyEvent::PhaseChange {
426            session_id: "s1".into(),
427            from: "Research".into(),
428            to: "Plan".into(),
429            issue: None,
430        };
431        assert!(!ch.matches(&phase));
432    }
433
434    #[test]
435    fn wildcard_matches_all() {
436        let ch: Channel = serde_json::from_value(serde_json::json!({
437            "type": "webhook",
438            "url": "https://example.com/hook",
439            "events": ["*"]
440        }))
441        .unwrap();
442
443        let event = NotifyEvent::SessionEnd {
444            session_id: "s1".into(),
445            outcome: "completed".into(),
446            duration_minutes: 30,
447            summary: String::new(),
448        };
449        assert!(ch.matches(&event));
450    }
451
452    #[test]
453    fn format_ntfy_approval_pending() {
454        let event = NotifyEvent::ApprovalPending {
455            draft_id: "drf_123".into(),
456            title: "Add auth module".into(),
457            stage_id: "stage_1".into(),
458            role: "tech-lead".into(),
459        };
460        let (title, body, priority) = format_ntfy(&event);
461        assert!(title.contains("Approval needed"));
462        assert!(title.contains("Add auth module"));
463        assert!(body.contains("drf_123"));
464        assert!(body.contains("tech-lead"));
465        assert_eq!(priority, "high");
466    }
467
468    #[test]
469    fn format_ntfy_phase_change() {
470        let event = NotifyEvent::PhaseChange {
471            session_id: "s1".into(),
472            from: "Research".into(),
473            to: "Implement".into(),
474            issue: Some(42),
475        };
476        let (title, body, priority) = format_ntfy(&event);
477        assert!(title.contains("Research -> Implement"));
478        assert!(title.contains("#42"));
479        assert!(body.contains("Research"));
480        assert_eq!(priority, "default");
481    }
482
483    #[test]
484    fn format_webhook_payload() {
485        let event = NotifyEvent::ApprovalPending {
486            draft_id: "drf_1".into(),
487            title: "Fix bug".into(),
488            stage_id: "s1".into(),
489            role: "reviewer".into(),
490        };
491        let payload = format_webhook(&event);
492        assert_eq!(payload["event_type"], "approval_pending");
493        assert_eq!(payload["data"]["draft_id"], "drf_1");
494        assert_eq!(payload["data"]["title"], "Fix bug");
495    }
496
497    #[test]
498    fn format_telegram_approval() {
499        let event = NotifyEvent::ApprovalPending {
500            draft_id: "drf_1".into(),
501            title: "Deploy v2".into(),
502            stage_id: "s1".into(),
503            role: "ops".into(),
504        };
505        let text = format_telegram(&event);
506        assert!(text.contains("<b>Approval needed</b>"));
507        assert!(text.contains("Deploy v2"));
508        assert!(text.contains("<code>drf_1</code>"));
509        assert!(text.contains("<i>ops</i>"));
510    }
511
512    #[test]
513    fn format_telegram_escapes_html() {
514        let event = NotifyEvent::ApprovalPending {
515            draft_id: "d1".into(),
516            title: "Fix <script> & stuff".into(),
517            stage_id: "s1".into(),
518            role: "dev".into(),
519        };
520        let text = format_telegram(&event);
521        assert!(text.contains("Fix &lt;script&gt; &amp; stuff"));
522    }
523}