feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support

This commit is contained in:
2026-04-20 20:43:42 +00:00
parent 3c010a3b1b
commit d2c18a4ebb
27 changed files with 4247 additions and 280 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## 2026-04-20 - 1.26.0 - feat(fax)
add fax routing, job tracking, inbox management, and T.38/UDPTL media support
- adds outbound fax origination through the proxy engine with provider codec validation and a new send_fax command
- introduces fax box configuration, inbox storage, and dashboard/API endpoints for listing, downloading, and deleting received fax messages
- tracks fax lifecycle events and persisted fax jobs in the runtime layer
- extends SIP SDP parsing and rewriting to support non-audio media, including T.38 over UDPTL
- records leg media protocol details and bridge state to distinguish RTP, WebRTC, internal, and fax media paths
## 2026-04-14 - 1.25.2 - fix(proxy-engine) ## 2026-04-14 - 1.25.2 - fix(proxy-engine)
improve inbound SIP routing diagnostics and enrich leg media state reporting improve inbound SIP routing diagnostics and enrich leg media state reporting
+3
View File
@@ -28,3 +28,6 @@ rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"]
CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc" CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc"
CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++" CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++"
AR_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-ar" AR_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-ar"
PKG_CONFIG_ALLOW_CROSS = "1"
PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu = "/"
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu = "/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig"
+161 -23
View File
@@ -165,7 +165,7 @@ dependencies = [
"nom", "nom",
"num-traits", "num-traits",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 1.0.69",
"time", "time",
] ]
@@ -181,7 +181,7 @@ dependencies = [
"nom", "nom",
"num-traits", "num-traits",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 1.0.69",
"time", "time",
] ]
@@ -327,6 +327,26 @@ dependencies = [
"virtue", "virtue",
] ]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.11.0",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 2.0.117",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -429,6 +449,15 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "0.1.10" version = "0.1.10"
@@ -498,6 +527,17 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "3.2.25" version = "3.2.25"
@@ -538,7 +578,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02" checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
dependencies = [ dependencies = [
"thiserror", "thiserror 1.0.69",
] ]
[[package]] [[package]]
@@ -1021,6 +1061,12 @@ dependencies = [
"signature", "signature",
] ]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "elliptic-curve" name = "elliptic-curve"
version = "0.12.3" version = "0.12.3"
@@ -1367,6 +1413,12 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "group" name = "group"
version = "0.12.1" version = "0.12.1"
@@ -1668,7 +1720,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"rtcp", "rtcp",
"rtp", "rtp",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"waitgroup", "waitgroup",
"webrtc-srtp", "webrtc-srtp",
@@ -1681,6 +1733,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1792,6 +1853,16 @@ dependencies = [
"rle-decode-fast", "rle-decode-fast",
] ]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if 1.0.4",
"windows-link",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -2386,7 +2457,9 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sip-proto", "sip-proto",
"spandsp",
"tokio", "tokio",
"udptl",
"webrtc", "webrtc",
] ]
@@ -2584,7 +2657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd" checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd"
dependencies = [ dependencies = [
"bytes", "bytes",
"thiserror", "thiserror 1.0.69",
"webrtc-util", "webrtc-util",
] ]
@@ -2594,7 +2667,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844" checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844"
dependencies = [ dependencies = [
"thiserror", "thiserror 1.0.69",
] ]
[[package]] [[package]]
@@ -2606,7 +2679,7 @@ dependencies = [
"bytes", "bytes",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"thiserror", "thiserror 1.0.69",
"webrtc-util", "webrtc-util",
] ]
@@ -2617,7 +2690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73" checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73"
dependencies = [ dependencies = [
"smallvec", "smallvec",
"thiserror", "thiserror 1.0.69",
] ]
[[package]] [[package]]
@@ -2744,7 +2817,7 @@ checksum = "4d22a5ef407871893fd72b4562ee15e4742269b173959db4b8df6f538c414e13"
dependencies = [ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"substring", "substring",
"thiserror", "thiserror 1.0.69",
"url", "url",
] ]
@@ -2957,6 +3030,28 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "spandsp"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f076b6e56f1a1062d6950dcd1c6c1df281ae2828db271929c50c191ec8c79e"
dependencies = [
"bitflags 2.11.0",
"spandsp-sys",
"thiserror 2.0.18",
]
[[package]]
name = "spandsp-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c05ab99051230293dded61ba3cd32f06eb15b437a8135be21f560f72bab713db"
dependencies = [
"bindgen",
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.5.2" version = "0.5.2"
@@ -3004,7 +3099,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"ring", "ring",
"subtle", "subtle",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"url", "url",
"webrtc-util", "webrtc-util",
@@ -3104,7 +3199,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
] ]
[[package]] [[package]]
@@ -3118,6 +3222,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -3194,9 +3309,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -3230,7 +3357,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"ring", "ring",
"stun", "stun",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"webrtc-util", "webrtc-util",
] ]
@@ -3241,6 +3368,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "udptl"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b255ad0ff36582a8a453c42a2bcc16c72d00f0ab16a14a4a7aeacb55ccb2a351"
dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -3532,7 +3670,7 @@ dependencies = [
"sha2", "sha2",
"smol_str", "smol_str",
"stun", "stun",
"thiserror", "thiserror 1.0.69",
"time", "time",
"tokio", "tokio",
"turn", "turn",
@@ -3557,7 +3695,7 @@ dependencies = [
"bytes", "bytes",
"derive_builder", "derive_builder",
"log", "log",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"webrtc-sctp", "webrtc-sctp",
"webrtc-util", "webrtc-util",
@@ -3595,7 +3733,7 @@ dependencies = [
"sha2", "sha2",
"signature", "signature",
"subtle", "subtle",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"webpki", "webpki",
"webrtc-util", "webrtc-util",
@@ -3617,7 +3755,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"stun", "stun",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"turn", "turn",
"url", "url",
@@ -3635,7 +3773,7 @@ checksum = "f08dfd7a6e3987e255c4dbe710dde5d94d0f0574f8a21afa95d171376c143106"
dependencies = [ dependencies = [
"log", "log",
"socket2 0.4.10", "socket2 0.4.10",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"webrtc-util", "webrtc-util",
] ]
@@ -3650,7 +3788,7 @@ dependencies = [
"bytes", "bytes",
"rand 0.8.5", "rand 0.8.5",
"rtp", "rtp",
"thiserror", "thiserror 1.0.69",
] ]
[[package]] [[package]]
@@ -3665,7 +3803,7 @@ dependencies = [
"crc", "crc",
"log", "log",
"rand 0.8.5", "rand 0.8.5",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"webrtc-util", "webrtc-util",
] ]
@@ -3688,7 +3826,7 @@ dependencies = [
"rtp", "rtp",
"sha1", "sha1",
"subtle", "subtle",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"webrtc-util", "webrtc-util",
] ]
@@ -3709,7 +3847,7 @@ dependencies = [
"log", "log",
"nix", "nix",
"rand 0.8.5", "rand 0.8.5",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"winapi", "winapi",
] ]
@@ -3880,7 +4018,7 @@ dependencies = [
"nom", "nom",
"oid-registry 0.4.0", "oid-registry 0.4.0",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 1.0.69",
"time", "time",
] ]
@@ -3899,7 +4037,7 @@ dependencies = [
"oid-registry 0.6.1", "oid-registry 0.6.1",
"ring", "ring",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 1.0.69",
"time", "time",
] ]
+2
View File
@@ -19,6 +19,8 @@ regex-lite = "0.1"
webrtc = "0.8" webrtc = "0.8"
rand = "0.8" rand = "0.8"
hound = "3.5" hound = "3.5"
spandsp = "0.1.5"
udptl = "0.1.0"
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] } kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
ort = { version = "=2.0.0-rc.11", default-features = false, features = [ ort = { version = "=2.0.0-rc.11", default-features = false, features = [
"std", "download-binaries", "copy-dylibs", "ndarray", "std", "download-binaries", "copy-dylibs", "ndarray",
+52 -1
View File
@@ -11,7 +11,7 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::sync::mpsc; use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
pub type LegId = String; pub type LegId = String;
@@ -114,6 +114,13 @@ pub struct LegInfo {
pub kind: LegKind, pub kind: LegKind,
pub state: LegState, pub state: LegState,
pub codec_pt: u8, pub codec_pt: u8,
/// Media transport currently negotiated for this leg.
///
/// `rtp` covers classic SIP audio media, `t38-udptl` covers T.38 fax,
/// `webrtc` is used for browser legs, and `internal` for proxy-local media/tool paths.
pub media_protocol: &'static str,
/// Whether this leg is currently wired into an active media bridge.
pub media_io_active: bool,
/// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc). /// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc).
pub sip_leg: Option<SipLeg>, pub sip_leg: Option<SipLeg>,
@@ -146,6 +153,15 @@ pub struct LegInfo {
pub metadata: HashMap<String, serde_json::Value>, pub metadata: HashMap<String, serde_json::Value>,
} }
#[derive(Clone)]
pub struct PendingDialogBridge {
pub source_leg_id: LegId,
pub target_leg_id: LegId,
pub source_request: SipMessage,
pub target_request: SipMessage,
pub method: String,
}
/// A multiparty call with N legs and a central mixer. /// A multiparty call with N legs and a central mixer.
pub struct Call { pub struct Call {
// Duplicated from the HashMap key in CallManager. Kept for future // Duplicated from the HashMap key in CallManager. Kept for future
@@ -169,12 +185,21 @@ pub struct Call {
/// Used to construct proper 180/200/error responses back to the device. /// Used to construct proper 180/200/error responses back to the device.
pub device_invite: Option<SipMessage>, pub device_invite: Option<SipMessage>,
/// Pending in-dialog B2BUA transaction bridged across two different SIP dialogs.
pub pending_dialog_bridge: Option<PendingDialogBridge>,
/// All legs in this call, keyed by leg ID. /// All legs in this call, keyed by leg ID.
pub legs: HashMap<LegId, LegInfo>, pub legs: HashMap<LegId, LegInfo>,
/// Channel to send commands to the mixer task. /// Channel to send commands to the mixer task.
pub mixer_cmd_tx: mpsc::Sender<MixerCommand>, pub mixer_cmd_tx: mpsc::Sender<MixerCommand>,
/// Active passthrough media bridge mode, if any.
pub media_bridge_mode: Option<String>,
/// Cancellation handles for non-mixer passthrough media tasks.
media_bridge_cancel_txs: Vec<watch::Sender<bool>>,
/// Handle to the mixer task (aborted on call teardown). /// Handle to the mixer task (aborted on call teardown).
mixer_task: Option<JoinHandle<()>>, mixer_task: Option<JoinHandle<()>>,
} }
@@ -196,8 +221,11 @@ impl Call {
callee_number: None, callee_number: None,
provider_id, provider_id,
device_invite: None, device_invite: None,
pending_dialog_bridge: None,
legs: HashMap::new(), legs: HashMap::new(),
mixer_cmd_tx, mixer_cmd_tx,
media_bridge_mode: None,
media_bridge_cancel_txs: Vec::new(),
mixer_task: Some(mixer_task), mixer_task: Some(mixer_task),
} }
} }
@@ -235,8 +263,31 @@ impl Call {
self.created_at.elapsed().as_secs() self.created_at.elapsed().as_secs()
} }
pub fn clear_media_bridge(&mut self) {
for cancel_tx in self.media_bridge_cancel_txs.drain(..) {
let _ = cancel_tx.send(true);
}
self.media_bridge_mode = None;
}
pub fn install_media_bridge(
&mut self,
mode: &str,
cancel_txs: Vec<watch::Sender<bool>>,
) {
self.clear_media_bridge();
self.media_bridge_mode = Some(mode.to_string());
self.media_bridge_cancel_txs = cancel_txs;
}
pub fn note_mixer_bridge(&mut self, mode: &str) {
self.clear_media_bridge();
self.media_bridge_mode = Some(mode.to_string());
}
/// Shut down the mixer and abort its task. /// Shut down the mixer and abort its task.
pub async fn shutdown_mixer(&mut self) { pub async fn shutdown_mixer(&mut self) {
self.clear_media_bridge();
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await; let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
if let Some(handle) = self.mixer_task.take() { if let Some(handle) = self.mixer_task.take() {
handle.abort(); handle.abort();
File diff suppressed because it is too large Load Diff
+19
View File
@@ -105,6 +105,8 @@ pub struct RouteAction {
pub ring_browsers: Option<bool>, pub ring_browsers: Option<bool>,
#[serde(rename = "voicemailBox")] #[serde(rename = "voicemailBox")]
pub voicemail_box: Option<String>, pub voicemail_box: Option<String>,
#[serde(rename = "faxBox")]
pub fax_box: Option<String>,
#[serde(rename = "ivrMenuId")] #[serde(rename = "ivrMenuId")]
pub ivr_menu_id: Option<String>, pub ivr_menu_id: Option<String>,
#[serde(rename = "noAnswerTimeout")] #[serde(rename = "noAnswerTimeout")]
@@ -161,6 +163,8 @@ pub struct AppConfig {
pub devices: Vec<DeviceConfig>, pub devices: Vec<DeviceConfig>,
pub routing: RoutingConfig, pub routing: RoutingConfig,
#[serde(default)] #[serde(default)]
pub faxboxes: Vec<FaxBoxConfig>,
#[serde(default)]
pub voiceboxes: Vec<VoiceboxConfig>, pub voiceboxes: Vec<VoiceboxConfig>,
#[serde(default)] #[serde(default)]
pub ivr: Option<IvrConfig>, pub ivr: Option<IvrConfig>,
@@ -191,6 +195,16 @@ pub struct VoiceboxConfig {
pub max_recording_sec: Option<u32>, pub max_recording_sec: Option<u32>,
} }
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct FaxBoxConfig {
pub id: String,
#[serde(default)]
pub enabled: bool,
#[serde(rename = "maxMessages")]
pub max_messages: Option<u32>,
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IVR config // IVR config
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -415,6 +429,7 @@ pub struct InboundRouteResult {
pub ring_all_devices: bool, pub ring_all_devices: bool,
pub ring_browsers: bool, pub ring_browsers: bool,
pub voicemail_box: Option<String>, pub voicemail_box: Option<String>,
pub fax_box: Option<String>,
pub ivr_menu_id: Option<String>, pub ivr_menu_id: Option<String>,
pub no_answer_timeout: Option<u32>, pub no_answer_timeout: Option<u32>,
} }
@@ -525,6 +540,7 @@ impl AppConfig {
ring_all_devices: explicit_targets.is_none(), ring_all_devices: explicit_targets.is_none(),
ring_browsers: route.action.ring_browsers.unwrap_or(false), ring_browsers: route.action.ring_browsers.unwrap_or(false),
voicemail_box: route.action.voicemail_box.clone(), voicemail_box: route.action.voicemail_box.clone(),
fax_box: route.action.fax_box.clone(),
ivr_menu_id: route.action.ivr_menu_id.clone(), ivr_menu_id: route.action.ivr_menu_id.clone(),
no_answer_timeout: route.action.no_answer_timeout, no_answer_timeout: route.action.no_answer_timeout,
}); });
@@ -574,6 +590,7 @@ mod tests {
extension: "100".to_string(), extension: "100".to_string(),
}], }],
routing: RoutingConfig { routes }, routing: RoutingConfig { routes },
faxboxes: vec![],
voiceboxes: vec![], voiceboxes: vec![],
ivr: None, ivr: None,
} }
@@ -620,6 +637,7 @@ mod tests {
targets: Some(vec!["desk".to_string()]), targets: Some(vec!["desk".to_string()]),
ring_browsers: Some(true), ring_browsers: Some(true),
voicemail_box: None, voicemail_box: None,
fax_box: None,
ivr_menu_id: None, ivr_menu_id: None,
no_answer_timeout: None, no_answer_timeout: None,
provider: None, provider: None,
@@ -644,6 +662,7 @@ mod tests {
targets: None, targets: None,
ring_browsers: Some(false), ring_browsers: Some(false),
voicemail_box: Some("support-box".to_string()), voicemail_box: Some("support-box".to_string()),
fax_box: None,
ivr_menu_id: None, ivr_menu_id: None,
no_answer_timeout: Some(20), no_answer_timeout: Some(20),
provider: None, provider: None,
File diff suppressed because it is too large Load Diff
+54 -1
View File
@@ -10,7 +10,7 @@ use crate::mixer::RtpPacket;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::sync::mpsc; use tokio::sync::{mpsc, watch};
/// Channel pair for connecting a leg to the mixer. /// Channel pair for connecting a leg to the mixer.
pub struct LegChannels { pub struct LegChannels {
@@ -109,3 +109,56 @@ pub fn spawn_sip_outbound(
} }
}) })
} }
/// Spawn a raw UDP inbound task for non-RTP passthrough media such as T.38 UDPTL.
pub fn spawn_raw_udp_inbound(
media_socket: Arc<UdpSocket>,
inbound_tx: mpsc::Sender<Vec<u8>>,
mut cancel_rx: watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut buf = vec![0u8; 2048];
loop {
tokio::select! {
_ = cancel_rx.changed() => break,
recv = media_socket.recv_from(&mut buf) => {
match recv {
Ok((n, _from)) => {
if n == 0 {
continue;
}
if inbound_tx.send(buf[..n].to_vec()).await.is_err() {
break;
}
}
Err(_) => break,
}
}
}
}
})
}
/// Spawn a raw UDP outbound task for non-RTP passthrough media such as T.38 UDPTL.
pub fn spawn_raw_udp_outbound(
media_socket: Arc<UdpSocket>,
remote_media: SocketAddr,
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
mut cancel_rx: watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_rx.changed() => break,
pkt = outbound_rx.recv() => {
match pkt {
Some(packet) => {
let _ = media_socket.send_to(&packet, remote_media).await;
}
None => break,
}
}
}
}
})
}
+165
View File
@@ -9,6 +9,8 @@ mod audio_player;
mod call; mod call;
mod call_manager; mod call_manager;
mod config; mod config;
#[allow(dead_code)]
mod fax_engine;
mod ipc; mod ipc;
mod jitter_buffer; mod jitter_buffer;
mod leg_io; mod leg_io;
@@ -139,6 +141,7 @@ async fn handle_command(
"configure" => handle_configure(engine, out_tx, &cmd).await, "configure" => handle_configure(engine, out_tx, &cmd).await,
"hangup" => handle_hangup(engine, out_tx, &cmd).await, "hangup" => handle_hangup(engine, out_tx, &cmd).await,
"make_call" => handle_make_call(engine, out_tx, &cmd).await, "make_call" => handle_make_call(engine, out_tx, &cmd).await,
"send_fax" => handle_send_fax(engine, out_tx, &cmd).await,
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await, "add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await, "remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
// WebRTC commands — lock webrtc only (no engine contention). // WebRTC commands — lock webrtc only (no engine contention).
@@ -576,6 +579,162 @@ async fn handle_make_call(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd:
} }
} }
/// Handle `send_fax` — place an outbound server-side fax call via SpanDSP over G.711 audio.
async fn handle_send_fax(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
Some(n) => n.to_string(),
None => {
respond_err(out_tx, &cmd.id, "missing number");
return;
}
};
let file_path = match cmd.params.get("file_path").and_then(|v| v.as_str()) {
Some(path) if std::path::Path::new(path).exists() => path.to_string(),
Some(_) => {
respond_err(out_tx, &cmd.id, "fax file does not exist");
return;
}
None => {
respond_err(out_tx, &cmd.id, "missing file_path");
return;
}
};
let provider_id = cmd.params.get("provider_id").and_then(|v| v.as_str());
let mut eng = engine.lock().await;
let config_ref = match &eng.config {
Some(c) => c.clone(),
None => {
respond_err(out_tx, &cmd.id, "not configured");
return;
}
};
let provider_config = if let Some(pid) = provider_id {
config_ref.providers.iter().find(|p| p.id == pid).cloned()
} else {
let route = config_ref.resolve_outbound_route(&number, None, &|_| true);
route.map(|r| r.provider)
};
let mut provider_config = match provider_config {
Some(p) => p,
None => {
respond_err(out_tx, &cmd.id, "no provider available");
return;
}
};
let fax_codec = if provider_config.codecs.contains(&codec_lib::PT_PCMU) {
codec_lib::PT_PCMU
} else if provider_config.codecs.contains(&codec_lib::PT_PCMA) {
codec_lib::PT_PCMA
} else {
respond_err(
out_tx,
&cmd.id,
&format!(
"provider {} does not advertise PCMU/PCMA, which outbound fax currently requires",
provider_config.id
),
);
return;
};
provider_config.codecs = vec![fax_codec];
let (public_ip, registered_aor) = if let Some(ps_arc) = eng
.provider_mgr
.find_by_address(
&provider_config
.outbound_proxy
.to_socket_addr()
.unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()),
)
.await
{
let ps = ps_arc.lock().await;
(ps.public_ip.clone(), ps.registered_aor.clone())
} else {
(
None,
format!(
"sip:{}@{}",
provider_config.username, provider_config.domain
),
)
};
let socket = match &eng.transport {
Some(t) => t.socket(),
None => {
respond_err(out_tx, &cmd.id, "not initialized");
return;
}
};
let ProxyEngine {
ref mut call_mgr,
ref mut rtp_pool,
..
} = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr
.make_outbound_call(
&number,
&provider_config,
&config_ref,
rtp_pool,
&socket,
public_ip.as_deref(),
&registered_aor,
)
.await;
let call_id = match call_id {
Some(id) => id,
None => {
respond_err(
out_tx,
&cmd.id,
"fax origination failed — provider not registered or no ports available",
);
return;
}
};
if let Some(call) = call_mgr.calls.get_mut(&call_id) {
let provider_leg_id = format!("{call_id}-prov");
if let Some(leg) = call.legs.get_mut(&provider_leg_id) {
leg.codec_pt = fax_codec;
leg.metadata
.insert("fax_mode".to_string(), serde_json::json!("outbound-audio"));
leg.metadata
.insert("fax_file_path".to_string(), serde_json::json!(file_path));
}
}
emit_event(
out_tx,
"outbound_call_started",
serde_json::json!({
"call_id": call_id,
"number": number,
"provider_id": provider_config.id,
"ring_browsers": false,
}),
);
respond_ok(
out_tx,
&cmd.id,
serde_json::json!({
"call_id": call_id,
"codec": if fax_codec == codec_lib::PT_PCMU { "PCMU" } else { "PCMA" },
}),
);
}
/// Handle the `hangup` command. /// Handle the `hangup` command.
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) { async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) { let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
@@ -738,6 +897,8 @@ async fn handle_webrtc_link(
kind: crate::call::LegKind::WebRtc, kind: crate::call::LegKind::WebRtc,
state: crate::call::LegState::Connected, state: crate::call::LegState::Connected,
codec_pt: codec_lib::PT_OPUS, codec_pt: codec_lib::PT_OPUS,
media_protocol: "webrtc",
media_io_active: true,
sip_leg: None, sip_leg: None,
sip_call_id: None, sip_call_id: None,
webrtc_session_id: Some(session_id.clone()), webrtc_session_id: Some(session_id.clone()),
@@ -762,6 +923,7 @@ async fn handle_webrtc_link(
"state": "connected", "state": "connected",
"codec": "Opus", "codec": "Opus",
"rtpPort": 0, "rtpPort": 0,
"mediaProtocol": "webrtc",
"remoteMedia": null, "remoteMedia": null,
"metadata": {}, "metadata": {},
}), }),
@@ -1462,6 +1624,8 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
kind: crate::call::LegKind::Tool, kind: crate::call::LegKind::Tool,
state: crate::call::LegState::Connected, state: crate::call::LegState::Connected,
codec_pt: 0, codec_pt: 0,
media_protocol: "internal",
media_io_active: true,
sip_leg: None, sip_leg: None,
sip_call_id: None, sip_call_id: None,
webrtc_session_id: None, webrtc_session_id: None,
@@ -1485,6 +1649,7 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
"state": "connected", "state": "connected",
"codec": null, "codec": null,
"rtpPort": 0, "rtpPort": 0,
"mediaProtocol": "internal",
"remoteMedia": null, "remoteMedia": null,
"metadata": { "tool_type": tool_type_str }, "metadata": { "tool_type": tool_type_str },
}), }),
+25
View File
@@ -108,6 +108,24 @@ impl SipLeg {
..Default::default() ..Default::default()
}); });
self.send_invite_with_sdp(from_uri, to_uri, sip_call_id, socket, sdp)
.await;
}
pub async fn send_invite_with_sdp(
&mut self,
from_uri: &str,
to_uri: &str,
sip_call_id: &str,
socket: &UdpSocket,
sdp: String,
) {
let ip = self
.config
.public_ip
.as_deref()
.unwrap_or(&self.config.lan_ip);
let invite = SipMessage::create_request( let invite = SipMessage::create_request(
"INVITE", "INVITE",
to_uri, to_uri,
@@ -401,6 +419,10 @@ impl SipLeg {
return SipLegAction::Send(ok.serialize()); return SipLegAction::Send(ok.serialize());
} }
if method == "INVITE" || method == "UPDATE" {
return SipLegAction::InDialogRequest(method.to_string());
}
SipLegAction::None SipLegAction::None
} }
@@ -436,6 +458,9 @@ pub enum SipLegAction {
StateChange(LegState), StateChange(LegState),
/// Connected — send this ACK. /// Connected — send this ACK.
ConnectedWithAck(Vec<u8>), ConnectedWithAck(Vec<u8>),
/// Provider sent an in-dialog request (re-INVITE / UPDATE) that needs
/// call-manager-specific handling.
InDialogRequest(String),
/// Terminated with a reason. /// Terminated with a reason.
Terminated(String), Terminated(String),
/// Send 200 OK and terminate. /// Send 200 OK and terminate.
+100 -19
View File
@@ -4,6 +4,8 @@
use md5::{Digest, Md5}; use md5::{Digest, Md5};
use rand::Rng; use rand::Rng;
use crate::{Endpoint, SdpMediaKind};
// ---- ID generators --------------------------------------------------------- // ---- ID generators ---------------------------------------------------------
/// Generate a random SIP Call-ID (32 hex chars). /// Generate a random SIP Call-ID (32 hex chars).
@@ -55,6 +57,9 @@ pub struct SdpOptions<'a> {
pub ip: &'a str, pub ip: &'a str,
pub port: u16, pub port: u16,
pub payload_types: &'a [u8], pub payload_types: &'a [u8],
pub media_kind: SdpMediaKind,
pub transport: &'a str,
pub media_formats: &'a [&'a str],
pub session_id: Option<&'a str>, pub session_id: Option<&'a str>,
pub session_name: Option<&'a str>, pub session_name: Option<&'a str>,
pub direction: Option<&'a str>, pub direction: Option<&'a str>,
@@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> {
ip: "0.0.0.0", ip: "0.0.0.0",
port: 0, port: 0,
payload_types: &[9, 0, 8, 101], payload_types: &[9, 0, 8, 101],
media_kind: SdpMediaKind::Audio,
transport: "RTP/AVP",
media_formats: &[],
session_id: None, session_id: None,
session_name: None, session_name: None,
direction: None, direction: None,
@@ -83,7 +91,14 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
.unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64))); .unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64)));
let session_name = opts.session_name.unwrap_or("-"); let session_name = opts.session_name.unwrap_or("-");
let direction = opts.direction.unwrap_or("sendrecv"); let direction = opts.direction.unwrap_or("sendrecv");
let pts: Vec<String> = opts.payload_types.iter().map(|pt| pt.to_string()).collect(); let media_formats: Vec<String> = if !opts.media_formats.is_empty() {
opts.media_formats
.iter()
.map(|fmt| fmt.to_string())
.collect()
} else {
opts.payload_types.iter().map(|pt| pt.to_string()).collect()
};
let mut lines = vec![ let mut lines = vec![
"v=0".to_string(), "v=0".to_string(),
@@ -91,9 +106,16 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
format!("s={session_name}"), format!("s={session_name}"),
format!("c=IN IP4 {}", opts.ip), format!("c=IN IP4 {}", opts.ip),
"t=0 0".to_string(), "t=0 0".to_string(),
format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")), format!(
"m={} {} {} {}",
opts.media_kind.as_sdp_token(),
opts.port,
opts.transport,
media_formats.join(" ")
),
]; ];
if opts.media_kind == SdpMediaKind::Audio {
for &pt in opts.payload_types { for &pt in opts.payload_types {
let name = codec_name(pt); let name = codec_name(pt);
if name != "unknown" { if name != "unknown" {
@@ -103,6 +125,7 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
lines.push("a=fmtp:101 0-16".to_string()); lines.push("a=fmtp:101 0-16".to_string());
} }
} }
}
lines.push(format!("a={direction}")); lines.push(format!("a={direction}"));
for attr in opts.attributes { for attr in opts.attributes {
@@ -199,38 +222,62 @@ pub fn compute_digest_auth(
// ---- SDP parser ------------------------------------------------------------ // ---- SDP parser ------------------------------------------------------------
use crate::Endpoint; /// Parse the preferred media endpoint from an SDP body.
///
/// Parse the audio media port, connection address, and preferred codec from an SDP body. /// Audio `m=` lines are preferred when present so existing RTP call flows keep
/// their current behavior. If no audio section exists, the first media section
/// is returned, which allows T.38-only SDP offers/answers to be represented.
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> { pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
let mut addr: Option<&str> = None; let mut addr: Option<&str> = None;
let mut port: Option<u16> = None; let mut preferred: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
let mut codec_pt: Option<u8> = None; let mut fallback: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
let normalized = sdp.replace("\r\n", "\n"); let normalized = sdp.replace("\r\n", "\n");
for raw in normalized.split('\n') { for raw in normalized.split('\n') {
let line = raw.trim(); let line = raw.trim();
if let Some(rest) = line.strip_prefix("c=IN IP4 ") { if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
addr = Some(rest.trim()); addr = Some(rest.trim());
} else if let Some(rest) = line.strip_prefix("m=audio ") { } else if let Some(rest) = line.strip_prefix("m=") {
// m=audio <port> RTP/AVP <pt1> [<pt2> ...] // m=<media> <port> <transport> <fmt1> [<fmt2> ...]
let parts: Vec<&str> = rest.split_whitespace().collect(); let mut media_and_rest = rest.splitn(2, ' ');
if !parts.is_empty() { let media = media_and_rest.next().unwrap_or("");
port = parts[0].parse().ok(); let remainder = media_and_rest.next().unwrap_or("");
let media_kind = SdpMediaKind::from_sdp_token(media);
if media_kind == SdpMediaKind::Unknown {
continue;
}
let parts: Vec<&str> = remainder.split_whitespace().collect();
if !parts.is_empty() {
if let Ok(port) = parts[0].parse() {
let transport = parts.get(1).copied().unwrap_or("").to_string();
let codec_pt = if media_kind == SdpMediaKind::Audio && parts.len() > 2 {
parts[2].parse::<u8>().ok()
} else {
None
};
let candidate = (media_kind, port, codec_pt, transport);
if fallback.is_none() {
fallback = Some(candidate.clone());
}
if media_kind == SdpMediaKind::Audio {
preferred = Some(candidate);
} else if preferred.is_none() {
preferred = Some(candidate);
}
} }
// parts[1] is "RTP/AVP" or similar, parts[2..] are payload types.
// The first PT is the preferred codec.
if parts.len() > 2 {
codec_pt = parts[2].parse::<u8>().ok();
} }
} }
} }
match (addr, port) { match (addr, preferred.or(fallback)) {
(Some(a), Some(p)) => Some(Endpoint { (Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
address: a.to_string(), address: a.to_string(),
port: p, port,
codec_pt, codec_pt,
media_kind,
transport,
}), }),
_ => None, _ => None,
} }
@@ -327,6 +374,40 @@ mod tests {
let ep = parse_sdp_endpoint(sdp).unwrap(); let ep = parse_sdp_endpoint(sdp).unwrap();
assert_eq!(ep.address, "10.0.0.1"); assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060); assert_eq!(ep.port, 5060);
assert_eq!(ep.media_kind, SdpMediaKind::Audio);
assert_eq!(ep.transport, "RTP/AVP");
assert!(ep.is_audio_rtp());
}
#[test]
fn test_parse_t38_sdp_endpoint() {
let sdp = concat!(
"v=0\r\n",
"c=IN IP4 203.0.113.9\r\n",
"m=image 4000 udptl t38\r\n",
"a=T38FaxVersion:0\r\n",
);
let ep = parse_sdp_endpoint(sdp).unwrap();
assert_eq!(ep.address, "203.0.113.9");
assert_eq!(ep.port, 4000);
assert_eq!(ep.media_kind, SdpMediaKind::Image);
assert_eq!(ep.transport, "udptl");
assert!(ep.is_t38_udptl());
}
#[test]
fn test_build_t38_sdp() {
let sdp = build_sdp(&SdpOptions {
ip: "192.168.1.1",
port: 4000,
media_kind: SdpMediaKind::Image,
transport: "udptl",
media_formats: &["t38"],
attributes: &["T38FaxVersion:0"],
..Default::default()
});
assert!(sdp.contains("m=image 4000 udptl t38"));
assert!(sdp.contains("a=T38FaxVersion:0"));
} }
#[test] #[test]
+43
View File
@@ -16,4 +16,47 @@ pub struct Endpoint {
pub port: u16, pub port: u16,
/// First payload type from the SDP `m=audio` line (the preferred codec). /// First payload type from the SDP `m=audio` line (the preferred codec).
pub codec_pt: Option<u8>, pub codec_pt: Option<u8>,
/// SDP media kind from the `m=` line.
pub media_kind: SdpMediaKind,
/// SDP transport token from the `m=` line (e.g. `RTP/AVP`, `udptl`).
pub transport: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SdpMediaKind {
Audio,
Image,
Application,
Unknown,
}
impl SdpMediaKind {
pub fn as_sdp_token(&self) -> &'static str {
match self {
Self::Audio => "audio",
Self::Image => "image",
Self::Application => "application",
Self::Unknown => "unknown",
}
}
pub fn from_sdp_token(token: &str) -> Self {
match token.to_ascii_lowercase().as_str() {
"audio" => Self::Audio,
"image" => Self::Image,
"application" => Self::Application,
_ => Self::Unknown,
}
}
}
impl Endpoint {
pub fn is_audio_rtp(&self) -> bool {
self.media_kind == SdpMediaKind::Audio
&& self.transport.to_ascii_uppercase().starts_with("RTP/")
}
pub fn is_t38_udptl(&self) -> bool {
self.media_kind == SdpMediaKind::Image && self.transport.eq_ignore_ascii_case("udptl")
}
} }
+40 -9
View File
@@ -2,7 +2,7 @@
//! //!
//! Ported from ts/sip/rewrite.ts. //! Ported from ts/sip/rewrite.ts.
use crate::Endpoint; use crate::{Endpoint, SdpMediaKind};
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`. /// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String { pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
@@ -57,12 +57,12 @@ pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
result result
} }
/// Rewrites the connection address (`c=`) and audio media port (`m=audio`) /// Rewrites the connection address (`c=`) and first supported media port
/// in an SDP body. Returns the rewritten body together with the original /// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the
/// endpoint that was replaced (if any). /// rewritten body together with the original endpoint that was replaced (if any).
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) { pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
let mut orig_addr: Option<String> = None; let mut orig_addr: Option<String> = None;
let mut orig_port: Option<u16> = None; let mut orig_media: Option<(SdpMediaKind, u16, String)> = None;
let lines: Vec<String> = body let lines: Vec<String> = body
.replace("\r\n", "\n") .replace("\r\n", "\n")
@@ -71,10 +71,25 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
if let Some(rest) = line.strip_prefix("c=IN IP4 ") { if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
orig_addr = Some(rest.trim().to_string()); orig_addr = Some(rest.trim().to_string());
format!("c=IN IP4 {ip}") format!("c=IN IP4 {ip}")
} else if line.starts_with("m=audio ") { } else if line.starts_with("m=audio ")
|| line.starts_with("m=image ")
|| line.starts_with("m=application ")
{
let parts: Vec<&str> = line.split(' ').collect(); let parts: Vec<&str> = line.split(' ').collect();
if parts.len() >= 2 { if parts.len() >= 2 {
orig_port = parts[1].parse().ok(); let media_kind = parts[0]
.strip_prefix("m=")
.map(SdpMediaKind::from_sdp_token)
.unwrap_or(SdpMediaKind::Unknown);
if orig_media.is_none() {
orig_media = parts[1].parse().ok().map(|orig_port| {
(
media_kind,
orig_port,
parts.get(2).copied().unwrap_or("").to_string(),
)
});
}
let mut rebuilt = parts[0].to_string(); let mut rebuilt = parts[0].to_string();
rebuilt.push(' '); rebuilt.push(' ');
rebuilt.push_str(&port.to_string()); rebuilt.push_str(&port.to_string());
@@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
}) })
.collect(); .collect();
let original = match (orig_addr, orig_port) { let original = match (orig_addr, orig_media) {
(Some(a), Some(p)) => Some(Endpoint { (Some(a), Some((media_kind, p, transport))) => Some(Endpoint {
address: a, address: a,
port: p, port: p,
codec_pt: None, codec_pt: None,
media_kind,
transport,
}), }),
_ => None, _ => None,
}; };
@@ -130,5 +147,19 @@ mod tests {
let ep = orig.unwrap(); let ep = orig.unwrap();
assert_eq!(ep.address, "10.0.0.1"); assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060); assert_eq!(ep.port, 5060);
assert_eq!(ep.transport, "RTP/AVP");
}
#[test]
fn test_rewrite_t38_sdp() {
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=image 5060 udptl t38\r\na=T38FaxVersion:0\r\n";
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 4000);
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
assert!(rewritten.contains("m=image 4000 udptl t38"));
let ep = orig.unwrap();
assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060);
assert_eq!(ep.media_kind, SdpMediaKind::Image);
assert_eq!(ep.transport, "udptl");
} }
} }
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.25.2', version: '1.26.0',
description: 'undefined' description: 'undefined'
} }
+12
View File
@@ -8,6 +8,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { IFaxBoxConfig } from './faxbox.ts';
import type { IVoiceboxConfig } from './voicebox.js'; import type { IVoiceboxConfig } from './voicebox.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -113,6 +114,9 @@ export interface ISipRouteAction {
/** Voicemail fallback for matched inbound routes. */ /** Voicemail fallback for matched inbound routes. */
voicemailBox?: string; voicemailBox?: string;
/** Fax inbox target for matched inbound routes. */
faxBox?: string;
/** Route to an IVR menu by menu ID (skip ringing devices). */ /** Route to an IVR menu by menu ID (skip ringing devices). */
ivrMenuId?: string; ivrMenuId?: string;
@@ -189,6 +193,7 @@ export interface IContact {
// "number | undefined is not assignable to number" type errors when // "number | undefined is not assignable to number" type errors when
// passing config.voiceboxes into VoiceboxManager.init(). // passing config.voiceboxes into VoiceboxManager.init().
export type { IVoiceboxConfig }; export type { IVoiceboxConfig };
export type { IFaxBoxConfig };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IVR configuration // IVR configuration
@@ -255,6 +260,7 @@ export interface IAppConfig {
incomingNumbers?: IIncomingNumberConfig[]; incomingNumbers?: IIncomingNumberConfig[];
routing: IRoutingConfig; routing: IRoutingConfig;
contacts: IContact[]; contacts: IContact[];
faxboxes?: IFaxBoxConfig[];
voiceboxes?: IVoiceboxConfig[]; voiceboxes?: IVoiceboxConfig[];
ivr?: IIvrConfig; ivr?: IIvrConfig;
} }
@@ -323,6 +329,12 @@ export function loadConfig(): IAppConfig {
c.starred ??= false; c.starred ??= false;
} }
cfg.faxboxes ??= [];
for (const fb of cfg.faxboxes) {
fb.enabled ??= true;
fb.maxMessages ??= 50;
}
// Voicebox defaults. // Voicebox defaults.
cfg.voiceboxes ??= []; cfg.voiceboxes ??= [];
for (const vb of cfg.voiceboxes) { for (const vb of cfg.voiceboxes) {
+149
View File
@@ -0,0 +1,149 @@
import fs from 'node:fs';
import path from 'node:path';
export interface IFaxBoxConfig {
id: string;
enabled: boolean;
maxMessages?: number;
}
export interface IFaxMessage {
id: string;
boxId: string;
callerNumber?: string;
timestamp: number;
fileName: string;
completionCode?: number | null;
completionLabel?: string | null;
pageCount?: number;
bitRate?: number;
}
export class FaxBoxManager {
private boxes = new Map<string, IFaxBoxConfig>();
private readonly basePath: string;
private readonly log: (msg: string) => void;
constructor(log: (msg: string) => void) {
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
this.log = log;
}
init(faxBoxConfigs: IFaxBoxConfig[]): void {
this.boxes.clear();
for (const cfg of faxBoxConfigs) {
cfg.enabled ??= true;
cfg.maxMessages ??= 50;
this.boxes.set(cfg.id, cfg);
}
fs.mkdirSync(this.basePath, { recursive: true });
this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`);
}
getBox(boxId: string): IFaxBoxConfig | null {
return this.boxes.get(boxId) ?? null;
}
getBoxDir(boxId: string): string {
return path.join(this.basePath, boxId);
}
addMessage(
boxId: string,
info: {
callerNumber?: string;
fileName: string;
completionCode?: number | null;
completionLabel?: string | null;
pageCount?: number;
bitRate?: number;
},
): void {
const msg: IFaxMessage = {
id: crypto.randomUUID(),
boxId,
callerNumber: info.callerNumber,
timestamp: Date.now(),
fileName: path.basename(info.fileName),
completionCode: info.completionCode ?? null,
completionLabel: info.completionLabel ?? null,
pageCount: info.pageCount,
bitRate: info.bitRate,
};
this.saveMessage(msg);
}
saveMessage(msg: IFaxMessage): void {
const boxDir = this.getBoxDir(msg.boxId);
fs.mkdirSync(boxDir, { recursive: true });
const messages = this.loadMessages(msg.boxId);
messages.unshift(msg);
const box = this.boxes.get(msg.boxId);
const maxMessages = box?.maxMessages ?? 50;
while (messages.length > maxMessages) {
const old = messages.pop()!;
const oldPath = path.join(boxDir, old.fileName);
try {
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} catch {}
}
this.writeMessages(msg.boxId, messages);
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
}
getMessages(boxId: string): IFaxMessage[] {
return this.loadMessages(boxId);
}
getMessage(boxId: string, messageId: string): IFaxMessage | null {
return this.loadMessages(boxId).find((m) => m.id === messageId) ?? null;
}
getMessageFilePath(boxId: string, messageId: string): string | null {
const msg = this.getMessage(boxId, messageId);
if (!msg) return null;
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
return fs.existsSync(filePath) ? filePath : null;
}
deleteMessage(boxId: string, messageId: string): boolean {
const messages = this.loadMessages(boxId);
const idx = messages.findIndex((m) => m.id === messageId);
if (idx === -1) return false;
const msg = messages[idx];
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {}
messages.splice(idx, 1);
this.writeMessages(boxId, messages);
return true;
}
private messagesPath(boxId: string): string {
return path.join(this.getBoxDir(boxId), 'messages.json');
}
private loadMessages(boxId: string): IFaxMessage[] {
const filePath = this.messagesPath(boxId);
try {
if (!fs.existsSync(filePath)) return [];
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as IFaxMessage[];
} catch {
return [];
}
}
private writeMessages(boxId: string, messages: IFaxMessage[]): void {
const boxDir = this.getBoxDir(boxId);
fs.mkdirSync(boxDir, { recursive: true });
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
}
}
+153
View File
@@ -0,0 +1,153 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
IFaxCompletedEvent,
IFaxFailedEvent,
IFaxStartedEvent,
} from './shared/proxy-events.ts';
export interface IFaxJob {
id: string;
callId: string;
number?: string;
providerId?: string;
direction: 'outbound' | 'inbound';
status: 'dialing' | 'started' | 'completed' | 'failed';
transport?: 'audio' | 't38';
filePath?: string;
codec?: string;
remoteMedia?: string;
success?: boolean;
completionCode?: number | null;
completionLabel?: string | null;
error?: string;
stats?: IFaxCompletedEvent['stats'];
createdAt: number;
updatedAt: number;
}
export class FaxJobManager {
private readonly basePath: string;
private readonly jobsPath: string;
private readonly log: (msg: string) => void;
constructor(log: (msg: string) => void) {
this.basePath = path.join(process.cwd(), '.nogit', 'fax');
this.jobsPath = path.join(this.basePath, 'jobs.json');
this.log = log;
}
init(): void {
fs.mkdirSync(this.basePath, { recursive: true });
if (!fs.existsSync(this.jobsPath)) {
this.writeJobs([]);
}
}
noteDialing(callId: string, number: string, providerId: string): void {
const jobs = this.loadJobs();
const now = Date.now();
const existing = jobs.find((job) => job.callId === callId);
if (existing) {
existing.number = number;
existing.providerId = providerId;
existing.updatedAt = now;
} else {
jobs.unshift({
id: callId,
callId,
number,
providerId,
direction: 'outbound',
status: 'dialing',
createdAt: now,
updatedAt: now,
});
}
this.writeJobs(jobs);
}
noteStarted(event: IFaxStartedEvent): void {
const jobs = this.loadJobs();
const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
job.status = 'started';
job.transport = event.transport;
job.filePath = event.file_path;
job.codec = event.codec;
job.remoteMedia = event.remote_media;
job.updatedAt = now;
this.writeJobs(jobs);
}
noteCompleted(event: IFaxCompletedEvent): void {
const jobs = this.loadJobs();
const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
job.status = 'completed';
job.transport = event.transport;
job.filePath = event.file_path;
job.codec = event.codec;
job.success = event.success;
job.completionCode = event.completion_code ?? null;
job.completionLabel = event.completion_label ?? null;
job.stats = event.stats;
job.updatedAt = now;
this.writeJobs(jobs);
}
noteFailed(event: IFaxFailedEvent): void {
const jobs = this.loadJobs();
const now = Date.now();
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
job.status = 'failed';
job.transport = event.transport;
job.filePath = event.file_path;
job.error = event.error;
job.success = false;
job.updatedAt = now;
this.writeJobs(jobs);
}
getJobs(): IFaxJob[] {
return this.loadJobs();
}
private getOrCreateJob(
jobs: IFaxJob[],
callId: string,
direction: 'outbound' | 'inbound',
now: number,
): IFaxJob {
let job = jobs.find((entry) => entry.callId === callId);
if (!job) {
job = {
id: callId,
callId,
direction,
status: 'dialing',
createdAt: now,
updatedAt: now,
};
jobs.unshift(job);
}
return job;
}
private loadJobs(): IFaxJob[] {
try {
const content = fs.readFileSync(this.jobsPath, 'utf8');
const parsed = JSON.parse(content);
return Array.isArray(parsed) ? parsed as IFaxJob[] : [];
} catch {
return [];
}
}
private writeJobs(jobs: IFaxJob[]): void {
fs.mkdirSync(this.basePath, { recursive: true });
fs.writeFileSync(this.jobsPath, JSON.stringify(jobs, null, 2));
this.log(`[fax] persisted ${jobs.length} job(s)`);
}
}
+72 -4
View File
@@ -11,6 +11,8 @@ import path from 'node:path';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import type { FaxBoxManager } from './faxbox.ts';
import type { FaxJobManager } from './faxjobs.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts'; import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts'; import type { VoiceboxManager } from './voicebox.ts';
@@ -22,6 +24,8 @@ interface IHandleRequestContext {
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null; onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
onHangupCall: (callId: string) => boolean; onHangupCall: (callId: string) => boolean;
onConfigSaved?: () => void | Promise<void>; onConfigSaved?: () => void | Promise<void>;
faxBoxManager?: FaxBoxManager;
faxJobManager?: FaxJobManager;
voiceboxManager?: VoiceboxManager; voiceboxManager?: VoiceboxManager;
} }
@@ -108,7 +112,7 @@ async function handleRequest(
res: http.ServerResponse, res: http.ServerResponse,
context: IHandleRequestContext, context: IHandleRequestContext,
): Promise<void> { ): Promise<void> {
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context; const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager } = context;
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET'; const method = req.method || 'GET';
@@ -147,6 +151,65 @@ async function handleRequest(
} }
} }
// API: send outbound fax.
if (url.pathname === '/api/fax' && method === 'POST') {
try {
const body = await readJsonBody(req);
const number = body?.number;
const filePath = body?.filePath;
if (!number || typeof number !== 'string') {
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
}
if (!filePath || typeof filePath !== 'string') {
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
}
const { sendFax } = await import('./proxybridge.ts');
const callId = await sendFax(number, filePath, body?.providerId);
if (callId) {
log(`[dashboard] fax started: ${callId} -> ${number} file=${filePath}`);
return sendJson(res, { ok: true, callId });
}
return sendJson(res, { ok: false, error: 'fax origination failed' }, 503);
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: fax jobs.
if (url.pathname === '/api/fax/jobs' && method === 'GET' && faxJobManager) {
return sendJson(res, { ok: true, jobs: faxJobManager.getJobs() });
}
// API: fax inbox - list messages.
const faxListMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)$/);
if (faxListMatch && method === 'GET' && faxBoxManager) {
const boxId = faxListMatch[1];
return sendJson(res, { ok: true, messages: faxBoxManager.getMessages(boxId) });
}
// API: fax inbox - stream TIFF.
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
if (faxFileMatch && method === 'GET' && faxBoxManager) {
const [, boxId, msgId] = faxFileMatch;
const filePath = faxBoxManager.getMessageFilePath(boxId, msgId);
if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
const stat = fs.statSync(filePath);
res.writeHead(200, {
'Content-Type': 'image/tiff',
'Content-Length': stat.size.toString(),
'Accept-Ranges': 'bytes',
});
fs.createReadStream(filePath).pipe(res);
return;
}
// API: fax inbox - delete message.
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
const [, boxId, msgId] = faxDeleteMatch;
return sendJson(res, { ok: faxBoxManager.deleteMessage(boxId, msgId) });
}
// API: add a SIP device to a call (mid-call INVITE to desk phone). // API: add a SIP device to a call (mid-call INVITE to desk phone).
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') { if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
try { try {
@@ -273,6 +336,7 @@ async function handleRequest(
} }
} }
if (updates.contacts !== undefined) cfg.contacts = updates.contacts; if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
if (updates.faxboxes !== undefined) cfg.faxboxes = updates.faxboxes;
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes; if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
if (updates.ivr !== undefined) cfg.ivr = updates.ivr; if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
@@ -368,6 +432,8 @@ export function initWebUi(
onStartCall, onStartCall,
onHangupCall, onHangupCall,
onConfigSaved, onConfigSaved,
faxBoxManager,
faxJobManager,
voiceboxManager, voiceboxManager,
onWebRtcOffer, onWebRtcOffer,
onWebRtcIce, onWebRtcIce,
@@ -387,12 +453,12 @@ export function initWebUi(
const cert = fs.readFileSync(certPath, 'utf8'); const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) => server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
useTls = true; useTls = true;
} catch { } catch {
server = http.createServer((req, res) => server = http.createServer((req, res) =>
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
); );
} }
@@ -429,7 +495,9 @@ export function initWebUi(
} }
} else if (msg.type?.startsWith('webrtc-')) { } else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp; msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket, msg); if (msg.type) {
handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string });
}
} }
} catch { /* ignore */ } } catch { /* ignore */ }
}); });
+22
View File
@@ -17,6 +17,9 @@ export type {
ICallEndedEvent, ICallEndedEvent,
ICallRingingEvent, ICallRingingEvent,
IDeviceRegisteredEvent, IDeviceRegisteredEvent,
IFaxCompletedEvent,
IFaxFailedEvent,
IFaxStartedEvent,
IIncomingCallEvent, IIncomingCallEvent,
ILegAddedEvent, ILegAddedEvent,
ILegRemovedEvent, ILegRemovedEvent,
@@ -52,6 +55,10 @@ type TProxyCommands = {
params: { number: string; device_id?: string; provider_id?: string }; params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string }; result: { call_id: string };
}; };
send_fax: {
params: { number: string; file_path: string; provider_id?: string };
result: { call_id: string; codec: 'PCMU' | 'PCMA' };
};
add_leg: { add_leg: {
params: { call_id: string; number: string; provider_id?: string }; params: { call_id: string; number: string; provider_id?: string };
result: { leg_id: string }; result: { leg_id: string };
@@ -262,6 +269,21 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
} }
} }
export async function sendFax(number: string, filePath: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await sendProxyCommand('send_fax', {
number,
file_path: filePath,
provider_id: providerId,
});
return result.call_id || null;
} catch (error: unknown) {
logFn?.(`[proxy-engine] send_fax error: ${errorMessage(error)}`);
return null;
}
}
/** /**
* Send a hangup command. * Send a hangup command.
*/ */
+56 -1
View File
@@ -1,4 +1,6 @@
import { onProxyEvent } from '../proxybridge.ts'; import { hangupCall, onProxyEvent } from '../proxybridge.ts';
import type { FaxBoxManager } from '../faxbox.ts';
import type { FaxJobManager } from '../faxjobs.ts';
import type { VoiceboxManager } from '../voicebox.ts'; import type { VoiceboxManager } from '../voicebox.ts';
import type { StatusStore } from './status-store.ts'; import type { StatusStore } from './status-store.ts';
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'; import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
@@ -6,6 +8,8 @@ import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'
export interface IRegisterProxyEventHandlersOptions { export interface IRegisterProxyEventHandlersOptions {
log: (msg: string) => void; log: (msg: string) => void;
statusStore: StatusStore; statusStore: StatusStore;
faxBoxManager: FaxBoxManager;
faxJobManager: FaxJobManager;
voiceboxManager: VoiceboxManager; voiceboxManager: VoiceboxManager;
webRtcLinks: WebRtcLinkManager; webRtcLinks: WebRtcLinkManager;
getBrowserDeviceIds: () => string[]; getBrowserDeviceIds: () => string[];
@@ -19,6 +23,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
const { const {
log, log,
statusStore, statusStore,
faxBoxManager,
faxJobManager,
voiceboxManager, voiceboxManager,
webRtcLinks, webRtcLinks,
getBrowserDeviceIds, getBrowserDeviceIds,
@@ -30,6 +36,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
const legMediaDetails = (data: { const legMediaDetails = (data: {
codec?: string | null; codec?: string | null;
mediaProtocol?: string | null;
remoteMedia?: string | null; remoteMedia?: string | null;
rtpPort?: number | null; rtpPort?: number | null;
}): string => { }): string => {
@@ -37,6 +44,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
if (data.codec) { if (data.codec) {
parts.push(`codec=${data.codec}`); parts.push(`codec=${data.codec}`);
} }
if (data.mediaProtocol) {
parts.push(`media=${data.mediaProtocol}`);
}
if (data.remoteMedia) { if (data.remoteMedia) {
parts.push(`remote=${data.remoteMedia}`); parts.push(`remote=${data.remoteMedia}`);
} }
@@ -91,6 +101,14 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`); log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
statusStore.noteOutboundCallStarted(data); statusStore.noteOutboundCallStarted(data);
if (data.ring_browsers === false) {
faxJobManager.noteDialing(data.call_id, data.number, data.provider_id);
}
if (data.ring_browsers === false) {
return;
}
for (const deviceId of getBrowserDeviceIds()) { for (const deviceId of getBrowserDeviceIds()) {
sendToBrowserDevice(deviceId, { sendToBrowserDevice(deviceId, {
type: 'webrtc-incoming', type: 'webrtc-incoming',
@@ -110,6 +128,10 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
log(`[call] ${data.call_id} connected`); log(`[call] ${data.call_id} connected`);
} }
if (data.media_protocol && data.media_protocol !== 'rtp') {
return;
}
if (!data.provider_media_addr || !data.provider_media_port) { if (!data.provider_media_addr || !data.provider_media_port) {
return; return;
} }
@@ -207,4 +229,37 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
onProxyEvent('voicemail_error', (data) => { onProxyEvent('voicemail_error', (data) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`); log(`[voicemail] error: ${data.error} call=${data.call_id}`);
}); });
onProxyEvent('fax_started', (data) => {
faxJobManager.noteStarted(data);
log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`);
});
onProxyEvent('fax_completed', (data) => {
faxJobManager.noteCompleted(data);
log(
`[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`,
);
if (data.direction === 'inbound' && data.success && data.fax_box_id) {
faxBoxManager.addMessage(data.fax_box_id, {
callerNumber: data.caller_number,
fileName: data.file_path,
completionCode: data.completion_code,
completionLabel: data.completion_label,
pageCount: data.stats.pages_rx || data.stats.pages_tx,
bitRate: data.stats.bit_rate,
});
}
if (data.direction === 'outbound' || data.fax_box_id) {
void hangupCall(data.call_id);
}
});
onProxyEvent('fax_failed', (data) => {
faxJobManager.noteFailed(data);
log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`);
if (data.direction === 'outbound' || data.fax_box_id) {
void hangupCall(data.call_id);
}
});
} }
+66 -64
View File
@@ -88,16 +88,12 @@ export class StatusStore {
} }
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void { noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
this.activeCalls.set(callId, { const call = this.getOrCreateCall(callId, 'outbound');
id: callId, call.direction = 'outbound';
direction: 'outbound', call.callerNumber = null;
callerNumber: null, call.calleeNumber = number;
calleeNumber: number, call.providerUsed = providerId || null;
providerUsed: providerId || null, call.state = 'setting-up';
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
} }
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null { noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
@@ -126,57 +122,40 @@ export class StatusStore {
} }
noteIncomingCall(data: IIncomingCallEvent): void { noteIncomingCall(data: IIncomingCallEvent): void {
this.activeCalls.set(data.call_id, { const call = this.getOrCreateCall(data.call_id, 'inbound');
id: data.call_id, call.direction = 'inbound';
direction: 'inbound', call.callerNumber = data.from_uri;
callerNumber: data.from_uri, call.calleeNumber = data.to_number;
calleeNumber: data.to_number, call.providerUsed = data.provider_id;
providerUsed: data.provider_id, if (call.state === 'setting-up') {
state: 'ringing',
startedAt: Date.now(),
legs: new Map(),
});
}
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
this.activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: null,
calleeNumber: data.number,
providerUsed: data.provider_id,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
});
}
noteCallRinging(data: ICallRingingEvent): void {
const call = this.activeCalls.get(data.call_id);
if (call) {
call.state = 'ringing'; call.state = 'ringing';
} }
} }
noteCallAnswered(data: ICallAnsweredEvent): boolean { noteOutboundDeviceCall(data: IOutboundCallEvent): void {
const call = this.activeCalls.get(data.call_id); const call = this.getOrCreateCall(data.call_id, 'outbound');
if (!call) { call.direction = 'outbound';
return false; call.callerNumber = data.from_device;
call.calleeNumber = data.to_number;
call.providerUsed = null;
} }
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
const call = this.getOrCreateCall(data.call_id, 'outbound');
call.direction = 'outbound';
call.callerNumber = call.callerNumber ?? null;
call.calleeNumber = data.number;
call.providerUsed = data.provider_id;
}
noteCallRinging(data: ICallRingingEvent): void {
const call = this.getOrCreateCall(data.call_id);
call.state = 'ringing';
}
noteCallAnswered(data: ICallAnsweredEvent): boolean {
const call = this.getOrCreateCall(data.call_id);
call.state = 'connected'; call.state = 'connected';
if (data.provider_media_addr && data.provider_media_port) { if (data.provider_media_addr && data.provider_media_port) {
@@ -186,7 +165,12 @@ export class StatusStore {
} }
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`; leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
if (data.sip_pt !== undefined) { if (data.media_protocol) {
leg.mediaProtocol = data.media_protocol;
}
if (data.media_protocol === 't38-udptl') {
leg.codec = 'T.38';
} else if (data.sip_pt !== undefined) {
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`; leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
} }
break; break;
@@ -216,6 +200,7 @@ export class StatusStore {
state: leg.state, state: leg.state,
codec: leg.codec, codec: leg.codec,
rtpPort: leg.rtpPort, rtpPort: leg.rtpPort,
mediaProtocol: leg.mediaProtocol,
remoteMedia: leg.remoteMedia, remoteMedia: leg.remoteMedia,
metadata: leg.metadata || {}, metadata: leg.metadata || {},
})), })),
@@ -230,10 +215,7 @@ export class StatusStore {
} }
noteLegAdded(data: ILegAddedEvent): void { noteLegAdded(data: ILegAddedEvent): void {
const call = this.activeCalls.get(data.call_id); const call = this.getOrCreateCall(data.call_id);
if (!call) {
return;
}
call.legs.set(data.leg_id, { call.legs.set(data.leg_id, {
id: data.leg_id, id: data.leg_id,
@@ -241,6 +223,7 @@ export class StatusStore {
state: data.state, state: data.state,
codec: data.codec ?? null, codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null, rtpPort: data.rtpPort ?? null,
mediaProtocol: data.mediaProtocol ?? null,
remoteMedia: data.remoteMedia ?? null, remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {}, metadata: data.metadata || {},
}); });
@@ -251,10 +234,7 @@ export class StatusStore {
} }
noteLegStateChanged(data: ILegStateChangedEvent): void { noteLegStateChanged(data: ILegStateChangedEvent): void {
const call = this.activeCalls.get(data.call_id); const call = this.getOrCreateCall(data.call_id);
if (!call) {
return;
}
const existingLeg = call.legs.get(data.leg_id); const existingLeg = call.legs.get(data.leg_id);
if (existingLeg) { if (existingLeg) {
@@ -265,6 +245,9 @@ export class StatusStore {
if (data.rtpPort !== undefined) { if (data.rtpPort !== undefined) {
existingLeg.rtpPort = data.rtpPort; existingLeg.rtpPort = data.rtpPort;
} }
if (data.mediaProtocol !== undefined) {
existingLeg.mediaProtocol = data.mediaProtocol;
}
if (data.remoteMedia !== undefined) { if (data.remoteMedia !== undefined) {
existingLeg.remoteMedia = data.remoteMedia; existingLeg.remoteMedia = data.remoteMedia;
} }
@@ -280,6 +263,7 @@ export class StatusStore {
state: data.state, state: data.state,
codec: data.codec ?? null, codec: data.codec ?? null,
rtpPort: data.rtpPort ?? null, rtpPort: data.rtpPort ?? null,
mediaProtocol: data.mediaProtocol ?? null,
remoteMedia: data.remoteMedia ?? null, remoteMedia: data.remoteMedia ?? null,
metadata: data.metadata || {}, metadata: data.metadata || {},
}); });
@@ -323,4 +307,22 @@ export class StatusStore {
} }
return 'webrtc'; return 'webrtc';
} }
private getOrCreateCall(callId: string, direction: 'inbound' | 'outbound' = 'inbound'): IActiveCall {
let call = this.activeCalls.get(callId);
if (!call) {
call = {
id: callId,
direction,
callerNumber: null,
calleeNumber: null,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
legs: new Map(),
};
this.activeCalls.set(callId, call);
}
return call;
}
} }
+57
View File
@@ -18,6 +18,7 @@ export interface IOutboundCallStartedEvent {
call_id: string; call_id: string;
number: string; number: string;
provider_id: string; provider_id: string;
ring_browsers?: boolean;
} }
export interface ICallRingingEvent { export interface ICallRingingEvent {
@@ -28,6 +29,7 @@ export interface ICallAnsweredEvent {
call_id: string; call_id: string;
provider_media_addr?: string; provider_media_addr?: string;
provider_media_port?: number; provider_media_port?: number;
media_protocol?: string;
sip_pt?: number; sip_pt?: number;
} }
@@ -67,6 +69,7 @@ export interface ILegAddedEvent {
state: string; state: string;
codec?: string | null; codec?: string | null;
rtpPort?: number | null; rtpPort?: number | null;
mediaProtocol?: string | null;
remoteMedia?: string | null; remoteMedia?: string | null;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@@ -82,6 +85,7 @@ export interface ILegStateChangedEvent {
state: string; state: string;
codec?: string | null; codec?: string | null;
rtpPort?: number | null; rtpPort?: number | null;
mediaProtocol?: string | null;
remoteMedia?: string | null; remoteMedia?: string | null;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@@ -128,6 +132,56 @@ export interface IVoicemailErrorEvent {
error: string; error: string;
} }
export interface IFaxStartedEvent {
call_id: string;
leg_id: string;
direction: 'outbound' | 'inbound';
transport: 'audio' | 't38';
file_path: string;
fax_box_id?: string;
caller_number?: string;
codec?: string;
remote_media?: string;
}
export interface IFaxCompletedEvent {
call_id: string;
leg_id: string;
direction: 'outbound' | 'inbound';
transport: 'audio' | 't38';
file_path: string;
fax_box_id?: string;
caller_number?: string;
codec?: string;
success: boolean;
completion_code?: number | null;
completion_label?: string | null;
stats: {
bit_rate: number;
error_correcting_mode: boolean;
pages_tx: number;
pages_rx: number;
image_size: number;
bad_rows: number;
longest_bad_row_run: number;
ecm_retries: number;
current_status: number;
rtp_events: number;
rtn_events: number;
};
}
export interface IFaxFailedEvent {
call_id: string;
leg_id: string;
direction: 'outbound' | 'inbound';
transport: 'audio' | 't38';
file_path: string;
fax_box_id?: string;
caller_number?: string;
error: string;
}
export type TProxyEventMap = { export type TProxyEventMap = {
provider_registered: IProviderRegisteredEvent; provider_registered: IProviderRegisteredEvent;
device_registered: IDeviceRegisteredEvent; device_registered: IDeviceRegisteredEvent;
@@ -148,4 +202,7 @@ export type TProxyEventMap = {
voicemail_started: IVoicemailStartedEvent; voicemail_started: IVoicemailStartedEvent;
recording_done: IRecordingDoneEvent; recording_done: IRecordingDoneEvent;
voicemail_error: IVoicemailErrorEvent; voicemail_error: IVoicemailErrorEvent;
fax_started: IFaxStartedEvent;
fax_completed: IFaxCompletedEvent;
fax_failed: IFaxFailedEvent;
}; };
+1
View File
@@ -26,6 +26,7 @@ export interface IActiveLeg {
state: string; state: string;
codec: string | null; codec: string | null;
rtpPort: number | null; rtpPort: number | null;
mediaProtocol: string | null;
remoteMedia: string | null; remoteMedia: string | null;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
} }
+13 -1
View File
@@ -9,6 +9,8 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { loadConfig, type IAppConfig } from './config.ts'; import { loadConfig, type IAppConfig } from './config.ts';
import { FaxBoxManager } from './faxbox.ts';
import { FaxJobManager } from './faxjobs.ts';
import { broadcastWs, initWebUi } from './frontend.ts'; import { broadcastWs, initWebUi } from './frontend.ts';
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts'; import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
import { VoiceboxManager } from './voicebox.ts'; import { VoiceboxManager } from './voicebox.ts';
@@ -35,8 +37,12 @@ const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig); const statusStore = new StatusStore(appConfig);
const webRtcLinks = new WebRtcLinkManager(); const webRtcLinks = new WebRtcLinkManager();
const faxBoxManager = new FaxBoxManager(log);
const faxJobManager = new FaxJobManager(log);
const voiceboxManager = new VoiceboxManager(log); const voiceboxManager = new VoiceboxManager(log);
faxBoxManager.init(appConfig.faxboxes ?? []);
faxJobManager.init();
voiceboxManager.init(appConfig.voiceboxes ?? []); voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log }); initWebRtcSignaling({ log });
@@ -61,6 +67,7 @@ function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
providers: config.providers, providers: config.providers,
devices: config.devices, devices: config.devices,
routing: config.routing, routing: config.routing,
faxboxes: config.faxboxes ?? [],
voiceboxes: config.voiceboxes ?? [], voiceboxes: config.voiceboxes ?? [],
ivr: config.ivr, ivr: config.ivr,
}; };
@@ -93,6 +100,7 @@ async function reloadConfig(): Promise<void> {
appConfig = nextConfig; appConfig = nextConfig;
statusStore.updateConfig(nextConfig); statusStore.updateConfig(nextConfig);
faxBoxManager.init(nextConfig.faxboxes ?? []);
voiceboxManager.init(nextConfig.voiceboxes ?? []); voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) { if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
@@ -123,6 +131,8 @@ async function startProxyEngine(): Promise<void> {
registerProxyEventHandlers({ registerProxyEventHandlers({
log, log,
statusStore, statusStore,
faxBoxManager,
faxJobManager,
voiceboxManager, voiceboxManager,
webRtcLinks, webRtcLinks,
getBrowserDeviceIds: getAllBrowserDeviceIds, getBrowserDeviceIds: getAllBrowserDeviceIds,
@@ -167,6 +177,8 @@ initWebUi({
return true; return true;
}, },
onConfigSaved: reloadConfig, onConfigSaved: reloadConfig,
faxBoxManager,
faxJobManager,
voiceboxManager, voiceboxManager,
onWebRtcOffer: async (sessionId, sdp, ws) => { onWebRtcOffer: async (sessionId, sdp, ws) => {
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`); log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
@@ -187,7 +199,7 @@ initWebUi({
log('[webrtc] ERROR: no answer SDP from Rust'); log('[webrtc] ERROR: no answer SDP from Rust');
}, },
onWebRtcIce: async (sessionId, candidate) => { onWebRtcIce: async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate); await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
}, },
onWebRtcClose: async (sessionId) => { onWebRtcClose: async (sessionId) => {
webRtcLinks.removeSession(sessionId); webRtcLinks.removeSession(sessionId);
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.25.2', version: '1.26.0',
description: 'undefined' description: 'undefined'
} }
+338 -57
View File
@@ -175,36 +175,240 @@ export class SipproxyViewCalls extends DeesElement {
.call-body { .call-body {
padding: 12px 16px 16px; padding: 12px 16px 16px;
display: grid;
gap: 16px;
} }
.legs-table { .call-overview {
width: 100%; display: grid;
border-collapse: collapse; grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr);
font-size: 0.75rem; gap: 14px;
margin-bottom: 12px;
} }
.legs-table th { .call-route-card,
text-align: left; .call-facts-card,
color: #64748b; .legs-section {
font-weight: 500; border-radius: 14px;
font-size: 0.65rem; border: 1px solid rgba(51, 65, 85, 0.75);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.92) 0%, rgba(8, 15, 31, 0.88) 100%);
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
}
.call-route-card,
.call-facts-card {
padding: 14px;
}
.section-kicker {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; color: #64748b;
padding: 6px 8px;
border-bottom: 1px solid var(--dees-color-border-default, #334155);
} }
.legs-table td { .route-line {
padding: 8px; display: grid;
font-family: 'JetBrains Mono', monospace; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
font-size: 0.7rem; align-items: center;
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5)); gap: 12px;
vertical-align: middle; margin-top: 12px;
} }
.legs-table tr:last-child td { .route-party {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(71, 85, 105, 0.45);
}
.route-party.align-end {
text-align: right;
align-items: flex-end;
}
.route-party-label {
font-size: 0.64rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.route-party-value {
min-width: 0;
font-size: 0.95rem;
font-weight: 600;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-arrow {
width: 34px;
height: 34px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: #93c5fd;
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(59, 130, 246, 0.35);
}
.call-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.subtle-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.03em;
color: #cbd5e1;
background: rgba(30, 41, 59, 0.9);
border: 1px solid rgba(71, 85, 105, 0.45);
}
.call-facts-card {
display: grid;
gap: 8px;
}
.fact-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid rgba(51, 65, 85, 0.55);
}
.fact-row:last-child {
border-bottom: none; border-bottom: none;
padding-bottom: 0;
}
.fact-label {
font-size: 0.65rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.fact-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.76rem;
text-align: right;
color: #e2e8f0;
word-break: break-word;
}
.legs-section {
padding: 14px;
display: grid;
gap: 12px;
}
.legs-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.legs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
}
.leg-card {
display: grid;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(71, 85, 105, 0.4);
}
.leg-card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.leg-card-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.leg-card-id {
font-family: 'JetBrains Mono', monospace;
font-size: 0.64rem;
color: #64748b;
word-break: break-all;
text-align: right;
}
.leg-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.leg-fact {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.leg-fact-wide {
grid-column: 1 / -1;
}
.leg-fact-label {
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.leg-fact-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.76rem;
color: #e2e8f0;
word-break: break-word;
}
.leg-actions {
display: flex;
justify-content: flex-end;
}
.no-legs {
padding: 16px;
border-radius: 12px;
border: 1px dashed rgba(71, 85, 105, 0.55);
color: #64748b;
font-size: 0.75rem;
text-align: center;
} }
.card-actions { .card-actions {
@@ -247,6 +451,34 @@ export class SipproxyViewCalls extends DeesElement {
.empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; } .empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; }
.empty-state-text { font-size: 0.9rem; font-weight: 500; } .empty-state-text { font-size: 0.9rem; font-weight: 500; }
.empty-state-sub { font-size: 0.75rem; margin-top: 4px; } .empty-state-sub { font-size: 0.75rem; margin-top: 4px; }
@media (max-width: 820px) {
.call-overview {
grid-template-columns: 1fr;
}
.route-line {
grid-template-columns: 1fr;
}
.route-arrow {
justify-self: center;
transform: rotate(90deg);
}
.route-party.align-end {
text-align: left;
align-items: flex-start;
}
.leg-card-top {
flex-direction: column;
}
.leg-card-id {
text-align: left;
}
}
`, `,
]; ];
@@ -550,61 +782,110 @@ export class SipproxyViewCalls extends DeesElement {
</div> </div>
<div class="call-id">${call.id}</div> <div class="call-id">${call.id}</div>
<div class="call-body"> <div class="call-body">
<div class="call-overview">
<div class="call-route-card">
<div class="section-kicker">Call Route</div>
<div class="route-line">
<div class="route-party">
<div class="route-party-label">From</div>
<div class="route-party-value">${call.callerNumber || 'Unknown caller'}</div>
</div>
<div class="route-arrow">${directionIcon(call.direction)}</div>
<div class="route-party align-end">
<div class="route-party-label">To</div>
<div class="route-party-value">${call.calleeNumber || 'System'}</div>
</div>
</div>
<div class="call-tags">
<span class="subtle-badge">${call.legs.length} ${call.legs.length === 1 ? 'leg' : 'legs'}</span>
<span class="subtle-badge">${call.providerUsed || 'system handled'}</span>
<span class="subtle-badge">started ${fmtTime(call.startedAt)}</span>
</div>
</div>
<div class="call-facts-card">
<div class="section-kicker">Session</div>
<div class="fact-row">
<span class="fact-label">State</span>
<span class="fact-value">${STATE_LABELS[call.state] || call.state}</span>
</div>
<div class="fact-row">
<span class="fact-label">Direction</span>
<span class="fact-value">${call.direction}</span>
</div>
<div class="fact-row">
<span class="fact-label">Duration</span>
<span class="fact-value">${fmtDuration(call.duration)}</span>
</div>
<div class="fact-row">
<span class="fact-label">Provider</span>
<span class="fact-value">${call.providerUsed || '--'}</span>
</div>
</div>
</div>
<div class="legs-section">
<div class="legs-header">
<div class="section-kicker">Active Legs</div>
<span class="subtle-badge">${call.legs.length}</span>
</div>
${call.legs.length ${call.legs.length
? html` ? html`
<table class="legs-table"> <div class="legs-grid">
<thead>
<tr>
<th>Type</th>
<th>State</th>
<th>Remote</th>
<th>Port</th>
<th>Codec</th>
<th>Pkts In</th>
<th>Pkts Out</th>
<th></th>
</tr>
</thead>
<tbody>
${call.legs.map( ${call.legs.map(
(leg) => html` (leg) => html`
<tr> <div class="leg-card">
<td> <div class="leg-card-top">
<div class="leg-card-badges">
<span class="badge" style="${legTypeBadgeStyle(leg.type)}"> <span class="badge" style="${legTypeBadgeStyle(leg.type)}">
${LEG_TYPE_LABELS[leg.type] || leg.type} ${LEG_TYPE_LABELS[leg.type] || leg.type}
</span> </span>
</td>
<td>
<span class="badge" style="${stateBadgeStyle(leg.state)}"> <span class="badge" style="${stateBadgeStyle(leg.state)}">
${leg.state} ${STATE_LABELS[leg.state] || leg.state}
</span> </span>
</td> </div>
<td> <div class="leg-card-id">${leg.id}</div>
${leg.remoteMedia || '--'} </div>
</td>
<td>${leg.rtpPort ?? '--'}</td> <div class="leg-facts">
<td> <div class="leg-fact">
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''} <span class="leg-fact-label">Codec</span>
</td> <span class="leg-fact-value">${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}</span>
<td>${leg.pktReceived}</td> </div>
<td>${leg.pktSent}</td> <div class="leg-fact">
<td> <span class="leg-fact-label">RTP Port</span>
<span class="leg-fact-value">${leg.rtpPort ?? '--'}</span>
</div>
<div class="leg-fact leg-fact-wide">
<span class="leg-fact-label">Remote Media</span>
<span class="leg-fact-value">${leg.remoteMedia || '--'}</span>
</div>
<div class="leg-fact">
<span class="leg-fact-label">Packets In</span>
<span class="leg-fact-value">${leg.pktReceived}</span>
</div>
<div class="leg-fact">
<span class="leg-fact-label">Packets Out</span>
<span class="leg-fact-value">${leg.pktSent}</span>
</div>
</div>
<div class="leg-actions">
<button <button
class="btn btn-remove" class="btn btn-remove"
@click=${() => this.handleRemoveLeg(call, leg)} @click=${() => this.handleRemoveLeg(call, leg)}
> >
Remove Remove
</button> </button>
</td> </div>
</tr> </div>
`, `,
)} )}
</tbody> </div>
</table>
` `
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;"> : html`<div class="no-legs">No legs reported yet. SIP/system legs should appear here as soon as the call is wired.</div>`}
No legs </div>
</div>`}
<div class="card-actions"> <div class="card-actions">
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}> <button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>