use super::traits::{Channel, ChannelMessage}; use async_trait::async_trait; use uuid::Uuid; /// WhatsApp channel — uses WhatsApp Business Cloud API /// /// This channel operates in webhook mode (push-based) rather than polling. /// Messages are received via the gateway's `/whatsapp` webhook endpoint. /// The `listen` method here is a no-op placeholder; actual message handling /// happens in the gateway when Meta sends webhook events. pub struct WhatsAppChannel { access_token: String, phone_number_id: String, verify_token: String, allowed_numbers: Vec, client: reqwest::Client, } impl WhatsAppChannel { pub fn new( access_token: String, phone_number_id: String, verify_token: String, allowed_numbers: Vec, ) -> Self { Self { access_token, phone_number_id, verify_token, allowed_numbers, client: reqwest::Client::new(), } } /// Check if a phone number is allowed (E.164 format: +1234567890) fn is_number_allowed(&self, phone: &str) -> bool { self.allowed_numbers .iter() .any(|n| n == "*" || n == phone) } /// Get the verify token for webhook verification pub fn verify_token(&self) -> &str { &self.verify_token } /// Parse an incoming webhook payload from Meta and extract messages pub fn parse_webhook_payload( &self, payload: &serde_json::Value, ) -> Vec { let mut messages = Vec::new(); // WhatsApp Cloud API webhook structure: // { "object": "whatsapp_business_account", "entry": [...] } let Some(entries) = payload.get("entry").and_then(|e| e.as_array()) else { return messages; }; for entry in entries { let Some(changes) = entry.get("changes").and_then(|c| c.as_array()) else { continue; }; for change in changes { let Some(value) = change.get("value") else { continue; }; // Extract messages array let Some(msgs) = value.get("messages").and_then(|m| m.as_array()) else { continue; }; for msg in msgs { // Get sender phone number let Some(from) = msg.get("from").and_then(|f| f.as_str()) else { continue; }; // Check allowlist let normalized_from = if from.starts_with('+') { from.to_string() } else { format!("+{from}") }; if !self.is_number_allowed(&normalized_from) { tracing::warn!( "WhatsApp: ignoring message from unauthorized number: {normalized_from}. \ Add to allowed_numbers in config.toml, then run `zeroclaw onboard --channels-only`." ); continue; } // Extract text content (support text messages only for now) let content = if let Some(text_obj) = msg.get("text") { text_obj .get("body") .and_then(|b| b.as_str()) .unwrap_or("") .to_string() } else { // Could be image, audio, etc. — skip for now tracing::debug!("WhatsApp: skipping non-text message from {from}"); continue; }; if content.is_empty() { continue; } // Get timestamp let timestamp = msg .get("timestamp") .and_then(|t| t.as_str()) .and_then(|t| t.parse::().ok()) .unwrap_or_else(|| { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() }); messages.push(ChannelMessage { id: Uuid::new_v4().to_string(), sender: normalized_from, content, channel: "whatsapp".to_string(), timestamp, }); } } } messages } } #[async_trait] impl Channel for WhatsAppChannel { fn name(&self) -> &str { "whatsapp" } async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> { // WhatsApp Cloud API: POST to /v18.0/{phone_number_id}/messages let url = format!( "https://graph.facebook.com/v18.0/{}/messages", self.phone_number_id ); // Normalize recipient (remove leading + if present for API) let to = recipient.strip_prefix('+').unwrap_or(recipient); let body = serde_json::json!({ "messaging_product": "whatsapp", "recipient_type": "individual", "to": to, "type": "text", "text": { "preview_url": false, "body": message } }); let resp = self .client .post(&url) .header("Authorization", format!("Bearer {}", self.access_token)) .header("Content-Type", "application/json") .json(&body) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let error_body = resp.text().await.unwrap_or_default(); tracing::error!("WhatsApp send failed: {status} — {error_body}"); anyhow::bail!("WhatsApp API error: {status}"); } Ok(()) } async fn listen(&self, _tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { // WhatsApp uses webhooks (push-based), not polling. // Messages are received via the gateway's /whatsapp endpoint. // This method keeps the channel "alive" but doesn't actively poll. tracing::info!( "WhatsApp channel active (webhook mode). \ Configure Meta webhook to POST to your gateway's /whatsapp endpoint." ); // Keep the task alive — it will be cancelled when the channel shuts down loop { tokio::time::sleep(std::time::Duration::from_secs(3600)).await; } } async fn health_check(&self) -> bool { // Check if we can reach the WhatsApp API let url = format!( "https://graph.facebook.com/v18.0/{}", self.phone_number_id ); self.client .get(&url) .header("Authorization", format!("Bearer {}", self.access_token)) .send() .await .map(|r| r.status().is_success()) .unwrap_or(false) } } #[cfg(test)] mod tests { use super::*; fn make_channel() -> WhatsAppChannel { WhatsAppChannel::new( "test-token".into(), "123456789".into(), "verify-me".into(), vec!["+1234567890".into()], ) } #[test] fn whatsapp_channel_name() { let ch = make_channel(); assert_eq!(ch.name(), "whatsapp"); } #[test] fn whatsapp_verify_token() { let ch = make_channel(); assert_eq!(ch.verify_token(), "verify-me"); } #[test] fn whatsapp_number_allowed_exact() { let ch = make_channel(); assert!(ch.is_number_allowed("+1234567890")); assert!(!ch.is_number_allowed("+9876543210")); } #[test] fn whatsapp_number_allowed_wildcard() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); assert!(ch.is_number_allowed("+1234567890")); assert!(ch.is_number_allowed("+9999999999")); } #[test] fn whatsapp_number_denied_empty() { let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec![]); assert!(!ch.is_number_allowed("+1234567890")); } #[test] fn whatsapp_parse_empty_payload() { let ch = make_channel(); let payload = serde_json::json!({}); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_valid_text_message() { let ch = make_channel(); let payload = serde_json::json!({ "object": "whatsapp_business_account", "entry": [{ "id": "123", "changes": [{ "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "15551234567", "phone_number_id": "123456789" }, "messages": [{ "from": "1234567890", "id": "wamid.xxx", "timestamp": "1699999999", "type": "text", "text": { "body": "Hello ZeroClaw!" } }] }, "field": "messages" }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].sender, "+1234567890"); assert_eq!(msgs[0].content, "Hello ZeroClaw!"); assert_eq!(msgs[0].channel, "whatsapp"); assert_eq!(msgs[0].timestamp, 1699999999); } #[test] fn whatsapp_parse_unauthorized_number() { let ch = make_channel(); let payload = serde_json::json!({ "object": "whatsapp_business_account", "entry": [{ "changes": [{ "value": { "messages": [{ "from": "9999999999", "timestamp": "1699999999", "type": "text", "text": { "body": "Spam" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty(), "Unauthorized numbers should be filtered"); } #[test] fn whatsapp_parse_non_text_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "1234567890", "timestamp": "1699999999", "type": "image", "image": { "id": "img123" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty(), "Non-text messages should be skipped"); } #[test] fn whatsapp_parse_multiple_messages() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [ { "from": "111", "timestamp": "1", "type": "text", "text": { "body": "First" } }, { "from": "222", "timestamp": "2", "type": "text", "text": { "body": "Second" } } ] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].content, "First"); assert_eq!(msgs[1].content, "Second"); } #[test] fn whatsapp_parse_normalizes_phone_with_plus() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["+1234567890".into()], ); // API sends without +, but we normalize to + let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "1234567890", "timestamp": "1", "type": "text", "text": { "body": "Hi" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].sender, "+1234567890"); } #[test] fn whatsapp_empty_text_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": "" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } // ══════════════════════════════════════════════════════════ // EDGE CASES — Comprehensive coverage // ══════════════════════════════════════════════════════════ #[test] fn whatsapp_parse_missing_entry_array() { let ch = make_channel(); let payload = serde_json::json!({ "object": "whatsapp_business_account" }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_entry_not_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": "not_an_array" }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_missing_changes_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "id": "123" }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_changes_not_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": "not_an_array" }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_missing_value() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": [{ "field": "messages" }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_missing_messages_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "metadata": {} } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_messages_not_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": "not_an_array" } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_missing_from_field() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "timestamp": "1", "type": "text", "text": { "body": "No sender" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty(), "Messages without 'from' should be skipped"); } #[test] fn whatsapp_parse_missing_text_body() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": {} }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty(), "Messages with empty text object should be skipped"); } #[test] fn whatsapp_parse_null_text_body() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": null } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty(), "Messages with null body should be skipped"); } #[test] fn whatsapp_parse_invalid_timestamp_uses_current() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "not_a_number", "type": "text", "text": { "body": "Hello" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); // Timestamp should be current time (non-zero) assert!(msgs[0].timestamp > 0); } #[test] fn whatsapp_parse_missing_timestamp_uses_current() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "type": "text", "text": { "body": "Hello" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert!(msgs[0].timestamp > 0); } #[test] fn whatsapp_parse_multiple_entries() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [ { "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": "Entry 1" } }] } }] }, { "changes": [{ "value": { "messages": [{ "from": "222", "timestamp": "2", "type": "text", "text": { "body": "Entry 2" } }] } }] } ] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].content, "Entry 1"); assert_eq!(msgs[1].content, "Entry 2"); } #[test] fn whatsapp_parse_multiple_changes() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [ { "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": "Change 1" } }] } }, { "value": { "messages": [{ "from": "222", "timestamp": "2", "type": "text", "text": { "body": "Change 2" } }] } } ] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].content, "Change 1"); assert_eq!(msgs[1].content, "Change 2"); } #[test] fn whatsapp_parse_status_update_ignored() { // Status updates have "statuses" instead of "messages" let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "statuses": [{ "id": "wamid.xxx", "status": "delivered", "timestamp": "1699999999" }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty(), "Status updates should be ignored"); } #[test] fn whatsapp_parse_audio_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "audio", "audio": { "id": "audio123", "mime_type": "audio/ogg" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_video_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "video", "video": { "id": "video123" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_document_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "document", "document": { "id": "doc123", "filename": "file.pdf" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_sticker_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "sticker", "sticker": { "id": "sticker123" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_location_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "location", "location": { "latitude": 40.7128, "longitude": -74.0060 } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_contacts_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "contacts", "contacts": [{ "name": { "formatted_name": "John" } }] }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_reaction_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "reaction", "reaction": { "message_id": "wamid.xxx", "emoji": "👍" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_mixed_authorized_unauthorized() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["+1111111111".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [ { "from": "1111111111", "timestamp": "1", "type": "text", "text": { "body": "Allowed" } }, { "from": "9999999999", "timestamp": "2", "type": "text", "text": { "body": "Blocked" } }, { "from": "1111111111", "timestamp": "3", "type": "text", "text": { "body": "Also allowed" } } ] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 2); assert_eq!(msgs[0].content, "Allowed"); assert_eq!(msgs[1].content, "Also allowed"); } #[test] fn whatsapp_parse_unicode_message() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": "Hello 👋 世界 🌍 مرحبا" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content, "Hello 👋 世界 🌍 مرحبا"); } #[test] fn whatsapp_parse_very_long_message() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let long_text = "A".repeat(10_000); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": long_text } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content.len(), 10_000); } #[test] fn whatsapp_parse_whitespace_only_message_skipped() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": " " } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); // Whitespace-only is NOT empty, so it passes through assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content, " "); } #[test] fn whatsapp_number_allowed_multiple_numbers() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["+1111111111".into(), "+2222222222".into(), "+3333333333".into()], ); assert!(ch.is_number_allowed("+1111111111")); assert!(ch.is_number_allowed("+2222222222")); assert!(ch.is_number_allowed("+3333333333")); assert!(!ch.is_number_allowed("+4444444444")); } #[test] fn whatsapp_number_allowed_case_sensitive() { // Phone numbers should be exact match let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["+1234567890".into()], ); assert!(ch.is_number_allowed("+1234567890")); // Different number should not match assert!(!ch.is_number_allowed("+1234567891")); } #[test] fn whatsapp_parse_phone_already_has_plus() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["+1234567890".into()], ); // If API sends with +, we should still handle it let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "+1234567890", "timestamp": "1", "type": "text", "text": { "body": "Hi" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].sender, "+1234567890"); } #[test] fn whatsapp_channel_fields_stored_correctly() { let ch = WhatsAppChannel::new( "my-access-token".into(), "phone-id-123".into(), "my-verify-token".into(), vec!["+111".into(), "+222".into()], ); assert_eq!(ch.verify_token(), "my-verify-token"); assert!(ch.is_number_allowed("+111")); assert!(ch.is_number_allowed("+222")); assert!(!ch.is_number_allowed("+333")); } #[test] fn whatsapp_parse_empty_messages_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_empty_entry_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_empty_changes_array() { let ch = make_channel(); let payload = serde_json::json!({ "entry": [{ "changes": [] }] }); let msgs = ch.parse_webhook_payload(&payload); assert!(msgs.is_empty()); } #[test] fn whatsapp_parse_newlines_preserved() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": "Line 1\nLine 2\nLine 3" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content, "Line 1\nLine 2\nLine 3"); } #[test] fn whatsapp_parse_special_characters() { let ch = WhatsAppChannel::new( "tok".into(), "123".into(), "ver".into(), vec!["*".into()], ); let payload = serde_json::json!({ "entry": [{ "changes": [{ "value": { "messages": [{ "from": "111", "timestamp": "1", "type": "text", "text": { "body": " & \"quotes\" 'apostrophe'" } }] } }] }] }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content, " & \"quotes\" 'apostrophe'"); } }