feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# 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)
|
||||
improve inbound SIP routing diagnostics and enrich leg media state reporting
|
||||
|
||||
|
||||
@@ -28,3 +28,6 @@ rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"]
|
||||
CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc"
|
||||
CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++"
|
||||
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"
|
||||
|
||||
Generated
+161
-23
@@ -165,7 +165,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -181,7 +181,7 @@ dependencies = [
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -327,6 +327,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -429,6 +449,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.10"
|
||||
@@ -498,6 +527,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
@@ -538,7 +578,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1021,6 +1061,12 @@ dependencies = [
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.12.3"
|
||||
@@ -1367,6 +1413,12 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.12.1"
|
||||
@@ -1668,7 +1720,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rtcp",
|
||||
"rtp",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"waitgroup",
|
||||
"webrtc-srtp",
|
||||
@@ -1681,6 +1733,15 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1792,6 +1853,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2386,7 +2457,9 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sip-proto",
|
||||
"spandsp",
|
||||
"tokio",
|
||||
"udptl",
|
||||
"webrtc",
|
||||
]
|
||||
|
||||
@@ -2584,7 +2657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"webrtc-util",
|
||||
]
|
||||
|
||||
@@ -2594,7 +2667,7 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2606,7 +2679,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"webrtc-util",
|
||||
]
|
||||
|
||||
@@ -2617,7 +2690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2744,7 +2817,7 @@ checksum = "4d22a5ef407871893fd72b4562ee15e4742269b173959db4b8df6f538c414e13"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"substring",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -2957,6 +3030,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
@@ -3004,7 +3099,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"url",
|
||||
"webrtc-util",
|
||||
@@ -3104,7 +3199,16 @@ version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
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]]
|
||||
@@ -3118,6 +3222,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -3194,9 +3309,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"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]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -3230,7 +3357,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"stun",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3241,6 +3368,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -3532,7 +3670,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"smol_str",
|
||||
"stun",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"turn",
|
||||
@@ -3557,7 +3695,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"derive_builder",
|
||||
"log",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-sctp",
|
||||
"webrtc-util",
|
||||
@@ -3595,7 +3733,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"signature",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webpki",
|
||||
"webrtc-util",
|
||||
@@ -3617,7 +3755,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"stun",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"turn",
|
||||
"url",
|
||||
@@ -3635,7 +3773,7 @@ checksum = "f08dfd7a6e3987e255c4dbe710dde5d94d0f0574f8a21afa95d171376c143106"
|
||||
dependencies = [
|
||||
"log",
|
||||
"socket2 0.4.10",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3650,7 +3788,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"rand 0.8.5",
|
||||
"rtp",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3665,7 +3803,7 @@ dependencies = [
|
||||
"crc",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3688,7 +3826,7 @@ dependencies = [
|
||||
"rtp",
|
||||
"sha1",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"webrtc-util",
|
||||
]
|
||||
@@ -3709,7 +3847,7 @@ dependencies = [
|
||||
"log",
|
||||
"nix",
|
||||
"rand 0.8.5",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"winapi",
|
||||
]
|
||||
@@ -3880,7 +4018,7 @@ dependencies = [
|
||||
"nom",
|
||||
"oid-registry 0.4.0",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
@@ -3899,7 +4037,7 @@ dependencies = [
|
||||
"oid-registry 0.6.1",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
]
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
hound = "3.5"
|
||||
spandsp = "0.1.5"
|
||||
udptl = "0.1.0"
|
||||
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
|
||||
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
||||
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub type LegId = String;
|
||||
@@ -114,6 +114,13 @@ pub struct LegInfo {
|
||||
pub kind: LegKind,
|
||||
pub state: LegState,
|
||||
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).
|
||||
pub sip_leg: Option<SipLeg>,
|
||||
@@ -146,6 +153,15 @@ pub struct LegInfo {
|
||||
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.
|
||||
pub struct Call {
|
||||
// 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.
|
||||
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.
|
||||
pub legs: HashMap<LegId, LegInfo>,
|
||||
|
||||
/// Channel to send commands to the mixer task.
|
||||
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).
|
||||
mixer_task: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -196,8 +221,11 @@ impl Call {
|
||||
callee_number: None,
|
||||
provider_id,
|
||||
device_invite: None,
|
||||
pending_dialog_bridge: None,
|
||||
legs: HashMap::new(),
|
||||
mixer_cmd_tx,
|
||||
media_bridge_mode: None,
|
||||
media_bridge_cancel_txs: Vec::new(),
|
||||
mixer_task: Some(mixer_task),
|
||||
}
|
||||
}
|
||||
@@ -235,8 +263,31 @@ impl Call {
|
||||
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.
|
||||
pub async fn shutdown_mixer(&mut self) {
|
||||
self.clear_media_bridge();
|
||||
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
|
||||
if let Some(handle) = self.mixer_task.take() {
|
||||
handle.abort();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,8 @@ pub struct RouteAction {
|
||||
pub ring_browsers: Option<bool>,
|
||||
#[serde(rename = "voicemailBox")]
|
||||
pub voicemail_box: Option<String>,
|
||||
#[serde(rename = "faxBox")]
|
||||
pub fax_box: Option<String>,
|
||||
#[serde(rename = "ivrMenuId")]
|
||||
pub ivr_menu_id: Option<String>,
|
||||
#[serde(rename = "noAnswerTimeout")]
|
||||
@@ -161,6 +163,8 @@ pub struct AppConfig {
|
||||
pub devices: Vec<DeviceConfig>,
|
||||
pub routing: RoutingConfig,
|
||||
#[serde(default)]
|
||||
pub faxboxes: Vec<FaxBoxConfig>,
|
||||
#[serde(default)]
|
||||
pub voiceboxes: Vec<VoiceboxConfig>,
|
||||
#[serde(default)]
|
||||
pub ivr: Option<IvrConfig>,
|
||||
@@ -191,6 +195,16 @@ pub struct VoiceboxConfig {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -415,6 +429,7 @@ pub struct InboundRouteResult {
|
||||
pub ring_all_devices: bool,
|
||||
pub ring_browsers: bool,
|
||||
pub voicemail_box: Option<String>,
|
||||
pub fax_box: Option<String>,
|
||||
pub ivr_menu_id: Option<String>,
|
||||
pub no_answer_timeout: Option<u32>,
|
||||
}
|
||||
@@ -525,6 +540,7 @@ impl AppConfig {
|
||||
ring_all_devices: explicit_targets.is_none(),
|
||||
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
||||
voicemail_box: route.action.voicemail_box.clone(),
|
||||
fax_box: route.action.fax_box.clone(),
|
||||
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
||||
no_answer_timeout: route.action.no_answer_timeout,
|
||||
});
|
||||
@@ -574,6 +590,7 @@ mod tests {
|
||||
extension: "100".to_string(),
|
||||
}],
|
||||
routing: RoutingConfig { routes },
|
||||
faxboxes: vec![],
|
||||
voiceboxes: vec![],
|
||||
ivr: None,
|
||||
}
|
||||
@@ -620,6 +637,7 @@ mod tests {
|
||||
targets: Some(vec!["desk".to_string()]),
|
||||
ring_browsers: Some(true),
|
||||
voicemail_box: None,
|
||||
fax_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
provider: None,
|
||||
@@ -644,6 +662,7 @@ mod tests {
|
||||
targets: None,
|
||||
ring_browsers: Some(false),
|
||||
voicemail_box: Some("support-box".to_string()),
|
||||
fax_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: Some(20),
|
||||
provider: None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use crate::mixer::RtpPacket;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
/// Channel pair for connecting a leg to the mixer.
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ mod audio_player;
|
||||
mod call;
|
||||
mod call_manager;
|
||||
mod config;
|
||||
#[allow(dead_code)]
|
||||
mod fax_engine;
|
||||
mod ipc;
|
||||
mod jitter_buffer;
|
||||
mod leg_io;
|
||||
@@ -139,6 +141,7 @@ async fn handle_command(
|
||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||
"hangup" => handle_hangup(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,
|
||||
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
||||
// 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(),
|
||||
®istered_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.
|
||||
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()) {
|
||||
@@ -738,6 +897,8 @@ async fn handle_webrtc_link(
|
||||
kind: crate::call::LegKind::WebRtc,
|
||||
state: crate::call::LegState::Connected,
|
||||
codec_pt: codec_lib::PT_OPUS,
|
||||
media_protocol: "webrtc",
|
||||
media_io_active: true,
|
||||
sip_leg: None,
|
||||
sip_call_id: None,
|
||||
webrtc_session_id: Some(session_id.clone()),
|
||||
@@ -762,6 +923,7 @@ async fn handle_webrtc_link(
|
||||
"state": "connected",
|
||||
"codec": "Opus",
|
||||
"rtpPort": 0,
|
||||
"mediaProtocol": "webrtc",
|
||||
"remoteMedia": null,
|
||||
"metadata": {},
|
||||
}),
|
||||
@@ -1462,6 +1624,8 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
|
||||
kind: crate::call::LegKind::Tool,
|
||||
state: crate::call::LegState::Connected,
|
||||
codec_pt: 0,
|
||||
media_protocol: "internal",
|
||||
media_io_active: true,
|
||||
sip_leg: None,
|
||||
sip_call_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",
|
||||
"codec": null,
|
||||
"rtpPort": 0,
|
||||
"mediaProtocol": "internal",
|
||||
"remoteMedia": null,
|
||||
"metadata": { "tool_type": tool_type_str },
|
||||
}),
|
||||
|
||||
@@ -108,6 +108,24 @@ impl SipLeg {
|
||||
..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(
|
||||
"INVITE",
|
||||
to_uri,
|
||||
@@ -401,6 +419,10 @@ impl SipLeg {
|
||||
return SipLegAction::Send(ok.serialize());
|
||||
}
|
||||
|
||||
if method == "INVITE" || method == "UPDATE" {
|
||||
return SipLegAction::InDialogRequest(method.to_string());
|
||||
}
|
||||
|
||||
SipLegAction::None
|
||||
}
|
||||
|
||||
@@ -436,6 +458,9 @@ pub enum SipLegAction {
|
||||
StateChange(LegState),
|
||||
/// Connected — send this ACK.
|
||||
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(String),
|
||||
/// Send 200 OK and terminate.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
use md5::{Digest, Md5};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{Endpoint, SdpMediaKind};
|
||||
|
||||
// ---- ID generators ---------------------------------------------------------
|
||||
|
||||
/// Generate a random SIP Call-ID (32 hex chars).
|
||||
@@ -55,6 +57,9 @@ pub struct SdpOptions<'a> {
|
||||
pub ip: &'a str,
|
||||
pub port: u16,
|
||||
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_name: Option<&'a str>,
|
||||
pub direction: Option<&'a str>,
|
||||
@@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> {
|
||||
ip: "0.0.0.0",
|
||||
port: 0,
|
||||
payload_types: &[9, 0, 8, 101],
|
||||
media_kind: SdpMediaKind::Audio,
|
||||
transport: "RTP/AVP",
|
||||
media_formats: &[],
|
||||
session_id: None,
|
||||
session_name: 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)));
|
||||
let session_name = opts.session_name.unwrap_or("-");
|
||||
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![
|
||||
"v=0".to_string(),
|
||||
@@ -91,9 +106,16 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
format!("s={session_name}"),
|
||||
format!("c=IN IP4 {}", opts.ip),
|
||||
"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 {
|
||||
let name = codec_name(pt);
|
||||
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(format!("a={direction}"));
|
||||
for attr in opts.attributes {
|
||||
@@ -199,38 +222,62 @@ pub fn compute_digest_auth(
|
||||
|
||||
// ---- SDP parser ------------------------------------------------------------
|
||||
|
||||
use crate::Endpoint;
|
||||
|
||||
/// Parse the audio media port, connection address, and preferred codec from an SDP body.
|
||||
/// Parse the preferred media endpoint 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> {
|
||||
let mut addr: Option<&str> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
let mut codec_pt: Option<u8> = None;
|
||||
let mut preferred: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||
let mut fallback: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||
|
||||
let normalized = sdp.replace("\r\n", "\n");
|
||||
for raw in normalized.split('\n') {
|
||||
let line = raw.trim();
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
addr = Some(rest.trim());
|
||||
} else if let Some(rest) = line.strip_prefix("m=audio ") {
|
||||
// m=audio <port> RTP/AVP <pt1> [<pt2> ...]
|
||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||
if !parts.is_empty() {
|
||||
port = parts[0].parse().ok();
|
||||
} else if let Some(rest) = line.strip_prefix("m=") {
|
||||
// m=<media> <port> <transport> <fmt1> [<fmt2> ...]
|
||||
let mut media_and_rest = rest.splitn(2, ' ');
|
||||
let media = media_and_rest.next().unwrap_or("");
|
||||
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) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
match (addr, preferred.or(fallback)) {
|
||||
(Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
|
||||
address: a.to_string(),
|
||||
port: p,
|
||||
port,
|
||||
codec_pt,
|
||||
media_kind,
|
||||
transport,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -327,6 +374,40 @@ mod tests {
|
||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
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]
|
||||
|
||||
@@ -16,4 +16,47 @@ pub struct Endpoint {
|
||||
pub port: u16,
|
||||
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 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`.
|
||||
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
|
||||
}
|
||||
|
||||
/// Rewrites the connection address (`c=`) and audio media port (`m=audio`)
|
||||
/// in an SDP body. Returns the rewritten body together with the original
|
||||
/// endpoint that was replaced (if any).
|
||||
/// Rewrites the connection address (`c=`) and first supported media port
|
||||
/// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the
|
||||
/// 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>) {
|
||||
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
|
||||
.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 ") {
|
||||
orig_addr = Some(rest.trim().to_string());
|
||||
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();
|
||||
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();
|
||||
rebuilt.push(' ');
|
||||
rebuilt.push_str(&port.to_string());
|
||||
@@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
||||
})
|
||||
.collect();
|
||||
|
||||
let original = match (orig_addr, orig_port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
let original = match (orig_addr, orig_media) {
|
||||
(Some(a), Some((media_kind, p, transport))) => Some(Endpoint {
|
||||
address: a,
|
||||
port: p,
|
||||
codec_pt: None,
|
||||
media_kind,
|
||||
transport,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
@@ -130,5 +147,19 @@ mod tests {
|
||||
let ep = orig.unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.25.2',
|
||||
version: '1.26.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IFaxBoxConfig } from './faxbox.ts';
|
||||
import type { IVoiceboxConfig } from './voicebox.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,6 +114,9 @@ export interface ISipRouteAction {
|
||||
/** Voicemail fallback for matched inbound routes. */
|
||||
voicemailBox?: string;
|
||||
|
||||
/** Fax inbox target for matched inbound routes. */
|
||||
faxBox?: string;
|
||||
|
||||
/** Route to an IVR menu by menu ID (skip ringing devices). */
|
||||
ivrMenuId?: string;
|
||||
|
||||
@@ -189,6 +193,7 @@ export interface IContact {
|
||||
// "number | undefined is not assignable to number" type errors when
|
||||
// passing config.voiceboxes into VoiceboxManager.init().
|
||||
export type { IVoiceboxConfig };
|
||||
export type { IFaxBoxConfig };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR configuration
|
||||
@@ -255,6 +260,7 @@ export interface IAppConfig {
|
||||
incomingNumbers?: IIncomingNumberConfig[];
|
||||
routing: IRoutingConfig;
|
||||
contacts: IContact[];
|
||||
faxboxes?: IFaxBoxConfig[];
|
||||
voiceboxes?: IVoiceboxConfig[];
|
||||
ivr?: IIvrConfig;
|
||||
}
|
||||
@@ -323,6 +329,12 @@ export function loadConfig(): IAppConfig {
|
||||
c.starred ??= false;
|
||||
}
|
||||
|
||||
cfg.faxboxes ??= [];
|
||||
for (const fb of cfg.faxboxes) {
|
||||
fb.enabled ??= true;
|
||||
fb.maxMessages ??= 50;
|
||||
}
|
||||
|
||||
// Voicebox defaults.
|
||||
cfg.voiceboxes ??= [];
|
||||
for (const vb of cfg.voiceboxes) {
|
||||
|
||||
+149
@@ -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
@@ -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
@@ -11,6 +11,8 @@ import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import type { FaxBoxManager } from './faxbox.ts';
|
||||
import type { FaxJobManager } from './faxjobs.ts';
|
||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||
import type { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
@@ -22,6 +24,8 @@ interface IHandleRequestContext {
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
|
||||
onHangupCall: (callId: string) => boolean;
|
||||
onConfigSaved?: () => void | Promise<void>;
|
||||
faxBoxManager?: FaxBoxManager;
|
||||
faxJobManager?: FaxJobManager;
|
||||
voiceboxManager?: VoiceboxManager;
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ async function handleRequest(
|
||||
res: http.ServerResponse,
|
||||
context: IHandleRequestContext,
|
||||
): 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 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).
|
||||
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
|
||||
try {
|
||||
@@ -273,6 +336,7 @@ async function handleRequest(
|
||||
}
|
||||
}
|
||||
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.ivr !== undefined) cfg.ivr = updates.ivr;
|
||||
|
||||
@@ -368,6 +432,8 @@ export function initWebUi(
|
||||
onStartCall,
|
||||
onHangupCall,
|
||||
onConfigSaved,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
onWebRtcOffer,
|
||||
onWebRtcIce,
|
||||
@@ -387,12 +453,12 @@ export function initWebUi(
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
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;
|
||||
} catch {
|
||||
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-')) {
|
||||
msg._remoteIp = remoteIp;
|
||||
handleWebRtcSignaling(socket, msg);
|
||||
if (msg.type) {
|
||||
handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
@@ -17,6 +17,9 @@ export type {
|
||||
ICallEndedEvent,
|
||||
ICallRingingEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
IFaxCompletedEvent,
|
||||
IFaxFailedEvent,
|
||||
IFaxStartedEvent,
|
||||
IIncomingCallEvent,
|
||||
ILegAddedEvent,
|
||||
ILegRemovedEvent,
|
||||
@@ -52,6 +55,10 @@ type TProxyCommands = {
|
||||
params: { number: string; device_id?: string; provider_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: {
|
||||
params: { call_id: string; number: string; provider_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.
|
||||
*/
|
||||
|
||||
@@ -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 { StatusStore } from './status-store.ts';
|
||||
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
|
||||
@@ -6,6 +8,8 @@ import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'
|
||||
export interface IRegisterProxyEventHandlersOptions {
|
||||
log: (msg: string) => void;
|
||||
statusStore: StatusStore;
|
||||
faxBoxManager: FaxBoxManager;
|
||||
faxJobManager: FaxJobManager;
|
||||
voiceboxManager: VoiceboxManager;
|
||||
webRtcLinks: WebRtcLinkManager;
|
||||
getBrowserDeviceIds: () => string[];
|
||||
@@ -19,6 +23,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
const {
|
||||
log,
|
||||
statusStore,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
webRtcLinks,
|
||||
getBrowserDeviceIds,
|
||||
@@ -30,6 +36,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
|
||||
const legMediaDetails = (data: {
|
||||
codec?: string | null;
|
||||
mediaProtocol?: string | null;
|
||||
remoteMedia?: string | null;
|
||||
rtpPort?: number | null;
|
||||
}): string => {
|
||||
@@ -37,6 +44,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
if (data.codec) {
|
||||
parts.push(`codec=${data.codec}`);
|
||||
}
|
||||
if (data.mediaProtocol) {
|
||||
parts.push(`media=${data.mediaProtocol}`);
|
||||
}
|
||||
if (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}`);
|
||||
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()) {
|
||||
sendToBrowserDevice(deviceId, {
|
||||
type: 'webrtc-incoming',
|
||||
@@ -110,6 +128,10 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
log(`[call] ${data.call_id} connected`);
|
||||
}
|
||||
|
||||
if (data.media_protocol && data.media_protocol !== 'rtp') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.provider_media_addr || !data.provider_media_port) {
|
||||
return;
|
||||
}
|
||||
@@ -207,4 +229,37 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
||||
onProxyEvent('voicemail_error', (data) => {
|
||||
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
@@ -88,16 +88,12 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
|
||||
this.activeCalls.set(callId, {
|
||||
id: callId,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: number,
|
||||
providerUsed: providerId || null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
const call = this.getOrCreateCall(callId, 'outbound');
|
||||
call.direction = 'outbound';
|
||||
call.callerNumber = null;
|
||||
call.calleeNumber = number;
|
||||
call.providerUsed = providerId || null;
|
||||
call.state = 'setting-up';
|
||||
}
|
||||
|
||||
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
|
||||
@@ -126,57 +122,40 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteIncomingCall(data: IIncomingCallEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'inbound',
|
||||
callerNumber: data.from_uri,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: data.provider_id,
|
||||
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) {
|
||||
const call = this.getOrCreateCall(data.call_id, 'inbound');
|
||||
call.direction = 'inbound';
|
||||
call.callerNumber = data.from_uri;
|
||||
call.calleeNumber = data.to_number;
|
||||
call.providerUsed = data.provider_id;
|
||||
if (call.state === 'setting-up') {
|
||||
call.state = 'ringing';
|
||||
}
|
||||
}
|
||||
|
||||
noteCallAnswered(data: ICallAnsweredEvent): boolean {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return false;
|
||||
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
|
||||
const call = this.getOrCreateCall(data.call_id, 'outbound');
|
||||
call.direction = 'outbound';
|
||||
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';
|
||||
|
||||
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}`;
|
||||
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}`;
|
||||
}
|
||||
break;
|
||||
@@ -216,6 +200,7 @@ export class StatusStore {
|
||||
state: leg.state,
|
||||
codec: leg.codec,
|
||||
rtpPort: leg.rtpPort,
|
||||
mediaProtocol: leg.mediaProtocol,
|
||||
remoteMedia: leg.remoteMedia,
|
||||
metadata: leg.metadata || {},
|
||||
})),
|
||||
@@ -230,10 +215,7 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteLegAdded(data: ILegAddedEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
const call = this.getOrCreateCall(data.call_id);
|
||||
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
@@ -241,6 +223,7 @@ export class StatusStore {
|
||||
state: data.state,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
mediaProtocol: data.mediaProtocol ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
@@ -251,10 +234,7 @@ export class StatusStore {
|
||||
}
|
||||
|
||||
noteLegStateChanged(data: ILegStateChangedEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
const call = this.getOrCreateCall(data.call_id);
|
||||
|
||||
const existingLeg = call.legs.get(data.leg_id);
|
||||
if (existingLeg) {
|
||||
@@ -265,6 +245,9 @@ export class StatusStore {
|
||||
if (data.rtpPort !== undefined) {
|
||||
existingLeg.rtpPort = data.rtpPort;
|
||||
}
|
||||
if (data.mediaProtocol !== undefined) {
|
||||
existingLeg.mediaProtocol = data.mediaProtocol;
|
||||
}
|
||||
if (data.remoteMedia !== undefined) {
|
||||
existingLeg.remoteMedia = data.remoteMedia;
|
||||
}
|
||||
@@ -280,6 +263,7 @@ export class StatusStore {
|
||||
state: data.state,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
mediaProtocol: data.mediaProtocol ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
@@ -323,4 +307,22 @@ export class StatusStore {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface IOutboundCallStartedEvent {
|
||||
call_id: string;
|
||||
number: string;
|
||||
provider_id: string;
|
||||
ring_browsers?: boolean;
|
||||
}
|
||||
|
||||
export interface ICallRingingEvent {
|
||||
@@ -28,6 +29,7 @@ export interface ICallAnsweredEvent {
|
||||
call_id: string;
|
||||
provider_media_addr?: string;
|
||||
provider_media_port?: number;
|
||||
media_protocol?: string;
|
||||
sip_pt?: number;
|
||||
}
|
||||
|
||||
@@ -67,6 +69,7 @@ export interface ILegAddedEvent {
|
||||
state: string;
|
||||
codec?: string | null;
|
||||
rtpPort?: number | null;
|
||||
mediaProtocol?: string | null;
|
||||
remoteMedia?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -82,6 +85,7 @@ export interface ILegStateChangedEvent {
|
||||
state: string;
|
||||
codec?: string | null;
|
||||
rtpPort?: number | null;
|
||||
mediaProtocol?: string | null;
|
||||
remoteMedia?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -128,6 +132,56 @@ export interface IVoicemailErrorEvent {
|
||||
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 = {
|
||||
provider_registered: IProviderRegisteredEvent;
|
||||
device_registered: IDeviceRegisteredEvent;
|
||||
@@ -148,4 +202,7 @@ export type TProxyEventMap = {
|
||||
voicemail_started: IVoicemailStartedEvent;
|
||||
recording_done: IRecordingDoneEvent;
|
||||
voicemail_error: IVoicemailErrorEvent;
|
||||
fax_started: IFaxStartedEvent;
|
||||
fax_completed: IFaxCompletedEvent;
|
||||
fax_failed: IFaxFailedEvent;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface IActiveLeg {
|
||||
state: string;
|
||||
codec: string | null;
|
||||
rtpPort: number | null;
|
||||
mediaProtocol: string | null;
|
||||
remoteMedia: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
+13
-1
@@ -9,6 +9,8 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadConfig, type IAppConfig } from './config.ts';
|
||||
import { FaxBoxManager } from './faxbox.ts';
|
||||
import { FaxJobManager } from './faxjobs.ts';
|
||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.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 webRtcLinks = new WebRtcLinkManager();
|
||||
const faxBoxManager = new FaxBoxManager(log);
|
||||
const faxJobManager = new FaxJobManager(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
|
||||
faxBoxManager.init(appConfig.faxboxes ?? []);
|
||||
faxJobManager.init();
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
initWebRtcSignaling({ log });
|
||||
|
||||
@@ -61,6 +67,7 @@ function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
|
||||
providers: config.providers,
|
||||
devices: config.devices,
|
||||
routing: config.routing,
|
||||
faxboxes: config.faxboxes ?? [],
|
||||
voiceboxes: config.voiceboxes ?? [],
|
||||
ivr: config.ivr,
|
||||
};
|
||||
@@ -93,6 +100,7 @@ async function reloadConfig(): Promise<void> {
|
||||
|
||||
appConfig = nextConfig;
|
||||
statusStore.updateConfig(nextConfig);
|
||||
faxBoxManager.init(nextConfig.faxboxes ?? []);
|
||||
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||
|
||||
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
||||
@@ -123,6 +131,8 @@ async function startProxyEngine(): Promise<void> {
|
||||
registerProxyEventHandlers({
|
||||
log,
|
||||
statusStore,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
webRtcLinks,
|
||||
getBrowserDeviceIds: getAllBrowserDeviceIds,
|
||||
@@ -167,6 +177,8 @@ initWebUi({
|
||||
return true;
|
||||
},
|
||||
onConfigSaved: reloadConfig,
|
||||
faxBoxManager,
|
||||
faxJobManager,
|
||||
voiceboxManager,
|
||||
onWebRtcOffer: async (sessionId, sdp, ws) => {
|
||||
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');
|
||||
},
|
||||
onWebRtcIce: async (sessionId, candidate) => {
|
||||
await webrtcIce(sessionId, candidate);
|
||||
await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
|
||||
},
|
||||
onWebRtcClose: async (sessionId) => {
|
||||
webRtcLinks.removeSession(sessionId);
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.25.2',
|
||||
version: '1.26.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -175,36 +175,240 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
|
||||
.call-body {
|
||||
padding: 12px 16px 16px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 12px;
|
||||
.call-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.legs-table th {
|
||||
text-align: left;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 0.65rem;
|
||||
.call-route-card,
|
||||
.call-facts-card,
|
||||
.legs-section {
|
||||
border-radius: 14px;
|
||||
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;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dees-color-border-default, #334155);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legs-table td {
|
||||
padding: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5));
|
||||
vertical-align: middle;
|
||||
.route-line {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
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;
|
||||
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 {
|
||||
@@ -247,6 +451,34 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
.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-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 class="call-id">${call.id}</div>
|
||||
<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
|
||||
? html`
|
||||
<table class="legs-table">
|
||||
<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>
|
||||
<div class="legs-grid">
|
||||
${call.legs.map(
|
||||
(leg) => html`
|
||||
<tr>
|
||||
<td>
|
||||
<div class="leg-card">
|
||||
<div class="leg-card-top">
|
||||
<div class="leg-card-badges">
|
||||
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
||||
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
||||
${leg.state}
|
||||
${STATE_LABELS[leg.state] || leg.state}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${leg.remoteMedia || '--'}
|
||||
</td>
|
||||
<td>${leg.rtpPort ?? '--'}</td>
|
||||
<td>
|
||||
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
||||
</td>
|
||||
<td>${leg.pktReceived}</td>
|
||||
<td>${leg.pktSent}</td>
|
||||
<td>
|
||||
</div>
|
||||
<div class="leg-card-id">${leg.id}</div>
|
||||
</div>
|
||||
|
||||
<div class="leg-facts">
|
||||
<div class="leg-fact">
|
||||
<span class="leg-fact-label">Codec</span>
|
||||
<span class="leg-fact-value">${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}</span>
|
||||
</div>
|
||||
<div class="leg-fact">
|
||||
<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
|
||||
class="btn btn-remove"
|
||||
@click=${() => this.handleRemoveLeg(call, leg)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
||||
No legs
|
||||
</div>`}
|
||||
: html`<div class="no-legs">No legs reported yet. SIP/system legs should appear here as soon as the call is wired.</div>`}
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
||||
|
||||
Reference in New Issue
Block a user