Skip to main content

active_call/handler/
handler.rs

1use crate::media::cache;
2use crate::synthesis::{SynthesisEvent, SynthesisOption};
3use crate::{
4    app::AppState,
5    call::{
6        ActiveCall, ActiveCallType, Command,
7        active_call::{ActiveCallGuard, CallParams, CallSpec},
8    },
9    handler::playbook,
10    playbook::{Playbook, PlaybookRunner},
11};
12use crate::{event::SessionEvent, media::track::TrackConfig};
13use axum::{
14    Json, Router,
15    extract::{Path, Query, State, WebSocketUpgrade, ws::Message},
16    response::sse::{Event, KeepAlive, Sse},
17    response::{IntoResponse, Response},
18    routing::{get, post},
19};
20use bytes::Bytes;
21use chrono::Utc;
22use futures::{SinkExt, StreamExt};
23use rustrtc::IceServer;
24use serde_json::json;
25use std::collections::HashMap;
26use std::{path::PathBuf, sync::Arc, time::Duration};
27use tokio::{join, select};
28use tokio_util::sync::CancellationToken;
29use tracing::{debug, info, trace, warn};
30use uuid::Uuid;
31
32fn filter_headers(
33    extras: &mut std::collections::HashMap<String, serde_json::Value>,
34    allowed_headers: &[String],
35) {
36    extras.retain(|k, _| allowed_headers.iter().any(|h| h.eq_ignore_ascii_case(k)));
37}
38
39pub fn call_router() -> Router<AppState> {
40    let r = Router::new()
41        .route("/call", get(ws_handler))
42        .route("/call/webrtc", get(webrtc_handler))
43        .route("/call/sip", get(sip_handler))
44        .route("/list", get(list_active_calls))
45        .route("/kill/{id}", get(kill_active_call))
46        .route("/events/{id}", get(stream_events))
47        .route("/command/{id}", post(send_command))
48        .route("/precache", post(precache));
49    r
50}
51
52pub fn iceservers_router() -> Router<AppState> {
53    let r = Router::new();
54    r.route("/iceservers", get(get_iceservers))
55}
56
57pub fn playbook_router() -> Router<AppState> {
58    Router::new()
59        .route("/api/playbooks", get(playbook::list_playbooks))
60        .route(
61            "/api/playbooks/{name}",
62            get(playbook::get_playbook).post(playbook::save_playbook),
63        )
64        .route(
65            "/api/playbook/run",
66            axum::routing::post(playbook::run_playbook),
67        )
68        .route("/api/records", get(playbook::list_records))
69}
70
71pub async fn ws_handler(
72    ws: WebSocketUpgrade,
73    State(state): State<AppState>,
74    Query(params): Query<CallParams>,
75) -> Response {
76    call_handler(ActiveCallType::WebSocket, ws, state, params).await
77}
78
79pub async fn sip_handler(
80    ws: WebSocketUpgrade,
81    State(state): State<AppState>,
82    Query(params): Query<CallParams>,
83) -> Response {
84    call_handler(ActiveCallType::Sip, ws, state, params).await
85}
86
87pub async fn webrtc_handler(
88    ws: WebSocketUpgrade,
89    State(state): State<AppState>,
90    Query(params): Query<CallParams>,
91) -> Response {
92    call_handler(ActiveCallType::Webrtc, ws, state, params).await
93}
94
95/// Core call handling logic that works with either WebSocket or mpsc channels
96///
97/// `extras` and `playbook_name` are session-scoped parameters passed directly
98/// by the caller (SIP handler, CLI, etc.) instead of through global maps.
99/// Returns the final call extras (including `_hangup_headers` if set) so the
100/// caller can use them for SIP BYE or other post-call processing.
101pub async fn call_handler_core(
102    call_type: ActiveCallType,
103    session_id: String,
104    app_state: AppState,
105    cancel_token: CancellationToken,
106    audio_receiver: tokio::sync::mpsc::UnboundedReceiver<Bytes>,
107    server_side_track: Option<String>,
108    dump_events: bool,
109    ping_interval: u64,
110    mut command_receiver: tokio::sync::mpsc::UnboundedReceiver<Command>,
111    event_sender_to_client: tokio::sync::mpsc::UnboundedSender<crate::event::SessionEvent>,
112    extras: Option<HashMap<String, serde_json::Value>>,
113    playbook_name: Option<String>,
114) -> Option<HashMap<String, serde_json::Value>> {
115    let _cancel_guard = cancel_token.clone().drop_guard();
116    let track_config = TrackConfig::default();
117
118    let active_call = Arc::new(ActiveCall::new(CallSpec {
119        call_type: call_type.clone(),
120        cancel_token: cancel_token.clone(),
121        session_id: session_id.clone(),
122        invitation: app_state.invitation.clone(),
123        app_state: app_state.clone(),
124        track_config,
125        audio_receiver: Some(audio_receiver),
126        dump_events,
127        server_side_track_id: server_side_track.clone(),
128        extras,
129    }));
130
131    // Load playbook: prefer direct parameter, fall back to pending_playbooks
132    // (pending_playbooks is used by the run_playbook HTTP endpoint)
133    {
134        let name_or_content = playbook_name.or_else(|| {
135            app_state
136                .pending_playbooks
137                .try_lock()
138                .ok()
139                .and_then(|mut pending| pending.remove(&session_id).map(|(val, _)| val))
140        });
141        if let Some(name_or_content) = name_or_content {
142            let playbook_result = if name_or_content.trim().starts_with("---") {
143                Playbook::parse(&name_or_content)
144            } else {
145                // If path already contains config/playbook, use it as-is; otherwise prepend it
146                let path = if name_or_content.starts_with("config/playbook/") {
147                    PathBuf::from(&name_or_content)
148                } else {
149                    PathBuf::from("config/playbook").join(&name_or_content)
150                };
151                Playbook::load(path).await
152            };
153
154            match playbook_result {
155                Ok(mut playbook) => {
156                    // Filter extracted headers if configured (only for SIP calls)
157                    if call_type == ActiveCallType::Sip {
158                        if let Some(sip_config) = &playbook.config.sip {
159                            if let Some(allowed_headers) = &sip_config.extract_headers {
160                                let extras = active_call.extras.rcu(|e| {
161                                    let mut e = HashMap::clone(e);
162                                    filter_headers(&mut e, allowed_headers);
163                                    e
164                                });
165                                // Store the list of SIP header keys for later template rendering
166                                let header_keys: Vec<String> = extras
167                                    .keys()
168                                    .filter(|k| !k.starts_with('_'))
169                                    .cloned()
170                                    .collect();
171                                active_call.set_extra(
172                                    "_sip_header_keys",
173                                    serde_json::to_value(&header_keys).unwrap_or_default(),
174                                );
175                                if let Ok(result) = playbook.render(&active_call.extras.load_full())
176                                {
177                                    playbook = result;
178                                }
179                            }
180                        }
181                    }
182
183                    match PlaybookRunner::new(playbook, active_call.clone()) {
184                        Ok(runner) => {
185                            crate::spawn(async move {
186                                runner.run().await;
187                            });
188                            let display_name = if name_or_content.trim().starts_with("---") {
189                                "custom content"
190                            } else {
191                                &name_or_content
192                            };
193                            info!(session_id, "Playbook runner started for {}", display_name);
194                        }
195                        Err(e) => {
196                            let display_name = if name_or_content.trim().starts_with("---") {
197                                "custom content"
198                            } else {
199                                &name_or_content
200                            };
201                            warn!(
202                                session_id,
203                                "Failed to create runner {}: {}", display_name, e
204                            )
205                        }
206                    }
207                }
208                Err(e) => {
209                    let display_name = if name_or_content.trim().starts_with("---") {
210                        "custom content"
211                    } else {
212                        &name_or_content
213                    };
214                    warn!(
215                        session_id,
216                        "Failed to load playbook {}: {}", display_name, e
217                    );
218                    let event = SessionEvent::Error {
219                        timestamp: crate::media::get_timestamp(),
220                        track_id: session_id.clone(),
221                        sender: "playbook".to_string(),
222                        error: format!("{}", e),
223                        code: None,
224                    };
225                    event_sender_to_client.send(event).ok();
226                    return None;
227                }
228            }
229        }
230    }
231
232    let recv_commands_loop = async {
233        while let Some(command) = command_receiver.recv().await {
234            if let Err(_) = active_call.enqueue_command(command).await {
235                break;
236            }
237        }
238    };
239
240    let mut event_receiver = active_call.event_sender.subscribe();
241    let send_events_loop = async {
242        loop {
243            match event_receiver.recv().await {
244                Ok(event) => {
245                    if let Err(_) = event_sender_to_client.send(event) {
246                        break;
247                    }
248                }
249                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
250                Err(_) => break,
251            }
252        }
253    };
254
255    let send_ping_loop = async {
256        if ping_interval == 0 {
257            active_call.cancel_token.cancelled().await;
258            return;
259        }
260        let mut ticker = tokio::time::interval(Duration::from_secs(ping_interval));
261        loop {
262            ticker.tick().await;
263            let payload = Utc::now().to_rfc3339();
264            let event = SessionEvent::Ping {
265                timestamp: crate::media::get_timestamp(),
266                payload: Some(payload),
267            };
268            if let Err(_) = active_call.event_sender.send(event) {
269                break;
270            }
271        }
272    };
273
274    let guard = ActiveCallGuard::new(active_call.clone());
275    info!(
276        session_id,
277        active_calls = guard.active_calls,
278        ?call_type,
279        "new call started"
280    );
281    let receiver = active_call.new_receiver();
282
283    let (r, _) = join! {
284        active_call.clone().serve(receiver),
285        async {
286            select!{
287                _ = send_ping_loop => {},
288                _ = cancel_token.cancelled() => {},
289                _ = send_events_loop => { },
290                _ = recv_commands_loop => {
291                    info!(session_id, "Command receiver closed");
292                },
293            }
294            cancel_token.cancel();
295        }
296    };
297    // drain events
298    while let Ok(event) = event_receiver.try_recv() {
299        if let Err(_) = event_sender_to_client.send(event) {
300            break;
301        }
302    }
303    match r {
304        Ok(_) => info!(session_id, "call ended successfully"),
305        Err(e) => warn!(session_id, "call ended with error: {}", e),
306    }
307
308    // Capture final extras (including _hangup_headers) before cleanup
309    let final_extras = Some(active_call.extras.load_full().as_ref().clone());
310
311    active_call.cleanup().await.ok();
312    debug!(session_id, "Call handler core completed");
313
314    final_extras
315}
316
317pub async fn call_handler(
318    call_type: ActiveCallType,
319    ws: WebSocketUpgrade,
320    app_state: AppState,
321    params: CallParams,
322) -> Response {
323    let has_explicit_id = params.id.is_some();
324    let session_id = params
325        .id
326        .clone()
327        .unwrap_or_else(|| format!("s.{}", Uuid::new_v4().to_string()));
328    let server_side_track = params.server_side_track.clone();
329    let dump_events = params.dump_events.unwrap_or(true);
330    let ping_interval = params.ping_interval.unwrap_or(20);
331
332    // Only an empty `forward` may hop to peers. Any present value is local-only.
333    // `forward=true` probes 404 if the call is absent so the originator can try
334    // the next peer instead of creating a new call.
335    //
336    // A ringing inbound SIP call only exists as a pending dialog (registered by
337    // the INVITE handler), not yet in `active_calls` — the ActiveCall is
338    // created by the websocket attach below. Treat pending dialogs as hosted
339    // locally so the attach neither probes peers nor 404s (same predicate the
340    // ActiveCall itself uses to resolve its SIP dialog).
341    if has_explicit_id {
342        let found_locally = {
343            let active_calls = app_state.active_calls.lock().unwrap();
344            active_calls.contains_key(&session_id)
345        } || app_state
346            .invitation
347            .find_dialog_id_by_session_id(&session_id)
348            .is_some();
349        if !found_locally {
350            if params.forward.is_none() {
351                if let Some(peer_ws) =
352                    crate::handler::peer::try_forward(&app_state, &session_id, &params).await
353                {
354                    info!(session_id, "forwarding websocket to peer hosting the call");
355                    return ws.on_upgrade(move |socket| async move {
356                        crate::handler::peer::tunnel(socket, peer_ws).await;
357                    });
358                }
359            }
360            if params.forward == Some(true) {
361                warn!(
362                    session_id,
363                    "call not found on this node, rejecting forwarded request"
364                );
365                return (
366                    axum::http::StatusCode::NOT_FOUND,
367                    "call not found on this node",
368                )
369                    .into_response();
370            }
371        }
372    }
373
374    let resp = ws.on_upgrade(move |socket| async move {
375        let (mut ws_sender, mut ws_receiver) = socket.split();
376        let (audio_sender, audio_receiver) = tokio::sync::mpsc::unbounded_channel::<Bytes>();
377        let (command_sender, command_receiver) = tokio::sync::mpsc::unbounded_channel::<Command>();
378        let (event_sender_to_client, mut event_receiver_from_core) =
379            tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
380        let cancel_token = CancellationToken::new();
381
382        // Start core handler in background
383        let session_id_clone = session_id.clone();
384        let app_state_clone = app_state.clone();
385        let cancel_token_clone = cancel_token.clone();
386        crate::spawn(async move {
387            call_handler_core(
388                call_type,
389                session_id_clone,
390                app_state_clone,
391                cancel_token_clone,
392                audio_receiver,
393                server_side_track,
394                dump_events,
395                ping_interval.into(),
396                command_receiver,
397                event_sender_to_client,
398                None, // extras — not used for WebSocket calls
399                None, // playbook_name — falls back to pending_playbooks
400            )
401            .await;
402        });
403
404        // Handle WebSocket I/O
405        let recv_from_ws_loop = async {
406            while let Some(Ok(message)) = ws_receiver.next().await {
407                match message {
408                    Message::Text(text) => {
409                        let command = match serde_json::from_str::<Command>(&text) {
410                            Ok(cmd) => cmd,
411                            Err(e) => {
412                                warn!(session_id, %text, "Failed to parse command {}", e);
413                                continue;
414                            }
415                        };
416                        if let Err(_) = command_sender.send(command) {
417                            break;
418                        }
419                    }
420                    Message::Binary(bin) => {
421                        audio_sender.send(bin.into()).ok();
422                    }
423                    Message::Close(_) => {
424                        info!(session_id, "WebSocket closed by client");
425                        break;
426                    }
427                    _ => {}
428                }
429            }
430        };
431
432        let send_to_ws_loop = async {
433            while let Some(event) = event_receiver_from_core.recv().await {
434                trace!(session_id, %event, "Sending WS message");
435                let message = match event.into_ws_message() {
436                    Ok(msg) => msg,
437                    Err(e) => {
438                        warn!(session_id, error=%e, "Failed to serialize event to WS message");
439                        continue;
440                    }
441                };
442                if let Err(_) = ws_sender.send(message).await {
443                    info!(session_id, "WebSocket send failed, closing");
444                    break;
445                }
446            }
447        };
448
449        select! {
450            _ = recv_from_ws_loop => {
451                info!(session_id, "WebSocket receive loop ended");
452            },
453            _ = send_to_ws_loop => {
454                info!(session_id, "WebSocket send loop ended");
455            },
456        }
457
458        cancel_token.cancel();
459        ws_sender.flush().await.ok();
460        ws_sender.close().await.ok();
461        debug!(session_id, "WebSocket connection closed");
462    });
463    resp
464}
465
466pub(crate) async fn get_iceservers(State(state): State<AppState>) -> Response {
467    if let Some(ice_servers) = state.config.ice_servers.as_ref() {
468        return Json(ice_servers).into_response();
469    }
470    Json(vec![IceServer {
471        urls: vec!["stun:stun.l.google.com:19302".to_string()],
472        ..Default::default()
473    }])
474    .into_response()
475}
476
477pub(crate) async fn list_active_calls(State(state): State<AppState>) -> Response {
478    // Clone the call handles out of the registry lock first, then read each
479    // call's lock-free progress snapshot without holding the registry lock.
480    let calls = state
481        .active_calls
482        .lock()
483        .unwrap()
484        .iter()
485        .map(|(_, c)| c.clone())
486        .collect::<Vec<_>>();
487    let list = calls
488        .iter()
489        .map(|c| {
490            let progress = c.progress.load_full();
491            json!({
492                "id": c.session_id,
493                "callType": c.call_type,
494                "cs.option": progress.option,
495                "ringTime": progress.ring_time,
496                "startTime": progress.answer_time,
497            })
498        })
499        .collect::<Vec<_>>();
500    Json(serde_json::json!({ "active_calls": list })).into_response()
501}
502
503pub(crate) async fn kill_active_call(
504    Path(id): Path<String>,
505    State(state): State<AppState>,
506) -> Response {
507    let active_calls = state.active_calls.lock().unwrap();
508    if let Some(call) = active_calls.get(&id) {
509        call.cancel_token.cancel();
510        Json(serde_json::json!({ "status": "killed", "id": id })).into_response()
511    } else {
512        (
513            axum::http::StatusCode::NOT_FOUND,
514            Json(serde_json::json!({ "status": "not_found", "id": id })),
515        )
516            .into_response()
517    }
518}
519
520pub(crate) async fn stream_events(
521    Path(id): Path<String>,
522    State(state): State<AppState>,
523) -> Response {
524    let mut rx_events;
525    let mut rx_commands;
526    {
527        let active_calls = state.active_calls.lock().unwrap();
528        if let Some(call) = active_calls.get(&id) {
529            rx_events = call.event_sender.subscribe();
530            rx_commands = call.cmd_sender.subscribe();
531        } else {
532            return (axum::http::StatusCode::NOT_FOUND, "track not active").into_response();
533        }
534    }
535
536    let stream = async_stream::stream! {
537        loop {
538            let result = tokio::select! {
539                r = rx_events.recv() => r.map(|e| serde_json::to_string(&e).map(|json| Event::default().event("event").data(json))),
540                r = rx_commands.recv() => r.map(|c| serde_json::to_string(&c).map(|json| Event::default().event("command").data(json))),
541            };
542            match result {
543                Ok(Ok(sse_event)) => yield Ok::<Event, serde_json::Error>(sse_event),
544                Ok(Err(e)) => yield Err(e.into()),
545                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
546                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
547            }
548        }
549    };
550
551    let mut response = Sse::new(stream)
552        .keep_alive(KeepAlive::default())
553        .into_response();
554    response.headers_mut().insert(
555        axum::http::header::CONTENT_TYPE,
556        "text/event-stream;charset=utf-8".parse().unwrap(),
557    );
558    response
559}
560
561pub(crate) async fn send_command(
562    Path(id): Path<String>,
563    State(state): State<AppState>,
564    Json(command): Json<Command>,
565) -> Response {
566    let active_calls = state.active_calls.lock().unwrap();
567    if let Some(call) = active_calls.get(&id) {
568        if let Ok(_) = call.cmd_sender.send(command) {
569            return Json(serde_json::json!({ "status": "sent", "id": id })).into_response();
570        }
571    }
572
573    (
574        axum::http::StatusCode::NOT_FOUND,
575        Json(serde_json::json!({ "status": "not_found", "id": id })),
576    )
577        .into_response()
578}
579
580/// Pre-generate and cache TTS audio without an active call.
581///
582/// Accepts a `Tts` command (same shape as `/command/{id}`): it synthesizes the
583/// text and stores the audio under the exact cache key the real `do_tts` would
584/// use. A later `Tts` with the same parameters then hits the cache instead of
585/// regenerating.
586///
587/// NOTE: the cache key is derived from the command parameters. For a cache hit
588/// the real command must use the same effective `option` (provider, samplerate,
589/// speaker, speed) — or pass an explicit `cacheKey`.
590pub(crate) async fn precache(
591    State(state): State<AppState>,
592    Json(command): Json<Command>,
593) -> Response {
594    let result = match command {
595        Command::Tts {
596            text,
597            speaker,
598            option,
599            cache_key,
600            ..
601        } => precache_tts(&state, text, speaker, option, cache_key).await,
602        _ => {
603            return (
604                axum::http::StatusCode::BAD_REQUEST,
605                Json(json!({ "status": "error", "error": "precache only accepts tts commands" })),
606            )
607                .into_response();
608        }
609    };
610
611    match result {
612        Ok((cache_key, bytes, existed)) => Json(json!({
613            "status": if existed { "exists" } else { "cached" },
614            "cacheKey": cache_key,
615            "bytes": bytes,
616        }))
617        .into_response(),
618        Err(e) => (
619            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
620            Json(json!({ "status": "error", "error": e.to_string() })),
621        )
622            .into_response(),
623    }
624}
625
626/// Synthesize `text` and store the audio under the same cache key `do_tts` uses.
627/// Returns (cache_key, bytes_stored, already_existed).
628async fn precache_tts(
629    state: &AppState,
630    text: String,
631    speaker: Option<String>,
632    option: Option<SynthesisOption>,
633    cache_key: Option<String>,
634) -> anyhow::Result<(String, usize, bool)> {
635    let mut opt = option.ok_or_else(|| anyhow::anyhow!("tts precache requires an option"))?;
636    // Fold the top-level speaker into the option (same precedence as do_tts)
637    opt.speaker = speaker.or(opt.speaker);
638    opt.check_default();
639
640    let mut client = state.stream_engine.create_tts_client(false, &opt).await?;
641    // Cache key must match tts.rs::handle_cache exactly.
642    let sample_rate = opt.samplerate.unwrap_or(16000) as u32;
643    let cache_key = cache_key.unwrap_or_else(|| {
644        cache::generate_cache_key(
645            &format!("tts:{}{}", client.provider(), text),
646            sample_rate,
647            opt.speaker.as_ref(),
648            opt.speed,
649        )
650    });
651    if cache::is_cached(&cache_key).await.unwrap_or(false) {
652        return Ok((cache_key, 0, true));
653    }
654
655    let mut stream = client.start().await?;
656    client.synthesize(&text, Some(0), Some(opt.clone())).await?;
657    client.stop().await?;
658
659    let mut chunks: Vec<Bytes> = Vec::new();
660    let mut first_chunk = true;
661    while let Some((_seq, res)) = stream.next().await {
662        match res? {
663            SynthesisEvent::AudioChunk(mut chunk) => {
664                // Strip the 44-byte WAV header off the first chunk (same as tts.rs).
665                if first_chunk {
666                    if chunk.len() > 44 && chunk[..4] == [0x52, 0x49, 0x46, 0x46] {
667                        let _ = chunk.split_to(44);
668                    }
669                    first_chunk = false;
670                }
671                chunks.push(chunk);
672            }
673            SynthesisEvent::Finished => break,
674            _ => {}
675        }
676    }
677    if chunks.is_empty() {
678        return Err(anyhow::anyhow!("tts produced no audio"));
679    }
680    let bytes: usize = chunks.iter().map(|c| c.len()).sum();
681    cache::store_in_cache_vectored(&cache_key, &chunks).await?;
682    info!(cache_key = %cache_key, bytes, "precache: stored tts audio");
683    Ok((cache_key, bytes, false))
684}
685
686trait IntoWsMessage {
687    fn into_ws_message(self) -> Result<Message, serde_json::Error>;
688}
689
690impl IntoWsMessage for crate::event::SessionEvent {
691    fn into_ws_message(self) -> Result<Message, serde_json::Error> {
692        match self {
693            SessionEvent::Binary { data, .. } => Ok(Message::Binary(data.into())),
694            SessionEvent::Ping { timestamp, payload } => {
695                let payload = payload.unwrap_or_else(|| timestamp.to_string());
696                Ok(Message::Ping(payload.into()))
697            }
698            event => serde_json::to_string(&event).map(|payload| Message::Text(payload.into())),
699        }
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use serde_json::json;
707    use std::collections::HashMap;
708
709    #[test]
710    fn test_filter_headers() {
711        let mut extras = HashMap::new();
712        extras.insert("X-Tenant-ID".to_string(), json!("123"));
713        extras.insert("X-User-ID".to_string(), json!("456"));
714        extras.insert("Custom-Header".to_string(), json!("abc"));
715        extras.insert("Irrelevant-Header".to_string(), json!("ignore"));
716
717        // Test case-insensitive matching
718        let allowed = vec!["x-tenant-id".to_string(), "Custom-Header".to_string()];
719
720        filter_headers(&mut extras, &allowed);
721
722        assert!(extras.contains_key("X-Tenant-ID"));
723        assert!(extras.contains_key("Custom-Header"));
724        assert!(!extras.contains_key("X-User-ID"));
725        assert!(!extras.contains_key("Irrelevant-Header"));
726
727        // ensure values are preserved
728        assert_eq!(extras.get("X-Tenant-ID").unwrap(), &json!("123"));
729        assert_eq!(extras.get("Custom-Header").unwrap(), &json!("abc"));
730    }
731
732    #[tokio::test]
733    async fn test_call_handler_core_extras_are_session_scoped() {
734        use crate::app::AppStateBuilder;
735        use crate::call::{ActiveCallType, Command};
736        use crate::config::Config;
737
738        let mut config = Config::default();
739        config.udp_port = 0;
740        let app_state = AppStateBuilder::new()
741            .with_config(config)
742            .build()
743            .await
744            .expect("Failed to build app state");
745
746        let session_id = "test-session-scoped".to_string();
747        let cancel_token = CancellationToken::new();
748
749        // Pass extras directly as a parameter (not via global map)
750        let mut extras = HashMap::new();
751        extras.insert("X-Custom".to_string(), json!("value"));
752
753        let (_audio_sender, audio_receiver) = tokio::sync::mpsc::unbounded_channel::<Bytes>();
754        let (command_sender, command_receiver) = tokio::sync::mpsc::unbounded_channel::<Command>();
755        let (event_sender, _event_receiver) =
756            tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
757
758        // Send a Hangup command immediately to end the call
759        command_sender
760            .send(Command::Hangup {
761                reason: None,
762                initiator: None,
763                headers: None,
764                refer: None,
765            })
766            .ok();
767        drop(command_sender);
768
769        // Run call_handler_core with extras passed directly
770        let final_extras = call_handler_core(
771            ActiveCallType::Sip,
772            session_id.clone(),
773            app_state.clone(),
774            cancel_token,
775            audio_receiver,
776            None,
777            false,
778            0,
779            command_receiver,
780            event_sender,
781            Some(extras), // extras passed directly
782            None,         // no playbook
783        )
784        .await;
785
786        // Verify that final extras are returned and contain our custom header
787        assert!(final_extras.is_some(), "final extras should be returned");
788        let extras = final_extras.unwrap();
789        assert_eq!(
790            extras.get("X-Custom"),
791            Some(&json!("value")),
792            "session-scoped extras should be preserved"
793        );
794    }
795}