Skip to content

Commit d2f38e9

Browse files
committed
chore: update dependencies and improve presence handling[latest]
1 parent 01d887c commit d2f38e9

15 files changed

Lines changed: 549 additions & 103 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ toml_edit = { version = "0.25.12", features = ["serde"] }
141141
rand = "0.10.1"
142142
chrono = { version = "0.4", features = ["serde"] }
143143
chrono-tz = "0.10.4"
144-
rsipstack = "0.5.15"
144+
rsipstack = "0.5.16"
145145
hex = { version = "0.4.3", optional = true }
146146
#rsipstack = { path = "../rsipstack" }
147147
audio-codec = "0.3.39"
@@ -248,7 +248,7 @@ ldap3 = { version = "0.12.1", optional = true }
248248
ring = { version = "0.17", optional = true }
249249
tempfile = "3.27.0"
250250
country-emoji = { version = "0.3.5", optional = true }
251-
flowdb = { version = "0.7.2", default-features = false }
251+
flowdb = { version = "0.7.4", default-features = false }
252252

253253

254254
[dev-dependencies]

src/addons/cc

Submodule cc updated from 93de315 to 4436e81

src/call/app/ivr/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,16 @@ impl EntryAction {
606606
pub fn is_dtmf_menu(&self) -> bool {
607607
matches!(self, EntryAction::DtmfMenu { .. })
608608
}
609+
610+
pub fn is_interruptible(&self) -> bool {
611+
matches!(
612+
self,
613+
EntryAction::Prompt {
614+
interruptible: true,
615+
..
616+
}
617+
)
618+
}
609619
}
610620

611621
#[cfg(test)]

src/call/app/ivr/executor.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,8 @@ impl StepIvrApp {
645645
if let ActionResult::WaitFor(WaitEvent::AudioComplete { .. }) = &result {
646646
if node.action.is_dtmf_menu() {
647647
self.pending_menu = Some(self.build_pending_menu(&node.action));
648+
} else if node.action.is_interruptible() {
649+
self.interrupt_on_dtmf = true;
648650
}
649651
}
650652
if matches!(&result, ActionResult::WaitFor(WaitEvent::NoAudio)) {
@@ -1801,6 +1803,62 @@ mod tests {
18011803
.await;
18021804
}
18031805

1806+
/// Regression: DTMF during an *interruptible* Prompt must be forwarded to
1807+
/// the provider. The `awaiting_dtmf` / `interrupt_on_dtmf` flags should
1808+
/// NOT block legitimate input when the node explicitly allows interruption.
1809+
#[tokio::test]
1810+
async fn test_interruptible_prompt_forwards_dtmf_to_provider() {
1811+
let prompt = ActionNode::new(EntryAction::Prompt {
1812+
file: Some("hello.wav".into()),
1813+
tts_text: None,
1814+
tts_voice: None,
1815+
record_name_list: None,
1816+
interruptible: true,
1817+
tts_api_url: None,
1818+
});
1819+
let transfer = ActionNode::new(EntryAction::Transfer {
1820+
target: "2001".into(),
1821+
});
1822+
1823+
let mut stack =
1824+
MockCallStack::run(Box::new(mock_app(vec![prompt, transfer])), "1001", "2000");
1825+
stack
1826+
.assert_cmd(200, "accept", |c| matches!(c, CallCommand::Answer { .. }))
1827+
.await;
1828+
stack
1829+
.assert_cmd(200, "play", |c| {
1830+
matches!(
1831+
c,
1832+
CallCommand::Play {
1833+
source: crate::call::domain::MediaSource::File { path },
1834+
..
1835+
} if path == "hello.wav"
1836+
)
1837+
})
1838+
.await;
1839+
1840+
// While the interruptible prompt is still playing, inject a DTMF digit.
1841+
// It MUST be forwarded to the provider (not silently ignored).
1842+
let _ = stack.drain_cmds();
1843+
stack.dtmf("5");
1844+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1845+
1846+
// The app should stop the audio first, then ask the provider for
1847+
// the next action (MockProvider returns Transfer("2001")).
1848+
stack
1849+
.assert_cmd(200, "stop", |c| {
1850+
matches!(c, CallCommand::StopPlayback { .. })
1851+
})
1852+
.await;
1853+
stack
1854+
.assert_cmd(
1855+
200,
1856+
"transfer",
1857+
|c| matches!(c, CallCommand::Transfer { target, .. } if target == "2001"),
1858+
)
1859+
.await;
1860+
}
1861+
18041862
// ── True E2E: HTTP → Python provider ─────────────────────────────────
18051863
//
18061864
// Validates the FULL IVR protocol chain:

src/console/handlers/call_record.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,28 @@ async fn download_call_record_sip_flow(
226226
// Default main call_id to "primary" if not overridden
227227
call_id_roles.insert(record.call_id.clone(), "primary".to_string());
228228

229-
let cdr_data = load_cdr_data(&state, &record).await;
230-
if let Some(cdr) = &cdr_data {
231-
for (cid, role) in &cdr.record.sip_leg_roles {
232-
call_id_roles.insert(cid.clone(), role.clone());
229+
// Load sip_leg_roles from DB metadata first (faster, no file I/O),
230+
// then fall back to the CDR JSON file.
231+
let mut sip_leg_roles_loaded = false;
232+
if let Some(ref meta) = record.metadata {
233+
if let Some(meta_map) = meta.as_object() {
234+
if let Some(json_str) = meta_map.get("sip_leg_roles").and_then(|v| v.as_str()) {
235+
if let Ok(roles) = serde_json::from_str::<HashMap<String, String>>(json_str) {
236+
for (cid, role) in roles {
237+
call_id_roles.insert(cid, role);
238+
}
239+
sip_leg_roles_loaded = true;
240+
}
241+
}
242+
}
243+
}
244+
245+
if !sip_leg_roles_loaded {
246+
let cdr_data = load_cdr_data(&state, &record).await;
247+
if let Some(cdr) = &cdr_data {
248+
for (cid, role) in &cdr.record.sip_leg_roles {
249+
call_id_roles.insert(cid.clone(), role.clone());
250+
}
233251
}
234252
}
235253

src/console/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ impl ConsoleState {
183183
serde_json::Value::String("© 2025 RustPBX. All rights reserved.".to_string())
184184
});
185185
let static_path = self.config().static_path();
186+
map.entry("static_path")
187+
.or_insert_with(|| serde_json::Value::String(static_path.clone()));
186188
map.entry("site_logo").or_insert_with(|| {
187189
serde_json::Value::String(format!("{}/images/logo.png", static_path))
188190
});

src/models/call_record.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,14 @@ pub async fn persist_call_record(
111111
transcript_language: Set(transcript_language.clone()),
112112
tags: Set(tags.clone()),
113113
leg_timeline: Set(leg_timeline_json),
114-
metadata: Set(details
115-
.metadata
116-
.as_ref()
117-
.and_then(|m| serde_json::to_value(m).ok())),
114+
metadata: Set({
115+
let mut m = details.metadata.clone().unwrap_or_default();
116+
if !record.sip_leg_roles.is_empty() {
117+
let json = serde_json::to_string(&record.sip_leg_roles).unwrap_or_default();
118+
m.insert("sip_leg_roles".to_string(), json);
119+
}
120+
serde_json::to_value(&m).ok()
121+
}),
118122
created_at: Set(record.start_time),
119123
updated_at: Set(record.end_time),
120124
archived_at: Set(None),

src/proxy/auth.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,14 +506,10 @@ impl ProxyModule for AuthModule {
506506
vec![Header::WwwAuthenticate(www_auth)],
507507
)
508508
} else {
509-
let www_auth = self.create_www_auth_challenge(&realm)?;
510509
let proxy_auth = self.create_proxy_auth_challenge(&realm)?;
511510
(
512511
rsipstack::sip::StatusCode::ProxyAuthenticationRequired,
513-
vec![
514-
Header::WwwAuthenticate(www_auth),
515-
Header::ProxyAuthenticate(proxy_auth),
516-
],
512+
vec![Header::ProxyAuthenticate(proxy_auth)],
517513
)
518514
};
519515

src/proxy/cluster_event.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,11 @@ impl ClusterPresenceMessage {
117117
let status = match self.status.as_str() {
118118
"available" => PresenceStatus::Available,
119119
"busy" => PresenceStatus::Busy,
120-
"away" => PresenceStatus::Away,
121-
_ => PresenceStatus::Offline,
120+
"away" => PresenceStatus::Away(String::new()),
121+
"dnd" => PresenceStatus::Dnd,
122+
"offline" => PresenceStatus::Offline,
123+
"" => PresenceStatus::Offline,
124+
other => PresenceStatus::Away(other.to_string()),
122125
};
123126
Some(PresenceState {
124127
status,
@@ -842,7 +845,7 @@ mod tests {
842845
last_updated: 0,
843846
};
844847
let state = msg.to_state().unwrap();
845-
assert_eq!(state.status, PresenceStatus::Away);
848+
assert_eq!(state.status, PresenceStatus::Away(String::new()));
846849
}
847850

848851
#[test]
@@ -859,16 +862,16 @@ mod tests {
859862
}
860863

861864
#[test]
862-
fn test_presence_message_to_state_unknown_falls_to_offline() {
865+
fn test_presence_message_to_state_custom_falls_to_away() {
863866
let msg = ClusterPresenceMessage {
864867
identity: "test".to_string(),
865-
status: "dnd".to_string(),
868+
status: "lunch".to_string(),
866869
note: None,
867870
activity: None,
868871
last_updated: 0,
869872
};
870873
let state = msg.to_state().unwrap();
871-
assert_eq!(state.status, PresenceStatus::Offline);
874+
assert_eq!(state.status, PresenceStatus::Away("lunch".to_string()));
872875
}
873876

874877
// ── LocatorEvent → ClusterLocatorMessage conversion ─────────────────────
@@ -1267,7 +1270,7 @@ mod tests {
12671270
#[test]
12681271
fn test_presence_state_full_round_trip() {
12691272
let original = PresenceState {
1270-
status: PresenceStatus::Away,
1273+
status: PresenceStatus::Away(String::new()),
12711274
note: Some("Lunch".to_string()),
12721275
activity: Some("meal".to_string()),
12731276
last_updated: 1715000000,
@@ -1286,7 +1289,7 @@ mod tests {
12861289
};
12871290

12881291
// Assert
1289-
assert_eq!(reconstructed.status, PresenceStatus::Away);
1292+
assert_eq!(reconstructed.status, PresenceStatus::Away(String::new()));
12901293
assert_eq!(reconstructed.note.as_deref(), Some("Lunch"));
12911294
assert_eq!(reconstructed.activity.as_deref(), Some("meal"));
12921295
assert_eq!(reconstructed.last_updated, 1715000000);

0 commit comments

Comments
 (0)