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
29#![deny(missing_docs)]
30#![deny(rustdoc::broken_intra_doc_links)]
31
32use std::collections::HashMap;
33use std::sync::Arc;
34
35use async_trait::async_trait;
36use serde::{Deserialize, Serialize};
37
38/// One piece of a notification. Transport-agnostic; senders decide how to
39/// render each block.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub enum Block {
42    /// A heading / section title.
43    Heading(String),
44    /// A paragraph of body text.
45    Text(String),
46    /// Key/value pairs (rendered as a definition list or `key: value` lines).
47    Fields(Vec<(String, String)>),
48    /// A visual separator.
49    Divider,
50    /// A call to action with a label + URL.
51    Action {
52        /// Button or link text.
53        label: String,
54        /// Destination URL.
55        url: String,
56    },
57}
58
59impl Block {
60    /// A heading block.
61    pub fn heading(s: impl Into<String>) -> Self {
62        Block::Heading(s.into())
63    }
64    /// A paragraph block.
65    pub fn text(s: impl Into<String>) -> Self {
66        Block::Text(s.into())
67    }
68    /// A key/value table block.
69    pub fn fields(kv: Vec<(String, String)>) -> Self {
70        Block::Fields(kv)
71    }
72    /// A call-to-action link block.
73    pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
74        Block::Action {
75            label: label.into(),
76            url: url.into(),
77        }
78    }
79}
80
81/// A transport-agnostic notification.
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83pub struct Notification {
84    /// Display title.
85    pub title: Option<String>,
86    /// Ordered content blocks rendered by each transport.
87    pub blocks: Vec<Block>,
88}
89
90impl Notification {
91    /// An empty notification without a title.
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Set the title.
97    pub fn title(mut self, t: impl Into<String>) -> Self {
98        self.title = Some(t.into());
99        self
100    }
101
102    /// Append a content block.
103    pub fn block(mut self, b: Block) -> Self {
104        self.blocks.push(b);
105        self
106    }
107
108    /// Reference plain-text rendering for chat/SMS transports.
109    pub fn to_plain_text(&self) -> String {
110        let mut out = String::new();
111        if let Some(t) = &self.title {
112            out.push_str(t);
113            out.push_str("\n\n");
114        }
115        for block in &self.blocks {
116            match block {
117                Block::Heading(h) => {
118                    out.push_str(h);
119                    out.push('\n');
120                }
121                Block::Text(t) => {
122                    out.push_str(t);
123                    out.push('\n');
124                }
125                Block::Fields(kv) => {
126                    for (k, v) in kv {
127                        out.push_str(&format!("{k}: {v}\n"));
128                    }
129                }
130                Block::Divider => out.push_str("---\n"),
131                Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
132            }
133        }
134        out.trim_end().to_string()
135    }
136}
137
138/// Failure while dispatching a notification.
139#[derive(Debug, thiserror::Error)]
140pub enum NotifyError {
141    /// No sender registered for channel ''.
142    #[error("no sender registered for channel '{0}'")]
143    UnknownChannel(String),
144    /// The channel sender failed.
145    #[error("transport '{transport}' failed: {reason}")]
146    Transport {
147        /// Channel name of the failing sender.
148        transport: String,
149        /// Transport-reported failure.
150        reason: String,
151    },
152}
153
154/// A transport. The `name` is the channel key callers dispatch to
155/// (`"telegram"`, `"email"`, `"log"`, …).
156#[async_trait]
157pub trait Sender: Send + Sync {
158    /// Channel name this sender serves (for example `telegram`).
159    fn name(&self) -> &str;
160    /// Deliver `notif` to `recipient` (chat id / email address / webhook …).
161    async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError>;
162}
163
164/// A durably queued notification claimed by a worker.
165#[derive(Debug, Clone, PartialEq)]
166pub struct OutboxItem {
167    /// Stable identifier of this record.
168    pub id: String,
169    /// Tenant that owns this record.
170    pub tenant_id: String,
171    /// Subject (user or service principal) acting on or owning this record.
172    pub subject_id: String,
173    /// Channel the notification is routed to.
174    pub channel: String,
175    /// Channel-specific recipient address.
176    pub recipient: String,
177    /// The notification to deliver.
178    pub notification: Notification,
179    /// Delivery attempts made so far.
180    pub attempts: u32,
181    /// Fencing token bumped on every lease acquisition; stale holders cannot write.
182    pub lease_version: i64,
183}
184
185/// A notification to enqueue.
186#[derive(Debug, Clone, PartialEq)]
187pub struct NewOutboxItem {
188    /// Tenant that owns the notification.
189    pub tenant_id: String,
190    /// Subject the notification is about.
191    pub subject_id: String,
192    /// Caller-supplied key that makes repeated submissions return the first result.
193    pub idempotency_key: String,
194    /// Channel the notification is routed to.
195    pub channel: String,
196    /// Channel-specific recipient address.
197    pub recipient: String,
198    /// The notification to deliver.
199    pub notification: Notification,
200}
201
202/// Lease-fenced durable queue between producers and transport senders.
203#[async_trait]
204pub trait DurableOutbox: Send + Sync {
205    /// Persist a notification and return its id.
206    async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String>;
207    /// Claim up to `batch` due items for `worker_id` with a lease of `lease_secs`.
208    async fn claim(
209        &self,
210        worker_id: &str,
211        lease_secs: i64,
212        batch: usize,
213    ) -> Result<Vec<OutboxItem>, String>;
214    /// Mark a claimed item delivered; fails when the lease was lost.
215    async fn mark_sent(&self, id: &str, lease_version: i64) -> Result<(), String>;
216    /// Release a claimed item for a later attempt with the given error.
217    async fn retry(
218        &self,
219        id: &str,
220        lease_version: i64,
221        error: &str,
222        delay_secs: i64,
223    ) -> Result<(), String>;
224}
225
226/// Routes notifications to registered senders by channel name.
227#[derive(Default, Clone)]
228pub struct Dispatcher {
229    senders: HashMap<String, Arc<dyn Sender>>,
230}
231
232impl Dispatcher {
233    /// A dispatcher with no senders registered.
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Register a sender for its channel; a later registration for the same channel replaces the earlier one.
239    pub fn register(&mut self, sender: Arc<dyn Sender>) -> &mut Self {
240        self.senders.insert(sender.name().to_string(), sender);
241        self
242    }
243
244    /// Channels with a registered sender.
245    pub fn channels(&self) -> impl Iterator<Item = &str> {
246        self.senders.keys().map(|s| s.as_str())
247    }
248
249    /// Dispatch to the sender registered under `channel`.
250    pub async fn dispatch(
251        &self,
252        channel: &str,
253        recipient: &str,
254        notif: &Notification,
255    ) -> Result<(), NotifyError> {
256        let sender = self
257            .senders
258            .get(channel)
259            .ok_or_else(|| NotifyError::UnknownChannel(channel.to_string()))?;
260        sender.send(recipient, notif).await
261    }
262
263    /// Claim due outbox items and deliver each through its channel sender, recording success or scheduling a retry.
264    pub async fn drain(
265        &self,
266        outbox: &dyn DurableOutbox,
267        worker_id: &str,
268        lease_secs: i64,
269        batch: usize,
270        retry_delay_secs: i64,
271    ) -> Result<usize, String> {
272        let items = outbox
273            .claim(worker_id, lease_secs, batch.clamp(1, 100))
274            .await?;
275        for item in &items {
276            match self
277                .dispatch(&item.channel, &item.recipient, &item.notification)
278                .await
279            {
280                Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
281                Err(error) => {
282                    outbox
283                        .retry(
284                            &item.id,
285                            item.lease_version,
286                            &error.to_string(),
287                            retry_delay_secs,
288                        )
289                        .await?
290                }
291            }
292        }
293        Ok(items.len())
294    }
295}
296
297/// Default sender: emits the rendered notification to the log. Useful in dev
298/// and as the reference transport.
299pub struct LogSender;
300
301#[async_trait]
302impl Sender for LogSender {
303    fn name(&self) -> &str {
304        "log"
305    }
306    async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
307        tracing::info!(target: "notify", recipient, body = %notif.to_plain_text(), "notification");
308        Ok(())
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::sync::Mutex;
316
317    #[derive(Default)]
318    struct MemoryOutbox {
319        items: Mutex<Vec<OutboxItem>>,
320        sent: Mutex<Vec<String>>,
321    }
322
323    #[async_trait]
324    impl DurableOutbox for MemoryOutbox {
325        async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String> {
326            let id = item.idempotency_key.clone();
327            self.items.lock().unwrap().push(OutboxItem {
328                id: id.clone(),
329                tenant_id: item.tenant_id,
330                subject_id: item.subject_id,
331                channel: item.channel,
332                recipient: item.recipient,
333                notification: item.notification,
334                attempts: 0,
335                lease_version: 1,
336            });
337            Ok(id)
338        }
339        async fn claim(&self, _: &str, _: i64, _: usize) -> Result<Vec<OutboxItem>, String> {
340            Ok(self.items.lock().unwrap().clone())
341        }
342        async fn mark_sent(&self, id: &str, _: i64) -> Result<(), String> {
343            self.sent.lock().unwrap().push(id.into());
344            Ok(())
345        }
346        async fn retry(&self, _: &str, _: i64, _: &str, _: i64) -> Result<(), String> {
347            Ok(())
348        }
349    }
350
351    #[test]
352    fn renders_plain_text() {
353        let n = Notification::new()
354            .title("Run finished")
355            .block(Block::heading("Summary"))
356            .block(Block::text("All good."))
357            .block(Block::fields(vec![("duration".into(), "1.2s".into())]))
358            .block(Block::action("View", "https://x/y"));
359        let txt = n.to_plain_text();
360        assert!(txt.starts_with("Run finished"));
361        assert!(txt.contains("duration: 1.2s"));
362        assert!(txt.contains("View: https://x/y"));
363    }
364
365    struct CapturingSender(Mutex<Vec<String>>);
366    #[async_trait]
367    impl Sender for CapturingSender {
368        fn name(&self) -> &str {
369            "capture"
370        }
371        async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
372            self.0
373                .lock()
374                .unwrap()
375                .push(format!("{recipient}|{}", notif.to_plain_text()));
376            Ok(())
377        }
378    }
379
380    #[tokio::test]
381    async fn dispatch_routes_to_named_sender() {
382        let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
383        let mut d = Dispatcher::new();
384        d.register(sender.clone());
385
386        let n = Notification::new().block(Block::text("hi"));
387        d.dispatch("capture", "user1", &n).await.unwrap();
388
389        assert_eq!(sender.0.lock().unwrap().len(), 1);
390        assert!(sender.0.lock().unwrap()[0].starts_with("user1|hi"));
391    }
392
393    #[tokio::test]
394    async fn unknown_channel_errors() {
395        let d = Dispatcher::new();
396        let n = Notification::new();
397        assert!(matches!(
398            d.dispatch("nope", "x", &n).await,
399            Err(NotifyError::UnknownChannel(_))
400        ));
401    }
402
403    #[tokio::test]
404    async fn durable_dispatch_marks_claimed_messages_sent() {
405        let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
406        let mut dispatcher = Dispatcher::new();
407        dispatcher.register(sender);
408        let outbox = MemoryOutbox::default();
409        outbox
410            .enqueue(NewOutboxItem {
411                tenant_id: "tenant".into(),
412                subject_id: "subject".into(),
413                idempotency_key: "one".into(),
414                channel: "capture".into(),
415                recipient: "recipient".into(),
416                notification: Notification::new().block(Block::text("hello")),
417            })
418            .await
419            .unwrap();
420        assert_eq!(
421            dispatcher
422                .drain(&outbox, "worker", 30, 10, 5)
423                .await
424                .unwrap(),
425            1
426        );
427        assert_eq!(&*outbox.sent.lock().unwrap(), &["one"]);
428    }
429}