Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 130 additions & 18 deletions crates/openfang-api/src/channel_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -977,6 +978,46 @@ fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
}
}

#[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,
Expand Down Expand Up @@ -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",
}
);
}
}
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
64 changes: 52 additions & 12 deletions crates/openfang-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1141,20 +1141,23 @@ 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 },
ChannelField { key: "webhook_port", label: "Webhook Port", field_type: FieldType::Number, env_var: None, required: false, placeholder: "8443", advanced: true },
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",
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -2063,16 +2094,15 @@ pub async fn reload_channels(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>) -> 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"
}));
}

Expand Down Expand Up @@ -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}")
})),
}
}
Expand All @@ -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<Arc<AppState>>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> 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!({
Expand Down Expand Up @@ -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<AppState>) -> 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<serde_json::Value, String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
Expand Down
Loading