Skip to main content

rvoip_vapi/
adapter.rs

1//! Staged outbound [`ConnectionAdapter`] implementation for Vapi.
2
3use std::collections::VecDeque;
4use std::fmt;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex as StdMutex};
7
8use async_trait::async_trait;
9use bytes::Bytes;
10use chrono::Utc;
11use dashmap::DashMap;
12use futures::{SinkExt, StreamExt};
13use parking_lot::Mutex;
14use rvoip_core::adapter::{
15    AdapterEvent, AdapterKind, AdapterLifecycleCapabilities, AdapterLifecycleSink,
16    AdapterLifecycleSinkSlot, ConnectionAdapter, ConnectionHandle, EndReason,
17    ExternalConnectionReference, OriginateRequest, OutboundActivation, RejectReason,
18    SignatureHeaders, TransferTarget,
19};
20use rvoip_core::commands::MuteDirection;
21use rvoip_core::connection::{Connection, ConnectionState, Direction, Transport, TransportHandle};
22use rvoip_core::error::{Result as RvoipResult, RvoipError};
23use rvoip_core::identity::IdentityAssurance;
24use rvoip_core::ids::ConnectionId;
25use rvoip_core::message::Message;
26use rvoip_core::stream::{MediaStream, MediaStreamHandle};
27use rvoip_core::{CapabilityDescriptor, NegotiatedCodecs};
28use tokio::sync::{broadcast, mpsc, watch};
29use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
30use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
31use tracing::warn;
32
33use crate::client::{connect_websocket, send_with_timeout, VapiHttpClient, VapiSocket};
34use crate::config::VapiConfig;
35use crate::error::{Result, VapiError};
36use crate::events::{VapiEvent, VapiEventEnvelope};
37use crate::media::{append_bounded_frames, AudioFramer, VapiMediaStream};
38use crate::types::{AddedMessage, VapiCallOptions, VapiCommand};
39
40pub const ADAPTER_EVENT_CAPACITY: usize = 256;
41pub const VAPI_CALL_REFERENCE_KIND: &str = "vapi-call-id";
42
43/// Opaque handle placed on the rvoip connection.
44pub struct VapiTransportHandle {
45    connection_id: ConnectionId,
46}
47
48impl VapiTransportHandle {
49    pub fn connection_id(&self) -> &ConnectionId {
50        &self.connection_id
51    }
52}
53
54impl fmt::Debug for VapiTransportHandle {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        formatter.write_str("VapiTransportHandle([redacted])")
57    }
58}
59
60struct Route {
61    connection_id: ConnectionId,
62    options: VapiCallOptions,
63    stream: Arc<VapiMediaStream>,
64    cancel: tokio_util::sync::CancellationToken,
65    live: AtomicBool,
66    active: AtomicBool,
67    published: AtomicBool,
68    terminal: AtomicBool,
69    publication: Mutex<()>,
70    activation_started: AtomicBool,
71    activation_result:
72        watch::Sender<Option<std::result::Result<OutboundActivation, ActivationFailure>>>,
73    command_tx: Mutex<Option<mpsc::Sender<String>>>,
74    call_id: Mutex<Option<String>>,
75    requested_end_reason: Mutex<Option<EndReason>>,
76    events: broadcast::Sender<VapiEvent>,
77    initial_events: Mutex<Option<broadcast::Receiver<VapiEvent>>>,
78    finished: tokio_util::sync::CancellationToken,
79}
80
81impl Route {
82    fn new(
83        connection_id: ConnectionId,
84        options: VapiCallOptions,
85        config: &VapiConfig,
86    ) -> Arc<Self> {
87        let cancel = tokio_util::sync::CancellationToken::new();
88        let stream = VapiMediaStream::new(
89            options.audio_format,
90            1,
91            config.media_queue_capacity,
92            cancel.clone(),
93        );
94        let (events, initial_events) = broadcast::channel(config.event_queue_capacity);
95        let (activation_result, _) = watch::channel(None);
96        Arc::new(Self {
97            connection_id,
98            options,
99            stream,
100            cancel,
101            live: AtomicBool::new(true),
102            active: AtomicBool::new(false),
103            published: AtomicBool::new(false),
104            terminal: AtomicBool::new(false),
105            publication: Mutex::new(()),
106            activation_started: AtomicBool::new(false),
107            activation_result,
108            command_tx: Mutex::new(None),
109            call_id: Mutex::new(None),
110            requested_end_reason: Mutex::new(None),
111            events,
112            initial_events: Mutex::new(Some(initial_events)),
113            finished: tokio_util::sync::CancellationToken::new(),
114        })
115    }
116
117    fn activation_receipt(&self) -> RvoipResult<OutboundActivation> {
118        let call_id = self.call_id.lock().clone().ok_or(RvoipError::InvalidState(
119            "Vapi call activation has no call identifier",
120        ))?;
121        let reference = ExternalConnectionReference::new(VAPI_CALL_REFERENCE_KIND, call_id)
122            .map_err(|_| RvoipError::Adapter("Vapi returned an invalid call identifier".into()))?;
123        Ok(OutboundActivation::with_external_reference(reference))
124    }
125
126    fn take_event_receiver(&self) -> broadcast::Receiver<VapiEvent> {
127        self.initial_events
128            .lock()
129            .take()
130            .unwrap_or_else(|| self.events.subscribe())
131    }
132}
133
134struct RuntimeEnvironment {
135    config: VapiConfig,
136    routes: Arc<DashMap<ConnectionId, Arc<Route>>>,
137    adapter_events: mpsc::Sender<AdapterEvent>,
138    vapi_events: broadcast::Sender<VapiEventEnvelope>,
139    lifecycle: AdapterLifecycleSinkSlot,
140}
141
142#[derive(Clone, Copy)]
143enum ActivationFailure {
144    Vapi(VapiError),
145    Cancelled,
146    EventQueueFull,
147}
148
149impl ActivationFailure {
150    fn into_rvoip(self, connection_id: &ConnectionId) -> RvoipError {
151        match self {
152            Self::Vapi(error) => error.into(),
153            Self::Cancelled => RvoipError::ConnectionNotFound(connection_id.clone()),
154            Self::EventQueueFull => {
155                RvoipError::AdmissionRejected("Vapi adapter event queue is full")
156            }
157        }
158    }
159}
160
161/// An outbound-only Vapi interop adapter.
162pub struct VapiAdapter {
163    config: VapiConfig,
164    http: VapiHttpClient,
165    routes: Arc<DashMap<ConnectionId, Arc<Route>>>,
166    adapter_events_tx: mpsc::Sender<AdapterEvent>,
167    adapter_events_rx: StdMutex<Option<mpsc::Receiver<AdapterEvent>>>,
168    vapi_events: broadcast::Sender<VapiEventEnvelope>,
169    lifecycle: AdapterLifecycleSinkSlot,
170}
171
172impl VapiAdapter {
173    pub fn new(config: VapiConfig) -> Result<Arc<Self>> {
174        config.validate()?;
175        let http = VapiHttpClient::new(&config)?;
176        let (adapter_events_tx, adapter_events_rx) = mpsc::channel(ADAPTER_EVENT_CAPACITY);
177        let (vapi_events, _) = broadcast::channel(config.event_queue_capacity);
178        Ok(Arc::new(Self {
179            config,
180            http,
181            routes: Arc::new(DashMap::new()),
182            adapter_events_tx,
183            adapter_events_rx: StdMutex::new(Some(adapter_events_rx)),
184            vapi_events,
185            lifecycle: AdapterLifecycleSinkSlot::default(),
186        }))
187    }
188
189    pub fn subscribe_vapi_events(&self) -> broadcast::Receiver<VapiEventEnvelope> {
190        self.vapi_events.subscribe()
191    }
192
193    pub fn subscribe_call_events(
194        &self,
195        connection_id: &ConnectionId,
196    ) -> RvoipResult<broadcast::Receiver<VapiEvent>> {
197        self.routes
198            .get(connection_id)
199            .map(|route| route.take_event_receiver())
200            .ok_or_else(|| RvoipError::ConnectionNotFound(connection_id.clone()))
201    }
202
203    pub(crate) fn call_event_sender(
204        &self,
205        connection_id: &ConnectionId,
206    ) -> RvoipResult<broadcast::Sender<VapiEvent>> {
207        self.routes
208            .get(connection_id)
209            .map(|route| route.events.clone())
210            .ok_or_else(|| RvoipError::ConnectionNotFound(connection_id.clone()))
211    }
212
213    pub async fn say(
214        &self,
215        connection_id: &ConnectionId,
216        content: impl Into<String>,
217        end_call_after_spoken: bool,
218        interrupt_assistant: bool,
219    ) -> Result<()> {
220        let content = content.into();
221        if content.trim().is_empty() {
222            return Err(VapiError::InvalidCallOptions(
223                "say content must not be empty",
224            ));
225        }
226        self.send_command(
227            connection_id,
228            VapiCommand::Say {
229                content,
230                end_call_after_spoken,
231                interrupt_assistant_enabled: interrupt_assistant,
232            },
233        )
234        .await
235    }
236
237    pub async fn add_message(
238        &self,
239        connection_id: &ConnectionId,
240        role: impl Into<String>,
241        content: impl Into<String>,
242        trigger_response: bool,
243    ) -> Result<()> {
244        let role = role.into();
245        let content = content.into();
246        if !matches!(role.as_str(), "user" | "assistant" | "system") || content.trim().is_empty() {
247            return Err(VapiError::InvalidCallOptions(
248                "the added message role or content is invalid",
249            ));
250        }
251        self.send_command(
252            connection_id,
253            VapiCommand::AddMessage {
254                message: AddedMessage { role, content },
255                trigger_response_enabled: trigger_response,
256            },
257        )
258        .await
259    }
260
261    pub async fn mute_assistant(&self, connection_id: &ConnectionId) -> Result<()> {
262        self.send_control(connection_id, "mute-assistant").await
263    }
264
265    pub async fn unmute_assistant(&self, connection_id: &ConnectionId) -> Result<()> {
266        self.send_control(connection_id, "unmute-assistant").await
267    }
268
269    async fn send_control(
270        &self,
271        connection_id: &ConnectionId,
272        control: &'static str,
273    ) -> Result<()> {
274        self.send_command(connection_id, VapiCommand::Control { control })
275            .await
276    }
277
278    async fn send_command(&self, connection_id: &ConnectionId, command: VapiCommand) -> Result<()> {
279        let route = self
280            .routes
281            .get(connection_id)
282            .map(|entry| Arc::clone(entry.value()))
283            .ok_or(VapiError::NotActive)?;
284        if !route.active.load(Ordering::Acquire) || !route.live.load(Ordering::Acquire) {
285            return Err(VapiError::NotActive);
286        }
287        let serialized =
288            serde_json::to_string(&command).map_err(|_| VapiError::ControlQueueUnavailable)?;
289        if serialized.len() > self.config.max_message_bytes {
290            return Err(VapiError::ControlMessageTooLarge);
291        }
292        let sender = route
293            .command_tx
294            .lock()
295            .clone()
296            .ok_or(VapiError::NotActive)?;
297        sender
298            .try_send(serialized)
299            .map_err(|_| VapiError::ControlQueueUnavailable)
300    }
301
302    fn runtime_environment(&self) -> RuntimeEnvironment {
303        RuntimeEnvironment {
304            config: self.config.clone(),
305            routes: Arc::clone(&self.routes),
306            adapter_events: self.adapter_events_tx.clone(),
307            vapi_events: self.vapi_events.clone(),
308            lifecycle: self.lifecycle.clone(),
309        }
310    }
311
312    async fn activate_route(&self, route: Arc<Route>) -> RvoipResult<OutboundActivation> {
313        let mut activation_result = route.activation_result.subscribe();
314        if route
315            .activation_started
316            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
317            .is_ok()
318        {
319            let environment = self.runtime_environment();
320            let http = self.http.clone();
321            let worker_route = Arc::clone(&route);
322            tokio::spawn(async move {
323                let result =
324                    activate_route_worker(&environment, &http, Arc::clone(&worker_route)).await;
325                worker_route.activation_result.send_replace(Some(result));
326            });
327        }
328        loop {
329            if let Some(result) = activation_result.borrow().clone() {
330                return result.map_err(|failure| failure.into_rvoip(&route.connection_id));
331            }
332            activation_result.changed().await.map_err(|_| {
333                RvoipError::InvalidState("Vapi activation owner stopped unexpectedly")
334            })?;
335        }
336    }
337
338    async fn terminate(&self, connection_id: ConnectionId, reason: EndReason) -> RvoipResult<()> {
339        let route = self
340            .routes
341            .get(&connection_id)
342            .map(|entry| Arc::clone(entry.value()))
343            .ok_or_else(|| RvoipError::ConnectionNotFound(connection_id.clone()))?;
344        *route.requested_end_reason.lock() = Some(reason.clone());
345        route.cancel.cancel();
346
347        if !route.active.load(Ordering::Acquire) {
348            finish_route(
349                &self.runtime_environment(),
350                Arc::clone(&route),
351                SessionOutcome::Local(reason),
352            )
353            .await;
354            return Ok(());
355        }
356        if tokio::time::timeout(
357            self.config.graceful_shutdown_timeout,
358            route.finished.cancelled(),
359        )
360        .await
361        .is_err()
362        {
363            let route = self
364                .routes
365                .get(&connection_id)
366                .map(|entry| Arc::clone(entry.value()));
367            if let Some(route) = route {
368                finish_route(
369                    &self.runtime_environment(),
370                    route,
371                    SessionOutcome::Local(reason),
372                )
373                .await;
374            }
375        }
376        Ok(())
377    }
378}
379
380#[async_trait]
381impl ConnectionAdapter for VapiAdapter {
382    fn transport(&self) -> Transport {
383        Transport::Vapi
384    }
385
386    fn kind(&self) -> AdapterKind {
387        AdapterKind::Interop
388    }
389
390    fn lifecycle_capabilities(&self) -> AdapterLifecycleCapabilities {
391        AdapterLifecycleCapabilities {
392            authoritative_liveness: true,
393            atomic_inbound_handoff: true,
394            terminal_fallback: true,
395            staged_outbound_activation: true,
396        }
397    }
398
399    fn install_lifecycle_sink(&self, sink: Arc<dyn AdapterLifecycleSink>) -> RvoipResult<()> {
400        self.lifecycle
401            .install(sink)
402            .map_err(|_| RvoipError::InvalidState("Vapi lifecycle sink already installed"))
403    }
404
405    fn is_connection_live(&self, connection_id: &ConnectionId) -> bool {
406        self.routes
407            .get(connection_id)
408            .is_some_and(|route| route.live.load(Ordering::Acquire))
409    }
410
411    async fn originate(&self, request: OriginateRequest) -> RvoipResult<ConnectionHandle> {
412        if request.direction != Direction::Outbound {
413            return Err(RvoipError::AdmissionRejected(
414                "Vapi originate requires outbound direction",
415            ));
416        }
417        let options = request.context.downcast_arc::<VapiCallOptions>().ok_or(
418            RvoipError::AdmissionRejected("Vapi originate requires VapiCallOptions context"),
419        )?;
420        options.validate().map_err(RvoipError::from)?;
421
422        let connection_id = ConnectionId::new();
423        let route = Route::new(
424            connection_id.clone(),
425            options.as_ref().clone(),
426            &self.config,
427        );
428        let codec = options.audio_format.codec();
429        let connection = Connection {
430            id: connection_id.clone(),
431            session_id: request.session_id,
432            participant_id: request.participant_id,
433            transport: Transport::Vapi,
434            direction: Direction::Outbound,
435            state: ConnectionState::Connecting,
436            capabilities: options.audio_format.capabilities(),
437            negotiated_codecs: NegotiatedCodecs {
438                audio: Some(codec),
439                video: None,
440            },
441            streams: vec![MediaStreamHandle::new(
442                Arc::clone(&route.stream) as Arc<dyn MediaStream>
443            )],
444            messaging_enabled: false,
445            transport_handle: TransportHandle(Arc::new(VapiTransportHandle {
446                connection_id: connection_id.clone(),
447            })),
448            opened_at: Utc::now(),
449            closed_at: None,
450        };
451        if self.routes.insert(connection_id, route).is_some() {
452            return Err(RvoipError::AdmissionRejected(
453                "Vapi provisional connection ID collided",
454            ));
455        }
456        Ok(ConnectionHandle::new(connection))
457    }
458
459    async fn activate_outbound(&self, connection_id: ConnectionId) -> RvoipResult<()> {
460        self.activate_outbound_with_receipt(connection_id)
461            .await
462            .map(|_| ())
463    }
464
465    async fn activate_outbound_with_receipt(
466        &self,
467        connection_id: ConnectionId,
468    ) -> RvoipResult<OutboundActivation> {
469        let route = self
470            .routes
471            .get(&connection_id)
472            .map(|entry| Arc::clone(entry.value()))
473            .ok_or_else(|| RvoipError::ConnectionNotFound(connection_id))?;
474        self.activate_route(route).await
475    }
476
477    async fn accept(&self, connection_id: ConnectionId) -> RvoipResult<()> {
478        if self.is_connection_live(&connection_id) {
479            Ok(())
480        } else {
481            Err(RvoipError::ConnectionNotFound(connection_id))
482        }
483    }
484
485    async fn reject(&self, connection_id: ConnectionId, _reason: RejectReason) -> RvoipResult<()> {
486        self.terminate(connection_id, EndReason::Cancelled).await
487    }
488
489    async fn end(&self, connection_id: ConnectionId, reason: EndReason) -> RvoipResult<()> {
490        self.terminate(connection_id, reason).await
491    }
492
493    async fn hold(&self, _connection_id: ConnectionId) -> RvoipResult<()> {
494        Err(RvoipError::NotImplemented(
495            "Vapi transport hold is not supported",
496        ))
497    }
498
499    async fn resume(&self, _connection_id: ConnectionId) -> RvoipResult<()> {
500        Err(RvoipError::NotImplemented(
501            "Vapi transport resume is not supported",
502        ))
503    }
504
505    async fn transfer(
506        &self,
507        _connection_id: ConnectionId,
508        _target: TransferTarget,
509    ) -> RvoipResult<()> {
510        Err(RvoipError::NotImplemented(
511            "Vapi transport transfer remains owned by rvoip",
512        ))
513    }
514
515    async fn streams(&self, connection_id: ConnectionId) -> RvoipResult<Vec<Arc<dyn MediaStream>>> {
516        let route = self
517            .routes
518            .get(&connection_id)
519            .map(|entry| Arc::clone(entry.value()))
520            .ok_or_else(|| RvoipError::ConnectionNotFound(connection_id.clone()))?;
521        if !route.active.load(Ordering::Acquire) || !route.live.load(Ordering::Acquire) {
522            return Ok(Vec::new());
523        }
524        Ok(vec![Arc::clone(&route.stream) as Arc<dyn MediaStream>])
525    }
526
527    async fn send_message(
528        &self,
529        _connection_id: ConnectionId,
530        _message: Message,
531    ) -> RvoipResult<()> {
532        Err(RvoipError::NotImplemented(
533            "use VapiAdapter::add_message for Vapi context injection",
534        ))
535    }
536
537    async fn send_dtmf(
538        &self,
539        _connection_id: ConnectionId,
540        _digits: &str,
541        _duration_ms: u32,
542    ) -> RvoipResult<()> {
543        Err(RvoipError::NotImplemented(
544            "Vapi WebSocket transport has no DTMF input message",
545        ))
546    }
547
548    async fn renegotiate_media(
549        &self,
550        _connection_id: ConnectionId,
551        _capabilities: CapabilityDescriptor,
552    ) -> RvoipResult<NegotiatedCodecs> {
553        Err(RvoipError::NotImplemented(
554            "Vapi raw-audio format is fixed for the call lifetime",
555        ))
556    }
557
558    async fn mute(&self, connection_id: ConnectionId, direction: MuteDirection) -> RvoipResult<()> {
559        match direction {
560            MuteDirection::Send | MuteDirection::Both => {
561                return Err(RvoipError::NotImplemented(
562                    "Vapi customer-input mute is not a documented WebSocket control",
563                ));
564            }
565            MuteDirection::Receive => self.send_control(&connection_id, "mute-assistant").await?,
566        }
567        Ok(())
568    }
569
570    async fn unmute(
571        &self,
572        connection_id: ConnectionId,
573        direction: MuteDirection,
574    ) -> RvoipResult<()> {
575        match direction {
576            MuteDirection::Send | MuteDirection::Both => {
577                return Err(RvoipError::NotImplemented(
578                    "Vapi customer-input unmute is not a documented WebSocket control",
579                ));
580            }
581            MuteDirection::Receive => {
582                self.send_control(&connection_id, "unmute-assistant")
583                    .await?
584            }
585        }
586        Ok(())
587    }
588
589    fn subscribe_events(&self) -> mpsc::Receiver<AdapterEvent> {
590        match self
591            .adapter_events_rx
592            .lock()
593            .unwrap_or_else(|poisoned| poisoned.into_inner())
594            .take()
595        {
596            Some(receiver) => receiver,
597            None => {
598                warn!("VapiAdapter::subscribe_events called more than once");
599                mpsc::channel(1).1
600            }
601        }
602    }
603
604    fn capabilities(&self) -> CapabilityDescriptor {
605        CapabilityDescriptor {
606            audio_codecs: vec![
607                crate::types::VapiAudioFormat::MuLaw8Khz.codec(),
608                crate::types::VapiAudioFormat::PcmS16Le16Khz.codec(),
609            ],
610            max_streams_per_connection: 1,
611            ..CapabilityDescriptor::default()
612        }
613    }
614
615    async fn verify_request_signature(
616        &self,
617        _connection_id: ConnectionId,
618        _signature: SignatureHeaders,
619    ) -> RvoipResult<IdentityAssurance> {
620        Ok(IdentityAssurance::Anonymous)
621    }
622}
623
624enum SessionOutcome {
625    Local(EndReason),
626    RemoteEnded,
627    RemoteClosed,
628    Failed(&'static str),
629}
630
631async fn activate_route_worker(
632    environment: &RuntimeEnvironment,
633    http: &VapiHttpClient,
634    route: Arc<Route>,
635) -> std::result::Result<OutboundActivation, ActivationFailure> {
636    if route.cancel.is_cancelled() || !route.live.load(Ordering::Acquire) {
637        return Err(ActivationFailure::Cancelled);
638    }
639
640    // This request deliberately lives in a detached worker. Cancellation of
641    // the caller awaiting `activate_outbound` cannot abandon an ambiguous
642    // POST that may already have created a billable remote call.
643    let created = match http.create_call(&environment.config, &route.options).await {
644        Ok(created) => created,
645        Err(error) => {
646            metrics::counter!("rvoip_vapi_setup_failures_total", "stage" => "call_creation")
647                .increment(1);
648            return Err(ActivationFailure::Vapi(error));
649        }
650    };
651    *route.call_id.lock() = Some(created.id);
652    let receipt = match route.activation_receipt() {
653        Ok(receipt) => receipt,
654        Err(_) => {
655            metrics::counter!("rvoip_vapi_setup_failures_total", "stage" => "call_response")
656                .increment(1);
657            cleanup_created_call(environment.config.clone(), created.websocket_url).await;
658            return Err(ActivationFailure::Vapi(VapiError::InvalidResponse(
659                "the call ID was invalid",
660            )));
661        }
662    };
663
664    if route.cancel.is_cancelled() || !route.live.load(Ordering::Acquire) {
665        cleanup_created_call(environment.config.clone(), created.websocket_url).await;
666        return Err(ActivationFailure::Cancelled);
667    }
668
669    let mut socket = match connect_websocket(&environment.config, &created.websocket_url).await {
670        Ok(socket) => socket,
671        Err(error) => {
672            metrics::counter!("rvoip_vapi_setup_failures_total", "stage" => "websocket")
673                .increment(1);
674            let cleanup_config = environment.config.clone();
675            let cleanup_url = created.websocket_url;
676            tokio::spawn(async move {
677                cleanup_created_call(cleanup_config, cleanup_url).await;
678            });
679            return Err(ActivationFailure::Vapi(error));
680        }
681    };
682    if route.cancel.is_cancelled() || !route.live.load(Ordering::Acquire) {
683        close_vapi_socket(&environment.config, &mut socket).await;
684        return Err(ActivationFailure::Cancelled);
685    }
686
687    let outgoing = match route.stream.take_outgoing_receiver() {
688        Ok(outgoing) => outgoing,
689        Err(error) => {
690            metrics::counter!("rvoip_vapi_setup_failures_total", "stage" => "media").increment(1);
691            close_vapi_socket(&environment.config, &mut socket).await;
692            return Err(ActivationFailure::Vapi(error));
693        }
694    };
695    let (command_tx, command_rx) = mpsc::channel(environment.config.control_queue_capacity);
696    *route.command_tx.lock() = Some(command_tx);
697    route.active.store(true, Ordering::Release);
698    route.stream.activate();
699
700    if route.cancel.is_cancelled() || !route.live.load(Ordering::Acquire) {
701        route.active.store(false, Ordering::Release);
702        route.stream.deactivate();
703        route.command_tx.lock().take();
704        close_vapi_socket(&environment.config, &mut socket).await;
705        let reason = route
706            .requested_end_reason
707            .lock()
708            .clone()
709            .unwrap_or(EndReason::Cancelled);
710        finish_route(
711            environment,
712            Arc::clone(&route),
713            SessionOutcome::Local(reason),
714        )
715        .await;
716        return Err(ActivationFailure::Cancelled);
717    }
718    // Serialize publication against terminal teardown. This guarantees that a
719    // terminal event can neither overtake `Connected` nor disappear in the
720    // interval between queue admission and the published flag becoming visible.
721    let publication = {
722        let _publication = route.publication.lock();
723        if route.cancel.is_cancelled()
724            || !route.live.load(Ordering::Acquire)
725            || route.terminal.load(Ordering::Acquire)
726        {
727            Err(ActivationFailure::Cancelled)
728        } else {
729            route.published.store(true, Ordering::Release);
730            if environment
731                .adapter_events
732                .try_send(AdapterEvent::Connected {
733                    connection_id: route.connection_id.clone(),
734                })
735                .is_err()
736            {
737                route.published.store(false, Ordering::Release);
738                Err(ActivationFailure::EventQueueFull)
739            } else {
740                Ok(())
741            }
742        }
743    };
744    if let Err(failure) = publication {
745        if matches!(failure, ActivationFailure::EventQueueFull) {
746            metrics::counter!("rvoip_vapi_setup_failures_total", "stage" => "publication")
747                .increment(1);
748        }
749        route.active.store(false, Ordering::Release);
750        route.stream.deactivate();
751        route.command_tx.lock().take();
752        close_vapi_socket(&environment.config, &mut socket).await;
753        let reason = route
754            .requested_end_reason
755            .lock()
756            .clone()
757            .unwrap_or(EndReason::Cancelled);
758        finish_route(
759            environment,
760            Arc::clone(&route),
761            SessionOutcome::Local(reason),
762        )
763        .await;
764        return Err(failure);
765    }
766    if route.cancel.is_cancelled()
767        || !route.live.load(Ordering::Acquire)
768        || route.terminal.load(Ordering::Acquire)
769    {
770        close_vapi_socket(&environment.config, &mut socket).await;
771        let reason = route
772            .requested_end_reason
773            .lock()
774            .clone()
775            .unwrap_or(EndReason::Cancelled);
776        finish_route(
777            environment,
778            Arc::clone(&route),
779            SessionOutcome::Local(reason),
780        )
781        .await;
782        return Err(ActivationFailure::Cancelled);
783    }
784
785    let runtime_environment = RuntimeEnvironment {
786        config: environment.config.clone(),
787        routes: Arc::clone(&environment.routes),
788        adapter_events: environment.adapter_events.clone(),
789        vapi_events: environment.vapi_events.clone(),
790        lifecycle: environment.lifecycle.clone(),
791    };
792    let task_route = Arc::clone(&route);
793    tokio::spawn(async move {
794        let outcome = run_websocket_session(
795            &runtime_environment.config,
796            &runtime_environment.vapi_events,
797            &task_route,
798            socket,
799            outgoing,
800            command_rx,
801        )
802        .await;
803        finish_route(&runtime_environment, task_route, outcome).await;
804    });
805    Ok(receipt)
806}
807
808async fn cleanup_created_call(config: VapiConfig, websocket_url: url::Url) {
809    if let Ok(mut socket) = connect_websocket(&config, &websocket_url).await {
810        close_vapi_socket(&config, &mut socket).await;
811    }
812}
813
814async fn close_vapi_socket(config: &VapiConfig, socket: &mut VapiSocket) {
815    let shutdown = async {
816        let _ = socket
817            .send(WebSocketMessage::Text(r#"{"type":"end-call"}"#.into()))
818            .await;
819        let _ = socket.close(None).await;
820    };
821    let _ = tokio::time::timeout(config.graceful_shutdown_timeout, shutdown).await;
822}
823
824enum SessionSendFailure {
825    Cancelled,
826    Failed,
827}
828
829async fn cancellable_session_send(
830    config: &VapiConfig,
831    route: &Route,
832    socket: &mut VapiSocket,
833    message: WebSocketMessage,
834) -> std::result::Result<(), SessionSendFailure> {
835    tokio::select! {
836        _ = route.cancel.cancelled() => Err(SessionSendFailure::Cancelled),
837        result = send_with_timeout(config.websocket_io_timeout, socket, message) => {
838            result.map_err(|_| SessionSendFailure::Failed)
839        }
840    }
841}
842
843fn requested_local_outcome(route: &Route) -> SessionOutcome {
844    SessionOutcome::Local(
845        route
846            .requested_end_reason
847            .lock()
848            .clone()
849            .unwrap_or(EndReason::Normal),
850    )
851}
852
853async fn run_websocket_session(
854    config: &VapiConfig,
855    global_events: &broadcast::Sender<VapiEventEnvelope>,
856    route: &Arc<Route>,
857    mut socket: VapiSocket,
858    mut outgoing: mpsc::Receiver<rvoip_core::MediaFrame>,
859    mut commands: mpsc::Receiver<String>,
860) -> SessionOutcome {
861    let mut incoming_framer = AudioFramer::new(route.options.audio_format);
862    let mut outgoing_framer = AudioFramer::new(route.options.audio_format);
863    let mut incoming_frames = VecDeque::<Bytes>::new();
864    let mut outgoing_frames = VecDeque::<Bytes>::new();
865    let mut timestamp_rtp = 0u32;
866    let mut incoming_tick = tokio::time::interval(std::time::Duration::from_millis(20));
867    incoming_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
868    incoming_tick.tick().await;
869    let mut outgoing_tick = tokio::time::interval(std::time::Duration::from_millis(20));
870    outgoing_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
871    outgoing_tick.tick().await;
872    let mut heartbeat = tokio::time::interval(config.heartbeat_interval);
873    heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
874    heartbeat.tick().await;
875    let heartbeat_deadline = tokio::time::sleep(config.websocket_io_timeout);
876    tokio::pin!(heartbeat_deadline);
877    let mut awaiting_heartbeat_response = false;
878
879    let outcome = loop {
880        tokio::select! {
881            _ = route.cancel.cancelled() => {
882                break requested_local_outcome(route);
883            }
884            _ = &mut heartbeat_deadline, if awaiting_heartbeat_response => {
885                break SessionOutcome::Failed("Vapi WebSocket heartbeat response timed out");
886            }
887            _ = incoming_tick.tick(), if !incoming_frames.is_empty() => {
888                if route.stream.incoming_is_closed() {
889                    break SessionOutcome::Local(EndReason::BridgeTorn);
890                }
891                if !route.stream.incoming_has_capacity() {
892                    continue;
893                }
894                let Some(frame) = incoming_frames.pop_front() else {
895                    continue;
896                };
897                if route.stream.try_push_incoming(frame, timestamp_rtp).is_err() {
898                    metrics::counter!("rvoip_vapi_queue_overflow_total", "direction" => "in").increment(1);
899                    break SessionOutcome::Failed("Vapi inbound media queue overflow");
900                }
901                timestamp_rtp = timestamp_rtp
902                    .wrapping_add(route.options.audio_format.timestamp_increment());
903                metrics::counter!("rvoip_vapi_audio_frames_total", "direction" => "in").increment(1);
904            }
905            _ = outgoing_tick.tick(), if !outgoing_frames.is_empty() => {
906                let Some(frame) = outgoing_frames.pop_front() else {
907                    continue;
908                };
909                match cancellable_session_send(
910                    config,
911                    route,
912                    &mut socket,
913                    WebSocketMessage::Binary(frame),
914                ).await {
915                    Ok(()) => {}
916                    Err(SessionSendFailure::Cancelled) => break requested_local_outcome(route),
917                    Err(SessionSendFailure::Failed) => {
918                        break SessionOutcome::Failed("Vapi WebSocket audio write failed");
919                    }
920                }
921                metrics::counter!("rvoip_vapi_audio_frames_total", "direction" => "out").increment(1);
922            }
923            _ = heartbeat.tick() => {
924                if awaiting_heartbeat_response {
925                    continue;
926                }
927                match cancellable_session_send(
928                    config,
929                    route,
930                    &mut socket,
931                    WebSocketMessage::Ping(Bytes::new()),
932                ).await {
933                    Ok(()) => {}
934                    Err(SessionSendFailure::Cancelled) => break requested_local_outcome(route),
935                    Err(SessionSendFailure::Failed) => {
936                        break SessionOutcome::Failed("Vapi WebSocket heartbeat failed");
937                    }
938                }
939                heartbeat_deadline
940                    .as_mut()
941                    .reset(tokio::time::Instant::now() + config.websocket_io_timeout);
942                awaiting_heartbeat_response = true;
943            }
944            media = outgoing.recv() => {
945                let Some(media) = media else {
946                    break SessionOutcome::Local(EndReason::BridgeTorn);
947                };
948                if media.payload_type == Some(101) {
949                    metrics::counter!("rvoip_vapi_dtmf_frames_dropped_total").increment(1);
950                    continue;
951                }
952                if append_bounded_frames(
953                    &mut outgoing_framer,
954                    &media.payload,
955                    &mut outgoing_frames,
956                    config.media_queue_capacity,
957                    config.max_message_bytes,
958                ).is_err() {
959                    metrics::counter!("rvoip_vapi_queue_overflow_total", "direction" => "out").increment(1);
960                    break SessionOutcome::Failed("Vapi outbound media queue overflow");
961                }
962            }
963            command = commands.recv() => {
964                let Some(json) = command else {
965                    break SessionOutcome::Local(EndReason::Normal);
966                };
967                match cancellable_session_send(
968                    config,
969                    route,
970                    &mut socket,
971                    WebSocketMessage::Text(json.into()),
972                ).await {
973                    Ok(()) => {}
974                    Err(SessionSendFailure::Cancelled) => break requested_local_outcome(route),
975                    Err(SessionSendFailure::Failed) => {
976                        break SessionOutcome::Failed("Vapi WebSocket control write failed");
977                    }
978                }
979            }
980            message = socket.next() => {
981                if matches!(message.as_ref(), Some(Ok(_))) {
982                    awaiting_heartbeat_response = false;
983                }
984                match message {
985                    Some(Ok(WebSocketMessage::Binary(payload))) => {
986                        let max_queued = config
987                            .startup_audio_frames
988                            .saturating_sub(route.stream.incoming_pending_frames());
989                        if append_bounded_frames(
990                            &mut incoming_framer,
991                            &payload,
992                            &mut incoming_frames,
993                            max_queued,
994                            config.max_message_bytes,
995                        ).is_err() {
996                            metrics::counter!("rvoip_vapi_queue_overflow_total", "direction" => "in").increment(1);
997                            break SessionOutcome::Failed("Vapi inbound media buffer overflow");
998                        }
999                    }
1000                    Some(Ok(WebSocketMessage::Text(text))) => {
1001                        if text.len() > config.max_message_bytes {
1002                            break SessionOutcome::Failed("Vapi event exceeded size limit");
1003                        }
1004                        let event = VapiEvent::parse(text.as_str());
1005                        let terminal = event.is_terminal_status();
1006                        publish_vapi_event(global_events, route, event);
1007                        if terminal {
1008                            break SessionOutcome::RemoteEnded;
1009                        }
1010                    }
1011                    Some(Ok(WebSocketMessage::Ping(payload))) => {
1012                        match cancellable_session_send(
1013                            config,
1014                            route,
1015                            &mut socket,
1016                            WebSocketMessage::Pong(payload),
1017                        ).await {
1018                            Ok(()) => {}
1019                            Err(SessionSendFailure::Cancelled) => break requested_local_outcome(route),
1020                            Err(SessionSendFailure::Failed) => {
1021                                break SessionOutcome::Failed("Vapi WebSocket pong failed");
1022                            }
1023                        }
1024                    }
1025                    Some(Ok(WebSocketMessage::Pong(_))) => {}
1026                    Some(Ok(WebSocketMessage::Close(frame))) => {
1027                        break match frame.map(|frame| frame.code) {
1028                            Some(CloseCode::Normal | CloseCode::Away) => {
1029                                SessionOutcome::RemoteEnded
1030                            }
1031                            _ => SessionOutcome::RemoteClosed,
1032                        };
1033                    }
1034                    None => break SessionOutcome::RemoteClosed,
1035                    Some(Ok(WebSocketMessage::Frame(_))) => {}
1036                    Some(Err(_)) => {
1037                        break SessionOutcome::Failed("Vapi WebSocket read failed");
1038                    }
1039                }
1040            }
1041        }
1042    };
1043
1044    match &outcome {
1045        SessionOutcome::Local(_) => {
1046            wait_for_vapi_shutdown(config, global_events, route, &mut socket).await;
1047        }
1048        _ => {
1049            let _ =
1050                tokio::time::timeout(config.graceful_shutdown_timeout, socket.close(None)).await;
1051        }
1052    }
1053    outcome
1054}
1055
1056async fn wait_for_vapi_shutdown(
1057    config: &VapiConfig,
1058    global_events: &broadcast::Sender<VapiEventEnvelope>,
1059    route: &Route,
1060    socket: &mut VapiSocket,
1061) {
1062    let deadline = tokio::time::Instant::now() + config.graceful_shutdown_timeout;
1063    if !matches!(
1064        tokio::time::timeout_at(
1065            deadline,
1066            socket.send(WebSocketMessage::Text(r#"{"type":"end-call"}"#.into())),
1067        )
1068        .await,
1069        Ok(Ok(()))
1070    ) {
1071        return;
1072    }
1073
1074    loop {
1075        match tokio::time::timeout_at(deadline, socket.next()).await {
1076            Ok(Some(Ok(WebSocketMessage::Text(text)))) => {
1077                if text.len() > config.max_message_bytes {
1078                    break;
1079                }
1080                let event = VapiEvent::parse(text.as_str());
1081                let terminal = event.is_terminal_status();
1082                publish_vapi_event(global_events, route, event);
1083                if terminal {
1084                    break;
1085                }
1086            }
1087            Ok(Some(Ok(WebSocketMessage::Ping(payload)))) => {
1088                if !matches!(
1089                    tokio::time::timeout_at(
1090                        deadline,
1091                        socket.send(WebSocketMessage::Pong(payload)),
1092                    )
1093                    .await,
1094                    Ok(Ok(()))
1095                ) {
1096                    break;
1097                }
1098            }
1099            Ok(Some(Ok(
1100                WebSocketMessage::Binary(_)
1101                | WebSocketMessage::Pong(_)
1102                | WebSocketMessage::Frame(_),
1103            ))) => {}
1104            Ok(Some(Ok(WebSocketMessage::Close(_))) | Some(Err(_)) | None) | Err(_) => break,
1105        }
1106    }
1107
1108    if tokio::time::Instant::now() < deadline {
1109        let _ = tokio::time::timeout_at(deadline, socket.close(None)).await;
1110    }
1111}
1112
1113fn publish_vapi_event(
1114    global_events: &broadcast::Sender<VapiEventEnvelope>,
1115    route: &Route,
1116    event: VapiEvent,
1117) {
1118    if matches!(event, VapiEvent::Unknown(_)) {
1119        metrics::counter!("rvoip_vapi_unknown_events_total").increment(1);
1120    }
1121    if matches!(event, VapiEvent::Malformed { .. }) {
1122        metrics::counter!("rvoip_vapi_malformed_events_total").increment(1);
1123    }
1124    let _ = global_events.send(VapiEventEnvelope {
1125        connection_id: route.connection_id.clone(),
1126        event: event.clone(),
1127    });
1128    let _ = route.events.send(event);
1129}
1130
1131async fn finish_route(
1132    environment: &RuntimeEnvironment,
1133    route: Arc<Route>,
1134    outcome: SessionOutcome,
1135) {
1136    if route.terminal.swap(true, Ordering::AcqRel) {
1137        return;
1138    }
1139    let published = {
1140        let _publication = route.publication.lock();
1141        route.published.load(Ordering::Acquire)
1142    };
1143    route.live.store(false, Ordering::Release);
1144    route.active.store(false, Ordering::Release);
1145    route.stream.deactivate();
1146    route.cancel.cancel();
1147    route.command_tx.lock().take();
1148    route.call_id.lock().take();
1149
1150    let remove = environment
1151        .routes
1152        .get(&route.connection_id)
1153        .is_some_and(|current| Arc::ptr_eq(current.value(), &route));
1154    if remove {
1155        environment.routes.remove(&route.connection_id);
1156    }
1157
1158    match &outcome {
1159        SessionOutcome::RemoteEnded => {
1160            metrics::counter!("rvoip_vapi_disconnects_total", "outcome" => "normal").increment(1);
1161        }
1162        SessionOutcome::RemoteClosed => {
1163            metrics::counter!("rvoip_vapi_disconnects_total", "outcome" => "abnormal").increment(1);
1164        }
1165        SessionOutcome::Failed(_) => {
1166            metrics::counter!("rvoip_vapi_disconnects_total", "outcome" => "failed").increment(1);
1167        }
1168        SessionOutcome::Local(_) => {}
1169    }
1170
1171    if published {
1172        let terminal_event = match outcome {
1173            SessionOutcome::Local(reason) => AdapterEvent::Ended {
1174                connection_id: route.connection_id.clone(),
1175                reason,
1176            },
1177            SessionOutcome::RemoteEnded => AdapterEvent::Ended {
1178                connection_id: route.connection_id.clone(),
1179                reason: EndReason::Normal,
1180            },
1181            SessionOutcome::RemoteClosed => AdapterEvent::Failed {
1182                connection_id: route.connection_id.clone(),
1183                detail: "Vapi WebSocket closed without a terminal status".into(),
1184            },
1185            SessionOutcome::Failed(detail) => AdapterEvent::Failed {
1186                connection_id: route.connection_id.clone(),
1187                detail: detail.into(),
1188            },
1189        };
1190        let _ = environment
1191            .lifecycle
1192            .queue_or_deliver_terminal(&environment.adapter_events, terminal_event)
1193            .await;
1194    }
1195    route.finished.cancel();
1196    metrics::counter!("rvoip_vapi_sessions_terminated_total").increment(1);
1197}