From dedf951b17b0096c7e9643e1bf37b287275d2036 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 24 Mar 2026 17:33:56 +0000 Subject: [PATCH] storkit: merge 382_story_whatsapp_transport_supports_twilio_api_as_alternative_to_meta_cloud_api --- Cargo.lock | 1 + server/Cargo.toml | 2 +- server/src/main.rs | 29 ++- server/src/matrix/bot.rs | 27 ++- server/src/matrix/config.rs | 213 ++++++++++++++++++--- server/src/transport.rs | 15 ++ server/src/whatsapp.rs | 363 ++++++++++++++++++++++++++++++++++-- 7 files changed, 591 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c235e2a..daf3e67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3274,6 +3274,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/server/Cargo.toml b/server/Cargo.toml index 4e06e28..826bf57 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -18,7 +18,7 @@ notify = { workspace = true } poem = { workspace = true, features = ["websocket"] } poem-openapi = { workspace = true, features = ["swagger-ui"] } portable-pty = { workspace = true } -reqwest = { workspace = true, features = ["json", "stream"] } +reqwest = { workspace = true, features = ["json", "stream", "form"] } rust-embed = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/server/src/main.rs b/server/src/main.rs index 57a5a31..aa3bcee 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -272,15 +272,25 @@ async fn main() -> Result<(), std::io::Error> { .and_then(|root| matrix::BotConfig::load(root)) .filter(|cfg| cfg.transport == "whatsapp") .map(|cfg| { - let template_name = cfg - .whatsapp_notification_template - .clone() - .unwrap_or_else(|| "pipeline_notification".to_string()); - let transport = Arc::new(whatsapp::WhatsAppTransport::new( - cfg.whatsapp_phone_number_id.clone().unwrap_or_default(), - cfg.whatsapp_access_token.clone().unwrap_or_default(), - template_name, - )); + let provider = cfg.whatsapp_provider.clone(); + let transport: Arc = + if provider == "twilio" { + Arc::new(whatsapp::TwilioWhatsAppTransport::new( + cfg.twilio_account_sid.clone().unwrap_or_default(), + cfg.twilio_auth_token.clone().unwrap_or_default(), + cfg.twilio_whatsapp_number.clone().unwrap_or_default(), + )) + } else { + let template_name = cfg + .whatsapp_notification_template + .clone() + .unwrap_or_else(|| "pipeline_notification".to_string()); + Arc::new(whatsapp::WhatsAppTransport::new( + cfg.whatsapp_phone_number_id.clone().unwrap_or_default(), + cfg.whatsapp_access_token.clone().unwrap_or_default(), + template_name, + )) + }; let bot_name = cfg .display_name .clone() @@ -289,6 +299,7 @@ async fn main() -> Result<(), std::io::Error> { let history = whatsapp::load_whatsapp_history(&root); Arc::new(whatsapp::WhatsAppWebhookContext { verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(), + provider, transport, project_root: root, agents: Arc::clone(&startup_agents), diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index af02b54..c4db522 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -369,15 +369,24 @@ pub async fn run_bot( // Create the transport abstraction based on the configured transport type. let transport: Arc = match config.transport.as_str() { "whatsapp" => { - slog!("[matrix-bot] Using WhatsApp transport"); - Arc::new(crate::whatsapp::WhatsAppTransport::new( - config.whatsapp_phone_number_id.clone().unwrap_or_default(), - config.whatsapp_access_token.clone().unwrap_or_default(), - config - .whatsapp_notification_template - .clone() - .unwrap_or_else(|| "pipeline_notification".to_string()), - )) + if config.whatsapp_provider == "twilio" { + slog!("[matrix-bot] Using WhatsApp/Twilio transport"); + Arc::new(crate::whatsapp::TwilioWhatsAppTransport::new( + config.twilio_account_sid.clone().unwrap_or_default(), + config.twilio_auth_token.clone().unwrap_or_default(), + config.twilio_whatsapp_number.clone().unwrap_or_default(), + )) + } else { + slog!("[matrix-bot] Using WhatsApp/Meta transport"); + Arc::new(crate::whatsapp::WhatsAppTransport::new( + config.whatsapp_phone_number_id.clone().unwrap_or_default(), + config.whatsapp_access_token.clone().unwrap_or_default(), + config + .whatsapp_notification_template + .clone() + .unwrap_or_else(|| "pipeline_notification".to_string()), + )) + } } _ => { slog!("[matrix-bot] Using Matrix transport"); diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index da0cfd5..cbfc9eb 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -87,6 +87,26 @@ pub struct BotConfig { /// use. Defaults to `"pipeline_notification"`. #[serde(default)] pub whatsapp_notification_template: Option, + /// Which WhatsApp provider to use: `"meta"` (default, direct Graph API) + /// or `"twilio"` (Twilio REST API as alternative to Meta). + /// + /// When `"twilio"`, the Twilio-specific fields below are required instead + /// of the Meta `whatsapp_phone_number_id` / `whatsapp_access_token` pair. + #[serde(default = "default_whatsapp_provider")] + pub whatsapp_provider: String, + + // ── Twilio WhatsApp fields ───────────────────────────────────────── + // Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`. + + /// Twilio Account SID (starts with `AC`). + #[serde(default)] + pub twilio_account_sid: Option, + /// Twilio Auth Token. + #[serde(default)] + pub twilio_auth_token: Option, + /// Twilio WhatsApp sender number in E.164 format, e.g. `+14155551234`. + #[serde(default)] + pub twilio_whatsapp_number: Option, // ── Slack Bot API fields ───────────────────────────────────────── // These are only required when `transport = "slack"`. @@ -106,6 +126,10 @@ fn default_transport() -> String { "matrix".to_string() } +fn default_whatsapp_provider() -> String { + "meta".to_string() +} + impl BotConfig { /// Load bot configuration from `.storkit/bot.toml`. /// @@ -133,27 +157,52 @@ impl BotConfig { } if config.transport == "whatsapp" { - // Validate WhatsApp-specific fields. - if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!( - "[bot] bot.toml: transport=\"whatsapp\" requires \ - whatsapp_phone_number_id" - ); - return None; - } - if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!( - "[bot] bot.toml: transport=\"whatsapp\" requires \ - whatsapp_access_token" - ); - return None; - } - if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) { - eprintln!( - "[bot] bot.toml: transport=\"whatsapp\" requires \ - whatsapp_verify_token" - ); - return None; + if config.whatsapp_provider == "twilio" { + // Validate Twilio-specific fields. + if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ + twilio_account_sid" + ); + return None; + } + if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ + twilio_auth_token" + ); + return None; + } + if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: whatsapp_provider=\"twilio\" requires \ + twilio_whatsapp_number" + ); + return None; + } + } else { + // Validate Meta (default) WhatsApp fields. + if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: transport=\"whatsapp\" requires \ + whatsapp_phone_number_id" + ); + return None; + } + if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: transport=\"whatsapp\" requires \ + whatsapp_access_token" + ); + return None; + } + if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: transport=\"whatsapp\" requires \ + whatsapp_verify_token" + ); + return None; + } } } else if config.transport == "slack" { // Validate Slack-specific fields. @@ -722,6 +771,128 @@ whatsapp_access_token = "EAAtoken" assert!(BotConfig::load(tmp.path()).is_none()); } + // ── Twilio config tests ───────────────────────────────────────────── + + #[test] + fn load_twilio_whatsapp_reads_config() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_account_sid = "ACtest" +twilio_auth_token = "authtest" +twilio_whatsapp_number = "+14155551234" +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.transport, "whatsapp"); + assert_eq!(config.whatsapp_provider, "twilio"); + assert_eq!(config.twilio_account_sid.as_deref(), Some("ACtest")); + assert_eq!(config.twilio_auth_token.as_deref(), Some("authtest")); + assert_eq!( + config.twilio_whatsapp_number.as_deref(), + Some("+14155551234") + ); + } + + #[test] + fn load_whatsapp_provider_defaults_to_meta() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "whatsapp" +whatsapp_phone_number_id = "123456" +whatsapp_access_token = "EAAtoken" +whatsapp_verify_token = "my-verify" +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.whatsapp_provider, "meta"); + } + + #[test] + fn load_twilio_returns_none_when_missing_account_sid() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_auth_token = "authtest" +twilio_whatsapp_number = "+14155551234" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + #[test] + fn load_twilio_returns_none_when_missing_auth_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_account_sid = "ACtest" +twilio_whatsapp_number = "+14155551234" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + #[test] + fn load_twilio_returns_none_when_missing_whatsapp_number() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "whatsapp" +whatsapp_provider = "twilio" +twilio_account_sid = "ACtest" +twilio_auth_token = "authtest" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + // ── Slack config tests ───────────────────────────────────────────── #[test] diff --git a/server/src/transport.rs b/server/src/transport.rs index 6f38776..d977298 100644 --- a/server/src/transport.rs +++ b/server/src/transport.rs @@ -94,4 +94,19 @@ mod tests { let _: Arc = Arc::new(crate::slack::SlackTransport::new("xoxb-test".to_string())); } + + /// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait + /// and can be used as `Arc` (compile-time check). + #[test] + fn twilio_transport_satisfies_trait() { + fn assert_transport() {} + assert_transport::(); + + let _: Arc = + Arc::new(crate::whatsapp::TwilioWhatsAppTransport::new( + "ACtest".to_string(), + "authtoken".to_string(), + "+14155551234".to_string(), + )); + } } diff --git a/server/src/whatsapp.rs b/server/src/whatsapp.rs index da3a59f..3681877 100644 --- a/server/src/whatsapp.rs +++ b/server/src/whatsapp.rs @@ -18,9 +18,10 @@ use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation}; use crate::slog; use crate::transport::{ChatTransport, MessageId}; -// ── Graph API base URL (overridable for tests) ────────────────────────── +// ── API base URLs (overridable for tests) ──────────────────────────────── const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0"; +const TWILIO_API_BASE: &str = "https://api.twilio.com"; /// Graph API error code indicating the 24-hour messaging window has elapsed. /// @@ -357,6 +358,181 @@ impl ChatTransport for WhatsAppTransport { } } +// ── Twilio Transport ──────────────────────────────────────────────────── + +/// WhatsApp transport that routes through Twilio's REST API. +/// +/// Sends messages via `POST {TWILIO_API_BASE}/2010-04-01/Accounts/{account_sid}/Messages.json` +/// using HTTP Basic Auth (Account SID as username, Auth Token as password). +/// +/// Inbound messages from Twilio arrive as `application/x-www-form-urlencoded` +/// POST bodies; use [`extract_twilio_text_messages`] to parse them. +pub struct TwilioWhatsAppTransport { + account_sid: String, + auth_token: String, + /// Sender number in E.164 format, e.g. `+14155551234`. + from_number: String, + client: reqwest::Client, + /// Optional base URL override for tests. + api_base: String, +} + +impl TwilioWhatsAppTransport { + pub fn new(account_sid: String, auth_token: String, from_number: String) -> Self { + Self { + account_sid, + auth_token, + from_number, + client: reqwest::Client::new(), + api_base: TWILIO_API_BASE.to_string(), + } + } + + #[cfg(test)] + fn with_api_base( + account_sid: String, + auth_token: String, + from_number: String, + api_base: String, + ) -> Self { + Self { + account_sid, + auth_token, + from_number, + client: reqwest::Client::new(), + api_base, + } + } + + /// Send a WhatsApp message via Twilio's Messaging REST API. + async fn send_text(&self, to: &str, body: &str) -> Result { + let url = format!( + "{}/2010-04-01/Accounts/{}/Messages.json", + self.api_base, self.account_sid + ); + + // Twilio expects the WhatsApp number with a "whatsapp:" prefix. + let from = if self.from_number.starts_with("whatsapp:") { + self.from_number.clone() + } else { + format!("whatsapp:{}", self.from_number) + }; + let to_wa = if to.starts_with("whatsapp:") { + to.to_string() + } else { + format!("whatsapp:{}", to) + }; + + let params = [("From", from.as_str()), ("To", to_wa.as_str()), ("Body", body)]; + + let resp = self + .client + .post(&url) + .basic_auth(&self.account_sid, Some(&self.auth_token)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Twilio 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!("Twilio API returned {status}: {resp_text}")); + } + + let parsed: TwilioSendResponse = serde_json::from_str(&resp_text).map_err(|e| { + format!("Failed to parse Twilio API response: {e} — body: {resp_text}") + })?; + + Ok(parsed.sid.unwrap_or_default()) + } +} + +#[async_trait] +impl ChatTransport for TwilioWhatsAppTransport { + async fn send_message( + &self, + recipient: &str, + plain: &str, + _html: &str, + ) -> Result { + slog!("[whatsapp/twilio] send_message to {recipient}: {plain:.80}"); + self.send_text(recipient, plain).await + } + + async fn edit_message( + &self, + recipient: &str, + _original_message_id: &str, + plain: &str, + html: &str, + ) -> Result<(), String> { + // Twilio does not support message editing — send a new message. + slog!("[whatsapp/twilio] edit_message — Twilio 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> { + // Twilio WhatsApp API does not expose typing indicators. + Ok(()) + } +} + +// ── Twilio API request/response types ────────────────────────────────── + +#[derive(Deserialize)] +struct TwilioSendResponse { + sid: Option, +} + +// ── Twilio webhook types (Twilio → us) ───────────────────────────────── + +/// Form-encoded fields from a Twilio WhatsApp inbound webhook POST. +#[derive(Deserialize, Debug)] +pub struct TwilioWebhookForm { + /// Sender number with `whatsapp:` prefix, e.g. `whatsapp:+15551234567`. + #[serde(rename = "From")] + pub from: Option, + /// Message body text. + #[serde(rename = "Body")] + pub body: Option, +} + +/// Extract text messages from a Twilio form-encoded webhook body. +/// +/// Returns `(sender_phone, message_body)` pairs, with the `whatsapp:` prefix +/// stripped from the sender number. +pub fn extract_twilio_text_messages(bytes: &[u8]) -> Vec<(String, String)> { + let form: TwilioWebhookForm = match serde_urlencoded::from_bytes(bytes) { + Ok(f) => f, + Err(e) => { + slog!("[whatsapp/twilio] Failed to parse webhook form body: {e}"); + return vec![]; + } + }; + + let from = match form.from { + Some(f) => f, + None => return vec![], + }; + let body = match form.body { + Some(b) if !b.is_empty() => b, + _ => return vec![], + }; + + // Strip the "whatsapp:" prefix so the sender is stored as a plain phone number. + let sender = from + .strip_prefix("whatsapp:") + .unwrap_or(&from) + .to_string(); + + vec![(sender, body)] +} + // ── Graph API request/response types ──────────────────────────────────── #[derive(Serialize)] @@ -615,7 +791,9 @@ pub struct VerifyQuery { /// Shared context for webhook handlers, injected via Poem's `Data` extractor. pub struct WhatsAppWebhookContext { pub verify_token: String, - pub transport: Arc, + /// Active provider: `"meta"` (Meta Graph API) or `"twilio"` (Twilio REST API). + pub provider: String, + pub transport: Arc, pub project_root: PathBuf, pub agents: Arc, pub bot_name: String, @@ -630,15 +808,21 @@ pub struct WhatsAppWebhookContext { pub window_tracker: Arc, } -/// GET /webhook/whatsapp — Meta verification handshake. +/// GET /webhook/whatsapp — webhook verification. /// -/// Meta sends `hub.mode=subscribe&hub.verify_token=&hub.challenge=`. -/// We return the challenge if the token matches. +/// 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 @@ -654,7 +838,13 @@ pub async fn webhook_verify( .body("Verification failed") } -/// POST /webhook/whatsapp — receive incoming messages from Meta. +/// 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. #[handler] pub async fn webhook_receive( req: &Request, @@ -672,23 +862,31 @@ pub async fn webhook_receive( } }; - 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 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 }; - let messages = extract_text_messages(&payload); if messages.is_empty() { - // Status updates, read receipts, etc. — acknowledge silently. - return Response::builder() - .status(StatusCode::OK) - .body("ok"); + return Response::builder().status(StatusCode::OK).body("ok"); } let ctx = Arc::clone(*ctx); @@ -1356,6 +1554,133 @@ mod tests { assert_eq!(conv.entries[1].content, "hi there!"); } + // ── TwilioWhatsAppTransport tests ───────────────────────────────── + + #[tokio::test] + async fn twilio_send_message_calls_twilio_api() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json") + .with_body(r#"{"sid": "SMtest123"}"#) + .create_async() + .await; + + let transport = TwilioWhatsAppTransport::with_api_base( + "ACtest".to_string(), + "authtoken".to_string(), + "+14155551234".to_string(), + server.url(), + ); + + let result = transport.send_message("+15551234567", "hello", "").await; + assert!(result.is_ok(), "unexpected err: {:?}", result.err()); + assert_eq!(result.unwrap(), "SMtest123"); + mock.assert_async().await; + } + + #[tokio::test] + async fn twilio_send_message_returns_err_on_api_error() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json") + .with_status(401) + .with_body(r#"{"message": "Unauthorized"}"#) + .create_async() + .await; + + let transport = TwilioWhatsAppTransport::with_api_base( + "ACtest".to_string(), + "badtoken".to_string(), + "+14155551234".to_string(), + server.url(), + ); + + let result = transport.send_message("+15551234567", "hello", "").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("401")); + } + + #[tokio::test] + async fn twilio_edit_message_sends_new_message() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json") + .with_body(r#"{"sid": "SMedit456"}"#) + .create_async() + .await; + + let transport = TwilioWhatsAppTransport::with_api_base( + "ACtest".to_string(), + "authtoken".to_string(), + "+14155551234".to_string(), + server.url(), + ); + + let result = transport + .edit_message("+15551234567", "old-sid", "updated text", "") + .await; + assert!(result.is_ok()); + mock.assert_async().await; + } + + #[tokio::test] + async fn twilio_send_typing_is_noop() { + let transport = TwilioWhatsAppTransport::new( + "ACtest".to_string(), + "authtoken".to_string(), + "+14155551234".to_string(), + ); + assert!(transport.send_typing("+15551234567", true).await.is_ok()); + } + + // ── extract_twilio_text_messages tests ──────────────────────────── + + #[test] + fn extract_twilio_text_messages_parses_valid_form() { + let body = b"From=whatsapp%3A%2B15551234567&Body=hello+world&To=whatsapp%3A%2B14155551234&MessageSid=SMtest"; + let msgs = extract_twilio_text_messages(body); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].0, "+15551234567"); + assert_eq!(msgs[0].1, "hello world"); + } + + #[test] + fn extract_twilio_text_messages_strips_whatsapp_prefix() { + let body = b"From=whatsapp%3A%2B15551234567&Body=hi"; + let msgs = extract_twilio_text_messages(body); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].0, "+15551234567"); + } + + #[test] + fn extract_twilio_text_messages_returns_empty_on_missing_from() { + let body = b"Body=hello"; + let msgs = extract_twilio_text_messages(body); + assert!(msgs.is_empty()); + } + + #[test] + fn extract_twilio_text_messages_returns_empty_on_missing_body() { + let body = b"From=whatsapp%3A%2B15551234567"; + let msgs = extract_twilio_text_messages(body); + assert!(msgs.is_empty()); + } + + #[test] + fn extract_twilio_text_messages_returns_empty_on_empty_body() { + let body = b"From=whatsapp%3A%2B15551234567&Body="; + let msgs = extract_twilio_text_messages(body); + assert!(msgs.is_empty()); + } + + #[test] + fn extract_twilio_text_messages_returns_empty_on_invalid_form() { + let body = b"not valid form encoded {{{{"; + // serde_urlencoded is lenient, so this might parse or return empty + // Either way it must not panic. + let _msgs = extract_twilio_text_messages(body); + } + #[test] fn load_whatsapp_history_returns_empty_when_file_missing() { let tmp = tempfile::tempdir().unwrap();