Skip to main content

zeroclaw_channels/
voice_call.rs

1//! Real-time voice call channel for Twilio, Telnyx, and Plivo.
2//!
3//! Handles inbound/outbound phone calls with real-time STT/TTS streaming,
4//! call transcription logging, and approval workflows for outbound calls.
5//! Webhook endpoints receive call events from the telephony model_provider and
6//! translate them into `ChannelMessage`s for the agent loop.
7
8use 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// ── Call state ────────────────────────────────────────────────────
21
22/// Lifecycle state of a phone call.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CallState {
26    /// Call is ringing (inbound or outbound).
27    Ringing,
28    /// Call is connected and audio is flowing.
29    InProgress,
30    /// Call has ended normally.
31    Completed,
32    /// Call failed to connect.
33    Failed,
34    /// Caller or callee hung up.
35    HungUp,
36    /// Call is queued (outbound, awaiting approval).
37    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/// Direction of a call.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum CallDirection {
57    Inbound,
58    Outbound,
59}
60
61/// Tracks an active call's metadata and transcription.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct CallRecord {
64    /// Unique call identifier (provider-specific SID/UUID).
65    pub call_id: String,
66    /// Direction: inbound or outbound.
67    pub direction: CallDirection,
68    /// Remote phone number (E.164).
69    pub remote_number: String,
70    /// Local phone number used.
71    pub local_number: String,
72    /// Current call state.
73    pub state: CallState,
74    /// When the call started (ISO-8601).
75    pub started_at: String,
76    /// When the call ended (ISO-8601), if applicable.
77    pub ended_at: Option<String>,
78    /// Duration in seconds (updated on completion).
79    pub duration_secs: u64,
80    /// Running transcript of the call.
81    pub transcript: Vec<TranscriptEntry>,
82}
83
84/// A single transcript entry from the call.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct TranscriptEntry {
87    /// Who said it: `"caller"` or `"agent"`.
88    pub speaker: String,
89    /// The transcribed text.
90    pub text: String,
91    /// ISO-8601 timestamp.
92    pub timestamp: String,
93}
94
95// ── Channel implementation ────────────────────────────────────────
96
97/// Voice call channel — handles telephony via Twilio, Telnyx, or Plivo.
98pub struct VoiceCallChannel {
99    config: VoiceCallConfig,
100    /// The alias key under `[channels.voice_call.<alias>]` this handle is
101    /// bound to. Used for attribution.
102    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    /// Get the provider-specific API base URL.
118    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    /// Place an outbound call via the configured model_provider.
127    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    /// Construct a full webhook URL from a path.
254    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    /// Record a transcript entry for an active call.
263    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    /// Get a snapshot of an active call.
275    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    /// List all active calls.
281    pub async fn active_calls(&self) -> Vec<CallRecord> {
282        let calls = self.active_calls.lock().await;
283        calls.values().cloned().collect()
284    }
285
286    /// Handle an incoming call webhook event.
287    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        // Notify the agent about the incoming call
318        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    /// Handle a call status update (state transition).
345    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    /// Save call transcript to workspace (if logging is enabled).
363    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
396// ── Channel trait implementation ─────────────────────────────────
397
398impl ::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        // For active calls, TTS the message to the caller
417        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                // TTS synthesis + streaming would be handled by the
429                // telephony model_provider's media stream API in production.
430                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        // The webhook server runs as an axum HTTP server on the configured port.
456        // In production, this handles:
457        // - POST /voice/inbound — Twilio/Telnyx/Plivo call initiation webhook
458        // - POST /voice/status — Call status updates
459        // - POST /voice/transcription — Real-time transcription events
460        // - WebSocket /voice/media — Bidirectional audio streaming
461        //
462        // For now, we set up the server structure. Full endpoint
463        // implementation depends on provider-specific webhook payloads.
464
465        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        // Check we can reach the model_provider API
498        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                // 401 is expected without valid auth — it means the API is reachable
519                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(()) // Not applicable for voice calls
530    }
531
532    async fn stop_typing(&self, _recipient: &str) -> Result<()> {
533        Ok(()) // Not applicable for voice calls
534    }
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        // Check call record was created
660        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        // Check message was sent to agent
667        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        // Transition to completed
691        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        // Check the logs/calls directory was created
745        let logs_dir = workspace.path().join("logs").join("calls");
746        assert!(logs_dir.exists());
747
748        // Check a JSON file was created
749        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        // Verify JSON content
756        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        // Logs directory should not exist
852        let logs_dir = workspace.path().join("logs").join("calls");
853        assert!(!logs_dir.exists());
854    }
855}