226 lines
8.5 KiB
Rust
226 lines
8.5 KiB
Rust
//! 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"));
|
|
}
|
|
}
|