//! 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`). //! - [`MessagingWindowTracker`] — tracks the 24-hour messaging window per user. //! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp //! webhook (GET verification handshake + POST incoming messages). pub mod commands; pub mod format; pub mod history; pub mod meta; pub mod twilio; pub mod verify; pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history}; pub use meta::WhatsAppTransport; pub use twilio::{TwilioWhatsAppTransport, extract_twilio_text_messages}; use serde::Deserialize; use std::sync::Arc; use crate::chat::ChatTransport; use crate::services::Services; use crate::slog; use poem::{Request, Response, handler, http::StatusCode, web::Query}; // ── 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, #[allow(dead_code)] // Present in Meta webhook JSON, kept for deserialization pub metadata: Option, } #[derive(Deserialize, Debug)] pub struct WebhookMetadata { #[allow(dead_code)] 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 } /// 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 { /// Shared services bundle (project root, agent pool, bot identity, permissions). pub services: Arc, pub verify_token: String, /// Active provider: `"meta"` (Meta Graph API) or `"twilio"` (Twilio REST API). pub provider: String, pub transport: Arc, /// Per-sender conversation history for LLM passthrough. pub history: WhatsAppConversationHistory, /// Maximum number of conversation entries to keep per sender. pub history_size: usize, /// Tracks the 24-hour messaging window per user phone number. pub window_tracker: Arc, /// Phone numbers allowed to send messages to the bot. /// When empty, all numbers are allowed (backwards compatible). pub allowed_phones: Vec, /// Meta app secret for `X-Hub-Signature-256` HMAC verification. /// When non-empty, every inbound POST is verified against this secret. /// When empty, signature verification is skipped. pub app_secret: String, } /// GET /webhook/whatsapp — webhook verification. /// /// For Meta: responds to the `hub.mode=subscribe` challenge handshake. /// For Twilio: Twilio does not send GET verification; always returns 200 OK. #[handler] pub async fn webhook_verify( Query(q): Query, ctx: poem::web::Data<&Arc>, ) -> Response { // Twilio does not use a GET challenge; just acknowledge. if ctx.provider == "twilio" { return Response::builder().status(StatusCode::OK).body("ok"); } // Meta verification handshake. 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. /// /// Dispatches to the appropriate parser based on the configured provider: /// - `"meta"`: parses Meta's JSON `WebhookPayload`. /// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body. /// /// Both providers expect a `200 OK` response, even on parse errors. /// /// For the `"meta"` provider, the `X-Hub-Signature-256` header is verified /// against the configured app secret (HMAC-SHA256). Requests with a missing /// or invalid signature are rejected with `401`/`403` respectively. #[handler] pub async fn webhook_receive( req: &Request, body: poem::Body, ctx: poem::web::Data<&Arc>, ) -> Response { 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"); } }; // Verify HMAC-SHA256 signature for Meta webhooks when an app secret is configured. if ctx.provider != "twilio" && !ctx.app_secret.is_empty() { let signature = req.header("X-Hub-Signature-256").unwrap_or(""); if signature.is_empty() { slog!("[whatsapp] Missing X-Hub-Signature-256 header; rejecting request"); return Response::builder() .status(StatusCode::UNAUTHORIZED) .body("Missing signature"); } if !verify::verify_meta_signature(&ctx.app_secret, &bytes, signature) { slog!("[whatsapp] X-Hub-Signature-256 verification failed; rejecting request"); return Response::builder() .status(StatusCode::FORBIDDEN) .body("Invalid signature"); } } let messages = if ctx.provider == "twilio" { let msgs = extract_twilio_text_messages(&bytes); if msgs.is_empty() { slog!("[whatsapp/twilio] No text messages in webhook body; ignoring"); } msgs } else { 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 msgs = extract_text_messages(&payload); if msgs.is_empty() { // Status updates, read receipts, etc. — acknowledge silently. return Response::builder().status(StatusCode::OK).body("ok"); } msgs }; if messages.is_empty() { 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}"); commands::handle_incoming_message(&ctx, &sender, &text).await; } }); Response::builder().status(StatusCode::OK).body("ok") } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; // ── Existing webhook / transport tests ──────────────────────────── #[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"); } }