1use std::collections::HashMap;
9use std::fmt;
10use std::sync::Arc;
11
12use anyhow::{Result, bail};
13use serde::{Deserialize, Serialize};
14use tokio::sync::{Mutex, mpsc};
15
16use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
17
18pub use zeroclaw_config::scattered_types::{VoiceCallConfig, VoiceProvider};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CallState {
26 Ringing,
28 InProgress,
30 Completed,
32 Failed,
34 HungUp,
36 PendingApproval,
38}
39
40impl fmt::Display for CallState {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::Ringing => write!(f, "ringing"),
44 Self::InProgress => write!(f, "in_progress"),
45 Self::Completed => write!(f, "completed"),
46 Self::Failed => write!(f, "failed"),
47 Self::HungUp => write!(f, "hung_up"),
48 Self::PendingApproval => write!(f, "pending_approval"),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum CallDirection {
57 Inbound,
58 Outbound,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct CallRecord {
64 pub call_id: String,
66 pub direction: CallDirection,
68 pub remote_number: String,
70 pub local_number: String,
72 pub state: CallState,
74 pub started_at: String,
76 pub ended_at: Option<String>,
78 pub duration_secs: u64,
80 pub transcript: Vec<TranscriptEntry>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct TranscriptEntry {
87 pub speaker: String,
89 pub text: String,
91 pub timestamp: String,
93}
94
95pub struct VoiceCallChannel {
99 config: VoiceCallConfig,
100 alias: String,
103 active_calls: Arc<Mutex<HashMap<String, CallRecord>>>,
104 client: reqwest::Client,
105}
106
107impl VoiceCallChannel {
108 pub fn new(alias: impl Into<String>, config: VoiceCallConfig) -> Self {
109 Self {
110 config,
111 alias: alias.into(),
112 active_calls: Arc::new(Mutex::new(HashMap::new())),
113 client: reqwest::Client::new(),
114 }
115 }
116
117 fn api_base_url(&self) -> &str {
119 match self.config.model_provider {
120 VoiceProvider::Twilio => "https://api.twilio.com/2010-04-01",
121 VoiceProvider::Telnyx => "https://api.telnyx.com/v2",
122 VoiceProvider::Plivo => "https://api.plivo.com/v1",
123 }
124 }
125
126 pub async fn place_call(&self, to_number: &str) -> Result<String> {
128 if self.config.require_outbound_approval {
129 ::zeroclaw_log::record!(
130 INFO,
131 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
132 .with_attrs(::serde_json::json!({"to": to_number})),
133 "outbound call requires approval"
134 );
135 return Ok(format!("PENDING_APPROVAL:{to_number}"));
136 }
137 self.execute_outbound_call(to_number).await
138 }
139
140 async fn execute_outbound_call(&self, to_number: &str) -> Result<String> {
141 let webhook_url = self.webhook_url("/voice/status");
142
143 match self.config.model_provider {
144 VoiceProvider::Twilio => {
145 let url = format!(
146 "{}/Accounts/{}/Calls.json",
147 self.api_base_url(),
148 self.config.account_id
149 );
150 let resp = self
151 .client
152 .post(&url)
153 .basic_auth(&self.config.account_id, Some(&self.config.auth_token))
154 .form(&[
155 ("To", to_number),
156 ("From", &self.config.from_number),
157 ("StatusCallback", &webhook_url),
158 ("Timeout", &self.config.max_call_duration_secs.to_string()),
159 ])
160 .send()
161 .await?;
162
163 if !resp.status().is_success() {
164 let body = resp.text().await.unwrap_or_default();
165 bail!("Twilio call failed: {body}");
166 }
167
168 let json: serde_json::Value = serde_json::from_str(&resp.text().await?)?;
169 let call_sid = json["sid"].as_str().unwrap_or("unknown").to_string();
170 ::zeroclaw_log::record!(
171 INFO,
172 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
173 .with_attrs(::serde_json::json!({"call_sid": call_sid, "to": to_number})),
174 "outbound call placed via Twilio"
175 );
176 Ok(call_sid)
177 }
178 VoiceProvider::Telnyx => {
179 let url = format!("{}/calls", self.api_base_url());
180 let resp = self
181 .client
182 .post(&url)
183 .bearer_auth(&self.config.auth_token)
184 .json(&serde_json::json!({
185 "connection_id": self.config.account_id,
186 "to": to_number,
187 "from": self.config.from_number,
188 "webhook_url": webhook_url,
189 "timeout_secs": self.config.max_call_duration_secs,
190 }))
191 .send()
192 .await?;
193
194 if !resp.status().is_success() {
195 let body = resp.text().await.unwrap_or_default();
196 bail!("Telnyx call failed: {body}");
197 }
198
199 let json: serde_json::Value = serde_json::from_str(&resp.text().await?)?;
200 let call_id = json["data"]["call_control_id"]
201 .as_str()
202 .unwrap_or("unknown")
203 .to_string();
204 ::zeroclaw_log::record!(
205 INFO,
206 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
207 .with_attrs(::serde_json::json!({"call_id": call_id, "to": to_number})),
208 "outbound call placed via Telnyx"
209 );
210 Ok(call_id)
211 }
212 VoiceProvider::Plivo => {
213 let url = format!(
214 "{}/Account/{}/Call/",
215 self.api_base_url(),
216 self.config.account_id
217 );
218 let resp = self
219 .client
220 .post(&url)
221 .basic_auth(&self.config.account_id, Some(&self.config.auth_token))
222 .json(&serde_json::json!({
223 "to": to_number,
224 "from": self.config.from_number,
225 "answer_url": self.webhook_url("/voice/answer"),
226 "hangup_url": self.webhook_url("/voice/hangup"),
227 "time_limit": self.config.max_call_duration_secs,
228 }))
229 .send()
230 .await?;
231
232 if !resp.status().is_success() {
233 let body = resp.text().await.unwrap_or_default();
234 bail!("Plivo call failed: {body}");
235 }
236
237 let json: serde_json::Value = serde_json::from_str(&resp.text().await?)?;
238 let call_uuid = json["request_uuid"]
239 .as_str()
240 .unwrap_or("unknown")
241 .to_string();
242 ::zeroclaw_log::record!(
243 INFO,
244 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
245 .with_attrs(::serde_json::json!({"call_uuid": call_uuid, "to": to_number})),
246 "outbound call placed via Plivo"
247 );
248 Ok(call_uuid)
249 }
250 }
251 }
252
253 fn webhook_url(&self, path: &str) -> String {
255 if let Some(ref base) = self.config.webhook_base_url {
256 format!("{}{}", base.trim_end_matches('/'), path)
257 } else {
258 format!("http://localhost:{}{}", self.config.webhook_port, path)
259 }
260 }
261
262 pub async fn add_transcript_entry(&self, call_id: &str, speaker: &str, text: &str) {
264 let mut calls = self.active_calls.lock().await;
265 if let Some(record) = calls.get_mut(call_id) {
266 record.transcript.push(TranscriptEntry {
267 speaker: speaker.to_string(),
268 text: text.to_string(),
269 timestamp: chrono::Utc::now().to_rfc3339(),
270 });
271 }
272 }
273
274 pub async fn get_call(&self, call_id: &str) -> Option<CallRecord> {
276 let calls = self.active_calls.lock().await;
277 calls.get(call_id).cloned()
278 }
279
280 pub async fn active_calls(&self) -> Vec<CallRecord> {
282 let calls = self.active_calls.lock().await;
283 calls.values().cloned().collect()
284 }
285
286 pub async fn handle_inbound_call(
288 &self,
289 call_id: &str,
290 from_number: &str,
291 tx: &mpsc::Sender<ChannelMessage>,
292 ) -> Result<()> {
293 let record = CallRecord {
294 call_id: call_id.to_string(),
295 direction: CallDirection::Inbound,
296 remote_number: from_number.to_string(),
297 local_number: self.config.from_number.clone(),
298 state: CallState::Ringing,
299 started_at: chrono::Utc::now().to_rfc3339(),
300 ended_at: None,
301 duration_secs: 0,
302 transcript: Vec::new(),
303 };
304
305 {
306 let mut calls = self.active_calls.lock().await;
307 calls.insert(call_id.to_string(), record);
308 }
309
310 ::zeroclaw_log::record!(
311 INFO,
312 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
313 .with_attrs(::serde_json::json!({"call_id": call_id, "from": from_number})),
314 "inbound call received"
315 );
316
317 let msg = ChannelMessage {
319 id: call_id.to_string(),
320 sender: from_number.to_string(),
321 reply_target: from_number.to_string(),
322 content: format!("[Voice Call] Incoming call from {from_number} (call_id: {call_id})"),
323 channel: "voice_call".to_string(),
324 channel_alias: None,
325 timestamp: chrono::Utc::now().timestamp().unsigned_abs(),
326 thread_ts: Some(call_id.to_string()),
327 interruption_scope_id: Some(call_id.to_string()),
328 attachments: vec![],
329 subject: None,
330 };
331 tx.send(msg).await.map_err(|e| {
332 ::zeroclaw_log::record!(
333 ERROR,
334 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
335 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
336 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
337 "Failed to send call event"
338 );
339 anyhow::Error::msg(format!("Failed to send call event: {e}"))
340 })?;
341 Ok(())
342 }
343
344 pub async fn handle_status_update(&self, call_id: &str, new_state: CallState) {
346 let mut calls = self.active_calls.lock().await;
347 if let Some(record) = calls.get_mut(call_id) {
348 let old_state = record.state;
349 record.state = new_state;
350
351 if matches!(
352 new_state,
353 CallState::Completed | CallState::Failed | CallState::HungUp
354 ) {
355 record.ended_at = Some(chrono::Utc::now().to_rfc3339());
356 }
357
358 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"call_id": call_id, "old_state": old_state, "new_state": new_state})), "call state transition");
359 }
360 }
361
362 pub async fn save_transcript(
364 &self,
365 call_id: &str,
366 workspace_dir: &std::path::Path,
367 ) -> Result<()> {
368 if !self.config.transcription_logging {
369 return Ok(());
370 }
371
372 let calls = self.active_calls.lock().await;
373 let Some(record) = calls.get(call_id) else {
374 bail!("Call not found: {call_id}");
375 };
376
377 let logs_dir = workspace_dir.join("logs").join("calls");
378 std::fs::create_dir_all(&logs_dir)?;
379
380 let filename = format!("{}_{}.json", record.started_at.replace(':', "-"), call_id);
381 let path = logs_dir.join(filename);
382 let json = serde_json::to_string_pretty(record)?;
383 std::fs::write(&path, json)?;
384
385 ::zeroclaw_log::record!(
386 INFO,
387 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
388 ::serde_json::json!({"call_id": call_id, "path": path.display().to_string()})
389 ),
390 "call transcript saved"
391 );
392 Ok(())
393 }
394}
395
396impl ::zeroclaw_api::attribution::Attributable for VoiceCallChannel {
399 fn role(&self) -> ::zeroclaw_api::attribution::Role {
400 ::zeroclaw_api::attribution::Role::Channel(
401 ::zeroclaw_api::attribution::ChannelKind::VoiceCall,
402 )
403 }
404 fn alias(&self) -> &str {
405 &self.alias
406 }
407}
408
409#[async_trait::async_trait]
410impl Channel for VoiceCallChannel {
411 fn name(&self) -> &str {
412 "voice_call"
413 }
414
415 async fn send(&self, message: &SendMessage) -> Result<()> {
416 if let Some(ref thread_ts) = message.thread_ts {
418 let calls = self.active_calls.lock().await;
419 if let Some(record) = calls.get(thread_ts)
420 && record.state == CallState::InProgress
421 {
422 ::zeroclaw_log::record!(
423 DEBUG,
424 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
425 .with_attrs(::serde_json::json!({"call_id": thread_ts})),
426 &format!("would TTS message to active call: {}", message.content)
427 );
428 return Ok(());
431 }
432 }
433
434 ::zeroclaw_log::record!(
435 DEBUG,
436 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
437 &format!("voice_call send (no active call): {}", message.content)
438 );
439 Ok(())
440 }
441
442 async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
443 let port = self.config.webhook_port;
444 let active_calls = self.active_calls.clone();
445 let _tx = tx.clone();
446
447 ::zeroclaw_log::record!(
448 INFO,
449 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
450 ::serde_json::json!({"port": port, "model_provider": self.config.model_provider})
451 ),
452 "voice call webhook server starting"
453 );
454
455 let app = axum::Router::new()
466 .route("/voice/health", axum::routing::get(|| async { "ok" }))
467 .with_state(active_calls);
468
469 let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
470 .await
471 .map_err(|e| {
472 ::zeroclaw_log::record!(
473 ERROR,
474 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
475 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
476 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
477 "Failed to bind voice webhook server"
478 );
479 anyhow::Error::msg(format!("Failed to bind voice webhook server: {e}"))
480 })?;
481
482 axum::serve(listener, app).await.map_err(|e| {
483 ::zeroclaw_log::record!(
484 ERROR,
485 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
486 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
487 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
488 "Voice webhook server error"
489 );
490 anyhow::Error::msg(format!("Voice webhook server error: {e}"))
491 })?;
492
493 Ok(())
494 }
495
496 async fn health_check(&self) -> bool {
497 let test_url = match self.config.model_provider {
499 VoiceProvider::Twilio => {
500 format!(
501 "{}/Accounts/{}.json",
502 self.api_base_url(),
503 self.config.account_id
504 )
505 }
506 VoiceProvider::Telnyx => format!("{}/connections", self.api_base_url()),
507 VoiceProvider::Plivo => {
508 format!(
509 "{}/Account/{}/",
510 self.api_base_url(),
511 self.config.account_id
512 )
513 }
514 };
515
516 match self.client.get(&test_url).send().await {
517 Ok(resp) => {
518 resp.status().is_success() || resp.status().as_u16() == 401
520 }
521 Err(e) => {
522 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error": format!("{}", e), "model_provider": self.config.model_provider})), "voice call health check failed");
523 false
524 }
525 }
526 }
527
528 async fn start_typing(&self, _recipient: &str) -> Result<()> {
529 Ok(()) }
531
532 async fn stop_typing(&self, _recipient: &str) -> Result<()> {
533 Ok(()) }
535
536 fn supports_draft_updates(&self) -> bool {
537 false
538 }
539
540 async fn send_draft(&self, _message: &SendMessage) -> Result<Option<String>> {
541 Ok(None)
542 }
543
544 async fn update_draft(&self, _recipient: &str, _message_id: &str, _text: &str) -> Result<()> {
545 Ok(())
546 }
547
548 async fn finalize_draft(&self, _recipient: &str, _message_id: &str, _text: &str) -> Result<()> {
549 Ok(())
550 }
551
552 async fn cancel_draft(&self, _recipient: &str, _message_id: &str) -> Result<()> {
553 Ok(())
554 }
555
556 async fn add_reaction(&self, _channel_id: &str, _message_id: &str, _emoji: &str) -> Result<()> {
557 Ok(())
558 }
559
560 async fn remove_reaction(
561 &self,
562 _channel_id: &str,
563 _message_id: &str,
564 _emoji: &str,
565 ) -> Result<()> {
566 Ok(())
567 }
568
569 async fn pin_message(&self, _channel_id: &str, _message_id: &str) -> Result<()> {
570 Ok(())
571 }
572
573 async fn unpin_message(&self, _channel_id: &str, _message_id: &str) -> Result<()> {
574 Ok(())
575 }
576
577 async fn redact_message(
578 &self,
579 _channel_id: &str,
580 _message_id: &str,
581 _reason: Option<String>,
582 ) -> Result<()> {
583 Ok(())
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 fn test_config() -> VoiceCallConfig {
592 VoiceCallConfig {
593 enabled: true,
594 model_provider: VoiceProvider::Twilio,
595 account_id: "AC_TEST_ACCOUNT".into(),
596 auth_token: "test_token".into(),
597 from_number: "+15551234567".into(),
598 webhook_port: 8090,
599 require_outbound_approval: true,
600 transcription_logging: true,
601 tts_voice: None,
602 max_call_duration_secs: 3600,
603 webhook_base_url: Some("https://tunnel.example.com".into()),
604 excluded_tools: vec![],
605 }
606 }
607
608 #[test]
609 fn provider_display() {
610 assert_eq!(VoiceProvider::Twilio.to_string(), "twilio");
611 assert_eq!(VoiceProvider::Telnyx.to_string(), "telnyx");
612 assert_eq!(VoiceProvider::Plivo.to_string(), "plivo");
613 }
614
615 #[test]
616 fn call_state_display() {
617 assert_eq!(CallState::Ringing.to_string(), "ringing");
618 assert_eq!(CallState::InProgress.to_string(), "in_progress");
619 assert_eq!(CallState::Completed.to_string(), "completed");
620 assert_eq!(CallState::PendingApproval.to_string(), "pending_approval");
621 }
622
623 #[test]
624 fn webhook_url_with_base() {
625 let channel = VoiceCallChannel::new("testbot", test_config());
626 assert_eq!(
627 channel.webhook_url("/voice/status"),
628 "https://tunnel.example.com/voice/status"
629 );
630 }
631
632 #[test]
633 fn webhook_url_without_base() {
634 let mut config = test_config();
635 config.webhook_base_url = None;
636 let channel = VoiceCallChannel::new("testbot", config);
637 assert_eq!(
638 channel.webhook_url("/voice/status"),
639 "http://localhost:8090/voice/status"
640 );
641 }
642
643 #[test]
644 fn channel_name() {
645 let channel = VoiceCallChannel::new("testbot", test_config());
646 assert_eq!(channel.name(), "voice_call");
647 }
648
649 #[tokio::test]
650 async fn handle_inbound_call_creates_record() {
651 let channel = VoiceCallChannel::new("testbot", test_config());
652 let (tx, mut rx) = mpsc::channel(10);
653
654 channel
655 .handle_inbound_call("call-123", "+15559876543", &tx)
656 .await
657 .unwrap();
658
659 let record = channel.get_call("call-123").await.unwrap();
661 assert_eq!(record.call_id, "call-123");
662 assert_eq!(record.remote_number, "+15559876543");
663 assert_eq!(record.state, CallState::Ringing);
664 assert_eq!(record.direction, CallDirection::Inbound);
665
666 let msg = rx.recv().await.unwrap();
668 assert!(msg.content.contains("Incoming call"));
669 assert!(msg.content.contains("+15559876543"));
670 }
671
672 #[tokio::test]
673 async fn handle_status_update_transitions_state() {
674 let channel = VoiceCallChannel::new("testbot", test_config());
675 let (tx, _rx) = mpsc::channel(10);
676
677 channel
678 .handle_inbound_call("call-456", "+15559876543", &tx)
679 .await
680 .unwrap();
681
682 channel
683 .handle_status_update("call-456", CallState::InProgress)
684 .await;
685
686 let record = channel.get_call("call-456").await.unwrap();
687 assert_eq!(record.state, CallState::InProgress);
688 assert!(record.ended_at.is_none());
689
690 channel
692 .handle_status_update("call-456", CallState::Completed)
693 .await;
694
695 let record = channel.get_call("call-456").await.unwrap();
696 assert_eq!(record.state, CallState::Completed);
697 assert!(record.ended_at.is_some());
698 }
699
700 #[tokio::test]
701 async fn add_transcript_entry_records_entries() {
702 let channel = VoiceCallChannel::new("testbot", test_config());
703 let (tx, _rx) = mpsc::channel(10);
704
705 channel
706 .handle_inbound_call("call-789", "+15559876543", &tx)
707 .await
708 .unwrap();
709
710 channel
711 .add_transcript_entry("call-789", "caller", "Hello, I need help")
712 .await;
713 channel
714 .add_transcript_entry("call-789", "agent", "Hi, how can I assist you?")
715 .await;
716
717 let record = channel.get_call("call-789").await.unwrap();
718 assert_eq!(record.transcript.len(), 2);
719 assert_eq!(record.transcript[0].speaker, "caller");
720 assert_eq!(record.transcript[0].text, "Hello, I need help");
721 assert_eq!(record.transcript[1].speaker, "agent");
722 }
723
724 #[tokio::test]
725 async fn save_transcript_creates_file() {
726 let channel = VoiceCallChannel::new("testbot", test_config());
727 let (tx, _rx) = mpsc::channel(10);
728 let workspace = tempfile::tempdir().unwrap();
729
730 channel
731 .handle_inbound_call("call-save", "+15559876543", &tx)
732 .await
733 .unwrap();
734
735 channel
736 .add_transcript_entry("call-save", "caller", "Test message")
737 .await;
738
739 channel
740 .save_transcript("call-save", workspace.path())
741 .await
742 .unwrap();
743
744 let logs_dir = workspace.path().join("logs").join("calls");
746 assert!(logs_dir.exists());
747
748 let entries: Vec<_> = std::fs::read_dir(&logs_dir)
750 .unwrap()
751 .filter_map(|e| e.ok())
752 .collect();
753 assert_eq!(entries.len(), 1);
754
755 let content = std::fs::read_to_string(entries[0].path()).unwrap();
757 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
758 assert_eq!(parsed["call_id"], "call-save");
759 assert_eq!(parsed["transcript"][0]["text"], "Test message");
760 }
761
762 #[tokio::test]
763 async fn active_calls_lists_all() {
764 let channel = VoiceCallChannel::new("testbot", test_config());
765 let (tx, _rx) = mpsc::channel(10);
766
767 channel
768 .handle_inbound_call("call-a", "+15551111111", &tx)
769 .await
770 .unwrap();
771 channel
772 .handle_inbound_call("call-b", "+15552222222", &tx)
773 .await
774 .unwrap();
775
776 let calls = channel.active_calls().await;
777 assert_eq!(calls.len(), 2);
778 }
779
780 #[tokio::test]
781 async fn place_call_requires_approval() {
782 let channel = VoiceCallChannel::new("testbot", test_config());
783 let result = channel.place_call("+15559876543").await.unwrap();
784 assert!(result.starts_with("PENDING_APPROVAL:"));
785 }
786
787 #[test]
788 fn config_serde_roundtrip() {
789 let config = test_config();
790 let json = serde_json::to_string(&config).unwrap();
791 let parsed: VoiceCallConfig = serde_json::from_str(&json).unwrap();
792 assert_eq!(parsed.model_provider, VoiceProvider::Twilio);
793 assert_eq!(parsed.from_number, "+15551234567");
794 assert_eq!(parsed.webhook_port, 8090);
795 }
796
797 #[test]
798 fn call_record_serde_roundtrip() {
799 let record = CallRecord {
800 call_id: "call-001".into(),
801 direction: CallDirection::Inbound,
802 remote_number: "+15559876543".into(),
803 local_number: "+15551234567".into(),
804 state: CallState::InProgress,
805 started_at: "2026-03-24T12:00:00Z".into(),
806 ended_at: None,
807 duration_secs: 0,
808 transcript: vec![TranscriptEntry {
809 speaker: "caller".into(),
810 text: "Hello".into(),
811 timestamp: "2026-03-24T12:00:01Z".into(),
812 }],
813 };
814 let json = serde_json::to_string(&record).unwrap();
815 let parsed: CallRecord = serde_json::from_str(&json).unwrap();
816 assert_eq!(parsed.call_id, "call-001");
817 assert_eq!(parsed.transcript.len(), 1);
818 }
819
820 #[test]
821 fn default_provider_is_twilio() {
822 assert_eq!(VoiceProvider::default(), VoiceProvider::Twilio);
823 }
824
825 #[test]
826 fn provider_serde_roundtrip() {
827 let json = serde_json::to_string(&VoiceProvider::Telnyx).unwrap();
828 assert_eq!(json, "\"telnyx\"");
829 let parsed: VoiceProvider = serde_json::from_str(&json).unwrap();
830 assert_eq!(parsed, VoiceProvider::Telnyx);
831 }
832
833 #[tokio::test]
834 async fn transcript_logging_disabled_skips_save() {
835 let mut config = test_config();
836 config.transcription_logging = false;
837 let channel = VoiceCallChannel::new("testbot", config);
838 let (tx, _rx) = mpsc::channel(10);
839 let workspace = tempfile::tempdir().unwrap();
840
841 channel
842 .handle_inbound_call("call-nolog", "+15559876543", &tx)
843 .await
844 .unwrap();
845
846 channel
847 .save_transcript("call-nolog", workspace.path())
848 .await
849 .unwrap();
850
851 let logs_dir = workspace.path().join("logs").join("calls");
853 assert!(!logs_dir.exists());
854 }
855}