use std::collections::HashMap;
use std::sync::Arc;
use bamboo_agent_core::AgentEvent;
use bamboo_notification::NotificationPriority;
use tokio::sync::{broadcast, RwLock};
use bamboo_server_tools::NotificationDispatcher;
use crate::app_state::watchers::SessionWatchers;
pub struct ServerNotificationDispatcher {
pub notification_service: Arc<bamboo_notification::NotificationService>,
pub session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
pub session_watchers: Arc<SessionWatchers>,
pub config: Arc<RwLock<bamboo_llm::Config>>,
}
impl ServerNotificationDispatcher {
pub fn new(
notification_service: Arc<bamboo_notification::NotificationService>,
session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
session_watchers: Arc<SessionWatchers>,
config: Arc<RwLock<bamboo_llm::Config>>,
) -> Self {
Self {
notification_service,
session_event_senders,
session_watchers,
config,
}
}
}
fn parse_priority(priority: &str) -> NotificationPriority {
match priority {
"high" => NotificationPriority::High,
"low" => NotificationPriority::Low,
_ => NotificationPriority::Normal,
}
}
impl NotificationDispatcher for ServerNotificationDispatcher {
fn dispatch(
&self,
session_id: &str,
title: &str,
body: &str,
priority: &str,
url: Option<&str>,
) {
let notification_service = self.notification_service.clone();
let session_event_senders = self.session_event_senders.clone();
let session_watchers = self.session_watchers.clone();
let config = self.config.clone();
let session_id = session_id.to_string();
let title = title.to_string();
let body = body.to_string();
let priority = parse_priority(priority);
let url = url.map(str::to_string);
tokio::spawn(async move {
let Some(event) = notification_service.mint_custom(&session_id, title, body, priority)
else {
return;
};
let sink_notification =
crate::notify_sinks::SinkNotification::from_event(&event).map(|mut n| {
n.click_url = url;
n
});
let tx = session_event_senders.read().await.get(&session_id).cloned();
if let Some(tx) = tx {
let _ = tx.send(event);
}
if let Some(sink_notification) = sink_notification {
let has_watcher = session_watchers.has_watcher(&session_id);
let config_snapshot = config.read().await.clone();
crate::notify_sinks::dispatch_to_sinks(
&config_snapshot,
has_watcher,
&sink_notification,
);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn dispatcher() -> (ServerNotificationDispatcher, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let notification_service = Arc::new(bamboo_notification::NotificationService::new(
dir.path().join("prefs.json"),
));
let dispatcher = ServerNotificationDispatcher::new(
notification_service,
Arc::new(RwLock::new(HashMap::new())),
SessionWatchers::new(),
Arc::new(RwLock::new(bamboo_llm::Config::default())),
);
(dispatcher, dir)
}
#[test]
fn parse_priority_maps_known_values_and_falls_back_to_normal() {
assert_eq!(parse_priority("high"), NotificationPriority::High);
assert_eq!(parse_priority("low"), NotificationPriority::Low);
assert_eq!(parse_priority("normal"), NotificationPriority::Normal);
assert_eq!(parse_priority("unexpected"), NotificationPriority::Normal);
}
#[tokio::test]
async fn dispatch_broadcasts_a_notification_event_on_the_session_channel() {
let (dispatcher, _dir) = dispatcher();
let (tx, mut rx) = broadcast::channel(16);
dispatcher
.session_event_senders
.write()
.await
.insert("sess-1".to_string(), tx);
dispatcher.dispatch("sess-1", "Reminder", "Stand up", "high", None);
let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("dispatch should broadcast within the timeout")
.unwrap();
match event {
AgentEvent::Notification {
session_id,
category,
priority,
title,
body,
..
} => {
assert_eq!(session_id, "sess-1");
assert_eq!(category, "custom");
assert_eq!(priority, "high");
assert_eq!(title, "Reminder");
assert_eq!(body, "Stand up");
}
other => panic!("expected Notification, got {other:?}"),
}
}
#[tokio::test]
async fn dispatch_is_a_no_op_when_notifications_are_disabled() {
let (dispatcher, _dir) = dispatcher();
dispatcher
.notification_service
.set_preferences(bamboo_notification::NotificationPreferences {
enabled: false,
..bamboo_notification::NotificationPreferences::default()
})
.unwrap();
let (tx, mut rx) = broadcast::channel(16);
dispatcher
.session_event_senders
.write()
.await
.insert("sess-1".to_string(), tx);
dispatcher.dispatch("sess-1", "Reminder", "Stand up", "normal", None);
let outcome = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await;
assert!(
outcome.is_err(),
"disabled notifications must not broadcast an event"
);
}
#[tokio::test]
async fn dispatch_with_no_subscriber_does_not_panic() {
let (dispatcher, _dir) = dispatcher();
dispatcher.dispatch("no-such-session", "Title", "Body", "low", None);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}