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;