diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 7d95a54..a5973e1 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -52,6 +52,7 @@ use openfang_channels::ntfy::NtfyAdapter; use openfang_channels::webhook::WebhookAdapter; use openfang_kernel::OpenFangKernel; use openfang_types::agent::AgentId; +use openfang_types::config::{WhatsAppConfig, WhatsAppMode}; use std::sync::Arc; use std::time::{Duration, Instant}; use tracing::{error, info, warn}; @@ -977,6 +978,46 @@ fn read_token(env_var: &str, adapter_name: &str) -> Option { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WhatsAppDeliveryMode { + Gateway, + CloudApi, + Disabled, +} + +fn select_whatsapp_delivery_mode( + wa_config: &WhatsAppConfig, + has_cloud_token: bool, + has_gateway_url: bool, + has_phone_number_id: bool, +) -> WhatsAppDeliveryMode { + match wa_config.mode { + WhatsAppMode::WebQr => { + if has_gateway_url { + WhatsAppDeliveryMode::Gateway + } else { + WhatsAppDeliveryMode::Disabled + } + } + WhatsAppMode::CloudApi => { + if has_cloud_token && has_phone_number_id { + WhatsAppDeliveryMode::CloudApi + } else { + WhatsAppDeliveryMode::Disabled + } + } + WhatsAppMode::Auto => { + if has_gateway_url { + WhatsAppDeliveryMode::Gateway + } else if has_cloud_token && has_phone_number_id { + WhatsAppDeliveryMode::CloudApi + } else { + WhatsAppDeliveryMode::Disabled + } + } + } +} + /// Start the channel bridge for all configured channels based on kernel config. /// /// Returns `Some(BridgeManager)` if any channels were configured and started, @@ -1089,26 +1130,57 @@ pub async fn start_channel_bridge_with_config( } } - // WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL) + // WhatsApp — supports explicit `web_qr` and `cloud_api` modes plus `auto`. if let Some(ref wa_config) = config.whatsapp { let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp"); - let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty()); - - if cloud_token.is_some() || gateway_url.is_some() { - let token = cloud_token.unwrap_or_default(); - let verify_token = - read_token(&wa_config.verify_token_env, "WhatsApp (verify)").unwrap_or_default(); - let adapter = Arc::new( - WhatsAppAdapter::new( - wa_config.phone_number_id.clone(), - token, - verify_token, - wa_config.webhook_port, - wa_config.allowed_users.clone(), - ) - .with_gateway(gateway_url), - ); - adapters.push((adapter, wa_config.default_agent.clone())); + let verify_token = + read_token(&wa_config.verify_token_env, "WhatsApp (verify)").unwrap_or_default(); + let gateway_url = wa_config.resolved_gateway_url(); + let phone_number_id = wa_config.resolved_phone_number_id(); + + let delivery_mode = select_whatsapp_delivery_mode( + wa_config, + cloud_token.is_some(), + gateway_url.is_some(), + !phone_number_id.is_empty(), + ); + + match delivery_mode { + WhatsAppDeliveryMode::Gateway => { + let adapter = Arc::new( + WhatsAppAdapter::new( + phone_number_id, + cloud_token.unwrap_or_default(), + verify_token, + wa_config.webhook_port, + wa_config.allowed_users.clone(), + ) + .with_gateway(gateway_url), + ); + adapters.push((adapter, wa_config.default_agent.clone())); + } + WhatsAppDeliveryMode::CloudApi => { + if let Some(token) = cloud_token { + let adapter = Arc::new(WhatsAppAdapter::new( + phone_number_id, + token, + verify_token, + wa_config.webhook_port, + wa_config.allowed_users.clone(), + )); + adapters.push((adapter, wa_config.default_agent.clone())); + } + } + WhatsAppDeliveryMode::Disabled => { + warn!( + "WhatsApp configured but mode '{}' requirements are not met; skipping adapter startup", + match wa_config.mode { + WhatsAppMode::Auto => "auto", + WhatsAppMode::CloudApi => "cloud_api", + WhatsAppMode::WebQr => "web_qr", + } + ); + } } } @@ -1639,6 +1711,8 @@ pub async fn reload_channels_from_disk( #[cfg(test)] mod tests { + use super::*; + #[tokio::test] async fn test_bridge_skips_when_no_config() { let config = openfang_types::config::KernelConfig::default(); @@ -1686,4 +1760,42 @@ mod tests { assert!(config.channels.webhook.is_none()); assert!(config.channels.linkedin.is_none()); } + + #[test] + fn test_select_whatsapp_delivery_mode_auto_prefers_gateway() { + let wa = openfang_types::config::WhatsAppConfig { + mode: openfang_types::config::WhatsAppMode::Auto, + ..Default::default() + }; + + let mode = select_whatsapp_delivery_mode(&wa, true, true, true); + assert_eq!(mode, WhatsAppDeliveryMode::Gateway); + } + + #[test] + fn test_select_whatsapp_delivery_mode_cloud_requires_phone_id() { + let wa = openfang_types::config::WhatsAppConfig { + mode: openfang_types::config::WhatsAppMode::CloudApi, + ..Default::default() + }; + + let missing_phone = select_whatsapp_delivery_mode(&wa, true, false, false); + assert_eq!(missing_phone, WhatsAppDeliveryMode::Disabled); + + let ready = select_whatsapp_delivery_mode(&wa, true, false, true); + assert_eq!(ready, WhatsAppDeliveryMode::CloudApi); + } + + #[test] + fn test_select_whatsapp_delivery_mode_web_qr_requires_gateway() { + let wa = openfang_types::config::WhatsAppConfig { + mode: openfang_types::config::WhatsAppMode::WebQr, + gateway_url: Some("http://127.0.0.1:3009".to_string()), + ..Default::default() + }; + + let mode = + select_whatsapp_delivery_mode(&wa, false, wa.resolved_gateway_url().is_some(), false); + assert_eq!(mode, WhatsAppDeliveryMode::Gateway); + } } diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 832f031..1c4b967 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -1141,12 +1141,15 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[ }, ChannelMeta { name: "whatsapp", display_name: "WhatsApp", icon: "WA", - description: "Connect your personal WhatsApp via QR scan", + description: "Linked devices QR login (default) with Cloud API fallback", category: "messaging", difficulty: "Easy", setup_time: "~1 min", - quick_setup: "Scan QR code with your phone — no developer account needed", + quick_setup: "Scan QR code with your phone — no Meta developer account needed", setup_type: "qr", fields: &[ - // Business API fallback fields — all advanced (hidden behind "Use Business API" toggle) + // Optional advanced mode controls / Cloud API fallback fields. + ChannelField { key: "mode", label: "Mode", field_type: FieldType::Text, env_var: None, required: false, placeholder: "web_qr", advanced: true }, + ChannelField { key: "gateway_url", label: "Gateway URL", field_type: FieldType::Text, env_var: None, required: false, placeholder: "http://127.0.0.1:3009", advanced: true }, + ChannelField { key: "gateway_url_env", label: "Gateway URL Env Var", field_type: FieldType::Text, env_var: None, required: false, placeholder: "WHATSAPP_WEB_GATEWAY_URL", advanced: true }, ChannelField { key: "access_token_env", label: "Access Token", field_type: FieldType::Secret, env_var: Some("WHATSAPP_ACCESS_TOKEN"), required: false, placeholder: "EAAx...", advanced: true }, ChannelField { key: "phone_number_id", label: "Phone Number ID", field_type: FieldType::Text, env_var: None, required: false, placeholder: "1234567890", advanced: true }, ChannelField { key: "verify_token_env", label: "Verify Token", field_type: FieldType::Secret, env_var: Some("WHATSAPP_VERIFY_TOKEN"), required: false, placeholder: "my-verify-token", advanced: true }, @@ -1154,7 +1157,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[ ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true }, ], setup_steps: &["Open WhatsApp on your phone", "Go to Linked Devices", "Tap Link a Device and scan the QR code"], - config_template: "[channels.whatsapp]\naccess_token_env = \"WHATSAPP_ACCESS_TOKEN\"\nphone_number_id = \"\"", + config_template: "[channels.whatsapp]\nmode = \"web_qr\"\ngateway_url_env = \"WHATSAPP_WEB_GATEWAY_URL\"", }, ChannelMeta { name: "signal", display_name: "Signal", icon: "SG", @@ -1887,6 +1890,34 @@ pub async fn configure_channel( } } + // WhatsApp setup defaults: QR mode first, cloud mode when cloud credentials are provided. + if name == "whatsapp" && !config_fields.contains_key("mode") { + let has_cloud_fields = fields + .get("access_token_env") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false) + || fields + .get("phone_number_id") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false) + || fields + .get("verify_token_env") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if has_cloud_fields { + config_fields.insert("mode".to_string(), "cloud_api".to_string()); + } else { + config_fields.insert("mode".to_string(), "web_qr".to_string()); + config_fields + .entry("gateway_url_env".to_string()) + .or_insert_with(|| "WHATSAPP_WEB_GATEWAY_URL".to_string()); + } + } + // Write config.toml section if let Err(e) = upsert_channel_config(&config_path, &name, &config_fields) { return ( @@ -2063,16 +2094,15 @@ pub async fn reload_channels(State(state): State>) -> impl IntoRes /// /// If a WhatsApp Web gateway is available (e.g. a Baileys-based bridge process), /// this proxies the request and returns a base64 QR code data URL. If no gateway -/// is running, it returns instructions to set one up. -pub async fn whatsapp_qr_start() -> impl IntoResponse { - // Check for WhatsApp Web gateway URL in config or env - let gateway_url = std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default(); +/// is running, it returns setup guidance. +pub async fn whatsapp_qr_start(State(state): State>) -> impl IntoResponse { + let gateway_url = resolve_whatsapp_gateway_url(&state).await; if gateway_url.is_empty() { return Json(serde_json::json!({ "available": false, - "message": "WhatsApp Web gateway not running. Start the gateway or use Business API mode.", - "help": "Run: npx openfang-whatsapp-gateway (or set WHATSAPP_WEB_GATEWAY_URL)" + "message": "WhatsApp Web gateway not configured.", + "help": "Set channels.whatsapp.mode = \"web_qr\" (or WHATSAPP_WEB_GATEWAY_URL) and restart daemon" })); } @@ -2108,7 +2138,7 @@ pub async fn whatsapp_qr_start() -> impl IntoResponse { Err(e) => Json(serde_json::json!({ "available": false, "message": format!("Could not reach WhatsApp Web gateway: {e}"), - "help": "Make sure the gateway is running at the configured URL" + "help": format!("Make sure gateway is running at {gateway_url}") })), } } @@ -2118,9 +2148,10 @@ pub async fn whatsapp_qr_start() -> impl IntoResponse { /// After calling `/qr/start`, the frontend polls this to check if the user /// has scanned the QR code and the WhatsApp Web session is connected. pub async fn whatsapp_qr_status( + State(state): State>, axum::extract::Query(params): axum::extract::Query>, ) -> impl IntoResponse { - let gateway_url = std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default(); + let gateway_url = resolve_whatsapp_gateway_url(&state).await; if gateway_url.is_empty() { return Json(serde_json::json!({ @@ -2160,6 +2191,15 @@ pub async fn whatsapp_qr_status( } } +/// Resolve effective WhatsApp Web gateway URL from live channel config and env. +async fn resolve_whatsapp_gateway_url(state: &Arc) -> String { + if let Some(wa) = state.channels_config.read().await.whatsapp.as_ref() { + return wa.resolved_gateway_url().unwrap_or_default(); + } + + std::env::var("WHATSAPP_WEB_GATEWAY_URL").unwrap_or_default() +} + /// Lightweight HTTP POST to a gateway URL. Returns parsed JSON body. async fn gateway_http_post(url_with_path: &str) -> Result { use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 5d908ff..a39d9f6 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -3302,9 +3302,9 @@ fn cmd_channel_list() { ("telegram", "TELEGRAM_BOT_TOKEN"), ("discord", "DISCORD_BOT_TOKEN"), ("slack", "SLACK_BOT_TOKEN"), - ("whatsapp", "WA_ACCESS_TOKEN"), + ("whatsapp", ""), ("signal", ""), - ("matrix", "MATRIX_TOKEN"), + ("matrix", "MATRIX_ACCESS_TOKEN"), ("email", "EMAIL_PASSWORD"), ]; @@ -3344,7 +3344,7 @@ fn cmd_channel_setup(channel: Option<&str>) { ("telegram", "Telegram bot (BotFather)"), ("discord", "Discord bot"), ("slack", "Slack app (Socket Mode)"), - ("whatsapp", "WhatsApp Cloud API"), + ("whatsapp", "WhatsApp (Linked Devices QR / Cloud API)"), ("email", "Email (IMAP/SMTP)"), ("signal", "Signal (signal-cli)"), ("matrix", "Matrix homeserver"), @@ -3465,37 +3465,81 @@ fn cmd_channel_setup(channel: Option<&str>) { "whatsapp" => { ui::section("Setting up WhatsApp"); ui::blank(); - println!(" WhatsApp Cloud API (recommended for production):"); - println!(" 1. Go to https://developers.facebook.com"); - println!(" 2. Create a Business App"); - println!(" 3. Add WhatsApp product"); - println!(" 4. Set up a test phone number"); - println!(" 5. Copy Phone Number ID and Access Token"); + println!(" Choose mode:"); + println!(" 1. Linked Devices QR (recommended, no Meta app setup)"); + println!(" 2. Cloud API (Meta Business / production webhooks)"); ui::blank(); - let phone_id = prompt_input(" Phone Number ID: "); - let access_token = prompt_input(" Access Token: "); - let verify_token = prompt_input(" Verify Token: "); - - let config_block = "\n[channels.whatsapp]\nmode = \"cloud_api\"\nphone_number_id_env = \"WA_PHONE_ID\"\naccess_token_env = \"WA_ACCESS_TOKEN\"\nverify_token_env = \"WA_VERIFY_TOKEN\"\nwebhook_port = 8443\ndefault_agent = \"assistant\"\n"; - maybe_write_channel_config("whatsapp", config_block); - - for (key, val) in [ - ("WA_PHONE_ID", &phone_id), - ("WA_ACCESS_TOKEN", &access_token), - ("WA_VERIFY_TOKEN", &verify_token), - ] { - if !val.is_empty() { - match dotenv::save_env_key(key, val) { - Ok(()) => ui::success(&format!("{key} saved to ~/.openfang/.env")), - Err(_) => println!(" export {key}={val}"), + let mode = prompt_input(" Mode [1]: "); + let cloud_mode = matches!(mode.trim(), "2" | "cloud" | "cloud_api"); + + if cloud_mode { + ui::blank(); + println!(" WhatsApp Cloud API setup:"); + println!(" 1. Go to https://developers.facebook.com"); + println!(" 2. Create a Business App and add WhatsApp product"); + println!(" 3. Copy Phone Number ID + Access Token"); + ui::blank(); + + let phone_id = prompt_input(" Phone Number ID: ").replace('\'', ""); + let access_token = prompt_input(" Access Token: "); + let verify_token = prompt_input(" Verify Token (for webhook challenge): "); + + let config_block = format!( + "\n[channels.whatsapp]\nmode = 'cloud_api'\nphone_number_id = '{phone_id}'\naccess_token_env = 'WHATSAPP_ACCESS_TOKEN'\nverify_token_env = 'WHATSAPP_VERIFY_TOKEN'\nwebhook_port = 8443\ndefault_agent = 'assistant'\n" + ); + maybe_write_channel_config("whatsapp", &config_block); + + for (key, val) in [ + ("WHATSAPP_ACCESS_TOKEN", &access_token), + ("WHATSAPP_VERIFY_TOKEN", &verify_token), + ] { + if !val.is_empty() { + match dotenv::save_env_key(key, val) { + Ok(()) => ui::success(&format!("{key} saved to ~/.openfang/.env")), + Err(_) => println!(" export {key}={val}"), + } } } - } - ui::blank(); - ui::success("WhatsApp configured"); - notify_daemon_restart(); + ui::blank(); + ui::success("WhatsApp Cloud API configured"); + notify_daemon_restart(); + } else { + ui::blank(); + println!(" Linked Devices (QR) mode:"); + println!(" - OpenFang runs the local WhatsApp Web gateway automatically."); + println!(" - Then open the web UI Channels page and scan the QR code."); + ui::blank(); + + let gateway_url = + prompt_input(" External gateway URL (optional, Enter for embedded default): ") + .replace('\'', ""); + + let mut config_block = String::from( + "\n[channels.whatsapp]\nmode = 'web_qr'\ngateway_url_env = 'WHATSAPP_WEB_GATEWAY_URL'\ndefault_agent = 'assistant'\n", + ); + if !gateway_url.trim().is_empty() { + config_block.push_str(&format!("gateway_url = '{}'\n", gateway_url.trim())); + } + maybe_write_channel_config("whatsapp", &config_block); + + if !gateway_url.trim().is_empty() { + match dotenv::save_env_key("WHATSAPP_WEB_GATEWAY_URL", gateway_url.trim()) { + Ok(()) => ui::success("WHATSAPP_WEB_GATEWAY_URL saved to ~/.openfang/.env"), + Err(_) => { + println!(" export WHATSAPP_WEB_GATEWAY_URL={}", gateway_url.trim()) + } + } + } + + ui::blank(); + ui::success("WhatsApp QR mode configured"); + ui::hint( + "Next: restart daemon, then open the web UI and scan the WhatsApp QR code.", + ); + notify_daemon_restart(); + } } "email" => { ui::section("Setting up Email"); diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 3ded5f1..2c6233d 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1574,18 +1574,50 @@ impl Default for SlackConfig { } } -/// WhatsApp Cloud API channel adapter configuration. +/// Default embedded WhatsApp Web gateway URL. +pub const DEFAULT_WHATSAPP_GATEWAY_URL: &str = "http://127.0.0.1:3009"; + +/// WhatsApp adapter operating mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum WhatsAppMode { + /// Auto-detect: prefer gateway mode when available, otherwise Cloud API. + #[default] + #[serde(rename = "auto")] + Auto, + /// Force WhatsApp Cloud API mode. + #[serde(rename = "cloud_api", alias = "cloud")] + CloudApi, + /// Force WhatsApp linked-devices / QR gateway mode. + #[serde( + rename = "web_qr", + alias = "web", + alias = "gateway", + alias = "linked_devices" + )] + WebQr, +} + +/// WhatsApp channel adapter configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct WhatsAppConfig { + /// Adapter mode (`auto`, `web_qr`, `cloud_api`). + pub mode: WhatsAppMode, /// Env var name holding the access token (Cloud API mode). pub access_token_env: String, /// Env var name holding the webhook verify token (Cloud API mode). pub verify_token_env: String, /// WhatsApp Business phone number ID (Cloud API mode). pub phone_number_id: String, + /// Legacy compatibility: env var name that stores the phone number ID. + /// + /// Deprecated in favor of `phone_number_id`, but still supported for + /// configs generated by older setup flows. + pub phone_number_id_env: Option, /// Port to listen for webhook callbacks (Cloud API mode). pub webhook_port: u16, + /// Optional explicit gateway URL (QR/Web mode), e.g. "http://127.0.0.1:3009". + pub gateway_url: Option, /// Env var name holding the WhatsApp Web gateway URL (QR/Web mode). /// When set, outgoing messages are routed through the gateway instead of Cloud API. pub gateway_url_env: String, @@ -1598,13 +1630,67 @@ pub struct WhatsAppConfig { pub overrides: ChannelOverrides, } +impl WhatsAppConfig { + /// Resolve effective WhatsApp Web gateway URL from config/env. + pub fn resolved_gateway_url(&self) -> Option { + if let Some(url) = self + .gateway_url + .as_ref() + .map(|u| u.trim()) + .filter(|u| !u.is_empty()) + { + return Some(url.to_string()); + } + + if !self.gateway_url_env.is_empty() { + if let Ok(url) = std::env::var(&self.gateway_url_env) { + let trimmed = url.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + // In explicit Web/QR mode, prefer the embedded default gateway location. + if self.mode == WhatsAppMode::WebQr { + return Some(DEFAULT_WHATSAPP_GATEWAY_URL.to_string()); + } + + None + } + + /// Resolve effective Cloud API phone number ID from config or legacy env indirection. + pub fn resolved_phone_number_id(&self) -> String { + if !self.phone_number_id.trim().is_empty() { + return self.phone_number_id.trim().to_string(); + } + + if let Some(env_name) = self + .phone_number_id_env + .as_ref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + { + return std::env::var(env_name) + .unwrap_or_default() + .trim() + .to_string(); + } + + String::new() + } +} + impl Default for WhatsAppConfig { fn default() -> Self { Self { + mode: WhatsAppMode::default(), access_token_env: "WHATSAPP_ACCESS_TOKEN".to_string(), verify_token_env: "WHATSAPP_VERIFY_TOKEN".to_string(), phone_number_id: String::new(), + phone_number_id_env: None, webhook_port: 8443, + gateway_url: None, gateway_url_env: "WHATSAPP_WEB_GATEWAY_URL".to_string(), allowed_users: vec![], default_agent: None, @@ -2746,14 +2832,57 @@ impl KernelConfig { } } if let Some(ref wa) = self.channels.whatsapp { - if std::env::var(&wa.access_token_env) + let access_token_set = !std::env::var(&wa.access_token_env) .unwrap_or_default() - .is_empty() - { - warnings.push(format!( - "WhatsApp configured but {} is not set", - wa.access_token_env - )); + .is_empty(); + let verify_token_set = !std::env::var(&wa.verify_token_env) + .unwrap_or_default() + .is_empty(); + let phone_number_id_set = !wa.resolved_phone_number_id().is_empty(); + let gateway_set = wa.resolved_gateway_url().is_some(); + + match wa.mode { + WhatsAppMode::CloudApi => { + if !access_token_set { + warnings.push(format!( + "WhatsApp (cloud_api) configured but {} is not set", + wa.access_token_env + )); + } + if !verify_token_set { + warnings.push(format!( + "WhatsApp (cloud_api) configured but {} is not set", + wa.verify_token_env + )); + } + if !phone_number_id_set { + warnings.push( + "WhatsApp (cloud_api) configured but phone_number_id is empty".into(), + ); + } + } + WhatsAppMode::WebQr => { + if !gateway_set { + warnings.push(format!( + "WhatsApp (web_qr) configured but gateway URL is missing (set {} or gateway_url)", + wa.gateway_url_env + )); + } + } + WhatsAppMode::Auto => { + if !gateway_set && !access_token_set { + warnings.push(format!( + "WhatsApp (auto) configured but neither {} nor {} is set", + wa.gateway_url_env, wa.access_token_env + )); + } + if access_token_set && !phone_number_id_set { + warnings.push( + "WhatsApp (auto/cloud) has access token but phone_number_id is empty" + .into(), + ); + } + } } } if let Some(ref mx) = self.channels.matrix { @@ -3274,8 +3403,10 @@ mod tests { #[test] fn test_whatsapp_config_defaults() { let wa = WhatsAppConfig::default(); + assert_eq!(wa.mode, WhatsAppMode::Auto); assert_eq!(wa.access_token_env, "WHATSAPP_ACCESS_TOKEN"); assert_eq!(wa.webhook_port, 8443); + assert_eq!(wa.gateway_url_env, "WHATSAPP_WEB_GATEWAY_URL"); assert!(wa.allowed_users.is_empty()); } @@ -3306,14 +3437,59 @@ mod tests { #[test] fn test_whatsapp_config_serde() { let wa = WhatsAppConfig { + mode: WhatsAppMode::CloudApi, phone_number_id: "12345".to_string(), ..Default::default() }; let json = serde_json::to_string(&wa).unwrap(); let back: WhatsAppConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.mode, WhatsAppMode::CloudApi); assert_eq!(back.phone_number_id, "12345"); } + #[test] + fn test_whatsapp_mode_serde_aliases() { + let wa: WhatsAppConfig = + toml::from_str("mode = 'gateway'\nphone_number_id = ''\n").unwrap(); + assert_eq!(wa.mode, WhatsAppMode::WebQr); + + let wa: WhatsAppConfig = + toml::from_str("mode = 'cloud_api'\nphone_number_id = '123'\n").unwrap(); + assert_eq!(wa.mode, WhatsAppMode::CloudApi); + } + + #[test] + fn test_whatsapp_legacy_phone_number_id_env() { + let var = "OPENFANG_TEST_WA_PHONE_ID"; + // SAFETY: test-scoped env var mutation. + unsafe { + std::env::set_var(var, "legacy-phone-id"); + } + + let wa_toml = format!("mode = 'cloud_api'\nphone_number_id_env = '{var}'\n"); + let wa: WhatsAppConfig = toml::from_str(&wa_toml).unwrap(); + + assert_eq!(wa.mode, WhatsAppMode::CloudApi); + assert_eq!(wa.resolved_phone_number_id(), "legacy-phone-id"); + + // SAFETY: test cleanup for env var mutation. + unsafe { + std::env::remove_var(var); + } + } + + #[test] + fn test_whatsapp_validate_web_qr_without_cloud_token() { + let mut config = KernelConfig::default(); + config.channels.whatsapp = Some(WhatsAppConfig { + mode: WhatsAppMode::WebQr, + ..Default::default() + }); + + let warnings = config.validate(); + assert!(!warnings.iter().any(|w| w.contains("WhatsApp"))); + } + #[test] fn test_matrix_config_serde() { let mx = MatrixConfig { diff --git a/docs/channel-adapters.md b/docs/channel-adapters.md index 850625a..63213ce 100644 --- a/docs/channel-adapters.md +++ b/docs/channel-adapters.md @@ -386,6 +386,31 @@ The Slack adapter uses Socket Mode, which establishes a WebSocket connection to ## WhatsApp +OpenFang supports two WhatsApp modes: + +1. **Linked Devices QR (recommended)** — pair your personal WhatsApp by scanning a QR code. +2. **Cloud API** — Meta Business webhook flow for production-style bot apps. + +### Linked Devices QR (default / first-class) + +Add this to config: + +```toml +[channels.whatsapp] +mode = "web_qr" +gateway_url_env = "WHATSAPP_WEB_GATEWAY_URL" +default_agent = "assistant" +``` + +Then restart daemon and open the Channels page in Web UI. Click WhatsApp and scan the QR code with: +- WhatsApp -> **Linked Devices** -> **Link a device** + +Notes: +- If `gateway_url` / `WHATSAPP_WEB_GATEWAY_URL` is not set, OpenFang uses the embedded default gateway at `http://127.0.0.1:3009`. +- No Meta developer account is required. + +### Cloud API mode + ### Prerequisites - A Meta Business account with WhatsApp Cloud API access @@ -393,45 +418,41 @@ The Slack adapter uses Socket Mode, which establishes a WebSocket connection to ### Setup 1. Go to [Meta for Developers](https://developers.facebook.com/). -2. Create a Business App. -3. Add the WhatsApp product. -4. Set up a test phone number (or use a production one). -5. Copy: +2. Create a Business App and add the WhatsApp product. +3. Copy: - Phone Number ID - Permanent Access Token - - Choose a Verify Token (any string you choose) -6. Set environment variables: + - Verify Token (your choice) +4. Set environment variables: ```bash -export WA_PHONE_ID=123456789012345 -export WA_ACCESS_TOKEN=EAABs... -export WA_VERIFY_TOKEN=my-secret-verify-token +export WHATSAPP_ACCESS_TOKEN=EAABs... +export WHATSAPP_VERIFY_TOKEN=my-secret-verify-token ``` -7. Add to config: +5. Add to config: ```toml [channels.whatsapp] mode = "cloud_api" -phone_number_id_env = "WA_PHONE_ID" -access_token_env = "WA_ACCESS_TOKEN" -verify_token_env = "WA_VERIFY_TOKEN" +phone_number_id = "123456789012345" +access_token_env = "WHATSAPP_ACCESS_TOKEN" +verify_token_env = "WHATSAPP_VERIFY_TOKEN" webhook_port = 8443 default_agent = "assistant" ``` -8. Set up a webhook in the Meta dashboard pointing to your server's public URL: +6. Configure Meta webhook URL: - URL: `https://your-domain.com:8443/webhook/whatsapp` - - Verify Token: the value you chose above + - Verify token: the same value as above - Subscribe to: `messages` -9. Restart the daemon. +7. Restart daemon. ### How It Works -The WhatsApp adapter runs an HTTP server (on the configured `webhook_port`) that receives incoming webhooks from the WhatsApp Cloud API. It handles webhook verification (GET) and message reception (POST). Responses are sent via the Cloud API's `messages` endpoint. - ---- +- In `web_qr`, OpenFang routes WhatsApp traffic through a gateway bridge (linked-devices session). +- In `cloud_api`, OpenFang runs webhook verification/ingest and sends replies through Meta Cloud API. ## Signal diff --git a/docs/configuration.md b/docs/configuration.md index 5e1195a..ab89d34 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -470,20 +470,31 @@ allowed_channels = [] #### `[channels.whatsapp]` ```toml +# Linked-devices QR mode (recommended) [channels.whatsapp] -access_token_env = "WHATSAPP_ACCESS_TOKEN" -verify_token_env = "WHATSAPP_VERIFY_TOKEN" -phone_number_id = "" -webhook_port = 8443 +mode = "web_qr" +gateway_url_env = "WHATSAPP_WEB_GATEWAY_URL" allowed_users = [] + +# Cloud API mode (Meta Business) +# [channels.whatsapp] +# mode = "cloud_api" +# phone_number_id = "123456789012345" +# access_token_env = "WHATSAPP_ACCESS_TOKEN" +# verify_token_env = "WHATSAPP_VERIFY_TOKEN" +# webhook_port = 8443 ``` | Field | Type | Default | Description | |-------|------|---------|-------------| +| `mode` | enum (`auto`\|`web_qr`\|`cloud_api`) | `"auto"` | Delivery mode. `web_qr` for linked devices, `cloud_api` for Meta webhooks, `auto` prefers gateway when available. | +| `gateway_url` | string or null | `null` | Optional explicit gateway URL for QR mode (e.g., `http://127.0.0.1:3009`). | +| `gateway_url_env` | string | `"WHATSAPP_WEB_GATEWAY_URL"` | Env var that can provide gateway URL. | | `access_token_env` | string | `"WHATSAPP_ACCESS_TOKEN"` | Env var holding the WhatsApp Cloud API access token. | | `verify_token_env` | string | `"WHATSAPP_VERIFY_TOKEN"` | Env var holding the webhook verification token. | -| `phone_number_id` | string | `""` | WhatsApp Business phone number ID. | -| `webhook_port` | u16 | `8443` | Port to listen for incoming webhook callbacks. | +| `phone_number_id` | string | `""` | WhatsApp Business phone number ID (Cloud API mode). | +| `phone_number_id_env` | string or null | `null` | **Legacy compatibility**: env var containing phone number ID from older configs. | +| `webhook_port` | u16 | `8443` | Port to listen for incoming webhook callbacks (Cloud API mode). | | `allowed_users` | list of strings | `[]` | Phone numbers allowed. Empty = allow all. | | `default_agent` | string or null | `null` | Agent name to route messages to. | @@ -1321,6 +1332,7 @@ Complete table of all environment variables referenced by the configuration. Non | `SLACK_BOT_TOKEN` | Slack | Slack bot token (`xoxb-`) for REST API. | | `WHATSAPP_ACCESS_TOKEN` | WhatsApp | WhatsApp Cloud API access token. | | `WHATSAPP_VERIFY_TOKEN` | WhatsApp | Webhook verification token. | +| `WHATSAPP_WEB_GATEWAY_URL` | WhatsApp | Optional linked-devices gateway URL for `web_qr` mode. | | `MATRIX_ACCESS_TOKEN` | Matrix | Matrix homeserver access token. | | `EMAIL_PASSWORD` | Email | Email account password or app password. | | `TEAMS_APP_PASSWORD` | Teams | Azure Bot Framework app password. | @@ -1376,7 +1388,7 @@ For every **enabled channel** (i.e., its config section is present in the TOML), | Telegram | `bot_token_env` | | Discord | `bot_token_env` | | Slack | `app_token_env`, `bot_token_env` (both checked) | -| WhatsApp | `access_token_env` | +| WhatsApp | mode-dependent: `cloud_api` checks `access_token_env`, `verify_token_env`, and `phone_number_id`; `auto` requires gateway or access token; `web_qr` uses gateway URL (`gateway_url`/`gateway_url_env`) | | Matrix | `access_token_env` | | Email | `password_env` | | Teams | `app_password_env` |