Skip to main content

construct/channels/
wati.rs

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
7/// WATI WhatsApp Business API channel.
8///
9/// This channel operates in webhook mode (push-based) rather than polling.
10/// Messages are received via the gateway's `/wati` webhook endpoint.
11/// The `listen` method here is a keepalive placeholder; actual message handling
12/// happens in the gateway when WATI sends webhook events.
13pub 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    /// Check if a phone number is allowed (E.164 format: +1234567890).
67    fn is_number_allowed(&self, phone: &str) -> bool {
68        self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
69    }
70
71    /// Extract and normalize the sender phone number from a WATI webhook payload.
72    /// Returns `None` if the sender is absent, empty, or not in the allowlist.
73    fn extract_sender(&self, payload: &serde_json::Value) -> Option<String> {
74        // Extract waId (sender phone number)
75        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        // Normalize phone to E.164 format
88        let normalized_phone = if wa_id.starts_with('+') {
89            wa_id.to_string()
90        } else {
91            format!("+{wa_id}")
92        };
93
94        // Check allowlist
95        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    /// Build the target field for the WATI API, prefixing with tenant_id if set.
108    fn build_target(&self, phone: &str) -> String {
109        // Strip leading '+' — WATI expects bare digits
110        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    /// Extract and normalize a timestamp from a WATI webhook payload.
123    ///
124    /// Handles unix seconds, unix milliseconds (divided by 1000), and ISO 8601
125    /// strings. Falls back to the current system time if parsing fails.
126    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    /// Parse an incoming webhook payload from WATI and extract messages.
163    ///
164    /// WATI's webhook payloads have variable field names depending on the API
165    /// version and configuration, so we try multiple paths for each field.
166    pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
167        let mut messages = Vec::new();
168
169        // Extract text — try multiple field paths
170        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        // Check fromMe — skip outgoing messages
187        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        // Extract and validate sender
200        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    /// Extract host from URL string.
221    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    /// Attempt to download and transcribe an audio message from a WATI webhook payload.
229    ///
230    /// Returns `Some(transcript)` if transcription succeeds, `None` otherwise.
231    /// Called by the gateway after detecting `type == "audio"` or `type == "voice"`.
232    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        // Validate media_url host matches api_url to prevent SSRF
241        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        // Check fromMe early to avoid downloading media for outgoing messages
252        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    /// Build a ChannelMessage from an audio transcript.
314    ///
315    /// This helper reuses the same sender extraction and timestamp logic as
316    /// `parse_webhook_payload()` but substitutes the transcript as the message content.
317    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        // Check fromMe — skip outgoing messages
325        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        // Extract and validate sender
343        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        // WATI uses webhooks (push-based), not polling.
401        // Messages are received via the gateway's /wati endpoint.
402        tracing::info!(
403            "WATI channel active (webhook mode). \
404            Configure WATI webhook to POST to your gateway's /wati endpoint."
405        );
406
407        // Keep the task alive — it will be cancelled when the channel shuts down
408        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        // WATI API does not support typing indicators
427        Ok(())
428    }
429
430    async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
431        // WATI API does not support typing indicators
432        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        // If the phone already has the tenant prefix, don't double it
525        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        // wa_id instead of waId, message.body instead of text
577        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        // Phone without + prefix
641        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        // Uses "from" instead of "waId"
664        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        // Uses "message.text" instead of top-level "text"
679        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        // Uses "owner" field as fromMe indicator
694        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        // URL with userinfo@ — proper parser extracts the real host, not the
1027        // attacker-controlled host that naive string splitting would produce
1028        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}