agentmux_srv/messaging/whatsapp/
rest.rs1use 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
25pub 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 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 let text = resp.text().await.unwrap_or_default();
95 return Err(format!("whatsapp rest: {status}: {text}"));
96 }
97
98 Ok(())
99}
100
101fn 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
129fn window_ok(last_inbound_ms: u64, now_ms: u64) -> bool {
133 now_ms.saturating_sub(last_inbound_ms) <= TWENTY_FOUR_HOURS_MS
134}
135
136pub(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 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 assert_eq!(normalize_phone("14155552671"), "14155552671");
187 }
188
189 #[test]
190 fn normalize_phone_produces_matching_keys_for_meta_inbound_and_e164_outbound() {
191 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}