Skip to main content

zeroclaw_channels/
whatsapp.rs

1use async_trait::async_trait;
2use regex::Regex;
3use std::collections::HashMap;
4use std::sync::{Arc, LazyLock};
5use tokio::sync::{Mutex, oneshot};
6use uuid::Uuid;
7use zeroclaw_api::channel::{
8    Channel, ChannelApprovalRequest, ChannelApprovalResponse, ChannelMessage, SendMessage,
9};
10
11/// Module-level `pending_approvals` map shared across every
12/// `Arc<WhatsAppChannel>` regardless of who constructs it.
13///
14/// WhatsApp uses webhooks, so `request_approval()` (called by the runtime's
15/// channel pool) and the reply intercept (in the gateway's
16/// `handle_whatsapp_message`) can run on *different* `Arc<WhatsAppChannel>`
17/// instances — the orchestrator constructs one, the gateway constructs
18/// another. An instance-local pending-approvals map would leave one side
19/// registering tokens the other side can never find, silently timing out
20/// every approval request.
21///
22/// Hoisting the map to a process-wide static sidesteps the Arc-sharing
23/// problem entirely: whoever calls `request_approval()` inserts; whoever
24/// receives the webhook reply looks up; both hit the same `HashMap`.
25type PendingApprovalsMap = Mutex<HashMap<String, oneshot::Sender<ChannelApprovalResponse>>>;
26static PENDING_APPROVALS: LazyLock<Arc<PendingApprovalsMap>> =
27    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
28
29/// `WhatsApp` channel — uses `WhatsApp` Business Cloud API
30///
31/// This channel operates in webhook mode (push-based) rather than polling.
32/// Messages are received via the gateway's `/whatsapp` webhook endpoint.
33/// The `listen` method here is a no-op placeholder; actual message handling
34/// happens in the gateway when Meta sends webhook events.
35fn ensure_https(url: &str) -> anyhow::Result<()> {
36    if !url.starts_with("https://") {
37        anyhow::bail!(
38            "Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"
39        );
40    }
41    Ok(())
42}
43
44///
45/// # Runtime Negotiation
46///
47/// This Cloud API channel is automatically selected when `phone_number_id` is set in the config.
48/// Use `WhatsAppWebChannel` (with `session_path`) for native Web mode.
49pub struct WhatsAppChannel {
50    access_token: String,
51    endpoint_id: String,
52    verify_token: String,
53    /// The alias key under `[channels.whatsapp.<alias>]` this handle is
54    /// bound to. Used to scope peer-group writes and resolver lookups.
55    alias: String,
56    /// Resolves inbound external peers from canonical state at message-time.
57    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
58    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
59    /// Per-channel proxy URL override.
60    proxy_url: Option<String>,
61    /// Compiled mention patterns for DM mention gating.
62    dm_mention_patterns: Vec<Regex>,
63    /// Compiled mention patterns for group-chat mention gating.
64    group_mention_patterns: Vec<Regex>,
65    /// Seconds to wait for an operator reply to a `request_approval` prompt
66    /// before treating the silence as a deny. Default 300.
67    approval_timeout_secs: u64,
68}
69
70impl WhatsAppChannel {
71    pub fn new(
72        access_token: String,
73        endpoint_id: String,
74        verify_token: String,
75        alias: impl Into<String>,
76        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
77    ) -> Self {
78        Self {
79            access_token,
80            endpoint_id,
81            verify_token,
82            alias: alias.into(),
83            peer_resolver,
84            proxy_url: None,
85            dm_mention_patterns: Vec::new(),
86            group_mention_patterns: Vec::new(),
87            approval_timeout_secs: 300,
88        }
89    }
90
91    /// Return the alias under `[channels.whatsapp.<alias>]` that this
92    /// channel handle is bound to.
93    pub fn alias(&self) -> &str {
94        &self.alias
95    }
96
97    pub fn with_approval_timeout_secs(mut self, secs: u64) -> Self {
98        self.approval_timeout_secs = secs;
99        self
100    }
101
102    /// Access the process-wide pending-approvals map shared across every
103    /// `WhatsAppChannel` instance. See [`PENDING_APPROVALS`] for why this
104    /// must be a static rather than per-instance.
105    pub fn pending_approvals(&self) -> &Arc<PendingApprovalsMap> {
106        &PENDING_APPROVALS
107    }
108
109    /// Set a per-channel proxy URL that overrides the global proxy config.
110    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
111        self.proxy_url = proxy_url;
112        self
113    }
114
115    /// Set mention patterns for DM mention gating.
116    /// Each pattern string is compiled as a case-insensitive regex.
117    /// Invalid patterns are logged and skipped.
118    pub fn with_dm_mention_patterns(mut self, patterns: Vec<String>) -> Self {
119        self.dm_mention_patterns = Self::compile_mention_patterns(&patterns);
120        self
121    }
122
123    /// Set mention patterns for group-chat mention gating.
124    /// Each pattern string is compiled as a case-insensitive regex.
125    /// Invalid patterns are logged and skipped.
126    pub fn with_group_mention_patterns(mut self, patterns: Vec<String>) -> Self {
127        self.group_mention_patterns = Self::compile_mention_patterns(&patterns);
128        self
129    }
130
131    /// Compile raw pattern strings into case-insensitive regexes.
132    /// Invalid or excessively large patterns are logged and skipped.
133    pub fn compile_mention_patterns(patterns: &[String]) -> Vec<Regex> {
134        patterns
135            .iter()
136            .filter_map(|p| {
137                let trimmed = p.trim();
138                if trimmed.is_empty() {
139                    return None;
140                }
141                match regex::RegexBuilder::new(trimmed)
142                    .case_insensitive(true)
143                    .size_limit(1 << 16) // 64 KiB — guard against ReDoS
144                    .build()
145                {
146                    Ok(re) => Some(re),
147                    Err(e) => {
148                        ::zeroclaw_log::record!(
149                            WARN,
150                            ::zeroclaw_log::Event::new(
151                                module_path!(),
152                                ::zeroclaw_log::Action::Note
153                            )
154                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
155                            .with_attrs(
156                                ::serde_json::json!({"trimmed": trimmed, "e": e.to_string()})
157                            ),
158                            "ignoring invalid mention_pattern"
159                        );
160                        None
161                    }
162                }
163            })
164            .collect()
165    }
166
167    /// Check whether `text` matches any pattern in the given slice.
168    pub fn text_matches_patterns(patterns: &[Regex], text: &str) -> bool {
169        patterns.iter().any(|re| re.is_match(text))
170    }
171
172    /// Apply mention-pattern gating for a message.
173    ///
174    /// Selects the appropriate pattern set based on `is_group`. When the
175    /// pattern set is non-empty, messages that do not match any pattern are
176    /// dropped (`None`); matched messages pass through unchanged. Empty
177    /// pattern sets always admit.
178    pub fn apply_mention_gating(
179        dm_patterns: &[Regex],
180        group_patterns: &[Regex],
181        content: &str,
182        is_group: bool,
183    ) -> Option<String> {
184        let patterns = if is_group {
185            group_patterns
186        } else {
187            dm_patterns
188        };
189        if patterns.is_empty() {
190            return Some(content.to_string());
191        }
192        if !Self::text_matches_patterns(patterns, content) {
193            return None;
194        }
195        Some(content.to_string())
196    }
197
198    /// Detect group messages in the WhatsApp Cloud API webhook payload.
199    ///
200    /// A message is considered a group message when it carries a `context`
201    /// object containing a non-empty `group_id` field.
202    fn is_group_message(msg: &serde_json::Value) -> bool {
203        msg.get("context")
204            .and_then(|ctx| ctx.get("group_id"))
205            .and_then(|g| g.as_str())
206            .is_some_and(|s| !s.is_empty())
207    }
208
209    fn http_client(&self) -> reqwest::Client {
210        zeroclaw_config::schema::build_channel_proxy_client(
211            "channel.whatsapp",
212            self.proxy_url.as_deref(),
213        )
214    }
215
216    /// Check if a phone number is allowed (E.164 format: +1234567890)
217    fn is_number_allowed(&self, phone: &str) -> bool {
218        let peers = (self.peer_resolver)();
219        crate::allowlist::is_user_allowed(&peers, phone, crate::allowlist::Match::Sensitive)
220    }
221
222    /// Get the verify token for webhook verification
223    pub fn verify_token(&self) -> &str {
224        &self.verify_token
225    }
226
227    /// Parse an incoming webhook payload from Meta and extract messages
228    pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
229        let mut messages = Vec::new();
230
231        // WhatsApp Cloud API webhook structure:
232        // { "object": "whatsapp_business_account", "entry": [...] }
233        let Some(entries) = payload.get("entry").and_then(|e| e.as_array()) else {
234            return messages;
235        };
236
237        for entry in entries {
238            let Some(changes) = entry.get("changes").and_then(|c| c.as_array()) else {
239                continue;
240            };
241
242            for change in changes {
243                let Some(value) = change.get("value") else {
244                    continue;
245                };
246
247                // Extract messages array
248                let Some(msgs) = value.get("messages").and_then(|m| m.as_array()) else {
249                    continue;
250                };
251
252                for msg in msgs {
253                    // Get sender phone number
254                    let Some(from) = msg.get("from").and_then(|f| f.as_str()) else {
255                        continue;
256                    };
257
258                    // Check allowlist
259                    let normalized_from = if from.starts_with('+') {
260                        from.to_string()
261                    } else {
262                        format!("+{from}")
263                    };
264
265                    if !self.is_number_allowed(&normalized_from) {
266                        ::zeroclaw_log::record!(
267                            WARN,
268                            ::zeroclaw_log::Event::new(
269                                module_path!(),
270                                ::zeroclaw_log::Action::Note
271                            )
272                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
273                            .with_attrs(::serde_json::json!({"normalized_from": normalized_from})),
274                            "ignoring message from unauthorized number: . Add to channels.whatsapp.allowed_numbers in config.toml, or run `zeroclaw onboard channels` to configure interactively."
275                        );
276                        continue;
277                    }
278
279                    // Extract text content (support text messages only for now)
280                    let content = if let Some(text_obj) = msg.get("text") {
281                        text_obj
282                            .get("body")
283                            .and_then(|b| b.as_str())
284                            .unwrap_or("")
285                            .to_string()
286                    } else {
287                        // Could be image, audio, etc. — skip for now
288                        ::zeroclaw_log::record!(
289                            DEBUG,
290                            ::zeroclaw_log::Event::new(
291                                module_path!(),
292                                ::zeroclaw_log::Action::Note
293                            )
294                            .with_attrs(::serde_json::json!({"from": from})),
295                            "skipping non-text message from"
296                        );
297                        continue;
298                    };
299
300                    if content.is_empty() {
301                        continue;
302                    }
303
304                    // Mention-pattern gating: apply dm_mention_patterns for
305                    // DMs and group_mention_patterns for groups. When the
306                    // applicable pattern set is non-empty, messages without a
307                    // match are dropped and matched fragments are stripped.
308                    let is_group = Self::is_group_message(msg);
309                    let content = match Self::apply_mention_gating(
310                        &self.dm_mention_patterns,
311                        &self.group_mention_patterns,
312                        &content,
313                        is_group,
314                    ) {
315                        Some(c) => c,
316                        None => {
317                            ::zeroclaw_log::record!(
318                                DEBUG,
319                                ::zeroclaw_log::Event::new(
320                                    module_path!(),
321                                    ::zeroclaw_log::Action::Note
322                                )
323                                .with_attrs(::serde_json::json!({"from": from})),
324                                "message from did not match mention patterns, dropping"
325                            );
326                            continue;
327                        }
328                    };
329
330                    // Get timestamp
331                    let timestamp = msg
332                        .get("timestamp")
333                        .and_then(|t| t.as_str())
334                        .and_then(|t| t.parse::<u64>().ok())
335                        .unwrap_or_else(|| {
336                            std::time::SystemTime::now()
337                                .duration_since(std::time::UNIX_EPOCH)
338                                .unwrap_or_default()
339                                .as_secs()
340                        });
341
342                    messages.push(ChannelMessage {
343                        id: Uuid::new_v4().to_string(),
344                        reply_target: normalized_from.clone(),
345                        sender: normalized_from,
346                        content,
347                        channel: "whatsapp".to_string(),
348                        channel_alias: Some(self.alias.clone()),
349                        timestamp,
350                        thread_ts: None,
351                        interruption_scope_id: None,
352                        attachments: vec![],
353                        subject: None,
354                    });
355                }
356            }
357        }
358
359        messages
360    }
361}
362
363impl ::zeroclaw_api::attribution::Attributable for WhatsAppChannel {
364    fn role(&self) -> ::zeroclaw_api::attribution::Role {
365        ::zeroclaw_api::attribution::Role::Channel(
366            ::zeroclaw_api::attribution::ChannelKind::WhatsappBusiness,
367        )
368    }
369    fn alias(&self) -> &str {
370        &self.alias
371    }
372}
373
374#[async_trait]
375impl Channel for WhatsAppChannel {
376    fn name(&self) -> &str {
377        "whatsapp"
378    }
379
380    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
381        // WhatsApp Cloud API: POST to /v18.0/{phone_number_id}/messages
382        let url = format!(
383            "https://graph.facebook.com/v18.0/{}/messages",
384            self.endpoint_id
385        );
386
387        // Normalize recipient (remove leading + if present for API)
388        let to = message
389            .recipient
390            .strip_prefix('+')
391            .unwrap_or(&message.recipient);
392
393        let body = serde_json::json!({
394            "messaging_product": "whatsapp",
395            "recipient_type": "individual",
396            "to": to,
397            "type": "text",
398            "text": {
399                "preview_url": false,
400                "body": message.content
401            }
402        });
403
404        ensure_https(&url)?;
405
406        let resp = self
407            .http_client()
408            .post(&url)
409            .bearer_auth(&self.access_token)
410            .header("Content-Type", "application/json")
411            .json(&body)
412            .send()
413            .await?;
414
415        if !resp.status().is_success() {
416            let status = resp.status();
417            let error_body = resp.text().await.unwrap_or_default();
418            ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"status": status.to_string(), "error_body": error_body})), "send failed:");
419            anyhow::bail!("WhatsApp API error: {status}");
420        }
421
422        Ok(())
423    }
424
425    async fn request_approval(
426        &self,
427        recipient: &str,
428        request: &ChannelApprovalRequest,
429    ) -> anyhow::Result<Option<ChannelApprovalResponse>> {
430        let token = crate::util::new_approval_token();
431        let (tx_approval, rx_approval) = oneshot::channel();
432        {
433            let mut map = PENDING_APPROVALS.lock().await;
434            map.insert(token.clone(), tx_approval);
435        }
436
437        let text = format!(
438            "APPROVAL REQUIRED [{}]\nTool: {}\nArgs: {}\n\nReply: \"{} yes\", \"{} no\", or \"{} always\"",
439            token, request.tool_name, request.arguments_summary, token, token, token
440        );
441        self.send(&SendMessage::new(text, recipient)).await?;
442
443        let timeout = std::time::Duration::from_secs(self.approval_timeout_secs);
444        let response = match tokio::time::timeout(timeout, rx_approval).await {
445            Ok(Ok(response)) => response,
446            _ => {
447                let mut map = PENDING_APPROVALS.lock().await;
448                map.remove(&token);
449                ChannelApprovalResponse::Deny
450            }
451        };
452        Ok(Some(response))
453    }
454
455    async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
456        // WhatsApp uses webhooks (push-based), not polling.
457        // Messages are received via the gateway's /whatsapp endpoint.
458        // This method keeps the channel "alive" but doesn't actively poll.
459        ::zeroclaw_log::record!(
460            INFO,
461            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
462            "WhatsApp channel active (webhook mode). \
463            Configure Meta webhook to POST to your gateway's /whatsapp endpoint."
464        );
465
466        // Keep the task alive — it will be cancelled when the channel shuts down
467        loop {
468            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
469        }
470    }
471
472    async fn health_check(&self) -> bool {
473        // Check if we can reach the WhatsApp API
474        let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
475
476        if ensure_https(&url).is_err() {
477            return false;
478        }
479
480        self.http_client()
481            .get(&url)
482            .bearer_auth(&self.access_token)
483            .send()
484            .await
485            .map(|r| r.status().is_success())
486            .unwrap_or(false)
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn whatsapp_channel_name() {
496        let ch = WhatsAppChannel::new(
497            "test-token".into(),
498            "123456789".into(),
499            "verify-me".into(),
500            "whatsapp_test_alias",
501            Arc::new(|| vec!["+1234567890".into()]),
502        );
503        assert_eq!(ch.name(), "whatsapp");
504    }
505
506    #[test]
507    fn whatsapp_verify_token() {
508        let ch = WhatsAppChannel::new(
509            "test-token".into(),
510            "123456789".into(),
511            "verify-me".into(),
512            "whatsapp_test_alias",
513            Arc::new(|| vec!["+1234567890".into()]),
514        );
515        assert_eq!(ch.verify_token(), "verify-me");
516    }
517
518    #[test]
519    fn whatsapp_number_allowed_exact() {
520        let ch = WhatsAppChannel::new(
521            "test-token".into(),
522            "123456789".into(),
523            "verify-me".into(),
524            "whatsapp_test_alias",
525            Arc::new(|| vec!["+1234567890".into()]),
526        );
527        assert!(ch.is_number_allowed("+1234567890"));
528        assert!(!ch.is_number_allowed("+9876543210"));
529    }
530
531    #[test]
532    fn whatsapp_number_allowed_wildcard() {
533        let ch = WhatsAppChannel::new(
534            "tok".into(),
535            "123".into(),
536            "ver".into(),
537            "whatsapp_test_alias",
538            Arc::new(|| vec!["*".into()]),
539        );
540        assert!(ch.is_number_allowed("+1234567890"));
541        assert!(ch.is_number_allowed("+9999999999"));
542    }
543
544    #[test]
545    fn whatsapp_number_denied_empty() {
546        let ch = WhatsAppChannel::new(
547            "tok".into(),
548            "123".into(),
549            "ver".into(),
550            "whatsapp_test_alias",
551            Arc::new(Vec::new),
552        );
553        assert!(!ch.is_number_allowed("+1234567890"));
554    }
555
556    #[test]
557    fn whatsapp_parse_empty_payload() {
558        let ch = WhatsAppChannel::new(
559            "test-token".into(),
560            "123456789".into(),
561            "verify-me".into(),
562            "whatsapp_test_alias",
563            Arc::new(|| vec!["+1234567890".into()]),
564        );
565        let payload = serde_json::json!({});
566        let msgs = ch.parse_webhook_payload(&payload);
567        assert!(msgs.is_empty());
568    }
569
570    #[test]
571    fn whatsapp_parse_valid_text_message() {
572        let ch = WhatsAppChannel::new(
573            "test-token".into(),
574            "123456789".into(),
575            "verify-me".into(),
576            "whatsapp_test_alias",
577            Arc::new(|| vec!["+1234567890".into()]),
578        );
579        let payload = serde_json::json!({
580            "object": "whatsapp_business_account",
581            "entry": [{
582                "id": "123",
583                "changes": [{
584                    "value": {
585                        "messaging_product": "whatsapp",
586                        "metadata": {
587                            "display_phone_number": "15551234567",
588                            "phone_number_id": "123456789"
589                        },
590                        "messages": [{
591                            "from": "1234567890",
592                            "id": "wamid.xxx",
593                            "timestamp": "1699999999",
594                            "type": "text",
595                            "text": {
596                                "body": "Hello ZeroClaw!"
597                            }
598                        }]
599                    },
600                    "field": "messages"
601                }]
602            }]
603        });
604
605        let msgs = ch.parse_webhook_payload(&payload);
606        assert_eq!(msgs.len(), 1);
607        assert_eq!(msgs[0].sender, "+1234567890");
608        assert_eq!(msgs[0].content, "Hello ZeroClaw!");
609        assert_eq!(msgs[0].channel, "whatsapp");
610        assert_eq!(msgs[0].timestamp, 1_699_999_999);
611    }
612
613    #[test]
614    fn whatsapp_parse_unauthorized_number() {
615        let ch = WhatsAppChannel::new(
616            "test-token".into(),
617            "123456789".into(),
618            "verify-me".into(),
619            "whatsapp_test_alias",
620            Arc::new(|| vec!["+1234567890".into()]),
621        );
622        let payload = serde_json::json!({
623            "object": "whatsapp_business_account",
624            "entry": [{
625                "changes": [{
626                    "value": {
627                        "messages": [{
628                            "from": "9999999999",
629                            "timestamp": "1699999999",
630                            "type": "text",
631                            "text": { "body": "Spam" }
632                        }]
633                    }
634                }]
635            }]
636        });
637
638        let msgs = ch.parse_webhook_payload(&payload);
639        assert!(msgs.is_empty(), "Unauthorized numbers should be filtered");
640    }
641
642    #[test]
643    fn whatsapp_parse_non_text_message_skipped() {
644        let ch = WhatsAppChannel::new(
645            "tok".into(),
646            "123".into(),
647            "ver".into(),
648            "whatsapp_test_alias",
649            Arc::new(|| vec!["*".into()]),
650        );
651        let payload = serde_json::json!({
652            "entry": [{
653                "changes": [{
654                    "value": {
655                        "messages": [{
656                            "from": "1234567890",
657                            "timestamp": "1699999999",
658                            "type": "image",
659                            "image": { "id": "img123" }
660                        }]
661                    }
662                }]
663            }]
664        });
665
666        let msgs = ch.parse_webhook_payload(&payload);
667        assert!(msgs.is_empty(), "Non-text messages should be skipped");
668    }
669
670    #[test]
671    fn whatsapp_parse_multiple_messages() {
672        let ch = WhatsAppChannel::new(
673            "tok".into(),
674            "123".into(),
675            "ver".into(),
676            "whatsapp_test_alias",
677            Arc::new(|| vec!["*".into()]),
678        );
679        let payload = serde_json::json!({
680            "entry": [{
681                "changes": [{
682                    "value": {
683                        "messages": [
684                            { "from": "111", "timestamp": "1", "type": "text", "text": { "body": "First" } },
685                            { "from": "222", "timestamp": "2", "type": "text", "text": { "body": "Second" } }
686                        ]
687                    }
688                }]
689            }]
690        });
691
692        let msgs = ch.parse_webhook_payload(&payload);
693        assert_eq!(msgs.len(), 2);
694        assert_eq!(msgs[0].content, "First");
695        assert_eq!(msgs[1].content, "Second");
696    }
697
698    #[test]
699    fn whatsapp_parse_normalizes_phone_with_plus() {
700        let ch = WhatsAppChannel::new(
701            "tok".into(),
702            "123".into(),
703            "ver".into(),
704            "whatsapp_test_alias",
705            Arc::new(|| vec!["+1234567890".into()]),
706        );
707        // API sends without +, but we normalize to +
708        let payload = serde_json::json!({
709            "entry": [{
710                "changes": [{
711                    "value": {
712                        "messages": [{
713                            "from": "1234567890",
714                            "timestamp": "1",
715                            "type": "text",
716                            "text": { "body": "Hi" }
717                        }]
718                    }
719                }]
720            }]
721        });
722
723        let msgs = ch.parse_webhook_payload(&payload);
724        assert_eq!(msgs.len(), 1);
725        assert_eq!(msgs[0].sender, "+1234567890");
726    }
727
728    #[test]
729    fn whatsapp_empty_text_skipped() {
730        let ch = WhatsAppChannel::new(
731            "tok".into(),
732            "123".into(),
733            "ver".into(),
734            "whatsapp_test_alias",
735            Arc::new(|| vec!["*".into()]),
736        );
737        let payload = serde_json::json!({
738            "entry": [{
739                "changes": [{
740                    "value": {
741                        "messages": [{
742                            "from": "111",
743                            "timestamp": "1",
744                            "type": "text",
745                            "text": { "body": "" }
746                        }]
747                    }
748                }]
749            }]
750        });
751
752        let msgs = ch.parse_webhook_payload(&payload);
753        assert!(msgs.is_empty());
754    }
755
756    // ══════════════════════════════════════════════════════════
757    // EDGE CASES — Comprehensive coverage
758    // ══════════════════════════════════════════════════════════
759
760    #[test]
761    fn whatsapp_parse_missing_entry_array() {
762        let ch = WhatsAppChannel::new(
763            "test-token".into(),
764            "123456789".into(),
765            "verify-me".into(),
766            "whatsapp_test_alias",
767            Arc::new(|| vec!["+1234567890".into()]),
768        );
769        let payload = serde_json::json!({
770            "object": "whatsapp_business_account"
771        });
772        let msgs = ch.parse_webhook_payload(&payload);
773        assert!(msgs.is_empty());
774    }
775
776    #[test]
777    fn whatsapp_parse_entry_not_array() {
778        let ch = WhatsAppChannel::new(
779            "test-token".into(),
780            "123456789".into(),
781            "verify-me".into(),
782            "whatsapp_test_alias",
783            Arc::new(|| vec!["+1234567890".into()]),
784        );
785        let payload = serde_json::json!({
786            "entry": "not_an_array"
787        });
788        let msgs = ch.parse_webhook_payload(&payload);
789        assert!(msgs.is_empty());
790    }
791
792    #[test]
793    fn whatsapp_parse_missing_changes_array() {
794        let ch = WhatsAppChannel::new(
795            "test-token".into(),
796            "123456789".into(),
797            "verify-me".into(),
798            "whatsapp_test_alias",
799            Arc::new(|| vec!["+1234567890".into()]),
800        );
801        let payload = serde_json::json!({
802            "entry": [{ "id": "123" }]
803        });
804        let msgs = ch.parse_webhook_payload(&payload);
805        assert!(msgs.is_empty());
806    }
807
808    #[test]
809    fn whatsapp_parse_changes_not_array() {
810        let ch = WhatsAppChannel::new(
811            "test-token".into(),
812            "123456789".into(),
813            "verify-me".into(),
814            "whatsapp_test_alias",
815            Arc::new(|| vec!["+1234567890".into()]),
816        );
817        let payload = serde_json::json!({
818            "entry": [{
819                "changes": "not_an_array"
820            }]
821        });
822        let msgs = ch.parse_webhook_payload(&payload);
823        assert!(msgs.is_empty());
824    }
825
826    #[test]
827    fn whatsapp_parse_missing_value() {
828        let ch = WhatsAppChannel::new(
829            "test-token".into(),
830            "123456789".into(),
831            "verify-me".into(),
832            "whatsapp_test_alias",
833            Arc::new(|| vec!["+1234567890".into()]),
834        );
835        let payload = serde_json::json!({
836            "entry": [{
837                "changes": [{ "field": "messages" }]
838            }]
839        });
840        let msgs = ch.parse_webhook_payload(&payload);
841        assert!(msgs.is_empty());
842    }
843
844    #[test]
845    fn whatsapp_parse_missing_messages_array() {
846        let ch = WhatsAppChannel::new(
847            "test-token".into(),
848            "123456789".into(),
849            "verify-me".into(),
850            "whatsapp_test_alias",
851            Arc::new(|| vec!["+1234567890".into()]),
852        );
853        let payload = serde_json::json!({
854            "entry": [{
855                "changes": [{
856                    "value": {
857                        "metadata": {}
858                    }
859                }]
860            }]
861        });
862        let msgs = ch.parse_webhook_payload(&payload);
863        assert!(msgs.is_empty());
864    }
865
866    #[test]
867    fn whatsapp_parse_messages_not_array() {
868        let ch = WhatsAppChannel::new(
869            "test-token".into(),
870            "123456789".into(),
871            "verify-me".into(),
872            "whatsapp_test_alias",
873            Arc::new(|| vec!["+1234567890".into()]),
874        );
875        let payload = serde_json::json!({
876            "entry": [{
877                "changes": [{
878                    "value": {
879                        "messages": "not_an_array"
880                    }
881                }]
882            }]
883        });
884        let msgs = ch.parse_webhook_payload(&payload);
885        assert!(msgs.is_empty());
886    }
887
888    #[test]
889    fn whatsapp_parse_missing_from_field() {
890        let ch = WhatsAppChannel::new(
891            "tok".into(),
892            "123".into(),
893            "ver".into(),
894            "whatsapp_test_alias",
895            Arc::new(|| vec!["*".into()]),
896        );
897        let payload = serde_json::json!({
898            "entry": [{
899                "changes": [{
900                    "value": {
901                        "messages": [{
902                            "timestamp": "1",
903                            "type": "text",
904                            "text": { "body": "No sender" }
905                        }]
906                    }
907                }]
908            }]
909        });
910        let msgs = ch.parse_webhook_payload(&payload);
911        assert!(msgs.is_empty(), "Messages without 'from' should be skipped");
912    }
913
914    #[test]
915    fn whatsapp_parse_missing_text_body() {
916        let ch = WhatsAppChannel::new(
917            "tok".into(),
918            "123".into(),
919            "ver".into(),
920            "whatsapp_test_alias",
921            Arc::new(|| vec!["*".into()]),
922        );
923        let payload = serde_json::json!({
924            "entry": [{
925                "changes": [{
926                    "value": {
927                        "messages": [{
928                            "from": "111",
929                            "timestamp": "1",
930                            "type": "text",
931                            "text": {}
932                        }]
933                    }
934                }]
935            }]
936        });
937        let msgs = ch.parse_webhook_payload(&payload);
938        assert!(
939            msgs.is_empty(),
940            "Messages with empty text object should be skipped"
941        );
942    }
943
944    #[test]
945    fn whatsapp_parse_null_text_body() {
946        let ch = WhatsAppChannel::new(
947            "tok".into(),
948            "123".into(),
949            "ver".into(),
950            "whatsapp_test_alias",
951            Arc::new(|| vec!["*".into()]),
952        );
953        let payload = serde_json::json!({
954            "entry": [{
955                "changes": [{
956                    "value": {
957                        "messages": [{
958                            "from": "111",
959                            "timestamp": "1",
960                            "type": "text",
961                            "text": { "body": null }
962                        }]
963                    }
964                }]
965            }]
966        });
967        let msgs = ch.parse_webhook_payload(&payload);
968        assert!(msgs.is_empty(), "Messages with null body should be skipped");
969    }
970
971    #[test]
972    fn whatsapp_parse_invalid_timestamp_uses_current() {
973        let ch = WhatsAppChannel::new(
974            "tok".into(),
975            "123".into(),
976            "ver".into(),
977            "whatsapp_test_alias",
978            Arc::new(|| vec!["*".into()]),
979        );
980        let payload = serde_json::json!({
981            "entry": [{
982                "changes": [{
983                    "value": {
984                        "messages": [{
985                            "from": "111",
986                            "timestamp": "not_a_number",
987                            "type": "text",
988                            "text": { "body": "Hello" }
989                        }]
990                    }
991                }]
992            }]
993        });
994        let msgs = ch.parse_webhook_payload(&payload);
995        assert_eq!(msgs.len(), 1);
996        // Timestamp should be current time (non-zero)
997        assert!(msgs[0].timestamp > 0);
998    }
999
1000    #[test]
1001    fn whatsapp_parse_missing_timestamp_uses_current() {
1002        let ch = WhatsAppChannel::new(
1003            "tok".into(),
1004            "123".into(),
1005            "ver".into(),
1006            "whatsapp_test_alias",
1007            Arc::new(|| vec!["*".into()]),
1008        );
1009        let payload = serde_json::json!({
1010            "entry": [{
1011                "changes": [{
1012                    "value": {
1013                        "messages": [{
1014                            "from": "111",
1015                            "type": "text",
1016                            "text": { "body": "Hello" }
1017                        }]
1018                    }
1019                }]
1020            }]
1021        });
1022        let msgs = ch.parse_webhook_payload(&payload);
1023        assert_eq!(msgs.len(), 1);
1024        assert!(msgs[0].timestamp > 0);
1025    }
1026
1027    #[test]
1028    fn whatsapp_parse_multiple_entries() {
1029        let ch = WhatsAppChannel::new(
1030            "tok".into(),
1031            "123".into(),
1032            "ver".into(),
1033            "whatsapp_test_alias",
1034            Arc::new(|| vec!["*".into()]),
1035        );
1036        let payload = serde_json::json!({
1037            "entry": [
1038                {
1039                    "changes": [{
1040                        "value": {
1041                            "messages": [{
1042                                "from": "111",
1043                                "timestamp": "1",
1044                                "type": "text",
1045                                "text": { "body": "Entry 1" }
1046                            }]
1047                        }
1048                    }]
1049                },
1050                {
1051                    "changes": [{
1052                        "value": {
1053                            "messages": [{
1054                                "from": "222",
1055                                "timestamp": "2",
1056                                "type": "text",
1057                                "text": { "body": "Entry 2" }
1058                            }]
1059                        }
1060                    }]
1061                }
1062            ]
1063        });
1064        let msgs = ch.parse_webhook_payload(&payload);
1065        assert_eq!(msgs.len(), 2);
1066        assert_eq!(msgs[0].content, "Entry 1");
1067        assert_eq!(msgs[1].content, "Entry 2");
1068    }
1069
1070    #[test]
1071    fn whatsapp_parse_multiple_changes() {
1072        let ch = WhatsAppChannel::new(
1073            "tok".into(),
1074            "123".into(),
1075            "ver".into(),
1076            "whatsapp_test_alias",
1077            Arc::new(|| vec!["*".into()]),
1078        );
1079        let payload = serde_json::json!({
1080            "entry": [{
1081                "changes": [
1082                    {
1083                        "value": {
1084                            "messages": [{
1085                                "from": "111",
1086                                "timestamp": "1",
1087                                "type": "text",
1088                                "text": { "body": "Change 1" }
1089                            }]
1090                        }
1091                    },
1092                    {
1093                        "value": {
1094                            "messages": [{
1095                                "from": "222",
1096                                "timestamp": "2",
1097                                "type": "text",
1098                                "text": { "body": "Change 2" }
1099                            }]
1100                        }
1101                    }
1102                ]
1103            }]
1104        });
1105        let msgs = ch.parse_webhook_payload(&payload);
1106        assert_eq!(msgs.len(), 2);
1107        assert_eq!(msgs[0].content, "Change 1");
1108        assert_eq!(msgs[1].content, "Change 2");
1109    }
1110
1111    #[test]
1112    fn whatsapp_parse_status_update_ignored() {
1113        // Status updates have "statuses" instead of "messages"
1114        let ch = WhatsAppChannel::new(
1115            "test-token".into(),
1116            "123456789".into(),
1117            "verify-me".into(),
1118            "whatsapp_test_alias",
1119            Arc::new(|| vec!["+1234567890".into()]),
1120        );
1121        let payload = serde_json::json!({
1122            "entry": [{
1123                "changes": [{
1124                    "value": {
1125                        "statuses": [{
1126                            "id": "wamid.xxx",
1127                            "status": "delivered",
1128                            "timestamp": "1699999999"
1129                        }]
1130                    }
1131                }]
1132            }]
1133        });
1134        let msgs = ch.parse_webhook_payload(&payload);
1135        assert!(msgs.is_empty(), "Status updates should be ignored");
1136    }
1137
1138    #[test]
1139    fn whatsapp_parse_audio_message_skipped() {
1140        let ch = WhatsAppChannel::new(
1141            "tok".into(),
1142            "123".into(),
1143            "ver".into(),
1144            "whatsapp_test_alias",
1145            Arc::new(|| vec!["*".into()]),
1146        );
1147        let payload = serde_json::json!({
1148            "entry": [{
1149                "changes": [{
1150                    "value": {
1151                        "messages": [{
1152                            "from": "111",
1153                            "timestamp": "1",
1154                            "type": "audio",
1155                            "audio": { "id": "audio123", "mime_type": "audio/ogg" }
1156                        }]
1157                    }
1158                }]
1159            }]
1160        });
1161        let msgs = ch.parse_webhook_payload(&payload);
1162        assert!(msgs.is_empty());
1163    }
1164
1165    #[test]
1166    fn whatsapp_parse_video_message_skipped() {
1167        let ch = WhatsAppChannel::new(
1168            "tok".into(),
1169            "123".into(),
1170            "ver".into(),
1171            "whatsapp_test_alias",
1172            Arc::new(|| vec!["*".into()]),
1173        );
1174        let payload = serde_json::json!({
1175            "entry": [{
1176                "changes": [{
1177                    "value": {
1178                        "messages": [{
1179                            "from": "111",
1180                            "timestamp": "1",
1181                            "type": "video",
1182                            "video": { "id": "video123" }
1183                        }]
1184                    }
1185                }]
1186            }]
1187        });
1188        let msgs = ch.parse_webhook_payload(&payload);
1189        assert!(msgs.is_empty());
1190    }
1191
1192    #[test]
1193    fn whatsapp_parse_document_message_skipped() {
1194        let ch = WhatsAppChannel::new(
1195            "tok".into(),
1196            "123".into(),
1197            "ver".into(),
1198            "whatsapp_test_alias",
1199            Arc::new(|| vec!["*".into()]),
1200        );
1201        let payload = serde_json::json!({
1202            "entry": [{
1203                "changes": [{
1204                    "value": {
1205                        "messages": [{
1206                            "from": "111",
1207                            "timestamp": "1",
1208                            "type": "document",
1209                            "document": { "id": "doc123", "filename": "file.pdf" }
1210                        }]
1211                    }
1212                }]
1213            }]
1214        });
1215        let msgs = ch.parse_webhook_payload(&payload);
1216        assert!(msgs.is_empty());
1217    }
1218
1219    #[test]
1220    fn whatsapp_parse_sticker_message_skipped() {
1221        let ch = WhatsAppChannel::new(
1222            "tok".into(),
1223            "123".into(),
1224            "ver".into(),
1225            "whatsapp_test_alias",
1226            Arc::new(|| vec!["*".into()]),
1227        );
1228        let payload = serde_json::json!({
1229            "entry": [{
1230                "changes": [{
1231                    "value": {
1232                        "messages": [{
1233                            "from": "111",
1234                            "timestamp": "1",
1235                            "type": "sticker",
1236                            "sticker": { "id": "sticker123" }
1237                        }]
1238                    }
1239                }]
1240            }]
1241        });
1242        let msgs = ch.parse_webhook_payload(&payload);
1243        assert!(msgs.is_empty());
1244    }
1245
1246    #[test]
1247    fn whatsapp_parse_location_message_skipped() {
1248        let ch = WhatsAppChannel::new(
1249            "tok".into(),
1250            "123".into(),
1251            "ver".into(),
1252            "whatsapp_test_alias",
1253            Arc::new(|| vec!["*".into()]),
1254        );
1255        let payload = serde_json::json!({
1256            "entry": [{
1257                "changes": [{
1258                    "value": {
1259                        "messages": [{
1260                            "from": "111",
1261                            "timestamp": "1",
1262                            "type": "location",
1263                            "location": { "latitude": 40.7128, "longitude": -74.0060 }
1264                        }]
1265                    }
1266                }]
1267            }]
1268        });
1269        let msgs = ch.parse_webhook_payload(&payload);
1270        assert!(msgs.is_empty());
1271    }
1272
1273    #[test]
1274    fn whatsapp_parse_contacts_message_skipped() {
1275        let ch = WhatsAppChannel::new(
1276            "tok".into(),
1277            "123".into(),
1278            "ver".into(),
1279            "whatsapp_test_alias",
1280            Arc::new(|| vec!["*".into()]),
1281        );
1282        let payload = serde_json::json!({
1283            "entry": [{
1284                "changes": [{
1285                    "value": {
1286                        "messages": [{
1287                            "from": "111",
1288                            "timestamp": "1",
1289                            "type": "contacts",
1290                            "contacts": [{ "name": { "formatted_name": "John" } }]
1291                        }]
1292                    }
1293                }]
1294            }]
1295        });
1296        let msgs = ch.parse_webhook_payload(&payload);
1297        assert!(msgs.is_empty());
1298    }
1299
1300    #[test]
1301    fn whatsapp_parse_reaction_message_skipped() {
1302        let ch = WhatsAppChannel::new(
1303            "tok".into(),
1304            "123".into(),
1305            "ver".into(),
1306            "whatsapp_test_alias",
1307            Arc::new(|| vec!["*".into()]),
1308        );
1309        let payload = serde_json::json!({
1310            "entry": [{
1311                "changes": [{
1312                    "value": {
1313                        "messages": [{
1314                            "from": "111",
1315                            "timestamp": "1",
1316                            "type": "reaction",
1317                            "reaction": { "message_id": "wamid.xxx", "emoji": "👍" }
1318                        }]
1319                    }
1320                }]
1321            }]
1322        });
1323        let msgs = ch.parse_webhook_payload(&payload);
1324        assert!(msgs.is_empty());
1325    }
1326
1327    #[test]
1328    fn whatsapp_parse_mixed_authorized_unauthorized() {
1329        let ch = WhatsAppChannel::new(
1330            "tok".into(),
1331            "123".into(),
1332            "ver".into(),
1333            "whatsapp_test_alias",
1334            Arc::new(|| vec!["+1111111111".into()]),
1335        );
1336        let payload = serde_json::json!({
1337            "entry": [{
1338                "changes": [{
1339                    "value": {
1340                        "messages": [
1341                            { "from": "1111111111", "timestamp": "1", "type": "text", "text": { "body": "Allowed" } },
1342                            { "from": "9999999999", "timestamp": "2", "type": "text", "text": { "body": "Blocked" } },
1343                            { "from": "1111111111", "timestamp": "3", "type": "text", "text": { "body": "Also allowed" } }
1344                        ]
1345                    }
1346                }]
1347            }]
1348        });
1349        let msgs = ch.parse_webhook_payload(&payload);
1350        assert_eq!(msgs.len(), 2);
1351        assert_eq!(msgs[0].content, "Allowed");
1352        assert_eq!(msgs[1].content, "Also allowed");
1353    }
1354
1355    #[test]
1356    fn whatsapp_parse_unicode_message() {
1357        let ch = WhatsAppChannel::new(
1358            "tok".into(),
1359            "123".into(),
1360            "ver".into(),
1361            "whatsapp_test_alias",
1362            Arc::new(|| vec!["*".into()]),
1363        );
1364        let payload = serde_json::json!({
1365            "entry": [{
1366                "changes": [{
1367                    "value": {
1368                        "messages": [{
1369                            "from": "111",
1370                            "timestamp": "1",
1371                            "type": "text",
1372                            "text": { "body": "Hello 👋 世界 🌍 مرحبا" }
1373                        }]
1374                    }
1375                }]
1376            }]
1377        });
1378        let msgs = ch.parse_webhook_payload(&payload);
1379        assert_eq!(msgs.len(), 1);
1380        assert_eq!(msgs[0].content, "Hello 👋 世界 🌍 مرحبا");
1381    }
1382
1383    #[test]
1384    fn whatsapp_parse_very_long_message() {
1385        let ch = WhatsAppChannel::new(
1386            "tok".into(),
1387            "123".into(),
1388            "ver".into(),
1389            "whatsapp_test_alias",
1390            Arc::new(|| vec!["*".into()]),
1391        );
1392        let long_text = "A".repeat(10_000);
1393        let payload = serde_json::json!({
1394            "entry": [{
1395                "changes": [{
1396                    "value": {
1397                        "messages": [{
1398                            "from": "111",
1399                            "timestamp": "1",
1400                            "type": "text",
1401                            "text": { "body": long_text }
1402                        }]
1403                    }
1404                }]
1405            }]
1406        });
1407        let msgs = ch.parse_webhook_payload(&payload);
1408        assert_eq!(msgs.len(), 1);
1409        assert_eq!(msgs[0].content.len(), 10_000);
1410    }
1411
1412    #[test]
1413    fn whatsapp_parse_whitespace_only_message_skipped() {
1414        let ch = WhatsAppChannel::new(
1415            "tok".into(),
1416            "123".into(),
1417            "ver".into(),
1418            "whatsapp_test_alias",
1419            Arc::new(|| vec!["*".into()]),
1420        );
1421        let payload = serde_json::json!({
1422            "entry": [{
1423                "changes": [{
1424                    "value": {
1425                        "messages": [{
1426                            "from": "111",
1427                            "timestamp": "1",
1428                            "type": "text",
1429                            "text": { "body": "   " }
1430                        }]
1431                    }
1432                }]
1433            }]
1434        });
1435        let msgs = ch.parse_webhook_payload(&payload);
1436        // Whitespace-only is NOT empty, so it passes through
1437        assert_eq!(msgs.len(), 1);
1438        assert_eq!(msgs[0].content, "   ");
1439    }
1440
1441    #[test]
1442    fn whatsapp_number_allowed_multiple_numbers() {
1443        let ch = WhatsAppChannel::new(
1444            "tok".into(),
1445            "123".into(),
1446            "ver".into(),
1447            "whatsapp_test_alias",
1448            Arc::new(|| {
1449                vec![
1450                    "+1111111111".into(),
1451                    "+2222222222".into(),
1452                    "+3333333333".into(),
1453                ]
1454            }),
1455        );
1456        assert!(ch.is_number_allowed("+1111111111"));
1457        assert!(ch.is_number_allowed("+2222222222"));
1458        assert!(ch.is_number_allowed("+3333333333"));
1459        assert!(!ch.is_number_allowed("+4444444444"));
1460    }
1461
1462    #[test]
1463    fn whatsapp_number_allowed_case_sensitive() {
1464        // Phone numbers should be exact match
1465        let ch = WhatsAppChannel::new(
1466            "tok".into(),
1467            "123".into(),
1468            "ver".into(),
1469            "whatsapp_test_alias",
1470            Arc::new(|| vec!["+1234567890".into()]),
1471        );
1472        assert!(ch.is_number_allowed("+1234567890"));
1473        // Different number should not match
1474        assert!(!ch.is_number_allowed("+1234567891"));
1475    }
1476
1477    #[test]
1478    fn whatsapp_parse_phone_already_has_plus() {
1479        let ch = WhatsAppChannel::new(
1480            "tok".into(),
1481            "123".into(),
1482            "ver".into(),
1483            "whatsapp_test_alias",
1484            Arc::new(|| vec!["+1234567890".into()]),
1485        );
1486        // If API sends with +, we should still handle it
1487        let payload = serde_json::json!({
1488            "entry": [{
1489                "changes": [{
1490                    "value": {
1491                        "messages": [{
1492                            "from": "+1234567890",
1493                            "timestamp": "1",
1494                            "type": "text",
1495                            "text": { "body": "Hi" }
1496                        }]
1497                    }
1498                }]
1499            }]
1500        });
1501        let msgs = ch.parse_webhook_payload(&payload);
1502        assert_eq!(msgs.len(), 1);
1503        assert_eq!(msgs[0].sender, "+1234567890");
1504    }
1505
1506    #[test]
1507    fn whatsapp_channel_fields_stored_correctly() {
1508        let ch = WhatsAppChannel::new(
1509            "my-access-token".into(),
1510            "phone-id-123".into(),
1511            "my-verify-token".into(),
1512            "whatsapp_test_alias",
1513            Arc::new(|| vec!["+111".into(), "+222".into()]),
1514        );
1515        assert_eq!(ch.verify_token(), "my-verify-token");
1516        assert!(ch.is_number_allowed("+111"));
1517        assert!(ch.is_number_allowed("+222"));
1518        assert!(!ch.is_number_allowed("+333"));
1519    }
1520
1521    #[test]
1522    fn whatsapp_parse_empty_messages_array() {
1523        let ch = WhatsAppChannel::new(
1524            "test-token".into(),
1525            "123456789".into(),
1526            "verify-me".into(),
1527            "whatsapp_test_alias",
1528            Arc::new(|| vec!["+1234567890".into()]),
1529        );
1530        let payload = serde_json::json!({
1531            "entry": [{
1532                "changes": [{
1533                    "value": {
1534                        "messages": []
1535                    }
1536                }]
1537            }]
1538        });
1539        let msgs = ch.parse_webhook_payload(&payload);
1540        assert!(msgs.is_empty());
1541    }
1542
1543    #[test]
1544    fn whatsapp_parse_empty_entry_array() {
1545        let ch = WhatsAppChannel::new(
1546            "test-token".into(),
1547            "123456789".into(),
1548            "verify-me".into(),
1549            "whatsapp_test_alias",
1550            Arc::new(|| vec!["+1234567890".into()]),
1551        );
1552        let payload = serde_json::json!({
1553            "entry": []
1554        });
1555        let msgs = ch.parse_webhook_payload(&payload);
1556        assert!(msgs.is_empty());
1557    }
1558
1559    #[test]
1560    fn whatsapp_parse_empty_changes_array() {
1561        let ch = WhatsAppChannel::new(
1562            "test-token".into(),
1563            "123456789".into(),
1564            "verify-me".into(),
1565            "whatsapp_test_alias",
1566            Arc::new(|| vec!["+1234567890".into()]),
1567        );
1568        let payload = serde_json::json!({
1569            "entry": [{
1570                "changes": []
1571            }]
1572        });
1573        let msgs = ch.parse_webhook_payload(&payload);
1574        assert!(msgs.is_empty());
1575    }
1576
1577    #[test]
1578    fn whatsapp_parse_newlines_preserved() {
1579        let ch = WhatsAppChannel::new(
1580            "tok".into(),
1581            "123".into(),
1582            "ver".into(),
1583            "whatsapp_test_alias",
1584            Arc::new(|| vec!["*".into()]),
1585        );
1586        let payload = serde_json::json!({
1587            "entry": [{
1588                "changes": [{
1589                    "value": {
1590                        "messages": [{
1591                            "from": "111",
1592                            "timestamp": "1",
1593                            "type": "text",
1594                            "text": { "body": "Line 1\nLine 2\nLine 3" }
1595                        }]
1596                    }
1597                }]
1598            }]
1599        });
1600        let msgs = ch.parse_webhook_payload(&payload);
1601        assert_eq!(msgs.len(), 1);
1602        assert_eq!(msgs[0].content, "Line 1\nLine 2\nLine 3");
1603    }
1604
1605    #[test]
1606    fn whatsapp_parse_special_characters() {
1607        let ch = WhatsAppChannel::new(
1608            "tok".into(),
1609            "123".into(),
1610            "ver".into(),
1611            "whatsapp_test_alias",
1612            Arc::new(|| vec!["*".into()]),
1613        );
1614        let payload = serde_json::json!({
1615            "entry": [{
1616                "changes": [{
1617                    "value": {
1618                        "messages": [{
1619                            "from": "111",
1620                            "timestamp": "1",
1621                            "type": "text",
1622                            "text": { "body": "<script>alert('xss')</script> & \"quotes\" 'apostrophe'" }
1623                        }]
1624                    }
1625                }]
1626            }]
1627        });
1628        let msgs = ch.parse_webhook_payload(&payload);
1629        assert_eq!(msgs.len(), 1);
1630        assert_eq!(
1631            msgs[0].content,
1632            "<script>alert('xss')</script> & \"quotes\" 'apostrophe'"
1633        );
1634    }
1635
1636    // ══════════════════════════════════════════════════════════
1637    // MENTION-PATTERN GATING — Unit tests
1638    // ══════════════════════════════════════════════════════════
1639
1640    // ── compile_mention_patterns ──
1641
1642    #[test]
1643    fn whatsapp_compile_valid_patterns() {
1644        let patterns = WhatsAppChannel::compile_mention_patterns(&[
1645            "@?ZeroClaw".into(),
1646            r"\+?15555550123".into(),
1647        ]);
1648        assert_eq!(patterns.len(), 2);
1649    }
1650
1651    #[test]
1652    fn whatsapp_compile_skips_invalid_patterns() {
1653        let patterns =
1654            WhatsAppChannel::compile_mention_patterns(&["@?ZeroClaw".into(), "[invalid".into()]);
1655        assert_eq!(patterns.len(), 1);
1656    }
1657
1658    #[test]
1659    fn whatsapp_compile_skips_empty_patterns() {
1660        let patterns =
1661            WhatsAppChannel::compile_mention_patterns(&["@?ZeroClaw".into(), "  ".into()]);
1662        assert_eq!(patterns.len(), 1);
1663    }
1664
1665    #[test]
1666    fn whatsapp_compile_empty_vec() {
1667        let patterns = WhatsAppChannel::compile_mention_patterns(&[]);
1668        assert!(patterns.is_empty());
1669    }
1670
1671    // ── text_matches_patterns ──
1672
1673    #[test]
1674    fn whatsapp_text_matches_at_name() {
1675        let pats = WhatsAppChannel::compile_mention_patterns(&["@?ZeroClaw".into()]);
1676        assert!(WhatsAppChannel::text_matches_patterns(
1677            &pats,
1678            "Hello @ZeroClaw"
1679        ));
1680    }
1681
1682    #[test]
1683    fn whatsapp_text_matches_name_only() {
1684        let pats = WhatsAppChannel::compile_mention_patterns(&["@?ZeroClaw".into()]);
1685        assert!(WhatsAppChannel::text_matches_patterns(
1686            &pats,
1687            "Hello ZeroClaw"
1688        ));
1689    }
1690
1691    #[test]
1692    fn whatsapp_text_matches_case_insensitive() {
1693        let pats = WhatsAppChannel::compile_mention_patterns(&["@?ZeroClaw".into()]);
1694        assert!(WhatsAppChannel::text_matches_patterns(
1695            &pats,
1696            "Hello @zeroclaw"
1697        ));
1698        assert!(WhatsAppChannel::text_matches_patterns(
1699            &pats,
1700            "Hello ZEROCLAW"
1701        ));
1702    }
1703
1704    #[test]
1705    fn whatsapp_text_matches_no_match() {
1706        let pats = WhatsAppChannel::compile_mention_patterns(&["@?ZeroClaw".into()]);
1707        assert!(!WhatsAppChannel::text_matches_patterns(
1708            &pats,
1709            "Hello @otherbot"
1710        ));
1711        assert!(!WhatsAppChannel::text_matches_patterns(
1712            &pats,
1713            "Hello world"
1714        ));
1715    }
1716
1717    #[test]
1718    fn whatsapp_text_matches_phone_pattern() {
1719        let pats = WhatsAppChannel::compile_mention_patterns(&[r"\+?15555550123".into()]);
1720        assert!(WhatsAppChannel::text_matches_patterns(
1721            &pats,
1722            "Hey +15555550123 help"
1723        ));
1724        assert!(WhatsAppChannel::text_matches_patterns(
1725            &pats,
1726            "Hey 15555550123 help"
1727        ));
1728        assert!(!WhatsAppChannel::text_matches_patterns(
1729            &pats,
1730            "Hey +19999999999 help"
1731        ));
1732    }
1733
1734    #[test]
1735    fn whatsapp_text_matches_multiple_patterns() {
1736        let pats = WhatsAppChannel::compile_mention_patterns(&[
1737            "@?ZeroClaw".into(),
1738            r"\+?15555550123".into(),
1739        ]);
1740        assert!(WhatsAppChannel::text_matches_patterns(
1741            &pats,
1742            "Hello @ZeroClaw"
1743        ));
1744        assert!(WhatsAppChannel::text_matches_patterns(
1745            &pats,
1746            "Hey +15555550123"
1747        ));
1748        assert!(!WhatsAppChannel::text_matches_patterns(
1749            &pats,
1750            "Hello world"
1751        ));
1752    }
1753
1754    #[test]
1755    fn whatsapp_text_matches_empty_patterns() {
1756        let pats: Vec<Regex> = vec![];
1757        assert!(!WhatsAppChannel::text_matches_patterns(
1758            &pats,
1759            "Hello @ZeroClaw"
1760        ));
1761    }
1762
1763    // ── builder tests ──
1764
1765    #[test]
1766    fn whatsapp_with_group_mention_patterns_compiles() {
1767        let ch = WhatsAppChannel::new(
1768            "tok".into(),
1769            "123".into(),
1770            "ver".into(),
1771            "whatsapp_test_alias",
1772            Arc::new(Vec::new),
1773        )
1774        .with_group_mention_patterns(vec!["@?bot".into()]);
1775        assert_eq!(ch.group_mention_patterns.len(), 1);
1776        assert!(ch.dm_mention_patterns.is_empty());
1777    }
1778
1779    #[test]
1780    fn whatsapp_with_dm_mention_patterns_compiles() {
1781        let ch = WhatsAppChannel::new(
1782            "tok".into(),
1783            "123".into(),
1784            "ver".into(),
1785            "whatsapp_test_alias",
1786            Arc::new(Vec::new),
1787        )
1788        .with_dm_mention_patterns(vec!["@?bot".into()]);
1789        assert_eq!(ch.dm_mention_patterns.len(), 1);
1790        assert!(ch.group_mention_patterns.is_empty());
1791    }
1792
1793    #[test]
1794    fn whatsapp_default_no_mention_patterns() {
1795        let ch = WhatsAppChannel::new(
1796            "tok".into(),
1797            "123".into(),
1798            "ver".into(),
1799            "whatsapp_test_alias",
1800            Arc::new(Vec::new),
1801        );
1802        assert!(ch.dm_mention_patterns.is_empty());
1803        assert!(ch.group_mention_patterns.is_empty());
1804    }
1805
1806    // ── mention_patterns integration with parse_webhook_payload ──
1807
1808    /// Helper: build a group message payload with optional context.group_id.
1809    fn group_msg(from: &str, ts: &str, body: &str) -> serde_json::Value {
1810        serde_json::json!({
1811            "from": from,
1812            "timestamp": ts,
1813            "type": "text",
1814            "text": { "body": body },
1815            "context": { "group_id": "120363012345678901@g.us" }
1816        })
1817    }
1818
1819    /// Helper: build a DM message payload (no group_id).
1820    fn dm_msg(from: &str, ts: &str, body: &str) -> serde_json::Value {
1821        serde_json::json!({
1822            "from": from,
1823            "timestamp": ts,
1824            "type": "text",
1825            "text": { "body": body }
1826        })
1827    }
1828
1829    #[test]
1830    fn whatsapp_is_group_message_with_group_id() {
1831        let msg = group_msg("111", "1", "Hello");
1832        assert!(WhatsAppChannel::is_group_message(&msg));
1833    }
1834
1835    #[test]
1836    fn whatsapp_is_group_message_without_context() {
1837        let msg = dm_msg("111", "1", "Hello");
1838        assert!(!WhatsAppChannel::is_group_message(&msg));
1839    }
1840
1841    #[test]
1842    fn whatsapp_is_group_message_empty_group_id() {
1843        let msg = serde_json::json!({
1844            "from": "111",
1845            "timestamp": "1",
1846            "type": "text",
1847            "text": { "body": "Hi" },
1848            "context": { "group_id": "" }
1849        });
1850        assert!(!WhatsAppChannel::is_group_message(&msg));
1851    }
1852
1853    #[test]
1854    fn whatsapp_group_mention_rejects_group_message_without_match() {
1855        let ch = WhatsAppChannel::new(
1856            "test-token".into(),
1857            "123456789".into(),
1858            "verify-me".into(),
1859            "whatsapp_test_alias",
1860            Arc::new(|| vec!["*".into()]),
1861        )
1862        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
1863        let payload = serde_json::json!({
1864            "entry": [{
1865                "changes": [{
1866                    "value": {
1867                        "messages": [group_msg("111", "1", "Hello without mention")]
1868                    }
1869                }]
1870            }]
1871        });
1872        let msgs = ch.parse_webhook_payload(&payload);
1873        assert!(
1874            msgs.is_empty(),
1875            "Should reject group messages without mention"
1876        );
1877    }
1878
1879    #[test]
1880    fn whatsapp_group_mention_dm_passes_through_without_match() {
1881        // group_mention_patterns configured but DMs should pass through
1882        let ch = WhatsAppChannel::new(
1883            "test-token".into(),
1884            "123456789".into(),
1885            "verify-me".into(),
1886            "whatsapp_test_alias",
1887            Arc::new(|| vec!["*".into()]),
1888        )
1889        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
1890        let payload = serde_json::json!({
1891            "entry": [{
1892                "changes": [{
1893                    "value": {
1894                        "messages": [dm_msg("111", "1", "Hello without mention")]
1895                    }
1896                }]
1897            }]
1898        });
1899        let msgs = ch.parse_webhook_payload(&payload);
1900        assert_eq!(
1901            msgs.len(),
1902            1,
1903            "DMs should pass through when only group patterns are set"
1904        );
1905        assert_eq!(msgs[0].content, "Hello without mention");
1906    }
1907
1908    #[test]
1909    fn whatsapp_group_mention_admits_and_preserves_in_group() {
1910        let ch = WhatsAppChannel::new(
1911            "test-token".into(),
1912            "123456789".into(),
1913            "verify-me".into(),
1914            "whatsapp_test_alias",
1915            Arc::new(|| vec!["*".into()]),
1916        )
1917        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
1918        let payload = serde_json::json!({
1919            "entry": [{
1920                "changes": [{
1921                    "value": {
1922                        "messages": [group_msg("111", "1", "@ZeroClaw what is the weather?")]
1923                    }
1924                }]
1925            }]
1926        });
1927        let msgs = ch.parse_webhook_payload(&payload);
1928        assert_eq!(msgs.len(), 1);
1929        assert_eq!(msgs[0].content, "@ZeroClaw what is the weather?");
1930    }
1931
1932    #[test]
1933    fn whatsapp_group_mention_preserves_mid_sentence_mention() {
1934        let ch = WhatsAppChannel::new(
1935            "test-token".into(),
1936            "123456789".into(),
1937            "verify-me".into(),
1938            "whatsapp_test_alias",
1939            Arc::new(|| vec!["*".into()]),
1940        )
1941        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
1942        let payload = serde_json::json!({
1943            "entry": [{
1944                "changes": [{
1945                    "value": {
1946                        "messages": [group_msg("111", "1", "Hey @ZeroClaw tell me a joke")]
1947                    }
1948                }]
1949            }]
1950        });
1951        let msgs = ch.parse_webhook_payload(&payload);
1952        assert_eq!(msgs.len(), 1);
1953        assert_eq!(msgs[0].content, "Hey @ZeroClaw tell me a joke");
1954    }
1955
1956    #[test]
1957    fn whatsapp_group_mention_admits_mention_only_group_message() {
1958        let ch = WhatsAppChannel::new(
1959            "test-token".into(),
1960            "123456789".into(),
1961            "verify-me".into(),
1962            "whatsapp_test_alias",
1963            Arc::new(|| vec!["*".into()]),
1964        )
1965        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
1966        let payload = serde_json::json!({
1967            "entry": [{
1968                "changes": [{
1969                    "value": {
1970                        "messages": [group_msg("111", "1", "@ZeroClaw")]
1971                    }
1972                }]
1973            }]
1974        });
1975        let msgs = ch.parse_webhook_payload(&payload);
1976        assert_eq!(msgs.len(), 1);
1977        assert_eq!(msgs[0].content, "@ZeroClaw");
1978    }
1979
1980    #[test]
1981    fn whatsapp_group_mention_case_insensitive_group_match() {
1982        let ch = WhatsAppChannel::new(
1983            "test-token".into(),
1984            "123456789".into(),
1985            "verify-me".into(),
1986            "whatsapp_test_alias",
1987            Arc::new(|| vec!["*".into()]),
1988        )
1989        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
1990        let payload = serde_json::json!({
1991            "entry": [{
1992                "changes": [{
1993                    "value": {
1994                        "messages": [group_msg("111", "1", "@zeroclaw status")]
1995                    }
1996                }]
1997            }]
1998        });
1999        let msgs = ch.parse_webhook_payload(&payload);
2000        assert_eq!(msgs.len(), 1);
2001        assert_eq!(msgs[0].content, "@zeroclaw status");
2002    }
2003
2004    #[test]
2005    fn whatsapp_no_patterns_passes_all_group_messages() {
2006        let ch = WhatsAppChannel::new(
2007            "tok".into(),
2008            "123".into(),
2009            "ver".into(),
2010            "whatsapp_test_alias",
2011            Arc::new(|| vec!["*".into()]),
2012        );
2013        let payload = serde_json::json!({
2014            "entry": [{
2015                "changes": [{
2016                    "value": {
2017                        "messages": [group_msg("111", "1", "Hello without mention")]
2018                    }
2019                }]
2020            }]
2021        });
2022        let msgs = ch.parse_webhook_payload(&payload);
2023        assert_eq!(msgs.len(), 1);
2024        assert_eq!(msgs[0].content, "Hello without mention");
2025    }
2026
2027    #[test]
2028    fn whatsapp_group_mention_mixed_group_messages() {
2029        let ch = WhatsAppChannel::new(
2030            "test-token".into(),
2031            "123456789".into(),
2032            "verify-me".into(),
2033            "whatsapp_test_alias",
2034            Arc::new(|| vec!["*".into()]),
2035        )
2036        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
2037        let payload = serde_json::json!({
2038            "entry": [{
2039                "changes": [{
2040                    "value": {
2041                        "messages": [
2042                            group_msg("111", "1", "No mention here"),
2043                            group_msg("222", "2", "@ZeroClaw help me"),
2044                            group_msg("333", "3", "Also no mention")
2045                        ]
2046                    }
2047                }]
2048            }]
2049        });
2050        let msgs = ch.parse_webhook_payload(&payload);
2051        assert_eq!(msgs.len(), 1);
2052        assert_eq!(msgs[0].content, "@ZeroClaw help me");
2053        assert_eq!(msgs[0].sender, "+222");
2054    }
2055
2056    #[test]
2057    fn whatsapp_group_mention_phone_pattern_in_group() {
2058        let ch = WhatsAppChannel::new(
2059            "tok".into(),
2060            "123".into(),
2061            "ver".into(),
2062            "whatsapp_test_alias",
2063            Arc::new(|| vec!["*".into()]),
2064        )
2065        .with_group_mention_patterns(vec![r"\+?15555550123".into()]);
2066        let payload = serde_json::json!({
2067            "entry": [{
2068                "changes": [{
2069                    "value": {
2070                        "messages": [group_msg("111", "1", "+15555550123 tell me a joke")]
2071                    }
2072                }]
2073            }]
2074        });
2075        let msgs = ch.parse_webhook_payload(&payload);
2076        assert_eq!(msgs.len(), 1);
2077        assert_eq!(msgs[0].content, "+15555550123 tell me a joke");
2078    }
2079
2080    #[test]
2081    fn whatsapp_group_mention_dm_not_stripped() {
2082        // DMs should not have group mention patterns applied
2083        let ch = WhatsAppChannel::new(
2084            "test-token".into(),
2085            "123456789".into(),
2086            "verify-me".into(),
2087            "whatsapp_test_alias",
2088            Arc::new(|| vec!["*".into()]),
2089        )
2090        .with_group_mention_patterns(vec!["@?ZeroClaw".into()]);
2091        let payload = serde_json::json!({
2092            "entry": [{
2093                "changes": [{
2094                    "value": {
2095                        "messages": [dm_msg("111", "1", "@ZeroClaw what is the weather?")]
2096                    }
2097                }]
2098            }]
2099        });
2100        let msgs = ch.parse_webhook_payload(&payload);
2101        assert_eq!(msgs.len(), 1);
2102        assert_eq!(
2103            msgs[0].content, "@ZeroClaw what is the weather?",
2104            "DM content should not be stripped by group patterns"
2105        );
2106    }
2107
2108    // ── dm_mention_patterns integration tests ──
2109
2110    #[test]
2111    fn whatsapp_dm_mention_rejects_dm_without_match() {
2112        let ch = WhatsAppChannel::new(
2113            "test-token".into(),
2114            "123456789".into(),
2115            "verify-me".into(),
2116            "whatsapp_test_alias",
2117            Arc::new(|| vec!["*".into()]),
2118        )
2119        .with_dm_mention_patterns(vec!["@?ZeroClaw".into()]);
2120        let payload = serde_json::json!({
2121            "entry": [{
2122                "changes": [{
2123                    "value": {
2124                        "messages": [dm_msg("111", "1", "Hello without mention")]
2125                    }
2126                }]
2127            }]
2128        });
2129        let msgs = ch.parse_webhook_payload(&payload);
2130        assert!(msgs.is_empty(), "Should reject DMs without mention");
2131    }
2132
2133    #[test]
2134    fn whatsapp_dm_mention_admits_and_preserves_in_dm() {
2135        let ch = WhatsAppChannel::new(
2136            "test-token".into(),
2137            "123456789".into(),
2138            "verify-me".into(),
2139            "whatsapp_test_alias",
2140            Arc::new(|| vec!["*".into()]),
2141        )
2142        .with_dm_mention_patterns(vec!["@?ZeroClaw".into()]);
2143        let payload = serde_json::json!({
2144            "entry": [{
2145                "changes": [{
2146                    "value": {
2147                        "messages": [dm_msg("111", "1", "@ZeroClaw what is the weather?")]
2148                    }
2149                }]
2150            }]
2151        });
2152        let msgs = ch.parse_webhook_payload(&payload);
2153        assert_eq!(msgs.len(), 1);
2154        assert_eq!(msgs[0].content, "@ZeroClaw what is the weather?");
2155    }
2156
2157    #[test]
2158    fn whatsapp_dm_mention_group_passes_through() {
2159        // dm_mention_patterns configured but group messages should pass through
2160        let ch = WhatsAppChannel::new(
2161            "test-token".into(),
2162            "123456789".into(),
2163            "verify-me".into(),
2164            "whatsapp_test_alias",
2165            Arc::new(|| vec!["*".into()]),
2166        )
2167        .with_dm_mention_patterns(vec!["@?ZeroClaw".into()]);
2168        let payload = serde_json::json!({
2169            "entry": [{
2170                "changes": [{
2171                    "value": {
2172                        "messages": [group_msg("111", "1", "Hello without mention")]
2173                    }
2174                }]
2175            }]
2176        });
2177        let msgs = ch.parse_webhook_payload(&payload);
2178        assert_eq!(
2179            msgs.len(),
2180            1,
2181            "Group messages should pass through when only DM patterns are set"
2182        );
2183        assert_eq!(msgs[0].content, "Hello without mention");
2184    }
2185
2186    #[test]
2187    fn approval_timeout_defaults_to_300_and_is_overridable() {
2188        let ch = WhatsAppChannel::new(
2189            "test-token".into(),
2190            "123456789".into(),
2191            "verify-me".into(),
2192            "whatsapp_test_alias",
2193            Arc::new(|| vec!["+1234567890".into()]),
2194        );
2195        assert_eq!(ch.approval_timeout_secs, 300);
2196        let ch2 = ch.with_approval_timeout_secs(60);
2197        assert_eq!(ch2.approval_timeout_secs, 60);
2198    }
2199
2200    #[tokio::test]
2201    async fn pending_approvals_are_shared_across_instances() {
2202        // Two independent WhatsAppChannel instances — the orchestrator's and
2203        // the gateway's — must see the same pending-approvals map so that a
2204        // reply intercepted on one instance resolves a token registered on
2205        // the other. Without the module-level static this test would fail.
2206        let orchestrator_ch = WhatsAppChannel::new(
2207            "test-token".into(),
2208            "123456789".into(),
2209            "verify-me".into(),
2210            "whatsapp_test_alias",
2211            Arc::new(|| vec!["+1234567890".into()]),
2212        );
2213        let gateway_ch = WhatsAppChannel::new(
2214            "test-token".into(),
2215            "123456789".into(),
2216            "verify-me".into(),
2217            "whatsapp_test_alias",
2218            Arc::new(|| vec!["+1234567890".into()]),
2219        );
2220
2221        let (tx, _rx) = oneshot::channel::<ChannelApprovalResponse>();
2222        {
2223            let mut map = orchestrator_ch.pending_approvals().lock().await;
2224            map.insert("test_share_tok".to_string(), tx);
2225        }
2226        {
2227            let map = gateway_ch.pending_approvals().lock().await;
2228            assert!(
2229                map.contains_key("test_share_tok"),
2230                "gateway instance must see orchestrator's registration"
2231            );
2232        }
2233        // Cleanup so later tests aren't polluted by this entry.
2234        gateway_ch
2235            .pending_approvals()
2236            .lock()
2237            .await
2238            .remove("test_share_tok");
2239    }
2240}