diff --git a/src/call/app/app_context.rs b/src/call/app/app_context.rs index 5a11dc51f..752384861 100644 --- a/src/call/app/app_context.rs +++ b/src/call/app/app_context.rs @@ -30,6 +30,21 @@ pub struct CallInfo { pub route_name: Option, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AppRouteContext { + pub callee: String, + pub sip_headers: HashMap, + pub variables: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AppInvocationContext { + pub app_execution_id: u64, + pub callee: String, + pub sip_headers: HashMap, + pub variables: HashMap, +} + pub struct AppSharedState { /// Arbitrary typed data, keyed by string. /// @@ -100,6 +115,9 @@ pub struct ApplicationContext { /// Call metadata. pub call_info: CallInfo, + /// Immutable metadata owned by the current application generation. + pub invocation: Option, + /// System configuration. pub config: Arc, @@ -144,6 +162,7 @@ impl ApplicationContext { http_client: crate::http_util::build_keepalive_client(None, None) .unwrap_or_else(|_| reqwest::Client::new()), call_info, + invocation: None, config, rwi_gateway: None, ivr_trace: None, @@ -198,6 +217,21 @@ pub fn extract_sip_headers(request: &rsipstack::sip::Request) -> HashMap, + routed: &[rsipstack::sip::Header], +) -> HashMap { + let mut merged = base.clone(); + for header in routed { + let name = header.name().to_string(); + merged.retain(|key, _| !key.eq_ignore_ascii_case(&name)); + merged.insert(name, header.value().to_string()); + } + merged +} + impl std::fmt::Debug for ApplicationContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ApplicationContext") @@ -266,6 +300,7 @@ mod tests { version: rsipstack::sip::Version::V2, headers: vec![ Header::Other("X-Custom".to_string(), "original-value".to_string()), + Header::Other("x-custom".to_string(), "duplicate-value".to_string()), Header::Other("X-Forwarded-For".to_string(), "192.168.1.1".to_string()), ] .into(), @@ -283,6 +318,7 @@ mod tests { let original = extract_sip_headers(&req); assert_eq!(original.get("X-Custom").unwrap(), "original-value"); + assert_eq!(original.get("x-custom").unwrap(), "duplicate-value"); assert_eq!(original.get("X-Forwarded-For").unwrap(), "192.168.1.1"); assert!( original.get("From").is_none(), @@ -291,7 +327,7 @@ mod tests { // Simulate routing-modified headers (overriding X-Custom, adding P-Asserted-Identity) let routed_headers: Option> = Some(vec![ - Header::Other("X-Custom".to_string(), "routing-value".to_string()), + Header::Other("x-custom".to_string(), "routing-value".to_string()), Header::Other( "P-Asserted-Identity".to_string(), "".to_string(), @@ -299,19 +335,21 @@ mod tests { ]); // Apply the same merge logic as in sip_session.rs - let mut merged = original; - if let Some(ref routed) = routed_headers { - for h in routed { - merged.insert(h.name().to_string(), h.value().to_string()); - } - } + let merged = merge_sip_headers(&original, routed_headers.as_deref().unwrap_or_default()); // Verify routing headers override originals assert_eq!( - merged.get("X-Custom").unwrap(), + merged.get("x-custom").unwrap(), "routing-value", "routed headers should override original" ); + assert_eq!( + merged + .keys() + .filter(|key| key.eq_ignore_ascii_case("X-Custom")) + .count(), + 1 + ); // Verify unmodified original headers are preserved assert_eq!( merged.get("X-Forwarded-For").unwrap(), diff --git a/src/call/app/ivr/common.rs b/src/call/app/ivr/common.rs index 951bd1d96..460dca5ce 100644 --- a/src/call/app/ivr/common.rs +++ b/src/call/app/ivr/common.rs @@ -13,6 +13,8 @@ use super::config::{ActionNode, EntryAction}; pub enum ActionResult { Terminal(TerminalAction), ChainedTo(ActionNode), + /// Complete an explicitly empty prompt without waiting for a media event. + ImmediateAudioComplete, WaitFor(WaitEvent), /// Chain to a registered domain [`CallApp`] (voicemail, csat_survey, …). StartSubApp(Box), @@ -341,6 +343,11 @@ pub async fn execute_action( tts_api_url, .. } => { + // Some("") is a successful no-media prompt; None still means missing audio input. + let explicitly_empty = tts_api_url.is_none() + && tts_text.as_deref() == Some("") + && file.as_deref().unwrap_or_default().is_empty() + && record_name_list.is_none(); let resolved_text = if tts_api_url.is_some() { match fetch_tts_text_from_api(tts_api_url.as_deref().unwrap(), sess, ctx).await { Some(text) => Some(text), @@ -366,6 +373,8 @@ pub async fn execute_action( Ok(ActionResult::WaitFor(WaitEvent::AudioComplete { interrupted: false, })) + } else if explicitly_empty { + Ok(ActionResult::ImmediateAudioComplete) } else { Ok(ActionResult::WaitFor(WaitEvent::NoAudio)) } diff --git a/src/call/app/ivr/executor.rs b/src/call/app/ivr/executor.rs index 1090e4049..5cb18590e 100644 --- a/src/call/app/ivr/executor.rs +++ b/src/call/app/ivr/executor.rs @@ -27,6 +27,7 @@ const DEFAULT_MAX_REPEAT_PROMPTS: u32 = 10; pub struct StepIvrApp { provider: Box, + provider_session: Option, current_node: Option, sess: SessionData, pending_menu: Option, @@ -119,6 +120,7 @@ impl StepIvrApp { let provider = Box::new(super::provider::StepProvider::new(url)); Self { provider, + provider_session: None, current_node: None, sess: SessionData::default(), pending_menu: None, @@ -159,6 +161,7 @@ impl StepIvrApp { pub fn with_provider(provider: Box) -> Self { Self { provider, + provider_session: None, current_node: None, sess: SessionData::default(), pending_menu: None, @@ -333,12 +336,7 @@ impl StepIvrApp { fn increment_total_steps(&self) { if let Some(t) = self.effective_trace() { - let sid = self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(); + let sid = self.provider_session_context().session_id; crate::utils::spawn(async move { t.increment_steps(&sid).await; }); @@ -371,12 +369,7 @@ impl StepIvrApp { } async fn record_session_end(&self, status: &str) { - let session_id = self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(); + let session_id = self.provider_session_context().session_id; if let Some(t) = self.effective_trace() { let sid = session_id; let st = status.to_string(); @@ -512,24 +505,10 @@ impl StepIvrApp { self.current_trigger = None; - let session_id = self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(); - let caller = self - .sess - .variables - .get("caller") - .cloned() - .unwrap_or_default(); - let callee = self - .sess - .variables - .get("callee") - .cloned() - .unwrap_or_default(); + let provider_session = self.provider_session_context(); + let session_id = provider_session.session_id; + let caller = provider_session.caller; + let callee = provider_session.callee; match result { Ok(action_result) => { @@ -582,6 +561,36 @@ impl StepIvrApp { TerminalAction::Exit => AppAction::Exit, } } + ActionResult::ImmediateAudioComplete => { + // Reuse pending-trace completion so the no-media Prompt remains observable. + self.pending_start_instant = self.step_start_instant; + self.pending_trace = Some(IvrTraceEntry { + session_id: session_id.clone(), + caller: caller.clone(), + callee: callee.clone(), + step_index: self.step_index, + trigger: trigger.clone(), + provider_url: None, + action_type: node_type_str, + action_json, + error: None, + step_id: step_id.clone(), + step_name: step_name.clone(), + step_start_time: self.current_step_start_time.clone(), + step_end_time: None, + duration_ms: 0, + extra: self.extra.clone(), + end_reason: None, + end_detail: None, + }); + self.current_node = Some( + self.request_next(Some(ProviderEvent::AudioComplete { + interrupted: false, + })) + .await?, + ); + return Box::pin(self.__exec_node(ctrl, ctx)).await; + } ActionResult::ChainedTo(next) => { self.current_trigger = Some(crate::rwi::TriggerInfo::new("chained")); self.current_node = Some(next); @@ -769,6 +778,45 @@ impl StepIvrApp { } } + fn provider_session_context(&self) -> SessionContext { + self.provider_session + .clone() + .unwrap_or_else(|| SessionContext { + session_id: self + .sess + .variables + .get("session_id") + .cloned() + .unwrap_or_default(), + app_execution_id: 0, + caller: self + .sess + .variables + .get("caller") + .cloned() + .unwrap_or_default(), + callee: self + .sess + .variables + .get("callee") + .cloned() + .unwrap_or_default(), + direction: self + .sess + .variables + .get("direction") + .cloned() + .unwrap_or_default(), + tenant_id: self.sess.variables.get("tenant_id").cloned(), + ivr_id: self.sess.variables.get("ivr_id").cloned(), + variables: self.sess.variables.clone(), + sip_headers: self.get_sip_headers(), + route_name: self.route_name.clone(), + custom_data: self.custom_data.clone(), + transferred_from: self.transferred_from.clone(), + }) + } + fn fallback_already_used(&self) -> bool { self.sess .variables @@ -812,24 +860,10 @@ impl StepIvrApp { /// Record a trace entry + RWI `ivr_step_trace` event for a fallback decision. fn record_fallback_trace(&self, reason: &str, target: Option<&str>) { - let session_id = self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(); - let caller = self - .sess - .variables - .get("caller") - .cloned() - .unwrap_or_default(); - let callee = self - .sess - .variables - .get("callee") - .cloned() - .unwrap_or_default(); + let provider_session = self.provider_session_context(); + let session_id = provider_session.session_id; + let caller = provider_session.caller; + let callee = provider_session.callee; let now = chrono::Utc::now().to_rfc3339(); self.record_trace(IvrTraceEntry { session_id, @@ -858,7 +892,7 @@ impl StepIvrApp { }); } - /// Session-level recovery: match `[proxy.ivr_fallback]` → JumpIvr, else hangup. + /// Session-level recovery: match `[proxy.ivr_fallback]` → direct IVR, else hangup. fn enter_ivr_fallback_node(&mut self, reason: &str) -> ActionNode { if self.fallback_already_used() { tracing::warn!( @@ -878,18 +912,9 @@ impl StepIvrApp { return Self::hangup_error_node(); }; - let caller = self - .sess - .variables - .get("caller") - .cloned() - .unwrap_or_default(); - let callee = self - .sess - .variables - .get("callee") - .cloned() - .unwrap_or_default(); + let provider_session = self.provider_session_context(); + let caller = provider_session.caller; + let callee = provider_session.callee; let headers = self.get_sip_headers(); let Some(target) = @@ -907,44 +932,28 @@ impl StepIvrApp { tracing::warn!( reason = %reason, target = %target, - "StepIvrApp: entering IVR fallback via toivr" + "StepIvrApp: entering direct IVR fallback" ); self.record_fallback_trace(reason, Some(&target)); let mut params = HashMap::new(); params.insert(IVR_FALLBACK_USED_KEY.into(), "1".into()); - ActionNode::new(EntryAction::JumpIvr { - route_point: target, + ActionNode::new(EntryAction::Transfer { + target: format!("ivr:{target}"), params, + return_app: None, + return_target: None, }) } fn build_fail_provider_context(&self, reason: String) -> ProviderContext { let now_rfc3339 = chrono::Utc::now().to_rfc3339(); + let session = self.provider_session_context(); ProviderContext { - session_id: self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(), - caller: self - .sess - .variables - .get("caller") - .cloned() - .unwrap_or_default(), - callee: self - .sess - .variables - .get("callee") - .cloned() - .unwrap_or_default(), - direction: self - .sess - .variables - .get("direction") - .cloned() - .unwrap_or_default(), + session_id: session.session_id, + app_execution_id: session.app_execution_id, + caller: session.caller, + callee: session.callee, + direction: session.direction, tenant_id: self.sess.variables.get("tenant_id").cloned(), ivr_id: self.sess.variables.get("ivr_id").cloned(), variables: self.sess.variables.clone(), @@ -1054,31 +1063,13 @@ impl StepIvrApp { let now_rfc3339 = chrono::Utc::now().to_rfc3339(); let prev_step_duration_ms = self.step_prev_duration_ms; + let session = self.provider_session_context(); let ctx = ProviderContext { - session_id: self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(), - caller: self - .sess - .variables - .get("caller") - .cloned() - .unwrap_or_default(), - callee: self - .sess - .variables - .get("callee") - .cloned() - .unwrap_or_default(), - direction: self - .sess - .variables - .get("direction") - .cloned() - .unwrap_or_default(), + session_id: session.session_id, + app_execution_id: session.app_execution_id, + caller: session.caller, + callee: session.callee, + direction: session.direction, tenant_id: self.sess.variables.get("tenant_id").cloned(), ivr_id: self.sess.variables.get("ivr_id").cloned(), variables: self.sess.variables.clone(), @@ -1407,6 +1398,17 @@ impl CallApp for StepIvrApp { self.set_runtime_status(context, "starting"); ctrl.answer().await?; + let invocation = + context + .invocation + .clone() + .unwrap_or_else(|| crate::call::app::AppInvocationContext { + app_execution_id: 0, + callee: context.call_info.callee.clone(), + sip_headers: context.call_info.sip_headers.clone(), + variables: HashMap::new(), + }); + self.sess .variables .insert("session_id".into(), context.call_info.session_id.clone()); @@ -1415,14 +1417,14 @@ impl CallApp for StepIvrApp { .insert("caller".into(), context.call_info.caller.clone()); self.sess .variables - .insert("callee".into(), context.call_info.callee.clone()); + .insert("callee".into(), invocation.callee.clone()); self.sess .variables .insert("direction".into(), context.call_info.direction.clone()); // Clone SIP headers once; store in self.sess for future request_next calls, // then move into SessionContext to avoid a second full clone. - let headers = context.call_info.sip_headers.clone(); + let headers = invocation.sip_headers.clone(); for (name, value) in &headers { let key = format!("sip_{}", name.replace(|c: char| !c.is_alphanumeric(), "_")); @@ -1431,6 +1433,15 @@ impl CallApp for StepIvrApp { self.sess.sip_headers = headers.clone(); + for variable in context.session_vars.iter() { + self.sess + .variables + .insert(variable.key().clone(), variable.value().clone()); + } + for (name, value) in &invocation.variables { + self.sess.variables.insert(name.clone(), value.clone()); + } + // Merge ivr_params (from JumpIvr query string) into session variables // so they are available for $var$ substitution and sent to the provider. if let Some(ref ivp) = self.ivr_params { @@ -1447,26 +1458,29 @@ impl CallApp for StepIvrApp { let sess_ctx = SessionContext { session_id: context.call_info.session_id.clone(), + app_execution_id: invocation.app_execution_id, caller: context.call_info.caller.clone(), - callee: context.call_info.callee.clone(), + callee: invocation.callee, direction: context.call_info.direction.clone(), tenant_id: None, ivr_id: None, + variables: self.sess.variables.clone(), sip_headers: Some(headers), route_name: self.route_name.clone(), custom_data: self.custom_data.clone(), transferred_from: self.transferred_from.clone(), }; + self.provider_session = Some(sess_ctx.clone()); self.set_runtime_status(context, "provider_start"); self.provider.on_session_start(&sess_ctx).await.ok(); self.step_prev_start_time = Some(chrono::Utc::now().to_rfc3339()); self.record_session_start( - &context.call_info.session_id, - &context.call_info.caller, - &context.call_info.callee, - &context.call_info.direction, + &sess_ctx.session_id, + &sess_ctx.caller, + &sess_ctx.callee, + &sess_ctx.direction, ) .await; @@ -1811,12 +1825,8 @@ impl CallApp for StepIvrApp { end_reason.reason = SessionEndTag::Timeout; end_reason_label = "timeout".to_string(); } - let session_id = self - .sess - .variables - .get("session_id") - .cloned() - .unwrap_or_default(); + let provider_session = self.provider_session_context(); + let session_id = provider_session.session_id; let end_sr = end_reason.clone(); // Always record the session_end trace entry — including on @@ -1842,18 +1852,8 @@ impl CallApp for StepIvrApp { None, ), }; - let caller = self - .sess - .variables - .get("caller") - .cloned() - .unwrap_or_default(); - let callee = self - .sess - .variables - .get("callee") - .cloned() - .unwrap_or_default(); + let caller = provider_session.caller; + let callee = provider_session.callee; self.record_trace(IvrTraceEntry { session_id: session_id.clone(), caller, @@ -1875,8 +1875,9 @@ impl CallApp for StepIvrApp { }); if !skip_provider_end { + let provider_session = self.provider_session_context(); self.provider - .on_session_end(&end_reason, &session_id) + .on_session_end_context(&end_reason, &provider_session) .await .ok(); } @@ -1980,7 +1981,10 @@ mod tests { nodes: Vec, idx: std::sync::Mutex, start_called: std::sync::Mutex, + start_context: std::sync::Mutex>, end_called: std::sync::Mutex, + events: std::sync::Mutex>>, + contexts: std::sync::Mutex>, } impl MockProvider { @@ -1989,7 +1993,10 @@ mod tests { nodes, idx: std::sync::Mutex::new(0), start_called: std::sync::Mutex::new(false), + start_context: std::sync::Mutex::new(None), end_called: std::sync::Mutex::new(false), + events: std::sync::Mutex::new(Vec::new()), + contexts: std::sync::Mutex::new(Vec::new()), } } } @@ -1998,7 +2005,9 @@ mod tests { #[async_trait] impl ActionProvider for MockProvider { - async fn next_action(&self, _ctx: ProviderContext) -> anyhow::Result { + async fn next_action(&self, ctx: ProviderContext) -> anyhow::Result { + self.events.lock().unwrap().push(ctx.event.clone()); + self.contexts.lock().unwrap().push(ctx); let mut idx = self.idx.lock().unwrap(); if *idx < self.nodes.len() { let node = self.nodes[*idx].clone(); @@ -2009,8 +2018,9 @@ mod tests { } } - async fn on_session_start(&self, _ctx: &SessionContext) -> anyhow::Result<()> { + async fn on_session_start(&self, ctx: &SessionContext) -> anyhow::Result<()> { *self.start_called.lock().unwrap() = true; + *self.start_context.lock().unwrap() = Some(ctx.clone()); Ok(()) } @@ -2063,6 +2073,54 @@ mod tests { ) } + #[tokio::test] + async fn step_provider_uses_invocation_identity_and_keeps_business_variables_separate() { + let provider = Arc::new(MockProvider::new(vec![ActionNode::new( + EntryAction::Transfer { + target: "2001".into(), + params: HashMap::new(), + return_app: None, + return_target: None, + }, + )])); + let mut context = make_test_context(); + context.invocation = Some(crate::call::app::AppInvocationContext { + app_execution_id: 2, + callee: "39230".into(), + sip_headers: HashMap::from([("X-Business-Type".into(), "34".into())]), + variables: HashMap::from([ + ("session_id".into(), "business-value".into()), + ("order_id".into(), "order-001".into()), + ]), + }); + let app = StepIvrApp::with_provider(Box::new(MockProviderHandle(provider.clone()))); + let mut stack = MockCallStack::run_with_context(Box::new(app), context); + + stack + .assert_cmd(200, "accept", |command| { + matches!(command, CallCommand::Answer { .. }) + }) + .await; + stack + .assert_cmd( + 200, + "transfer", + |command| matches!(command, CallCommand::Transfer { target, .. } if target == "2001"), + ) + .await; + + let start = provider.start_context.lock().unwrap().clone().unwrap(); + assert_eq!(start.session_id, "test-session"); + assert_eq!(start.app_execution_id, 2); + assert_eq!(start.callee, "39230"); + assert_eq!(start.sip_headers.as_ref().unwrap()["X-Business-Type"], "34"); + assert_eq!(start.variables["session_id"], "business-value"); + let contexts = provider.contexts.lock().unwrap(); + assert_eq!(contexts[0].session_id, "test-session"); + assert_eq!(contexts[0].app_execution_id, 2); + assert_eq!(contexts[0].variables["session_id"], "business-value"); + } + struct BlockingProvider { entered_next: Arc, release_next: Arc, @@ -2282,6 +2340,95 @@ mod tests { assert_eq!(transfer_trace.event.payload["step_id"], "transfer-step"); } + #[tokio::test] + async fn test_empty_prompt_completes_without_audio() { + use crate::rwi::gateway::RwiGateway; + + let prompt: ActionNode = serde_json::from_value(serde_json::json!({ + "type": "prompt", + "tts_text": "", + "step_id": "empty-prompt-step", + "extra": { "nodetype": "dynamic_prompt" } + })) + .expect("provider-shaped empty Prompt must deserialize"); + let mut transfer = ActionNode::new(EntryAction::Transfer { + target: "2001".into(), + params: HashMap::new(), + return_app: None, + return_target: None, + }); + transfer.step_id = Some("next-step".into()); + let gateway = RwiGateway::new(); + let mut events = gateway.subscribe_events(); + let mut app = mock_app(vec![prompt, transfer]); + app.rwi_gateway = Some(Arc::new(parking_lot::RwLock::new(gateway))); + + let mut stack = MockCallStack::run(Box::new(app), "1001", "2000"); + stack + .assert_cmd(200, "accept", |c| matches!(c, CallCommand::Answer { .. })) + .await; + stack + .assert_cmd( + 200, + "transfer", + |c| matches!(c, CallCommand::Transfer { target, .. } if target == "2001"), + ) + .await; + + let prompt_trace = events + .try_recv() + .expect("empty prompt trace must be enqueued"); + let transfer_trace = events.try_recv().expect("next step trace must be enqueued"); + assert_eq!(prompt_trace.event.payload["step_id"], "empty-prompt-step"); + assert_eq!(prompt_trace.event.payload["action_type"], "Prompt"); + assert_eq!( + prompt_trace.event.payload["extra"]["nodetype"], + "dynamic_prompt" + ); + assert_eq!(transfer_trace.event.payload["step_id"], "next-step"); + assert_eq!( + transfer_trace.event.payload["trigger"]["type"], + "audio_complete" + ); + } + + #[tokio::test] + async fn test_missing_prompt_audio_reports_provider_error() { + let prompt: ActionNode = serde_json::from_value(serde_json::json!({ + "type": "prompt", + "step_id": "missing-audio-step" + })) + .expect("provider-shaped Prompt without media must deserialize"); + let mut transfer = ActionNode::new(EntryAction::Transfer { + target: "2001".into(), + params: HashMap::new(), + return_app: None, + return_target: None, + }); + transfer.step_id = Some("error-step".into()); + let provider = Arc::new(MockProvider::new(vec![prompt, transfer])); + let app = StepIvrApp::with_provider(Box::new(MockProviderHandle(provider.clone()))); + + let mut stack = MockCallStack::run(Box::new(app), "1001", "2000"); + stack + .assert_cmd(200, "accept", |c| matches!(c, CallCommand::Answer { .. })) + .await; + stack + .assert_cmd( + 200, + "transfer", + |c| matches!(c, CallCommand::Transfer { target, .. } if target == "2001"), + ) + .await; + + assert!(provider.events.lock().unwrap().iter().any(|event| { + matches!( + event, + Some(ProviderEvent::Error { reason }) if reason == "TTS service not available" + ) + })); + } + #[tokio::test] async fn test_dtmf_menu_with_local_entries() { let mut entries = HashMap::new(); @@ -3317,24 +3464,34 @@ mod tests { fallback_action: None, }) .with_prefer_ivr_fallback(true); - let mut stack = MockCallStack::run( + let mut context = make_test_context(); + context.invocation = Some(crate::call::app::AppInvocationContext { + app_execution_id: 2, + callee: "39230".into(), + sip_headers: HashMap::new(), + variables: HashMap::from([ + ("caller".into(), "business-caller".into()), + ("callee".into(), "business-callee".into()), + ("session_id".into(), "business-session".into()), + ]), + }); + let mut stack = MockCallStack::run_with_context( Box::new( StepIvrApp::with_provider(Box::new(provider)) .with_name("fail-fb") .with_ivr_fallback(Some(fb)), ), - "1001", - "2000", + context, ); stack .assert_cmd(200, "accept", |c| matches!(c, CallCommand::Answer { .. })) .await; stack - .assert_cmd(2000, "toivr fallback", |c| { + .assert_cmd(2000, "direct IVR fallback", |c| { matches!( c, CallCommand::Transfer { target, .. } - if target.starts_with("toivr:builtin_vip") + if target.starts_with("ivr:builtin_vip") ) }) .await; @@ -3389,13 +3546,13 @@ mod tests { stack .assert_cmd(200, "accept", |c| matches!(c, CallCommand::Answer { .. })) .await; - // prefer_ivr_fallback must skip retry.fallback hangup and JumpIvr instead. + // prefer_ivr_fallback must skip retry.fallback hangup and use direct IVR instead. stack - .assert_cmd(2000, "toivr default", |c| { + .assert_cmd(2000, "direct default IVR", |c| { matches!( c, CallCommand::Transfer { target, .. } - if target.starts_with("toivr:default_ivr") + if target.starts_with("ivr:default_ivr") ) }) .await; @@ -5251,6 +5408,7 @@ mod tests { /// returns. struct MockStepProviderServer { url: String, + requests: Arc>>, _listener: std::net::TcpListener, } @@ -5261,21 +5419,28 @@ mod tests { listener.local_addr().expect("mock provider addr") ); let accept_listener = listener.try_clone().expect("clone mock provider listener"); + let requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded_requests = requests.clone(); std::thread::spawn(move || { for stream in accept_listener.incoming() { let Ok(stream) = stream else { continue }; - std::thread::spawn(|| { - let _ = serve_connection(stream); + let connection_requests = recorded_requests.clone(); + std::thread::spawn(move || { + let _ = serve_connection(stream, connection_requests); }); } }); MockStepProviderServer { url, + requests, _listener: listener, } } - fn serve_connection(mut stream: std::net::TcpStream) -> std::io::Result<()> { + fn serve_connection( + mut stream: std::net::TcpStream, + requests: Arc>>, + ) -> std::io::Result<()> { use std::io::{BufRead, BufReader, Read, Write}; stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?; @@ -5308,6 +5473,9 @@ mod tests { } let mut body = vec![0u8; content_length]; reader.read_exact(&mut body)?; + if let Ok(value) = serde_json::from_slice(&body) { + requests.lock().unwrap().push((path.clone(), value)); + } let payload = mock_step_response(&path, &body); let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ @@ -5347,12 +5515,17 @@ mod tests { let session = SessionContext { session_id: "test-session".to_string(), + app_execution_id: 2, caller: "1001".to_string(), callee: "2000".to_string(), direction: "inbound".to_string(), tenant_id: None, ivr_id: None, - sip_headers: None, + variables: HashMap::from([("order_id".to_string(), "order-001".to_string())]), + sip_headers: Some(HashMap::from([( + "X-Business-Type".to_string(), + "34".to_string(), + )])), route_name: None, custom_data: None, transferred_from: None, @@ -5361,6 +5534,7 @@ mod tests { let ctx = ProviderContext { session_id: session.session_id.clone(), + app_execution_id: session.app_execution_id, caller: session.caller.clone(), callee: session.callee.clone(), direction: session.direction.clone(), @@ -5389,6 +5563,7 @@ mod tests { }), ..ProviderContext { session_id: session.session_id.clone(), + app_execution_id: session.app_execution_id, caller: session.caller.clone(), callee: session.callee.clone(), direction: session.direction.clone(), @@ -5414,15 +5589,46 @@ mod tests { ); step_provider - .on_session_end( + .on_session_end_context( &SessionEndReason { reason: SessionEndTag::Normal, detail: None, }, - "test-session", + &session, ) .await .unwrap(); + + let requests = provider.requests.lock().unwrap(); + let start = requests + .iter() + .find(|(path, _)| path == "/ivr/step/start") + .map(|(_, body)| body) + .expect("start request"); + assert_eq!(start["session_id"], "test-session"); + assert_eq!(start["app_execution_id"], 2); + assert_eq!(start["variables"]["order_id"], "order-001"); + assert_eq!(start["sip_headers"]["X-Business-Type"], "34"); + + let step_requests = requests + .iter() + .filter(|(path, _)| path == "/ivr/step") + .map(|(_, body)| body) + .collect::>(); + assert_eq!(step_requests.len(), 2); + assert!( + step_requests.iter().all(|body| { + body["session_id"] == "test-session" && body["app_execution_id"] == 2 + }) + ); + + let end = requests + .iter() + .find(|(path, _)| path == "/ivr/step/end") + .map(|(_, body)| body) + .expect("end request"); + assert_eq!(end["session_id"], "test-session"); + assert_eq!(end["app_execution_id"], 2); } // ── DTMF delivery in step-provider mode ────────────────────────────── @@ -5748,11 +5954,8 @@ mod tests { .await .expect("fallback node"); match node.action { - EntryAction::JumpIvr { - route_point, - params, - } => { - assert_eq!(route_point, "builtin_vip"); + EntryAction::Transfer { target, params, .. } => { + assert_eq!(target, "ivr:builtin_vip"); assert_eq!( params .get(crate::call::app::ivr::fallback::IVR_FALLBACK_USED_KEY) @@ -5760,7 +5963,7 @@ mod tests { Some("1") ); } - other => panic!("expected JumpIvr fallback, got {other:?}"), + other => panic!("expected direct IVR fallback, got {other:?}"), } assert!(app.fallback_already_used()); } @@ -5786,8 +5989,8 @@ mod tests { let node = app.enter_ivr_fallback_node("step:test"); match node.action { - EntryAction::JumpIvr { route_point, .. } => assert_eq!(route_point, "default_ivr"), - other => panic!("expected default JumpIvr, got {other:?}"), + EntryAction::Transfer { target, .. } => assert_eq!(target, "ivr:default_ivr"), + other => panic!("expected default direct IVR transfer, got {other:?}"), } } diff --git a/src/call/app/ivr/provider.rs b/src/call/app/ivr/provider.rs index 55ff9dc25..820d3face 100644 --- a/src/call/app/ivr/provider.rs +++ b/src/call/app/ivr/provider.rs @@ -40,6 +40,14 @@ pub trait ActionProvider: Send + Sync { Ok(()) } + async fn on_session_end_context( + &self, + reason: &SessionEndReason, + context: &SessionContext, + ) -> anyhow::Result<()> { + self.on_session_end(reason, &context.session_id).await + } + /// Called when a DtmfMenu resolves a DTMF key locally (no round‑trip to /// the provider). Fire‑and‑forget notification so the provider stays /// informed about which keys were pressed and what action was taken. @@ -51,11 +59,13 @@ pub trait ActionProvider: Send + Sync { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionContext { pub session_id: String, + pub app_execution_id: u64, pub caller: String, pub callee: String, pub direction: String, pub tenant_id: Option, pub ivr_id: Option, + pub variables: HashMap, /// All SIP headers from the original INVITE request. #[serde(skip_serializing_if = "Option::is_none")] pub sip_headers: Option>, @@ -74,6 +84,7 @@ pub struct SessionContext { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderContext { pub session_id: String, + pub app_execution_id: u64, pub caller: String, pub callee: String, pub direction: String, @@ -449,14 +460,15 @@ impl ActionProvider for StepProvider { Ok(()) } - async fn on_session_end( + async fn on_session_end_context( &self, reason: &SessionEndReason, - session_id: &str, + context: &SessionContext, ) -> anyhow::Result<()> { let url = self.endpoint_url(Some("end")); let body = serde_json::json!({ - "session_id": session_id, + "session_id": context.session_id, + "app_execution_id": context.app_execution_id, "reason": reason.reason, "detail": reason.detail, }); diff --git a/src/call/app/ivr/third_party.rs b/src/call/app/ivr/third_party.rs index e346d7d1d..71f269a16 100644 --- a/src/call/app/ivr/third_party.rs +++ b/src/call/app/ivr/third_party.rs @@ -890,6 +890,7 @@ mod tests { fn test_provider_ctx(event: ProviderEvent) -> ProviderContext { ProviderContext { session_id: "s1".into(), + app_execution_id: 1, caller: "1001".into(), callee: "2000".into(), direction: "inbound".into(), diff --git a/src/call/app/ivr_test.rs b/src/call/app/ivr_test.rs index b8cb5bb1a..f392d1a14 100644 --- a/src/call/app/ivr_test.rs +++ b/src/call/app/ivr_test.rs @@ -3003,6 +3003,7 @@ action = { type = "transfer", target = "100" } sip_headers: HashMap::new(), route_name: None, }, + invocation: None, config: std::sync::Arc::new(crate::config::Config::default()), rwi_gateway: None, ivr_trace: None, diff --git a/src/call/app/mod.rs b/src/call/app/mod.rs index 52cf85f33..06b82f2b9 100644 --- a/src/call/app/mod.rs +++ b/src/call/app/mod.rs @@ -107,7 +107,8 @@ mod ivr_test; mod queue_test; pub use app_context::{ - AppSharedState, ApplicationContext, CallInfo, PendingQueuePlan, extract_sip_headers, + AppInvocationContext, AppRouteContext, AppSharedState, ApplicationContext, CallInfo, + PendingQueuePlan, extract_sip_headers, merge_sip_headers, }; pub use controller::{ CallController, ControllerEvent, DtmfCollectConfig, HangupDuringCollection, PlaybackToken, diff --git a/src/call/app/queue.rs b/src/call/app/queue.rs index b6687fdd1..18b55b7d7 100644 --- a/src/call/app/queue.rs +++ b/src/call/app/queue.rs @@ -651,6 +651,9 @@ impl QueueApp { crate::call::TransferEndpoint::Queue(queue_name) => { AppAction::Transfer(format!("queue:{}", queue_name)) } + crate::call::TransferEndpoint::RoutePoint(route_point) => { + AppAction::Transfer(format!("toivr:{}", route_point)) + } crate::call::TransferEndpoint::Ivr(ivr_name) => { AppAction::Transfer(format!("ivr:{}", ivr_name)) } diff --git a/src/call/mod.rs b/src/call/mod.rs index 2bc822f3b..af3cf647c 100644 --- a/src/call/mod.rs +++ b/src/call/mod.rs @@ -256,6 +256,8 @@ pub enum TransferEndpoint { /// Raw SIP URI or plain extension number. Uri(String), Queue(String), + /// Route through call routing and start the matched application. + RoutePoint(String), /// Forward to an IVR project by name (config/ivr/.toml). Ivr(String), /// Forward to a voicemail mailbox identified by extension. @@ -267,7 +269,7 @@ pub enum TransferEndpoint { impl TransferEndpoint { /// Parse a prefix‑based destination string. /// - /// Handles `queue:`, `ivr:`, `voicemail:`, `conference:`. + /// Handles `queue:`, `toivr:`, `ivr:`, `voicemail:`, `conference:`. /// Plain strings (no recognised prefix) are returned as `Uri(String)`. /// Does **not** add a `sip:` scheme – callers that need it should use /// [`build_sip_uri`] afterwards. @@ -279,7 +281,7 @@ impl TransferEndpoint { let prefixes: &[(&str, fn(String) -> TransferEndpoint)] = &[ ("queue:", |v| TransferEndpoint::Queue(v)), - ("toivr:", |v| TransferEndpoint::Ivr(v)), + ("toivr:", |v| TransferEndpoint::RoutePoint(v)), ("ivr:", |v| TransferEndpoint::Ivr(v)), ("voicemail:", |v| TransferEndpoint::Voicemail(v)), ("conference:", |v| TransferEndpoint::Conference(v)), @@ -305,6 +307,7 @@ impl std::fmt::Display for TransferEndpoint { match self { TransferEndpoint::Uri(uri) => write!(f, "{}", uri), TransferEndpoint::Queue(name) => write!(f, "queue:{}", name), + TransferEndpoint::RoutePoint(name) => write!(f, "toivr:{}", name), TransferEndpoint::Ivr(name) => write!(f, "ivr:{}", name), TransferEndpoint::Voicemail(ext) => write!(f, "voicemail:{}", ext), TransferEndpoint::Conference(id) => write!(f, "conference:{}", id), @@ -1297,6 +1300,13 @@ impl RoutingState { mod tests { use super::*; + #[test] + fn route_point_transfer_endpoint_preserves_distinct_prefix() { + let endpoint = TransferEndpoint::parse("toivr:39230").expect("route point must parse"); + + assert_eq!(endpoint.to_string(), "toivr:39230"); + } + fn minimal_request() -> rsipstack::sip::Request { let uri = rsipstack::sip::Uri { scheme: Some(rsipstack::sip::Scheme::Sip), diff --git a/src/call/runtime/app_runtime.rs b/src/call/runtime/app_runtime.rs index 16c59d1ca..c0dd62e4b 100644 --- a/src/call/runtime/app_runtime.rs +++ b/src/call/runtime/app_runtime.rs @@ -66,6 +66,23 @@ pub trait AppRuntime: Send + Sync { auto_answer: bool, ) -> AppResult<()>; + /// Start an application with invocation-local route metadata. + async fn start_app_with_route_context( + &self, + app_name: &str, + params: Option, + auto_answer: bool, + route_context: crate::call::app::AppRouteContext, + ) -> AppResult<()> { + let _ = route_context; + self.start_app(app_name, params, auto_answer).await + } + + /// Return the immutable metadata for the currently installed generation. + async fn current_app_invocation(&self) -> Option { + None + } + /// Stop the current application /// /// # Arguments diff --git a/src/call/runtime/default_app_runtime.rs b/src/call/runtime/default_app_runtime.rs index 02fc51099..c9e8b6310 100644 --- a/src/call/runtime/default_app_runtime.rs +++ b/src/call/runtime/default_app_runtime.rs @@ -24,6 +24,7 @@ struct RunningApp { /// Lets the event-loop teardown clear its own registration on natural /// exit without ever clobbering a successor's. generation: u64, + invocation: crate::call::app::AppInvocationContext, } /// Configuration needed to create an AppRuntime @@ -46,6 +47,7 @@ pub struct DefaultAppRuntime { app_factory: Option>, /// Incremented on every successful `start_app`. app_generation: Arc, + last_invocation: Arc>>, } /// Factory trait for creating CallApp instances. @@ -72,6 +74,7 @@ impl DefaultAppRuntime { running: Arc::new(RwLock::new(None)), app_factory: None, app_generation: Arc::new(AtomicU64::new(0)), + last_invocation: Arc::new(parking_lot::RwLock::new(None)), } } pub fn with_factory(mut self, factory: Arc) -> Self { @@ -107,6 +110,35 @@ impl AppRuntime for DefaultAppRuntime { app_name: &str, params: Option, auto_answer: bool, + ) -> AppResult<()> { + let inherited = self.last_invocation.read().clone(); + self.start_app_with_route_context( + app_name, + params, + auto_answer, + crate::call::app::AppRouteContext { + callee: inherited + .as_ref() + .map(|context| context.callee.clone()) + .unwrap_or_else(|| self.context.call_info.callee.clone()), + sip_headers: inherited + .as_ref() + .map(|context| context.sip_headers.clone()) + .unwrap_or_else(|| self.context.call_info.sip_headers.clone()), + variables: inherited + .map(|context| context.variables) + .unwrap_or_default(), + }, + ) + .await + } + + async fn start_app_with_route_context( + &self, + app_name: &str, + params: Option, + auto_answer: bool, + route_context: crate::call::app::AppRouteContext, ) -> AppResult<()> { // Check if already running { @@ -125,6 +157,15 @@ impl AppRuntime for DefaultAppRuntime { // `set_app_event_sender(None)`, dropping the successor's channel and // killing the new IVR with ExitReason::Normal. let generation = self.app_generation.fetch_add(1, Ordering::SeqCst) + 1; + let invocation = crate::call::app::AppInvocationContext { + app_execution_id: generation, + callee: route_context.callee, + sip_headers: route_context.sip_headers, + variables: route_context.variables, + }; + let mut invocation_context = (*self.context).clone(); + invocation_context.invocation = Some(invocation.clone()); + let invocation_context = Arc::new(invocation_context); // Create event channel for app events (DTMF, hangup, etc.) let (event_tx, event_rx) = mpsc::unbounded_channel::(); @@ -142,7 +183,7 @@ impl AppRuntime for DefaultAppRuntime { // Get the app from factory let app = if let Some(factory) = &self.app_factory { match factory - .create_app(app_name, params.clone(), &self.context) + .create_app(app_name, params.clone(), &invocation_context) .await { Ok(app) => app, @@ -164,6 +205,7 @@ impl AppRuntime for DefaultAppRuntime { return Err(AppRuntimeError::UnknownApp(app_name.to_string())); } }; + *self.last_invocation.write() = Some(invocation.clone()); if app_name == "ivr" { if let Some(file) = params @@ -171,10 +213,13 @@ impl AppRuntime for DefaultAppRuntime { .and_then(|p| p.get("file")) .and_then(|v| v.as_str()) { - crate::call::app::ivr::exec::remember_ivr_start_file(self.context.as_ref(), file); + crate::call::app::ivr::exec::remember_ivr_start_file( + invocation_context.as_ref(), + file, + ); } if let Some(csat) = params.as_ref().and_then(|p| p.get("csat_params")) { - self.context.set_var( + invocation_context.set_var( crate::call::app::ivr::builtin::CSAT_PARAMS_KEY, csat.to_string(), ); @@ -187,6 +232,7 @@ impl AppRuntime for DefaultAppRuntime { name: app_name.to_string(), cancel_token: cancel_token.clone(), generation, + invocation, }); } @@ -202,7 +248,7 @@ impl AppRuntime for DefaultAppRuntime { // Spawn the event loop let session_id_for_log = self.session_id.clone(); let app_name_owned = app_name.to_string(); - let context = self.context.clone(); + let context = invocation_context; let handle = self.handle.clone(); let generation_counter = self.app_generation.clone(); let running_slot = self.running.clone(); @@ -280,6 +326,15 @@ impl AppRuntime for DefaultAppRuntime { Ok(()) } + async fn current_app_invocation(&self) -> Option { + self.running + .read() + .await + .as_ref() + .map(|app| app.invocation.clone()) + .or_else(|| self.last_invocation.read().clone()) + } + async fn stop_app(&self, reason: Option) -> AppResult<()> { let running = { let running = self.running.read().await; @@ -534,6 +589,44 @@ mod tests { } } + struct HoldApp; + + #[async_trait::async_trait] + impl crate::call::app::CallApp for HoldApp { + fn app_type(&self) -> crate::call::app::CallAppType { + crate::call::app::CallAppType::Custom + } + + fn name(&self) -> &str { + "hold_app" + } + + async fn on_enter( + &mut self, + _controller: &mut crate::call::app::CallController, + _context: &crate::call::app::ApplicationContext, + ) -> anyhow::Result { + Ok(crate::call::app::AppAction::Continue) + } + } + + struct CaptureFactory { + contexts: std::sync::Mutex>, + } + + #[async_trait] + impl AppFactory for CaptureFactory { + async fn create_app( + &self, + _app_name: &str, + _params: Option, + context: &crate::call::app::ApplicationContext, + ) -> Result>, anyhow::Error> { + self.contexts.lock().unwrap().push(context.clone()); + Ok(Some(Box::new(HoldApp))) + } + } + fn make_runtime() -> (DefaultAppRuntime, mpsc::Receiver) { let (cmd_tx, cmd_rx) = mpsc::channel(64); let handle = SipSessionHandle::new_for_test("runtime-test", cmd_tx); @@ -622,4 +715,82 @@ mod tests { "second stop: NotRunning" ); } + + #[tokio::test] + async fn routed_start_owns_immutable_generation_context() { + let (cmd_tx, _cmd_rx) = mpsc::channel(64); + let handle = SipSessionHandle::new_for_test("runtime-test", cmd_tx); + let context = crate::call::app::ApplicationContext::new( + Default::default(), + crate::call::app::CallInfo { + session_id: "runtime-test".into(), + caller: "1001".into(), + callee: "1002".into(), + direction: "inbound".into(), + started_at: chrono::Utc::now(), + sip_headers: std::collections::HashMap::from([( + "X-Business-Type".into(), + "old".into(), + )]), + route_name: None, + }, + std::sync::Arc::new(crate::config::Config::default()), + ); + let factory = Arc::new(CaptureFactory { + contexts: std::sync::Mutex::new(Vec::new()), + }); + let runtime = DefaultAppRuntime::new(AppRuntimeConfig { + session_id: "runtime-test".into(), + handle, + context: Arc::new(context), + }) + .with_factory(factory.clone()); + + runtime + .start_app_with_route_context( + "hold_app", + None, + false, + crate::call::app::AppRouteContext { + callee: "39230".into(), + sip_headers: std::collections::HashMap::from([( + "x-business-type".into(), + "34".into(), + )]), + variables: std::collections::HashMap::from([( + "order_id".into(), + "order-001".into(), + )]), + }, + ) + .await + .unwrap(); + + let invocation = runtime.current_app_invocation().await.unwrap(); + assert_eq!(invocation.app_execution_id, 1); + assert_eq!(invocation.callee, "39230"); + assert_eq!(invocation.sip_headers["x-business-type"], "34"); + assert_eq!(invocation.variables["order_id"], "order-001"); + assert_eq!( + runtime.context.call_info.sip_headers["X-Business-Type"], + "old" + ); + let captured = factory.contexts.lock().unwrap(); + assert_eq!( + captured[0].invocation.as_ref().unwrap().app_execution_id, + invocation.app_execution_id + ); + drop(captured); + + runtime + .stop_app(Some("bridge excursion".into())) + .await + .unwrap(); + runtime.start_app("hold_app", None, false).await.unwrap(); + let resumed = runtime.current_app_invocation().await.unwrap(); + assert_eq!(resumed.app_execution_id, 2); + assert_eq!(resumed.callee, "39230"); + assert_eq!(resumed.sip_headers["x-business-type"], "34"); + assert_eq!(resumed.variables["order_id"], "order-001"); + } } diff --git a/src/proxy/call.rs b/src/proxy/call.rs index c36c63881..f842d81dc 100644 --- a/src/proxy/call.rs +++ b/src/proxy/call.rs @@ -679,6 +679,7 @@ impl CallModule { let mut forced_preview_forward: Option = None; let mut forced_pending_queue: Option = None; let mut forced_pending_app: Option<(String, Option, bool)> = None; + let mut forced_route_point: Option = None; if let Some(config) = callee_forwarding.as_ref() && matches!(config.mode, crate::call::CallForwardingMode::Always) @@ -755,6 +756,9 @@ impl CallModule { } forced_pending_queue = Some(queue_plan); } + crate::call::TransferEndpoint::RoutePoint(route_point) => { + forced_route_point = Some(route_point.clone()); + } crate::call::TransferEndpoint::Ivr(ivr_name) => { let name = ivr_name.trim(); if name.is_empty() { @@ -839,15 +843,45 @@ impl CallModule { })?, }; - let preview_option = InviteOption { + let mut preview_option = InviteOption { callee: callee_uri.clone(), caller: caller_uri.clone(), contact: caller_uri.clone(), ..Default::default() }; + let mut route_origin = None; + if let Some(route_point) = forced_route_point.as_deref() { + let target = crate::call::build_sip_uri(route_point, &callee_realm); + let target_uri = rsipstack::sip::Uri::try_from(target.as_str()).map_err(|e| { + RouteError::from(( + anyhow!("invalid always-forwarding route point '{}': {}", route_point, e), + Some(rsipstack::sip::StatusCode::ServerInternalError), + )) + .with_code(&crate::proxy::error_catalog::ALWAYS_FWD_URI_INVALID) + })?; + preview_option.callee = target_uri.clone(); + + // Route points are new routing identities, so every matcher view must see the target. + let mut synthetic_origin = original.clone(); + synthetic_origin.uri = target_uri.clone(); + synthetic_origin + .headers + .retain(|header| !matches!(header, rsipstack::sip::Header::To(_))); + synthetic_origin.headers.push( + rsipstack::sip::typed::To { + display_name: None, + uri: target_uri, + params: vec![], + } + .into(), + ); + route_origin = Some(synthetic_origin); + } let mut routed_headers: Option> = None; - let (preview_forward, pending_queue, pending_app, dialplan_hints) = if always_forwarding { + let (preview_forward, pending_queue, pending_app, dialplan_hints) = if always_forwarding + && forced_route_point.is_none() + { ( forced_preview_forward, forced_pending_queue, @@ -855,8 +889,13 @@ impl CallModule { None, ) } else { - let preview_outcome = route_invite - .preview_route(preview_option, original, &direction, cookie) + let mut preview_outcome = route_invite + .preview_route( + preview_option, + route_origin.as_ref().unwrap_or(original), + &direction, + cookie, + ) .await .map_err(|e| { RouteError::from(( @@ -866,6 +905,36 @@ impl CallModule { .with_code(&crate::proxy::error_catalog::ROUTE_PREVIEW_ERROR) })?; + if forced_route_point.is_some() + && !matches!( + &preview_outcome, + RouteResult::Application { .. } | RouteResult::Abort(_, _) + ) + { + // Rejecting a non-application result must release policy slots acquired by routing. + let hints = match &mut preview_outcome { + RouteResult::Queue { hints, .. } + | RouteResult::Forward(_, hints) + | RouteResult::NotHandled(_, hints) => hints.as_mut(), + _ => None, + }; + if let Some(hints) = hints { + if let Some(guard) = self.inner.routing_state.policy_guard.as_ref() { + crate::call::policy::PolicyGuard::release_concurrency_holds( + &hints.concurrency_holds, + guard.limiter().as_ref(), + ) + .await; + } + hints.concurrency_holds.clear(); + } + return Err(RouteError::from(( + anyhow!("always-forwarding route point did not resolve to an application"), + Some(rsipstack::sip::StatusCode::ServerInternalError), + )) + .with_code(&crate::proxy::error_catalog::ROUTE_PREVIEW_ERROR)); + } + match preview_outcome { RouteResult::Queue { queue, hints, .. } => (None, Some(queue), None, hints), RouteResult::Forward(option, hints) => (Some(option), None, None, hints), @@ -2793,6 +2862,67 @@ mod tests { } } + struct RoutePointApplicationRouteInvite; + + #[async_trait] + impl RouteInvite for RoutePointApplicationRouteInvite { + async fn route_invite( + &self, + mut option: InviteOption, + origin: &rsipstack::sip::Request, + _direction: &DialDirection, + _cookie: &TransactionCookie, + ) -> Result { + assert_eq!(option.callee.user(), Some("39230")); + assert_eq!(origin.uri.user(), Some("39230")); + assert_eq!( + origin + .to_header() + .expect("route-point To header") + .uri() + .expect("route-point To URI") + .user(), + Some("39230") + ); + option.headers = Some(vec![rsipstack::sip::Header::Other( + "X-Business-Key".into(), + "driver-spring-festival".into(), + )]); + Ok(RouteResult::Application { + option, + app_name: "step_ivr".to_string(), + app_params: Some(serde_json::json!({ + "businessKey": "driver-spring-festival" + })), + auto_answer: true, + hints: None, + }) + } + } + + struct RoutePointNotHandledRouteInvite; + + #[async_trait] + impl RouteInvite for RoutePointNotHandledRouteInvite { + async fn route_invite( + &self, + option: InviteOption, + _origin: &rsipstack::sip::Request, + _direction: &DialDirection, + _cookie: &TransactionCookie, + ) -> Result { + let mut hints = crate::config::DialplanHints::default(); + hints + .concurrency_holds + .push(crate::call::policy::ConcurrencyHold { + policy_id: "route-point-policy".to_string(), + scope: "caller".to_string(), + scope_value: "bp".to_string(), + }); + Ok(RouteResult::NotHandled(option, Some(hints))) + } + } + struct RewrittenForwardRouteInvite; #[async_trait] @@ -3812,6 +3942,120 @@ mod tests { assert_eq!(target, "sip:alice@rustpbx.com"); } + #[tokio::test] + async fn default_resolve_always_forwarding_route_point_uses_application_route_context() { + let (server, mut config) = create_test_server().await; + Arc::make_mut(&mut config).frequency_limiter = Some("memory".to_string()); + server + .user_backend + .create_user(SipUser { + id: 101, + username: "cfwdrp".to_string(), + enabled: true, + realm: Some("rustpbx.com".to_string()), + call_forwarding_mode: Some("always".to_string()), + call_forwarding_destination: Some("toivr:39230".to_string()), + ..Default::default() + }) + .await + .expect("create route-point forwarding user"); + let module = CallModule::new(config, server); + + let mut request = crate::proxy::tests::common::create_test_request( + rsipstack::sip::Method::Invite, + "bp", + None, + "rustpbx.com", + None, + ); + let route_point_user_uri = format!("sip:{}@{}", "cfwdrp", "rustpbx.com"); + request.uri = rsipstack::sip::Uri::try_from(route_point_user_uri.as_str()).unwrap(); + let request_uri = request.uri.clone(); + replace_to_header(&mut request, request_uri); + + let caller = SipUser { + username: "bp".to_string(), + realm: Some("rustpbx.com".to_string()), + ..Default::default() + }; + let dialplan = module + .default_resolve( + &request, + Box::new(RoutePointApplicationRouteInvite), + &caller, + &TransactionCookie::default(), + ) + .await + .expect("route-point forwarding should resolve through call routing"); + + match &dialplan.flow { + crate::call::DialplanFlow::Application { + app_name, + app_params, + auto_answer, + } => { + assert_eq!(app_name, "step_ivr"); + assert_eq!( + app_params.as_ref().and_then(|params| params.get("businessKey")), + Some(&serde_json::json!("driver-spring-festival")) + ); + assert!(*auto_answer); + } + other => panic!("expected application flow, got {other:?}"), + } + assert_eq!( + dialplan.routed_headers.as_ref().and_then(|headers| headers + .iter() + .find_map(|header| match header { + rsipstack::sip::Header::Other(name, value) + if name.eq_ignore_ascii_case("X-Business-Key") => + { + Some(value.as_str()) + } + _ => None, + })), + Some("driver-spring-festival") + ); + + let limiter = module + .inner + .routing_state + .policy_guard + .as_ref() + .expect("frequency policy guard") + .limiter() + .clone(); + assert!( + limiter + .check_concurrency("route-point-policy", "caller", "bp", 1) + .await + .expect("acquire route-point concurrency hold") + ); + let error = module + .default_resolve( + &request, + Box::new(RoutePointNotHandledRouteInvite), + &caller, + &TransactionCookie::default(), + ) + .await + .expect_err("unresolved route point must not dial the forwarding user"); + assert_eq!( + error.status, + Some(rsipstack::sip::StatusCode::ServerInternalError) + ); + assert!( + limiter + .check_concurrency("route-point-policy", "caller", "bp", 1) + .await + .expect("reacquire released route-point concurrency hold") + ); + limiter + .release_concurrency("route-point-policy", "caller", "bp") + .await + .expect("release test concurrency hold"); + } + #[tokio::test] async fn default_resolve_always_forwarding_queue_missing_returns_480() { let (server, config) = create_test_server().await; diff --git a/src/proxy/proxy_call/sip_session/session.rs b/src/proxy/proxy_call/sip_session/session.rs index 334319cd3..e791b52d0 100644 --- a/src/proxy/proxy_call/sip_session/session.rs +++ b/src/proxy/proxy_call/sip_session/session.rs @@ -621,7 +621,19 @@ impl SipSession { params: Option, label: &str, ) -> Result<()> { - self.ensure_app_running_with(kind, params, true, label) + self.ensure_app_running_with(kind, params, true, label, None) + .await + } + + pub(crate) async fn ensure_app_running_with_route_context( + &self, + kind: &str, + params: Option, + auto_answer: bool, + label: &str, + route_context: crate::call::app::AppRouteContext, + ) -> Result<()> { + self.ensure_app_running_with(kind, params, auto_answer, label, Some(route_context)) .await } @@ -639,12 +651,18 @@ impl SipSession { params: Option, auto_answer: bool, label: &str, + route_context: Option, ) -> Result<()> { use crate::call::runtime::AppRuntimeError; - let result = self - .app_runtime - .start_app(kind, params.clone(), auto_answer) - .await; + let result = if let Some(context) = route_context.clone() { + self.app_runtime + .start_app_with_route_context(kind, params.clone(), auto_answer, context) + .await + } else { + self.app_runtime + .start_app(kind, params.clone(), auto_answer) + .await + }; match result { Ok(()) => { // App now drives the session — suppress the RTP watchdog unless @@ -664,9 +682,14 @@ impl SipSession { warn!(session_id = %self.id, error = ?stop_err, "Failed to stop existing {} app", label) } } - self.app_runtime - .start_app(kind, params, auto_answer) - .await + let restarted = if let Some(context) = route_context { + self.app_runtime + .start_app_with_route_context(kind, params, auto_answer, context) + .await + } else { + self.app_runtime.start_app(kind, params, auto_answer).await + }; + restarted .map(|()| self.sync_rtp_timeout_pause()) .map_err(|e| anyhow!("Failed to restart {}: {:?}", label, e)) } @@ -791,14 +814,12 @@ impl SipSession { // no inbound request. let sip_headers = match mode { ConstructMode::Uas { server_dialog } => { - let mut hdrs = - crate::call::app::extract_sip_headers(&server_dialog.initial_request()); + let hdrs = crate::call::app::extract_sip_headers(&server_dialog.initial_request()); if let Some(ref routed) = context.dialplan.routed_headers { - for h in routed { - hdrs.insert(h.name().to_string(), h.value().to_string()); - } + crate::call::app::merge_sip_headers(&hdrs, routed) + } else { + hdrs } - hdrs } ConstructMode::Uac => Default::default(), }; @@ -1152,13 +1173,12 @@ impl SipSession { // field is injected by CallMetaStore enrichment (meta was inserted // above, before this event is dispatched). let incoming_sip_headers = { - let mut hdrs = crate::call::app::extract_sip_headers(&server_dialog.initial_request()); + let hdrs = crate::call::app::extract_sip_headers(&server_dialog.initial_request()); if let Some(ref routed) = session.context.dialplan.routed_headers { - for h in routed { - hdrs.insert(h.name().to_string(), h.value().to_string()); - } + crate::call::app::merge_sip_headers(&hdrs, routed) + } else { + hdrs } - hdrs }; if let Some(ref gw) = server.rwi_gateway { let ev = crate::rwi::CallCreated { @@ -3840,6 +3860,7 @@ impl SipSession { None, plan.accept_immediately, &format!("queue '{}'", plan.queue_name), + None, ) .await .map_err(|e| anyhow!("Failed to start queue app: {:?}", e))?; @@ -9531,7 +9552,7 @@ impl SipSession { Self::send_info_to_dialog(&dlg, headers, body).await } - async fn handle_hangup(&mut self, cmd: &HangupCommand) -> CommandResult { + pub(super) async fn handle_hangup(&mut self, cmd: &HangupCommand) -> CommandResult { self.meta.pending_transfer_outcome = None; let cascade = &cmd.cascade; diff --git a/src/proxy/proxy_call/sip_session/tests/mod.rs b/src/proxy/proxy_call/sip_session/tests/mod.rs index 57f311b99..dc8a56068 100644 --- a/src/proxy/proxy_call/sip_session/tests/mod.rs +++ b/src/proxy/proxy_call/sip_session/tests/mod.rs @@ -3174,6 +3174,91 @@ fn test_forward_route_config() -> crate::config::ProxyConfig { config } +fn test_application_route_config() -> crate::config::ProxyConfig { + use crate::config::ProxyConfig; + use crate::proxy::routing::{MatchConditions, RewriteRules, RouteAction, RouteRule}; + + let mut config = ProxyConfig::default(); + config.routes = Some(vec![RouteRule { + name: "alfred-route-point".to_string(), + priority: 100, + match_conditions: MatchConditions { + request_uri_user: Some("39230".to_string()), + headers: HashMap::from([("header.X-Carried".to_string(), "original".to_string())]), + ..Default::default() + }, + rewrite: Some(RewriteRules { + headers: HashMap::from([("header.X-Business-Type".to_string(), "34".to_string())]), + ..Default::default() + }), + action: RouteAction { + action: Some("application".to_string()), + app: Some("step_ivr".to_string()), + app_params: Some(serde_json::json!({"url": "http://127.0.0.1/ivr/step"})), + auto_answer: true, + ..Default::default() + }, + ..Default::default() + }]); + config +} + +#[tokio::test] +async fn route_leg_resolves_application_with_carried_and_rewritten_headers() { + use crate::call::{DialDirection, TransactionCookie}; + use crate::proxy::proxy_call::sip_session::util::route_leg; + use crate::proxy::tests::common::create_test_server_with_config; + + let (server, _) = create_test_server_with_config(test_application_route_config()).await; + let target: rsipstack::sip::Uri = format!("sip:{}{}{}", "39230", "@", "rustpbx.test") + .try_into() + .unwrap(); + let caller: rsipstack::sip::Uri = format!("sip:{}{}{}", "alice", "@", "rustpbx.test") + .try_into() + .unwrap(); + let contact = caller.clone(); + let carry_headers = vec![rsipstack::sip::Header::Other( + "X-Carried".to_string(), + "original".to_string(), + )]; + + let result = route_leg( + &server, + &target, + &caller, + &contact, + Some(carry_headers), + &DialDirection::Inbound, + TransactionCookie::default(), + ) + .await + .expect("route_leg should not error") + .expect("route should be handled"); + + match result { + crate::config::RouteResult::Application { + option, + app_name, + app_params, + auto_answer, + .. + } => { + assert_eq!(app_name, "step_ivr"); + assert_eq!( + app_params, + Some(serde_json::json!({"url": "http://127.0.0.1/ivr/step"})) + ); + assert!(auto_answer); + assert!(option.headers.as_ref().is_some_and(|headers| { + headers.iter().any(|header| { + header.name().eq_ignore_ascii_case("X-Business-Type") && header.value() == "34" + }) + })); + } + _ => panic!("expected Application route"), + } +} + /// `route_outbound_leg` routes an external target through the route table /// when the global `route_originated_calls` flag is on, stamping the /// matched trunk's destination + credential onto the returned InviteOption. diff --git a/src/proxy/proxy_call/sip_session/transfer.rs b/src/proxy/proxy_call/sip_session/transfer.rs index ad3a10a5d..422e095dc 100644 --- a/src/proxy/proxy_call/sip_session/transfer.rs +++ b/src/proxy/proxy_call/sip_session/transfer.rs @@ -142,6 +142,10 @@ pub(crate) enum TransferTarget { name: String, params: HashMap, }, + RoutePoint { + name: String, + params: HashMap, + }, Voicemail { extension: String, }, @@ -295,26 +299,12 @@ pub(crate) fn parse_transfer_target(target: &str) -> TransferTarget { } } } - crate::call::TransferEndpoint::Ivr(mut raw_name) => { - let query_str = raw_name.find('?').map(|pos| { - let qs = raw_name[pos + 1..].to_string(); - raw_name.truncate(pos); - qs - }); - let name = raw_name.trim().to_string(); - let mut params = HashMap::new(); - if let Some(ref query) = query_str { - for pair in query.split('&') { - if pair.is_empty() { - continue; - } - let mut parts = pair.splitn(2, '='); - let key = parts.next().unwrap_or(""); - let value = parts.next().unwrap_or(""); - let decoded = super::pct_decode_query(value); - params.insert(key.to_string(), decoded); - } - } + crate::call::TransferEndpoint::RoutePoint(raw_name) => { + let (name, params) = parse_named_target(raw_name); + TransferTarget::RoutePoint { name, params } + } + crate::call::TransferEndpoint::Ivr(raw_name) => { + let (name, params) = parse_named_target(raw_name); TransferTarget::Ivr { name, params } } crate::call::TransferEndpoint::Voicemail(extension) => { @@ -380,6 +370,28 @@ pub(crate) fn parse_transfer_target(target: &str) -> TransferTarget { } } +fn parse_named_target(mut raw_name: String) -> (String, HashMap) { + let query_str = raw_name.find('?').map(|pos| { + let qs = raw_name[pos + 1..].to_string(); + raw_name.truncate(pos); + qs + }); + let name = raw_name.trim().to_string(); + let mut params = HashMap::new(); + if let Some(query) = query_str { + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let mut parts = pair.splitn(2, '='); + let key = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or(""); + params.insert(key.to_string(), super::pct_decode_query(value)); + } + } + (name, params) +} + impl SipSession { pub(super) async fn handle_transfer( &mut self, @@ -502,6 +514,10 @@ impl SipSession { info!(session_id = %self.id, %leg_id, ivr = %name, "Handling IVR transfer by starting IvrApp"); self.start_ivr_app(&name, params).await } + TransferTarget::RoutePoint { name, params } => { + info!(session_id = %self.id, %leg_id, route_point = %name, "Handling IVR route-point transfer"); + self.start_route_point_app(&name, params).await + } TransferTarget::Voicemail { extension } => { info!(session_id = %self.id, %leg_id, %extension, "Handling voicemail transfer by starting VoicemailApp"); self.start_voicemail_app(&extension).await @@ -952,6 +968,176 @@ impl SipSession { self.start_queue_app(fallback_plan).await } + async fn start_route_point_app( + &mut self, + route_point: &str, + variables: HashMap, + ) -> Result<()> { + let caller = self + .context + .dialplan + .caller + .clone() + .ok_or_else(|| anyhow!("route-point transfer has no caller identity"))?; + let contact = self + .context + .dialplan + .caller_contact + .as_ref() + .map(|contact| contact.uri.clone()) + .unwrap_or_else(|| caller.clone()); + let realm = self.server.proxy_config.load().select_realm(""); + let target = crate::call::build_sip_uri(route_point, &realm); + let target_uri = rsipstack::sip::Uri::try_from(target.as_str()) + .map_err(|error| anyhow!("invalid route-point target: {error}"))?; + let current_invocation = self.app_runtime.current_app_invocation().await; + let current_headers = current_invocation + .as_ref() + .map(|context| context.sip_headers.clone()) + .unwrap_or_else(|| { + self.app_runtime + .app_context() + .map(|context| context.call_info.sip_headers.clone()) + .unwrap_or_default() + }); + let carry_headers = current_headers + .iter() + .map(|(name, value)| rsipstack::sip::Header::Other(name.clone(), value.clone())) + .collect::>(); + let routed = super::util::route_leg( + &self.server, + &target_uri, + &caller, + &contact, + (!carry_headers.is_empty()).then_some(carry_headers), + &self.context.dialplan.direction, + self.context.cookie.clone(), + ) + .await?; + + match routed { + Some(crate::config::RouteResult::Application { + option, + app_name, + app_params, + auto_answer, + hints, + }) => { + self.track_routed_leg_hints(hints); + let sip_headers = crate::call::app::merge_sip_headers( + ¤t_headers, + option.headers.as_deref().unwrap_or_default(), + ); + let route_context = crate::call::app::AppRouteContext { + callee: route_point.to_string(), + sip_headers, + variables: variables.clone(), + }; + if let Err(error) = self + .ensure_app_running_with_route_context( + &app_name, + app_params, + auto_answer, + &format!("route-point application '{app_name}'"), + route_context, + ) + .await + { + return self + .try_route_point_fallback_or_terminate( + error, + &format!("toivr:{route_point}"), + &variables, + ) + .await; + } + Ok(()) + } + Some(crate::config::RouteResult::Abort(code, reason)) => { + let status = code.code(); + self.terminate_route_point_handoff( + anyhow!( + "route aborted for IVR route point: {} {}", + status, + reason.unwrap_or_default() + ), + super::util::sip_status_to_hangup_reason(status), + Some(status), + ) + .await + } + Some(crate::config::RouteResult::Forward(_, hints)) + | Some(crate::config::RouteResult::NotHandled(_, hints)) => { + self.track_routed_leg_hints(hints); + self.try_route_point_fallback_or_terminate( + anyhow!("route point did not resolve to an application"), + &format!("toivr:{route_point}"), + &variables, + ) + .await + } + Some(crate::config::RouteResult::Queue { hints, .. }) => { + self.track_routed_leg_hints(hints); + self.try_route_point_fallback_or_terminate( + anyhow!("route point resolved to an unsupported queue"), + &format!("toivr:{route_point}"), + &variables, + ) + .await + } + None => { + self.try_route_point_fallback_or_terminate( + anyhow!("route point was not handled"), + &format!("toivr:{route_point}"), + &variables, + ) + .await + } + } + } + + async fn try_route_point_fallback_or_terminate( + &mut self, + error: anyhow::Error, + route_point: &str, + variables: &HashMap, + ) -> Result<()> { + match self + .try_ivr_fallback_after_start_failure(error, route_point, variables) + .await + { + Ok(()) => Ok(()), + Err(error) => { + let failure = crate::call::app::error_catalog::IVR_START_FAILED; + self.terminate_route_point_handoff( + error, + failure.hangup_reason.clone(), + failure.sip_status, + ) + .await + } + } + } + + async fn terminate_route_point_handoff( + &mut self, + error: anyhow::Error, + reason: crate::callrecord::CallRecordHangupReason, + code: Option, + ) -> Result<()> { + let hangup = self + .handle_hangup(&crate::call::domain::HangupCommand::all(Some(reason), code)) + .await; + if !hangup.success { + return Err(anyhow!( + "{}; terminal hangup failed: {}", + error, + hangup.message.unwrap_or_default() + )); + } + Err(error) + } + /// Resolve a raw [`ReturnTargetSpec`] (extracted from query params) into a /// concrete [`ReturnAppSpec`] ready to be stored on `CallMeta`. /// @@ -1044,17 +1230,23 @@ impl SipSession { return Err(original); }; - let (caller, callee, headers) = self + let invocation = self.app_runtime.current_app_invocation().await; + let call_info = self .app_runtime .app_context() - .map(|ctx| { - ( - ctx.call_info.caller.clone(), - ctx.call_info.callee.clone(), - Some(ctx.call_info.sip_headers.clone()), - ) - }) + .map(|context| context.call_info.clone()); + let caller = call_info + .as_ref() + .map(|info| info.caller.clone()) + .unwrap_or_default(); + let callee = invocation + .as_ref() + .map(|context| context.callee.clone()) + .or_else(|| call_info.as_ref().map(|info| info.callee.clone())) .unwrap_or_default(); + let headers = invocation + .map(|context| context.sip_headers) + .or_else(|| call_info.map(|info| info.sip_headers)); let Some(target) = fallback::resolve_fallback_target(fb, &caller, &callee, headers.as_ref()) @@ -2047,6 +2239,18 @@ mod tests { ); } + #[test] + fn test_parse_transfer_target_route_point() { + let t = parse_transfer_target("toivr:39230?order_id=order-001"); + assert_eq!( + t, + TransferTarget::RoutePoint { + name: "39230".to_string(), + params: HashMap::from([("order_id".to_string(), "order-001".to_string())]), + } + ); + } + #[test] fn test_parse_transfer_target_ivr_whitespace_trimmed() { let t = parse_transfer_target("ivr: welcome "); diff --git a/src/proxy/proxy_call/sip_session/util.rs b/src/proxy/proxy_call/sip_session/util.rs index f44b14533..c614a5b06 100644 --- a/src/proxy/proxy_call/sip_session/util.rs +++ b/src/proxy/proxy_call/sip_session/util.rs @@ -64,7 +64,28 @@ pub(crate) async fn route_outbound_leg( carry_headers: Option>, cookie: crate::call::cookie::TransactionCookie, ) -> Result> { - use crate::call::{DialDirection, RouteInvite}; + route_leg( + server, + target_uri, + caller, + contact, + carry_headers, + &crate::call::DialDirection::Outbound, + cookie, + ) + .await +} + +pub(crate) async fn route_leg( + server: &SipServerRef, + target_uri: &rsipstack::sip::Uri, + caller: &rsipstack::sip::Uri, + contact: &rsipstack::sip::Uri, + carry_headers: Option>, + direction: &crate::call::DialDirection, + cookie: crate::call::cookie::TransactionCookie, +) -> Result> { + use crate::call::RouteInvite; let route_invite: Box = { let routing_state = server.routing_state.read().clone(); @@ -133,12 +154,7 @@ pub(crate) async fn route_outbound_leg( }; match route_invite - .route_invite( - option, - &synthetic_request, - &DialDirection::Outbound, - &cookie, - ) + .route_invite(option, &synthetic_request, direction, &cookie) .await { Ok(result) => Ok(Some(result)), diff --git a/src/proxy/tests/test_sip_session_regressions.rs b/src/proxy/tests/test_sip_session_regressions.rs index 013cf3270..07431335d 100644 --- a/src/proxy/tests/test_sip_session_regressions.rs +++ b/src/proxy/tests/test_sip_session_regressions.rs @@ -2,6 +2,7 @@ use super::common::{ create_test_request, create_test_server, create_test_server_with_config, create_test_server_with_config_and_sipflow_backend, create_transaction, }; +use crate::call::app::{AppInvocationContext, ApplicationContext, CallInfo}; use crate::call::domain::{CallCommand, Leg, LegId, LegState, MediaPathMode, ReturnAppSpec}; use crate::call::runtime::{AppRuntime, AppRuntimeError, BridgeConfig}; use crate::call::{ @@ -213,6 +214,71 @@ struct NameCapturingRuntime { started_apps: std::sync::Mutex>, } +struct RoutePointRuntime { + started_apps: std::sync::Mutex)>>, + failed_apps: Vec, + invocation: Option, + context: Option>, +} + +impl RoutePointRuntime { + fn new(failed_apps: &[&str]) -> Self { + Self { + started_apps: std::sync::Mutex::new(Vec::new()), + failed_apps: failed_apps.iter().map(|name| name.to_string()).collect(), + invocation: None, + context: None, + } + } + + fn started_apps(&self) -> Vec<(String, Option)> { + self.started_apps.lock().unwrap().clone() + } +} + +#[async_trait] +impl AppRuntime for RoutePointRuntime { + fn app_context(&self) -> Option<&Arc> { + self.context.as_ref() + } + + async fn start_app( + &self, + app_name: &str, + params: Option, + _auto_answer: bool, + ) -> crate::call::runtime::AppResult<()> { + self.started_apps + .lock() + .unwrap() + .push((app_name.to_string(), params)); + if self.failed_apps.iter().any(|name| name == app_name) { + return Err(AppRuntimeError::UnknownApp(app_name.to_string())); + } + Ok(()) + } + + async fn current_app_invocation(&self) -> Option { + self.invocation.clone() + } + + async fn stop_app(&self, _reason: Option) -> crate::call::runtime::AppResult<()> { + Ok(()) + } + + fn inject_event(&self, _event: serde_json::Value) -> crate::call::runtime::AppResult<()> { + Ok(()) + } + + fn is_running(&self) -> bool { + false + } + + fn current_app(&self) -> Option { + None + } +} + impl NameCapturingRuntime { fn new() -> Self { Self { @@ -2607,6 +2673,232 @@ fn merge_leg_invite_headers_no_location_headers() { // ── IVR start-failure fallback guards ── +fn route_point_config(action: crate::proxy::routing::RouteAction) -> ProxyConfig { + use crate::proxy::routing::{MatchConditions, RouteRule}; + + let mut config = ProxyConfig::default(); + config.routes = Some(vec![RouteRule { + name: "route-point".to_string(), + priority: 100, + match_conditions: MatchConditions { + request_uri_user: Some("39230".to_string()), + ..Default::default() + }, + action, + ..Default::default() + }]); + config +} + +fn route_point_dialplan() -> Dialplan { + build_dialplan_with_mode(MediaProxyMode::Auto) + .with_caller("sip:alice@rustpbx.test".try_into().unwrap()) +} + +async fn execute_route_point_transfer( + session: &mut SipSession, +) -> crate::call::runtime::CommandResult { + assert!(session.update_leg_state(&LegId::from("caller"), LegState::Connected)); + let (_callee_tx, mut callee_rx) = mpsc::unbounded_channel(); + session + .execute_command( + CallCommand::Transfer { + leg_id: LegId::from("caller"), + target: "toivr:39230".to_string(), + attended: false, + }, + Some(&mut callee_rx), + ) + .await +} + +fn assert_route_point_handoff_terminated(session: &SipSession) { + assert_eq!( + session + .legs + .get(&LegId::from("caller")) + .map(|leg| leg.state), + Some(LegState::Ended) + ); + assert!(session.pending_hangup.contains(&session.caller_dialog_id())); +} + +#[tokio::test] +async fn route_point_fallback_matches_current_invocation_context() { + use crate::proxy::routing::MatchConditions; + use sea_orm::DatabaseConnection; + + let mut config = ProxyConfig::default(); + config.ivr_fallback = Some(crate::config::IvrFallbackConfig { + default: Some("original-safe".to_string()), + rules: vec![crate::config::IvrFallbackRule { + name: Some("routed-context".to_string()), + priority: 100, + match_conditions: MatchConditions { + callee: Some("route-200".to_string()), + headers: HashMap::from([("header.X-Business-Type".to_string(), "34".to_string())]), + ..Default::default() + }, + target: "routed-safe".to_string(), + }], + }); + let mut session = build_session_with_config(route_point_dialplan(), config).await; + let app_context = Arc::new(ApplicationContext::new( + DatabaseConnection::default(), + CallInfo { + session_id: "test-session".to_string(), + caller: "alice".to_string(), + callee: "original-100".to_string(), + direction: "inbound".to_string(), + started_at: chrono::Utc::now(), + sip_headers: HashMap::from([("X-Business-Type".to_string(), "old".to_string())]), + route_name: None, + }, + Arc::new(crate::config::Config::default()), + )); + let mut runtime = RoutePointRuntime::new(&[]); + runtime.context = Some(app_context); + runtime.invocation = Some(AppInvocationContext { + app_execution_id: 2, + callee: "route-200".to_string(), + sip_headers: HashMap::from([("X-Business-Type".to_string(), "34".to_string())]), + variables: HashMap::new(), + }); + let runtime = Arc::new(runtime); + session.app_runtime = runtime.clone(); + + session + .try_ivr_fallback_after_start_failure( + anyhow::anyhow!("route application failed"), + "toivr:39230", + &HashMap::new(), + ) + .await + .expect("current invocation should select a direct IVR fallback"); + + let starts = runtime.started_apps(); + assert_eq!(starts.len(), 1); + assert_eq!(starts[0].0, "ivr"); + assert!( + starts[0] + .1 + .as_ref() + .and_then(|params| params.get("file")) + .and_then(serde_json::Value::as_str) + .is_some_and(|file| file.contains("routed-safe")) + ); +} + +#[tokio::test] +async fn route_point_abort_terminates_without_fallback() { + use crate::proxy::routing::RouteAction; + + let mut config = route_point_config(RouteAction { + action: Some("busy".to_string()), + ..Default::default() + }); + config.ivr_fallback = Some(crate::config::IvrFallbackConfig { + default: Some("safe-ivr".to_string()), + rules: vec![], + }); + let mut session = build_session_with_config(route_point_dialplan(), config).await; + let runtime = Arc::new(RoutePointRuntime::new(&[])); + session.app_runtime = runtime.clone(); + + let result = execute_route_point_transfer(&mut session).await; + + assert!(!result.success); + assert!(runtime.started_apps().is_empty()); + assert_route_point_handoff_terminated(&session); +} + +#[tokio::test] +async fn route_point_miss_without_fallback_terminates() { + let mut session = + build_session_with_config(route_point_dialplan(), ProxyConfig::default()).await; + session.app_runtime = Arc::new(RoutePointRuntime::new(&[])); + + let result = execute_route_point_transfer(&mut session).await; + + assert!(!result.success); + assert_route_point_handoff_terminated(&session); +} + +#[tokio::test] +async fn route_point_queue_result_starts_direct_ivr_fallback_once() { + use crate::proxy::routing::RouteAction; + + let mut config = route_point_config(RouteAction { + action: Some("queue".to_string()), + queue: Some("support".to_string()), + ..Default::default() + }); + config.queues.insert( + "support".to_string(), + RouteQueueConfig { + name: Some("support".to_string()), + strategy: RouteQueueStrategyConfig { + targets: vec![RouteQueueTargetConfig { + uri: "sip:agent@rustpbx.test".to_string(), + label: None, + }], + ..Default::default() + }, + ..Default::default() + }, + ); + config.ivr_fallback = Some(crate::config::IvrFallbackConfig { + default: Some("safe-ivr".to_string()), + rules: vec![], + }); + let mut session = build_session_with_config(route_point_dialplan(), config).await; + let runtime = Arc::new(RoutePointRuntime::new(&[])); + session.app_runtime = runtime.clone(); + + let result = execute_route_point_transfer(&mut session).await; + + assert!(result.success); + assert_eq!( + runtime + .started_apps() + .into_iter() + .map(|(name, _)| name) + .collect::>(), + vec!["ivr".to_string()] + ); +} + +#[tokio::test] +async fn route_point_app_and_fallback_start_failure_terminates() { + use crate::proxy::routing::RouteAction; + + let mut config = route_point_config(RouteAction { + action: Some("application".to_string()), + app: Some("step_ivr".to_string()), + ..Default::default() + }); + config.ivr_fallback = Some(crate::config::IvrFallbackConfig { + default: Some("safe-ivr".to_string()), + rules: vec![], + }); + let mut session = build_session_with_config(route_point_dialplan(), config).await; + let runtime = Arc::new(RoutePointRuntime::new(&["step_ivr", "ivr"])); + session.app_runtime = runtime.clone(); + + let result = execute_route_point_transfer(&mut session).await; + + assert!(!result.success); + assert_eq!( + runtime + .started_apps() + .into_iter() + .map(|(name, _)| name) + .collect::>(), + vec!["step_ivr".to_string(), "ivr".to_string()] + ); + assert_route_point_handoff_terminated(&session); +} + /// With no `[proxy.ivr_fallback]` configured the original start error must /// surface unchanged — no silent swallowing. #[tokio::test]