Skip to main content

agentmux_srv/messaging/whatsapp/
rest.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WhatsApp Cloud API REST client — outbound message send only.
5//!
6//! Mirrors Discord's `rest.rs` shape (a single `send_message` function, no
7//! client struct), with one addition Discord doesn't need: the 24-hour
8//! customer service window (spec §3.3/§3.4). Outside the window, Meta
9//! rejects a free-form text send (error code 131047); rather than round-trip
10//! to the API to discover that, `send_message` checks the local window state
11//! first and fails fast (or falls back to a configured template) before
12//! making the HTTP call at all.
13
14use std::collections::HashMap;
15use std::sync::Mutex;
16
17use crate::messaging::OutboundMsg;
18
19use super::types::SendBody;
20use super::WhatsAppConfig;
21
22const GRAPH_API_VERSION: &str = "v25.0";
23const TWENTY_FOUR_HOURS_MS: u64 = 24 * 60 * 60 * 1000;
24
25/// POST /{phone_number_id}/messages
26pub async fn send_message(
27    http: &reqwest::Client,
28    config: &WhatsAppConfig,
29    window_state: &Mutex<HashMap<String, u64>>,
30    msg: &OutboundMsg,
31) -> Result<(), String> {
32    // WhatsApp has no channel/room concept — `OutboundMsg.channel_id` is
33    // repurposed as "recipient phone number" (spec §5.3). There is no
34    // bridge-level default to fall back to the way Discord/Slack have a
35    // default channel, so an empty recipient is a caller error.
36    //
37    // Normalized (digits only) before anything else: `handle_whatsapp_send`
38    // documents `to` as "E.164 preferred" (e.g. "+14155552671"), but Meta's
39    // inbound webhook `from` field has no `+` (e.g. "14155552671"). Without
40    // normalizing both to the same form, the window-state lookup below
41    // key-misses every caller following the documented `+`-prefixed format,
42    // always falling through to "24h window expired" even when Meta's real
43    // window is open. Digits-only also matches the format the Graph API's
44    // own `to` field expects, so the normalized value is used for the send
45    // body too, not just the lookup.
46    let to = normalize_phone(msg.channel_id.trim());
47    if to.is_empty() {
48        return Err(
49            "whatsapp: OutboundMsg.channel_id (recipient phone number) is required".to_string(),
50        );
51    }
52
53    let last_inbound = window_state.lock().unwrap().get(&to).copied();
54    let within_window = last_inbound
55        .map(|last| window_ok(last, now_ms()))
56        .unwrap_or(false);
57
58    let text = flatten_text(msg);
59
60    let body = if within_window {
61        SendBody::text(&to, &text)
62    } else {
63        match config.fallback_template.as_deref().filter(|t| !t.is_empty()) {
64            Some(template) => SendBody::template(&to, template, &config.fallback_template_lang),
65            None => {
66                return Err(
67                    "whatsapp: 24h window expired and no fallback template configured"
68                        .to_string(),
69                );
70            }
71        }
72    };
73
74    let url = format!(
75        "https://graph.facebook.com/{GRAPH_API_VERSION}/{}/messages",
76        config.phone_number_id
77    );
78
79    let resp = http
80        .post(&url)
81        .bearer_auth(&config.access_token)
82        .json(&body)
83        .send()
84        .await
85        .map_err(|e| format!("whatsapp rest: http error: {e}"))?;
86
87    if !resp.status().is_success() {
88        let status = resp.status();
89        // Graph API error bodies are `{ "error": { "message", "type", "code",
90        // "error_subcode", "fbtrace_id" } }` — never contain the access
91        // token or app secret, so passing the text through is safe (unlike
92        // request header content on the inbound webhook path, which is
93        // deliberately never logged — see webhook.rs).
94        let text = resp.text().await.unwrap_or_default();
95        return Err(format!("whatsapp rest: {status}: {text}"));
96    }
97
98    Ok(())
99}
100
101/// WhatsApp's Cloud API has no rich embed support (spec §5.3) — if
102/// `OutboundMsg.embed` is set, flatten it to plain text rather than dropping
103/// it silently.
104fn flatten_text(msg: &OutboundMsg) -> String {
105    let Some(embed) = &msg.embed else {
106        return msg.text.clone();
107    };
108
109    let mut parts = Vec::new();
110    if let Some(title) = &embed.title {
111        parts.push(title.clone());
112    }
113    parts.push(embed.description.clone());
114    for f in &embed.fields {
115        parts.push(format!("{}: {}", f.name, f.value));
116    }
117    if let Some(footer) = &embed.footer {
118        parts.push(footer.clone());
119    }
120    let flattened = parts.join("\n\n");
121
122    if msg.text.is_empty() {
123        flattened
124    } else {
125        format!("{}\n\n{}", msg.text, flattened)
126    }
127}
128
129/// True if `now_ms` is within 24h of `last_inbound_ms`. Pulled out as a pure
130/// function so the boundary condition (spec §10's "24h window edge case") is
131/// unit-testable without a clock or network call.
132fn window_ok(last_inbound_ms: u64, now_ms: u64) -> bool {
133    now_ms.saturating_sub(last_inbound_ms) <= TWENTY_FOUR_HOURS_MS
134}
135
136/// Strips everything but ASCII digits, so the same phone number in different
137/// formats — Meta's inbound `from` (no `+`) vs. a caller-supplied `to` in
138/// E.164 (`+`-prefixed) or with spaces/dashes — normalizes to one 24h-window
139/// state key. `WhatsAppBridge::record_inbound` (mod.rs) applies this same
140/// normalization when storing, so the two call sites always agree.
141pub(crate) fn normalize_phone(raw: &str) -> String {
142    raw.chars().filter(|c| c.is_ascii_digit()).collect()
143}
144
145use agentmux_common::time::now_ms_u64 as now_ms;
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::messaging::{EmbedField, MsgEmbed};
151
152    #[test]
153    fn window_ok_within_bound() {
154        let last = 1_000_000u64;
155        assert!(window_ok(last, last + TWENTY_FOUR_HOURS_MS));
156    }
157
158    #[test]
159    fn window_ok_exactly_at_boundary_is_inclusive() {
160        let last = 1_000_000u64;
161        assert!(window_ok(last, last + TWENTY_FOUR_HOURS_MS));
162    }
163
164    #[test]
165    fn window_ok_one_ms_past_boundary_expires() {
166        let last = 1_000_000u64;
167        assert!(!window_ok(last, last + TWENTY_FOUR_HOURS_MS + 1));
168    }
169
170    #[test]
171    fn window_ok_now_before_last_does_not_panic() {
172        // Clock skew / stale entry — saturating_sub prevents underflow panic.
173        assert!(window_ok(2_000_000, 1_000_000));
174    }
175
176    #[test]
177    fn normalize_phone_strips_plus_and_formatting() {
178        assert_eq!(normalize_phone("+14155552671"), "14155552671");
179        assert_eq!(normalize_phone("1-415-555-2671"), "14155552671");
180        assert_eq!(normalize_phone("(415) 555-2671"), "4155552671");
181    }
182
183    #[test]
184    fn normalize_phone_is_noop_on_already_digits_only() {
185        // Meta's inbound `from` format — no `+`, no separators.
186        assert_eq!(normalize_phone("14155552671"), "14155552671");
187    }
188
189    #[test]
190    fn normalize_phone_produces_matching_keys_for_meta_inbound_and_e164_outbound() {
191        // Regression for the window-state key mismatch a review caught:
192        // Meta's inbound `from` and a caller's documented E.164 `to` must
193        // normalize to the identical map key.
194        let meta_inbound_from = "14155552671";
195        let caller_supplied_to = "+14155552671";
196        assert_eq!(normalize_phone(meta_inbound_from), normalize_phone(caller_supplied_to));
197    }
198
199    #[test]
200    fn flatten_text_plain_text_only() {
201        let msg = OutboundMsg {
202            text: "hello".to_string(),
203            channel_id: "15551234567".to_string(),
204            reply_to: None,
205            embed: None,
206            edit_message_id: None,
207            blocks: None,
208        };
209        assert_eq!(flatten_text(&msg), "hello");
210    }
211
212    #[test]
213    fn flatten_text_embed_flattens_title_description_fields_footer() {
214        let msg = OutboundMsg {
215            text: String::new(),
216            channel_id: "15551234567".to_string(),
217            reply_to: None,
218            embed: Some(MsgEmbed {
219                title: Some("Title".to_string()),
220                description: "Desc".to_string(),
221                color: None,
222                fields: vec![EmbedField {
223                    name: "Status".to_string(),
224                    value: "OK".to_string(),
225                    inline: false,
226                }],
227                footer: Some("Footer".to_string()),
228            }),
229            edit_message_id: None,
230            blocks: None,
231        };
232        assert_eq!(flatten_text(&msg), "Title\n\nDesc\n\nStatus: OK\n\nFooter");
233    }
234
235    #[test]
236    fn flatten_text_prepends_text_before_flattened_embed() {
237        let msg = OutboundMsg {
238            text: "lead-in".to_string(),
239            channel_id: "15551234567".to_string(),
240            reply_to: None,
241            embed: Some(MsgEmbed {
242                title: None,
243                description: "Desc".to_string(),
244                color: None,
245                fields: vec![],
246                footer: None,
247            }),
248            edit_message_id: None,
249            blocks: None,
250        };
251        assert_eq!(flatten_text(&msg), "lead-in\n\nDesc");
252    }
253}