Skip to main content

construct/channels/
whatsapp.rs

1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use regex::Regex;
4use uuid::Uuid;
5
6/// `WhatsApp` channel — uses `WhatsApp` Business Cloud API
7///
8/// This channel operates in webhook mode (push-based) rather than polling.
9/// Messages are received via the gateway's `/whatsapp` webhook endpoint.
10/// The `listen` method here is a no-op placeholder; actual message handling
11/// happens in the gateway when Meta sends webhook events.
12fn ensure_https(url: &str) -> anyhow::Result<()> {
13    if !url.starts_with("https://") {
14        anyhow::bail!(
15            "Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"
16        );
17    }
18    Ok(())
19}
20
21///
22/// # Runtime Negotiation
23///
24/// This Cloud API channel is automatically selected when `phone_number_id` is set in the config.
25/// Use `WhatsAppWebChannel` (with `session_path`) for native Web mode.
26pub struct WhatsAppChannel {
27    access_token: String,
28    endpoint_id: String,
29    verify_token: String,
30    allowed_numbers: Vec<String>,
31    /// Per-channel proxy URL override.
32    proxy_url: Option<String>,
33    /// Compiled mention patterns for DM mention gating.
34    dm_mention_patterns: Vec<Regex>,
35    /// Compiled mention patterns for group-chat mention gating.
36    group_mention_patterns: Vec<Regex>,
37}
38
39impl WhatsAppChannel {
40    pub fn new(
41        access_token: String,
42        endpoint_id: String,
43        verify_token: String,
44        allowed_numbers: Vec<String>,
45    ) -> Self {
46        Self {
47            access_token,
48            endpoint_id,
49            verify_token,
50            allowed_numbers,
51            proxy_url: None,
52            dm_mention_patterns: Vec::new(),
53            group_mention_patterns: Vec::new(),
54        }
55    }
56
57    /// Set a per-channel proxy URL that overrides the global proxy config.
58    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
59        self.proxy_url = proxy_url;
60        self
61    }
62
63    /// Set mention patterns for DM mention gating.
64    /// Each pattern string is compiled as a case-insensitive regex.
65    /// Invalid patterns are logged and skipped.
66    pub fn with_dm_mention_patterns(mut self, patterns: Vec<String>) -> Self {
67        self.dm_mention_patterns = Self::compile_mention_patterns(&patterns);
68        self
69    }
70
71    /// Set mention patterns for group-chat mention gating.
72    /// Each pattern string is compiled as a case-insensitive regex.
73    /// Invalid patterns are logged and skipped.
74    pub fn with_group_mention_patterns(mut self, patterns: Vec<String>) -> Self {
75        self.group_mention_patterns = Self::compile_mention_patterns(&patterns);
76        self
77    }
78
79    /// Compile raw pattern strings into case-insensitive regexes.
80    /// Invalid or excessively large patterns are logged and skipped.
81    pub(crate) fn compile_mention_patterns(patterns: &[String]) -> Vec<Regex> {
82        patterns
83            .iter()
84            .filter_map(|p| {
85                let trimmed = p.trim();
86                if trimmed.is_empty() {
87                    return None;
88                }
89                match regex::RegexBuilder::new(trimmed)
90                    .case_insensitive(true)
91                    .size_limit(1 << 16) // 64 KiB — guard against ReDoS
92                    .build()
93                {
94                    Ok(re) => Some(re),
95                    Err(e) => {
96                        tracing::warn!(
97                            "WhatsApp: ignoring invalid mention_pattern {trimmed:?}: {e}"
98                        );
99                        None
100                    }
101                }
102            })
103            .collect()
104    }
105
106    /// Check whether `text` matches any pattern in the given slice.
107    pub(crate) fn text_matches_patterns(patterns: &[Regex], text: &str) -> bool {
108        patterns.iter().any(|re| re.is_match(text))
109    }
110
111    /// Strip all pattern matches from `text`, collapse whitespace,
112    /// and return `None` if the result is empty.
113    pub(crate) fn strip_patterns(patterns: &[Regex], text: &str) -> Option<String> {
114        let mut result = text.to_string();
115        for re in patterns {
116            result = re.replace_all(&result, " ").into_owned();
117        }
118        let normalized = result.split_whitespace().collect::<Vec<_>>().join(" ");
119        (!normalized.is_empty()).then_some(normalized)
120    }
121
122    /// Apply mention-pattern gating for a message.
123    ///
124    /// Selects the appropriate pattern set based on `is_group` and applies
125    /// mention gating: when patterns are non-empty, messages that do not
126    /// match any pattern are dropped (`None`); messages that match have
127    /// the matched fragments stripped.
128    /// When the applicable pattern set is empty the original content is
129    /// returned unchanged.
130    pub(crate) fn apply_mention_gating(
131        dm_patterns: &[Regex],
132        group_patterns: &[Regex],
133        content: &str,
134        is_group: bool,
135    ) -> Option<String> {
136        let patterns = if is_group {
137            group_patterns
138        } else {
139            dm_patterns
140        };
141        if patterns.is_empty() {
142            return Some(content.to_string());
143        }
144        if !Self::text_matches_patterns(patterns, content) {
145            return None;
146        }
147        Self::strip_patterns(patterns, content)
148    }
149
150    /// Detect group messages in the WhatsApp Cloud API webhook payload.
151    ///
152    /// A message is considered a group message when it carries a `context`
153    /// object containing a non-empty `group_id` field.
154    fn is_group_message(msg: &serde_json::Value) -> bool {
155        msg.get("context")
156            .and_then(|ctx| ctx.get("group_id"))
157            .and_then(|g| g.as_str())
158            .is_some_and(|s| !s.is_empty())
159    }
160
161    fn http_client(&self) -> reqwest::Client {
162        crate::config::build_channel_proxy_client("channel.whatsapp", self.proxy_url.as_deref())
163    }
164
165    /// Check if a phone number is allowed (E.164 format: +1234567890)
166    fn is_number_allowed(&self, phone: &str) -> bool {
167        self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
168    }
169
170    /// Get the verify token for webhook verification
171    pub fn verify_token(&self) -> &str {
172        &self.verify_token
173    }
174
175    /// Parse an incoming webhook payload from Meta and extract messages
176    pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
177        let mut messages = Vec::new();
178
179        // WhatsApp Cloud API webhook structure:
180        // { "object": "whatsapp_business_account", "entry": [...] }
181        let Some(entries) = payload.get("entry").and_then(|e| e.as_array()) else {
182            return messages;
183        };
184
185        for entry in entries {
186            let Some(changes) = entry.get("changes").and_then(|c| c.as_array()) else {
187                continue;
188            };
189
190            for change in changes {
191                let Some(value) = change.get("value") else {
192                    continue;
193                };
194
195                // Extract messages array
196                let Some(msgs) = value.get("messages").and_then(|m| m.as_array()) else {
197                    continue;
198                };
199
200                for msg in msgs {
201                    // Get sender phone number
202                    let Some(from) = msg.get("from").and_then(|f| f.as_str()) else {
203                        continue;
204                    };
205
206                    // Check allowlist
207                    let normalized_from = if from.starts_with('+') {
208                        from.to_string()
209                    } else {
210                        format!("+{from}")
211                    };
212
213                    if !self.is_number_allowed(&normalized_from) {
214                        tracing::warn!(
215                            "WhatsApp: ignoring message from unauthorized number: {normalized_from}. \
216                            Add to channels.whatsapp.allowed_numbers in config.toml, \
217                            or run `construct onboard --channels-only` to configure interactively."
218                        );
219                        continue;
220                    }
221
222                    // Extract text content (support text messages only for now)
223                    let content = if let Some(text_obj) = msg.get("text") {
224                        text_obj
225                            .get("body")
226                            .and_then(|b| b.as_str())
227                            .unwrap_or("")
228                            .to_string()
229                    } else {
230                        // Could be image, audio, etc. — skip for now
231                        tracing::debug!("WhatsApp: skipping non-text message from {from}");
232                        continue;
233                    };
234
235                    if content.is_empty() {
236                        continue;
237                    }
238
239                    // Mention-pattern gating: apply dm_mention_patterns for
240                    // DMs and group_mention_patterns for groups. When the
241                    // applicable pattern set is non-empty, messages without a
242                    // match are dropped and matched fragments are stripped.
243                    let is_group = Self::is_group_message(msg);
244                    let content = match Self::apply_mention_gating(
245                        &self.dm_mention_patterns,
246                        &self.group_mention_patterns,
247                        &content,
248                        is_group,
249                    ) {
250                        Some(c) => c,
251                        None => {
252                            tracing::debug!(
253                                "WhatsApp: message from {from} did not match mention patterns, dropping"
254                            );
255                            continue;
256                        }
257                    };
258
259                    // Get timestamp
260                    let timestamp = msg
261                        .get("timestamp")
262                        .and_then(|t| t.as_str())
263                        .and_then(|t| t.parse::<u64>().ok())
264                        .unwrap_or_else(|| {
265                            std::time::SystemTime::now()
266                                .duration_since(std::time::UNIX_EPOCH)
267                                .unwrap_or_default()
268                                .as_secs()
269                        });
270
271                    messages.push(ChannelMessage {
272                        id: Uuid::new_v4().to_string(),
273                        reply_target: normalized_from.clone(),
274                        sender: normalized_from,
275                        content,
276                        channel: "whatsapp".to_string(),
277                        timestamp,
278                        thread_ts: None,
279                        interruption_scope_id: None,
280                        attachments: vec![],
281                    });
282                }
283            }
284        }
285
286        messages
287    }
288}
289
290#[async_trait]
291impl Channel for WhatsAppChannel {
292    fn name(&self) -> &str {
293        "whatsapp"
294    }
295
296    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
297        // WhatsApp Cloud API: POST to /v18.0/{phone_number_id}/messages
298        let url = format!(
299            "https://graph.facebook.com/v18.0/{}/messages",
300            self.endpoint_id
301        );
302
303        // Normalize recipient (remove leading + if present for API)
304        let to = message
305            .recipient
306            .strip_prefix('+')
307            .unwrap_or(&message.recipient);
308
309        let body = serde_json::json!({
310            "messaging_product": "whatsapp",
311            "recipient_type": "individual",
312            "to": to,
313            "type": "text",
314            "text": {
315                "preview_url": false,
316                "body": message.content
317            }
318        });
319
320        ensure_https(&url)?;
321
322        let resp = self
323            .http_client()
324            .post(&url)
325            .bearer_auth(&self.access_token)
326            .header("Content-Type", "application/json")
327            .json(&body)
328            .send()
329            .await?;
330
331        if !resp.status().is_success() {
332            let status = resp.status();
333            let error_body = resp.text().await.unwrap_or_default();
334            tracing::error!("WhatsApp send failed: {status} — {error_body}");
335            anyhow::bail!("WhatsApp API error: {status}");
336        }
337
338        Ok(())
339    }
340
341    async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
342        // WhatsApp uses webhooks (push-based), not polling.
343        // Messages are received via the gateway's /whatsapp endpoint.
344        // This method keeps the channel "alive" but doesn't actively poll.
345        tracing::info!(
346            "WhatsApp channel active (webhook mode). \
347            Configure Meta webhook to POST to your gateway's /whatsapp endpoint."
348        );
349
350        // Keep the task alive — it will be cancelled when the channel shuts down
351        loop {
352            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
353        }
354    }
355
356    async fn health_check(&self) -> bool {
357        // Check if we can reach the WhatsApp API
358        let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
359
360        if ensure_https(&url).is_err() {
361            return false;
362        }
363
364        self.http_client()
365            .get(&url)
366            .bearer_auth(&self.access_token)
367            .send()
368            .await
369            .map(|r| r.status().is_success())
370            .unwrap_or(false)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn make_channel() -> WhatsAppChannel {
379        WhatsAppChannel::new(
380            "test-token".into(),
381            "123456789".into(),
382            "verify-me".into(),
383            vec!["+1234567890".into()],
384        )
385    }
386
387    #[test]
388    fn whatsapp_channel_name() {
389        let ch = make_channel();
390        assert_eq!(ch.name(), "whatsapp");
391    }
392
393    #[test]
394    fn whatsapp_verify_token() {
395        let ch = make_channel();
396        assert_eq!(ch.verify_token(), "verify-me");
397    }
398
399    #[test]
400    fn whatsapp_number_allowed_exact() {
401        let ch = make_channel();
402        assert!(ch.is_number_allowed("+1234567890"));
403        assert!(!ch.is_number_allowed("+9876543210"));
404    }
405
406    #[test]
407    fn whatsapp_number_allowed_wildcard() {
408        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
409        assert!(ch.is_number_allowed("+1234567890"));
410        assert!(ch.is_number_allowed("+9999999999"));
411    }
412
413    #[test]
414    fn whatsapp_number_denied_empty() {
415        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec![]);
416        assert!(!ch.is_number_allowed("+1234567890"));
417    }
418
419    #[test]
420    fn whatsapp_parse_empty_payload() {
421        let ch = make_channel();
422        let payload = serde_json::json!({});
423        let msgs = ch.parse_webhook_payload(&payload);
424        assert!(msgs.is_empty());
425    }
426
427    #[test]
428    fn whatsapp_parse_valid_text_message() {
429        let ch = make_channel();
430        let payload = serde_json::json!({
431            "object": "whatsapp_business_account",
432            "entry": [{
433                "id": "123",
434                "changes": [{
435                    "value": {
436                        "messaging_product": "whatsapp",
437                        "metadata": {
438                            "display_phone_number": "15551234567",
439                            "phone_number_id": "123456789"
440                        },
441                        "messages": [{
442                            "from": "1234567890",
443                            "id": "wamid.xxx",
444                            "timestamp": "1699999999",
445                            "type": "text",
446                            "text": {
447                                "body": "Hello Construct!"
448                            }
449                        }]
450                    },
451                    "field": "messages"
452                }]
453            }]
454        });
455
456        let msgs = ch.parse_webhook_payload(&payload);
457        assert_eq!(msgs.len(), 1);
458        assert_eq!(msgs[0].sender, "+1234567890");
459        assert_eq!(msgs[0].content, "Hello Construct!");
460        assert_eq!(msgs[0].channel, "whatsapp");
461        assert_eq!(msgs[0].timestamp, 1_699_999_999);
462    }
463
464    #[test]
465    fn whatsapp_parse_unauthorized_number() {
466        let ch = make_channel();
467        let payload = serde_json::json!({
468            "object": "whatsapp_business_account",
469            "entry": [{
470                "changes": [{
471                    "value": {
472                        "messages": [{
473                            "from": "9999999999",
474                            "timestamp": "1699999999",
475                            "type": "text",
476                            "text": { "body": "Spam" }
477                        }]
478                    }
479                }]
480            }]
481        });
482
483        let msgs = ch.parse_webhook_payload(&payload);
484        assert!(msgs.is_empty(), "Unauthorized numbers should be filtered");
485    }
486
487    #[test]
488    fn whatsapp_parse_non_text_message_skipped() {
489        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
490        let payload = serde_json::json!({
491            "entry": [{
492                "changes": [{
493                    "value": {
494                        "messages": [{
495                            "from": "1234567890",
496                            "timestamp": "1699999999",
497                            "type": "image",
498                            "image": { "id": "img123" }
499                        }]
500                    }
501                }]
502            }]
503        });
504
505        let msgs = ch.parse_webhook_payload(&payload);
506        assert!(msgs.is_empty(), "Non-text messages should be skipped");
507    }
508
509    #[test]
510    fn whatsapp_parse_multiple_messages() {
511        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
512        let payload = serde_json::json!({
513            "entry": [{
514                "changes": [{
515                    "value": {
516                        "messages": [
517                            { "from": "111", "timestamp": "1", "type": "text", "text": { "body": "First" } },
518                            { "from": "222", "timestamp": "2", "type": "text", "text": { "body": "Second" } }
519                        ]
520                    }
521                }]
522            }]
523        });
524
525        let msgs = ch.parse_webhook_payload(&payload);
526        assert_eq!(msgs.len(), 2);
527        assert_eq!(msgs[0].content, "First");
528        assert_eq!(msgs[1].content, "Second");
529    }
530
531    #[test]
532    fn whatsapp_parse_normalizes_phone_with_plus() {
533        let ch = WhatsAppChannel::new(
534            "tok".into(),
535            "123".into(),
536            "ver".into(),
537            vec!["+1234567890".into()],
538        );
539        // API sends without +, but we normalize to +
540        let payload = serde_json::json!({
541            "entry": [{
542                "changes": [{
543                    "value": {
544                        "messages": [{
545                            "from": "1234567890",
546                            "timestamp": "1",
547                            "type": "text",
548                            "text": { "body": "Hi" }
549                        }]
550                    }
551                }]
552            }]
553        });
554
555        let msgs = ch.parse_webhook_payload(&payload);
556        assert_eq!(msgs.len(), 1);
557        assert_eq!(msgs[0].sender, "+1234567890");
558    }
559
560    #[test]
561    fn whatsapp_empty_text_skipped() {
562        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
563        let payload = serde_json::json!({
564            "entry": [{
565                "changes": [{
566                    "value": {
567                        "messages": [{
568                            "from": "111",
569                            "timestamp": "1",
570                            "type": "text",
571                            "text": { "body": "" }
572                        }]
573                    }
574                }]
575            }]
576        });
577
578        let msgs = ch.parse_webhook_payload(&payload);
579        assert!(msgs.is_empty());
580    }
581
582    // ══════════════════════════════════════════════════════════
583    // EDGE CASES — Comprehensive coverage
584    // ══════════════════════════════════════════════════════════
585
586    #[test]
587    fn whatsapp_parse_missing_entry_array() {
588        let ch = make_channel();
589        let payload = serde_json::json!({
590            "object": "whatsapp_business_account"
591        });
592        let msgs = ch.parse_webhook_payload(&payload);
593        assert!(msgs.is_empty());
594    }
595
596    #[test]
597    fn whatsapp_parse_entry_not_array() {
598        let ch = make_channel();
599        let payload = serde_json::json!({
600            "entry": "not_an_array"
601        });
602        let msgs = ch.parse_webhook_payload(&payload);
603        assert!(msgs.is_empty());
604    }
605
606    #[test]
607    fn whatsapp_parse_missing_changes_array() {
608        let ch = make_channel();
609        let payload = serde_json::json!({
610            "entry": [{ "id": "123" }]
611        });
612        let msgs = ch.parse_webhook_payload(&payload);
613        assert!(msgs.is_empty());
614    }
615
616    #[test]
617    fn whatsapp_parse_changes_not_array() {
618        let ch = make_channel();
619        let payload = serde_json::json!({
620            "entry": [{
621                "changes": "not_an_array"
622            }]
623        });
624        let msgs = ch.parse_webhook_payload(&payload);
625        assert!(msgs.is_empty());
626    }
627
628    #[test]
629    fn whatsapp_parse_missing_value() {
630        let ch = make_channel();
631        let payload = serde_json::json!({
632            "entry": [{
633                "changes": [{ "field": "messages" }]
634            }]
635        });
636        let msgs = ch.parse_webhook_payload(&payload);
637        assert!(msgs.is_empty());
638    }
639
640    #[test]
641    fn whatsapp_parse_missing_messages_array() {
642        let ch = make_channel();
643        let payload = serde_json::json!({
644            "entry": [{
645                "changes": [{
646                    "value": {
647                        "metadata": {}
648                    }
649                }]
650            }]
651        });
652        let msgs = ch.parse_webhook_payload(&payload);
653        assert!(msgs.is_empty());
654    }
655
656    #[test]
657    fn whatsapp_parse_messages_not_array() {
658        let ch = make_channel();
659        let payload = serde_json::json!({
660            "entry": [{
661                "changes": [{
662                    "value": {
663                        "messages": "not_an_array"
664                    }
665                }]
666            }]
667        });
668        let msgs = ch.parse_webhook_payload(&payload);
669        assert!(msgs.is_empty());
670    }
671
672    #[test]
673    fn whatsapp_parse_missing_from_field() {
674        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
675        let payload = serde_json::json!({
676            "entry": [{
677                "changes": [{
678                    "value": {
679                        "messages": [{
680                            "timestamp": "1",
681                            "type": "text",
682                            "text": { "body": "No sender" }
683                        }]
684                    }
685                }]
686            }]
687        });
688        let msgs = ch.parse_webhook_payload(&payload);
689        assert!(msgs.is_empty(), "Messages without 'from' should be skipped");
690    }
691
692    #[test]
693    fn whatsapp_parse_missing_text_body() {
694        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
695        let payload = serde_json::json!({
696            "entry": [{
697                "changes": [{
698                    "value": {
699                        "messages": [{
700                            "from": "111",
701                            "timestamp": "1",
702                            "type": "text",
703                            "text": {}
704                        }]
705                    }
706                }]
707            }]
708        });
709        let msgs = ch.parse_webhook_payload(&payload);
710        assert!(
711            msgs.is_empty(),
712            "Messages with empty text object should be skipped"
713        );
714    }
715
716    #[test]
717    fn whatsapp_parse_null_text_body() {
718        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
719        let payload = serde_json::json!({
720            "entry": [{
721                "changes": [{
722                    "value": {
723                        "messages": [{
724                            "from": "111",
725                            "timestamp": "1",
726                            "type": "text",
727                            "text": { "body": null }
728                        }]
729                    }
730                }]
731            }]
732        });
733        let msgs = ch.parse_webhook_payload(&payload);
734        assert!(msgs.is_empty(), "Messages with null body should be skipped");
735    }
736
737    #[test]
738    fn whatsapp_parse_invalid_timestamp_uses_current() {
739        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
740        let payload = serde_json::json!({
741            "entry": [{
742                "changes": [{
743                    "value": {
744                        "messages": [{
745                            "from": "111",
746                            "timestamp": "not_a_number",
747                            "type": "text",
748                            "text": { "body": "Hello" }
749                        }]
750                    }
751                }]
752            }]
753        });
754        let msgs = ch.parse_webhook_payload(&payload);
755        assert_eq!(msgs.len(), 1);
756        // Timestamp should be current time (non-zero)
757        assert!(msgs[0].timestamp > 0);
758    }
759
760    #[test]
761    fn whatsapp_parse_missing_timestamp_uses_current() {
762        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
763        let payload = serde_json::json!({
764            "entry": [{
765                "changes": [{
766                    "value": {
767                        "messages": [{
768                            "from": "111",
769                            "type": "text",
770                            "text": { "body": "Hello" }
771                        }]
772                    }
773                }]
774            }]
775        });
776        let msgs = ch.parse_webhook_payload(&payload);
777        assert_eq!(msgs.len(), 1);
778        assert!(msgs[0].timestamp > 0);
779    }
780
781    #[test]
782    fn whatsapp_parse_multiple_entries() {
783        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
784        let payload = serde_json::json!({
785            "entry": [
786                {
787                    "changes": [{
788                        "value": {
789                            "messages": [{
790                                "from": "111",
791                                "timestamp": "1",
792                                "type": "text",
793                                "text": { "body": "Entry 1" }
794                            }]
795                        }
796                    }]
797                },
798                {
799                    "changes": [{
800                        "value": {
801                            "messages": [{
802                                "from": "222",
803                                "timestamp": "2",
804                                "type": "text",
805                                "text": { "body": "Entry 2" }
806                            }]
807                        }
808                    }]
809                }
810            ]
811        });
812        let msgs = ch.parse_webhook_payload(&payload);
813        assert_eq!(msgs.len(), 2);
814        assert_eq!(msgs[0].content, "Entry 1");
815        assert_eq!(msgs[1].content, "Entry 2");
816    }
817
818    #[test]
819    fn whatsapp_parse_multiple_changes() {
820        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
821        let payload = serde_json::json!({
822            "entry": [{
823                "changes": [
824                    {
825                        "value": {
826                            "messages": [{
827                                "from": "111",
828                                "timestamp": "1",
829                                "type": "text",
830                                "text": { "body": "Change 1" }
831                            }]
832                        }
833                    },
834                    {
835                        "value": {
836                            "messages": [{
837                                "from": "222",
838                                "timestamp": "2",
839                                "type": "text",
840                                "text": { "body": "Change 2" }
841                            }]
842                        }
843                    }
844                ]
845            }]
846        });
847        let msgs = ch.parse_webhook_payload(&payload);
848        assert_eq!(msgs.len(), 2);
849        assert_eq!(msgs[0].content, "Change 1");
850        assert_eq!(msgs[1].content, "Change 2");
851    }
852
853    #[test]
854    fn whatsapp_parse_status_update_ignored() {
855        // Status updates have "statuses" instead of "messages"
856        let ch = make_channel();
857        let payload = serde_json::json!({
858            "entry": [{
859                "changes": [{
860                    "value": {
861                        "statuses": [{
862                            "id": "wamid.xxx",
863                            "status": "delivered",
864                            "timestamp": "1699999999"
865                        }]
866                    }
867                }]
868            }]
869        });
870        let msgs = ch.parse_webhook_payload(&payload);
871        assert!(msgs.is_empty(), "Status updates should be ignored");
872    }
873
874    #[test]
875    fn whatsapp_parse_audio_message_skipped() {
876        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
877        let payload = serde_json::json!({
878            "entry": [{
879                "changes": [{
880                    "value": {
881                        "messages": [{
882                            "from": "111",
883                            "timestamp": "1",
884                            "type": "audio",
885                            "audio": { "id": "audio123", "mime_type": "audio/ogg" }
886                        }]
887                    }
888                }]
889            }]
890        });
891        let msgs = ch.parse_webhook_payload(&payload);
892        assert!(msgs.is_empty());
893    }
894
895    #[test]
896    fn whatsapp_parse_video_message_skipped() {
897        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
898        let payload = serde_json::json!({
899            "entry": [{
900                "changes": [{
901                    "value": {
902                        "messages": [{
903                            "from": "111",
904                            "timestamp": "1",
905                            "type": "video",
906                            "video": { "id": "video123" }
907                        }]
908                    }
909                }]
910            }]
911        });
912        let msgs = ch.parse_webhook_payload(&payload);
913        assert!(msgs.is_empty());
914    }
915
916    #[test]
917    fn whatsapp_parse_document_message_skipped() {
918        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
919        let payload = serde_json::json!({
920            "entry": [{
921                "changes": [{
922                    "value": {
923                        "messages": [{
924                            "from": "111",
925                            "timestamp": "1",
926                            "type": "document",
927                            "document": { "id": "doc123", "filename": "file.pdf" }
928                        }]
929                    }
930                }]
931            }]
932        });
933        let msgs = ch.parse_webhook_payload(&payload);
934        assert!(msgs.is_empty());
935    }
936
937    #[test]
938    fn whatsapp_parse_sticker_message_skipped() {
939        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
940        let payload = serde_json::json!({
941            "entry": [{
942                "changes": [{
943                    "value": {
944                        "messages": [{
945                            "from": "111",
946                            "timestamp": "1",
947                            "type": "sticker",
948                            "sticker": { "id": "sticker123" }
949                        }]
950                    }
951                }]
952            }]
953        });
954        let msgs = ch.parse_webhook_payload(&payload);
955        assert!(msgs.is_empty());
956    }
957
958    #[test]
959    fn whatsapp_parse_location_message_skipped() {
960        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
961        let payload = serde_json::json!({
962            "entry": [{
963                "changes": [{
964                    "value": {
965                        "messages": [{
966                            "from": "111",
967                            "timestamp": "1",
968                            "type": "location",
969                            "location": { "latitude": 40.7128, "longitude": -74.0060 }
970                        }]
971                    }
972                }]
973            }]
974        });
975        let msgs = ch.parse_webhook_payload(&payload);
976        assert!(msgs.is_empty());
977    }
978
979    #[test]
980    fn whatsapp_parse_contacts_message_skipped() {
981        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
982        let payload = serde_json::json!({
983            "entry": [{
984                "changes": [{
985                    "value": {
986                        "messages": [{
987                            "from": "111",
988                            "timestamp": "1",
989                            "type": "contacts",
990                            "contacts": [{ "name": { "formatted_name": "John" } }]
991                        }]
992                    }
993                }]
994            }]
995        });
996        let msgs = ch.parse_webhook_payload(&payload);
997        assert!(msgs.is_empty());
998    }
999
1000    #[test]
1001    fn whatsapp_parse_reaction_message_skipped() {
1002        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1003        let payload = serde_json::json!({
1004            "entry": [{
1005                "changes": [{
1006                    "value": {
1007                        "messages": [{
1008                            "from": "111",
1009                            "timestamp": "1",
1010                            "type": "reaction",
1011                            "reaction": { "message_id": "wamid.xxx", "emoji": "👍" }
1012                        }]
1013                    }
1014                }]
1015            }]
1016        });
1017        let msgs = ch.parse_webhook_payload(&payload);
1018        assert!(msgs.is_empty());
1019    }
1020
1021    #[test]
1022    fn whatsapp_parse_mixed_authorized_unauthorized() {
1023        let ch = WhatsAppChannel::new(
1024            "tok".into(),
1025            "123".into(),
1026            "ver".into(),
1027            vec!["+1111111111".into()],
1028        );
1029        let payload = serde_json::json!({
1030            "entry": [{
1031                "changes": [{
1032                    "value": {
1033                        "messages": [
1034                            { "from": "1111111111", "timestamp": "1", "type": "text", "text": { "body": "Allowed" } },
1035                            { "from": "9999999999", "timestamp": "2", "type": "text", "text": { "body": "Blocked" } },
1036                            { "from": "1111111111", "timestamp": "3", "type": "text", "text": { "body": "Also allowed" } }
1037                        ]
1038                    }
1039                }]
1040            }]
1041        });
1042        let msgs = ch.parse_webhook_payload(&payload);
1043        assert_eq!(msgs.len(), 2);
1044        assert_eq!(msgs[0].content, "Allowed");
1045        assert_eq!(msgs[1].content, "Also allowed");
1046    }
1047
1048    #[test]
1049    fn whatsapp_parse_unicode_message() {
1050        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1051        let payload = serde_json::json!({
1052            "entry": [{
1053                "changes": [{
1054                    "value": {
1055                        "messages": [{
1056                            "from": "111",
1057                            "timestamp": "1",
1058                            "type": "text",
1059                            "text": { "body": "Hello 👋 世界 🌍 مرحبا" }
1060                        }]
1061                    }
1062                }]
1063            }]
1064        });
1065        let msgs = ch.parse_webhook_payload(&payload);
1066        assert_eq!(msgs.len(), 1);
1067        assert_eq!(msgs[0].content, "Hello 👋 世界 🌍 مرحبا");
1068    }
1069
1070    #[test]
1071    fn whatsapp_parse_very_long_message() {
1072        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1073        let long_text = "A".repeat(10_000);
1074        let payload = serde_json::json!({
1075            "entry": [{
1076                "changes": [{
1077                    "value": {
1078                        "messages": [{
1079                            "from": "111",
1080                            "timestamp": "1",
1081                            "type": "text",
1082                            "text": { "body": long_text }
1083                        }]
1084                    }
1085                }]
1086            }]
1087        });
1088        let msgs = ch.parse_webhook_payload(&payload);
1089        assert_eq!(msgs.len(), 1);
1090        assert_eq!(msgs[0].content.len(), 10_000);
1091    }
1092
1093    #[test]
1094    fn whatsapp_parse_whitespace_only_message_skipped() {
1095        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1096        let payload = serde_json::json!({
1097            "entry": [{
1098                "changes": [{
1099                    "value": {
1100                        "messages": [{
1101                            "from": "111",
1102                            "timestamp": "1",
1103                            "type": "text",
1104                            "text": { "body": "   " }
1105                        }]
1106                    }
1107                }]
1108            }]
1109        });
1110        let msgs = ch.parse_webhook_payload(&payload);
1111        // Whitespace-only is NOT empty, so it passes through
1112        assert_eq!(msgs.len(), 1);
1113        assert_eq!(msgs[0].content, "   ");
1114    }
1115
1116    #[test]
1117    fn whatsapp_number_allowed_multiple_numbers() {
1118        let ch = WhatsAppChannel::new(
1119            "tok".into(),
1120            "123".into(),
1121            "ver".into(),
1122            vec![
1123                "+1111111111".into(),
1124                "+2222222222".into(),
1125                "+3333333333".into(),
1126            ],
1127        );
1128        assert!(ch.is_number_allowed("+1111111111"));
1129        assert!(ch.is_number_allowed("+2222222222"));
1130        assert!(ch.is_number_allowed("+3333333333"));
1131        assert!(!ch.is_number_allowed("+4444444444"));
1132    }
1133
1134    #[test]
1135    fn whatsapp_number_allowed_case_sensitive() {
1136        // Phone numbers should be exact match
1137        let ch = WhatsAppChannel::new(
1138            "tok".into(),
1139            "123".into(),
1140            "ver".into(),
1141            vec!["+1234567890".into()],
1142        );
1143        assert!(ch.is_number_allowed("+1234567890"));
1144        // Different number should not match
1145        assert!(!ch.is_number_allowed("+1234567891"));
1146    }
1147
1148    #[test]
1149    fn whatsapp_parse_phone_already_has_plus() {
1150        let ch = WhatsAppChannel::new(
1151            "tok".into(),
1152            "123".into(),
1153            "ver".into(),
1154            vec!["+1234567890".into()],
1155        );
1156        // If API sends with +, we should still handle it
1157        let payload = serde_json::json!({
1158            "entry": [{
1159                "changes": [{
1160                    "value": {
1161                        "messages": [{
1162                            "from": "+1234567890",
1163                            "timestamp": "1",
1164                            "type": "text",
1165                            "text": { "body": "Hi" }
1166                        }]
1167                    }
1168                }]
1169            }]
1170        });
1171        let msgs = ch.parse_webhook_payload(&payload);
1172        assert_eq!(msgs.len(), 1);
1173        assert_eq!(msgs[0].sender, "+1234567890");
1174    }
1175
1176    #[test]
1177    fn whatsapp_channel_fields_stored_correctly() {
1178        let ch = WhatsAppChannel::new(
1179            "my-access-token".into(),
1180            "phone-id-123".into(),
1181            "my-verify-token".into(),
1182            vec!["+111".into(), "+222".into()],
1183        );
1184        assert_eq!(ch.verify_token(), "my-verify-token");
1185        assert!(ch.is_number_allowed("+111"));
1186        assert!(ch.is_number_allowed("+222"));
1187        assert!(!ch.is_number_allowed("+333"));
1188    }
1189
1190    #[test]
1191    fn whatsapp_parse_empty_messages_array() {
1192        let ch = make_channel();
1193        let payload = serde_json::json!({
1194            "entry": [{
1195                "changes": [{
1196                    "value": {
1197                        "messages": []
1198                    }
1199                }]
1200            }]
1201        });
1202        let msgs = ch.parse_webhook_payload(&payload);
1203        assert!(msgs.is_empty());
1204    }
1205
1206    #[test]
1207    fn whatsapp_parse_empty_entry_array() {
1208        let ch = make_channel();
1209        let payload = serde_json::json!({
1210            "entry": []
1211        });
1212        let msgs = ch.parse_webhook_payload(&payload);
1213        assert!(msgs.is_empty());
1214    }
1215
1216    #[test]
1217    fn whatsapp_parse_empty_changes_array() {
1218        let ch = make_channel();
1219        let payload = serde_json::json!({
1220            "entry": [{
1221                "changes": []
1222            }]
1223        });
1224        let msgs = ch.parse_webhook_payload(&payload);
1225        assert!(msgs.is_empty());
1226    }
1227
1228    #[test]
1229    fn whatsapp_parse_newlines_preserved() {
1230        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1231        let payload = serde_json::json!({
1232            "entry": [{
1233                "changes": [{
1234                    "value": {
1235                        "messages": [{
1236                            "from": "111",
1237                            "timestamp": "1",
1238                            "type": "text",
1239                            "text": { "body": "Line 1\nLine 2\nLine 3" }
1240                        }]
1241                    }
1242                }]
1243            }]
1244        });
1245        let msgs = ch.parse_webhook_payload(&payload);
1246        assert_eq!(msgs.len(), 1);
1247        assert_eq!(msgs[0].content, "Line 1\nLine 2\nLine 3");
1248    }
1249
1250    #[test]
1251    fn whatsapp_parse_special_characters() {
1252        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1253        let payload = serde_json::json!({
1254            "entry": [{
1255                "changes": [{
1256                    "value": {
1257                        "messages": [{
1258                            "from": "111",
1259                            "timestamp": "1",
1260                            "type": "text",
1261                            "text": { "body": "<script>alert('xss')</script> & \"quotes\" 'apostrophe'" }
1262                        }]
1263                    }
1264                }]
1265            }]
1266        });
1267        let msgs = ch.parse_webhook_payload(&payload);
1268        assert_eq!(msgs.len(), 1);
1269        assert_eq!(
1270            msgs[0].content,
1271            "<script>alert('xss')</script> & \"quotes\" 'apostrophe'"
1272        );
1273    }
1274
1275    // ══════════════════════════════════════════════════════════
1276    // MENTION-PATTERN GATING — Unit tests
1277    // ══════════════════════════════════════════════════════════
1278
1279    fn make_group_mention_channel() -> WhatsAppChannel {
1280        WhatsAppChannel::new(
1281            "test-token".into(),
1282            "123456789".into(),
1283            "verify-me".into(),
1284            vec!["*".into()],
1285        )
1286        .with_group_mention_patterns(vec!["@?Construct".into()])
1287    }
1288
1289    fn make_dm_mention_channel() -> WhatsAppChannel {
1290        WhatsAppChannel::new(
1291            "test-token".into(),
1292            "123456789".into(),
1293            "verify-me".into(),
1294            vec!["*".into()],
1295        )
1296        .with_dm_mention_patterns(vec!["@?Construct".into()])
1297    }
1298
1299    // ── compile_mention_patterns ──
1300
1301    #[test]
1302    fn whatsapp_compile_valid_patterns() {
1303        let patterns = WhatsAppChannel::compile_mention_patterns(&[
1304            "@?Construct".into(),
1305            r"\+?15555550123".into(),
1306        ]);
1307        assert_eq!(patterns.len(), 2);
1308    }
1309
1310    #[test]
1311    fn whatsapp_compile_skips_invalid_patterns() {
1312        let patterns =
1313            WhatsAppChannel::compile_mention_patterns(&["@?Construct".into(), "[invalid".into()]);
1314        assert_eq!(patterns.len(), 1);
1315    }
1316
1317    #[test]
1318    fn whatsapp_compile_skips_empty_patterns() {
1319        let patterns =
1320            WhatsAppChannel::compile_mention_patterns(&["@?Construct".into(), "  ".into()]);
1321        assert_eq!(patterns.len(), 1);
1322    }
1323
1324    #[test]
1325    fn whatsapp_compile_empty_vec() {
1326        let patterns = WhatsAppChannel::compile_mention_patterns(&[]);
1327        assert!(patterns.is_empty());
1328    }
1329
1330    // ── text_matches_patterns ──
1331
1332    #[test]
1333    fn whatsapp_text_matches_at_name() {
1334        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1335        assert!(WhatsAppChannel::text_matches_patterns(
1336            &pats,
1337            "Hello @Construct"
1338        ));
1339    }
1340
1341    #[test]
1342    fn whatsapp_text_matches_name_only() {
1343        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1344        assert!(WhatsAppChannel::text_matches_patterns(
1345            &pats,
1346            "Hello Construct"
1347        ));
1348    }
1349
1350    #[test]
1351    fn whatsapp_text_matches_case_insensitive() {
1352        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1353        assert!(WhatsAppChannel::text_matches_patterns(
1354            &pats,
1355            "Hello @construct"
1356        ));
1357        assert!(WhatsAppChannel::text_matches_patterns(
1358            &pats,
1359            "Hello CONSTRUCT"
1360        ));
1361    }
1362
1363    #[test]
1364    fn whatsapp_text_matches_no_match() {
1365        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1366        assert!(!WhatsAppChannel::text_matches_patterns(
1367            &pats,
1368            "Hello @otherbot"
1369        ));
1370        assert!(!WhatsAppChannel::text_matches_patterns(
1371            &pats,
1372            "Hello world"
1373        ));
1374    }
1375
1376    #[test]
1377    fn whatsapp_text_matches_phone_pattern() {
1378        let pats = WhatsAppChannel::compile_mention_patterns(&[r"\+?15555550123".into()]);
1379        assert!(WhatsAppChannel::text_matches_patterns(
1380            &pats,
1381            "Hey +15555550123 help"
1382        ));
1383        assert!(WhatsAppChannel::text_matches_patterns(
1384            &pats,
1385            "Hey 15555550123 help"
1386        ));
1387        assert!(!WhatsAppChannel::text_matches_patterns(
1388            &pats,
1389            "Hey +19999999999 help"
1390        ));
1391    }
1392
1393    #[test]
1394    fn whatsapp_text_matches_multiple_patterns() {
1395        let pats = WhatsAppChannel::compile_mention_patterns(&[
1396            "@?Construct".into(),
1397            r"\+?15555550123".into(),
1398        ]);
1399        assert!(WhatsAppChannel::text_matches_patterns(
1400            &pats,
1401            "Hello @Construct"
1402        ));
1403        assert!(WhatsAppChannel::text_matches_patterns(
1404            &pats,
1405            "Hey +15555550123"
1406        ));
1407        assert!(!WhatsAppChannel::text_matches_patterns(
1408            &pats,
1409            "Hello world"
1410        ));
1411    }
1412
1413    #[test]
1414    fn whatsapp_text_matches_empty_patterns() {
1415        let pats: Vec<Regex> = vec![];
1416        assert!(!WhatsAppChannel::text_matches_patterns(
1417            &pats,
1418            "Hello @Construct"
1419        ));
1420    }
1421
1422    // ── strip_patterns ──
1423
1424    #[test]
1425    fn whatsapp_strip_at_name() {
1426        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1427        assert_eq!(
1428            WhatsAppChannel::strip_patterns(&pats, "@Construct what is the weather?"),
1429            Some("what is the weather?".into())
1430        );
1431    }
1432
1433    #[test]
1434    fn whatsapp_strip_name_without_at() {
1435        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1436        assert_eq!(
1437            WhatsAppChannel::strip_patterns(&pats, "Construct what is the weather?"),
1438            Some("what is the weather?".into())
1439        );
1440    }
1441
1442    #[test]
1443    fn whatsapp_strip_at_end() {
1444        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1445        assert_eq!(
1446            WhatsAppChannel::strip_patterns(&pats, "Help me @Construct"),
1447            Some("Help me".into())
1448        );
1449    }
1450
1451    #[test]
1452    fn whatsapp_strip_mid_sentence() {
1453        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1454        assert_eq!(
1455            WhatsAppChannel::strip_patterns(&pats, "Hey @Construct how are you?"),
1456            Some("Hey how are you?".into())
1457        );
1458    }
1459
1460    #[test]
1461    fn whatsapp_strip_multiple_occurrences() {
1462        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1463        assert_eq!(
1464            WhatsAppChannel::strip_patterns(&pats, "@Construct hello @Construct"),
1465            Some("hello".into())
1466        );
1467    }
1468
1469    #[test]
1470    fn whatsapp_strip_returns_none_when_only_mention() {
1471        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1472        assert_eq!(WhatsAppChannel::strip_patterns(&pats, "@Construct"), None);
1473    }
1474
1475    #[test]
1476    fn whatsapp_strip_returns_none_for_whitespace_only() {
1477        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1478        assert_eq!(
1479            WhatsAppChannel::strip_patterns(&pats, "  @Construct  "),
1480            None
1481        );
1482    }
1483
1484    #[test]
1485    fn whatsapp_strip_collapses_whitespace() {
1486        let pats = WhatsAppChannel::compile_mention_patterns(&["@?Construct".into()]);
1487        assert_eq!(
1488            WhatsAppChannel::strip_patterns(&pats, "@Construct   status   please"),
1489            Some("status please".into())
1490        );
1491    }
1492
1493    #[test]
1494    fn whatsapp_strip_phone_pattern() {
1495        let pats = WhatsAppChannel::compile_mention_patterns(&[r"\+?15555550123".into()]);
1496        assert_eq!(
1497            WhatsAppChannel::strip_patterns(&pats, "Hey +15555550123 help me"),
1498            Some("Hey help me".into())
1499        );
1500    }
1501
1502    // ── builder tests ──
1503
1504    #[test]
1505    fn whatsapp_with_group_mention_patterns_compiles() {
1506        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec![])
1507            .with_group_mention_patterns(vec!["@?bot".into()]);
1508        assert_eq!(ch.group_mention_patterns.len(), 1);
1509        assert!(ch.dm_mention_patterns.is_empty());
1510    }
1511
1512    #[test]
1513    fn whatsapp_with_dm_mention_patterns_compiles() {
1514        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec![])
1515            .with_dm_mention_patterns(vec!["@?bot".into()]);
1516        assert_eq!(ch.dm_mention_patterns.len(), 1);
1517        assert!(ch.group_mention_patterns.is_empty());
1518    }
1519
1520    #[test]
1521    fn whatsapp_default_no_mention_patterns() {
1522        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec![]);
1523        assert!(ch.dm_mention_patterns.is_empty());
1524        assert!(ch.group_mention_patterns.is_empty());
1525    }
1526
1527    // ── mention_patterns integration with parse_webhook_payload ──
1528
1529    /// Helper: build a group message payload with optional context.group_id.
1530    fn group_msg(from: &str, ts: &str, body: &str) -> serde_json::Value {
1531        serde_json::json!({
1532            "from": from,
1533            "timestamp": ts,
1534            "type": "text",
1535            "text": { "body": body },
1536            "context": { "group_id": "120363012345678901@g.us" }
1537        })
1538    }
1539
1540    /// Helper: build a DM message payload (no group_id).
1541    fn dm_msg(from: &str, ts: &str, body: &str) -> serde_json::Value {
1542        serde_json::json!({
1543            "from": from,
1544            "timestamp": ts,
1545            "type": "text",
1546            "text": { "body": body }
1547        })
1548    }
1549
1550    #[test]
1551    fn whatsapp_is_group_message_with_group_id() {
1552        let msg = group_msg("111", "1", "Hello");
1553        assert!(WhatsAppChannel::is_group_message(&msg));
1554    }
1555
1556    #[test]
1557    fn whatsapp_is_group_message_without_context() {
1558        let msg = dm_msg("111", "1", "Hello");
1559        assert!(!WhatsAppChannel::is_group_message(&msg));
1560    }
1561
1562    #[test]
1563    fn whatsapp_is_group_message_empty_group_id() {
1564        let msg = serde_json::json!({
1565            "from": "111",
1566            "timestamp": "1",
1567            "type": "text",
1568            "text": { "body": "Hi" },
1569            "context": { "group_id": "" }
1570        });
1571        assert!(!WhatsAppChannel::is_group_message(&msg));
1572    }
1573
1574    #[test]
1575    fn whatsapp_group_mention_rejects_group_message_without_match() {
1576        let ch = make_group_mention_channel();
1577        let payload = serde_json::json!({
1578            "entry": [{
1579                "changes": [{
1580                    "value": {
1581                        "messages": [group_msg("111", "1", "Hello without mention")]
1582                    }
1583                }]
1584            }]
1585        });
1586        let msgs = ch.parse_webhook_payload(&payload);
1587        assert!(
1588            msgs.is_empty(),
1589            "Should reject group messages without mention"
1590        );
1591    }
1592
1593    #[test]
1594    fn whatsapp_group_mention_dm_passes_through_without_match() {
1595        // group_mention_patterns configured but DMs should pass through
1596        let ch = make_group_mention_channel();
1597        let payload = serde_json::json!({
1598            "entry": [{
1599                "changes": [{
1600                    "value": {
1601                        "messages": [dm_msg("111", "1", "Hello without mention")]
1602                    }
1603                }]
1604            }]
1605        });
1606        let msgs = ch.parse_webhook_payload(&payload);
1607        assert_eq!(
1608            msgs.len(),
1609            1,
1610            "DMs should pass through when only group patterns are set"
1611        );
1612        assert_eq!(msgs[0].content, "Hello without mention");
1613    }
1614
1615    #[test]
1616    fn whatsapp_group_mention_accepts_and_strips_in_group() {
1617        let ch = make_group_mention_channel();
1618        let payload = serde_json::json!({
1619            "entry": [{
1620                "changes": [{
1621                    "value": {
1622                        "messages": [group_msg("111", "1", "@Construct what is the weather?")]
1623                    }
1624                }]
1625            }]
1626        });
1627        let msgs = ch.parse_webhook_payload(&payload);
1628        assert_eq!(msgs.len(), 1);
1629        assert_eq!(msgs[0].content, "what is the weather?");
1630    }
1631
1632    #[test]
1633    fn whatsapp_group_mention_strips_from_group_content() {
1634        let ch = make_group_mention_channel();
1635        let payload = serde_json::json!({
1636            "entry": [{
1637                "changes": [{
1638                    "value": {
1639                        "messages": [group_msg("111", "1", "Hey @Construct tell me a joke")]
1640                    }
1641                }]
1642            }]
1643        });
1644        let msgs = ch.parse_webhook_payload(&payload);
1645        assert_eq!(msgs.len(), 1);
1646        assert_eq!(msgs[0].content, "Hey tell me a joke");
1647    }
1648
1649    #[test]
1650    fn whatsapp_group_mention_drops_mention_only_group_message() {
1651        let ch = make_group_mention_channel();
1652        let payload = serde_json::json!({
1653            "entry": [{
1654                "changes": [{
1655                    "value": {
1656                        "messages": [group_msg("111", "1", "@Construct")]
1657                    }
1658                }]
1659            }]
1660        });
1661        let msgs = ch.parse_webhook_payload(&payload);
1662        assert!(
1663            msgs.is_empty(),
1664            "Should drop group message that is only a mention"
1665        );
1666    }
1667
1668    #[test]
1669    fn whatsapp_group_mention_case_insensitive_group_match() {
1670        let ch = make_group_mention_channel();
1671        let payload = serde_json::json!({
1672            "entry": [{
1673                "changes": [{
1674                    "value": {
1675                        "messages": [group_msg("111", "1", "@construct status")]
1676                    }
1677                }]
1678            }]
1679        });
1680        let msgs = ch.parse_webhook_payload(&payload);
1681        assert_eq!(msgs.len(), 1);
1682        assert_eq!(msgs[0].content, "status");
1683    }
1684
1685    #[test]
1686    fn whatsapp_no_patterns_passes_all_group_messages() {
1687        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
1688        let payload = serde_json::json!({
1689            "entry": [{
1690                "changes": [{
1691                    "value": {
1692                        "messages": [group_msg("111", "1", "Hello without mention")]
1693                    }
1694                }]
1695            }]
1696        });
1697        let msgs = ch.parse_webhook_payload(&payload);
1698        assert_eq!(msgs.len(), 1);
1699        assert_eq!(msgs[0].content, "Hello without mention");
1700    }
1701
1702    #[test]
1703    fn whatsapp_group_mention_mixed_group_messages() {
1704        let ch = make_group_mention_channel();
1705        let payload = serde_json::json!({
1706            "entry": [{
1707                "changes": [{
1708                    "value": {
1709                        "messages": [
1710                            group_msg("111", "1", "No mention here"),
1711                            group_msg("222", "2", "@Construct help me"),
1712                            group_msg("333", "3", "Also no mention")
1713                        ]
1714                    }
1715                }]
1716            }]
1717        });
1718        let msgs = ch.parse_webhook_payload(&payload);
1719        assert_eq!(msgs.len(), 1);
1720        assert_eq!(msgs[0].content, "help me");
1721        assert_eq!(msgs[0].sender, "+222");
1722    }
1723
1724    #[test]
1725    fn whatsapp_group_mention_phone_pattern_in_group() {
1726        let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()])
1727            .with_group_mention_patterns(vec![r"\+?15555550123".into()]);
1728        let payload = serde_json::json!({
1729            "entry": [{
1730                "changes": [{
1731                    "value": {
1732                        "messages": [group_msg("111", "1", "+15555550123 tell me a joke")]
1733                    }
1734                }]
1735            }]
1736        });
1737        let msgs = ch.parse_webhook_payload(&payload);
1738        assert_eq!(msgs.len(), 1);
1739        assert_eq!(msgs[0].content, "tell me a joke");
1740    }
1741
1742    #[test]
1743    fn whatsapp_group_mention_dm_not_stripped() {
1744        // DMs should not have group mention patterns applied
1745        let ch = make_group_mention_channel();
1746        let payload = serde_json::json!({
1747            "entry": [{
1748                "changes": [{
1749                    "value": {
1750                        "messages": [dm_msg("111", "1", "@Construct what is the weather?")]
1751                    }
1752                }]
1753            }]
1754        });
1755        let msgs = ch.parse_webhook_payload(&payload);
1756        assert_eq!(msgs.len(), 1);
1757        assert_eq!(
1758            msgs[0].content, "@Construct what is the weather?",
1759            "DM content should not be stripped by group patterns"
1760        );
1761    }
1762
1763    // ── dm_mention_patterns integration tests ──
1764
1765    #[test]
1766    fn whatsapp_dm_mention_rejects_dm_without_match() {
1767        let ch = make_dm_mention_channel();
1768        let payload = serde_json::json!({
1769            "entry": [{
1770                "changes": [{
1771                    "value": {
1772                        "messages": [dm_msg("111", "1", "Hello without mention")]
1773                    }
1774                }]
1775            }]
1776        });
1777        let msgs = ch.parse_webhook_payload(&payload);
1778        assert!(msgs.is_empty(), "Should reject DMs without mention");
1779    }
1780
1781    #[test]
1782    fn whatsapp_dm_mention_accepts_and_strips_in_dm() {
1783        let ch = make_dm_mention_channel();
1784        let payload = serde_json::json!({
1785            "entry": [{
1786                "changes": [{
1787                    "value": {
1788                        "messages": [dm_msg("111", "1", "@Construct what is the weather?")]
1789                    }
1790                }]
1791            }]
1792        });
1793        let msgs = ch.parse_webhook_payload(&payload);
1794        assert_eq!(msgs.len(), 1);
1795        assert_eq!(msgs[0].content, "what is the weather?");
1796    }
1797
1798    #[test]
1799    fn whatsapp_dm_mention_group_passes_through() {
1800        // dm_mention_patterns configured but group messages should pass through
1801        let ch = make_dm_mention_channel();
1802        let payload = serde_json::json!({
1803            "entry": [{
1804                "changes": [{
1805                    "value": {
1806                        "messages": [group_msg("111", "1", "Hello without mention")]
1807                    }
1808                }]
1809            }]
1810        });
1811        let msgs = ch.parse_webhook_payload(&payload);
1812        assert_eq!(
1813            msgs.len(),
1814            1,
1815            "Group messages should pass through when only DM patterns are set"
1816        );
1817        assert_eq!(msgs[0].content, "Hello without mention");
1818    }
1819}