restore: reset past source tree deletion, apply pending work
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use crate::chat::transport::matrix::RoomConversation;
|
||||
use crate::slog;
|
||||
|
||||
// ── 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>>>;
|
||||
|
||||
/// On-disk format for persisted WhatsApp conversation history.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PersistedWhatsAppHistory {
|
||||
senders: HashMap<String, RoomConversation>,
|
||||
}
|
||||
|
||||
/// Path to the persisted WhatsApp conversation history file.
|
||||
const WHATSAPP_HISTORY_FILE: &str = ".storkit/whatsapp_history.json";
|
||||
|
||||
/// Load WhatsApp conversation history from disk.
|
||||
pub fn load_whatsapp_history(project_root: &std::path::Path) -> HashMap<String, RoomConversation> {
|
||||
let path = project_root.join(WHATSAPP_HISTORY_FILE);
|
||||
let data = match std::fs::read_to_string(&path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
let persisted: PersistedWhatsAppHistory = match serde_json::from_str(&data) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
slog!("[whatsapp] Failed to parse history file: {e}");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
persisted.senders
|
||||
}
|
||||
|
||||
/// Save WhatsApp conversation history to disk.
|
||||
pub(super) fn save_whatsapp_history(
|
||||
project_root: &std::path::Path,
|
||||
history: &HashMap<String, RoomConversation>,
|
||||
) {
|
||||
let persisted = PersistedWhatsAppHistory {
|
||||
senders: history.clone(),
|
||||
};
|
||||
let path = project_root.join(WHATSAPP_HISTORY_FILE);
|
||||
match serde_json::to_string_pretty(&persisted) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
slog!("[whatsapp] Failed to write history file: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => slog!("[whatsapp] Failed to serialise history: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(".storkit");
|
||||
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(".storkit");
|
||||
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(".storkit");
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user