Skip to main content

agentmux_srv/messaging/whatsapp/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WhatsApp Cloud API messaging bridge — inbound webhook receiver + outbound
5//! Graph API send. See
6//! `docs/specs/SPEC_MESSAGING_INTEGRATION_WHATSAPP_2026_07_07.md`.
7//!
8//! Lifecycle:
9//!   1. `WhatsAppBridge::init_global(config, http)` — call once at startup.
10//!   2. The outbound send loop runs in a background tokio task, reading from
11//!      an mpsc channel — same shape as Discord/Telegram/Slack, so this
12//!      bridge implements the real (sync) `MessagingBridge::send` exactly
13//!      like its siblings, rather than the async-trait alternative the spec
14//!      sketched before the trait had actually shipped (see §5.1's "open
15//!      decision point" — resolved here in favor of matching the merged
16//!      Discord/Telegram/Slack convention instead of relitigating it).
17//!   3. Inbound is passive: Meta POSTs to `GET`/`POST /webhook/whatsapp`,
18//!      handled by ordinary axum request tasks (`webhook.rs`), not a loop
19//!      this module drives. That's already fully concurrent with the
20//!      outbound send loop — one lives on axum's request-handling tasks, the
21//!      other on its own `tokio::spawn`ed task — so there's no interleaving
22//!      hazard to split apart the way `telegram/poller.rs` and
23//!      `slack/socket.rs` had to (see those files' doc comments for the
24//!      review finding that motivated their inbound/outbound task split).
25//!   4. `WhatsAppBridge::get()` returns the singleton for HTTP handler /
26//!      webhook handler use.
27//!   5. `bridge.send(msg)` enqueues a message for the Graph API sender.
28//!   6. `bridge.health()` returns the current status.
29//!
30//! ## Scope deviation from the spec: no automated tunnel management
31//!
32//! The spec (§4) designs a `TunnelManager` that spawns and supervises a
33//! `cloudflared` subprocess, auto-detects its connected URL, and sequences
34//! bridge startup around it. That subsystem cannot be meaningfully built or
35//! verified in this environment (no live Cloudflare/Meta credentials, no way
36//! to exercise a real tunnel handshake), so it is **not implemented in this
37//! PR**. v1 assumes the operator has already stood up their own tunnel
38//! (Cloudflare Tunnel, ngrok, or otherwise) pointed at this instance's
39//! webhook port, and has manually registered the callback URL + verify token
40//! in Meta's App Dashboard — both one-time, out-of-band steps independent of
41//! `agentmux-srv`'s process lifecycle. `messaging:whatsapp:tunnel_domain` is
42//! kept as a config field purely so the startup log can print the full
43//! callback URL as a convenience reminder; nothing reads it to manage a
44//! subprocess. `messaging::tunnel` (the spec's shared `TunnelManager`) is
45//! not created. This scoping decision means `BridgeHealth` reflects only
46//! "the bridge is initialized and the outbound sender is running", not
47//! "Meta can currently reach us" — the latter depends on infrastructure this
48//! process doesn't own or observe.
49
50mod webhook;
51pub mod rest;
52pub mod types;
53
54use std::collections::HashMap;
55use std::sync::{Arc, Mutex, OnceLock};
56
57use tokio::sync::mpsc;
58
59pub use webhook::{handle_inbound, handle_verify};
60
61use crate::messaging::{BridgeHealth, BridgeStatus, OutboundMsg};
62
63// ── Config ─────────────────────────────────────────────────────────────────
64
65#[derive(Debug, Clone)]
66pub struct WhatsAppConfig {
67    /// WhatsApp Business phone number ID (Meta App Dashboard > WhatsApp > API Setup).
68    pub phone_number_id: String,
69    /// System User access token (permanent). Treat as a secret — do not log.
70    pub access_token: String,
71    /// Meta App Secret, used to validate `X-Hub-Signature-256` on inbound
72    /// webhooks. Treat as a secret — do not log.
73    pub app_secret: String,
74    /// Verify token used in the `GET /webhook/whatsapp` handshake. Treat as
75    /// a secret — do not log.
76    pub webhook_verify_token: String,
77    /// Agent ID to inject inbound WhatsApp messages into via the reactive
78    /// bus. If `None`, inbound messages are logged but not forwarded.
79    pub target_agent: Option<String>,
80    /// Template name used for outbound sends outside the 24h customer
81    /// service window (spec §3.3/§3.4). If `None` and the window has
82    /// expired, `send()` fails fast rather than attempting delivery.
83    pub fallback_template: Option<String>,
84    /// Template language code (BCP-47), e.g. "en_US".
85    pub fallback_template_lang: String,
86}
87
88// ── Bridge ─────────────────────────────────────────────────────────────────
89
90pub struct WhatsAppBridge {
91    outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
92    health: Arc<Mutex<BridgeHealth>>,
93    app_secret: String,
94    webhook_verify_token: String,
95    target_agent: Option<String>,
96    /// Per-sender (WhatsApp phone number) last-inbound timestamp (unix ms),
97    /// for 24h customer service window tracking (spec §3.3). Not persisted
98    /// to disk in v1 — a restart means the bridge falls back to templates
99    /// until the next inbound message re-opens the window, an accepted
100    /// degradation per the spec.
101    window_state: Arc<Mutex<HashMap<String, u64>>>,
102}
103
104static GLOBAL_BRIDGE: OnceLock<WhatsAppBridge> = OnceLock::new();
105
106impl WhatsAppBridge {
107    /// Initialize the global WhatsApp bridge and start the outbound send
108    /// background task. No-op if already initialized.
109    ///
110    /// Unlike Discord/Telegram/Slack, there is no inbound connection loop to
111    /// spawn here — inbound delivery is passive HTTP handled by
112    /// `webhook::handle_verify`/`handle_inbound`, already registered
113    /// unconditionally on the main axum router (see `server/mod.rs`)
114    /// regardless of whether `init_global` has run. Those handlers return
115    /// `503` via `WhatsAppBridge::get()` returning `None` until this runs.
116    pub fn init_global(config: WhatsAppConfig, http: reqwest::Client) {
117        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
118        let health = Arc::new(Mutex::new(BridgeHealth::connecting("whatsapp")));
119        let window_state: Arc<Mutex<HashMap<String, u64>>> = Arc::new(Mutex::new(HashMap::new()));
120
121        let bridge = WhatsAppBridge {
122            outbound_tx,
123            health: health.clone(),
124            app_secret: config.app_secret.clone(),
125            webhook_verify_token: config.webhook_verify_token.clone(),
126            target_agent: config.target_agent.clone(),
127            window_state: window_state.clone(),
128        };
129
130        if GLOBAL_BRIDGE.set(bridge).is_err() {
131            return; // already initialized
132        }
133
134        // No tunnel subprocess to wait on in this PR's scope (see module
135        // doc comment). The bridge is "connected" in the sense that the
136        // outbound sender and webhook handlers are live; whether Meta can
137        // actually reach the webhook route depends on infra this process
138        // doesn't manage, so this status should be read as "ready", not as
139        // proof of end-to-end reachability.
140        {
141            let mut h = health.lock().unwrap();
142            h.status = BridgeStatus::Connected;
143        }
144
145        let target_agent = config.target_agent.clone();
146        tokio::spawn(async move {
147            run_outbound_loop(config, http, window_state, outbound_rx).await;
148        });
149
150        tracing::info!(
151            "whatsapp_bridge: initialized (target={:?}) — ensure a tunnel is pointed at this \
152             instance's webhook port and the callback URL is registered in Meta App Dashboard > \
153             WhatsApp > Configuration (v1 does not manage the tunnel itself)",
154            target_agent
155        );
156    }
157
158    pub fn get() -> Option<&'static WhatsAppBridge> {
159        GLOBAL_BRIDGE.get()
160    }
161
162    /// Enqueue a message for delivery via the Graph API.
163    /// Returns error if the bridge task has exited (should not happen in normal operation).
164    pub fn send(&self, msg: OutboundMsg) -> Result<(), String> {
165        self.outbound_tx
166            .send(msg)
167            .map_err(|_| "whatsapp_bridge: outbound channel closed".to_string())
168    }
169
170    pub fn health(&self) -> BridgeHealth {
171        self.health.lock().unwrap().clone()
172    }
173
174    pub(crate) fn app_secret(&self) -> &str {
175        &self.app_secret
176    }
177
178    pub(crate) fn verify_token(&self) -> &str {
179        &self.webhook_verify_token
180    }
181
182    pub(crate) fn target_agent(&self) -> Option<&String> {
183        self.target_agent.as_ref()
184    }
185
186    /// Called by `webhook::handle_inbound` on each valid inbound message, to
187    /// update the 24h window state (spec §3.3) and the health snapshot's
188    /// `last_event_at`.
189    ///
190    /// Keys on `rest::normalize_phone(from_id)`, not the raw id, so this
191    /// matches whatever normalized form `rest::send_message` looks the
192    /// number up under — Meta's inbound `from` has no `+` prefix, but a
193    /// caller sending via `handle_whatsapp_send` may supply one (E.164);
194    /// without a shared normalization the window lookup always missed
195    /// (found via review).
196    pub(crate) fn record_inbound(&self, from_id: &str) {
197        let now = now_ms();
198        self.window_state
199            .lock()
200            .unwrap()
201            .insert(rest::normalize_phone(from_id), now);
202        let mut h = self.health.lock().unwrap();
203        h.last_event_at = Some(now / 1000);
204    }
205}
206
207impl crate::messaging::MessagingBridge for WhatsAppBridge {
208    fn health(&self) -> BridgeHealth {
209        WhatsAppBridge::health(self)
210    }
211}
212
213/// Drains the outbound mpsc queue and calls the Graph API for each message.
214/// Runs as its own `tokio::spawn`ed task, fully independent of the axum
215/// request tasks servicing `webhook::handle_inbound` — a slow or
216/// rate-limited send here cannot block the webhook route from accepting
217/// Meta's next delivery (see module doc comment).
218async fn run_outbound_loop(
219    config: WhatsAppConfig,
220    http: reqwest::Client,
221    window_state: Arc<Mutex<HashMap<String, u64>>>,
222    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
223) {
224    while let Some(msg) = outbound_rx.recv().await {
225        if let Err(e) = rest::send_message(&http, &config, &window_state, &msg).await {
226            tracing::warn!("whatsapp_bridge: send failed: {e}");
227        }
228    }
229}
230
231use agentmux_common::time::now_ms_u64 as now_ms;