//! WhatsApp Meta (Cloud API) transport — sends and receives messages via the Meta Graph API. use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::chat::{ChatTransport, MessageId}; use crate::slog; use super::history::MessagingWindowTracker; // ── API base URLs (overridable for tests) ──────────────────────────────── pub(super) const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0"; /// Graph API error code indicating the 24-hour messaging window has elapsed. /// /// When Meta returns this code the caller must fall back to an approved message /// template instead of free-form text. pub(super) const ERROR_CODE_OUTSIDE_WINDOW: i64 = 131047; /// Sentinel error string returned by [`WhatsAppTransport::send_text`] when the /// Graph API reports that the 24-hour messaging window has expired. pub(super) const OUTSIDE_WINDOW_ERR: &str = "OUTSIDE_MESSAGING_WINDOW"; // ── WhatsApp Transport ────────────────────────────────────────────────── /// Real WhatsApp Business API transport. /// /// Sends text messages via `POST {GRAPH_API_BASE}/{phone_number_id}/messages`. /// Falls back to approved notification templates when the 24-hour window has /// elapsed (Meta error 131047). pub struct WhatsAppTransport { phone_number_id: String, access_token: String, client: reqwest::Client, /// Name of the approved Meta message template used for notifications /// outside the 24-hour messaging window. #[allow(dead_code)] // Used by Meta provider path (send_template_notification) notification_template_name: String, /// Optional base URL override for tests. api_base: String, } impl WhatsAppTransport { pub fn new( phone_number_id: String, access_token: String, notification_template_name: String, ) -> Self { Self { phone_number_id, access_token, client: reqwest::Client::new(), notification_template_name, api_base: GRAPH_API_BASE.to_string(), } } #[cfg(test)] pub(crate) 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(), notification_template_name: "pipeline_notification".to_string(), api_base, } } /// Send a free-form text message to a WhatsApp user via the Graph API. /// /// Returns [`OUTSIDE_WINDOW_ERR`] if the API responds with error code /// 131047 (messaging window expired). All other errors are returned as /// descriptive strings. 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() { // Check for 'outside messaging window' (code 131047). Return a // distinct sentinel so callers can fall back to a template without // crashing. if let Ok(err_body) = serde_json::from_str::(&resp_text) && err_body.error.as_ref().and_then(|e| e.code) == Some(ERROR_CODE_OUTSIDE_WINDOW) { slog!( "[whatsapp] Outside 24-hour messaging window for {to}; \ template required (error 131047)" ); return Err(OUTSIDE_WINDOW_ERR.to_string()); } 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) } /// Send an approved template notification message. /// /// Used when the 24-hour window has expired and free-form text is not /// permitted. The template must already be approved in the Meta Business /// Manager under the name configured in `bot.toml` /// (`whatsapp_notification_template`, default `pipeline_notification`). /// /// The template body is expected to accept two positional parameters: /// `{{1}}` = story name, `{{2}}` = pipeline stage. #[allow(dead_code)] // Meta provider path — template fallback for expired 24h window pub async fn send_template_notification( &self, to: &str, story_name: &str, stage: &str, ) -> Result { let url = format!("{}/{}/messages", self.api_base, self.phone_number_id); let payload = GraphTemplateMessage { messaging_product: "whatsapp", to, r#type: "template", template: GraphTemplate { name: &self.notification_template_name, language: GraphLanguage { code: "en_US" }, components: vec![GraphTemplateComponent { r#type: "body", parameters: vec![ GraphTemplateParameter { r#type: "text", text: story_name.to_string(), }, GraphTemplateParameter { r#type: "text", text: stage.to_string(), }, ], }], }, }; let resp = self .client .post(&url) .bearer_auth(&self.access_token) .json(&payload) .send() .await .map_err(|e| format!("WhatsApp template 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 template API returned {status}: {resp_text}" )); } let parsed: GraphSendResponse = serde_json::from_str(&resp_text).map_err(|e| { format!("Failed to parse WhatsApp template API response: {e} — body: {resp_text}") })?; let msg_id = parsed .messages .first() .map(|m| m.id.clone()) .unwrap_or_default(); Ok(msg_id) } /// Send a pipeline notification, respecting the 24-hour messaging window. /// /// - Within the window: sends a free-form text message. /// - Outside the window (or if the API returns 131047): sends an approved /// template message instead. /// /// This method never crashes on a messaging-window error — it always /// attempts the template fallback and logs what happened. #[allow(dead_code)] // Meta provider path — window-aware notification dispatch pub async fn send_notification( &self, to: &str, tracker: &MessagingWindowTracker, story_name: &str, stage: &str, ) -> Result { if tracker.is_within_window(to) { let text = format!("Story '{story_name}' has moved to {stage}."); match self.send_text(to, &text).await { Ok(id) => return Ok(id), Err(ref e) if e == OUTSIDE_WINDOW_ERR => { // Window expired between our check and the API call — // fall through to the template path. slog!( "[whatsapp] Window expired mid-flight for {to}; \ falling back to template" ); } Err(e) => return Err(e), } } // Outside window — use the approved template. slog!("[whatsapp] Sending template notification to {to} (outside 24h window)"); self.send_template_notification(to, story_name, stage).await } } #[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}"); match self.send_text(recipient, plain).await { Ok(id) => Ok(id), Err(ref e) if e == OUTSIDE_WINDOW_ERR => { // Graceful degradation: log and surface a meaningful error // rather than crashing. Callers sending command responses // should normally be within the window; this handles the edge // case where processing was delayed. slog!( "[whatsapp] Cannot send to {recipient}: outside 24h window \ (message dropped)" ); Err(format!( "Outside 24-hour messaging window for {recipient}; \ send a message to the bot first to re-open the window" )) } Err(e) => Err(e), } } 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, } // ── Graph API error response types ───────────────────────────────────── #[derive(Deserialize)] struct GraphApiErrorResponse { error: Option, } #[derive(Deserialize)] struct GraphApiError { code: Option, #[allow(dead_code)] message: Option, } // ── Template message types ────────────────────────────────────────────── #[allow(dead_code)] // Meta provider path — template message types #[derive(Serialize)] struct GraphTemplateMessage<'a> { messaging_product: &'a str, to: &'a str, r#type: &'a str, template: GraphTemplate<'a>, } #[allow(dead_code)] #[derive(Serialize)] struct GraphTemplate<'a> { name: &'a str, language: GraphLanguage, components: Vec, } #[allow(dead_code)] #[derive(Serialize)] struct GraphLanguage { code: &'static str, } #[allow(dead_code)] #[derive(Serialize)] struct GraphTemplateComponent { r#type: &'static str, parameters: Vec, } #[allow(dead_code)] #[derive(Serialize)] struct GraphTemplateParameter { r#type: &'static str, text: String, } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::chat::transport::whatsapp::history::MessagingWindowTracker; // ── send_text error handling ─────────────────────────────────────── #[tokio::test] async fn send_text_handles_131047_outside_window_error() { let mut server = mockito::Server::new_async().await; server .mock("POST", "/123456/messages") .with_status(400) .with_body( r#"{"error":{"message":"More than 24 hours have passed","type":"OAuthException","code":131047}}"#, ) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); let result = transport.send_text("15551234567", "hello").await; assert!(result.is_err()); assert_eq!(result.unwrap_err(), OUTSIDE_WINDOW_ERR); } #[tokio::test] async fn send_message_handles_outside_window_gracefully() { let mut server = mockito::Server::new_async().await; server .mock("POST", "/123456/messages") .with_status(400) .with_body( r#"{"error":{"message":"More than 24 hours have passed","type":"OAuthException","code":131047}}"#, ) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); // send_message must not panic — it returns Err with a human-readable message. let result = transport.send_message("15551234567", "hello", "").await; assert!(result.is_err()); let msg = result.unwrap_err(); assert!( msg.contains("24-hour messaging window"), "unexpected: {msg}" ); } // ── send_template_notification ──────────────────────────────────── #[tokio::test] async fn send_template_notification_calls_graph_api() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/123456/messages") .match_header("authorization", "Bearer test-token") .match_body(mockito::Matcher::PartialJsonString( r#"{"type":"template"}"#.to_string(), )) .with_body(r#"{"messages": [{"id": "wamid.tpl123"}]}"#) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); let result = transport .send_template_notification("15551234567", "my-story", "done") .await; assert!(result.is_ok(), "unexpected err: {:?}", result.err()); assert_eq!(result.unwrap(), "wamid.tpl123"); mock.assert_async().await; } #[tokio::test] async fn send_notification_uses_text_within_window() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/123456/messages") .match_body(mockito::Matcher::PartialJsonString( r#"{"type":"text"}"#.to_string(), )) .with_body(r#"{"messages": [{"id": "wamid.txt1"}]}"#) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); let tracker = MessagingWindowTracker::new(); tracker.record_message("15551234567"); let result = transport .send_notification("15551234567", &tracker, "my-story", "done") .await; assert!(result.is_ok()); mock.assert_async().await; } #[tokio::test] async fn send_notification_uses_template_outside_window() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/123456/messages") .match_body(mockito::Matcher::PartialJsonString( r#"{"type":"template"}"#.to_string(), )) .with_body(r#"{"messages": [{"id": "wamid.tpl2"}]}"#) .create_async() .await; let transport = WhatsAppTransport::with_api_base( "123456".to_string(), "test-token".to_string(), server.url(), ); // No record_message call — user is outside the window. let tracker = MessagingWindowTracker::new(); let result = transport .send_notification("15551234567", &tracker, "my-story", "done") .await; assert!(result.is_ok()); mock.assert_async().await; } #[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(), "tpl".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")); } }