//! WhatsApp conversation history — per-number message history and messaging window tracking. use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; use crate::chat::history::{load_chat_history, save_chat_history}; use crate::chat::transport::matrix::RoomConversation; // ── Messaging window tracker ───────────────────────────────────────────── /// Tracks the 24-hour messaging window per WhatsApp phone number. /// /// Meta's Business API only permits free-form text messages within 24 hours of /// the last *inbound* message from that user. After that window expires, only /// approved message templates may be sent. /// /// Call [`record_message`] whenever an inbound message is received. Before /// sending a proactive notification, call [`is_within_window`] to choose /// between free-form text and a template message. pub struct MessagingWindowTracker { last_message: std::sync::Mutex>, #[allow(dead_code)] // Used by Meta provider path (is_within_window → send_notification) window_duration: std::time::Duration, } impl Default for MessagingWindowTracker { fn default() -> Self { Self::new() } } impl MessagingWindowTracker { /// Create a tracker with the standard 24-hour window. pub fn new() -> Self { Self { last_message: std::sync::Mutex::new(HashMap::new()), window_duration: std::time::Duration::from_secs(24 * 60 * 60), } } /// Create a tracker with a custom window duration (useful in tests). #[cfg(test)] pub(crate) fn with_duration(window_duration: std::time::Duration) -> Self { Self { last_message: std::sync::Mutex::new(HashMap::new()), window_duration, } } /// Record that `phone` sent an inbound message right now. pub fn record_message(&self, phone: &str) { self.last_message .lock() .unwrap() .insert(phone.to_string(), std::time::Instant::now()); } /// Returns `true` when the last inbound message from `phone` arrived within /// the 24-hour window, meaning free-form replies are still permitted. #[allow(dead_code)] // Used by Meta provider path (send_notification) pub fn is_within_window(&self, phone: &str) -> bool { let map = self.last_message.lock().unwrap(); match map.get(phone) { Some(&instant) => instant.elapsed() < self.window_duration, None => false, } } } // ── Conversation history persistence ───────────────────────────────── /// Per-sender conversation history, keyed by phone number. pub type WhatsAppConversationHistory = Arc>>; /// Path to the persisted WhatsApp conversation history file. const WHATSAPP_HISTORY_FILE: &str = ".huskies/whatsapp_history.json"; /// Load WhatsApp conversation history from disk. pub fn load_whatsapp_history(project_root: &std::path::Path) -> HashMap { load_chat_history(project_root, WHATSAPP_HISTORY_FILE, "whatsapp") } /// Save WhatsApp conversation history to disk. pub(super) fn save_whatsapp_history( project_root: &std::path::Path, history: &HashMap, ) { save_chat_history(project_root, WHATSAPP_HISTORY_FILE, "whatsapp", history); } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation}; // ── MessagingWindowTracker ──────────────────────────────────────── #[test] fn window_tracker_unknown_user_is_outside_window() { let tracker = MessagingWindowTracker::new(); assert!(!tracker.is_within_window("15551234567")); } #[test] fn window_tracker_records_within_window() { let tracker = MessagingWindowTracker::new(); tracker.record_message("15551234567"); assert!(tracker.is_within_window("15551234567")); } #[test] fn window_tracker_expired_window_returns_false() { // Use a 1-nanosecond window so it expires immediately. let tracker = MessagingWindowTracker::with_duration(std::time::Duration::from_nanos(1)); tracker.record_message("15551234567"); // Sleep briefly to ensure the instant has elapsed. std::thread::sleep(std::time::Duration::from_millis(1)); assert!(!tracker.is_within_window("15551234567")); } #[test] fn window_tracker_tracks_users_independently() { let tracker = MessagingWindowTracker::new(); tracker.record_message("111"); assert!(tracker.is_within_window("111")); assert!(!tracker.is_within_window("222")); } // ── WhatsApp history persistence tests ────────────────────────────── #[test] fn save_and_load_whatsapp_history_round_trips() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".huskies"); std::fs::create_dir_all(&sk).unwrap(); let mut history = HashMap::new(); history.insert( "15551234567".to_string(), RoomConversation { session_id: Some("sess-abc".to_string()), entries: vec![ ConversationEntry { role: ConversationRole::User, sender: "15551234567".to_string(), content: "hello".to_string(), }, ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: "hi there!".to_string(), }, ], }, ); save_whatsapp_history(tmp.path(), &history); let loaded = load_whatsapp_history(tmp.path()); assert_eq!(loaded.len(), 1); let conv = loaded.get("15551234567").unwrap(); assert_eq!(conv.session_id.as_deref(), Some("sess-abc")); assert_eq!(conv.entries.len(), 2); assert_eq!(conv.entries[0].content, "hello"); assert_eq!(conv.entries[1].content, "hi there!"); } #[test] fn load_whatsapp_history_returns_empty_when_file_missing() { let tmp = tempfile::tempdir().unwrap(); let history = load_whatsapp_history(tmp.path()); assert!(history.is_empty()); } #[test] fn load_whatsapp_history_returns_empty_on_invalid_json() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".huskies"); std::fs::create_dir_all(&sk).unwrap(); std::fs::write(sk.join("whatsapp_history.json"), "not json {{{").unwrap(); let history = load_whatsapp_history(tmp.path()); assert!(history.is_empty()); } #[test] fn save_whatsapp_history_preserves_multiple_senders() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".huskies"); std::fs::create_dir_all(&sk).unwrap(); let mut history = HashMap::new(); history.insert( "111".to_string(), RoomConversation { session_id: None, entries: vec![ConversationEntry { role: ConversationRole::User, sender: "111".to_string(), content: "msg1".to_string(), }], }, ); history.insert( "222".to_string(), RoomConversation { session_id: Some("sess-222".to_string()), entries: vec![ConversationEntry { role: ConversationRole::User, sender: "222".to_string(), content: "msg2".to_string(), }], }, ); save_whatsapp_history(tmp.path(), &history); let loaded = load_whatsapp_history(tmp.path()); assert_eq!(loaded.len(), 2); assert!(loaded.contains_key("111")); assert!(loaded.contains_key("222")); assert_eq!(loaded["222"].session_id.as_deref(), Some("sess-222")); } }