1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use uuid::Uuid;
4
5const MAX_WATI_AUDIO_BYTES: u64 = 25 * 1024 * 1024;
6
7pub struct WatiChannel {
14 api_token: String,
15 api_url: String,
16 tenant_id: Option<String>,
17 allowed_numbers: Vec<String>,
18 client: reqwest::Client,
19 transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
20}
21
22impl WatiChannel {
23 pub fn new(
24 api_token: String,
25 api_url: String,
26 tenant_id: Option<String>,
27 allowed_numbers: Vec<String>,
28 ) -> Self {
29 Self::new_with_proxy(api_token, api_url, tenant_id, allowed_numbers, None)
30 }
31
32 pub fn new_with_proxy(
33 api_token: String,
34 api_url: String,
35 tenant_id: Option<String>,
36 allowed_numbers: Vec<String>,
37 proxy_url: Option<String>,
38 ) -> Self {
39 Self {
40 api_token,
41 api_url,
42 tenant_id,
43 allowed_numbers,
44 client: crate::config::build_channel_proxy_client("channel.wati", proxy_url.as_deref()),
45 transcription_manager: None,
46 }
47 }
48
49 pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
50 if !config.enabled {
51 return self;
52 }
53 match super::transcription::TranscriptionManager::new(&config) {
54 Ok(m) => {
55 self.transcription_manager = Some(std::sync::Arc::new(m));
56 }
57 Err(e) => {
58 tracing::warn!(
59 "transcription manager init failed, voice transcription disabled: {e}"
60 );
61 }
62 }
63 self
64 }
65
66 fn is_number_allowed(&self, phone: &str) -> bool {
68 self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
69 }
70
71 fn extract_sender(&self, payload: &serde_json::Value) -> Option<String> {
74 let wa_id = payload
76 .get("waId")
77 .or_else(|| payload.get("wa_id"))
78 .or_else(|| payload.get("from"))
79 .and_then(|v| v.as_str())
80 .unwrap_or("")
81 .trim();
82
83 if wa_id.is_empty() {
84 return None;
85 }
86
87 let normalized_phone = if wa_id.starts_with('+') {
89 wa_id.to_string()
90 } else {
91 format!("+{wa_id}")
92 };
93
94 if !self.is_number_allowed(&normalized_phone) {
96 tracing::warn!(
97 "WATI: ignoring message from unauthorized sender: {normalized_phone}. \
98 Add to channels.wati.allowed_numbers in config.toml, \
99 or run `construct onboard --channels-only` to configure interactively."
100 );
101 return None;
102 }
103
104 Some(normalized_phone)
105 }
106
107 fn build_target(&self, phone: &str) -> String {
109 let bare = phone.strip_prefix('+').unwrap_or(phone);
111 if let Some(ref tid) = self.tenant_id {
112 if bare.starts_with(&format!("{tid}:")) {
113 bare.to_string()
114 } else {
115 format!("{tid}:{bare}")
116 }
117 } else {
118 bare.to_string()
119 }
120 }
121
122 fn extract_timestamp(payload: &serde_json::Value) -> u64 {
127 payload
128 .get("timestamp")
129 .or_else(|| payload.get("created"))
130 .map(|t| {
131 if let Some(secs) = t.as_u64() {
132 if secs > 10_000_000_000 {
133 secs / 1000
134 } else {
135 secs
136 }
137 } else if let Some(s) = t.as_str() {
138 chrono::DateTime::parse_from_rfc3339(s)
139 .ok()
140 .map(|dt| dt.timestamp().cast_unsigned())
141 .unwrap_or_else(|| {
142 std::time::SystemTime::now()
143 .duration_since(std::time::UNIX_EPOCH)
144 .unwrap_or_default()
145 .as_secs()
146 })
147 } else {
148 std::time::SystemTime::now()
149 .duration_since(std::time::UNIX_EPOCH)
150 .unwrap_or_default()
151 .as_secs()
152 }
153 })
154 .unwrap_or_else(|| {
155 std::time::SystemTime::now()
156 .duration_since(std::time::UNIX_EPOCH)
157 .unwrap_or_default()
158 .as_secs()
159 })
160 }
161
162 pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
167 let mut messages = Vec::new();
168
169 let text = payload
171 .get("text")
172 .and_then(|v| v.as_str())
173 .or_else(|| {
174 payload
175 .get("message")
176 .and_then(|m| m.get("text").or_else(|| m.get("body")))
177 .and_then(|v| v.as_str())
178 })
179 .unwrap_or("")
180 .trim();
181
182 if text.is_empty() {
183 return messages;
184 }
185
186 let from_me = payload
188 .get("fromMe")
189 .or_else(|| payload.get("from_me"))
190 .or_else(|| payload.get("owner"))
191 .and_then(|v| v.as_bool())
192 .unwrap_or(false);
193
194 if from_me {
195 tracing::debug!("WATI: skipping fromMe message");
196 return messages;
197 }
198
199 let Some(normalized_phone) = self.extract_sender(payload) else {
201 return messages;
202 };
203
204 let timestamp = Self::extract_timestamp(payload);
205 messages.push(ChannelMessage {
206 id: Uuid::new_v4().to_string(),
207 reply_target: normalized_phone.clone(),
208 sender: normalized_phone,
209 content: text.to_string(),
210 channel: "wati".to_string(),
211 timestamp,
212 thread_ts: None,
213 interruption_scope_id: None,
214 attachments: vec![],
215 });
216
217 messages
218 }
219
220 fn extract_host(url_str: &str) -> Option<String> {
222 reqwest::Url::parse(url_str)
223 .ok()?
224 .host_str()
225 .map(|h| h.to_ascii_lowercase())
226 }
227
228 pub async fn try_transcribe_audio(&self, payload: &serde_json::Value) -> Option<String> {
233 let manager = self.transcription_manager.as_deref()?;
234
235 let media_url = payload
236 .get("mediaUrl")
237 .or_else(|| payload.get("media_url"))
238 .and_then(|v| v.as_str())?;
239
240 let api_host = Self::extract_host(&self.api_url);
242 let media_host = Self::extract_host(media_url);
243 match (api_host, media_host) {
244 (Some(ref expected), Some(ref actual)) if actual == expected => {}
245 _ => {
246 tracing::warn!("WATI: blocked media URL with unexpected host: {media_url}");
247 return None;
248 }
249 }
250
251 let from_me = payload
253 .get("fromMe")
254 .or_else(|| payload.get("from_me"))
255 .or_else(|| payload.get("owner"))
256 .and_then(|v| v.as_bool())
257 .unwrap_or(false);
258 if from_me {
259 tracing::debug!("WATI: skipping fromMe audio before download");
260 return None;
261 }
262
263 let msg_type = payload
264 .get("type")
265 .and_then(|v| v.as_str())
266 .unwrap_or("audio");
267
268 let file_name = match msg_type {
269 "voice" => "voice.ogg",
270 _ => "audio.ogg",
271 };
272
273 let mut resp = match self
274 .client
275 .get(media_url)
276 .bearer_auth(&self.api_token)
277 .send()
278 .await
279 {
280 Ok(r) => r,
281 Err(e) => {
282 tracing::warn!("WATI: media download request failed: {e}");
283 return None;
284 }
285 };
286
287 if !resp.status().is_success() {
288 tracing::warn!("WATI: media download failed: {}", resp.status());
289 return None;
290 }
291
292 let mut audio_bytes = Vec::new();
293 while let Some(chunk) = resp.chunk().await.ok().flatten() {
294 audio_bytes.extend_from_slice(&chunk);
295 if audio_bytes.len() as u64 > MAX_WATI_AUDIO_BYTES {
296 tracing::warn!(
297 "WATI: audio download exceeds {} byte limit",
298 MAX_WATI_AUDIO_BYTES
299 );
300 return None;
301 }
302 }
303
304 match manager.transcribe(&audio_bytes, file_name).await {
305 Ok(transcript) => Some(transcript),
306 Err(e) => {
307 tracing::warn!("WATI: transcription failed: {e}");
308 None
309 }
310 }
311 }
312
313 pub fn parse_audio_as_message(
318 &self,
319 payload: &serde_json::Value,
320 transcript: String,
321 ) -> Vec<ChannelMessage> {
322 let mut messages = Vec::new();
323
324 let from_me = payload
326 .get("fromMe")
327 .or_else(|| payload.get("from_me"))
328 .or_else(|| payload.get("owner"))
329 .and_then(|v| v.as_bool())
330 .unwrap_or(false);
331
332 if from_me {
333 tracing::debug!("WATI: skipping fromMe audio message");
334 return messages;
335 }
336
337 if transcript.trim().is_empty() {
338 tracing::debug!("WATI: skipping empty audio transcript");
339 return messages;
340 }
341
342 let Some(normalized_phone) = self.extract_sender(payload) else {
344 return messages;
345 };
346
347 let timestamp = Self::extract_timestamp(payload);
348 messages.push(ChannelMessage {
349 id: Uuid::new_v4().to_string(),
350 reply_target: normalized_phone.clone(),
351 sender: normalized_phone,
352 content: transcript,
353 channel: "wati".to_string(),
354 timestamp,
355 thread_ts: None,
356 interruption_scope_id: None,
357 attachments: vec![],
358 });
359
360 messages
361 }
362}
363
364#[async_trait]
365impl Channel for WatiChannel {
366 fn name(&self) -> &str {
367 "wati"
368 }
369
370 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
371 let target = self.build_target(&message.recipient);
372
373 let body = serde_json::json!({
374 "target": target,
375 "text": message.content
376 });
377
378 let url = format!("{}/api/ext/v3/conversations/messages/text", self.api_url);
379
380 let resp = self
381 .client
382 .post(&url)
383 .bearer_auth(&self.api_token)
384 .header("Content-Type", "application/json")
385 .json(&body)
386 .send()
387 .await?;
388
389 if !resp.status().is_success() {
390 let status = resp.status();
391 let error_body = resp.text().await.unwrap_or_default();
392 tracing::error!("WATI send failed: {status} — {error_body}");
393 anyhow::bail!("WATI API error: {status}");
394 }
395
396 Ok(())
397 }
398
399 async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
400 tracing::info!(
403 "WATI channel active (webhook mode). \
404 Configure WATI webhook to POST to your gateway's /wati endpoint."
405 );
406
407 loop {
409 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
410 }
411 }
412
413 async fn health_check(&self) -> bool {
414 let url = format!("{}/api/ext/v3/contacts/count", self.api_url);
415
416 self.client
417 .get(&url)
418 .bearer_auth(&self.api_token)
419 .send()
420 .await
421 .map(|r| r.status().is_success())
422 .unwrap_or(false)
423 }
424
425 async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
426 Ok(())
428 }
429
430 async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
431 Ok(())
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn make_channel() -> WatiChannel {
441 WatiChannel {
442 api_token: "test-token".into(),
443 api_url: "https://live-mt-server.wati.io".into(),
444 tenant_id: None,
445 allowed_numbers: vec!["+1234567890".into()],
446 client: reqwest::Client::new(),
447 transcription_manager: None,
448 }
449 }
450
451 fn make_wildcard_channel() -> WatiChannel {
452 WatiChannel {
453 api_token: "test-token".into(),
454 api_url: "https://live-mt-server.wati.io".into(),
455 tenant_id: None,
456 allowed_numbers: vec!["*".into()],
457 client: reqwest::Client::new(),
458 transcription_manager: None,
459 }
460 }
461
462 #[test]
463 fn wati_channel_name() {
464 let ch = make_channel();
465 assert_eq!(ch.name(), "wati");
466 }
467
468 #[test]
469 fn wati_number_allowed_exact() {
470 let ch = make_channel();
471 assert!(ch.is_number_allowed("+1234567890"));
472 assert!(!ch.is_number_allowed("+9876543210"));
473 }
474
475 #[test]
476 fn wati_number_allowed_wildcard() {
477 let ch = make_wildcard_channel();
478 assert!(ch.is_number_allowed("+1234567890"));
479 assert!(ch.is_number_allowed("+9999999999"));
480 }
481
482 #[test]
483 fn wati_number_allowed_empty() {
484 let ch = WatiChannel {
485 api_token: "tok".into(),
486 api_url: "https://live-mt-server.wati.io".into(),
487 tenant_id: None,
488 allowed_numbers: vec![],
489 client: reqwest::Client::new(),
490 transcription_manager: None,
491 };
492 assert!(!ch.is_number_allowed("+1234567890"));
493 }
494
495 #[test]
496 fn wati_build_target_with_tenant() {
497 let ch = WatiChannel {
498 api_token: "tok".into(),
499 api_url: "https://live-mt-server.wati.io".into(),
500 tenant_id: Some("tenant1".into()),
501 allowed_numbers: vec![],
502 client: reqwest::Client::new(),
503 transcription_manager: None,
504 };
505 assert_eq!(ch.build_target("+1234567890"), "tenant1:1234567890");
506 }
507
508 #[test]
509 fn wati_build_target_without_tenant() {
510 let ch = make_channel();
511 assert_eq!(ch.build_target("+1234567890"), "1234567890");
512 }
513
514 #[test]
515 fn wati_build_target_already_prefixed() {
516 let ch = WatiChannel {
517 api_token: "tok".into(),
518 api_url: "https://live-mt-server.wati.io".into(),
519 tenant_id: Some("tenant1".into()),
520 allowed_numbers: vec![],
521 client: reqwest::Client::new(),
522 transcription_manager: None,
523 };
524 assert_eq!(ch.build_target("tenant1:1234567890"), "tenant1:1234567890");
526 }
527
528 #[test]
529 fn wati_parse_valid_message() {
530 let ch = make_channel();
531 let payload = serde_json::json!({
532 "text": "Hello from WATI!",
533 "waId": "1234567890",
534 "fromMe": false,
535 "timestamp": 1_705_320_000_u64
536 });
537
538 let msgs = ch.parse_webhook_payload(&payload);
539 assert_eq!(msgs.len(), 1);
540 assert_eq!(msgs[0].sender, "+1234567890");
541 assert_eq!(msgs[0].content, "Hello from WATI!");
542 assert_eq!(msgs[0].channel, "wati");
543 assert_eq!(msgs[0].reply_target, "+1234567890");
544 assert_eq!(msgs[0].timestamp, 1_705_320_000);
545 }
546
547 #[test]
548 fn wati_parse_skip_from_me() {
549 let ch = make_wildcard_channel();
550 let payload = serde_json::json!({
551 "text": "My own message",
552 "waId": "1234567890",
553 "fromMe": true
554 });
555
556 let msgs = ch.parse_webhook_payload(&payload);
557 assert!(msgs.is_empty(), "fromMe messages should be skipped");
558 }
559
560 #[test]
561 fn wati_parse_skip_no_text() {
562 let ch = make_wildcard_channel();
563 let payload = serde_json::json!({
564 "waId": "1234567890",
565 "fromMe": false
566 });
567
568 let msgs = ch.parse_webhook_payload(&payload);
569 assert!(msgs.is_empty(), "Messages without text should be skipped");
570 }
571
572 #[test]
573 fn wati_parse_alternative_field_names() {
574 let ch = make_wildcard_channel();
575
576 let payload = serde_json::json!({
578 "message": { "body": "Alt field test" },
579 "wa_id": "1234567890",
580 "from_me": false,
581 "timestamp": 1_705_320_000_u64
582 });
583
584 let msgs = ch.parse_webhook_payload(&payload);
585 assert_eq!(msgs.len(), 1);
586 assert_eq!(msgs[0].content, "Alt field test");
587 assert_eq!(msgs[0].sender, "+1234567890");
588 }
589
590 #[test]
591 fn wati_parse_timestamp_seconds() {
592 let ch = make_wildcard_channel();
593 let payload = serde_json::json!({
594 "text": "Test",
595 "waId": "1234567890",
596 "timestamp": 1_705_320_000_u64
597 });
598
599 let msgs = ch.parse_webhook_payload(&payload);
600 assert_eq!(msgs[0].timestamp, 1_705_320_000);
601 }
602
603 #[test]
604 fn wati_parse_timestamp_milliseconds() {
605 let ch = make_wildcard_channel();
606 let payload = serde_json::json!({
607 "text": "Test",
608 "waId": "1234567890",
609 "timestamp": 1_705_320_000_000_u64
610 });
611
612 let msgs = ch.parse_webhook_payload(&payload);
613 assert_eq!(msgs[0].timestamp, 1_705_320_000);
614 }
615
616 #[test]
617 fn wati_parse_timestamp_iso() {
618 let ch = make_wildcard_channel();
619 let payload = serde_json::json!({
620 "text": "Test",
621 "waId": "1234567890",
622 "timestamp": "2025-01-15T12:00:00Z"
623 });
624
625 let msgs = ch.parse_webhook_payload(&payload);
626 assert_eq!(msgs[0].timestamp, 1_736_942_400);
627 }
628
629 #[test]
630 fn wati_parse_normalizes_phone() {
631 let ch = WatiChannel {
632 api_token: "tok".into(),
633 api_url: "https://live-mt-server.wati.io".into(),
634 tenant_id: None,
635 allowed_numbers: vec!["+1234567890".into()],
636 client: reqwest::Client::new(),
637 transcription_manager: None,
638 };
639
640 let payload = serde_json::json!({
642 "text": "Hi",
643 "waId": "1234567890",
644 "fromMe": false
645 });
646
647 let msgs = ch.parse_webhook_payload(&payload);
648 assert_eq!(msgs.len(), 1);
649 assert_eq!(msgs[0].sender, "+1234567890");
650 }
651
652 #[test]
653 fn wati_parse_empty_payload() {
654 let ch = make_channel();
655 let payload = serde_json::json!({});
656 let msgs = ch.parse_webhook_payload(&payload);
657 assert!(msgs.is_empty());
658 }
659
660 #[test]
661 fn wati_parse_from_field_fallback() {
662 let ch = make_wildcard_channel();
663 let payload = serde_json::json!({
665 "text": "Fallback test",
666 "from": "1234567890",
667 "fromMe": false
668 });
669
670 let msgs = ch.parse_webhook_payload(&payload);
671 assert_eq!(msgs.len(), 1);
672 assert_eq!(msgs[0].sender, "+1234567890");
673 }
674
675 #[test]
676 fn wati_parse_message_text_fallback() {
677 let ch = make_wildcard_channel();
678 let payload = serde_json::json!({
680 "message": { "text": "Nested text" },
681 "waId": "1234567890",
682 "fromMe": false
683 });
684
685 let msgs = ch.parse_webhook_payload(&payload);
686 assert_eq!(msgs.len(), 1);
687 assert_eq!(msgs[0].content, "Nested text");
688 }
689
690 #[test]
691 fn wati_parse_owner_field_as_from_me() {
692 let ch = make_wildcard_channel();
693 let payload = serde_json::json!({
695 "text": "Test",
696 "waId": "1234567890",
697 "owner": true
698 });
699
700 let msgs = ch.parse_webhook_payload(&payload);
701 assert!(msgs.is_empty(), "owner=true messages should be skipped");
702 }
703
704 #[test]
705 fn wati_manager_none_when_not_configured() {
706 let ch = make_channel();
707 assert!(ch.transcription_manager.is_none());
708 }
709
710 #[test]
711 fn wati_manager_some_when_valid_config() {
712 let config = crate::config::TranscriptionConfig {
713 enabled: true,
714 default_provider: "groq".to_string(),
715 api_key: Some("test-key".to_string()),
716 api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
717 model: "distil-whisper-large-v3-en".to_string(),
718 language: None,
719 initial_prompt: None,
720 max_duration_secs: 120,
721 openai: None,
722 deepgram: None,
723 assemblyai: None,
724 google: None,
725 local_whisper: None,
726 transcribe_non_ptt_audio: false,
727 };
728
729 let ch = WatiChannel::new(
730 "test-token".into(),
731 "https://live-mt-server.wati.io".into(),
732 None,
733 vec!["+1234567890".into()],
734 )
735 .with_transcription(config);
736
737 assert!(ch.transcription_manager.is_some());
738 }
739
740 #[test]
741 fn wati_manager_none_and_warn_on_init_failure() {
742 let config = crate::config::TranscriptionConfig {
743 enabled: true,
744 default_provider: "groq".to_string(),
745 api_key: Some(String::new()),
746 api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
747 model: "distil-whisper-large-v3-en".to_string(),
748 language: None,
749 initial_prompt: None,
750 max_duration_secs: 120,
751 openai: None,
752 deepgram: None,
753 assemblyai: None,
754 google: None,
755 local_whisper: None,
756 transcribe_non_ptt_audio: false,
757 };
758
759 let ch = WatiChannel::new(
760 "test-token".into(),
761 "https://live-mt-server.wati.io".into(),
762 None,
763 vec!["+1234567890".into()],
764 )
765 .with_transcription(config);
766
767 assert!(ch.transcription_manager.is_none());
768 }
769
770 #[tokio::test]
771 async fn wati_try_transcribe_returns_none_when_manager_none() {
772 let ch = make_channel();
773 let payload = serde_json::json!({
774 "type": "audio",
775 "mediaUrl": "https://example.com/audio.ogg",
776 "waId": "1234567890"
777 });
778
779 let result = ch.try_transcribe_audio(&payload).await;
780 assert!(result.is_none());
781 }
782
783 #[tokio::test]
784 async fn wati_try_transcribe_returns_none_when_no_media_url() {
785 let config = crate::config::TranscriptionConfig {
786 enabled: false,
787 default_provider: "groq".to_string(),
788 api_key: Some("test-key".to_string()),
789 api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
790 model: "distil-whisper-large-v3-en".to_string(),
791 language: None,
792 initial_prompt: None,
793 max_duration_secs: 120,
794 openai: None,
795 deepgram: None,
796 assemblyai: None,
797 google: None,
798 local_whisper: None,
799 transcribe_non_ptt_audio: false,
800 };
801
802 let ch = WatiChannel::new(
803 "test-token".into(),
804 "https://live-mt-server.wati.io".into(),
805 None,
806 vec!["+1234567890".into()],
807 )
808 .with_transcription(config);
809
810 let payload = serde_json::json!({
811 "type": "audio",
812 "waId": "1234567890"
813 });
814
815 let result = ch.try_transcribe_audio(&payload).await;
816 assert!(result.is_none());
817 }
818
819 #[test]
820 fn wati_filename_voice_type() {
821 let _ch = make_channel();
822 let payload = serde_json::json!({
823 "type": "voice",
824 "mediaUrl": "https://example.com/media/123",
825 "waId": "1234567890"
826 });
827
828 let msg_type = payload
829 .get("type")
830 .and_then(|v| v.as_str())
831 .unwrap_or("audio");
832 let file_name = match msg_type {
833 "voice" => "voice.ogg",
834 _ => "audio.ogg",
835 };
836
837 assert_eq!(file_name, "voice.ogg");
838 }
839
840 #[test]
841 fn wati_filename_audio_type() {
842 let _ch = make_channel();
843 let payload = serde_json::json!({
844 "type": "audio",
845 "mediaUrl": "https://example.com/media/123",
846 "waId": "1234567890"
847 });
848
849 let msg_type = payload
850 .get("type")
851 .and_then(|v| v.as_str())
852 .unwrap_or("audio");
853 let file_name = match msg_type {
854 "voice" => "voice.ogg",
855 _ => "audio.ogg",
856 };
857
858 assert_eq!(file_name, "audio.ogg");
859 }
860
861 #[test]
862 fn wati_extract_sender_absent_returns_none() {
863 let ch = make_channel();
864 let payload = serde_json::json!({
865 "type": "audio"
866 });
867
868 let result = ch.extract_sender(&payload);
869 assert!(result.is_none());
870 }
871
872 #[test]
873 fn wati_extract_sender_not_in_allowlist_returns_none() {
874 let ch = make_channel();
875 let payload = serde_json::json!({
876 "waId": "9999999999"
877 });
878
879 let result = ch.extract_sender(&payload);
880 assert!(result.is_none());
881 }
882
883 #[test]
884 fn wati_parse_audio_as_message_uses_transcript_as_content() {
885 let ch = make_wildcard_channel();
886 let payload = serde_json::json!({
887 "type": "audio",
888 "waId": "1234567890",
889 "fromMe": false,
890 "timestamp": 1_705_320_000_u64
891 });
892
893 let transcript = "This is a test transcript.".to_string();
894 let msgs = ch.parse_audio_as_message(&payload, transcript.clone());
895
896 assert_eq!(msgs.len(), 1);
897 assert_eq!(msgs[0].content, transcript);
898 assert_eq!(msgs[0].sender, "+1234567890");
899 assert_eq!(msgs[0].channel, "wati");
900 assert_eq!(msgs[0].timestamp, 1_705_320_000);
901 }
902
903 #[tokio::test]
904 async fn wati_transcribes_audio_via_local_whisper() {
905 use wiremock::matchers::{method, path};
906 use wiremock::{Mock, MockServer, ResponseTemplate};
907
908 let media_server = MockServer::start().await;
909 let whisper_server = MockServer::start().await;
910
911 let audio_bytes = b"fake-audio-data";
912 Mock::given(method("GET"))
913 .and(path("/media/123"))
914 .respond_with(ResponseTemplate::new(200).set_body_bytes(audio_bytes))
915 .mount(&media_server)
916 .await;
917
918 let transcript = "Transcribed text from local whisper.";
919 Mock::given(method("POST"))
920 .and(path("/v1/transcribe"))
921 .respond_with(
922 ResponseTemplate::new(200).set_body_json(serde_json::json!({"text": transcript})),
923 )
924 .mount(&whisper_server)
925 .await;
926
927 let config = crate::config::TranscriptionConfig {
928 enabled: true,
929 default_provider: "local_whisper".to_string(),
930 api_key: None,
931 api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
932 model: "whisper-1".to_string(),
933 language: None,
934 initial_prompt: None,
935 max_duration_secs: 120,
936 openai: None,
937 deepgram: None,
938 assemblyai: None,
939 google: None,
940 local_whisper: Some(crate::config::LocalWhisperConfig {
941 url: format!("{}/v1/transcribe", whisper_server.uri()),
942 bearer_token: Some("test-token".to_string()),
943 max_audio_bytes: 25 * 1024 * 1024,
944 timeout_secs: 300,
945 }),
946 transcribe_non_ptt_audio: false,
947 };
948
949 let ch = WatiChannel::new(
950 "test-token".into(),
951 media_server.uri(),
952 None,
953 vec!["+1234567890".into()],
954 )
955 .with_transcription(config);
956
957 let payload = serde_json::json!({
958 "type": "audio",
959 "mediaUrl": format!("{}/media/123", media_server.uri()),
960 "waId": "1234567890"
961 });
962
963 let result = ch.try_transcribe_audio(&payload).await;
964 assert_eq!(result, Some(transcript.to_string()));
965 }
966
967 #[tokio::test]
968 async fn wati_try_transcribe_returns_none_on_media_download_failure() {
969 use wiremock::matchers::{method, path};
970 use wiremock::{Mock, MockServer, ResponseTemplate};
971
972 let media_server = MockServer::start().await;
973
974 Mock::given(method("GET"))
975 .and(path("/media/123"))
976 .respond_with(ResponseTemplate::new(404))
977 .mount(&media_server)
978 .await;
979
980 let config = crate::config::TranscriptionConfig {
981 enabled: true,
982 default_provider: "local_whisper".to_string(),
983 api_key: None,
984 api_url: "https://api.groq.com/openai/v1/audio/transcriptions".to_string(),
985 model: "whisper-1".to_string(),
986 language: None,
987 initial_prompt: None,
988 max_duration_secs: 120,
989 openai: None,
990 deepgram: None,
991 assemblyai: None,
992 google: None,
993 local_whisper: Some(crate::config::LocalWhisperConfig {
994 url: "http://localhost:8000/v1/transcribe".to_string(),
995 bearer_token: Some("test-token".to_string()),
996 max_audio_bytes: 25 * 1024 * 1024,
997 timeout_secs: 300,
998 }),
999 transcribe_non_ptt_audio: false,
1000 };
1001
1002 let ch = WatiChannel::new(
1003 "test-token".into(),
1004 media_server.uri(),
1005 None,
1006 vec!["+1234567890".into()],
1007 )
1008 .with_transcription(config);
1009
1010 let payload = serde_json::json!({
1011 "type": "audio",
1012 "mediaUrl": format!("{}/media/123", media_server.uri()),
1013 "waId": "1234567890"
1014 });
1015
1016 let result = ch.try_transcribe_audio(&payload).await;
1017 assert!(result.is_none());
1018 }
1019
1020 #[test]
1021 fn extract_host_uses_url_parser() {
1022 assert_eq!(
1023 WatiChannel::extract_host("https://live-mt-server.wati.io/media/123"),
1024 Some("live-mt-server.wati.io".to_string())
1025 );
1026 assert_eq!(
1029 WatiChannel::extract_host("https://live-mt-server.wati.io@evil.com/media/123"),
1030 Some("evil.com".to_string())
1031 );
1032 }
1033
1034 #[tokio::test]
1035 async fn wati_try_transcribe_blocks_host_mismatch() {
1036 let config = crate::config::TranscriptionConfig {
1037 enabled: true,
1038 default_provider: "local_whisper".into(),
1039 local_whisper: Some(crate::config::LocalWhisperConfig {
1040 url: "http://localhost:8001/v1/transcribe".into(),
1041 bearer_token: Some("test-token".into()),
1042 max_audio_bytes: 25 * 1024 * 1024,
1043 timeout_secs: 120,
1044 }),
1045 ..Default::default()
1046 };
1047
1048 let ch = WatiChannel::new(
1049 "test-token".into(),
1050 "https://live-mt-server.wati.io".into(),
1051 None,
1052 vec!["+1234567890".into()],
1053 )
1054 .with_transcription(config);
1055
1056 let payload = serde_json::json!({
1057 "type": "audio",
1058 "mediaUrl": "https://evil.com/media/123",
1059 "waId": "1234567890"
1060 });
1061
1062 let result = ch.try_transcribe_audio(&payload).await;
1063 assert!(result.is_none());
1064 }
1065}