From c14911009fc0c31149b3cf8e20d46a0d2aceba90 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 4 Feb 2026 16:42:49 -0800 Subject: [PATCH] Add WhatsApp channel WASM module Implements the sandboxed-channel WIT interface for WhatsApp Cloud API: - Webhook verification (GET with hub.mode=subscribe) - Incoming message handling (POST webhooks) - Outgoing responses via Graph API - Parses WhatsApp webhook payload format Built and tested in Docker sandbox with wasm32-wasip2 target. Co-Authored-By: Claude Opus 4.5 --- channels/whatsapp/Cargo.lock | 401 +++++++++++++++++++++++++++++++++++ channels/whatsapp/Cargo.toml | 18 ++ channels/whatsapp/src/lib.rs | 266 +++++++++++++++++++++++ 3 files changed, 685 insertions(+) create mode 100644 channels/whatsapp/Cargo.lock create mode 100644 channels/whatsapp/Cargo.toml create mode 100644 channels/whatsapp/src/lib.rs diff --git a/channels/whatsapp/Cargo.lock b/channels/whatsapp/Cargo.lock new file mode 100644 index 00000000..0e55d1e5 --- /dev/null +++ b/channels/whatsapp/Cargo.lock @@ -0,0 +1,401 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "whatsapp-channel" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/channels/whatsapp/Cargo.toml b/channels/whatsapp/Cargo.toml new file mode 100644 index 00000000..6e91d1f3 --- /dev/null +++ b/channels/whatsapp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "whatsapp-channel" +version = "0.1.0" +edition = "2021" +description = "WhatsApp channel for near-agent using the Cloud API" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/channels/whatsapp/src/lib.rs b/channels/whatsapp/src/lib.rs new file mode 100644 index 00000000..3fb1d875 --- /dev/null +++ b/channels/whatsapp/src/lib.rs @@ -0,0 +1,266 @@ +//! WhatsApp Channel for near-agent +//! +//! Implements the channel interface for WhatsApp Cloud API. +//! Handles incoming webhooks and sends responses via the API. + +use serde::{Deserialize, Serialize}; + +// Generate bindings from the WIT file +wit_bindgen::generate!({ + world: "sandboxed-channel", + path: "../../wit/channel.wit", +}); + +use exports::near::agent::channel::*; +use near::agent::channel_host::*; + +struct WhatsAppChannel; + +impl Guest for WhatsAppChannel { + fn on_start(config_json: String) -> Result { + log(LogLevel::Info, &format!("WhatsApp channel starting with config: {}", config_json)); + + Ok(ChannelConfig { + display_name: "WhatsApp".to_string(), + http_endpoints: vec![ + HttpEndpointConfig { + path: "/webhook/whatsapp".to_string(), + methods: vec!["GET".to_string(), "POST".to_string()], + require_secret: true, + }, + ], + poll: None, // WhatsApp uses webhooks, not polling + }) + } + + fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { + log(LogLevel::Debug, &format!("Received {} request to {}", req.method, req.path)); + + // Handle webhook verification (GET request) + if req.method == "GET" { + return handle_verification(&req); + } + + // Handle incoming messages (POST request) + if req.method == "POST" { + return handle_incoming_message(&req); + } + + // Method not allowed + OutgoingHttpResponse { + status: 405, + headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), + body: b"Method not allowed".to_vec(), + } + } + + fn on_poll() { + // WhatsApp uses webhooks, no polling needed + } + + fn on_respond(response: AgentResponse) -> Result<(), String> { + log(LogLevel::Info, &format!("Sending response to WhatsApp: {}", response.message_id)); + + // Parse metadata to get phone number + let metadata: ResponseMetadata = serde_json::from_str(&response.metadata_json) + .map_err(|e| format!("Failed to parse metadata: {}", e))?; + + // Build WhatsApp API request + let phone_number_id = metadata.phone_number_id.ok_or("Missing phone_number_id")?; + let recipient = metadata.recipient.ok_or("Missing recipient")?; + + let api_url = format!( + "https://graph.facebook.com/v18.0/{}/messages", + phone_number_id + ); + + let request_body = serde_json::json!({ + "messaging_product": "whatsapp", + "to": recipient, + "type": "text", + "text": { + "body": response.content + } + }); + + let body_bytes = serde_json::to_vec(&request_body) + .map_err(|e| format!("Failed to serialize request: {}", e))?; + + // Make API request (host will inject the access token) + let result = http_request( + "POST", + &api_url, + r#"{"Content-Type": "application/json"}"#, + Some(&body_bytes), + ); + + match result { + Ok(resp) if resp.status >= 200 && resp.status < 300 => { + log(LogLevel::Info, "Message sent successfully"); + Ok(()) + } + Ok(resp) => { + let body_str = String::from_utf8_lossy(&resp.body); + Err(format!("WhatsApp API error {}: {}", resp.status, body_str)) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } + } + + fn on_shutdown() { + log(LogLevel::Info, "WhatsApp channel shutting down"); + } +} + +/// Handle WhatsApp webhook verification request +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()); + + // WhatsApp sends hub.mode=subscribe for verification + if mode == Some("subscribe") { + if let Some(challenge) = challenge { + log(LogLevel::Info, "Webhook verification successful"); + return OutgoingHttpResponse { + status: 200, + headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), + body: challenge.as_bytes().to_vec(), + }; + } + } + + OutgoingHttpResponse { + status: 403, + headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), + body: b"Verification failed".to_vec(), + } +} + +/// Handle incoming WhatsApp message +fn handle_incoming_message(req: &IncomingHttpRequest) -> OutgoingHttpResponse { + // Parse webhook payload + let payload: WebhookPayload = match serde_json::from_slice(&req.body) { + Ok(p) => p, + Err(e) => { + log(LogLevel::Warn, &format!("Failed to parse webhook payload: {}", e)); + return OutgoingHttpResponse { + status: 400, + headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), + body: b"Invalid payload".to_vec(), + }; + } + }; + + // Process each entry + for entry in payload.entry.iter() { + for change in entry.changes.iter() { + if change.field != "messages" { + continue; + } + + let value = &change.value; + let phone_number_id = value.metadata.phone_number_id.clone(); + + // Process messages + for message in value.messages.iter() { + // Only handle text messages for now + if message.r#type != "text" { + continue; + } + + let text = message.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); + let from = message.from.clone(); + let msg_id = message.id.clone(); + + // Build metadata for response routing + let metadata = MessageMetadata { + phone_number_id: phone_number_id.clone(), + message_id: msg_id.clone(), + timestamp: message.timestamp.clone(), + }; + + // Emit message to the agent + emit_message(&EmittedMessage { + user_id: from.clone(), + user_name: None, // Could look up contact name + content: text, + thread_id: None, + metadata_json: serde_json::to_string(&metadata).unwrap_or_default(), + }); + + log(LogLevel::Info, &format!("Emitted message from {}", from)); + } + } + } + + // Acknowledge receipt + OutgoingHttpResponse { + status: 200, + headers_json: r#"{"Content-Type": "text/plain"}"#.to_string(), + body: b"OK".to_vec(), + } +} + +// ==================== WhatsApp API Types ==================== + +#[derive(Debug, Deserialize)] +struct WebhookPayload { + entry: Vec, +} + +#[derive(Debug, Deserialize)] +struct WebhookEntry { + changes: Vec, +} + +#[derive(Debug, Deserialize)] +struct WebhookChange { + field: String, + value: WebhookValue, +} + +#[derive(Debug, Deserialize)] +struct WebhookValue { + metadata: WhatsAppMetadata, + #[serde(default)] + messages: Vec, +} + +#[derive(Debug, Deserialize)] +struct WhatsAppMetadata { + phone_number_id: String, +} + +#[derive(Debug, Deserialize)] +struct WhatsAppMessage { + id: String, + from: String, + timestamp: String, + r#type: String, + text: Option, +} + +#[derive(Debug, Deserialize)] +struct WhatsAppText { + body: String, +} + +#[derive(Debug, Serialize)] +struct MessageMetadata { + phone_number_id: String, + message_id: String, + timestamp: String, +} + +#[derive(Debug, Deserialize)] +struct ResponseMetadata { + phone_number_id: Option, + recipient: Option, +} + +// Export the channel implementation +export!(WhatsAppChannel);