1use serde::Deserialize;
2use std::time::Duration;
3#[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#[derive(Deserialize, Clone, Debug, Default)]
44pub struct NotifyConfig {
45 pub channels: Vec<Channel>,
46}
47impl NotifyConfig {
48 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#[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 PhaseTerminal {
116 plan: String,
117 phase: String,
118 state: String,
119 attempt: u32,
120 final_output: Option<String>,
121 },
122 GateProgress {
124 plan: String,
125 phase: String,
126 subject: String,
127 gate_sha: String,
128 wait_label: String,
129 },
130 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
246const 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
257pub 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
272pub 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}
293pub 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
340fn 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
449fn 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 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
476fn 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}
595const 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('&', "&")
630 .replace('<', "<")
631 .replace('>', ">")
632}
633
634#[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 <a>: Failed"));
802 assert!(text.contains("err <&>"));
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 <script> & 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><&></b>"), "text={text}");
948 assert!(text.contains("a<b>&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}