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 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
194const 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
205pub 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
220pub 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
251fn 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
326fn 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
344fn 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('&', "&")
434 .replace('<', "<")
435 .replace('>', ">")
436}
437
438#[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 <script> & stuff"));
609 }
610}