use crate::config::LoadedConfig;
use crate::state::{AgentState, reduce};
use anyhow::{Context, Result};
use regex::Regex;
use ringo_agent::{AgentConfig, ProcessClient, SavedAudio};
use ringo_core::account::{Account, BackendOptions};
use ringo_core::event::AppEvent;
use ringo_profile::header::{HeaderContext, HeaderTemplate};
use std::collections::{BTreeMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::RecvTimeoutError;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::{Mutex as AsyncMutex, broadcast};
const EVENT_CHANNEL_CAPACITY: usize = 256;
const DIAL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const EVENT_LOG_CAPACITY: usize = 256;
pub(crate) const EVENT_PAGE_LIMIT: usize = 100;
pub(crate) struct EventLog {
entries: Mutex<VecDeque<(u64, AppEvent)>>,
next_id: std::sync::atomic::AtomicU64,
notify: tokio::sync::Notify,
}
impl EventLog {
#[cfg(test)]
pub(crate) fn for_tests() -> Self {
Self::new()
}
fn new() -> Self {
Self {
entries: Mutex::new(VecDeque::new()),
next_id: std::sync::atomic::AtomicU64::new(1),
notify: tokio::sync::Notify::new(),
}
}
pub(crate) fn append(&self, event: AppEvent) -> u64 {
let id = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.push_back((id, event));
while entries.len() > EVENT_LOG_CAPACITY {
entries.pop_front();
}
drop(entries);
self.notify.notify_waiters();
id
}
pub(crate) fn first_after(
&self,
after_id: u64,
pred: impl Fn(&AppEvent) -> bool,
) -> Option<(u64, AppEvent)> {
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|(id, e)| *id > after_id && pred(e))
.cloned()
}
pub(crate) fn page_after(&self, after_id: u64) -> (Vec<(u64, AppEvent)>, u64, u64, bool) {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let oldest = entries.front().map(|(id, _)| *id).unwrap_or(0);
let newest = entries.back().map(|(id, _)| *id).unwrap_or(0).max(
self.next_id
.load(std::sync::atomic::Ordering::Relaxed)
.saturating_sub(1),
);
let truncated = after_id.saturating_add(1) < oldest;
let events: Vec<(u64, AppEvent)> = entries
.iter()
.filter(|(id, _)| *id > after_id)
.take(EVENT_PAGE_LIMIT)
.cloned()
.collect();
(events, oldest, newest, truncated)
}
}
const HEADER_POLL_INTERVAL: Duration = Duration::from_millis(150);
#[derive(Debug, Clone)]
pub enum DialOutcome {
Established {
call_id: String,
},
Failed {
call_id: String,
reason: String,
},
StillRinging {
call_id: String,
},
NoCall,
}
#[derive(Debug, Clone, Default)]
pub struct DialPolicy {
deny: Vec<Regex>,
allow: Vec<Regex>,
}
impl DialPolicy {
pub fn build(deny: Vec<String>, allow: Vec<String>) -> Result<Self> {
let compile = |rules: Vec<String>, what: &str| -> Result<Vec<Regex>> {
rules
.into_iter()
.map(|r| {
Regex::new(&r)
.with_context(|| format!("--dial-{what} rule `{r}` is not a valid regex"))
})
.collect()
};
Ok(Self {
deny: compile(deny, "deny")?,
allow: compile(allow, "allow")?,
})
}
pub fn check(&self, resolved: &str) -> std::result::Result<(), String> {
let user = resolved
.split_once(':')
.and_then(|(_, rest)| rest.split_once('@').map(|(u, _)| u.to_string()))
.unwrap_or_else(|| resolved.to_string());
let user = user.as_str();
for rule in &self.deny {
if rule.is_match(user) || rule.is_match(resolved) {
return Err(format!(
"denied by dial policy (matched deny rule `{}`)",
rule.as_str()
));
}
}
if !self.allow.is_empty()
&& !self
.allow
.iter()
.any(|r| r.is_match(user) || r.is_match(resolved))
{
return Err("denied by dial policy (no allow rule matches)".to_string());
}
Ok(())
}
}
pub struct Hub {
slots: Vec<Slot>,
dial: DialPolicy,
}
struct Slot {
name: String,
info: Arc<AgentInfo>,
aor: String,
account: Account,
options: BackendOptions,
custom_headers: Vec<(String, String)>,
log: Arc<EventLog>,
agent: AsyncMutex<Option<Arc<Agent>>>,
}
impl Hub {
pub fn new(config: LoadedConfig, dial: DialPolicy) -> Self {
let slots = config
.agents
.into_iter()
.map(|def: crate::config::AgentDef| {
let aor = format!("sip:{}@{}", def.account.username, def.account.domain);
Slot {
name: def.name,
info: Arc::new(AgentInfo {
profile: def.profile,
description: def.description,
metadata: def.metadata,
}),
aor,
options: config.backend.clone(),
custom_headers: def.custom_headers,
log: Arc::new(EventLog::new()),
agent: AsyncMutex::new(None),
account: def.account,
}
})
.collect();
Self { slots, dial }
}
pub fn overview(&self) -> Vec<AgentOverview> {
self.slots
.iter()
.map(|s| {
let (running, state) = match s.agent.try_lock() {
Err(_) => (true, None),
Ok(guard) => match guard.as_ref() {
Some(a) => (true, Some(a.state())),
None => (false, None),
},
};
AgentOverview {
name: s.name.clone(),
info: Arc::clone(&s.info),
aor: s.aor.clone(),
running,
state,
}
})
.collect()
}
pub async fn get(&self, name: &str) -> Result<Arc<Agent>> {
let slot = self
.slots
.iter()
.find(|s| s.name == name)
.with_context(|| {
let known: Vec<&str> = self.slots.iter().map(|s| s.name.as_str()).collect();
format!("unknown agent `{name}`; known agents: {}", known.join(", "))
})?;
let mut guard = slot.agent.lock().await;
if let Some(agent) = guard.as_ref() {
if !agent.is_dead() {
return Ok(Arc::clone(agent));
}
eprintln!("ringo-mcp: agent `{name}` worker is gone; respawning");
}
let agent = Arc::new(
Agent::connect(
&slot.name,
Arc::clone(&slot.info),
slot.account.clone(),
slot.options.clone(),
&slot.custom_headers,
Arc::clone(&slot.log),
)
.await
.with_context(|| format!("start agent `{}`", slot.name))?,
);
*guard = Some(Arc::clone(&agent));
Ok(agent)
}
pub fn check_dial(&self, target: &str, domain: &str) -> std::result::Result<String, String> {
let resolved = resolve_target(target, domain);
self.dial.check(&resolved)?;
Ok(resolved)
}
pub async fn stop(&self, name: &str) -> Result<bool> {
let slot = self
.slots
.iter()
.find(|s| s.name == name)
.with_context(|| {
let known: Vec<&str> = self.slots.iter().map(|s| s.name.as_str()).collect();
format!("unknown agent `{name}`; known agents: {}", known.join(", "))
})?;
let mut guard = slot.agent.lock().await;
let Some(agent) = guard.take() else {
return Ok(false);
};
agent.shutdown_worker();
tokio::task::spawn_blocking(move || drop(agent));
Ok(true)
}
pub fn shutdown(&self) {
for slot in &self.slots {
if let Ok(guard) = slot.agent.try_lock() {
if let Some(agent) = guard.as_ref() {
agent.shutdown_worker();
}
}
}
}
}
pub struct AgentOverview {
pub name: String,
pub info: Arc<AgentInfo>,
pub aor: String,
pub running: bool,
pub state: Option<AgentState>,
}
#[derive(Debug, Default)]
pub struct AgentInfo {
pub profile: Option<String>,
pub description: Option<String>,
pub metadata: BTreeMap<String, String>,
}
fn is_agent_relevant(event: &AppEvent) -> bool {
!matches!(event, AppEvent::Unknown { .. })
}
pub struct Agent {
pub name: String,
pub info: Arc<AgentInfo>,
pub aor: String,
pub domain: String,
recording: Arc<RecordingState>,
client: Arc<ProcessClient>,
state: Arc<Mutex<AgentState>>,
events: broadcast::Sender<(u64, AppEvent)>,
log: Arc<EventLog>,
custom_headers: Vec<(String, HeaderTemplate)>,
}
impl Agent {
async fn connect(
name: &str,
info: Arc<AgentInfo>,
account: Account,
options: BackendOptions,
custom_headers: &[(String, String)],
log: Arc<EventLog>,
) -> Result<Self> {
let custom_headers: Vec<(String, HeaderTemplate)> = custom_headers
.iter()
.map(|(k, v)| (k.clone(), HeaderTemplate::new(v)))
.collect();
let aor = format!("sip:{}@{}", account.username, account.domain);
let domain = account.domain.clone();
let record_default = options.record_audio;
let config = AgentConfig {
name: name.to_string(),
account,
options,
};
let label = name.to_string();
let (client, events) = tokio::task::spawn_blocking(move || ProcessClient::spawn(config))
.await
.context("agent spawn task panicked")?
.with_context(|| format!("spawn worker for `{label}`"))?;
let client = Arc::new(client);
let (events_tx, _) = broadcast::channel::<(u64, AppEvent)>(EVENT_CHANNEL_CAPACITY);
let state = Arc::new(Mutex::new(AgentState::default()));
let recording = Arc::new(RecordingState::new(record_default));
let bridge_state = Arc::clone(&state);
let bridge_tx = events_tx.clone();
let bridge_client = Arc::clone(&client);
let bridge_log = Arc::clone(&log);
let bridge_recording = Arc::clone(&recording);
let headers = client.headers_handle();
tokio::task::spawn_blocking(move || {
loop {
match events.recv_timeout(HEADER_POLL_INTERVAL) {
Ok(event) => {
if !is_agent_relevant(&event) {
continue;
}
let id = bridge_log.append(event.clone());
let _ = bridge_tx.send((id, event.clone()));
let mut g = bridge_state.lock().unwrap_or_else(|e| e.into_inner());
let starts_call = matches!(
&event,
AppEvent::CallIncoming { .. } | AppEvent::CallOutgoing { .. }
);
let was_idle = g.calls.is_empty();
reduce(&mut g, &event);
let last_call_ended = closes_last_call(&event, &g);
if last_call_ended {
bridge_client.set_audio_source("silence");
}
match bridge_recording.on_event(
starts_call && was_idle,
matches!(event, AppEvent::CallOutgoing { .. }),
last_call_ended,
) {
RecordingAction::Arm => bridge_client.set_recording(true),
RecordingAction::Disarm => bridge_client.set_recording(false),
RecordingAction::None => {}
}
}
Err(RecvTimeoutError::Timeout) => {
let invites = headers.lock().unwrap_or_else(|e| e.into_inner()).take();
if let Some(invites) = invites {
let mut g = bridge_state.lock().unwrap_or_else(|e| e.into_inner());
g.merge_invites(invites);
}
}
Err(RecvTimeoutError::Disconnected) => {
let mut g = bridge_state.lock().unwrap_or_else(|e| e.into_inner());
g.worker_dead = true;
return;
}
}
}
});
eprintln!("ringo-mcp: agent `{name}` ready ({aor})");
for (key, tpl) in &custom_headers {
if !tpl.is_dynamic() {
client.add_header(key, tpl.raw());
}
}
Ok(Self {
name: name.to_string(),
info,
aor,
domain,
recording,
client,
state,
events: events_tx,
log,
custom_headers,
})
}
pub fn is_dead(&self) -> bool {
self.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.worker_dead
}
pub fn state(&self) -> AgentState {
self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub async fn wait_event(
&self,
timeout: Duration,
names: Option<&[&str]>,
after_id: Option<u64>,
) -> Option<(u64, AppEvent)> {
match after_id {
None => {
let mut rx = self.events.subscribe();
tokio::time::timeout(timeout, async {
loop {
match rx.recv().await {
Ok((id, event)) => {
if names.is_none_or(|n| n.contains(&crate::event_name(&event))) {
return Some((id, event));
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
})
.await
.ok()
.flatten()
}
Some(cursor) => {
let matches =
|e: &AppEvent| names.is_none_or(|n| n.contains(&crate::event_name(e)));
if let Some(hit) = self.log.first_after(cursor, matches) {
return Some(hit);
}
tokio::time::timeout(timeout, async {
loop {
let notified = std::pin::pin!(self.log.notify.notified());
if let Some(hit) = self.log.first_after(cursor, matches) {
return Some(hit);
}
notified.await;
}
})
.await
.ok()
.flatten()
}
}
}
pub(crate) fn event_log_page(&self, after_id: u64) -> (Vec<(u64, AppEvent)>, u64, u64, bool) {
self.log.page_after(after_id)
}
pub fn dial(&self, resolved: &str) {
self.refresh_dynamic_headers();
self.client.dial(resolved);
}
pub async fn dial_and_wait(&self, resolved: &str, timeout: Duration) -> DialOutcome {
let mut rx = self.events.subscribe();
self.dial(resolved);
let deadline = Instant::now() + timeout;
let confirm_budget = DIAL_CONFIRM_TIMEOUT.min(timeout);
let confirmed = tokio::time::timeout(confirm_budget, async {
loop {
match rx.recv().await {
Ok((_, AppEvent::CallOutgoing { call_id, .. })) => return Some(call_id),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
Ok(_) => {}
}
}
})
.await;
let our_id = match confirmed {
Ok(Some(id)) => id,
_ => return DialOutcome::NoCall,
};
let remaining = deadline.saturating_duration_since(Instant::now());
let other_id = our_id.clone();
tokio::time::timeout(remaining, async {
loop {
match rx.recv().await {
Ok((_, AppEvent::CallEstablished { call_id })) if call_id == our_id => {
return DialOutcome::Established {
call_id: call_id.clone(),
};
}
Ok((
_,
AppEvent::CallClosed {
call_id, reason, ..
},
)) if call_id == our_id => {
return DialOutcome::Failed {
call_id: call_id.clone(),
reason,
};
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => {
return DialOutcome::Failed {
call_id: other_id,
reason: "agent worker is gone".to_string(),
};
}
Ok(_) => {}
}
}
})
.await
.unwrap_or(DialOutcome::StillRinging {
call_id: our_id.clone(),
})
}
fn refresh_dynamic_headers(&self) {
let dynamic_keys: std::collections::HashSet<&str> = self
.custom_headers
.iter()
.filter(|(_, tpl)| tpl.is_dynamic())
.map(|(key, _)| key.as_str())
.collect();
if dynamic_keys.is_empty() {
return;
}
let ctx = HeaderContext::for_call();
for key in &dynamic_keys {
self.client.rm_header(key);
}
for (key, tpl) in &self.custom_headers {
if dynamic_keys.contains(key.as_str()) {
self.client.add_header(key, &tpl.render(&ctx));
}
}
}
pub fn add_header(&self, name: &str, value: &str) {
let rendered = HeaderTemplate::new(value).render(&HeaderContext::for_call());
self.client.rm_header(name);
self.client.add_header(name, &rendered);
}
pub fn rm_header(&self, name: &str) {
self.client.rm_header(name);
}
pub fn accept(&self) {
self.client.accept();
}
pub fn hangup(&self) {
self.client.hangup();
}
pub fn hangup_all(&self) {
self.client.hangup_all();
}
pub fn hold(&self) {
self.client.hold();
self.set_current_phase(crate::state::CallPhase::Held);
}
pub fn resume(&self) {
self.client.resume();
self.set_current_phase(crate::state::CallPhase::Established);
}
fn set_current_phase(&self, phase: crate::state::CallPhase) {
let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
if let Some(call) = g.calls.last_mut() {
call.phase = phase;
}
}
pub fn mute(&self) {
self.client.mute();
}
pub fn send_dtmf(&self, digit: char) {
self.client.send_dtmf(digit);
}
pub fn transfer(&self, resolved: &str) {
self.client.transfer(resolved);
}
pub fn play(&self, spec: &str) {
self.client.set_audio_source(spec);
}
pub fn set_recording(&self, on: bool) {
if on {
self.recording.arm(false);
} else {
self.recording.disarm();
}
self.client.set_recording(on);
}
pub fn arm_for_next_call(&self) {
self.recording.arm(true);
self.client.set_recording(true);
}
pub fn ever_recorded(&self) -> bool {
self.recording.ever_armed()
}
pub fn media_stats(&self) -> Option<ringo_core::event::MediaStats> {
self.client.media_stats()
}
pub fn received_dtmf(&self) -> String {
self.client.received_dtmf()
}
pub fn shutdown_worker(&self) {
self.client.request_shutdown();
}
pub fn call_count(&self) -> u32 {
self.client.call_count()
}
pub fn worker_pid(&self) -> u32 {
self.client.pid()
}
pub fn save_audio(&self, prefix: &str) -> Vec<SavedAudio> {
self.client.save_audio_detailed(prefix)
}
}
fn resolve_target(target: &str, domain: &str) -> String {
let target = sanitize_target(target);
if target.starts_with("sip:") || target.starts_with("sips:") {
target
} else if target.contains('@') {
format!("sip:{target}")
} else {
format!("sip:{target}@{domain}")
}
}
fn sanitize_target(input: &str) -> String {
let trimmed = input.trim();
let (scheme, rest) = trimmed.split_once(':').unwrap_or(("", trimmed));
let trimmed = match scheme.to_ascii_lowercase().as_str() {
"tel" | "callto" => rest.trim(),
s @ ("sip" | "sips") => return format!("{s}:{rest}"),
_ => trimmed,
};
if trimmed.contains('@') || trimmed.chars().any(|c| c.is_ascii_alphabetic()) {
return trimmed.to_string();
}
trimmed
.chars()
.filter(|c| !(c.is_whitespace() || matches!(c, '-' | '.' | '/' | '(' | ')')))
.collect()
}
fn closes_last_call(event: &AppEvent, state: &AgentState) -> bool {
matches!(event, AppEvent::CallClosed { .. }) && state.calls.is_empty()
}
#[derive(Debug, PartialEq, Eq)]
enum RecordingAction {
Arm,
Disarm,
None,
}
#[derive(Debug)]
struct RecordingState {
record_default: bool,
current: AtomicU64,
reserved: AtomicU64,
next_generation: AtomicU64,
ever: AtomicBool,
}
impl RecordingState {
fn new(record_default: bool) -> Self {
Self {
record_default,
current: AtomicU64::new(u64::from(record_default)),
reserved: AtomicU64::new(0),
next_generation: AtomicU64::new(2),
ever: AtomicBool::new(record_default),
}
}
fn arm(&self, for_next_dial: bool) {
let generation = self.next_generation.fetch_add(1, Ordering::AcqRel);
if for_next_dial {
self.reserved.store(generation, Ordering::Release);
}
self.current.store(generation, Ordering::Release);
self.ever.store(true, Ordering::Relaxed);
}
fn disarm(&self) {
self.current.store(0, Ordering::Release);
self.reserved.store(0, Ordering::Release);
}
fn ever_armed(&self) -> bool {
self.ever.load(Ordering::Relaxed)
}
fn on_event(
&self,
started_from_idle: bool,
dialed: bool,
last_call_ended: bool,
) -> RecordingAction {
if dialed {
self.reserved.store(0, Ordering::Release);
}
if started_from_idle && self.record_default {
self.arm(false);
return RecordingAction::Arm;
}
if last_call_ended && !self.record_default {
let current = self.current.load(Ordering::Acquire);
let reserved = self.reserved.load(Ordering::Acquire);
if current != 0
&& current != reserved
&& self
.current
.compare_exchange(current, 0, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return RecordingAction::Disarm;
}
}
RecordingAction::None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_bare_numbers_and_uris() {
assert_eq!(
resolve_target("1002", "pbx.example.com"),
"sip:1002@pbx.example.com"
);
assert_eq!(
resolve_target("1002@other.example.com", "pbx.example.com"),
"sip:1002@other.example.com"
);
assert_eq!(
resolve_target("sip:1002@other.example.com", "pbx.example.com"),
"sip:1002@other.example.com"
);
assert_eq!(
resolve_target("sips:1002@other.example.com", "pbx.example.com"),
"sips:1002@other.example.com"
);
}
#[test]
fn cleans_up_targets_the_way_people_and_llms_write_them() {
let d = "pbx.example.com";
assert_eq!(
resolve_target("+49 89 123456", d),
"sip:+4989123456@pbx.example.com"
);
assert_eq!(
resolve_target("(0123) 4567-890", d),
"sip:01234567890@pbx.example.com"
);
assert_eq!(
resolve_target("0123.4567/890", d),
"sip:01234567890@pbx.example.com"
);
assert_eq!(resolve_target(" 100 ", d), "sip:100@pbx.example.com");
assert_eq!(
resolve_target("tel:+49-30-1234567", d),
"sip:+49301234567@pbx.example.com"
);
assert_eq!(
resolve_target("callto:0123 456", d),
"sip:0123456@pbx.example.com"
);
assert_eq!(resolve_target("TEL:100", d), "sip:100@pbx.example.com");
assert_eq!(
resolve_target("SIP:1002@Other.example.com", d),
"sip:1002@Other.example.com"
);
assert_eq!(
resolve_target("Sips:1002@other.example.com", d),
"sips:1002@other.example.com"
);
assert_eq!(resolve_target("*100#", d), "sip:*100#@pbx.example.com");
assert_eq!(
resolve_target("john.doe", d),
"sip:john.doe@pbx.example.com"
);
assert_eq!(
resolve_target("alice-bob", d),
"sip:alice-bob@pbx.example.com"
);
assert_eq!(resolve_target("user@host", d), "sip:user@host");
}
#[test]
fn dial_policy_sees_the_cleaned_up_target() {
let hub = Hub::new(
loaded_config(),
DialPolicy::build(vec!["^00".into(), "^\\+?49900".into()], vec![]).unwrap(),
);
for target in [
"00 41 1234",
"tel:0041-1234",
"+49 900 123",
"TEL:+49900123",
] {
assert!(
hub.check_dial(target, "pbx.example.com").is_err(),
"{target} must be denied"
);
}
}
fn loaded_config() -> LoadedConfig {
LoadedConfig {
agents: vec![
crate::config::AgentDef {
name: "alice".into(),
profile: None,
description: None,
metadata: Default::default(),
account: Account {
username: "1001".into(),
domain: "pbx.example.com".into(),
password: "pw".into(),
..Default::default()
},
custom_headers: Vec::new(),
},
crate::config::AgentDef {
name: "bob".into(),
profile: None,
description: None,
metadata: Default::default(),
account: Account {
username: "1002".into(),
domain: "pbx.example.com".into(),
password: "pw".into(),
..Default::default()
},
custom_headers: Vec::new(),
},
],
backend: BackendOptions::default(),
}
}
#[test]
fn hub_builds_lazy_and_overview_knows_config_only() {
let hub = Hub::new(loaded_config(), DialPolicy::default());
let overview = hub.overview();
assert_eq!(overview.len(), 2);
assert_eq!(overview[0].name, "alice");
assert_eq!(overview[0].aor, "sip:1001@pbx.example.com");
assert!(!overview[0].running, "nothing spawned without a tool call");
assert!(overview[0].state.is_none());
assert!(hub.slots.iter().all(|s| s.agent.blocking_lock().is_none()));
}
#[test]
fn unknown_events_never_reach_the_agent() {
assert!(!is_agent_relevant(&AppEvent::Unknown {
class: "bevent".into(),
type_: "29".into(),
}));
assert!(!is_agent_relevant(&AppEvent::Unknown {
class: "other".into(),
type_: "1".into(),
}));
assert!(is_agent_relevant(&AppEvent::RegisterOk {
account: "a".into()
}));
assert!(is_agent_relevant(&AppEvent::CallHold {
call_id: "c".into()
}));
assert!(is_agent_relevant(&AppEvent::CallClosed {
call_id: "c".into(),
reason: "bye".into(),
error: false,
}));
}
#[test]
fn dial_policy_denies_expensive_targets() {
let policy = DialPolicy::build(
vec!["^00".into(), "^0900".into()],
vec![r"^\d{2,5}$".into()],
)
.unwrap();
assert!(policy.check("sip:0044164123@pbx.example.com").is_err());
let err = policy.check("sip:0900123@pbx.example.com").unwrap_err();
assert!(err.contains("^0900"), "{err}");
assert!(policy.check("sip:1002@pbx.example.com").is_ok());
let err = policy.check("sip:1002003@pbx.example.com").unwrap_err();
assert!(err.contains("no allow rule"), "{err}");
let domain_only = DialPolicy::build(vec![], vec!["@pbx\\.example\\.com$".into()]).unwrap();
assert!(domain_only.check("sip:1002@pbx.example.com").is_ok());
assert!(domain_only.check("sip:1002@other.example.net").is_err());
assert!(DialPolicy::default().check("sip:anything@x").is_ok());
let err = DialPolicy::build(vec!["[".into()], vec![])
.unwrap_err()
.to_string();
assert!(err.contains("not a valid regex"), "{err}");
let hub = Hub::new(
loaded_config(),
DialPolicy::build(vec!["^00".into()], vec![]).unwrap(),
);
assert_eq!(
hub.check_dial("1002", "pbx.example.com").unwrap(),
"sip:1002@pbx.example.com"
);
assert!(hub.check_dial("0044164123", "pbx.example.com").is_err());
}
#[test]
fn hub_carries_custom_header_templates_into_the_slots() {
let mut cfg = loaded_config();
cfg.agents[0].custom_headers = vec![
("X-Static".into(), "fixed".into()),
("X-Session-Tag".into(), "session-${uuid}".into()),
];
let hub = Hub::new(cfg, DialPolicy::default());
let templates = &hub.slots[0].custom_headers;
assert_eq!(templates.len(), 2);
assert_eq!(templates[1].1, "session-${uuid}");
}
#[test]
fn default_recording_rearms_per_call_and_never_disarms() {
use RecordingAction::*;
let r = RecordingState::new(true);
assert!(r.ever_armed());
assert_eq!(r.on_event(true, true, false), Arm);
assert_eq!(r.on_event(false, false, false), None);
assert_eq!(r.on_event(false, false, true), None);
assert_eq!(r.on_event(true, false, false), Arm);
}
#[test]
fn per_call_recording_is_disarmed_when_the_last_call_ends() {
use RecordingAction::*;
let r = RecordingState::new(false);
assert!(!r.ever_armed());
assert_eq!(r.on_event(true, true, false), None);
assert_eq!(r.on_event(false, false, true), None);
r.arm(false);
assert!(r.ever_armed());
assert_eq!(r.on_event(true, false, false), None);
assert_eq!(r.on_event(false, false, true), Disarm);
assert_eq!(r.on_event(false, false, true), None);
r.arm(false);
r.disarm();
assert_eq!(r.on_event(false, false, true), None);
}
#[test]
fn record_dial_survives_the_previous_calls_close_in_flight() {
use RecordingAction::*;
let r = RecordingState::new(false);
r.arm(false);
r.arm(true);
assert_eq!(r.on_event(false, false, true), None);
assert_eq!(r.on_event(true, true, false), None);
assert_eq!(r.on_event(false, false, true), Disarm);
}
#[test]
fn closes_last_call_only_after_the_final_call_ended() {
let mut s = AgentState::default();
reduce(
&mut s,
&AppEvent::CallOutgoing {
call_id: "c1".into(),
number: "1002".into(),
},
);
reduce(
&mut s,
&AppEvent::CallOutgoing {
call_id: "c2".into(),
number: "1003".into(),
},
);
let closed = |id: &str| AppEvent::CallClosed {
call_id: id.into(),
reason: "ok".into(),
error: false,
};
reduce(&mut s, &closed("c1"));
assert!(!closes_last_call(&closed("c1"), &s));
reduce(&mut s, &closed("c2"));
assert!(closes_last_call(&closed("c2"), &s));
assert!(!closes_last_call(
&AppEvent::CallEstablished {
call_id: "c3".into()
},
&s
));
}
#[test]
fn get_rejects_unknown_agents_with_known_names() {
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let hub = Hub::new(loaded_config(), DialPolicy::default());
let err = match rt.block_on(hub.get("mallory")) {
Err(e) => e.to_string(),
Ok(_) => panic!("unknown agent must fail"),
};
assert!(err.contains("unknown agent `mallory`"), "{err}");
assert!(err.contains("alice, bob"), "{err}");
}
}