Skip to content

Commit 84a8d64

Browse files
committed
fix: provider-driven menu timeout, InputPhone, and RecordingComplete forwarding to CCF + [latest]
1 parent 8e3b2bd commit 84a8d64

4 files changed

Lines changed: 338 additions & 46 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rustpbx"
3-
version = "0.4.10"
3+
version = "0.4.11"
44
edition = "2024"
55
default-run = "rustpbx"
66
authors = ["jinti<shenjindi@fourz.cn>"]

src/addons/cc

Submodule cc updated from e68c4d3 to ccf30ff

src/call/app/ivr/executor.rs

Lines changed: 237 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use super::config::{ActionNode, EntryAction};
33
use super::provider::{ActionProvider, EndReason, ProviderContext, ProviderEvent, SessionContext};
44
use super::trace::{IvrTraceCollector, IvrTraceEntry, IvrTraceSession};
55
use crate::call::app::{
6-
AppAction, AppEvent, ApplicationContext, CallApp, CallAppType, CallController,
6+
AppAction, AppEvent, ApplicationContext, CallApp, CallAppType, CallController, RecordingInfo,
77
};
88
use async_trait::async_trait;
99
use 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

8586
impl 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

Comments
 (0)