Skip to content

Commit 6c7725b

Browse files
committed
feat: Enhance IVR and Call Recording Features[latest]
1 parent f70a493 commit 6c7725b

13 files changed

Lines changed: 435 additions & 69 deletions

File tree

src/app.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,17 @@ impl AppStateBuilder {
292292
.filter(|policy| policy.uploads_recording())
293293
.cloned();
294294

295+
// Create RWI gateway early so it can be shared with call record hooks
296+
// (e.g. RecordingUploadHook emits RecordingMetadataAvailable after upload).
297+
let rwi_gateway: Option<crate::rwi::RwiGatewayRef> =
298+
if config.rwi.is_some() || config.rwi_webhook.is_some() {
299+
Some(std::sync::Arc::new(parking_lot::RwLock::new(
300+
crate::rwi::RwiGateway::new(),
301+
)))
302+
} else {
303+
None
304+
};
305+
295306
let callrecord_formatter = if let Some(formatter) = self.callrecord_formatter {
296307
formatter
297308
} else {
@@ -342,7 +353,11 @@ impl AppStateBuilder {
342353
}
343354

344355
if let Some(policy) = recording_upload_policy.as_ref() {
345-
builder = builder.with_hook(Box::new(RecordingUploadHook::new(policy.clone())));
356+
let mut hook = RecordingUploadHook::new(policy.clone());
357+
if let Some(ref gw) = rwi_gateway {
358+
hook = hook.with_rwi_gateway(gw.clone());
359+
}
360+
builder = builder.with_hook(Box::new(hook));
346361
}
347362

348363
builder = builder.with_hook(Box::new(DatabaseHook {
@@ -383,13 +398,7 @@ impl AppStateBuilder {
383398
callrecord_stats: callrecord_stats.clone(),
384399
storage: storage.clone(),
385400
rwi_auth: crate::rwi::create_rwi_auth(&config),
386-
rwi_gateway: if config.rwi.is_some() || config.rwi_webhook.is_some() {
387-
Some(std::sync::Arc::new(parking_lot::RwLock::new(
388-
crate::rwi::RwiGateway::new(),
389-
)))
390-
} else {
391-
None
392-
},
401+
rwi_gateway: rwi_gateway.clone(),
393402
rwi_call_registry: None,
394403
});
395404

src/call/app/app_context.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ pub struct CallInfo {
2626
/// headers like Via, Max-Forwards, Call-ID, CSeq, Content-Length).
2727
#[serde(skip_serializing_if = "HashMap::is_empty")]
2828
pub sip_headers: HashMap<String, String>,
29+
/// Name of the matched routing rule that dispatched this call.
30+
#[serde(skip_serializing_if = "Option::is_none")]
31+
pub route_name: Option<String>,
2932
}
3033

3134
pub struct AppSharedState {
@@ -218,6 +221,7 @@ mod tests {
218221
direction: "inbound".to_string(),
219222
started_at: Utc::now(),
220223
sip_headers: HashMap::new(),
224+
route_name: None,
221225
}
222226
}
223227

src/call/app/ivr/executor.rs

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ pub struct StepIvrApp {
2222
step_index: u32,
2323
ivr_name: Option<String>,
2424
rwi_gateway: Option<crate::rwi::RwiGatewayRef>,
25+
/// Name of the route that dispatched this call into the IVR.
26+
route_name: Option<String>,
27+
/// Route-level configured headers.
28+
route_headers: Option<HashMap<String, String>>,
29+
/// Passthrough data set by the external provider (echoed back each step).
30+
custom_data: Option<serde_json::Value>,
31+
/// Previous step start time (RFC3339) for timing reporting.
32+
step_prev_start_time: Option<String>,
33+
/// Previous step wall-clock duration in ms.
34+
step_prev_duration_ms: u64,
35+
/// How this session entered IVR: `None` (fresh inbound), `"agent"`, `"queue"`.
36+
transferred_from: Option<String>,
37+
/// Last transfer target string (for EndReason classification).
38+
last_transfer_target: Option<String>,
2539
}
2640

2741
#[derive(Clone)]
@@ -48,6 +62,13 @@ impl StepIvrApp {
4862
step_index: 0,
4963
ivr_name: None,
5064
rwi_gateway: None,
65+
route_name: None,
66+
route_headers: None,
67+
custom_data: None,
68+
step_prev_start_time: None,
69+
step_prev_duration_ms: 0,
70+
transferred_from: None,
71+
last_transfer_target: None,
5172
}
5273
}
5374

@@ -64,6 +85,13 @@ impl StepIvrApp {
6485
step_index: 0,
6586
ivr_name: None,
6687
rwi_gateway: None,
88+
route_name: None,
89+
route_headers: None,
90+
custom_data: None,
91+
step_prev_start_time: None,
92+
step_prev_duration_ms: 0,
93+
transferred_from: None,
94+
last_transfer_target: None,
6795
}
6896
}
6997

@@ -98,6 +126,24 @@ impl StepIvrApp {
98126
self
99127
}
100128

129+
/// Set the route name that dispatched this call into the IVR.
130+
pub fn with_route_name(mut self, name: Option<String>) -> Self {
131+
self.route_name = name;
132+
self
133+
}
134+
135+
/// Set the route-level configured headers.
136+
pub fn with_route_headers(mut self, headers: Option<HashMap<String, String>>) -> Self {
137+
self.route_headers = headers;
138+
self
139+
}
140+
141+
/// Mark this session as re-entered from agent or queue.
142+
pub fn with_transferred_from(mut self, from: Option<String>) -> Self {
143+
self.transferred_from = from;
144+
self
145+
}
146+
101147
fn record_trace(&self, entry: IvrTraceEntry) {
102148
if let Some(t) = self.effective_trace() {
103149
let ent = entry.clone();
@@ -258,9 +304,10 @@ impl StepIvrApp {
258304
error: None,
259305
});
260306
match terminal {
261-
TerminalAction::Transfer(target) => {
262-
(AppAction::Transfer(target), "terminal")
263-
}
307+
TerminalAction::Transfer(target) => {
308+
self.last_transfer_target = Some(target.clone());
309+
(AppAction::Transfer(target), "terminal")
310+
}
264311
TerminalAction::Hangup { reason, code } => {
265312
(AppAction::Hangup { reason, code }, "terminal")
266313
}
@@ -319,7 +366,9 @@ impl StepIvrApp {
319366
}
320367
}
321368

322-
async fn request_next(&self, event: Option<ProviderEvent>) -> anyhow::Result<ActionNode> {
369+
async fn request_next(&mut self, event: Option<ProviderEvent>) -> anyhow::Result<ActionNode> {
370+
let now_rfc3339 = chrono::Utc::now().to_rfc3339();
371+
let prev_step_duration_ms = self.step_prev_duration_ms;
323372
let ctx = ProviderContext {
324373
session_id: self
325374
.sess
@@ -350,12 +399,25 @@ impl StepIvrApp {
350399
variables: self.sess.variables.clone(),
351400
sip_headers: self.get_sip_headers(),
352401
event,
402+
route_name: self.route_name.clone(),
403+
route_headers: self.route_headers.clone(),
404+
custom_data: self.custom_data.clone(),
405+
step_start_time: Some(self.step_prev_start_time.clone().unwrap_or_else(|| now_rfc3339.clone())),
406+
step_end_time: Some(now_rfc3339.clone()),
407+
step_duration_ms: if prev_step_duration_ms > 0 { Some(prev_step_duration_ms) } else { None },
408+
step_index: Some(self.step_index),
409+
transferred_from: self.transferred_from.clone(),
353410
};
354411

355412
let start = std::time::Instant::now();
356413
let result = self.provider.next_action(ctx.clone()).await;
357414
let elapsed_ms = start.elapsed().as_millis() as u64;
358415

416+
// Save step timing for the next ProviderContext.
417+
self.step_prev_start_time = Some(now_rfc3339);
418+
self.step_prev_duration_ms = elapsed_ms;
419+
self.step_index += 1;
420+
359421
// Trace the provider call
360422
let trace_action_type = match &result {
361423
Ok(node) => match &node.action {
@@ -680,9 +742,15 @@ impl CallApp for StepIvrApp {
680742
tenant_id: None,
681743
ivr_id: None,
682744
sip_headers: Some(headers),
745+
route_name: self.route_name.clone(),
746+
route_headers: self.route_headers.clone(),
747+
custom_data: self.custom_data.clone(),
748+
transferred_from: self.transferred_from.clone(),
683749
};
684750
self.provider.on_session_start(&sess_ctx).await.ok();
685751

752+
self.step_prev_start_time = Some(chrono::Utc::now().to_rfc3339());
753+
686754
self.record_session_start(
687755
&context.call_info.session_id,
688756
&context.call_info.caller,
@@ -811,17 +879,30 @@ impl CallApp for StepIvrApp {
811879
async fn on_exit(&mut self, reason: crate::call::app::ExitReason) -> anyhow::Result<()> {
812880
let end_reason = match reason {
813881
crate::call::app::ExitReason::Normal => EndReason::Normal,
814-
crate::call::app::ExitReason::Hangup
815-
| crate::call::app::ExitReason::RemoteHangup(_) => EndReason::Hangup,
816-
crate::call::app::ExitReason::Transferred => EndReason::Transfer(String::new()),
882+
crate::call::app::ExitReason::Hangup => EndReason::Hangup,
883+
crate::call::app::ExitReason::RemoteHangup(_) => EndReason::UserHangup,
884+
crate::call::app::ExitReason::Transferred => {
885+
// Determine transfer target type from the last action.
886+
let target = self.last_transfer_target.clone().unwrap_or_default();
887+
if target.starts_with("queue:") {
888+
EndReason::TransferToQueue(target)
889+
} else if target.starts_with("toivr:") || target.starts_with("ivr:") {
890+
EndReason::TransferToIvr(target)
891+
} else {
892+
EndReason::Transfer(target)
893+
}
894+
}
817895
crate::call::app::ExitReason::Error(e) => EndReason::Error(e),
818896
_ => EndReason::Normal,
819897
};
820898
self.provider.on_session_end(&end_reason).await.ok();
821899
let status = match &end_reason {
822900
EndReason::Normal => "completed",
823901
EndReason::Transfer(_) => "completed",
902+
EndReason::TransferToQueue(_) => "completed",
903+
EndReason::TransferToIvr(_) => "completed",
824904
EndReason::Hangup => "completed",
905+
EndReason::UserHangup => "completed",
825906
EndReason::Error(_) => "error",
826907
};
827908
self.record_session_end(status).await;
@@ -1486,6 +1567,10 @@ mod tests {
14861567
tenant_id: None,
14871568
ivr_id: None,
14881569
sip_headers: None,
1570+
route_name: None,
1571+
route_headers: None,
1572+
custom_data: None,
1573+
transferred_from: None,
14891574
};
14901575
step_provider
14911576
.on_session_start(&session)
@@ -1502,6 +1587,14 @@ mod tests {
15021587
variables: HashMap::new(),
15031588
sip_headers: None,
15041589
event: Some(ProviderEvent::SessionStart),
1590+
route_name: None,
1591+
route_headers: None,
1592+
custom_data: None,
1593+
step_start_time: None,
1594+
step_end_time: None,
1595+
step_duration_ms: None,
1596+
step_index: None,
1597+
transferred_from: None,
15051598
};
15061599
let prompt = step_provider.next_action(ctx).await.unwrap();
15071600
assert!(
@@ -1523,6 +1616,14 @@ mod tests {
15231616
variables: HashMap::new(),
15241617
sip_headers: None,
15251618
event: None,
1619+
route_name: None,
1620+
route_headers: None,
1621+
custom_data: None,
1622+
step_start_time: None,
1623+
step_end_time: None,
1624+
step_duration_ms: None,
1625+
step_index: None,
1626+
transferred_from: None,
15261627
}
15271628
};
15281629
let transfer = step_provider.next_action(ctx).await.unwrap();

src/call/app/ivr/provider.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ pub struct SessionContext {
4646
/// All SIP headers from the original INVITE request.
4747
#[serde(skip_serializing_if = "Option::is_none")]
4848
pub sip_headers: Option<HashMap<String, String>>,
49+
/// Name of the matched route that sent this call into the IVR.
50+
#[serde(skip_serializing_if = "Option::is_none")]
51+
pub route_name: Option<String>,
52+
/// Extra headers configured on the route (from routing rule `option.headers`).
53+
#[serde(skip_serializing_if = "Option::is_none")]
54+
pub route_headers: Option<HashMap<String, String>>,
55+
/// Arbitrary passthrough data set by the caller / external system.
56+
/// The provider receives this and can use it for correlation.
57+
#[serde(skip_serializing_if = "Option::is_none")]
58+
pub custom_data: Option<serde_json::Value>,
59+
/// Whether this session was re-entered from agent/queue (transfer-back).
60+
#[serde(skip_serializing_if = "Option::is_none")]
61+
pub transferred_from: Option<String>,
4962
}
5063

5164
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -61,6 +74,32 @@ pub struct ProviderContext {
6174
#[serde(skip_serializing_if = "Option::is_none")]
6275
pub sip_headers: Option<HashMap<String, String>>,
6376
pub event: Option<ProviderEvent>,
77+
/// Name of the matched route that sent this call into the IVR.
78+
#[serde(skip_serializing_if = "Option::is_none")]
79+
pub route_name: Option<String>,
80+
/// Extra headers configured on the route.
81+
#[serde(skip_serializing_if = "Option::is_none")]
82+
pub route_headers: Option<HashMap<String, String>>,
83+
/// Passthrough data — the provider can set `custom_data` in its response
84+
/// and it will be echoed back in every subsequent ProviderContext.
85+
#[serde(skip_serializing_if = "Option::is_none")]
86+
pub custom_data: Option<serde_json::Value>,
87+
/// Step timing: ISO-8601 timestamp when this step started.
88+
#[serde(skip_serializing_if = "Option::is_none")]
89+
pub step_start_time: Option<String>,
90+
/// Step timing: ISO-8601 timestamp when this step ended (set before sending).
91+
#[serde(skip_serializing_if = "Option::is_none")]
92+
pub step_end_time: Option<String>,
93+
/// Step timing: wall-clock duration of the previous step in milliseconds.
94+
#[serde(skip_serializing_if = "Option::is_none")]
95+
pub step_duration_ms: Option<u64>,
96+
/// Monotonic step index (0 for SessionStart, incremented thereafter).
97+
#[serde(skip_serializing_if = "Option::is_none")]
98+
pub step_index: Option<u32>,
99+
/// Whether this session was re-entered from agent/queue.
100+
/// Values: `"agent"`, `"queue"`, or `None`.
101+
#[serde(skip_serializing_if = "Option::is_none")]
102+
pub transferred_from: Option<String>,
64103
}
65104

66105
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -100,9 +139,19 @@ pub enum ProviderEvent {
100139

101140
#[derive(Debug, Clone)]
102141
pub enum EndReason {
142+
/// IVR completed normally (played all nodes, no transfer).
103143
Normal,
144+
/// IVR exited because the call was transferred to an agent or extension.
104145
Transfer(String),
146+
/// IVR exited because the call was sent to a queue.
147+
TransferToQueue(String),
148+
/// IVR exited because the call jumped to another IVR.
149+
TransferToIvr(String),
150+
/// System (PBX) initiated the hangup.
105151
Hangup,
152+
/// User / remote party hung up.
153+
UserHangup,
154+
/// Error during IVR execution.
106155
Error(String),
107156
}
108157

0 commit comments

Comments
 (0)