// WhatsApp API types have fields reserved for future use (contacts, statuses, etc.) #![allow(dead_code)] //! WhatsApp Cloud API channel for IronClaw. //! //! This WASM component implements the channel interface for handling WhatsApp //! webhooks and sending messages back via the Cloud API. //! //! # Features //! //! - Webhook-based message receiving (WhatsApp is webhook-only, no polling) //! - Text message support //! - Business account support //! - User name extraction from contacts //! //! # Security //! //! - Access token is injected by host during HTTP requests via {WHATSAPP_ACCESS_TOKEN} placeholder //! - WASM never sees raw credentials //! - Webhook verify token validation by host // Generate bindings from the WIT file wit_bindgen::generate!({ world: "sandboxed-channel", path: "../../wit/channel.wit", }); use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; // ============================================================================ // WhatsApp Cloud API Types // ============================================================================ /// WhatsApp webhook payload. /// https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples #[derive(Debug, Deserialize)] struct WebhookPayload { /// Always "whatsapp_business_account" object: String, /// Array of webhook entries entry: Vec, } /// Single webhook entry. #[derive(Debug, Deserialize)] struct WebhookEntry { /// WhatsApp Business Account ID id: String, /// Changes in this entry changes: Vec, } /// A change notification. #[derive(Debug, Deserialize)] struct WebhookChange { /// Field that changed (usually "messages") field: String, /// The change value value: WebhookValue, } /// The value of a change. #[derive(Debug, Deserialize)] struct WebhookValue { /// Messaging product (always "whatsapp") messaging_product: String, /// Business account metadata metadata: BusinessMetadata, /// Contact information (sender details) #[serde(default)] contacts: Vec, /// Incoming messages #[serde(default)] messages: Vec, /// Message statuses (delivered, read, etc.) #[serde(default)] statuses: Vec, } /// Business account metadata. #[derive(Debug, Deserialize)] struct BusinessMetadata { /// Display phone number display_phone_number: String, /// Phone number ID (used in API calls) phone_number_id: String, } /// Contact information. #[derive(Debug, Deserialize)] struct Contact { /// WhatsApp ID (phone number) wa_id: String, /// Profile information profile: Option, } /// Contact profile. #[derive(Debug, Deserialize)] struct ContactProfile { /// Display name name: String, } /// Incoming WhatsApp message. #[derive(Debug, Deserialize)] struct WhatsAppMessage { /// Message ID id: String, /// Sender's phone number from: String, /// Unix timestamp timestamp: String, /// Message type: text, image, audio, video, document, etc. #[serde(rename = "type")] message_type: String, /// Text content (if type is "text") text: Option, /// Context for replies context: Option, } /// Text message content. #[derive(Debug, Deserialize)] struct TextContent { /// The message body body: String, } /// Reply context. #[derive(Debug, Deserialize)] struct MessageContext { /// Message ID being replied to message_id: String, /// Phone number of original sender from: Option, } /// Message status update. #[derive(Debug, Deserialize)] struct MessageStatus { /// Message ID id: String, /// Status: sent, delivered, read, failed status: String, /// Timestamp timestamp: String, /// Recipient ID recipient_id: String, } /// WhatsApp API response wrapper. #[derive(Debug, Deserialize)] struct WhatsAppApiResponse { /// Messages sent (on success) messages: Option>, /// Error info (on failure) error: Option, } /// Sent message info. #[derive(Debug, Deserialize)] struct SentMessage { /// Message ID id: String, } /// API error details. #[derive(Debug, Deserialize)] struct ApiError { /// Error message message: String, /// Error type #[serde(rename = "type")] error_type: Option, /// Error code code: Option, } // ============================================================================ // Channel Metadata // ============================================================================ /// Metadata stored with emitted messages for response routing. /// This MUST contain all info needed to send a response. #[derive(Debug, Serialize, Deserialize)] struct WhatsAppMessageMetadata { /// Phone number ID (business account, for API URL) phone_number_id: String, /// Sender's phone number (becomes recipient for response) sender_phone: String, /// Original message ID (for reply context) message_id: String, /// Timestamp of original message timestamp: String, } /// Channel configuration from capabilities file. #[derive(Debug, Deserialize)] struct WhatsAppConfig { /// API version to use (default: v18.0) #[serde(default = "default_api_version")] api_version: String, /// Whether to reply to the original message (thread context) #[serde(default = "default_reply_to_message")] reply_to_message: bool, } fn default_api_version() -> String { "v18.0".to_string() } fn default_reply_to_message() -> bool { true } // ============================================================================ // Channel Implementation // ============================================================================ struct WhatsAppChannel; impl Guest for WhatsAppChannel { fn on_start(config_json: String) -> Result { let config: WhatsAppConfig = match serde_json::from_str(&config_json) { Ok(c) => c, Err(e) => { channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to parse WhatsApp config, using defaults: {}", e), ); WhatsAppConfig { api_version: default_api_version(), reply_to_message: default_reply_to_message(), } } }; channel_host::log( channel_host::LogLevel::Info, &format!( "WhatsApp channel starting (API version: {})", config.api_version ), ); // Persist api_version in workspace so on_respond() can read it let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version); // WhatsApp Cloud API is webhook-only, no polling available Ok(ChannelConfig { display_name: "WhatsApp".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/whatsapp".to_string(), // GET for webhook verification, POST for incoming messages methods: vec!["GET".to_string(), "POST".to_string()], // Webhook verify token should be validated by host require_secret: true, }], poll: None, // WhatsApp doesn't support polling }) } fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { channel_host::log( channel_host::LogLevel::Debug, &format!("Received {} request to {}", req.method, req.path), ); // Handle webhook verification (GET request from Meta) if req.method == "GET" { return handle_verification(&req); } // Handle incoming messages (POST request) if req.method == "POST" { // Defense in depth: check secret validation // Host validates the verify token, but we double-check the flag if !req.secret_validated { channel_host::log( channel_host::LogLevel::Warn, "Webhook request with invalid or missing verify token", ); // Return 401 but note that host should have already rejected these } return handle_incoming_message(&req); } // Method not allowed json_response(405, serde_json::json!({"error": "Method not allowed"})) } fn on_poll() { // WhatsApp Cloud API is webhook-only, no polling // This should never be called since poll config is None } fn on_respond(response: AgentResponse) -> Result<(), String> { channel_host::log( channel_host::LogLevel::Debug, &format!("Sending response for message: {}", response.message_id), ); // Parse metadata from the ORIGINAL incoming message // This contains the routing info we need (sender becomes recipient) let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; // Read api_version from workspace (set during on_start), fallback to default let api_version = channel_host::workspace_read("channels/whatsapp/api_version") .filter(|s| !s.is_empty()) .unwrap_or_else(|| "v18.0".to_string()); // Build WhatsApp API URL with token placeholder // Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header let api_url = format!( "https://graph.facebook.com/{}/{}/messages", api_version, metadata.phone_number_id ); // Build sendMessage payload let payload = serde_json::json!({ "messaging_product": "whatsapp", "recipient_type": "individual", "to": metadata.sender_phone, // Original sender becomes recipient "type": "text", "text": { "preview_url": false, "body": response.content } }); let payload_bytes = serde_json::to_vec(&payload) .map_err(|e| format!("Failed to serialize payload: {}", e))?; // Headers with Bearer token placeholder // Host will inject the actual access token let headers = serde_json::json!({ "Content-Type": "application/json", "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}" }); let result = channel_host::http_request( "POST", &api_url, &headers.to_string(), Some(&payload_bytes), None, ); match result { Ok(http_response) => { // Parse WhatsApp API response let api_response: Result = serde_json::from_slice(&http_response.body); match api_response { Ok(resp) => { // Check for API error if let Some(error) = resp.error { return Err(format!( "WhatsApp API error: {} (code: {:?})", error.message, error.code )); } // Success - log the sent message ID if let Some(messages) = resp.messages { if let Some(sent) = messages.first() { channel_host::log( channel_host::LogLevel::Debug, &format!( "Sent message to {}: id={}", metadata.sender_phone, sent.id ), ); } } Ok(()) } Err(e) => { // Couldn't parse response, check status code if http_response.status >= 200 && http_response.status < 300 { // Probably OK even if we can't parse channel_host::log( channel_host::LogLevel::Info, "Message sent (response parse failed but status OK)", ); Ok(()) } else { let body_str = String::from_utf8_lossy(&http_response.body); Err(format!( "WhatsApp API HTTP {}: {} (parse error: {})", http_response.status, body_str, e )) } } } } Err(e) => Err(format!("HTTP request failed: {}", e)), } } fn on_status(_update: StatusUpdate) {} fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, "WhatsApp channel shutting down", ); } } // ============================================================================ // Webhook Verification // ============================================================================ /// Handle WhatsApp webhook verification request from Meta. /// /// Meta sends a GET request with: /// - hub.mode=subscribe /// - hub.challenge= /// - hub.verify_token= /// /// We must respond with the challenge value to verify. fn handle_verification(req: &IncomingHttpRequest) -> OutgoingHttpResponse { // Parse query parameters let query: serde_json::Value = serde_json::from_str(&req.query_json).unwrap_or(serde_json::Value::Null); let mode = query.get("hub.mode").and_then(|v| v.as_str()); let challenge = query.get("hub.challenge").and_then(|v| v.as_str()); // Verify token is validated by host via secret_validated field // We just need to check mode and return challenge if mode == Some("subscribe") { if let Some(challenge) = challenge { channel_host::log( channel_host::LogLevel::Info, "Webhook verification successful", ); // Must respond with the challenge as plain text return OutgoingHttpResponse { status: 200, headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), body: challenge.as_bytes().to_vec(), }; } } channel_host::log( channel_host::LogLevel::Warn, &format!( "Webhook verification failed: mode={:?}, challenge={:?}", mode, challenge.is_some() ), ); OutgoingHttpResponse { status: 403, headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), body: b"Verification failed".to_vec(), } } // ============================================================================ // Message Handling // ============================================================================ /// Handle incoming WhatsApp webhook payload. fn handle_incoming_message(req: &IncomingHttpRequest) -> OutgoingHttpResponse { // Parse the body as UTF-8 let body_str = match std::str::from_utf8(&req.body) { Ok(s) => s, Err(_) => { return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"})); } }; // Parse webhook payload let payload: WebhookPayload = match serde_json::from_str(body_str) { Ok(p) => p, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to parse webhook payload: {}", e), ); // Return 200 to prevent Meta from retrying return json_response(200, serde_json::json!({"status": "ok"})); } }; // Validate object type if payload.object != "whatsapp_business_account" { channel_host::log( channel_host::LogLevel::Warn, &format!("Unexpected object type: {}", payload.object), ); return json_response(200, serde_json::json!({"status": "ok"})); } // Process each entry for entry in payload.entry { for change in entry.changes { // Only handle message changes if change.field != "messages" { continue; } let value = change.value; let phone_number_id = value.metadata.phone_number_id.clone(); // Build contact name lookup let contact_names: std::collections::HashMap = value .contacts .iter() .filter_map(|c| { c.profile .as_ref() .map(|p| (c.wa_id.clone(), p.name.clone())) }) .collect(); // Skip status updates (delivered, read, etc.) - we only want messages // This prevents loops and unnecessary processing if !value.statuses.is_empty() && value.messages.is_empty() { channel_host::log( channel_host::LogLevel::Debug, &format!("Skipping {} status updates", value.statuses.len()), ); continue; } // Process messages for message in value.messages { handle_message(&message, &phone_number_id, &contact_names); } } } // Always respond 200 quickly (Meta expects fast responses) json_response(200, serde_json::json!({"status": "ok"})) } /// Process a single WhatsApp message. fn handle_message( message: &WhatsAppMessage, phone_number_id: &str, contact_names: &std::collections::HashMap, ) { // Only handle text messages for now // TODO: Add support for image, audio, video, document, etc. if message.message_type != "text" { channel_host::log( channel_host::LogLevel::Debug, &format!("Skipping non-text message type: {}", message.message_type), ); return; } // Extract text content let text = match &message.text { Some(t) if !t.body.is_empty() => t.body.clone(), _ => return, }; // Look up sender's name from contacts let user_name = contact_names.get(&message.from).cloned(); // Build metadata for response routing // This is critical - the response handler uses this to know where to send let metadata = WhatsAppMessageMetadata { phone_number_id: phone_number_id.to_string(), sender_phone: message.from.clone(), // This becomes recipient in response message_id: message.id.clone(), timestamp: message.timestamp.clone(), }; let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Emit the message to the agent channel_host::emit_message(&EmittedMessage { user_id: message.from.clone(), user_name, content: text, thread_id: None, // WhatsApp doesn't have threads like Slack/Discord metadata_json, }); channel_host::log( channel_host::LogLevel::Debug, &format!( "Emitted message from {} (phone_number_id={})", message.from, phone_number_id ), ); } // ============================================================================ // Utilities // ============================================================================ /// Create a JSON HTTP response. fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { let body = serde_json::to_vec(&value).unwrap_or_default(); let headers = serde_json::json!({"Content-Type": "application/json"}); OutgoingHttpResponse { status, headers_json: headers.to_string(), body, } } // Export the component export!(WhatsAppChannel); // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_webhook_payload() { let json = r#"{ "object": "whatsapp_business_account", "entry": [{ "id": "123456789", "changes": [{ "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "+1234567890", "phone_number_id": "987654321" }, "contacts": [{ "wa_id": "15551234567", "profile": { "name": "John Doe" } }], "messages": [{ "id": "wamid.abc123", "from": "15551234567", "timestamp": "1234567890", "type": "text", "text": { "body": "Hello!" } }] } }] }] }"#; let payload: WebhookPayload = serde_json::from_str(json).unwrap(); assert_eq!(payload.object, "whatsapp_business_account"); assert_eq!(payload.entry.len(), 1); let change = &payload.entry[0].changes[0]; assert_eq!(change.field, "messages"); assert_eq!(change.value.metadata.phone_number_id, "987654321"); let message = &change.value.messages[0]; assert_eq!(message.from, "15551234567"); assert_eq!(message.text.as_ref().unwrap().body, "Hello!"); } #[test] fn test_parse_status_update() { let json = r#"{ "object": "whatsapp_business_account", "entry": [{ "id": "123456789", "changes": [{ "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "+1234567890", "phone_number_id": "987654321" }, "statuses": [{ "id": "wamid.abc123", "status": "delivered", "timestamp": "1234567890", "recipient_id": "15551234567" }] } }] }] }"#; let payload: WebhookPayload = serde_json::from_str(json).unwrap(); let value = &payload.entry[0].changes[0].value; // Should have status but no messages assert!(value.messages.is_empty()); assert_eq!(value.statuses.len(), 1); assert_eq!(value.statuses[0].status, "delivered"); } #[test] fn test_metadata_roundtrip() { let metadata = WhatsAppMessageMetadata { phone_number_id: "123456".to_string(), sender_phone: "15551234567".to_string(), message_id: "wamid.abc".to_string(), timestamp: "1234567890".to_string(), }; let json = serde_json::to_string(&metadata).unwrap(); let parsed: WhatsAppMessageMetadata = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.phone_number_id, "123456"); assert_eq!(parsed.sender_phone, "15551234567"); } }