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
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+161
-23
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
®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.
|
/// 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 },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,16 +106,24 @@ 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(" ")
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for &pt in opts.payload_types {
|
if opts.media_kind == SdpMediaKind::Audio {
|
||||||
let name = codec_name(pt);
|
for &pt in opts.payload_types {
|
||||||
if name != "unknown" {
|
let name = codec_name(pt);
|
||||||
lines.push(format!("a=rtpmap:{pt} {name}"));
|
if name != "unknown" {
|
||||||
}
|
lines.push(format!("a=rtpmap:{pt} {name}"));
|
||||||
if pt == 101 {
|
}
|
||||||
lines.push("a=fmtp:101 0-16".to_string());
|
if pt == 101 {
|
||||||
|
lines.push("a=fmtp:101 0-16".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
// parts[1] is "RTP/AVP" or similar, parts[2..] are payload types.
|
|
||||||
// The first PT is the preferred codec.
|
let parts: Vec<&str> = remainder.split_whitespace().collect();
|
||||||
if parts.len() > 2 {
|
if !parts.is_empty() {
|
||||||
codec_pt = parts[2].parse::<u8>().ok();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 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 */ }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
@@ -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,56 +122,39 @@ 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
noteCallAnswered(data: ICallAnsweredEvent): boolean {
|
||||||
const call = this.activeCalls.get(data.call_id);
|
const call = this.getOrCreateCall(data.call_id);
|
||||||
if (!call) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
call.state = 'connected';
|
call.state = 'connected';
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
${call.legs.length
|
<div class="call-overview">
|
||||||
? html`
|
<div class="call-route-card">
|
||||||
<table class="legs-table">
|
<div class="section-kicker">Call Route</div>
|
||||||
<thead>
|
<div class="route-line">
|
||||||
<tr>
|
<div class="route-party">
|
||||||
<th>Type</th>
|
<div class="route-party-label">From</div>
|
||||||
<th>State</th>
|
<div class="route-party-value">${call.callerNumber || 'Unknown caller'}</div>
|
||||||
<th>Remote</th>
|
</div>
|
||||||
<th>Port</th>
|
<div class="route-arrow">${directionIcon(call.direction)}</div>
|
||||||
<th>Codec</th>
|
<div class="route-party align-end">
|
||||||
<th>Pkts In</th>
|
<div class="route-party-label">To</div>
|
||||||
<th>Pkts Out</th>
|
<div class="route-party-value">${call.calleeNumber || 'System'}</div>
|
||||||
<th></th>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="call-tags">
|
||||||
<tbody>
|
<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`
|
||||||
|
<div class="legs-grid">
|
||||||
${call.legs.map(
|
${call.legs.map(
|
||||||
(leg) => html`
|
(leg) => html`
|
||||||
<tr>
|
<div class="leg-card">
|
||||||
<td>
|
<div class="leg-card-top">
|
||||||
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
<div class="leg-card-badges">
|
||||||
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
||||||
</span>
|
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
||||||
</td>
|
</span>
|
||||||
<td>
|
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
||||||
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
${STATE_LABELS[leg.state] || leg.state}
|
||||||
${leg.state}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</td>
|
<div class="leg-card-id">${leg.id}</div>
|
||||||
<td>
|
</div>
|
||||||
${leg.remoteMedia || '--'}
|
|
||||||
</td>
|
<div class="leg-facts">
|
||||||
<td>${leg.rtpPort ?? '--'}</td>
|
<div class="leg-fact">
|
||||||
<td>
|
<span class="leg-fact-label">Codec</span>
|
||||||
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
<span class="leg-fact-value">${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}</span>
|
||||||
</td>
|
</div>
|
||||||
<td>${leg.pktReceived}</td>
|
<div class="leg-fact">
|
||||||
<td>${leg.pktSent}</td>
|
<span class="leg-fact-label">RTP Port</span>
|
||||||
<td>
|
<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 class="no-legs">No legs reported yet. SIP/system legs should appear here as soon as the call is wired.</div>`}
|
||||||
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
</div>
|
||||||
No legs
|
|
||||||
</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)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user