Skip to main content

agentmux_srv/messaging/whatsapp/
types.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WhatsApp Cloud API wire types — webhook payload (inbound) and Graph API
5//! send body (outbound). Hand-rolled serde structs over `reqwest` +
6//! `serde_json`, matching Discord's `types.rs` precedent (no SDK crate).
7
8use serde::{Deserialize, Serialize};
9
10// ── Webhook payload (inbound) ───────────────────────────────────────────────
11//
12// Meta's nested envelope: `entry[].changes[].value.messages[]`. Deserialized
13// permissively (unknown fields ignored by default with serde) — `statuses[]`
14// (delivery receipts) is intentionally not modeled at all, per spec §5.4;
15// any change payload that carries only `statuses` and no `messages` simply
16// yields zero `ExtractedMessage`s.
17
18#[derive(Debug, Default, Deserialize)]
19pub struct WebhookPayload {
20    #[serde(default)]
21    pub entry: Vec<WebhookEntry>,
22}
23
24#[derive(Debug, Default, Deserialize)]
25pub struct WebhookEntry {
26    #[serde(default)]
27    pub changes: Vec<WebhookChange>,
28}
29
30#[derive(Debug, Default, Deserialize)]
31pub struct WebhookChange {
32    #[serde(default)]
33    pub value: WebhookValue,
34}
35
36#[derive(Debug, Default, Deserialize)]
37pub struct WebhookValue {
38    #[serde(default)]
39    pub messages: Vec<WebhookMessage>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct WebhookMessage {
44    /// Sender's WhatsApp phone number (no `+`, e.g. "14155552671").
45    pub from: String,
46    pub id: String,
47    #[serde(default)]
48    pub text: Option<WebhookText>,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52pub struct WebhookText {
53    pub body: String,
54}
55
56/// A flattened, normalized inbound WhatsApp message extracted from the
57/// nested webhook envelope.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ExtractedMessage {
60    pub from: String,
61    pub id: String,
62    pub text: String,
63}
64
65impl WebhookPayload {
66    /// Flattens `entry[].changes[].value.messages[]`. Only text messages are
67    /// surfaced in v1 (spec §5.2/§5.4 scope) — messages of other types
68    /// (image, reaction, location, …) or `statuses` entries are skipped, not
69    /// errored on, since a webhook batch legitimately mixes message types
70    /// and delivery-receipt updates.
71    pub fn extract_messages(&self) -> Vec<ExtractedMessage> {
72        self.entry
73            .iter()
74            .flat_map(|e| e.changes.iter())
75            .flat_map(|c| c.value.messages.iter())
76            .filter_map(|m| {
77                let text = m.text.as_ref()?.body.clone();
78                Some(ExtractedMessage {
79                    from: m.from.clone(),
80                    id: m.id.clone(),
81                    text,
82                })
83            })
84            .collect()
85    }
86}
87
88// ── Graph API send types (outbound) ────────────────────────────────────────
89
90#[derive(Debug, Serialize)]
91pub struct SendBody {
92    pub messaging_product: &'static str,
93    pub to: String,
94    #[serde(rename = "type")]
95    pub msg_type: &'static str,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub text: Option<SendText>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub template: Option<SendTemplate>,
100}
101
102#[derive(Debug, Serialize)]
103pub struct SendText {
104    pub body: String,
105}
106
107#[derive(Debug, Serialize)]
108pub struct SendTemplate {
109    pub name: String,
110    pub language: SendTemplateLanguage,
111}
112
113#[derive(Debug, Serialize)]
114pub struct SendTemplateLanguage {
115    pub code: String,
116}
117
118impl SendBody {
119    pub fn text(to: &str, body: &str) -> Self {
120        SendBody {
121            messaging_product: "whatsapp",
122            to: to.to_string(),
123            msg_type: "text",
124            text: Some(SendText {
125                body: body.to_string(),
126            }),
127            template: None,
128        }
129    }
130
131    pub fn template(to: &str, name: &str, lang: &str) -> Self {
132        SendBody {
133            messaging_product: "whatsapp",
134            to: to.to_string(),
135            msg_type: "template",
136            text: None,
137            template: Some(SendTemplate {
138                name: name.to_string(),
139                language: SendTemplateLanguage {
140                    code: lang.to_string(),
141                },
142            }),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn extract_messages_flattens_nested_envelope_and_skips_non_text() {
153        let json = r#"{
154            "entry": [{
155                "changes": [{
156                    "value": {
157                        "messages": [
158                            { "from": "15551234567", "id": "wamid.1", "type": "text", "text": { "body": "hi" } },
159                            { "from": "15551234567", "id": "wamid.2", "type": "reaction" }
160                        ]
161                    }
162                }]
163            }]
164        }"#;
165        let payload: WebhookPayload = serde_json::from_str(json).unwrap();
166        let msgs = payload.extract_messages();
167        assert_eq!(msgs.len(), 1);
168        assert_eq!(msgs[0].from, "15551234567");
169        assert_eq!(msgs[0].id, "wamid.1");
170        assert_eq!(msgs[0].text, "hi");
171    }
172
173    #[test]
174    fn extract_messages_ignores_statuses_only_payload() {
175        // `statuses` isn't modeled at all — a change with only delivery
176        // receipts yields an empty `messages` vec (default), so extraction
177        // returns zero results without error.
178        let json = r#"{
179            "entry": [{
180                "changes": [{
181                    "value": {
182                        "statuses": [{ "id": "wamid.1", "status": "delivered" }]
183                    }
184                }]
185            }]
186        }"#;
187        let payload: WebhookPayload = serde_json::from_str(json).unwrap();
188        assert!(payload.extract_messages().is_empty());
189    }
190
191    #[test]
192    fn extract_messages_empty_entry_list() {
193        let payload: WebhookPayload = serde_json::from_str(r#"{"entry": []}"#).unwrap();
194        assert!(payload.extract_messages().is_empty());
195    }
196
197    #[test]
198    fn send_body_text_serializes_expected_shape() {
199        let body = SendBody::text("15551234567", "hello");
200        let v = serde_json::to_value(&body).unwrap();
201        assert_eq!(v["messaging_product"], "whatsapp");
202        assert_eq!(v["to"], "15551234567");
203        assert_eq!(v["type"], "text");
204        assert_eq!(v["text"]["body"], "hello");
205        assert!(v.get("template").is_none());
206    }
207
208    #[test]
209    fn send_body_template_serializes_expected_shape() {
210        let body = SendBody::template("15551234567", "hello_world", "en_US");
211        let v = serde_json::to_value(&body).unwrap();
212        assert_eq!(v["type"], "template");
213        assert_eq!(v["template"]["name"], "hello_world");
214        assert_eq!(v["template"]["language"]["code"], "en_US");
215        assert!(v.get("text").is_none());
216    }
217}