Skip to main content

edda_notify/
lib.rs

1use serde::Deserialize;
2use std::time::Duration;
3// ── Config ──
4
5/// Notification channel configuration — stored in `.edda/config.json` under key `notify_channels`.
6#[derive(Deserialize, Clone, Debug)]
7#[serde(tag = "type")]
8pub enum Channel {
9    #[serde(rename = "ntfy")]
10    Ntfy { url: String, events: Vec<String> },
11    #[serde(rename = "webhook")]
12    Webhook { url: String, events: Vec<String> },
13    #[serde(rename = "telegram")]
14    Telegram {
15        bot_token: String,
16        chat_id: String,
17        events: Vec<String>,
18    },
19}
20impl Channel {
21    fn events(&self) -> &[String] {
22        match self {
23            Channel::Ntfy { events, .. } => events,
24            Channel::Webhook { events, .. } => events,
25            Channel::Telegram { events, .. } => events,
26        }
27    }
28
29    pub fn display_name(&self) -> String {
30        match self {
31            Channel::Ntfy { url, .. } => format!("ntfy({})", url),
32            Channel::Webhook { url, .. } => format!("webhook({})", url),
33            Channel::Telegram { chat_id, .. } => format!("telegram(chat:{})", chat_id),
34        }
35    }
36
37    fn matches(&self, event: &NotifyEvent) -> bool {
38        let name = event.event_name();
39        self.events().iter().any(|e| e == name || e == "*")
40    }
41}
42/// Top-level notify configuration.
43#[derive(Deserialize, Clone, Debug, Default)]
44pub struct NotifyConfig {
45    pub channels: Vec<Channel>,
46}
47impl NotifyConfig {
48    /// Load from `.edda/config.json` key `notify_channels`.
49    /// Returns empty config if key is missing or unparseable.
50    pub fn load(paths: &edda_ledger::EddaPaths) -> Self {
51        let path = &paths.config_json;
52        let content = match std::fs::read_to_string(path) {
53            Ok(c) => c,
54            Err(_) => return Self::default(),
55        };
56        let val: serde_json::Value = match serde_json::from_str(&content) {
57            Ok(v) => v,
58            Err(_) => return Self::default(),
59        };
60        let channels_val = match val.get("notify_channels") {
61            Some(v) => v.clone(),
62            None => return Self::default(),
63        };
64        let channels: Vec<Channel> = match serde_json::from_value(channels_val) {
65            Ok(c) => c,
66            Err(_) => return Self::default(),
67        };
68        Self { channels }
69    }
70}
71
72// ── Notification Events ──
73
74/// Notification event types mapped from edda domain events.
75#[derive(Clone)]
76pub enum NotifyEvent {
77    ApprovalPending {
78        draft_id: String,
79        title: String,
80        stage_id: String,
81        role: String,
82    },
83    PhaseChange {
84        session_id: String,
85        from: String,
86        to: String,
87        issue: Option<u64>,
88    },
89    SessionEnd {
90        session_id: String,
91        outcome: String,
92        duration_minutes: u64,
93        summary: String,
94    },
95    Anomaly {
96        signal_type: String,
97        count: usize,
98        detail: String,
99    },
100    RequestPending {
101        from_label: String,
102        to_label: String,
103        message: String,
104    },
105    TaskAssigned {
106        task_id: u64,
107        title: String,
108        assignee: String,
109    },
110    /// GH-564: one notification per phase terminal transition. `state` is
111    /// the terminal status name ("Passed" | "Failed" | "Stale" | "Skipped" |
112    /// "Aborted"); "Aborted" is plan-level and names the phase that forced
113    /// the abort. `final_output` carries the agent's last output line when
114    /// the transition site has one (by convention it contains the PR URL).
115    PhaseTerminal {
116        plan: String,
117        phase: String,
118        state: String,
119        attempt: u32,
120        final_output: Option<String>,
121    },
122    /// GH-551/GH-751: progress notification for a gated phase awaiting verdict.
123    GateProgress {
124        plan: String,
125        phase: String,
126        subject: String,
127        gate_sha: String,
128        wait_label: String,
129    },
130    /// GH-765: free-text delivery for the daily fleet digest (and any other
131    /// operator-facing push). `title` is the first line, `body` the rest.
132    Digest { title: String, body: String },
133}
134
135impl NotifyEvent {
136    pub fn event_name(&self) -> &'static str {
137        match self {
138            NotifyEvent::ApprovalPending { .. } => "approval_pending",
139            NotifyEvent::PhaseChange { .. } => "phase_change",
140            NotifyEvent::SessionEnd { .. } => "session_end",
141            NotifyEvent::Anomaly { .. } => "anomaly",
142            NotifyEvent::RequestPending { .. } => "request_pending",
143            NotifyEvent::TaskAssigned { .. } => "task_assigned",
144            NotifyEvent::PhaseTerminal { .. } => "phase_terminal",
145            NotifyEvent::GateProgress { .. } => "gate_progress",
146            NotifyEvent::Digest { .. } => "digest",
147        }
148    }
149
150    fn to_json(&self) -> serde_json::Value {
151        match self {
152            NotifyEvent::ApprovalPending {
153                draft_id,
154                title,
155                stage_id,
156                role,
157            } => serde_json::json!({
158                "draft_id": draft_id,
159                "title": title,
160                "stage_id": stage_id,
161                "role": role,
162            }),
163            NotifyEvent::PhaseChange {
164                session_id,
165                from,
166                to,
167                issue,
168            } => serde_json::json!({
169                "session_id": session_id,
170                "from": from,
171                "to": to,
172                "issue": issue,
173            }),
174            NotifyEvent::SessionEnd {
175                session_id,
176                outcome,
177                duration_minutes,
178                summary,
179            } => serde_json::json!({
180                "session_id": session_id,
181                "outcome": outcome,
182                "duration_minutes": duration_minutes,
183                "summary": summary,
184            }),
185            NotifyEvent::Anomaly {
186                signal_type,
187                count,
188                detail,
189            } => serde_json::json!({
190                "signal_type": signal_type,
191                "count": count,
192                "detail": detail,
193            }),
194            NotifyEvent::RequestPending {
195                from_label,
196                to_label,
197                message,
198            } => serde_json::json!({
199                "from_label": from_label,
200                "to_label": to_label,
201                "message": message,
202            }),
203            NotifyEvent::TaskAssigned {
204                task_id,
205                title,
206                assignee,
207            } => serde_json::json!({
208                "task_id": task_id,
209                "title": title,
210                "assignee": assignee,
211            }),
212            NotifyEvent::PhaseTerminal {
213                plan,
214                phase,
215                state,
216                attempt,
217                final_output,
218            } => serde_json::json!({
219                "plan": plan,
220                "phase": phase,
221                "state": state,
222                "attempt": attempt,
223                "final_output": final_output,
224            }),
225            NotifyEvent::GateProgress {
226                plan,
227                phase,
228                subject,
229                gate_sha,
230                wait_label,
231            } => serde_json::json!({
232                "plan": plan,
233                "phase": phase,
234                "subject": subject,
235                "gate_sha": gate_sha,
236                "wait_label": wait_label,
237            }),
238            NotifyEvent::Digest { title, body } => serde_json::json!({
239                "title": title,
240                "body": body,
241            }),
242        }
243    }
244}
245
246// ── Dispatch ──
247
248const TIMEOUT: Duration = Duration::from_secs(5);
249
250fn make_agent() -> ureq::Agent {
251    ureq::Agent::config_builder()
252        .timeout_global(Some(TIMEOUT))
253        .build()
254        .new_agent()
255}
256
257/// Send notifications to all channels matching this event.
258/// Errors are logged to stderr but never propagated.
259pub fn dispatch(config: &NotifyConfig, event: &NotifyEvent) {
260    let agent = make_agent();
261    for channel in &config.channels {
262        if !channel.matches(event) {
263            continue;
264        }
265        let name = channel.display_name();
266        if let Err(e) = send(&agent, channel, event) {
267            tracing::warn!(channel = %name, error = %e, "notification send failed");
268        }
269    }
270}
271
272/// Send a test notification to all configured channels.
273/// Returns per-channel results for CLI display.
274pub fn test_channels(config: &NotifyConfig) -> Vec<(String, Result<(), String>)> {
275    let test_event = NotifyEvent::SessionEnd {
276        session_id: "test".to_string(),
277        outcome: "test".to_string(),
278        duration_minutes: 0,
279        summary: "edda notify test — if you see this, notifications are working!".to_string(),
280    };
281    let agent = make_agent();
282    config
283        .channels
284        .iter()
285        .map(|ch| {
286            (
287                ch.display_name(),
288                send(&agent, ch, &test_event).map_err(|e| e.to_string()),
289            )
290        })
291        .collect()
292}
293/// Send a free-text message (event name "digest") to every channel whose
294/// `events` list contains "digest" or "*". Same shape as [`test_channels`]:
295/// per-channel results for CLI display; channels that do not subscribe get
296/// a skip error so the operator sees why nothing arrived.
297pub fn send_text(
298    config: &NotifyConfig,
299    title: &str,
300    body: &str,
301) -> Vec<(String, Result<(), String>)> {
302    let event = NotifyEvent::Digest {
303        title: title.to_string(),
304        body: body.to_string(),
305    };
306    send_to_all(config, &event)
307}
308
309fn send_to_all(config: &NotifyConfig, event: &NotifyEvent) -> Vec<(String, Result<(), String>)> {
310    let agent = make_agent();
311    config
312        .channels
313        .iter()
314        .map(|ch| {
315            let name = ch.display_name();
316            let result = if ch.matches(event) {
317                send(&agent, ch, event).map_err(|e| e.to_string())
318            } else {
319                Err(format!(
320                    "channel does not subscribe to {:?} (events: {:?})",
321                    event.event_name(),
322                    ch.events()
323                ))
324            };
325            (name, result)
326        })
327        .collect()
328}
329
330fn send(agent: &ureq::Agent, channel: &Channel, event: &NotifyEvent) -> anyhow::Result<()> {
331    match channel {
332        Channel::Ntfy { url, .. } => send_ntfy(agent, url, event),
333        Channel::Webhook { url, .. } => send_webhook(agent, url, event),
334        Channel::Telegram {
335            bot_token, chat_id, ..
336        } => send_telegram(agent, bot_token, chat_id, event),
337    }
338}
339
340// ── ntfy ──
341
342fn send_ntfy(agent: &ureq::Agent, url: &str, event: &NotifyEvent) -> anyhow::Result<()> {
343    let (title, body, priority) = format_ntfy(event);
344    agent
345        .post(url)
346        .header("Title", &title)
347        .header("Priority", &priority)
348        .send(&body)?;
349    Ok(())
350}
351
352fn format_ntfy(event: &NotifyEvent) -> (String, String, String) {
353    match event {
354        NotifyEvent::ApprovalPending {
355            title,
356            role,
357            draft_id,
358            ..
359        } => (
360            format!("Approval needed: {title}"),
361            format!("Draft {draft_id} requires {role} approval"),
362            "high".to_string(),
363        ),
364        NotifyEvent::PhaseChange {
365            from, to, issue, ..
366        } => {
367            let issue_str = issue.map_or(String::new(), |i| format!(" (#{i})"));
368            (
369                format!("Phase: {from} -> {to}{issue_str}"),
370                format!("Agent transitioned from {from} to {to}"),
371                "default".to_string(),
372            )
373        }
374        NotifyEvent::SessionEnd {
375            outcome, summary, ..
376        } => (
377            format!("Session ended: {outcome}"),
378            if summary.is_empty() {
379                "Agent session completed".to_string()
380            } else {
381                summary.clone()
382            },
383            "low".to_string(),
384        ),
385        NotifyEvent::Anomaly {
386            signal_type,
387            count,
388            detail,
389        } => (
390            format!("Anomaly: {signal_type} x{count}"),
391            detail.clone(),
392            "urgent".to_string(),
393        ),
394        NotifyEvent::RequestPending {
395            from_label,
396            to_label,
397            message,
398        } => (
399            format!("Request for {to_label} from {from_label}"),
400            message.clone(),
401            "high".to_string(),
402        ),
403        NotifyEvent::TaskAssigned {
404            task_id,
405            title,
406            assignee,
407        } => (
408            format!("Task assigned: {title}"),
409            format!("#{task_id} assigned to {assignee}"),
410            "default".to_string(),
411        ),
412        NotifyEvent::PhaseTerminal {
413            plan,
414            phase,
415            state,
416            attempt,
417            final_output,
418        } => {
419            let priority = match state.as_str() {
420                "Failed" | "Aborted" | "Stale" => "high",
421                "Skipped" => "low",
422                _ => "default",
423            };
424            let mut body = format!("plan {plan} · attempt {attempt}");
425            if let Some(out) = final_output {
426                body.push('\n');
427                body.push_str(out);
428            }
429            (
430                format!("Phase {phase}: {state}"),
431                body,
432                priority.to_string(),
433            )
434        }
435        NotifyEvent::GateProgress {
436            subject,
437            gate_sha,
438            wait_label,
439            ..
440        } => (
441            format!("Verdict needed: {subject}"),
442            format!("Waiting on sha {gate_sha} — {wait_label}"),
443            "default".to_string(),
444        ),
445        NotifyEvent::Digest { title, body } => (title.clone(), body.clone(), "default".to_string()),
446    }
447}
448
449// ── Webhook (generic JSON POST) ──
450
451fn send_webhook(agent: &ureq::Agent, url: &str, event: &NotifyEvent) -> anyhow::Result<()> {
452    let payload = format_webhook(event);
453    agent
454        .post(url)
455        .header("Content-Type", "application/json")
456        .send(payload.to_string())?;
457    Ok(())
458}
459
460fn format_webhook(event: &NotifyEvent) -> serde_json::Value {
461    // GH-765: the digest posts a flat payload (event name + title + body) so
462    // simple receivers do not have to unwrap the generic envelope.
463    if let NotifyEvent::Digest { title, body } = event {
464        return serde_json::json!({
465            "event": "digest",
466            "title": title,
467            "body": body,
468        });
469    }
470    serde_json::json!({
471        "event_type": event.event_name(),
472        "data": event.to_json(),
473    })
474}
475
476// ── Telegram ──
477
478fn send_telegram(
479    agent: &ureq::Agent,
480    bot_token: &str,
481    chat_id: &str,
482    event: &NotifyEvent,
483) -> anyhow::Result<()> {
484    let text = format_telegram(event);
485    let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
486    let body = serde_json::json!({
487        "chat_id": chat_id,
488        "text": text,
489        "parse_mode": "HTML",
490    });
491    agent
492        .post(&url)
493        .header("Content-Type", "application/json")
494        .send(body.to_string())?;
495    Ok(())
496}
497
498fn format_telegram(event: &NotifyEvent) -> String {
499    match event {
500        NotifyEvent::ApprovalPending {
501            title,
502            role,
503            draft_id,
504            ..
505        } => {
506            let t = escape_html(title);
507            let d = escape_html(draft_id);
508            let r = escape_html(role);
509            format!(
510                "<b>Approval needed</b>\n{t}\nDraft <code>{d}</code> requires <i>{r}</i> approval"
511            )
512        }
513        NotifyEvent::PhaseChange {
514            from, to, issue, ..
515        } => {
516            let issue_str = issue.map_or(String::new(), |i| format!(" (#{})", i));
517            let f = escape_html(from);
518            let t = escape_html(to);
519            format!("<b>Phase change</b>{issue_str}\n{f} \u{2192} {t}")
520        }
521        NotifyEvent::SessionEnd {
522            outcome, summary, ..
523        } => {
524            let o = escape_html(outcome);
525            if summary.is_empty() {
526                format!("<b>Session ended</b>: {o}")
527            } else {
528                let s = escape_html(summary);
529                format!("<b>Session ended</b>: {o}\n{s}")
530            }
531        }
532        NotifyEvent::Anomaly {
533            signal_type,
534            count,
535            detail,
536        } => {
537            let st = escape_html(signal_type);
538            let d = escape_html(detail);
539            format!("<b>Anomaly detected</b>\n{st} x{count}\n{d}")
540        }
541        NotifyEvent::RequestPending {
542            from_label,
543            to_label,
544            message,
545        } => format!(
546            "<b>Request pending</b>\n{} → {}\n{}",
547            escape_html(from_label),
548            escape_html(to_label),
549            escape_html(message)
550        ),
551        NotifyEvent::TaskAssigned {
552            task_id,
553            title,
554            assignee,
555        } => format!(
556            "<b>Task assigned</b>\n#{} {}\n{}",
557            task_id,
558            escape_html(title),
559            escape_html(assignee)
560        ),
561        NotifyEvent::PhaseTerminal {
562            plan,
563            phase,
564            state,
565            attempt,
566            final_output,
567        } => {
568            let mut text = format!(
569                "<b>Phase {}: {}</b>\nplan {} · attempt {}",
570                escape_html(phase),
571                escape_html(state),
572                escape_html(plan),
573                attempt
574            );
575            if let Some(out) = final_output {
576                text.push('\n');
577                text.push_str(&escape_html(out));
578            }
579            text
580        }
581        NotifyEvent::GateProgress {
582            subject,
583            gate_sha,
584            wait_label,
585            ..
586        } => {
587            let s = escape_html(subject);
588            let g = escape_html(gate_sha);
589            let w = escape_html(wait_label);
590            format!("<b>Verdict needed: {s}</b>\nsha <code>{g}</code> — {w}")
591        }
592        NotifyEvent::Digest { title, body } => truncate_telegram_digest(title, body),
593    }
594}
595/// Telegram rejects messages over 4096 characters; keep a margin for the
596/// HTML entities and the title. A longer text is truncated with a trailing
597/// ellipsis rather than failing delivery.
598const TELEGRAM_MAX_CHARS: usize = 3900;
599
600fn truncate_telegram_digest(title: &str, body: &str) -> String {
601    let escaped_len = |s: &str| escape_html(s).chars().count();
602    let overhead = "<b></b>\n".chars().count();
603    if overhead + escaped_len(title) + escaped_len(body) <= TELEGRAM_MAX_CHARS {
604        return format!("<b>{}</b>\n{}", escape_html(title), escape_html(body));
605    }
606    let title_budget = TELEGRAM_MAX_CHARS - overhead - 1;
607    if escaped_len(title) > title_budget {
608        return format!("<b>{}…</b>", escape_prefix(title, title_budget));
609    }
610    let body_budget = TELEGRAM_MAX_CHARS - overhead - escaped_len(title) - 1;
611    format!(
612        "<b>{}</b>\n{}…",
613        escape_html(title),
614        escape_prefix(body, body_budget)
615    )
616}
617fn escape_prefix(text: &str, budget: usize) -> String {
618    let mut prefix = String::new();
619    for ch in text.chars() {
620        let escaped = escape_html(&ch.to_string());
621        if prefix.chars().count() + escaped.chars().count() > budget {
622            break;
623        }
624        prefix.push_str(&escaped);
625    }
626    prefix
627}
628fn escape_html(s: &str) -> String {
629    s.replace('&', "&amp;")
630        .replace('<', "&lt;")
631        .replace('>', "&gt;")
632}
633
634// ── Tests ──
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn config_deserialize_ntfy() {
642        let json =
643            r#"[{"type":"ntfy","url":"https://ntfy.sh/test","events":["approval_pending"]}]"#;
644        let channels: Vec<Channel> = serde_json::from_str(json).unwrap();
645        assert_eq!(channels.len(), 1);
646        assert!(
647            matches!(&channels[0], Channel::Ntfy { url, events } if url == "https://ntfy.sh/test" && events == &["approval_pending"])
648        );
649    }
650
651    #[test]
652    fn config_deserialize_all_types() {
653        let json = r#"[
654            {"type":"ntfy","url":"https://ntfy.sh/t","events":["approval_pending"]},
655            {"type":"webhook","url":"https://hooks.slack.com/xxx","events":["phase_change"]},
656            {"type":"telegram","bot_token":"123:ABC","chat_id":"456","events":["session_end"]}
657        ]"#;
658        let channels: Vec<Channel> = serde_json::from_str(json).unwrap();
659        assert_eq!(channels.len(), 3);
660        assert!(matches!(&channels[0], Channel::Ntfy { .. }));
661        assert!(matches!(&channels[1], Channel::Webhook { .. }));
662        assert!(matches!(&channels[2], Channel::Telegram { .. }));
663    }
664
665    #[test]
666    fn config_load_missing_file() {
667        let paths = edda_ledger::EddaPaths::discover(std::path::Path::new("/nonexistent"));
668        let config = NotifyConfig::load(&paths);
669        assert!(config.channels.is_empty());
670    }
671
672    #[test]
673    fn event_matches_channel() {
674        let ch: Channel = serde_json::from_value(serde_json::json!({
675            "type": "ntfy",
676            "url": "https://ntfy.sh/test",
677            "events": ["approval_pending", "anomaly"]
678        }))
679        .unwrap();
680
681        let approval = NotifyEvent::ApprovalPending {
682            draft_id: "d1".into(),
683            title: "t".into(),
684            stage_id: "s1".into(),
685            role: "reviewer".into(),
686        };
687        assert!(ch.matches(&approval));
688
689        let phase = NotifyEvent::PhaseChange {
690            session_id: "s1".into(),
691            from: "Research".into(),
692            to: "Plan".into(),
693            issue: None,
694        };
695        assert!(!ch.matches(&phase));
696    }
697
698    #[test]
699    fn coordination_events_have_stable_names_and_payloads() {
700        let request = NotifyEvent::RequestPending {
701            from_label: "auth".into(),
702            to_label: "billing".into(),
703            message: "need invoice type".into(),
704        };
705        assert_eq!(request.event_name(), "request_pending");
706        assert_eq!(request.to_json()["to_label"], "billing");
707
708        let task = NotifyEvent::TaskAssigned {
709            task_id: 11,
710            title: "Fix coordination".into(),
711            assignee: "coord-worker".into(),
712        };
713        assert_eq!(task.event_name(), "task_assigned");
714        assert_eq!(task.to_json()["task_id"], 11);
715    }
716
717    #[test]
718    fn wildcard_matches_all() {
719        let ch: Channel = serde_json::from_value(serde_json::json!({
720            "type": "webhook",
721            "url": "https://example.com/hook",
722            "events": ["*"]
723        }))
724        .unwrap();
725
726        let event = NotifyEvent::SessionEnd {
727            session_id: "s1".into(),
728            outcome: "completed".into(),
729            duration_minutes: 30,
730            summary: String::new(),
731        };
732        assert!(ch.matches(&event));
733    }
734
735    #[test]
736    fn phase_terminal_has_stable_name_payload_and_matching() {
737        let event = NotifyEvent::PhaseTerminal {
738            plan: "gh564".into(),
739            phase: "implement".into(),
740            state: "Passed".into(),
741            attempt: 2,
742            final_output: Some("PR: https://github.com/x/y/pull/9".into()),
743        };
744        assert_eq!(event.event_name(), "phase_terminal");
745        assert_eq!(event.to_json()["state"], "Passed");
746        assert_eq!(event.to_json()["attempt"], 2);
747        assert_eq!(
748            event.to_json()["final_output"],
749            "PR: https://github.com/x/y/pull/9"
750        );
751
752        let ch: Channel = serde_json::from_value(serde_json::json!({
753            "type": "ntfy",
754            "url": "https://ntfy.sh/test",
755            "events": ["phase_terminal"]
756        }))
757        .unwrap();
758        assert!(ch.matches(&event));
759    }
760
761    #[test]
762    fn phase_terminal_none_final_output_serializes_to_null() {
763        let event = NotifyEvent::PhaseTerminal {
764            plan: "p".into(),
765            phase: "a".into(),
766            state: "Stale".into(),
767            attempt: 1,
768            final_output: None,
769        };
770        assert!(event.to_json()["final_output"].is_null());
771    }
772
773    #[test]
774    fn format_ntfy_phase_terminal_priority_by_state() {
775        let mk = |state: &str| NotifyEvent::PhaseTerminal {
776            plan: "p".into(),
777            phase: "a".into(),
778            state: state.into(),
779            attempt: 1,
780            final_output: Some("PR: https://x/1".into()),
781        };
782        let (title, body, priority) = format_ntfy(&mk("Failed"));
783        assert!(title.contains("Phase a: Failed"));
784        assert!(body.contains("PR: https://x/1"));
785        assert_eq!(priority, "high");
786        assert_eq!(format_ntfy(&mk("Passed")).2, "default");
787        assert_eq!(format_ntfy(&mk("Skipped")).2, "low");
788        assert_eq!(format_ntfy(&mk("Aborted")).2, "high");
789    }
790
791    #[test]
792    fn format_telegram_phase_terminal_escapes_html() {
793        let event = NotifyEvent::PhaseTerminal {
794            plan: "p".into(),
795            phase: "<a>".into(),
796            state: "Failed".into(),
797            attempt: 1,
798            final_output: Some("err <&>".into()),
799        };
800        let text = format_telegram(&event);
801        assert!(text.contains("Phase &lt;a&gt;: Failed"));
802        assert!(text.contains("err &lt;&amp;&gt;"));
803    }
804
805    #[test]
806    fn format_webhook_phase_terminal_payload() {
807        let event = NotifyEvent::PhaseTerminal {
808            plan: "p".into(),
809            phase: "a".into(),
810            state: "Skipped".into(),
811            attempt: 3,
812            final_output: None,
813        };
814        let payload = format_webhook(&event);
815        assert_eq!(payload["event_type"], "phase_terminal");
816        assert_eq!(payload["data"]["plan"], "p");
817        assert_eq!(payload["data"]["attempt"], 3);
818    }
819
820    #[test]
821    fn format_gate_progress_events() {
822        let event = NotifyEvent::GateProgress {
823            plan: "p".into(),
824            phase: "a".into(),
825            subject: "p/a".into(),
826            gate_sha: "1234567890abcdef".into(),
827            wait_label: "9m0s remaining".into(),
828        };
829        let (title, body, priority) = format_ntfy(&event);
830        assert_eq!(title, "Verdict needed: p/a");
831        assert_eq!(body, "Waiting on sha 1234567890abcdef — 9m0s remaining");
832        assert_eq!(priority, "default");
833
834        let text = format_telegram(&event);
835        assert!(text.contains("<b>Verdict needed: p/a</b>"));
836        assert!(text.contains("sha <code>1234567890abcdef</code> — 9m0s remaining"));
837
838        let payload = format_webhook(&event);
839        assert_eq!(payload["event_type"], "gate_progress");
840        assert_eq!(payload["data"]["plan"], "p");
841        assert_eq!(payload["data"]["phase"], "a");
842        assert_eq!(payload["data"]["subject"], "p/a");
843        assert_eq!(payload["data"]["gate_sha"], "1234567890abcdef");
844        assert_eq!(payload["data"]["wait_label"], "9m0s remaining");
845    }
846
847    #[test]
848    fn format_ntfy_approval_pending() {
849        let event = NotifyEvent::ApprovalPending {
850            draft_id: "drf_123".into(),
851            title: "Add auth module".into(),
852            stage_id: "stage_1".into(),
853            role: "tech-lead".into(),
854        };
855        let (title, body, priority) = format_ntfy(&event);
856        assert!(title.contains("Approval needed"));
857        assert!(title.contains("Add auth module"));
858        assert!(body.contains("drf_123"));
859        assert!(body.contains("tech-lead"));
860        assert_eq!(priority, "high");
861    }
862
863    #[test]
864    fn format_ntfy_phase_change() {
865        let event = NotifyEvent::PhaseChange {
866            session_id: "s1".into(),
867            from: "Research".into(),
868            to: "Implement".into(),
869            issue: Some(42),
870        };
871        let (title, body, priority) = format_ntfy(&event);
872        assert!(title.contains("Research -> Implement"));
873        assert!(title.contains("#42"));
874        assert!(body.contains("Research"));
875        assert_eq!(priority, "default");
876    }
877
878    #[test]
879    fn format_webhook_payload() {
880        let event = NotifyEvent::ApprovalPending {
881            draft_id: "drf_1".into(),
882            title: "Fix bug".into(),
883            stage_id: "s1".into(),
884            role: "reviewer".into(),
885        };
886        let payload = format_webhook(&event);
887        assert_eq!(payload["event_type"], "approval_pending");
888        assert_eq!(payload["data"]["draft_id"], "drf_1");
889        assert_eq!(payload["data"]["title"], "Fix bug");
890    }
891
892    #[test]
893    fn format_telegram_approval() {
894        let event = NotifyEvent::ApprovalPending {
895            draft_id: "drf_1".into(),
896            title: "Deploy v2".into(),
897            stage_id: "s1".into(),
898            role: "ops".into(),
899        };
900        let text = format_telegram(&event);
901        assert!(text.contains("<b>Approval needed</b>"));
902        assert!(text.contains("Deploy v2"));
903        assert!(text.contains("<code>drf_1</code>"));
904        assert!(text.contains("<i>ops</i>"));
905    }
906
907    #[test]
908    fn format_telegram_escapes_html() {
909        let event = NotifyEvent::ApprovalPending {
910            draft_id: "d1".into(),
911            title: "Fix <script> & stuff".into(),
912            stage_id: "s1".into(),
913            role: "dev".into(),
914        };
915        let text = format_telegram(&event);
916        assert!(text.contains("Fix &lt;script&gt; &amp; stuff"));
917    }
918
919    #[test]
920    fn digest_has_stable_name_and_flat_webhook_payload() {
921        let event = NotifyEvent::Digest {
922            title: "Fleet digest 2026-09-03".into(),
923            body: "## 例外\n(無)\n".into(),
924        };
925        assert_eq!(event.event_name(), "digest");
926        let payload = format_webhook(&event);
927        assert_eq!(payload["event"], "digest");
928        assert_eq!(payload["title"], "Fleet digest 2026-09-03");
929        assert_eq!(payload["body"], "## 例外\n(無)\n");
930
931        let ch: Channel = serde_json::from_value(serde_json::json!({
932            "type": "webhook",
933            "url": "https://example.com/hook",
934            "events": ["digest"]
935        }))
936        .unwrap();
937        assert!(ch.matches(&event));
938    }
939
940    #[test]
941    fn format_telegram_digest_escapes_html() {
942        let event = NotifyEvent::Digest {
943            title: "<&>".into(),
944            body: "a<b>&c".into(),
945        };
946        let text = format_telegram(&event);
947        assert!(text.starts_with("<b>&lt;&amp;&gt;</b>"), "text={text}");
948        assert!(text.contains("a&lt;b&gt;&amp;c"), "text={text}");
949    }
950
951    #[test]
952    fn format_telegram_digest_truncates_to_3900_with_ellipsis() {
953        let event = NotifyEvent::Digest {
954            title: "t".into(),
955            body: "x".repeat(5000),
956        };
957        let text = format_telegram(&event);
958        assert_eq!(text.chars().count(), 3900);
959        assert!(text.ends_with('\u{2026}'));
960
961        let short = NotifyEvent::Digest {
962            title: "t".into(),
963            body: "short".into(),
964        };
965        let text = format_telegram(&short);
966        assert!(!text.ends_with('\u{2026}'));
967    }
968
969    #[test]
970    fn digest_truncation_keeps_entities_and_tags_complete() {
971        let event = NotifyEvent::Digest {
972            title: "t".into(),
973            body: format!("{}&", "x".repeat(3889)),
974        };
975        let text = format_telegram(&event);
976        assert!(!text.ends_with("&…"), "text={text}");
977        assert!(text.starts_with("<b>t</b>\n"), "text={text}");
978        assert!(text.chars().count() <= TELEGRAM_MAX_CHARS);
979        let text = format_telegram(&NotifyEvent::Digest {
980            title: "x".repeat(4000),
981            body: String::new(),
982        });
983        assert!(text.ends_with("…</b>"), "text={text}");
984    }
985    #[test]
986    fn test_channels_ignores_event_subscriptions() {
987        let config = NotifyConfig {
988            channels: vec![Channel::Webhook {
989                url: "http://127.0.0.1:9".into(),
990                events: vec!["approval_pending".into()],
991            }],
992        };
993        let results = test_channels(&config);
994        assert!(!results[0]
995            .1
996            .as_ref()
997            .unwrap_err()
998            .contains("does not subscribe"));
999    }
1000}