agentmux_srv/messaging/whatsapp/
webhook.rs1use std::collections::HashMap;
30
31use axum::{
32 body::Bytes,
33 extract::Query,
34 http::{HeaderMap, StatusCode},
35 response::IntoResponse,
36};
37use agentmux_common::secret_eq::secret_eq;
38use hmac::{Hmac, Mac};
39use sha2::Sha256;
40
41use super::types::WebhookPayload;
42use super::WhatsAppBridge;
43use crate::backend::reactive::handler::get_global_handler;
44use crate::backend::reactive::types::InjectionRequest;
45
46type HmacSha256 = Hmac<Sha256>;
47
48pub async fn handle_verify(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
55 let Some(bridge) = WhatsAppBridge::get() else {
56 return (
57 StatusCode::SERVICE_UNAVAILABLE,
58 "whatsapp bridge not initialized".to_string(),
59 )
60 .into_response();
61 };
62
63 let mode = params.get("hub.mode").map(String::as_str).unwrap_or("");
64 let token = params
65 .get("hub.verify_token")
66 .map(String::as_str)
67 .unwrap_or("");
68 let challenge = params.get("hub.challenge").cloned().unwrap_or_default();
69
70 let token_matches = secret_eq(token.as_bytes(), bridge.verify_token().as_bytes());
71
72 if mode == "subscribe" && token_matches {
73 (StatusCode::OK, challenge).into_response()
74 } else {
75 tracing::warn!(
77 "whatsapp_bridge: webhook verification handshake rejected (mode={mode:?}, token_match={token_matches})"
78 );
79 (StatusCode::FORBIDDEN, "verification failed".to_string()).into_response()
80 }
81}
82
83pub async fn handle_inbound(headers: HeaderMap, body: Bytes) -> impl IntoResponse {
91 let Some(bridge) = WhatsAppBridge::get() else {
92 return StatusCode::SERVICE_UNAVAILABLE;
93 };
94
95 let sig_header = headers
96 .get("X-Hub-Signature-256")
97 .and_then(|v| v.to_str().ok())
98 .unwrap_or("");
99
100 if !verify_signature(bridge.app_secret(), &body, sig_header) {
101 tracing::warn!("whatsapp_bridge: rejected inbound webhook — signature mismatch");
109 return StatusCode::UNAUTHORIZED;
110 }
111
112 let payload: WebhookPayload = match serde_json::from_slice(&body) {
113 Ok(p) => p,
114 Err(e) => {
115 tracing::warn!("whatsapp_bridge: webhook payload parse error: {e}");
116 return StatusCode::BAD_REQUEST;
117 }
118 };
119
120 for msg in payload.extract_messages() {
121 bridge.record_inbound(&msg.from);
122
123 let Some(target) = bridge.target_agent() else {
124 tracing::debug!(
125 "whatsapp_bridge: message from {} (no target agent configured)",
126 msg.from
127 );
128 continue;
129 };
130
131 let envelope = format!("[WhatsApp {}]: {}", msg.from, msg.text);
132 let handler = get_global_handler();
133 let req = InjectionRequest {
134 target_agent: target.clone(),
135 message: envelope,
136 source_agent: Some("whatsapp".to_string()),
137 request_id: Some(msg.id.clone()),
138 priority: None,
139 jekt_tier: None,
140 delivery_tier: Some("wan".to_string()),
141 forward_hops: 0,
142 ..Default::default()
143 };
144 let result = handler.inject_message(req);
145 if !result.success {
146 tracing::warn!(
147 "whatsapp_bridge: inject to agent {target} failed: {:?}",
148 result.error
149 );
150 }
151 }
152
153 StatusCode::OK
160}
161
162fn verify_signature(app_secret: &str, body: &[u8], header: &str) -> bool {
168 let Some(hex_sig) = header.strip_prefix("sha256=") else {
169 return false;
170 };
171 let Ok(expected) = hex::decode(hex_sig) else {
172 return false;
173 };
174 let Ok(mut mac) = HmacSha256::new_from_slice(app_secret.as_bytes()) else {
175 return false;
176 };
177 mac.update(body);
178 mac.verify_slice(&expected).is_ok()
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn sign(secret: &str, body: &[u8]) -> String {
186 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
187 mac.update(body);
188 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
189 }
190
191 #[test]
192 fn verify_signature_accepts_known_good_vector() {
193 let secret = "app_secret_123";
194 let body = b"{\"entry\":[]}";
195 let header = sign(secret, body);
196 assert!(verify_signature(secret, body, &header));
197 }
198
199 #[test]
200 fn verify_signature_rejects_wrong_secret() {
201 let header = sign("right_secret", b"hello");
202 assert!(!verify_signature("wrong_secret", b"hello", &header));
203 }
204
205 #[test]
206 fn verify_signature_rejects_tampered_body() {
207 let header = sign("secret", b"original");
208 assert!(!verify_signature("secret", b"tampered", &header));
209 }
210
211 #[test]
212 fn verify_signature_rejects_missing_sha256_prefix() {
213 assert!(!verify_signature("secret", b"hello", "deadbeef"));
214 }
215
216 #[test]
217 fn verify_signature_rejects_malformed_hex() {
218 assert!(!verify_signature("secret", b"hello", "sha256=not_valid_hex"));
219 }
220
221 #[test]
222 fn verify_signature_rejects_empty_header() {
223 assert!(!verify_signature("secret", b"hello", ""));
224 }
225}