1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use regex::Regex;
4use uuid::Uuid;
5
6fn 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
21pub struct WhatsAppChannel {
27 access_token: String,
28 endpoint_id: String,
29 verify_token: String,
30 allowed_numbers: Vec<String>,
31 proxy_url: Option<String>,
33 dm_mention_patterns: Vec<Regex>,
35 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 pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
59 self.proxy_url = proxy_url;
60 self
61 }
62
63 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 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 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) .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 pub(crate) fn text_matches_patterns(patterns: &[Regex], text: &str) -> bool {
108 patterns.iter().any(|re| re.is_match(text))
109 }
110
111 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 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 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 fn is_number_allowed(&self, phone: &str) -> bool {
167 self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
168 }
169
170 pub fn verify_token(&self) -> &str {
172 &self.verify_token
173 }
174
175 pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
177 let mut messages = Vec::new();
178
179 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 let Some(msgs) = value.get("messages").and_then(|m| m.as_array()) else {
197 continue;
198 };
199
200 for msg in msgs {
201 let Some(from) = msg.get("from").and_then(|f| f.as_str()) else {
203 continue;
204 };
205
206 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 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 tracing::debug!("WhatsApp: skipping non-text message from {from}");
232 continue;
233 };
234
235 if content.is_empty() {
236 continue;
237 }
238
239 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 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 let url = format!(
299 "https://graph.facebook.com/v18.0/{}/messages",
300 self.endpoint_id
301 );
302
303 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 tracing::info!(
346 "WhatsApp channel active (webhook mode). \
347 Configure Meta webhook to POST to your gateway's /whatsapp endpoint."
348 );
349
350 loop {
352 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
353 }
354 }
355
356 async fn health_check(&self) -> bool {
357 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 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 #[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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 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}