@@ -3,7 +3,7 @@ use super::config::{ActionNode, EntryAction};
33use super :: provider:: { ActionProvider , EndReason , ProviderContext , ProviderEvent , SessionContext } ;
44use super :: trace:: { IvrTraceCollector , IvrTraceEntry , IvrTraceSession } ;
55use crate :: call:: app:: {
6- AppAction , AppEvent , ApplicationContext , CallApp , CallAppType , CallController ,
6+ AppAction , AppEvent , ApplicationContext , CallApp , CallAppType , CallController , RecordingInfo ,
77} ;
88use async_trait:: async_trait;
99use std:: collections:: HashMap ;
@@ -80,6 +80,7 @@ struct PendingMenu {
8080 invalid_action : Option < Box < ActionNode > > ,
8181 max_retries : u32 ,
8282 retry_count : u32 ,
83+ timeout_ms : u64 ,
8384}
8485
8586impl StepIvrApp {
@@ -485,6 +486,52 @@ impl StepIvrApp {
485486 return Box :: pin ( self . __exec_node ( ctrl, ctx) ) . await ;
486487 }
487488 ActionResult :: WaitFor ( ref wait_event) => {
489+ // InputPhone collects digits synchronously; forward to provider
490+ if matches ! ( node. action, EntryAction :: InputPhone { .. } ) {
491+ let ( provider_event, step_trigger) = match wait_event {
492+ WaitEvent :: DtmfCollected { .. } => {
493+ let number = self . sess . variables
494+ . get ( "phone_number" )
495+ . cloned ( )
496+ . unwrap_or_default ( ) ;
497+ ( ProviderEvent :: PhoneCollected { number : number. clone ( ) } ,
498+ crate :: rwi:: TriggerInfo :: with_detail (
499+ "phone_collected" ,
500+ serde_json:: json!( { "number" : number } ) ,
501+ ) )
502+ }
503+ WaitEvent :: DtmfTimeout => {
504+ ( ProviderEvent :: DtmfTimeout ,
505+ crate :: rwi:: TriggerInfo :: new ( "dtmf_timeout" ) )
506+ }
507+ _ => unreachable ! ( ) ,
508+ } ;
509+ self . pending_start_instant = self . step_start_instant ;
510+ self . pending_trace = Some ( IvrTraceEntry {
511+ session_id : session_id. clone ( ) ,
512+ caller : caller. clone ( ) ,
513+ callee : callee. clone ( ) ,
514+ step_index : self . step_index ,
515+ trigger : step_trigger,
516+ provider_url : None ,
517+ action_type : node_type_str,
518+ action_json,
519+ error : None ,
520+ step_id : step_id. clone ( ) ,
521+ step_name : step_name. clone ( ) ,
522+ step_start_time : self . current_step_start_time . clone ( ) ,
523+ step_end_time : None ,
524+ duration_ms : 0 ,
525+ extra : self . extra . clone ( ) ,
526+ end_reason : None ,
527+ end_detail : None ,
528+ } ) ;
529+ self . current_node = Some (
530+ self . request_next ( Some ( provider_event) ) . await ?,
531+ ) ;
532+ return Box :: pin ( self . __exec_node ( ctrl, ctx) ) . await ;
533+ }
534+
488535 let step_trigger = match wait_event {
489536 WaitEvent :: DtmfCollected { digit } => {
490537 crate :: rwi:: TriggerInfo :: with_detail (
@@ -744,7 +791,7 @@ impl StepIvrApp {
744791 if let Some ( ref menu) = self . pending_menu {
745792 ctrl. set_timeout (
746793 "ivr_dtmf_timeout" ,
747- Duration :: from_millis ( menu. max_retries as u64 * 5000 ) ,
794+ Duration :: from_millis ( menu. timeout_ms ) ,
748795 ) ;
749796 }
750797 return Ok ( ActionResult :: WaitFor ( WaitEvent :: AudioComplete {
@@ -768,13 +815,15 @@ impl StepIvrApp {
768815 timeout_action,
769816 invalid_action,
770817 max_retries,
818+ timeout_ms,
771819 ..
772820 } => PendingMenu {
773821 entries : entries. clone ( ) ,
774822 timeout_action : timeout_action. clone ( ) ,
775823 invalid_action : invalid_action. clone ( ) ,
776824 max_retries : * max_retries,
777825 retry_count : 0 ,
826+ timeout_ms : * timeout_ms,
778827 } ,
779828 _ => {
780829 warn ! ( "build_pending_menu called with unexpected action type, returning empty menu" ) ;
@@ -784,6 +833,7 @@ impl StepIvrApp {
784833 invalid_action : None ,
785834 max_retries : 0 ,
786835 retry_count : 0 ,
836+ timeout_ms : 0 ,
787837 }
788838 }
789839 }
@@ -796,6 +846,7 @@ impl StepIvrApp {
796846 let timeout_action = menu. timeout_action ;
797847 let invalid_action = menu. invalid_action ;
798848 let max_retries = menu. max_retries ;
849+ let timeout_ms = menu. timeout_ms ;
799850
800851 if let Some ( next) = entries. get ( digit) {
801852 return Some ( next. clone ( ) ) ;
@@ -812,6 +863,7 @@ impl StepIvrApp {
812863 timeout_action,
813864 invalid_action : None ,
814865 max_retries,
866+ timeout_ms,
815867 } ) ;
816868 return Some ( next_action) ;
817869 }
@@ -821,6 +873,7 @@ impl StepIvrApp {
821873 timeout_action,
822874 invalid_action : None ,
823875 max_retries,
876+ timeout_ms,
824877 } ) ;
825878 None
826879 }
@@ -832,6 +885,7 @@ impl StepIvrApp {
832885 let timeout_action = menu. timeout_action ;
833886 let invalid_action = menu. invalid_action ;
834887 let max_retries = menu. max_retries ;
888+ let timeout_ms = menu. timeout_ms ;
835889
836890 if let Some ( ta) = timeout_action {
837891 return Some ( * ta) ;
@@ -849,6 +903,7 @@ impl StepIvrApp {
849903 timeout_action : None ,
850904 invalid_action,
851905 max_retries,
906+ timeout_ms,
852907 } ) ;
853908 None
854909 }
@@ -962,6 +1017,16 @@ impl CallApp for StepIvrApp {
9621017 self . current_track_id = None ;
9631018 self . interrupt_on_dtmf = false ;
9641019
1020+ // Provider-driven menus (empty entries) forward any DTMF to provider
1021+ let is_provider_driven = self . pending_menu . as_ref ( ) . map_or ( false , |m| m. entries . is_empty ( ) ) ;
1022+ if is_provider_driven {
1023+ self . pending_menu . take ( ) ;
1024+ self . current_node = Some (
1025+ self . request_next ( Some ( ProviderEvent :: Dtmf { digit } ) ) . await ?,
1026+ ) ;
1027+ return self . __exec_node ( ctrl, context) . await ;
1028+ }
1029+
9651030 if let Some ( next) = self . handle_menu_dtmf ( & digit) {
9661031 self . provider . on_local_dtmf_match ( & digit, & next) . await ;
9671032 let dtmf_detail = serde_json:: json!( { "digit" : digit. clone( ) } ) ;
@@ -1008,7 +1073,7 @@ impl CallApp for StepIvrApp {
10081073 self . awaiting_dtmf = true ;
10091074 ctrl. set_timeout (
10101075 "ivr_dtmf_timeout" ,
1011- Duration :: from_millis ( menu. max_retries as u64 * 5000 ) ,
1076+ Duration :: from_millis ( menu. timeout_ms ) ,
10121077 ) ;
10131078 return Ok ( AppAction :: Continue ) ;
10141079 }
@@ -1067,6 +1132,17 @@ impl CallApp for StepIvrApp {
10671132 self . awaiting_dtmf = false ;
10681133
10691134 if self . pending_menu . is_some ( ) {
1135+ // Provider-driven menus (empty entries) forward timeout to provider
1136+ let is_provider_driven = self . pending_menu . as_ref ( ) . map_or ( false , |m| m. entries . is_empty ( ) ) ;
1137+ if is_provider_driven {
1138+ self . pending_menu . take ( ) ;
1139+ self . current_node = Some (
1140+ self . request_next ( Some ( ProviderEvent :: DtmfMenuTimeout ) ) . await ?,
1141+ ) ;
1142+ self . current_trigger = Some ( crate :: rwi:: TriggerInfo :: new ( "dtmf_menu_timeout" ) ) ;
1143+ return self . __exec_node ( ctrl, context) . await ;
1144+ }
1145+
10701146 if let Some ( next) = self . handle_menu_timeout ( ) {
10711147 self . current_trigger = Some ( crate :: rwi:: TriggerInfo :: new ( "dtmf_menu_timeout" ) ) ;
10721148 self . current_node = Some ( next) ;
@@ -1202,6 +1278,23 @@ impl CallApp for StepIvrApp {
12021278 self . pending_menu = None ;
12031279 Ok ( ( ) )
12041280 }
1281+
1282+ async fn on_record_complete (
1283+ & mut self ,
1284+ info : RecordingInfo ,
1285+ ctrl : & mut CallController ,
1286+ context : & ApplicationContext ,
1287+ ) -> anyhow:: Result < AppAction > {
1288+ let duration_secs = info. duration . as_secs ( ) ;
1289+ self . current_node = Some (
1290+ self . request_next ( Some ( ProviderEvent :: RecordingComplete {
1291+ url : info. path ,
1292+ duration_secs,
1293+ } ) )
1294+ . await ?,
1295+ ) ;
1296+ self . __exec_node ( ctrl, context) . await
1297+ }
12051298}
12061299
12071300#[ cfg( test) ]
@@ -2356,6 +2449,147 @@ mod tests {
23562449 state. release_step . notify_waiters ( ) ;
23572450 }
23582451
2452+ // ── Bug 6: Provider-driven menu timeout forwards to provider ──────────
2453+
2454+ #[ tokio:: test]
2455+ async fn test_provider_driven_menu_timeout_forwards_to_provider ( ) {
2456+ let menu = ActionNode :: new ( EntryAction :: DtmfMenu {
2457+ greeting : None ,
2458+ greeting_text : None ,
2459+ greeting_record_list : None ,
2460+ greeting_voice : None ,
2461+ timeout_ms : 3000 ,
2462+ max_retries : 1 ,
2463+ entries : HashMap :: new ( ) ,
2464+ timeout_action : None ,
2465+ invalid_action : None ,
2466+ greeting_api_url : None ,
2467+ } ) ;
2468+ let followup = ActionNode :: new ( EntryAction :: Transfer {
2469+ target : "2001" . into ( ) ,
2470+ } ) ;
2471+
2472+ let mut stack = MockCallStack :: run (
2473+ Box :: new ( mock_app ( vec ! [ menu, followup] ) ) ,
2474+ "1001" ,
2475+ "2000" ,
2476+ ) ;
2477+ stack
2478+ . assert_cmd ( 200 , "accept" , |c| matches ! ( c, CallCommand :: Answer { .. } ) )
2479+ . await ;
2480+
2481+ // Menu has no greeting → NoAudio path → timeout set, app waiting
2482+ tokio:: time:: sleep ( Duration :: from_millis ( 50 ) ) . await ;
2483+ stack. timeout ( "ivr_dtmf_timeout" ) ;
2484+
2485+ // With fix: provider called with DtmfMenuTimeout → returns Transfer
2486+ stack
2487+ . assert_cmd (
2488+ 500 ,
2489+ "transfer" ,
2490+ |c| matches ! ( c, CallCommand :: Transfer { target, .. } if target == "2001" ) ,
2491+ )
2492+ . await ;
2493+ }
2494+
2495+ // ── Bug 7: InputPhone forwards collected digits to provider ───────────
2496+
2497+ #[ tokio:: test]
2498+ async fn test_input_phone_forwards_to_provider ( ) {
2499+ let input_phone = ActionNode :: new ( EntryAction :: InputPhone {
2500+ prompt : Some ( "enter_phone.wav" . into ( ) ) ,
2501+ prompt_text : None ,
2502+ prompt_voice : None ,
2503+ min_digits : 11 ,
2504+ max_digits : 11 ,
2505+ } ) ;
2506+ let followup = ActionNode :: new ( EntryAction :: Transfer {
2507+ target : "2001" . into ( ) ,
2508+ } ) ;
2509+
2510+ let mut stack = MockCallStack :: run (
2511+ Box :: new ( mock_app ( vec ! [ input_phone, followup] ) ) ,
2512+ "1001" ,
2513+ "2000" ,
2514+ ) ;
2515+ stack
2516+ . assert_cmd ( 200 , "accept" , |c| matches ! ( c, CallCommand :: Answer { .. } ) )
2517+ . await ;
2518+ stack
2519+ . assert_cmd ( 200 , "play" , |c| {
2520+ matches ! (
2521+ c,
2522+ CallCommand :: Play {
2523+ source: crate :: call:: domain:: MediaSource :: File { path } , ..
2524+ } if path == "enter_phone.wav"
2525+ )
2526+ } )
2527+ . await ;
2528+
2529+ // Inject DTMF digits while collect_dtmf is waiting
2530+ stack. dtmf ( "12345678901" ) ;
2531+
2532+ // With fix: provider called with PhoneCollected → returns Transfer
2533+ stack
2534+ . assert_cmd (
2535+ 500 ,
2536+ "transfer" ,
2537+ |c| matches ! ( c, CallCommand :: Transfer { target, .. } if target == "2001" ) ,
2538+ )
2539+ . await ;
2540+ }
2541+
2542+ // ── Bug 8: Torecord forwards recording complete to provider ──────────
2543+
2544+ #[ tokio:: test]
2545+ async fn test_torecord_recording_complete_forwards_to_provider ( ) {
2546+ let torecord = ActionNode :: new ( EntryAction :: Torecord {
2547+ prompt : Some ( "record.wav" . into ( ) ) ,
2548+ beep : true ,
2549+ max_duration_secs : Some ( 5 ) ,
2550+ } ) ;
2551+ let followup = ActionNode :: new ( EntryAction :: Transfer {
2552+ target : "2001" . into ( ) ,
2553+ } ) ;
2554+
2555+ let mut stack = MockCallStack :: run (
2556+ Box :: new ( mock_app ( vec ! [ torecord, followup] ) ) ,
2557+ "1001" ,
2558+ "2000" ,
2559+ ) ;
2560+ stack
2561+ . assert_cmd ( 200 , "accept" , |c| matches ! ( c, CallCommand :: Answer { .. } ) )
2562+ . await ;
2563+ stack
2564+ . assert_cmd ( 200 , "play" , |c| {
2565+ matches ! (
2566+ c,
2567+ CallCommand :: Play {
2568+ source: crate :: call:: domain:: MediaSource :: File { path } , ..
2569+ } if path == "record.wav"
2570+ )
2571+ } )
2572+ . await ;
2573+ stack
2574+ . assert_cmd ( 200 , "start_record" , |c| {
2575+ matches ! ( c, CallCommand :: StartRecording { .. } )
2576+ } )
2577+ . await ;
2578+
2579+ // Inject recording complete
2580+ tokio:: time:: sleep ( Duration :: from_millis ( 50 ) ) . await ;
2581+ stack. record_complete ( "recordings/test.wav" , Duration :: from_secs ( 3 ) , 12345 ) ;
2582+
2583+ // With fix: provider called with RecordingComplete → returns Transfer
2584+ stack
2585+ . assert_cmd (
2586+ 500 ,
2587+ "transfer" ,
2588+ |c| matches ! ( c, CallCommand :: Transfer { target, .. } if target == "2001" ) ,
2589+ )
2590+ . await ;
2591+ }
2592+
23592593 // ── True E2E: HTTP → Python provider ─────────────────────────────────
23602594 //
23612595 // Validates the FULL IVR protocol chain:
0 commit comments