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