//! WhatsApp command handling — processes incoming WhatsApp messages as bot commands. use super::WhatsAppWebhookContext; use super::format::markdown_to_whatsapp; use super::history::save_whatsapp_history; use crate::chat::transport::matrix::RoomConversation; use crate::chat::util::is_permission_approval; use crate::http::context::PermissionDecision; use crate::slog; mod llm; use llm::handle_llm_message; /// Dispatch an incoming WhatsApp message to bot commands. pub(super) async fn handle_incoming_message( ctx: &WhatsAppWebhookContext, sender: &str, message: &str, ) { use crate::chat::commands::{CommandDispatch, try_handle_command}; // Allowlist check: when configured, silently ignore unauthorized senders. if !ctx.allowed_phones.is_empty() && !ctx.allowed_phones.iter().any(|p| p == sender) { slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}"); return; } // Record this inbound message to keep the 24-hour window open. ctx.window_tracker.record_message(sender); // If there is a pending permission prompt for this sender, interpret the // message as a yes/no response instead of starting a new command/LLM flow. { if let Some(tx) = ctx .services .pending_perm_replies .resolve_oldest(sender) .await { let decision = if is_permission_approval(message) { PermissionDecision::Approve } else { PermissionDecision::Deny }; let _ = tx.send(decision); let confirmation = if decision == PermissionDecision::Approve { "Permission approved." } else { "Permission denied." }; let formatted = markdown_to_whatsapp(confirmation); let _ = ctx.transport.send_message(sender, &formatted, "").await; return; } } let dispatch = CommandDispatch { services: &ctx.services, project_root: &ctx.services.project_root, bot_user_id: &ctx.services.bot_user_id, room_id: sender, }; if let Some(response) = try_handle_command(&dispatch, message) { slog!("[whatsapp] Sending command response to {sender}"); let formatted = markdown_to_whatsapp(&response); if let Err(e) = ctx.transport.send_message(sender, &formatted, "").await { slog!("[whatsapp] Failed to send reply to {sender}: {e}"); } return; } // Check for async commands (htop, delete). if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) { use crate::chat::transport::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::chat::transport::matrix::htop::build_htop_message( &ctx.services.agents, 0, duration_secs, ); let _ = ctx.transport.send_message(sender, &snapshot, "").await; } } return; } if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) { let response = match del_cmd { crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => { slog!("[whatsapp] Handling delete command from {sender}: story {story_number}"); crate::chat::transport::matrix::delete::handle_delete( &ctx.services.bot_name, &story_number, &ctx.services.project_root, &ctx.services.agents, ) .await } crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => { format!("Usage: `{} delete `", ctx.services.bot_name) } }; let _ = ctx.transport.send_message(sender, &response, "").await; return; } if let Some(rmtree_cmd) = crate::chat::transport::matrix::rmtree::extract_rmtree_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) { let response = match rmtree_cmd { crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => { slog!("[whatsapp] Handling rmtree command from {sender}: story {story_number}"); crate::chat::transport::matrix::rmtree::handle_rmtree( &ctx.services.bot_name, &story_number, &ctx.services.project_root, &ctx.services.agents, ) .await } crate::chat::transport::matrix::rmtree::RmtreeCommand::BadArgs => { format!("Usage: `{} rmtree `", ctx.services.bot_name) } }; let _ = ctx.transport.send_message(sender, &response, "").await; return; } if crate::chat::transport::matrix::reset::extract_reset_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) .is_some() { slog!("[whatsapp] Handling reset command from {sender}"); { let mut guard = ctx.history.lock().await; let conv = guard .entry(sender.to_string()) .or_insert_with(RoomConversation::default); conv.session_id = None; conv.entries.clear(); save_whatsapp_history(&ctx.services.project_root, &guard); } let _ = ctx .transport .send_message(sender, "Session cleared.", "") .await; return; } if crate::chat::compact::extract_compact_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) .is_some() { slog!("[whatsapp] Handling compact command from {sender}"); let response = crate::chat::compact::handle_compact_for_key( sender, &ctx.history, &ctx.services.project_root, 8_000, save_whatsapp_history, ) .await; let formatted = markdown_to_whatsapp(&response); let _ = ctx.transport.send_message(sender, &formatted, "").await; return; } if let Some(start_cmd) = crate::chat::transport::matrix::start::extract_start_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) { let response = match start_cmd { crate::chat::transport::matrix::start::StartCommand::Start { story_number, agent_hint, } => { slog!("[whatsapp] Handling start command from {sender}: story {story_number}"); crate::chat::transport::matrix::start::handle_start( &ctx.services.bot_name, &story_number, agent_hint.as_deref(), &ctx.services.project_root, &ctx.services.agents, ) .await } crate::chat::transport::matrix::start::StartCommand::BadArgs => { format!("Usage: `{} start `", ctx.services.bot_name) } }; let _ = ctx.transport.send_message(sender, &response, "").await; return; } if let Some(assign_cmd) = crate::chat::transport::matrix::assign::extract_assign_command( message, &ctx.services.bot_name, &ctx.services.bot_user_id, ) { let response = match assign_cmd { crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model, } => { slog!( "[whatsapp] Handling assign command from {sender}: story {story_number} model {model}" ); crate::chat::transport::matrix::assign::handle_assign( &ctx.services.bot_name, &story_number, &model, &ctx.services.project_root, &ctx.services.agents, ) .await } crate::chat::transport::matrix::assign::AssignCommand::BadArgs => { format!("Usage: `{} assign `", ctx.services.bot_name) } }; let formatted = markdown_to_whatsapp(&response); let _ = ctx.transport.send_message(sender, &formatted, "").await; return; } // No command matched — forward to LLM for conversational response. slog!("[whatsapp] No command matched, forwarding to LLM for {sender}"); handle_llm_message(ctx, sender, message).await; } /// Forward a message to Claude Code and send the response back via WhatsApp. #[cfg(test)] mod tests { use super::super::WhatsAppWebhookContext; use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory}; use super::*; use crate::agents::AgentPool; use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation}; use crate::io::watcher::WatcherEvent; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; /// Build a minimal WhatsAppWebhookContext for allowlist tests. fn make_ctx_with_allowlist(allowed_phones: Vec) -> Arc { struct NullTransport; #[async_trait::async_trait] impl crate::chat::ChatTransport for NullTransport { async fn send_message( &self, _room: &str, _plain: &str, _html: &str, ) -> Result { Ok(String::new()) } async fn edit_message( &self, _room: &str, _id: &str, _plain: &str, _html: &str, ) -> Result<(), String> { Ok(()) } async fn send_typing(&self, _room: &str, _typing: bool) -> Result<(), String> { Ok(()) } } let tmp = tempfile::tempdir().unwrap(); let (tx, _rx) = tokio::sync::broadcast::channel::(16); let agents = Arc::new(AgentPool::new(3999, tx)); let tracker = Arc::new(MessagingWindowTracker::new()); let services = Arc::new(crate::services::Services { project_root: tmp.path().to_path_buf(), status: agents.status_broadcaster(), agents, bot_name: "Bot".to_string(), bot_user_id: "whatsapp-bot".to_string(), ambient_rooms: Arc::new(std::sync::Mutex::new(Default::default())), permission_registry: crate::service::permission_router::ResponderRegistry::new(), pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(), permission_timeout_secs: 120, remembered_permissions: crate::service::permission_router::RememberedPermissions::new(), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), }); Arc::new(WhatsAppWebhookContext { services, verify_token: "tok".to_string(), provider: "meta".to_string(), transport: Arc::new(NullTransport), history: Arc::new(tokio::sync::Mutex::new(Default::default())), history_size: 20, window_tracker: tracker, allowed_phones, app_secret: String::new(), twilio_auth_token: String::new(), }) } // ── OAuth login link formatting ─────────────────────────────────────── #[test] fn whatsapp_oauth_error_produces_plain_text_url() { let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"; let url = crate::llm::oauth::extract_login_url_from_error(err); assert!(url.is_some(), "should extract URL from OAuth error"); let msg = format!( "Authentication required. Log in to Claude here: {}", url.unwrap() ); assert!(msg.contains("http://localhost:3001/oauth/authorize")); assert!( !msg.contains('['), "WhatsApp message should not use Markdown link syntax" ); } #[test] fn whatsapp_non_oauth_error_not_formatted_as_link() { let err = "Some unrelated error occurred during processing"; assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none()); } // ── Allowlist tests ─────────────────────────────────────────────────── #[tokio::test] async fn allowlist_blocks_unauthorized_sender() { let allowed = vec!["+15551111111".to_string()]; let ctx = make_ctx_with_allowlist(allowed); let unauthorized = "+15559999999"; handle_incoming_message(&ctx, unauthorized, "hello").await; // window_tracker is only updated AFTER the allowlist check, so an // unauthorized sender must leave the tracker untouched. assert!( !ctx.window_tracker.is_within_window(unauthorized), "unauthorized sender should not have updated the window tracker" ); } #[tokio::test] async fn allowlist_empty_allows_all_senders() { // Empty allowlist = open (backwards compatible). let ctx = make_ctx_with_allowlist(vec![]); let sender = "+15551234567"; handle_incoming_message(&ctx, sender, "hello").await; // window_tracker.record_message is called right after the allowlist // check passes, so the sender should be recorded. assert!( ctx.window_tracker.is_within_window(sender), "sender should be recorded when allowlist is empty" ); } #[tokio::test] async fn allowlist_allows_listed_sender() { let sender = "+15551111111"; let ctx = make_ctx_with_allowlist(vec![sender.to_string()]); handle_incoming_message(&ctx, sender, "hello").await; assert!( ctx.window_tracker.is_within_window(sender), "listed sender should be recorded in the window tracker" ); } // ── reset command extraction ─────────────────────────────────────── #[test] fn reset_command_extracted_from_plain_message() { let result = crate::chat::transport::matrix::reset::extract_reset_command( "reset", "Timmy", "@timmy:home.local", ); assert!(result.is_some(), "plain 'reset' should be recognised"); } #[test] fn reset_command_extracted_with_bot_name_prefix() { let result = crate::chat::transport::matrix::reset::extract_reset_command( "Timmy reset", "Timmy", "@timmy:home.local", ); assert!(result.is_some(), "'Timmy reset' should be recognised"); } #[tokio::test] async fn reset_command_clears_whatsapp_session() { let sender = "+15555550100"; let history: WhatsAppConversationHistory = Arc::new(TokioMutex::new({ let mut m = HashMap::new(); m.insert( sender.to_string(), RoomConversation { session_id: Some("old-session".to_string()), entries: vec![ConversationEntry { role: ConversationRole::User, sender: sender.to_string(), content: "previous message".to_string(), }], ..Default::default() }, ); m })); let tmp = tempfile::tempdir().unwrap(); let sk = tmp.path().join(".huskies"); std::fs::create_dir_all(&sk).unwrap(); { let mut guard = history.lock().await; let conv = guard .entry(sender.to_string()) .or_insert_with(RoomConversation::default); conv.session_id = None; conv.entries.clear(); save_whatsapp_history(tmp.path(), &guard); } let guard = history.lock().await; let conv = guard.get(sender).unwrap(); assert!(conv.session_id.is_none(), "session_id should be cleared"); assert!(conv.entries.is_empty(), "entries should be cleared"); } #[test] fn start_command_extracted_from_plain_message() { // WhatsApp messages arrive without a bot mention prefix. // extract_start_command must recognise "start 42" by itself. let result = crate::chat::transport::matrix::start::extract_start_command( "start 42", "Timmy", "@timmy:home.local", ); assert!(result.is_some(), "plain 'start 42' should be recognised"); assert_eq!( result, Some(crate::chat::transport::matrix::start::StartCommand::Start { story_number: "42".to_string(), agent_hint: None, }) ); } #[test] fn start_command_extracted_with_bot_name_prefix() { let result = crate::chat::transport::matrix::start::extract_start_command( "Timmy start 99", "Timmy", "@timmy:home.local", ); assert!(result.is_some(), "'Timmy start 99' should be recognised"); } #[test] fn non_start_whatsapp_message_not_extracted() { let result = crate::chat::transport::matrix::start::extract_start_command( "help", "Timmy", "@timmy:home.local", ); assert!(result.is_none(), "'help' should not be recognised as start"); } // ── rmtree command extraction ────────────────────────────────────── #[test] fn rmtree_command_extracted_from_plain_message() { // WhatsApp messages arrive without a bot mention prefix. // extract_rmtree_command must recognise "rmtree 42" by itself. let result = crate::chat::transport::matrix::rmtree::extract_rmtree_command( "rmtree 42", "Timmy", "@timmy:home.local", ); assert!( matches!( result, Some(crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { .. }) ), "plain 'rmtree 42' should be recognised" ); } #[test] fn rmtree_command_extracted_with_bot_name_prefix() { let result = crate::chat::transport::matrix::rmtree::extract_rmtree_command( "Timmy rmtree 42", "Timmy", "@timmy:home.local", ); assert!( matches!( result, Some(crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { .. }) ), "'Timmy rmtree 42' should be recognised" ); } #[test] fn rmtree_command_returns_bad_args_without_number() { let result = crate::chat::transport::matrix::rmtree::extract_rmtree_command( "rmtree", "Timmy", "@timmy:home.local", ); assert_eq!( result, Some(crate::chat::transport::matrix::rmtree::RmtreeCommand::BadArgs) ); } #[test] fn non_rmtree_whatsapp_message_not_extracted() { let result = crate::chat::transport::matrix::rmtree::extract_rmtree_command( "status", "Timmy", "@timmy:home.local", ); assert!( result.is_none(), "'status' should not be recognised as rmtree" ); } // ── assign command extraction ────────────────────────────────────── #[test] fn assign_command_extracted_from_plain_message() { let result = crate::chat::transport::matrix::assign::extract_assign_command( "assign 42 opus", "Timmy", "@timmy:home.local", ); assert!( matches!( result, Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) ), "plain 'assign 42 opus' should be recognised" ); } #[test] fn assign_command_extracted_with_bot_name_prefix() { let result = crate::chat::transport::matrix::assign::extract_assign_command( "Timmy assign 42 sonnet", "Timmy", "@timmy:home.local", ); assert!( matches!( result, Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) ), "'Timmy assign 42 sonnet' should be recognised" ); } #[test] fn assign_command_returns_bad_args_without_model() { let result = crate::chat::transport::matrix::assign::extract_assign_command( "assign 42", "Timmy", "@timmy:home.local", ); assert_eq!( result, Some(crate::chat::transport::matrix::assign::AssignCommand::BadArgs) ); } #[test] fn non_assign_whatsapp_message_not_extracted() { let result = crate::chat::transport::matrix::assign::extract_assign_command( "status", "Timmy", "@timmy:home.local", ); assert!( result.is_none(), "'status' should not be recognised as assign" ); } }