Files
huskies/server/src/chat/transport/whatsapp/history.rs
T

226 lines
8.5 KiB
Rust
Raw Normal View History

//! 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<HashMap<String, std::time::Instant>>,
#[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<TokioMutex<HashMap<String, RoomConversation>>>;
/// 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<String, RoomConversation> {
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<String, RoomConversation>,
) {
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"));
}
}