nirc-rs/src/engine/notify.rs

306 lines
12 KiB
Rust
Executable File

//! Notification system — Phase 17.
//!
//! Provides desktop notifications, terminal bell, and highlight-based alerts.
//! Uses the `notify-rust` pattern (or a simple fallback) for desktop notifications.
//! All notifications are non-blocking and go through an mpsc channel.
use crate::core::message::{ChatMessage, MessageKind};
use crate::core::protocol::ProtocolType;
use std::collections::HashSet;
use std::time::Instant;
use tokio::sync::mpsc;
use tracing::debug;
/// A notification to be displayed to the user.
#[derive(Debug, Clone)]
pub struct Notification {
/// Notification title (e.g. "IRC — #nirc").
pub title: String,
/// Notification body (e.g. "bob: hello there").
pub body: String,
/// Priority determines the delivery method.
pub urgency: NotificationUrgency,
/// The protocol that generated this notification.
pub protocol: ProtocolType,
/// Timestamp when the notification was created.
pub created_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationUrgency {
/// Normal message — can be batched/delayed.
Low,
/// Highlight or direct message — show immediately.
Normal,
/// Error or critical event — show immediately with emphasis.
Critical,
}
/// Configuration for the notification system.
#[derive(Debug, Clone)]
pub struct NotifyConfig {
/// Enable desktop notifications (via D-Bus / terminal fallback).
pub desktop_enabled: bool,
/// Enable terminal bell on highlights.
pub bell_enabled: bool,
/// Minimum interval between repeated notifications from the same source (ms).
pub debounce_ms: u64,
/// Words that trigger highlight notifications (in addition to own nick).
pub extra_highlight_words: Vec<String>,
/// Only notify for these protocols (empty = all).
pub protocol_filter: Vec<ProtocolType>,
/// Maximum notification body length.
pub max_body_length: usize,
/// Suppress notifications when the terminal is focused.
pub suppress_when_focused: bool,
}
impl Default for NotifyConfig {
fn default() -> Self {
Self {
desktop_enabled: true,
bell_enabled: true,
debounce_ms: 2000,
extra_highlight_words: Vec::new(),
protocol_filter: Vec::new(),
max_body_length: 200,
suppress_when_focused: false,
}
}
}
/// The notification engine. Evaluates messages and emits notifications.
pub struct NotifyEngine {
config: NotifyConfig,
/// Own nickname for highlight detection.
own_nick: String,
/// Combined highlight words.
highlight_words: HashSet<String>,
/// Debounce tracker: source → last notification time.
last_notify: std::collections::HashMap<String, Instant>,
/// Channel to send notifications to the TUI/frontend.
tx: mpsc::Sender<Notification>,
}
impl NotifyEngine {
/// Create a new notification engine.
pub fn new(own_nick: &str, config: NotifyConfig, tx: mpsc::Sender<Notification>) -> Self {
let mut highlight_words: HashSet<String> = config.extra_highlight_words.iter().cloned().collect();
highlight_words.insert(own_nick.to_lowercase());
Self { config, own_nick: own_nick.to_lowercase(), highlight_words, last_notify: std::collections::HashMap::new(), tx }
}
/// Evaluate a chat message and potentially emit a notification.
///
/// Returns true if a notification was sent.
pub fn on_message(&mut self, msg: &ChatMessage) -> bool {
// Ignore own messages.
if msg.is_own {
return false;
}
// Protocol filter.
if !self.config.protocol_filter.is_empty() && !self.config.protocol_filter.contains(&msg.protocol) {
return false;
}
// Determine if this message warrants a notification.
let (should_notify, urgency) = match &msg.kind {
MessageKind::Text => {
if self.is_highlight(msg) {
(true, NotificationUrgency::Normal)
} else {
// Only notify for PMs and errors in non-highlight text.
(false, NotificationUrgency::Low)
}
}
MessageKind::Private => (true, NotificationUrgency::Normal),
MessageKind::Error => (true, NotificationUrgency::Critical),
MessageKind::FileTransfer { filename: _, size_bytes: _, .. } => {
(true, NotificationUrgency::Normal)
}
MessageKind::Action | MessageKind::Notice => {
if self.is_highlight(msg) {
(true, NotificationUrgency::Normal)
} else {
(false, NotificationUrgency::Low)
}
}
};
if !should_notify {
return false;
}
// Debounce: don't re-notify the same source too quickly.
let debounce_key = format!("{}:{}", msg.protocol.tag(), msg.source);
if let Some(last) = self.last_notify.get(&debounce_key) {
if last.elapsed().as_millis() < self.config.debounce_ms as u128 {
return false;
}
}
self.last_notify.insert(debounce_key, Instant::now());
// Build notification.
let title = match &msg.kind {
MessageKind::Private => format!("{} — PM from {}", msg.protocol.label(), msg.sender),
MessageKind::FileTransfer { filename, .. } => format!("{} — File: {}", msg.protocol.label(), filename),
MessageKind::Error => format!("{} — Error", msg.protocol.label()),
_ => format!("{}{}", msg.protocol.label(), msg.source),
};
let body = match &msg.kind {
MessageKind::FileTransfer { filename, size_bytes, .. } => {
let sz = if *size_bytes > 1_048_576 { format!("{:.1} MB", *size_bytes as f64 / 1_048_576.0) } else { format!("{} KB", *size_bytes / 1024) };
format!("{} offered {} ({}). Use /acceptfile to receive.", msg.sender, filename, sz)
}
_ => format!("{}: {}", msg.sender, msg.body),
};
// Truncate body.
let body = if body.len() > self.config.max_body_length {
format!("{}...", &body[..self.config.max_body_length.saturating_sub(3)])
} else {
body
};
let notification = Notification {
title,
body,
urgency,
protocol: msg.protocol,
created_at: Instant::now(),
};
// Terminal bell for highlights.
if self.config.bell_enabled && matches!(urgency, NotificationUrgency::Normal | NotificationUrgency::Critical) {
// Use \x07 (BEL) which crossterm will handle.
// The TUI layer is responsible for actually emitting the bell character.
debug!("Bell triggered for highlight");
}
// Desktop notification (non-blocking send).
if self.config.desktop_enabled {
let _ = self.tx.try_send(notification);
return true;
}
false
}
/// Check if a message contains a highlight word.
fn is_highlight(&self, msg: &ChatMessage) -> bool {
let body_lower = msg.body.to_lowercase();
self.highlight_words.iter().any(|w| {
// Match whole words only.
for segment in body_lower.split(|c: char| !c.is_alphanumeric() && c != '_') {
if segment == w {
return true;
}
}
false
})
}
/// Update the own nickname (e.g. after NICK change).
pub fn set_nick(&mut self, nick: &str) {
self.own_nick = nick.to_lowercase();
self.highlight_words.insert(nick.to_lowercase());
}
/// Add an extra highlight word.
pub fn add_highlight_word(&mut self, word: &str) {
self.highlight_words.insert(word.to_lowercase());
}
/// Remove a highlight word (except own nick).
pub fn remove_highlight_word(&mut self, word: &str) {
if word.to_lowercase() != self.own_nick {
self.highlight_words.remove(&word.to_lowercase());
}
}
}
/// Simple in-process notification display (for terminal/TUI integration).
/// In a GUI context, this would use the platform's notification daemon.
pub fn display_terminal_notification(notif: &Notification) {
match notif.urgency {
NotificationUrgency::Critical => {
eprintln!("\x07[!!] {}{}", notif.title, notif.body);
}
NotificationUrgency::Normal => {
eprintln!("\x07[*] {}{}", notif.title, notif.body);
}
NotificationUrgency::Low => {
debug!(title = %notif.title, body = %notif.body, "Low-priority notification suppressed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::protocol::ProtocolType;
fn make_msg(kind: MessageKind, sender: &str, body: &str) -> ChatMessage {
ChatMessage { id: "test".into(), protocol: ProtocolType::Irc, kind, source: "#test".into(), sender: sender.into(), body: body.into(), timestamp: chrono::Utc::now(), is_own: false, remote_ts: false }
}
#[test]
fn highlight_own_nick() {
let (tx, mut rx) = mpsc::channel(8);
let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx);
let msg = make_msg(MessageKind::Text, "bob", "hey testuser are you there?");
assert!(engine.on_message(&msg));
let notif = rx.blocking_recv().unwrap();
assert!(notif.title.contains("#test"));
}
#[test]
fn no_highlight_random() {
let (tx, _rx) = mpsc::channel(8);
let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx);
let msg = make_msg(MessageKind::Text, "bob", "hello everyone");
assert!(!engine.on_message(&msg));
}
#[test]
fn pm_always_notifies() {
let (tx, mut rx) = mpsc::channel(8);
let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx);
let msg = ChatMessage { id: "t".into(), protocol: ProtocolType::Irc, kind: MessageKind::Private, source: "bob".into(), sender: "bob".into(), body: "secret".into(), timestamp: chrono::Utc::now(), is_own: false, remote_ts: false };
assert!(engine.on_message(&msg));
let notif = rx.blocking_recv().unwrap();
assert!(notif.title.contains("PM"));
}
#[test]
fn error_notifies() {
let (tx, mut rx) = mpsc::channel(8);
let mut engine = NotifyEngine::new("testuser", NotifyConfig::default(), tx);
let msg = make_msg(MessageKind::Error, "", "connection reset");
assert!(engine.on_message(&msg));
let notif = rx.blocking_recv().unwrap();
assert_eq!(notif.urgency, NotificationUrgency::Critical);
}
#[test]
fn debounce_prevents_spam() {
let (tx, _rx) = mpsc::channel(8);
let cfg = NotifyConfig { debounce_ms: 5000, ..Default::default() };
let mut engine = NotifyEngine::new("testuser", cfg, tx);
let msg1 = make_msg(MessageKind::Text, "bob", "testuser hello");
let msg2 = make_msg(MessageKind::Text, "bob", "testuser again");
assert!(engine.on_message(&msg1));
assert!(!engine.on_message(&msg2)); // Debounced
}
#[test]
fn extra_highlight_word() {
let (tx, mut rx) = mpsc::channel(8);
let cfg = NotifyConfig { extra_highlight_words: vec!["urgent".into()], ..Default::default() };
let mut engine = NotifyEngine::new("testuser", cfg, tx);
let msg = make_msg(MessageKind::Text, "bob", "this is urgent news");
assert!(engine.on_message(&msg));
}
}