diff --git a/.story_kit/bot.toml.example b/.story_kit/bot.toml.example index 38b090f..b96e482 100644 --- a/.story_kit/bot.toml.example +++ b/.story_kit/bot.toml.example @@ -17,3 +17,13 @@ enabled = false # Rooms where the bot responds to all messages (not just addressed ones). # This list is updated automatically when users toggle ambient mode at runtime. # ambient_rooms = ["!roomid:example.com"] + +# ── WhatsApp Business API ────────────────────────────────────────────── +# Set transport = "whatsapp" to use WhatsApp instead of Matrix. +# The webhook endpoint will be available at /webhook/whatsapp. +# You must configure this URL in the Meta Developer Dashboard. +# +# transport = "whatsapp" +# whatsapp_phone_number_id = "123456789012345" +# whatsapp_access_token = "EAAx..." +# whatsapp_verify_token = "my-secret-verify-token" diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 5a48cc8..19c3c15 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -29,6 +29,8 @@ use settings::SettingsApi; use std::path::{Path, PathBuf}; use std::sync::Arc; +use crate::whatsapp::WhatsAppWebhookContext; + const DEFAULT_PORT: u16 = 3001; pub fn parse_port(value: Option) -> u16 { @@ -51,12 +53,15 @@ pub fn remove_port_file(path: &Path) { let _ = std::fs::remove_file(path); } -pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { +pub fn build_routes( + ctx: AppContext, + whatsapp_ctx: Option>, +) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); let (api_service, docs_service) = build_openapi_service(ctx_arc.clone()); - Route::new() + let mut route = Route::new() .nest("/api", api_service) .nest("/docs", docs_service.swagger_ui()) .at("/ws", get(ws::ws_handler)) @@ -71,8 +76,18 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { .at("/health", get(health::health)) .at("/assets/*path", get(assets::embedded_asset)) .at("/", get(assets::embedded_index)) - .at("/*path", get(assets::embedded_file)) - .data(ctx_arc) + .at("/*path", get(assets::embedded_file)); + + if let Some(wa_ctx) = whatsapp_ctx { + route = route.at( + "/webhook/whatsapp", + get(crate::whatsapp::webhook_verify) + .post(crate::whatsapp::webhook_receive) + .data(wa_ctx), + ); + } + + route.data(ctx_arc) } type ApiTuple = ( @@ -181,6 +196,6 @@ mod tests { fn build_routes_constructs_without_panic() { let tmp = tempfile::tempdir().unwrap(); let ctx = context::AppContext::new_test(tmp.path().to_path_buf()); - let _endpoint = build_routes(ctx); + let _endpoint = build_routes(ctx, None); } } diff --git a/server/src/main.rs b/server/src/main.rs index a215a7b..811e363 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -193,7 +193,32 @@ async fn main() -> Result<(), std::io::Error> { qa_app_process: Arc::new(std::sync::Mutex::new(None)), }; - let app = build_routes(ctx); + // Build WhatsApp webhook context if bot.toml configures transport = "whatsapp". + let whatsapp_ctx: Option> = startup_root + .as_ref() + .and_then(|root| matrix::BotConfig::load(root)) + .filter(|cfg| cfg.transport == "whatsapp") + .map(|cfg| { + let transport = Arc::new(whatsapp::WhatsAppTransport::new( + cfg.whatsapp_phone_number_id.clone().unwrap_or_default(), + cfg.whatsapp_access_token.clone().unwrap_or_default(), + )); + let bot_name = cfg + .display_name + .clone() + .unwrap_or_else(|| "Assistant".to_string()); + Arc::new(whatsapp::WhatsAppWebhookContext { + verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(), + transport, + project_root: startup_root.clone().unwrap(), + agents: Arc::clone(&startup_agents), + bot_name, + bot_user_id: "whatsapp-bot".to_string(), + ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + }) + }); + + let app = build_routes(ctx, whatsapp_ctx); // Optional Matrix bot: connect to the homeserver and start listening for diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 3fee04d..63d6aac 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -368,8 +368,11 @@ 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 (stub)"); - Arc::new(crate::whatsapp::WhatsAppTransport::new()) + 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(), + )) } _ => { slog!("[matrix-bot] Using Matrix transport"); @@ -1393,7 +1396,7 @@ mod tests { ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())), agents: Arc::new(AgentPool::new_test(3000)), htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), - transport: Arc::new(crate::whatsapp::WhatsAppTransport::new()), + transport: Arc::new(crate::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string())), }; // Clone must work (required by Matrix SDK event handler injection). let _cloned = ctx.clone(); diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index b475260..4160298 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -66,6 +66,20 @@ pub struct BotConfig { /// round-tripping. #[serde(default = "default_transport")] pub transport: String, + + // ── WhatsApp Business API fields ───────────────────────────────── + // These are only required when `transport = "whatsapp"`. + + /// WhatsApp Business phone number ID from the Meta dashboard. + #[serde(default)] + pub whatsapp_phone_number_id: Option, + /// Long-lived access token for the WhatsApp Business API. + #[serde(default)] + pub whatsapp_access_token: Option, + /// Verify token used in the webhook handshake (you choose this value + /// and configure it in the Meta webhook settings). + #[serde(default)] + pub whatsapp_verify_token: Option, } fn default_transport() -> String { @@ -97,7 +111,31 @@ impl BotConfig { { config.room_ids.push(single); } - if config.room_ids.is_empty() { + + 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; + } + } else if config.room_ids.is_empty() { eprintln!( "[matrix-bot] bot.toml has no room_ids configured — \ add `room_ids = [\"!roomid:example.com\"]` to bot.toml" @@ -556,10 +594,88 @@ password = "secret" room_ids = ["!abc:example.com"] 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.transport, "whatsapp"); + assert_eq!( + config.whatsapp_phone_number_id.as_deref(), + Some("123456") + ); + assert_eq!( + config.whatsapp_access_token.as_deref(), + Some("EAAtoken") + ); + assert_eq!( + config.whatsapp_verify_token.as_deref(), + Some("my-verify") + ); + } + + #[test] + fn load_whatsapp_returns_none_when_missing_phone_number_id() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + 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_access_token = "EAAtoken" +whatsapp_verify_token = "my-verify" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + #[test] + fn load_whatsapp_returns_none_when_missing_access_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + 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_verify_token = "my-verify" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + #[test] + fn load_whatsapp_returns_none_when_missing_verify_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + 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" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); } } diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 6213ef1..e672b91 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -61,6 +61,12 @@ pub fn spawn_bot( } }; + // WhatsApp transport is handled via HTTP webhooks, not the Matrix sync loop. + if config.transport == "whatsapp" { + crate::slog!("[bot] transport=whatsapp — skipping Matrix bot; webhooks handle WhatsApp"); + return; + } + crate::slog!( "[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}", config.homeserver, diff --git a/server/src/transport.rs b/server/src/transport.rs index 169035b..82348b5 100644 --- a/server/src/transport.rs +++ b/server/src/transport.rs @@ -59,26 +59,20 @@ mod tests { use super::*; use std::sync::Arc; - /// Verify that the WhatsApp stub satisfies the ChatTransport trait and - /// can be used as `Arc`. - #[tokio::test] - async fn whatsapp_transport_satisfies_trait() { - let transport: Arc = - Arc::new(crate::whatsapp::WhatsAppTransport::new()); + /// Verify that WhatsAppTransport satisfies the ChatTransport trait and + /// can be used as `Arc` (compile-time check). + /// Functional tests are in `whatsapp::tests` using mockito. + #[test] + fn whatsapp_transport_satisfies_trait() { + fn assert_transport() {} + assert_transport::(); - let msg_id = transport - .send_message("room-1", "hello", "

hello

") - .await - .unwrap(); - assert!(!msg_id.is_empty()); - - transport - .edit_message("room-1", &msg_id, "edited", "

edited

") - .await - .unwrap(); - - transport.send_typing("room-1", true).await.unwrap(); - transport.send_typing("room-1", false).await.unwrap(); + // Verify it can be wrapped in Arc. + let _: Arc = + Arc::new(crate::whatsapp::WhatsAppTransport::new( + "test-phone".to_string(), + "test-token".to_string(), + )); } /// MatrixTransport cannot be tested without a live homeserver, but we diff --git a/server/src/whatsapp.rs b/server/src/whatsapp.rs index b535a48..05265dc 100644 --- a/server/src/whatsapp.rs +++ b/server/src/whatsapp.rs @@ -1,43 +1,103 @@ -//! WhatsApp stub implementation of [`ChatTransport`]. +//! WhatsApp Business API integration. //! -//! This is a placeholder transport that logs operations and returns stub -//! values. It exists to prove the transport abstraction works with a -//! second platform and will be replaced with a real WhatsApp Business API -//! integration in the future. +//! Provides: +//! - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the +//! Meta Graph API (`graph.facebook.com/v21.0/{phone_number_id}/messages`). +//! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp +//! webhook (GET verification handshake + POST incoming messages). use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use crate::agents::AgentPool; use crate::slog; use crate::transport::{ChatTransport, MessageId}; -/// Stub WhatsApp transport. +// ── Graph API base URL (overridable for tests) ────────────────────────── + +const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0"; + +// ── WhatsApp Transport ────────────────────────────────────────────────── + +/// Real WhatsApp Business API transport. /// -/// All methods log the operation and return success with placeholder values. -/// Message editing is not supported by WhatsApp — `edit_message` sends a -/// new message instead (TODO: implement via WhatsApp Business API). +/// Sends text messages via `POST {GRAPH_API_BASE}/{phone_number_id}/messages`. pub struct WhatsAppTransport { - /// Counter for generating unique stub message IDs. - next_id: std::sync::atomic::AtomicU64, + phone_number_id: String, + access_token: String, + client: reqwest::Client, + /// Optional base URL override for tests. + api_base: String, } impl WhatsAppTransport { - pub fn new() -> Self { + pub fn new(phone_number_id: String, access_token: String) -> Self { Self { - next_id: std::sync::atomic::AtomicU64::new(1), + phone_number_id, + access_token, + client: reqwest::Client::new(), + api_base: GRAPH_API_BASE.to_string(), } } - fn next_message_id(&self) -> String { - let id = self - .next_id - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - format!("whatsapp-stub-{id}") + #[cfg(test)] + 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(), + api_base, + } } -} -impl Default for WhatsAppTransport { - fn default() -> Self { - Self::new() + /// Send a text message to a WhatsApp user via the Graph API. + 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() { + 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) } } @@ -45,73 +105,475 @@ impl Default for WhatsAppTransport { impl ChatTransport for WhatsAppTransport { async fn send_message( &self, - room_id: &str, + recipient: &str, plain: &str, _html: &str, ) -> Result { - // TODO: Send via WhatsApp Business API - let msg_id = self.next_message_id(); - slog!( - "[whatsapp-stub] send_message to {room_id}: {plain:.80} (id={msg_id})" - ); - Ok(msg_id) + slog!("[whatsapp] send_message to {recipient}: {plain:.80}"); + self.send_text(recipient, plain).await } async fn edit_message( &self, - room_id: &str, - original_message_id: &str, + recipient: &str, + _original_message_id: &str, plain: &str, html: &str, ) -> Result<(), String> { - // WhatsApp does not support message editing. - // Send a new message instead. - slog!( - "[whatsapp-stub] edit_message (original={original_message_id}) — \ - WhatsApp does not support edits, sending new message" - ); - self.send_message(room_id, plain, html).await.map(|_| ()) + // 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, room_id: &str, typing: bool) -> Result<(), String> { - // TODO: Send typing indicator via WhatsApp Business API - slog!("[whatsapp-stub] send_typing to {room_id}: typing={typing}"); + 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, +} + +// ── 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, + pub metadata: Option, +} + +#[derive(Deserialize, Debug)] +pub struct WebhookMetadata { + 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 +} + +// ── Webhook handlers (Poem) ──────────────────────────────────────────── + +use poem::{Request, Response, handler, http::StatusCode, web::Query}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Mutex; + +/// 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 { + pub verify_token: String, + pub transport: Arc, + pub project_root: PathBuf, + pub agents: Arc, + pub bot_name: String, + /// The bot's "user ID" for command dispatch (e.g. "whatsapp-bot"). + pub bot_user_id: String, + pub ambient_rooms: Arc>>, +} + +/// GET /webhook/whatsapp — Meta verification handshake. +/// +/// Meta sends `hub.mode=subscribe&hub.verify_token=&hub.challenge=`. +/// We return the challenge if the token matches. +#[handler] +pub async fn webhook_verify( + Query(q): Query, + ctx: poem::web::Data<&Arc>, +) -> Response { + 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 from Meta. +#[handler] +pub async fn webhook_receive( + req: &Request, + body: poem::Body, + ctx: poem::web::Data<&Arc>, +) -> Response { + let _ = req; + 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"); + } + }; + + 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 = extract_text_messages(&payload); + if messages.is_empty() { + // Status updates, read receipts, etc. — acknowledge silently. + 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}"); + handle_incoming_message(&ctx, &sender, &text).await; + } + }); + + Response::builder() + .status(StatusCode::OK) + .body("ok") +} + +/// Dispatch an incoming WhatsApp message to bot commands. +async fn handle_incoming_message( + ctx: &WhatsAppWebhookContext, + sender: &str, + message: &str, +) { + use crate::matrix::commands::{CommandDispatch, try_handle_command}; + + let dispatch = CommandDispatch { + bot_name: &ctx.bot_name, + bot_user_id: &ctx.bot_user_id, + project_root: &ctx.project_root, + agents: &ctx.agents, + ambient_rooms: &ctx.ambient_rooms, + room_id: sender, + // WhatsApp messages are always "addressed" to the bot (1:1 or bot-specific). + is_addressed: true, + }; + + if let Some(response) = try_handle_command(&dispatch, message) { + slog!("[whatsapp] Sending command response to {sender}"); + if let Err(e) = ctx.transport.send_message(sender, &response, "").await { + slog!("[whatsapp] Failed to send reply to {sender}: {e}"); + } + return; + } + + // Check for async commands (htop, delete). + if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + use crate::matrix::htop::HtopCommand; + slog!("[whatsapp] Handling htop command from {sender}"); + match htop_cmd { + HtopCommand::Stop => { + // htop stop — no-op on WhatsApp since there's no persistent + // editable message; just acknowledge. + let _ = ctx + .transport + .send_message(sender, "htop stopped.", "") + .await; + } + HtopCommand::Start { duration_secs } => { + // On WhatsApp, send a single snapshot instead of a live-updating + // dashboard since we can't edit messages. + let snapshot = + crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); + let _ = ctx + .transport + .send_message(sender, &snapshot, "") + .await; + } + } + return; + } + + if let Some(del_cmd) = crate::matrix::delete::extract_delete_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match del_cmd { + crate::matrix::delete::DeleteCommand::Delete { story_number } => { + slog!("[whatsapp] Handling delete command from {sender}: story {story_number}"); + crate::matrix::delete::handle_delete( + &ctx.bot_name, + &story_number, + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::matrix::delete::DeleteCommand::BadArgs => { + format!("Usage: `{} delete `", ctx.bot_name) + } + }; + let _ = ctx.transport.send_message(sender, &response, "").await; + return; + } + + // No command matched — inform the user that only commands are supported. + // (LLM passthrough is a separate story.) + let _ = ctx + .transport + .send_message( + sender, + "I only respond to commands right now. Try `help` to see what's available.", + "", + ) + .await; +} + +// ── Tests ─────────────────────────────────────────────────────────────── + #[cfg(test)] mod tests { use super::*; - #[tokio::test] - async fn send_message_returns_unique_ids() { - let transport = WhatsAppTransport::new(); - let id1 = transport - .send_message("room1", "hello", "

hello

") - .await - .unwrap(); - let id2 = transport - .send_message("room1", "world", "

world

") - .await - .unwrap(); - assert_ne!(id1, id2, "each message should get a unique ID"); - assert!(id1.starts_with("whatsapp-stub-")); + #[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"); } #[tokio::test] - async fn edit_message_succeeds() { - let transport = WhatsAppTransport::new(); - let result = transport - .edit_message("room1", "msg-1", "updated", "

updated

") + 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; - assert!(result.is_ok(), "edit should succeed (sends new message)"); + + 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 send_typing_succeeds() { - let transport = WhatsAppTransport::new(); + 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()); 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")); + } }