1use std::time::Duration;
2
3use serde::Deserialize;
4
5#[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#[derive(Deserialize, Clone, Debug, Default)]
48pub struct NotifyConfig {
49 pub channels: Vec<Channel>,
50}
51
52impl NotifyConfig {
53 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
77pub 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
164const 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
175pub 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
190pub 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
221fn 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
278fn 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
296fn 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('&', "&")
366 .replace('<', "<")
367 .replace('>', ">")
368}
369
370#[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 <script> & stuff"));
522 }
523}