use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::config::NotificationConfig;
use crate::hooks::HookEvent;
use crate::notification::{TerminalBell, WebhookNotifier};
use crate::session::SessionId;
#[derive(Debug, Clone, Default)]
pub struct NotificationStatus {
pub pending_count: usize,
pub last_attention_event: Option<HookEvent>,
}
pub struct NotificationManager {
config: NotificationConfig,
webhook: Option<WebhookNotifier>,
session_names: HashMap<SessionId, String>,
status: NotificationStatus,
status_tx: Option<mpsc::Sender<NotificationStatus>>,
}
impl NotificationManager {
#[must_use]
pub fn new(config: NotificationConfig) -> Self {
let webhook = if config.webhook.enabled {
Some(WebhookNotifier::new(&config.webhook))
} else {
None
};
Self {
config,
webhook,
session_names: HashMap::new(),
status: NotificationStatus::default(),
status_tx: None,
}
}
pub fn register_session(&mut self, id: SessionId, name: String) {
self.session_names.insert(id, name);
}
pub fn unregister_session(&mut self, id: &SessionId) {
self.session_names.remove(id);
}
pub async fn handle(&mut self, event: HookEvent) {
let session_name = self
.session_names
.get(&event.session_id)
.cloned()
.unwrap_or_else(|| event.session_id.to_string());
debug!(
"Handling {:?} event for session {}",
event.event_type, session_name
);
if event.requires_attention()
&& self.config.terminal_bell
&& let Err(e) = TerminalBell::ring()
{
warn!("Failed to ring terminal bell: {e}");
}
if event.requires_attention()
&& let Some(ref webhook) = self.webhook
&& let Err(e) = webhook.send(&event, &session_name).await
{
warn!("Webhook notification failed: {e}");
}
if event.requires_attention() {
self.status.pending_count += 1;
self.status.last_attention_event = Some(event);
self.send_status_update().await;
}
}
#[must_use]
pub fn status(&self) -> &NotificationStatus {
&self.status
}
async fn send_status_update(&self) {
if let Some(ref tx) = self.status_tx
&& tx.send(self.status.clone()).await.is_err()
{
debug!("Status channel closed");
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::config::WebhookConfig;
use rstest::rstest;
use serde_json::json;
use uuid::Uuid;
fn test_config() -> NotificationConfig {
NotificationConfig {
terminal_bell: false,
webhook: WebhookConfig::default(),
}
}
fn test_session_id() -> SessionId {
SessionId::from(Uuid::new_v4())
}
fn make_event(session_id: SessionId, hook_name: &str) -> HookEvent {
let payload = match hook_name {
"Notification" => json!({"hook_event_name": "Notification", "message": "Test"}),
_ => json!({"hook_event_name": hook_name, "tool_name": "Bash"}),
};
HookEvent::from_payload(session_id, payload).expect("create event")
}
#[rstest]
#[case("Notification", 1, true)]
#[case("PreToolUse", 0, false)]
#[tokio::test]
async fn handle_event_pending_count(
#[case] hook_name: &str,
#[case] expected_count: usize,
#[case] has_attention: bool,
) {
let mut manager = NotificationManager::new(test_config());
let session_id = test_session_id();
manager.register_session(session_id, "test".to_string());
manager.handle(make_event(session_id, hook_name)).await;
assert_eq!(manager.status().pending_count, expected_count);
assert_eq!(
manager.status().last_attention_event.is_some(),
has_attention
);
}
#[test]
fn register_unregister_session() {
let mut manager = NotificationManager::new(test_config());
let session_id = test_session_id();
manager.register_session(session_id, "my-session".to_string());
assert!(manager.session_names.contains_key(&session_id));
manager.unregister_session(&session_id);
assert!(!manager.session_names.contains_key(&session_id));
}
#[rstest]
#[case(true, "http://example.com", true)]
#[case(false, "", false)]
fn new_webhook_config(#[case] enabled: bool, #[case] url: &str, #[case] has_webhook: bool) {
let config = NotificationConfig {
terminal_bell: false,
webhook: WebhookConfig {
enabled,
url: url.to_string(),
},
};
assert_eq!(
NotificationManager::new(config).webhook.is_some(),
has_webhook
);
}
}