Skip to main content

af_notify/
lib.rs

1//! `af-notify` — notification dispatcher + transport seam. Port of the
2//! reusable core of `agent_core/notifications/`.
3//!
4//! The governance rule that carries over: **every outbound message goes through
5//! the dispatcher**, never a raw `api.telegram.org/...` POST. A product builds a
6//! typed [`Notification`] (transport-agnostic blocks) and dispatches it; each
7//! [`Sender`] renders the blocks its own way (a chat transport flattens to
8//! text, a rich transport keeps structure).
9//!
10//! Transports (Telegram / email / Discord) are **pluggable adapters** that
11//! implement [`Sender`] — the core never depends on a specific provider.
12//!
13//! ```
14//! use af_notify::{Block, Dispatcher, LogSender, Notification};
15//! use std::sync::Arc;
16//!
17//! # tokio_test(async {
18//! let mut d = Dispatcher::new();
19//! d.register(Arc::new(LogSender));
20//! let notif = Notification::new()
21//!     .title("Run finished")
22//!     .block(Block::text("Your workflow completed."))
23//!     .block(Block::fields(vec![("duration".into(), "1.2s".into())]));
24//! d.dispatch("log", "ops", &notif).await.unwrap();
25//! # });
26//! # fn tokio_test<F: std::future::Future>(_: F) {}
27//! ```
28
29use std::collections::HashMap;
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use serde::{Deserialize, Serialize};
34
35/// One piece of a notification. Transport-agnostic; senders decide how to
36/// render each block.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub enum Block {
39    /// A heading / section title.
40    Heading(String),
41    /// A paragraph of body text.
42    Text(String),
43    /// Key/value pairs (rendered as a definition list or `key: value` lines).
44    Fields(Vec<(String, String)>),
45    /// A visual separator.
46    Divider,
47    /// A call to action with a label + URL.
48    Action { label: String, url: String },
49}
50
51impl Block {
52    pub fn heading(s: impl Into<String>) -> Self {
53        Block::Heading(s.into())
54    }
55    pub fn text(s: impl Into<String>) -> Self {
56        Block::Text(s.into())
57    }
58    pub fn fields(kv: Vec<(String, String)>) -> Self {
59        Block::Fields(kv)
60    }
61    pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
62        Block::Action {
63            label: label.into(),
64            url: url.into(),
65        }
66    }
67}
68
69/// A transport-agnostic notification.
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
71pub struct Notification {
72    pub title: Option<String>,
73    pub blocks: Vec<Block>,
74}
75
76impl Notification {
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    pub fn title(mut self, t: impl Into<String>) -> Self {
82        self.title = Some(t.into());
83        self
84    }
85
86    pub fn block(mut self, b: Block) -> Self {
87        self.blocks.push(b);
88        self
89    }
90
91    /// Reference plain-text rendering for chat/SMS transports.
92    pub fn to_plain_text(&self) -> String {
93        let mut out = String::new();
94        if let Some(t) = &self.title {
95            out.push_str(t);
96            out.push_str("\n\n");
97        }
98        for block in &self.blocks {
99            match block {
100                Block::Heading(h) => {
101                    out.push_str(h);
102                    out.push('\n');
103                }
104                Block::Text(t) => {
105                    out.push_str(t);
106                    out.push('\n');
107                }
108                Block::Fields(kv) => {
109                    for (k, v) in kv {
110                        out.push_str(&format!("{k}: {v}\n"));
111                    }
112                }
113                Block::Divider => out.push_str("---\n"),
114                Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
115            }
116        }
117        out.trim_end().to_string()
118    }
119}
120
121#[derive(Debug, thiserror::Error)]
122pub enum NotifyError {
123    #[error("no sender registered for channel '{0}'")]
124    UnknownChannel(String),
125    #[error("transport '{transport}' failed: {reason}")]
126    Transport { transport: String, reason: String },
127}
128
129/// A transport. The `name` is the channel key callers dispatch to
130/// (`"telegram"`, `"email"`, `"log"`, …).
131#[async_trait]
132pub trait Sender: Send + Sync {
133    fn name(&self) -> &str;
134    /// Deliver `notif` to `recipient` (chat id / email address / webhook …).
135    async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError>;
136}
137
138#[derive(Debug, Clone, PartialEq)]
139pub struct OutboxItem {
140    pub id: String,
141    pub tenant_id: String,
142    pub subject_id: String,
143    pub channel: String,
144    pub recipient: String,
145    pub notification: Notification,
146    pub attempts: u32,
147    pub lease_version: i64,
148}
149
150#[derive(Debug, Clone, PartialEq)]
151pub struct NewOutboxItem {
152    pub tenant_id: String,
153    pub subject_id: String,
154    pub idempotency_key: String,
155    pub channel: String,
156    pub recipient: String,
157    pub notification: Notification,
158}
159
160#[async_trait]
161pub trait DurableOutbox: Send + Sync {
162    async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String>;
163    async fn claim(
164        &self,
165        worker_id: &str,
166        lease_secs: i64,
167        batch: usize,
168    ) -> Result<Vec<OutboxItem>, String>;
169    async fn mark_sent(&self, id: &str, lease_version: i64) -> Result<(), String>;
170    async fn retry(
171        &self,
172        id: &str,
173        lease_version: i64,
174        error: &str,
175        delay_secs: i64,
176    ) -> Result<(), String>;
177}
178
179/// Routes notifications to registered senders by channel name.
180#[derive(Default, Clone)]
181pub struct Dispatcher {
182    senders: HashMap<String, Arc<dyn Sender>>,
183}
184
185impl Dispatcher {
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    pub fn register(&mut self, sender: Arc<dyn Sender>) -> &mut Self {
191        self.senders.insert(sender.name().to_string(), sender);
192        self
193    }
194
195    pub fn channels(&self) -> impl Iterator<Item = &str> {
196        self.senders.keys().map(|s| s.as_str())
197    }
198
199    /// Dispatch to the sender registered under `channel`.
200    pub async fn dispatch(
201        &self,
202        channel: &str,
203        recipient: &str,
204        notif: &Notification,
205    ) -> Result<(), NotifyError> {
206        let sender = self
207            .senders
208            .get(channel)
209            .ok_or_else(|| NotifyError::UnknownChannel(channel.to_string()))?;
210        sender.send(recipient, notif).await
211    }
212
213    pub async fn drain(
214        &self,
215        outbox: &dyn DurableOutbox,
216        worker_id: &str,
217        lease_secs: i64,
218        batch: usize,
219        retry_delay_secs: i64,
220    ) -> Result<usize, String> {
221        let items = outbox
222            .claim(worker_id, lease_secs, batch.clamp(1, 100))
223            .await?;
224        for item in &items {
225            match self
226                .dispatch(&item.channel, &item.recipient, &item.notification)
227                .await
228            {
229                Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
230                Err(error) => {
231                    outbox
232                        .retry(
233                            &item.id,
234                            item.lease_version,
235                            &error.to_string(),
236                            retry_delay_secs,
237                        )
238                        .await?
239                }
240            }
241        }
242        Ok(items.len())
243    }
244}
245
246/// Default sender: emits the rendered notification to the log. Useful in dev
247/// and as the reference transport.
248pub struct LogSender;
249
250#[async_trait]
251impl Sender for LogSender {
252    fn name(&self) -> &str {
253        "log"
254    }
255    async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
256        tracing::info!(target: "notify", recipient, body = %notif.to_plain_text(), "notification");
257        Ok(())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::sync::Mutex;
265
266    #[derive(Default)]
267    struct MemoryOutbox {
268        items: Mutex<Vec<OutboxItem>>,
269        sent: Mutex<Vec<String>>,
270    }
271
272    #[async_trait]
273    impl DurableOutbox for MemoryOutbox {
274        async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String> {
275            let id = item.idempotency_key.clone();
276            self.items.lock().unwrap().push(OutboxItem {
277                id: id.clone(),
278                tenant_id: item.tenant_id,
279                subject_id: item.subject_id,
280                channel: item.channel,
281                recipient: item.recipient,
282                notification: item.notification,
283                attempts: 0,
284                lease_version: 1,
285            });
286            Ok(id)
287        }
288        async fn claim(&self, _: &str, _: i64, _: usize) -> Result<Vec<OutboxItem>, String> {
289            Ok(self.items.lock().unwrap().clone())
290        }
291        async fn mark_sent(&self, id: &str, _: i64) -> Result<(), String> {
292            self.sent.lock().unwrap().push(id.into());
293            Ok(())
294        }
295        async fn retry(&self, _: &str, _: i64, _: &str, _: i64) -> Result<(), String> {
296            Ok(())
297        }
298    }
299
300    #[test]
301    fn renders_plain_text() {
302        let n = Notification::new()
303            .title("Run finished")
304            .block(Block::heading("Summary"))
305            .block(Block::text("All good."))
306            .block(Block::fields(vec![("duration".into(), "1.2s".into())]))
307            .block(Block::action("View", "https://x/y"));
308        let txt = n.to_plain_text();
309        assert!(txt.starts_with("Run finished"));
310        assert!(txt.contains("duration: 1.2s"));
311        assert!(txt.contains("View: https://x/y"));
312    }
313
314    struct CapturingSender(Mutex<Vec<String>>);
315    #[async_trait]
316    impl Sender for CapturingSender {
317        fn name(&self) -> &str {
318            "capture"
319        }
320        async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
321            self.0
322                .lock()
323                .unwrap()
324                .push(format!("{recipient}|{}", notif.to_plain_text()));
325            Ok(())
326        }
327    }
328
329    #[tokio::test]
330    async fn dispatch_routes_to_named_sender() {
331        let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
332        let mut d = Dispatcher::new();
333        d.register(sender.clone());
334
335        let n = Notification::new().block(Block::text("hi"));
336        d.dispatch("capture", "user1", &n).await.unwrap();
337
338        assert_eq!(sender.0.lock().unwrap().len(), 1);
339        assert!(sender.0.lock().unwrap()[0].starts_with("user1|hi"));
340    }
341
342    #[tokio::test]
343    async fn unknown_channel_errors() {
344        let d = Dispatcher::new();
345        let n = Notification::new();
346        assert!(matches!(
347            d.dispatch("nope", "x", &n).await,
348            Err(NotifyError::UnknownChannel(_))
349        ));
350    }
351
352    #[tokio::test]
353    async fn durable_dispatch_marks_claimed_messages_sent() {
354        let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
355        let mut dispatcher = Dispatcher::new();
356        dispatcher.register(sender);
357        let outbox = MemoryOutbox::default();
358        outbox
359            .enqueue(NewOutboxItem {
360                tenant_id: "tenant".into(),
361                subject_id: "subject".into(),
362                idempotency_key: "one".into(),
363                channel: "capture".into(),
364                recipient: "recipient".into(),
365                notification: Notification::new().block(Block::text("hello")),
366            })
367            .await
368            .unwrap();
369        assert_eq!(
370            dispatcher
371                .drain(&outbox, "worker", 30, 10, 5)
372                .await
373                .unwrap(),
374            1
375        );
376        assert_eq!(&*outbox.sent.lock().unwrap(), &["one"]);
377    }
378}