//! WhatsApp Business API integration. //! //! Provides: //! - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the //! Meta Graph API (`graph.facebook.com/v21.0/{phone_number_id}/messages`). //! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp //! webhook (GET verification handshake + POST incoming messages). use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; use crate::agents::AgentPool; use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation}; use crate::slog; use crate::transport::{ChatTransport, MessageId}; // ── Graph API base URL (overridable for tests) ────────────────────────── const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0"; // ── WhatsApp Transport ────────────────────────────────────────────────── /// Real WhatsApp Business API transport. /// /// Sends text messages via `POST {GRAPH_API_BASE}/{phone_number_id}/messages`. pub struct WhatsAppTransport { phone_number_id: String, access_token: String, client: reqwest::Client, /// Optional base URL override for tests. api_base: String, } impl WhatsAppTransport { pub fn new(phone_number_id: String, access_token: String) -> Self { Self { phone_number_id, access_token, client: reqwest::Client::new(), api_base: GRAPH_API_BASE.to_string(), } } #[cfg(test)] fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self { Self { phone_number_id, access_token, client: reqwest::Client::new(), api_base, } } /// Send a text message to a WhatsApp user via the Graph API. async fn send_text(&self, to: &str, body: &str) -> Result { let url = format!( "{}/{}/messages", self.api_base, self.phone_number_id ); let payload = GraphSendMessage { messaging_product: "whatsapp", to, r#type: "text", text: GraphTextBody { body }, }; let resp = self .client .post(&url) .bearer_auth(&self.access_token) .json(&payload) .send() .await .map_err(|e| format!("WhatsApp API request failed: {e}"))?; let status = resp.status(); let resp_text = resp .text() .await .unwrap_or_else(|_| "".to_string()); if !status.is_success() { return Err(format!( "WhatsApp API returned {status}: {resp_text}" )); } // Extract the message ID from the response. let parsed: GraphSendResponse = serde_json::from_str(&resp_text).map_err(|e| { format!("Failed to parse WhatsApp API response: {e} — body: {resp_text}") })?; let msg_id = parsed .messages .first() .map(|m| m.id.clone()) .unwrap_or_default(); Ok(msg_id) } } #[async_trait] impl ChatTransport for WhatsAppTransport { async fn send_message( &self, recipient: &str, plain: &str, _html: &str, ) -> Result { slog!("[whatsapp] send_message to {recipient}: {plain:.80}"); self.send_text(recipient, plain).await } async fn edit_message( &self, recipient: &str, _original_message_id: &str, plain: &str, html: &str, ) -> Result<(), String> { // WhatsApp does not support message editing — send a new message. slog!("[whatsapp] edit_message — WhatsApp does not support edits, sending new message"); self.send_message(recipient, plain, html).await.map(|_| ()) } async fn send_typing(&self, _recipient: &str, _typing: bool) -> Result<(), String> { // WhatsApp Business API does not expose typing indicators. Ok(()) } } // ── Graph API request/response types ──────────────────────────────────── #[derive(Serialize)] struct GraphSendMessage<'a> { messaging_product: &'a str, to: &'a str, r#type: &'a str, text: GraphTextBody<'a>, } #[derive(Serialize)] struct GraphTextBody<'a> { body: &'a str, } #[derive(Deserialize)] struct GraphSendResponse { #[serde(default)] messages: Vec, } #[derive(Deserialize)] struct GraphMessageId { id: String, } // ── Webhook types (Meta → us) ─────────────────────────────────────────── /// Top-level webhook payload from Meta. #[derive(Deserialize, Debug)] pub struct WebhookPayload { #[serde(default)] pub entry: Vec, } #[derive(Deserialize, Debug)] pub struct WebhookEntry { #[serde(default)] pub changes: Vec, } #[derive(Deserialize, Debug)] pub struct WebhookChange { pub value: Option, } #[derive(Deserialize, Debug)] pub struct WebhookValue { #[serde(default)] pub messages: Vec, pub metadata: Option, } #[derive(Deserialize, Debug)] pub struct WebhookMetadata { pub phone_number_id: Option, } #[derive(Deserialize, Debug)] pub struct WebhookMessage { pub from: Option, pub r#type: Option, pub text: Option, } #[derive(Deserialize, Debug)] pub struct WebhookText { pub body: Option, } /// Extract text messages from a webhook payload. /// /// Returns `(sender_phone, message_body)` pairs. pub fn extract_text_messages(payload: &WebhookPayload) -> Vec<(String, String)> { let mut messages = Vec::new(); for entry in &payload.entry { for change in &entry.changes { if let Some(value) = &change.value { for msg in &value.messages { if msg.r#type.as_deref() == Some("text") && let (Some(from), Some(text)) = (&msg.from, &msg.text) && let Some(body) = &text.body { messages.push((from.clone(), body.clone())); } } } } } messages } // ── WhatsApp message size limit ────────────────────────────────────── /// WhatsApp Business API maximum message body size in characters. const WHATSAPP_MAX_MESSAGE_LEN: usize = 4096; /// Split a text into chunks that fit within WhatsApp's message size limit. /// /// Tries to split on paragraph boundaries (`\n\n`), falling back to line /// boundaries (`\n`), and finally hard-splitting at the character limit. pub fn chunk_for_whatsapp(text: &str) -> Vec { if text.len() <= WHATSAPP_MAX_MESSAGE_LEN { return vec![text.to_string()]; } let mut chunks = Vec::new(); let mut remaining = text; while !remaining.is_empty() { if remaining.len() <= WHATSAPP_MAX_MESSAGE_LEN { chunks.push(remaining.to_string()); break; } // Find the best split point within the limit. let window = &remaining[..WHATSAPP_MAX_MESSAGE_LEN]; // Prefer paragraph boundary. let split_pos = window .rfind("\n\n") .or_else(|| window.rfind('\n')) .unwrap_or(WHATSAPP_MAX_MESSAGE_LEN); let (chunk, rest) = remaining.split_at(split_pos); let chunk = chunk.trim(); if !chunk.is_empty() { chunks.push(chunk.to_string()); } // Skip the delimiter. remaining = rest.trim_start_matches('\n'); } chunks } // ── Conversation history persistence ───────────────────────────────── /// Per-sender conversation history, keyed by phone number. pub type WhatsAppConversationHistory = Arc>>; /// On-disk format for persisted WhatsApp conversation history. #[derive(Serialize, Deserialize)] struct PersistedWhatsAppHistory { senders: HashMap, } /// Path to the persisted WhatsApp conversation history file. const WHATSAPP_HISTORY_FILE: &str = ".story_kit/whatsapp_history.json"; /// Load WhatsApp conversation history from disk. pub fn load_whatsapp_history( project_root: &std::path::Path, ) -> HashMap { 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. fn save_whatsapp_history( project_root: &std::path::Path, history: &HashMap, ) { 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}"), } } // ── Webhook handlers (Poem) ──────────────────────────────────────────── use poem::{Request, Response, handler, http::StatusCode, web::Query}; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Mutex; /// Query parameters for the webhook verification GET request. #[derive(Deserialize)] pub struct VerifyQuery { #[serde(rename = "hub.mode")] pub hub_mode: Option, #[serde(rename = "hub.verify_token")] pub hub_verify_token: Option, #[serde(rename = "hub.challenge")] pub hub_challenge: Option, } /// Shared context for webhook handlers, injected via Poem's `Data` extractor. pub struct WhatsAppWebhookContext { pub verify_token: String, pub transport: Arc, pub project_root: PathBuf, pub agents: Arc, pub bot_name: String, /// The bot's "user ID" for command dispatch (e.g. "whatsapp-bot"). pub bot_user_id: String, pub ambient_rooms: Arc>>, /// Per-sender conversation history for LLM passthrough. pub history: WhatsAppConversationHistory, /// Maximum number of conversation entries to keep per sender. pub history_size: usize, } /// GET /webhook/whatsapp — Meta verification handshake. /// /// Meta sends `hub.mode=subscribe&hub.verify_token=&hub.challenge=`. /// We return the challenge if the token matches. #[handler] pub async fn webhook_verify( Query(q): Query, ctx: poem::web::Data<&Arc>, ) -> Response { if q.hub_mode.as_deref() == Some("subscribe") && q.hub_verify_token.as_deref() == Some(&ctx.verify_token) && let Some(challenge) = q.hub_challenge { slog!("[whatsapp] Webhook verification succeeded"); return Response::builder() .status(StatusCode::OK) .body(challenge); } slog!("[whatsapp] Webhook verification failed"); Response::builder() .status(StatusCode::FORBIDDEN) .body("Verification failed") } /// POST /webhook/whatsapp — receive incoming messages from Meta. #[handler] pub async fn webhook_receive( req: &Request, body: poem::Body, ctx: poem::web::Data<&Arc>, ) -> Response { let _ = req; let bytes = match body.into_bytes().await { Ok(b) => b, Err(e) => { slog!("[whatsapp] Failed to read webhook body: {e}"); return Response::builder() .status(StatusCode::BAD_REQUEST) .body("Bad request"); } }; let payload: WebhookPayload = match serde_json::from_slice(&bytes) { Ok(p) => p, Err(e) => { slog!("[whatsapp] Failed to parse webhook payload: {e}"); // Meta expects 200 even on parse errors to avoid retries. return Response::builder() .status(StatusCode::OK) .body("ok"); } }; let messages = extract_text_messages(&payload); if messages.is_empty() { // Status updates, read receipts, etc. — acknowledge silently. return Response::builder() .status(StatusCode::OK) .body("ok"); } let ctx = Arc::clone(*ctx); tokio::spawn(async move { for (sender, text) in messages { slog!("[whatsapp] Message from {sender}: {text}"); handle_incoming_message(&ctx, &sender, &text).await; } }); Response::builder() .status(StatusCode::OK) .body("ok") } /// Dispatch an incoming WhatsApp message to bot commands. async fn handle_incoming_message( ctx: &WhatsAppWebhookContext, sender: &str, message: &str, ) { use crate::matrix::commands::{CommandDispatch, try_handle_command}; let dispatch = CommandDispatch { bot_name: &ctx.bot_name, bot_user_id: &ctx.bot_user_id, project_root: &ctx.project_root, agents: &ctx.agents, ambient_rooms: &ctx.ambient_rooms, room_id: sender, // WhatsApp messages are always "addressed" to the bot (1:1 or bot-specific). is_addressed: true, }; if let Some(response) = try_handle_command(&dispatch, message) { slog!("[whatsapp] Sending command response to {sender}"); if let Err(e) = ctx.transport.send_message(sender, &response, "").await { slog!("[whatsapp] Failed to send reply to {sender}: {e}"); } return; } // Check for async commands (htop, delete). if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command( message, &ctx.bot_name, &ctx.bot_user_id, ) { use crate::matrix::htop::HtopCommand; slog!("[whatsapp] Handling htop command from {sender}"); match htop_cmd { HtopCommand::Stop => { // htop stop — no-op on WhatsApp since there's no persistent // editable message; just acknowledge. let _ = ctx .transport .send_message(sender, "htop stopped.", "") .await; } HtopCommand::Start { duration_secs } => { // On WhatsApp, send a single snapshot instead of a live-updating // dashboard since we can't edit messages. let snapshot = crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); let _ = ctx .transport .send_message(sender, &snapshot, "") .await; } } return; } if let Some(del_cmd) = crate::matrix::delete::extract_delete_command( message, &ctx.bot_name, &ctx.bot_user_id, ) { let response = match del_cmd { crate::matrix::delete::DeleteCommand::Delete { story_number } => { slog!("[whatsapp] Handling delete command from {sender}: story {story_number}"); crate::matrix::delete::handle_delete( &ctx.bot_name, &story_number, &ctx.project_root, &ctx.agents, ) .await } crate::matrix::delete::DeleteCommand::BadArgs => { format!("Usage: `{} delete `", ctx.bot_name) } }; let _ = ctx.transport.send_message(sender, &response, "").await; return; } // No command matched — forward to LLM for conversational response. slog!("[whatsapp] No command matched, forwarding to LLM for {sender}"); handle_llm_message(ctx, sender, message).await; } /// Forward a message to Claude Code and send the response back via WhatsApp. async fn handle_llm_message( ctx: &WhatsAppWebhookContext, sender: &str, user_message: &str, ) { use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; use crate::matrix::drain_complete_paragraphs; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::watch; // Look up existing session ID for this sender. let resume_session_id: Option = { let guard = ctx.history.lock().await; guard .get(sender) .and_then(|conv| conv.session_id.clone()) }; let bot_name = &ctx.bot_name; let prompt = format!( "[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{sender}: {user_message}" ); let provider = ClaudeCodeProvider::new(); let (_cancel_tx, mut cancel_rx) = watch::channel(false); // Channel for sending complete chunks to the WhatsApp posting task. let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::(); let msg_tx_for_callback = msg_tx.clone(); // Spawn a task to post messages as they arrive. let post_transport = Arc::clone(&ctx.transport); let post_sender = sender.to_string(); let post_task = tokio::spawn(async move { while let Some(chunk) = msg_rx.recv().await { // Split into WhatsApp-sized chunks. for part in chunk_for_whatsapp(&chunk) { let _ = post_transport.send_message(&post_sender, &part, "").await; } } }); // Shared buffer between the sync token callback and the async scope. let buffer = Arc::new(std::sync::Mutex::new(String::new())); let buffer_for_callback = Arc::clone(&buffer); let sent_any_chunk = Arc::new(AtomicBool::new(false)); let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk); let project_root_str = ctx.project_root.to_string_lossy().to_string(); let result = provider .chat_stream( &prompt, &project_root_str, resume_session_id.as_deref(), None, &mut cancel_rx, move |token| { let mut buf = buffer_for_callback.lock().unwrap(); buf.push_str(token); let paragraphs = drain_complete_paragraphs(&mut buf); for chunk in paragraphs { sent_any_chunk_for_callback.store(true, Ordering::Relaxed); let _ = msg_tx_for_callback.send(chunk); } }, |_thinking| {}, |_activity| {}, ) .await; // Flush remaining text. let remaining = buffer.lock().unwrap().trim().to_string(); let did_send_any = sent_any_chunk.load(Ordering::Relaxed); let (assistant_reply, new_session_id) = match result { Ok(ClaudeCodeResult { messages, session_id, }) => { let reply = if !remaining.is_empty() { let _ = msg_tx.send(remaining.clone()); remaining } else if !did_send_any { let last_text = messages .iter() .rev() .find(|m| { m.role == crate::llm::types::Role::Assistant && !m.content.is_empty() }) .map(|m| m.content.clone()) .unwrap_or_default(); if !last_text.is_empty() { let _ = msg_tx.send(last_text.clone()); } last_text } else { remaining }; slog!("[whatsapp] session_id from chat_stream: {:?}", session_id); (reply, session_id) } Err(e) => { slog!("[whatsapp] LLM error: {e}"); let err_msg = format!("Error processing your request: {e}"); let _ = msg_tx.send(err_msg.clone()); (err_msg, None) } }; // Signal the posting task to finish and wait for it. drop(msg_tx); let _ = post_task.await; // Record this exchange in conversation history. if !assistant_reply.starts_with("Error processing") { let mut guard = ctx.history.lock().await; let conv = guard.entry(sender.to_string()).or_default(); if new_session_id.is_some() { conv.session_id = new_session_id; } conv.entries.push(ConversationEntry { role: ConversationRole::User, sender: sender.to_string(), content: user_message.to_string(), }); conv.entries.push(ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: assistant_reply, }); // Trim to configured maximum. if conv.entries.len() > ctx.history_size { let excess = conv.entries.len() - ctx.history_size; conv.entries.drain(..excess); } save_whatsapp_history(&ctx.project_root, &guard); } } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn extract_text_messages_parses_valid_payload() { let json = r#"{ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "15551234567", "type": "text", "text": { "body": "help" } }], "metadata": { "phone_number_id": "123456" } } }] }] }"#; let payload: WebhookPayload = serde_json::from_str(json).unwrap(); let msgs = extract_text_messages(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].0, "15551234567"); assert_eq!(msgs[0].1, "help"); } #[test] fn extract_text_messages_ignores_non_text() { let json = r#"{ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "15551234567", "type": "image", "image": { "id": "img123" } }], "metadata": { "phone_number_id": "123456" } } }] }] }"#; let payload: WebhookPayload = serde_json::from_str(json).unwrap(); let msgs = extract_text_messages(&payload); assert!(msgs.is_empty()); } #[test] fn extract_text_messages_handles_empty_payload() { let json = r#"{ "entry": [] }"#; let payload: WebhookPayload = serde_json::from_str(json).unwrap(); let msgs = extract_text_messages(&payload); assert!(msgs.is_empty()); } #[test] fn extract_text_messages_handles_multiple_messages() { let json = r#"{ "entry": [{ "changes": [{ "value": { "messages": [ { "from": "111", "type": "text", "text": { "body": "status" } }, { "from": "222", "type": "text", "text": { "body": "help" } } ], "metadata": { "phone_number_id": "123456" } } }] }] }"#; let payload: WebhookPayload = serde_json::from_str(json).unwrap(); let msgs = extract_text_messages(&payload); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].1, "status"); assert_eq!(msgs[1].1, "help"); } #[tokio::test] async fn transport_send_message_calls_graph_api() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/123456/messages") .match_header("authorization", "Bearer test-token") .with_body(r#"{"messages": [{"id": "wamid.abc123"}]}"#) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); let result = transport .send_message("15551234567", "hello", "

hello

") .await; assert!(result.is_ok()); assert_eq!(result.unwrap(), "wamid.abc123"); mock.assert_async().await; } #[tokio::test] async fn transport_edit_sends_new_message() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/123456/messages") .with_body(r#"{"messages": [{"id": "wamid.xyz"}]}"#) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); let result = transport .edit_message("15551234567", "old-msg-id", "updated", "

updated

") .await; assert!(result.is_ok()); mock.assert_async().await; } #[tokio::test] async fn transport_send_typing_succeeds() { let transport = WhatsAppTransport::new("123".to_string(), "tok".to_string()); assert!(transport.send_typing("room1", true).await.is_ok()); assert!(transport.send_typing("room1", false).await.is_ok()); } #[tokio::test] async fn transport_handles_api_error() { let mut server = mockito::Server::new_async().await; server .mock("POST", "/123456/messages") .with_status(401) .with_body(r#"{"error": {"message": "Invalid token"}}"#) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "bad-token".to_string(), server.url(), ); let result = transport .send_message("15551234567", "hello", "") .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("401")); } // ── chunk_for_whatsapp tests ──────────────────────────────────────── #[test] fn chunk_short_message_returns_single_chunk() { let chunks = chunk_for_whatsapp("Hello world"); assert_eq!(chunks, vec!["Hello world"]); } #[test] fn chunk_exactly_at_limit_returns_single_chunk() { let text = "a".repeat(WHATSAPP_MAX_MESSAGE_LEN); let chunks = chunk_for_whatsapp(&text); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].len(), WHATSAPP_MAX_MESSAGE_LEN); } #[test] fn chunk_splits_on_paragraph_boundary() { // Create text with a paragraph boundary near the split point. let first_para = "a".repeat(4000); let second_para = "b".repeat(200); let text = format!("{first_para}\n\n{second_para}"); let chunks = chunk_for_whatsapp(&text); assert_eq!(chunks.len(), 2); assert_eq!(chunks[0], first_para); assert_eq!(chunks[1], second_para); } #[test] fn chunk_splits_on_line_boundary_when_no_paragraph_break() { let first_line = "a".repeat(4000); let second_line = "b".repeat(200); let text = format!("{first_line}\n{second_line}"); let chunks = chunk_for_whatsapp(&text); assert_eq!(chunks.len(), 2); assert_eq!(chunks[0], first_line); assert_eq!(chunks[1], second_line); } #[test] fn chunk_hard_splits_continuous_text() { let text = "x".repeat(WHATSAPP_MAX_MESSAGE_LEN * 2 + 100); let chunks = chunk_for_whatsapp(&text); assert!(chunks.len() >= 2); for chunk in &chunks { assert!(chunk.len() <= WHATSAPP_MAX_MESSAGE_LEN); } // Verify all content is preserved. let reassembled: String = chunks.join(""); assert_eq!(reassembled.len(), text.len()); } #[test] fn chunk_empty_string_returns_single_empty() { let chunks = chunk_for_whatsapp(""); assert_eq!(chunks, vec![""]); } // ── WhatsApp history persistence tests ────────────────────────────── #[test] fn save_and_load_whatsapp_history_round_trips() { let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".story_kit"); 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(".story_kit"); 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(".story_kit"); 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")); } }