use super::super::convert::{into_value, json_to_js, throw};
use super::audio::AudioSpec;
use super::core::HostState;
use crate::engine::assertion::Value as EngVal;
use crate::engine::audio;
use crate::engine::ctx::{CallState, Ctx as EngineCtx, sip_user_part};
use crate::engine::duration;
use indexmap::IndexMap;
use ringo_core::account::Account;
use rquickjs::class::Trace;
use rquickjs::function::Opt;
use rquickjs::{Class, Ctx as JsCtx, IntoJs, JsLifetime, Object, Result as JsResult, Value};
use std::sync::Arc;
#[derive(ringo_flow_macros::TsEnum)]
pub(in crate::script::js) enum State {
Idle,
Ringing,
Established,
}
impl State {
pub(in crate::script::js) fn object<'js>(ctx: &JsCtx<'js>) -> JsResult<Object<'js>> {
let obj = Object::new(ctx.clone())?;
for (key, value) in State::VALUES {
obj.set(*key, *value)?;
}
Ok(obj)
}
fn js_value(self) -> &'static str {
match self {
State::Idle => "idle",
State::Ringing => "ringing",
State::Established => "established",
}
}
}
impl From<CallState> for State {
fn from(s: CallState) -> Self {
match s {
CallState::Idle => State::Idle,
CallState::Ringing => State::Ringing,
CallState::Established => State::Established,
}
}
}
impl<'js> IntoJs<'js> for State {
fn into_js(self, ctx: &JsCtx<'js>) -> JsResult<Value<'js>> {
rquickjs::String::from_str(ctx.clone(), self.js_value()).map(Value::from_string)
}
}
#[derive(rquickjs::IntoJs, ringo_flow_macros::TsInterface)]
#[jsdoc(readonly)]
struct Peer {
uri: String,
number: String,
name: Option<String>,
}
#[derive(rquickjs::IntoJs, ringo_flow_macros::TsInterface)]
#[jsdoc(readonly)]
struct CallQuality {
mos: f64,
rtt: f64,
jitter: f64,
#[qjs(rename = "packetLoss")]
packet_loss: f64,
}
#[derive(ringo_flow_macros::TsInterface)]
struct AgentConfig {
username: String,
domain: String,
password: Option<String>,
transport: Option<String>,
auth_user: Option<String>,
display_name: Option<String>,
outbound: Option<String>,
stun_server: Option<String>,
media_enc: Option<String>,
regint: Option<u32>,
mwi: Option<bool>,
dtmf_mode: Option<String>,
#[jsdoc(optional, type = "Record<string, string | string[]>")]
headers: Option<Vec<(String, String)>>,
deflect_to: Option<String>,
#[jsdoc(optional, type = "Record<string, unknown>")]
metadata: Option<serde_json::Value>,
}
impl AgentConfig {
fn from_js(label: &str, config: &Object<'_>) -> Result<Self, String> {
super::super::bindings::reject_unknown_keys(label, config, Self::FIELDS)?;
let headers = headers_from_obj(label, config)?;
Ok(AgentConfig {
username: cfg_str(label, config, "username")?
.ok_or_else(|| format!("{label}: `username` is required"))?,
domain: cfg_str(label, config, "domain")?
.ok_or_else(|| format!("{label}: `domain` is required"))?,
password: cfg_str(label, config, "password")?,
transport: cfg_str(label, config, "transport")?,
auth_user: cfg_str(label, config, "auth_user")?,
display_name: cfg_str(label, config, "display_name")?,
outbound: cfg_str(label, config, "outbound")?,
stun_server: cfg_str(label, config, "stun_server")?,
media_enc: cfg_str(label, config, "media_enc")?,
regint: cfg_u32(label, config, "regint")?,
mwi: cfg_bool(label, config, "mwi")?,
dtmf_mode: cfg_str(label, config, "dtmf_mode")?,
headers: Some(headers).filter(|h| !h.is_empty()),
deflect_to: cfg_str(label, config, "deflect_to")?,
metadata: metadata_from_obj(label, config)?,
})
}
fn into_parts(
self,
) -> (
Account,
Vec<(String, String)>,
Option<String>,
serde_json::Value,
) {
let account = Account {
username: self.username,
domain: self.domain,
password: self.password.unwrap_or_default(),
display_name: self.display_name,
transport: self.transport,
auth_user: self.auth_user,
outbound: self.outbound,
stun_server: self.stun_server,
media_enc: self.media_enc,
regint: self.regint,
mwi: self.mwi.unwrap_or(false),
dtmf_mode: self.dtmf_mode,
catchall: false,
audio_codecs: Vec::new(),
};
let headers = self.headers.unwrap_or_default();
let metadata = self
.metadata
.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
(account, headers, self.deflect_to, metadata)
}
}
#[derive(rquickjs::IntoJs, ringo_flow_macros::TsInterface)]
#[jsdoc(rename = "AgentInfo")]
struct AgentSnapshot {
name: String,
aor: String,
registered: bool,
#[jsdoc(type = "State")]
state: String,
reason: Option<String>,
#[qjs(rename = "statusCode")]
status_code: Option<i64>,
peer: Option<Peer>,
calls: i64,
}
#[derive(Trace, JsLifetime, Clone)]
#[rquickjs::class(rename = "Agent")]
pub struct Agent {
#[qjs(skip_trace)]
pub name: String,
#[qjs(skip_trace)]
pub ctx: Arc<EngineCtx>,
#[qjs(skip_trace)]
pub metadata: Arc<serde_json::Value>,
}
#[ringo_flow_macros::ts_export]
#[rquickjs::methods]
impl Agent {
#[qjs(constructor)]
fn new<'js>(
cx: JsCtx<'js>,
name: String,
#[jsdoc(type = "AgentConfig")] config: Object<'js>,
) -> JsResult<Agent> {
let eng = cx
.userdata::<HostState>()
.expect("host state stored at install")
.eng
.clone();
let label = format!("agent `{name}`");
let to_err = |e: String| throw(&cx, &e);
let (account, headers, deflect_to, metadata) = AgentConfig::from_js(&label, &config)
.map_err(to_err)?
.into_parts();
eng.connect_agent(&name, account, &headers)
.map_err(to_err)?;
if let Some(target) = deflect_to {
eng.deflect_to_uri(&name, &target).map_err(to_err)?;
}
Ok(Agent {
name,
ctx: eng,
metadata: Arc::new(metadata),
})
}
#[qjs(get)]
fn registered<'js>(&self, ctx: JsCtx<'js>) -> JsResult<bool> {
self.ctx.registered(&self.name).map_err(|e| throw(&ctx, &e))
}
#[qjs(get)]
fn state<'js>(&self, ctx: JsCtx<'js>) -> JsResult<State> {
self.ctx
.call_state(&self.name)
.map(State::from)
.map_err(|e| throw(&ctx, &e))
}
#[qjs(get)]
fn quality<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Option<CallQuality>> {
let stats = self.ctx.quality(&self.name).map_err(|e| throw(&ctx, &e))?;
crate::engine::ctx::mark_pending_label(format!("{} quality MOS", self.name));
Ok(stats.map(|s| CallQuality {
mos: s.mos,
rtt: s.rtt_ms,
jitter: s.jitter_ms,
packet_loss: s.packet_loss_pct,
}))
}
#[qjs(get, rename = "receivedDtmf")]
fn received_dtmf<'js>(&self, ctx: JsCtx<'js>) -> JsResult<String> {
self.ctx
.received_dtmf(&self.name)
.map_err(|e| throw(&ctx, &e))
}
#[qjs(get)]
#[jsdoc(type = "Record<string, unknown>")]
fn metadata<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Value<'js>> {
json_to_js(&ctx, self.metadata.as_ref())
}
fn register<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx.register(&self.name).map_err(|e| throw(&ctx, &e))
}
fn accept<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx.accept(&self.name).map_err(|e| throw(&ctx, &e))
}
fn hangup<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx.hangup(&self.name).map_err(|e| throw(&ctx, &e))
}
fn dtmf<'js>(&self, ctx: JsCtx<'js>, digits: String, gap: Opt<String>) -> JsResult<()> {
let gap = match gap.0 {
Some(s) => duration::parse_duration(&s).map_err(|e| throw(&ctx, &e))?,
None => std::time::Duration::ZERO,
};
self.ctx
.dtmf(&self.name, &digits, gap)
.map_err(|e| throw(&ctx, &e))
}
fn dial<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "Agent | string")] target: Value<'js>,
) -> JsResult<()> {
dispatch_target(
&ctx,
"dial",
&target,
|n| self.ctx.dial_agent(&self.name, n),
|u| self.ctx.dial_uri(&self.name, u),
)
}
fn hold<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx.hold(&self.name).map_err(|e| throw(&ctx, &e))
}
fn resume<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx.resume(&self.name).map_err(|e| throw(&ctx, &e))
}
fn mute<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx.mute(&self.name).map_err(|e| throw(&ctx, &e))
}
fn transfer<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "Agent | string")] target: Value<'js>,
) -> JsResult<()> {
dispatch_target(
&ctx,
"transfer",
&target,
|n| self.ctx.transfer_agent(&self.name, n),
|u| self.ctx.transfer_uri(&self.name, u),
)
}
#[qjs(rename = "attendedTransfer")]
fn attended_transfer<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "Agent | string")] target: Value<'js>,
) -> JsResult<()> {
dispatch_target(
&ctx,
"attendedTransfer",
&target,
|n| self.ctx.attended_transfer_agent(&self.name, n),
|u| self.ctx.attended_transfer_uri(&self.name, u),
)
}
#[qjs(rename = "completeTransfer")]
fn complete_transfer<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx
.complete_transfer(&self.name)
.map_err(|e| throw(&ctx, &e))
}
#[qjs(rename = "abortTransfer")]
fn abort_transfer<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx
.abort_transfer(&self.name)
.map_err(|e| throw(&ctx, &e))
}
fn deflect<'js>(
&self,
ctx: JsCtx<'js>,
#[jsdoc(type = "Agent | string")] target: Value<'js>,
) -> JsResult<()> {
dispatch_target(
&ctx,
"deflect",
&target,
|n| self.ctx.deflect_to_agent(&self.name, n),
|u| self.ctx.deflect_to_uri(&self.name, u),
)
}
#[qjs(rename = "stopDeflect")]
fn stop_deflect<'js>(&self, ctx: JsCtx<'js>) -> JsResult<()> {
self.ctx
.stop_deflect(&self.name)
.map_err(|e| throw(&ctx, &e))
}
#[qjs(rename = "respondIncoming")]
fn respond_incoming<'js>(
&self,
ctx: JsCtx<'js>,
code: i64,
reason: String,
#[jsdoc(type = "Record<string, string>")] headers: Opt<Object<'js>>,
) -> JsResult<()> {
let lines = match headers.0 {
Some(h) => h
.props::<String, String>()
.filter_map(JsResult::ok)
.map(|(k, v)| format!("{k}: {v}"))
.collect(),
None => Vec::new(),
};
self.ctx
.respond_incoming(&self.name, code as u16, &reason, lines)
.map_err(|e| throw(&ctx, &e))
}
#[jsdoc(type = "AgentInfo")]
fn info<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Value<'js>> {
let i = self.ctx.info(&self.name).map_err(|e| throw(&ctx, &e))?;
info_object(&ctx, &i)
}
#[qjs(get)]
fn reason<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Option<String>> {
self.ctx.reason(&self.name).map_err(|e| throw(&ctx, &e))
}
#[qjs(get, rename = "statusCode")]
fn status_code<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Option<i64>> {
Ok(self
.ctx
.status_code(&self.name)
.map_err(|e| throw(&ctx, &e))?
.map(|c| c as i64))
}
fn header<'js>(&self, ctx: JsCtx<'js>, name: String) -> JsResult<Option<String>> {
self.ctx
.header(&self.name, &name)
.map_err(|e| throw(&ctx, &e))
}
#[qjs(get)]
#[jsdoc(type = "Record<string, string[]>")]
fn headers<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Value<'js>> {
match self.ctx.headers(&self.name) {
Ok(pairs) => {
let mut grouped: IndexMap<String, Vec<EngVal>> = IndexMap::new();
for (k, v) in pairs {
grouped.entry(k).or_default().push(EngVal::Str(v));
}
into_value(
&ctx,
EngVal::Map(
grouped
.into_iter()
.map(|(k, vs)| (k, EngVal::List(vs)))
.collect(),
),
)
}
Err(e) => Err(throw(&ctx, &e)),
}
}
#[qjs(get)]
fn peer<'js>(&self, ctx: JsCtx<'js>) -> JsResult<Option<Peer>> {
Ok(self
.ctx
.peer(&self.name)
.map_err(|e| throw(&ctx, &e))?
.map(|(uri, name)| Peer {
number: sip_user_part(&uri),
uri,
name,
}))
}
#[qjs(rename = "sendAudio")]
fn send_audio<'js>(&self, ctx: JsCtx<'js>, spec: Class<'js, AudioSpec>) -> JsResult<()> {
let spec = spec.borrow().inner.clone();
audio::send_audio(&self.ctx, &self.name, spec).map_err(|e| throw(&ctx, &e))
}
#[qjs(rename = "verifyAudio")]
async fn verify_audio<'js>(&self, ctx: JsCtx<'js>, freq: i64, within: String) -> JsResult<()> {
let eng = self.ctx.clone();
let name = self.name.clone();
let handle = self.ctx.rt.clone();
match handle
.spawn_blocking(move || audio::verify_audio(&eng, &name, freq, &within))
.await
{
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(throw(&ctx, &e)),
Err(e) => Err(throw(&ctx, &format!("verifyAudio task failed: {e}"))),
}
}
}
fn dispatch_target<'js>(
ctx: &JsCtx<'js>,
verb: &str,
target: &Value<'js>,
on_agent: impl FnOnce(&str) -> Result<(), String>,
on_uri: impl FnOnce(&str) -> Result<(), String>,
) -> JsResult<()> {
if let Some(s) = target.as_string() {
on_uri(&s.to_string()?).map_err(|e| throw(ctx, &e))
} else if let Ok(other) = Class::<Agent>::from_value(target) {
let name = other.borrow().name.clone();
on_agent(&name).map_err(|e| throw(ctx, &e))
} else {
Err(throw(
ctx,
&format!("{verb}: target must be an Agent or a SIP URI/number string"),
))
}
}
fn cfg_str(label: &str, config: &Object<'_>, key: &str) -> Result<Option<String>, String> {
let v: Value = config
.get(key)
.map_err(|_| format!("{label}: `{key}` is unreadable"))?;
if v.is_undefined() || v.is_null() {
return Ok(None);
}
v.as_string()
.and_then(|s| s.to_string().ok())
.map(Some)
.ok_or_else(|| format!("{label}: `{key}` must be a string"))
}
fn cfg_u32(label: &str, config: &Object<'_>, key: &str) -> Result<Option<u32>, String> {
let v: Value = config
.get(key)
.map_err(|_| format!("{label}: `{key}` is unreadable"))?;
if v.is_undefined() || v.is_null() {
return Ok(None);
}
let n = v
.as_int()
.map(|i| i as f64)
.or_else(|| v.as_float())
.ok_or_else(|| format!("{label}: `{key}` must be a number"))?;
if n < 0.0 || n.fract() != 0.0 {
return Err(format!("{label}: `{key}` must be a non-negative integer"));
}
Ok(Some(n as u32))
}
fn cfg_bool(label: &str, config: &Object<'_>, key: &str) -> Result<Option<bool>, String> {
let v: Value = config
.get(key)
.map_err(|_| format!("{label}: `{key}` is unreadable"))?;
if v.is_undefined() || v.is_null() {
return Ok(None);
}
v.as_bool()
.map(Some)
.ok_or_else(|| format!("{label}: `{key}` must be a boolean"))
}
fn metadata_from_obj(
label: &str,
config: &Object<'_>,
) -> Result<Option<serde_json::Value>, String> {
let val: Value = config
.get("metadata")
.map_err(|_| format!("{label}: `metadata` is unreadable"))?;
if val.is_undefined() || val.is_null() {
return Ok(None);
}
if !val.is_object() || val.is_array() {
return Err(format!("{label}: `metadata` must be an object"));
}
let json = val
.ctx()
.json_stringify(val.clone())
.ok()
.flatten()
.and_then(|s| s.to_string().ok())
.ok_or_else(|| format!("{label}: `metadata` is not JSON-serialisable"))?;
let parsed = serde_json::from_str(&json)
.map_err(|_| format!("{label}: `metadata` is not JSON-serialisable"))?;
Ok(Some(parsed))
}
fn headers_from_obj(label: &str, config: &Object<'_>) -> Result<Vec<(String, String)>, String> {
let Some(h) = config
.get::<_, Option<Object>>("headers")
.map_err(|_| format!("{label}: `headers` must be an object"))?
else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for entry in h.props::<String, Value>() {
let (k, v) = entry.map_err(|_| format!("{label}: `headers` is unreadable"))?;
if !is_header_token(&k) {
return Err(format!("{label}: `{k}` is not a valid SIP header name"));
}
if let Some(s) = v.as_string() {
let s = s
.to_string()
.map_err(|_| format!("{label}: `{k}` value is not a string"))?;
out.push((k, s));
} else if let Some(arr) = v.as_array() {
for item in arr.iter::<Value>() {
let item = item.map_err(|_| format!("{label}: `{k}` array is unreadable"))?;
let s = item
.as_string()
.and_then(|s| s.to_string().ok())
.ok_or_else(|| format!("{label}: `{k}` values must be strings"))?;
out.push((k.clone(), s));
}
} else {
return Err(format!(
"{label}: `{k}` must be a string or an array of strings"
));
}
}
Ok(out)
}
fn is_header_token(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-.!%*_+`'~".contains(&b))
}
fn info_object<'js>(ctx: &JsCtx<'js>, i: &crate::engine::AgentInfo) -> JsResult<Value<'js>> {
let snapshot = AgentSnapshot {
name: i.name.clone(),
aor: i.aor.clone(),
registered: i.registered,
state: i.state.to_string(),
reason: i.reason.clone(),
status_code: i.status_code.map(|c| c as i64),
peer: i.peer.as_ref().map(|(uri, name)| Peer {
number: sip_user_part(uri),
uri: uri.clone(),
name: name.clone(),
}),
calls: i.calls as i64,
};
snapshot.into_js(ctx)
}