From d2c18a4ebbf6161d06e21acf091b8eaa5fa75058 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 20:43:42 +0000 Subject: [PATCH] feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support --- changelog.md | 9 + rust/.cargo/config.toml | 3 + rust/Cargo.lock | 184 ++- rust/crates/proxy-engine/Cargo.toml | 2 + rust/crates/proxy-engine/src/call.rs | 53 +- rust/crates/proxy-engine/src/call_manager.rs | 1525 +++++++++++++++++- rust/crates/proxy-engine/src/config.rs | 19 + rust/crates/proxy-engine/src/fax_engine.rs | 1176 ++++++++++++++ rust/crates/proxy-engine/src/leg_io.rs | 55 +- rust/crates/proxy-engine/src/main.rs | 165 ++ rust/crates/proxy-engine/src/sip_leg.rs | 25 + rust/crates/sip-proto/src/helpers.rs | 133 +- rust/crates/sip-proto/src/lib.rs | 43 + rust/crates/sip-proto/src/rewrite.rs | 49 +- ts/00_commitinfo_data.ts | 2 +- ts/config.ts | 12 + ts/faxbox.ts | 149 ++ ts/faxjobs.ts | 153 ++ ts/frontend.ts | 76 +- ts/proxybridge.ts | 22 + ts/runtime/proxy-events.ts | 57 +- ts/runtime/status-store.ts | 130 +- ts/shared/proxy-events.ts | 57 + ts/shared/status.ts | 1 + ts/sipproxy.ts | 14 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/sipproxy-view-calls.ts | 411 ++++- 27 files changed, 4247 insertions(+), 280 deletions(-) create mode 100644 rust/crates/proxy-engine/src/fax_engine.rs create mode 100644 ts/faxbox.ts create mode 100644 ts/faxjobs.ts diff --git a/changelog.md b/changelog.md index 0670061..cb7f8a5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-20 - 1.26.0 - feat(fax) +add fax routing, job tracking, inbox management, and T.38/UDPTL media support + +- adds outbound fax origination through the proxy engine with provider codec validation and a new send_fax command +- introduces fax box configuration, inbox storage, and dashboard/API endpoints for listing, downloading, and deleting received fax messages +- tracks fax lifecycle events and persisted fax jobs in the runtime layer +- extends SIP SDP parsing and rewriting to support non-audio media, including T.38 over UDPTL +- records leg media protocol details and bridge state to distinguish RTP, WebRTC, internal, and fax media paths + ## 2026-04-14 - 1.25.2 - fix(proxy-engine) improve inbound SIP routing diagnostics and enrich leg media state reporting diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml index ff42eeb..98ef1f0 100644 --- a/rust/.cargo/config.toml +++ b/rust/.cargo/config.toml @@ -28,3 +28,6 @@ rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"] CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc" CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++" AR_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-ar" +PKG_CONFIG_ALLOW_CROSS = "1" +PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu = "/" +PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu = "/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 54d63fc..176f121 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -165,7 +165,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 1.0.69", "time", ] @@ -181,7 +181,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 1.0.69", "time", ] @@ -327,6 +327,26 @@ dependencies = [ "virtue", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -429,6 +449,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "0.1.10" @@ -498,6 +527,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "3.2.25" @@ -538,7 +578,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02" dependencies = [ - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1021,6 +1061,12 @@ dependencies = [ "signature", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.12.3" @@ -1367,6 +1413,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.12.1" @@ -1668,7 +1720,7 @@ dependencies = [ "rand 0.8.5", "rtcp", "rtp", - "thiserror", + "thiserror 1.0.69", "tokio", "waitgroup", "webrtc-srtp", @@ -1681,6 +1733,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1792,6 +1853,16 @@ dependencies = [ "rle-decode-fast", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if 1.0.4", + "windows-link", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2386,7 +2457,9 @@ dependencies = [ "serde", "serde_json", "sip-proto", + "spandsp", "tokio", + "udptl", "webrtc", ] @@ -2584,7 +2657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd" dependencies = [ "bytes", - "thiserror", + "thiserror 1.0.69", "webrtc-util", ] @@ -2594,7 +2667,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5248489db464de29835170cd1f6e19933146b0016789effc59cb53d9f13844" dependencies = [ - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2606,7 +2679,7 @@ dependencies = [ "bytes", "rand 0.8.5", "serde", - "thiserror", + "thiserror 1.0.69", "webrtc-util", ] @@ -2617,7 +2690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bb90df8268abfe08452ef2dae9e867a54edfdaa71b3127ef47d8b031f77ac73" dependencies = [ "smallvec", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2744,7 +2817,7 @@ checksum = "4d22a5ef407871893fd72b4562ee15e4742269b173959db4b8df6f538c414e13" dependencies = [ "rand 0.8.5", "substring", - "thiserror", + "thiserror 1.0.69", "url", ] @@ -2957,6 +3030,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "spandsp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f076b6e56f1a1062d6950dcd1c6c1df281ae2828db271929c50c191ec8c79e" +dependencies = [ + "bitflags 2.11.0", + "spandsp-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "spandsp-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05ab99051230293dded61ba3cd32f06eb15b437a8135be21f560f72bab713db" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + [[package]] name = "spin" version = "0.5.2" @@ -3004,7 +3099,7 @@ dependencies = [ "rand 0.8.5", "ring", "subtle", - "thiserror", + "thiserror 1.0.69", "tokio", "url", "webrtc-util", @@ -3104,7 +3199,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -3118,6 +3222,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "time" version = "0.3.47" @@ -3194,9 +3309,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3230,7 +3357,7 @@ dependencies = [ "rand 0.8.5", "ring", "stun", - "thiserror", + "thiserror 1.0.69", "tokio", "webrtc-util", ] @@ -3241,6 +3368,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "udptl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b255ad0ff36582a8a453c42a2bcc16c72d00f0ab16a14a4a7aeacb55ccb2a351" +dependencies = [ + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3532,7 +3670,7 @@ dependencies = [ "sha2", "smol_str", "stun", - "thiserror", + "thiserror 1.0.69", "time", "tokio", "turn", @@ -3557,7 +3695,7 @@ dependencies = [ "bytes", "derive_builder", "log", - "thiserror", + "thiserror 1.0.69", "tokio", "webrtc-sctp", "webrtc-util", @@ -3595,7 +3733,7 @@ dependencies = [ "sha2", "signature", "subtle", - "thiserror", + "thiserror 1.0.69", "tokio", "webpki", "webrtc-util", @@ -3617,7 +3755,7 @@ dependencies = [ "serde", "serde_json", "stun", - "thiserror", + "thiserror 1.0.69", "tokio", "turn", "url", @@ -3635,7 +3773,7 @@ checksum = "f08dfd7a6e3987e255c4dbe710dde5d94d0f0574f8a21afa95d171376c143106" dependencies = [ "log", "socket2 0.4.10", - "thiserror", + "thiserror 1.0.69", "tokio", "webrtc-util", ] @@ -3650,7 +3788,7 @@ dependencies = [ "bytes", "rand 0.8.5", "rtp", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3665,7 +3803,7 @@ dependencies = [ "crc", "log", "rand 0.8.5", - "thiserror", + "thiserror 1.0.69", "tokio", "webrtc-util", ] @@ -3688,7 +3826,7 @@ dependencies = [ "rtp", "sha1", "subtle", - "thiserror", + "thiserror 1.0.69", "tokio", "webrtc-util", ] @@ -3709,7 +3847,7 @@ dependencies = [ "log", "nix", "rand 0.8.5", - "thiserror", + "thiserror 1.0.69", "tokio", "winapi", ] @@ -3880,7 +4018,7 @@ dependencies = [ "nom", "oid-registry 0.4.0", "rusticata-macros", - "thiserror", + "thiserror 1.0.69", "time", ] @@ -3899,7 +4037,7 @@ dependencies = [ "oid-registry 0.6.1", "ring", "rusticata-macros", - "thiserror", + "thiserror 1.0.69", "time", ] diff --git a/rust/crates/proxy-engine/Cargo.toml b/rust/crates/proxy-engine/Cargo.toml index fce00d0..6a6230a 100644 --- a/rust/crates/proxy-engine/Cargo.toml +++ b/rust/crates/proxy-engine/Cargo.toml @@ -19,6 +19,8 @@ regex-lite = "0.1" webrtc = "0.8" rand = "0.8" hound = "3.5" +spandsp = "0.1.5" +udptl = "0.1.0" kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] } ort = { version = "=2.0.0-rc.11", default-features = false, features = [ "std", "download-binaries", "copy-dylibs", "ndarray", diff --git a/rust/crates/proxy-engine/src/call.rs b/rust/crates/proxy-engine/src/call.rs index 292c052..e00b0ab 100644 --- a/rust/crates/proxy-engine/src/call.rs +++ b/rust/crates/proxy-engine/src/call.rs @@ -11,7 +11,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::Instant; use tokio::net::UdpSocket; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; pub type LegId = String; @@ -114,6 +114,13 @@ pub struct LegInfo { pub kind: LegKind, pub state: LegState, pub codec_pt: u8, + /// Media transport currently negotiated for this leg. + /// + /// `rtp` covers classic SIP audio media, `t38-udptl` covers T.38 fax, + /// `webrtc` is used for browser legs, and `internal` for proxy-local media/tool paths. + pub media_protocol: &'static str, + /// Whether this leg is currently wired into an active media bridge. + pub media_io_active: bool, /// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc). pub sip_leg: Option, @@ -146,6 +153,15 @@ pub struct LegInfo { pub metadata: HashMap, } +#[derive(Clone)] +pub struct PendingDialogBridge { + pub source_leg_id: LegId, + pub target_leg_id: LegId, + pub source_request: SipMessage, + pub target_request: SipMessage, + pub method: String, +} + /// A multiparty call with N legs and a central mixer. pub struct Call { // Duplicated from the HashMap key in CallManager. Kept for future @@ -169,12 +185,21 @@ pub struct Call { /// Used to construct proper 180/200/error responses back to the device. pub device_invite: Option, + /// Pending in-dialog B2BUA transaction bridged across two different SIP dialogs. + pub pending_dialog_bridge: Option, + /// All legs in this call, keyed by leg ID. pub legs: HashMap, /// Channel to send commands to the mixer task. pub mixer_cmd_tx: mpsc::Sender, + /// Active passthrough media bridge mode, if any. + pub media_bridge_mode: Option, + + /// Cancellation handles for non-mixer passthrough media tasks. + media_bridge_cancel_txs: Vec>, + /// Handle to the mixer task (aborted on call teardown). mixer_task: Option>, } @@ -196,8 +221,11 @@ impl Call { callee_number: None, provider_id, device_invite: None, + pending_dialog_bridge: None, legs: HashMap::new(), mixer_cmd_tx, + media_bridge_mode: None, + media_bridge_cancel_txs: Vec::new(), mixer_task: Some(mixer_task), } } @@ -235,8 +263,31 @@ impl Call { self.created_at.elapsed().as_secs() } + pub fn clear_media_bridge(&mut self) { + for cancel_tx in self.media_bridge_cancel_txs.drain(..) { + let _ = cancel_tx.send(true); + } + self.media_bridge_mode = None; + } + + pub fn install_media_bridge( + &mut self, + mode: &str, + cancel_txs: Vec>, + ) { + self.clear_media_bridge(); + self.media_bridge_mode = Some(mode.to_string()); + self.media_bridge_cancel_txs = cancel_txs; + } + + pub fn note_mixer_bridge(&mut self, mode: &str) { + self.clear_media_bridge(); + self.media_bridge_mode = Some(mode.to_string()); + } + /// Shut down the mixer and abort its task. pub async fn shutdown_mixer(&mut self) { + self.clear_media_bridge(); let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await; if let Some(handle) = self.mixer_task.take() { handle.abort(); diff --git a/rust/crates/proxy-engine/src/call_manager.rs b/rust/crates/proxy-engine/src/call_manager.rs index 0982a6a..7663225 100644 --- a/rust/crates/proxy-engine/src/call_manager.rs +++ b/rust/crates/proxy-engine/src/call_manager.rs @@ -4,15 +4,21 @@ //! Legs can be SIP (provider/device), WebRTC (browser), or Media (voicemail/IVR). //! The mixer provides mix-minus audio to all participants. -use crate::call::{Call, CallDirection, CallState, LegId, LegInfo, LegKind, LegState}; +use crate::call::{ + Call, CallDirection, CallState, LegId, LegInfo, LegKind, LegState, PendingDialogBridge, +}; use crate::config::{extract_inbound_called_number, normalize_routing_identity, AppConfig, ProviderConfig}; use crate::ipc::{emit_event, OutTx}; -use crate::leg_io::{create_leg_channels, spawn_sip_inbound, spawn_sip_outbound}; +use crate::leg_io::{ + create_leg_channels, spawn_raw_udp_inbound, spawn_raw_udp_outbound, spawn_sip_inbound, + spawn_sip_outbound, +}; use crate::mixer::spawn_mixer; use crate::registrar::Registrar; use crate::rtp::RtpPortPool; use crate::sip_leg::{SipLeg, SipLegAction, SipLegConfig}; use crate::tts::TtsEngine; +use sip_proto::dialog::{DialogState, SipDialog}; use sip_proto::helpers::{ build_sdp, generate_call_id, generate_tag, parse_sdp_endpoint, SdpOptions, }; @@ -23,7 +29,7 @@ use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; use tokio::net::UdpSocket; -use tokio::sync::Mutex; +use tokio::sync::{mpsc, watch, Mutex}; fn emit_inbound_diagnostic( out_tx: &OutTx, @@ -61,13 +67,162 @@ pub struct InboundCallCreated { /// Emit a `leg_added` event with full leg information. /// Free function (not a method) to avoid `&self` borrow conflicts when `self.calls` is borrowed. -fn codec_label(codec_pt: u8) -> String { - match codec_pt { +fn codec_label(leg: &LegInfo) -> String { + if leg.media_protocol == "t38-udptl" { + return "T.38".to_string(); + } + + match leg.codec_pt { 0 => "PCMU".to_string(), 8 => "PCMA".to_string(), 9 => "G.722".to_string(), 111 => "Opus".to_string(), - _ => format!("PT{codec_pt}"), + _ => format!("PT{}", leg.codec_pt), + } +} + +fn endpoint_media_protocol(endpoint: &sip_proto::Endpoint) -> &'static str { + if endpoint.is_t38_udptl() { + "t38-udptl" + } else { + "rtp" + } +} + +fn has_to_tag(msg: &SipMessage) -> bool { + msg.get_header("To") + .map(|to| to.to_ascii_lowercase().contains(";tag=")) + .unwrap_or(false) +} + +fn apply_sdp_to_leg(leg: &mut LegInfo, sdp: &str) { + if let Some(ep) = parse_sdp_endpoint(sdp) { + if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() { + leg.remote_media = Some(addr); + } + leg.media_protocol = endpoint_media_protocol(&ep); + if let Some(pt) = ep.codec_pt { + leg.codec_pt = pt; + } + } +} + +fn outbound_audio_fax_file(leg: &LegInfo) -> Option { + match leg.metadata.get("fax_mode").and_then(|value| value.as_str()) { + Some("outbound-audio") => leg + .metadata + .get("fax_file_path") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()), + _ => None, + } +} + +fn fax_file_path(leg: &LegInfo) -> Option { + leg.metadata + .get("fax_file_path") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) +} + +fn fax_box_id(leg: &LegInfo) -> Option { + leg.metadata + .get("fax_box_id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) +} + +fn fax_caller_number(leg: &LegInfo) -> Option { + leg.metadata + .get("caller_number") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) +} + +fn build_t38_sdp(ip: &str, port: u16) -> String { + build_sdp(&SdpOptions { + ip, + port, + media_kind: sip_proto::SdpMediaKind::Image, + transport: "udptl", + media_formats: &["t38"], + attributes: &[ + "T38FaxVersion:0", + "T38MaxBitRate:14400", + "T38FaxFillBitRemoval:0", + "T38FaxTranscodingMMR:0", + "T38FaxTranscodingJBIG:0", + "T38FaxRateManagement:transferredTCF", + "T38FaxMaxBuffer:2000", + "T38FaxMaxDatagram:400", + ], + ..Default::default() + }) +} + +fn response_reason_phrase(msg: &SipMessage) -> &str { + msg.start_line.splitn(3, ' ').nth(2).unwrap_or("Response") +} + +fn build_non_2xx_ack(original_invite: &SipMessage, response: &SipMessage) -> SipMessage { + let via = original_invite.get_header("Via").unwrap_or("").to_string(); + let from = original_invite.get_header("From").unwrap_or("").to_string(); + let to = response.get_header("To").unwrap_or("").to_string(); + let call_id = original_invite.call_id().to_string(); + let cseq_num: u32 = original_invite + .get_header("CSeq") + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + let ruri = original_invite.request_uri().unwrap_or("sip:unknown").to_string(); + + SipMessage::new( + format!("ACK {ruri} SIP/2.0"), + vec![ + ("Via".to_string(), via), + ("From".to_string(), from), + ("To".to_string(), to), + ("Call-ID".to_string(), call_id), + ("CSeq".to_string(), format!("{cseq_num} ACK")), + ("Max-Forwards".to_string(), "70".to_string()), + ("Content-Length".to_string(), "0".to_string()), + ], + String::new(), + ) +} + +fn device_dialog(call: &Call, device_leg_id: &str, config: &AppConfig) -> Option { + let device_invite = call.device_invite.as_ref()?; + let local_tag = call + .legs + .get(device_leg_id)? + .metadata + .get("dialog_local_tag")? + .as_str()?; + let mut dialog = SipDialog::from_uas_invite( + device_invite, + local_tag, + &config.proxy.lan_ip, + config.proxy.lan_port, + ); + dialog.state = DialogState::Confirmed; + dialog.local_cseq = call + .legs + .get(device_leg_id)? + .metadata + .get("dialog_local_cseq") + .and_then(|value| value.as_u64()) + .unwrap_or(0) as u32; + Some(dialog) +} + +fn select_audio_fax_codec(codecs: &[u8]) -> Option { + if codecs.contains(&codec_lib::PT_PCMU) { + Some(codec_lib::PT_PCMU) + } else if codecs.contains(&codec_lib::PT_PCMA) { + Some(codec_lib::PT_PCMA) + } else { + None } } @@ -92,8 +247,9 @@ fn leg_event_payload(call_id: &str, leg: &LegInfo) -> serde_json::Value { "leg_id": leg.id, "kind": leg.kind.as_str(), "state": leg.state.as_str(), - "codec": codec_label(leg.codec_pt), + "codec": codec_label(leg), "rtpPort": leg.rtp_port, + "mediaProtocol": leg.media_protocol, "remoteMedia": leg.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())), "metadata": leg_metadata_json(leg), }) @@ -186,7 +342,7 @@ impl CallManager { if is_b2bua_leg { return self - .route_b2bua_message(&call_id, &leg_id, msg, from_addr, socket) + .route_b2bua_message(&call_id, &leg_id, msg, from_addr, socket, config) .await; } @@ -207,6 +363,542 @@ impl CallManager { .await } + async fn handle_local_fax_offer( + &mut self, + call_id: &str, + leg_id: &str, + msg: &SipMessage, + socket: &UdpSocket, + config: &AppConfig, + ) -> bool { + let method = msg.method().unwrap_or(""); + if method != "INVITE" && method != "UPDATE" { + return false; + } + + let offer = match msg.has_sdp_body().then(|| parse_sdp_endpoint(&msg.body)).flatten() { + Some(offer) => offer, + None => { + let resp = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let target = self + .calls + .get(call_id) + .and_then(|call| call.legs.get(leg_id)) + .and_then(|leg| leg.signaling_addr); + if let Some(target) = target { + let _ = socket.send_to(&resp.serialize(), target).await; + } + return true; + } + }; + + let ( + rtp_socket, + rtp_port, + codec_pt, + public_ip, + current_media_protocol, + fax_file_path, + fax_box_id, + caller_number, + signaling_addr, + ) = match self.calls.get(call_id).and_then(|call| call.legs.get(leg_id)) { + Some(leg) => ( + leg.rtp_socket.clone(), + leg.rtp_port, + leg.codec_pt, + leg.public_ip.clone(), + leg.media_protocol.to_string(), + fax_file_path(leg), + fax_box_id(leg), + fax_caller_number(leg), + leg.signaling_addr, + ), + None => return false, + }; + if fax_file_path.is_none() && fax_box_id.is_none() { + return false; + } + let Some(target) = signaling_addr else { + return false; + }; + + let advertise_ip = public_ip.unwrap_or_else(|| config.proxy.lan_ip.clone()); + + if offer.is_t38_udptl() { + let response = SipMessage::create_response( + 200, + "OK", + msg, + Some(ResponseOptions { + contact: Some(format!("", config.proxy.lan_ip, config.proxy.lan_port)), + body: Some(build_t38_sdp(&advertise_ip, rtp_port)), + content_type: Some("application/sdp".to_string()), + ..Default::default() + }), + ); + let _ = socket.send_to(&response.serialize(), target).await; + + if let Some(rtp_socket) = rtp_socket { + let remote = match format!("{}:{}", offer.address, offer.port).parse() { + Ok(remote) => remote, + Err(_) => return true, + }; + let (cancel_tx, cancel_rx) = watch::channel(false); + + if let Some(file_path) = fax_file_path { + if let (Some(fax_box_id), Some(caller_number)) = (fax_box_id, caller_number) { + crate::fax_engine::spawn_inbound_t38_fax( + call_id.to_string(), + leg_id.to_string(), + fax_box_id, + caller_number, + file_path, + rtp_socket, + remote, + self.out_tx.clone(), + cancel_rx, + ); + } else { + crate::fax_engine::spawn_outbound_t38_fax( + call_id.to_string(), + leg_id.to_string(), + file_path, + rtp_socket, + remote, + self.out_tx.clone(), + cancel_rx, + ); + } + } + + if let Some(call) = self.calls.get_mut(call_id) { + if let Some(leg) = call.legs.get_mut(leg_id) { + leg.media_protocol = "t38-udptl"; + leg.remote_media = Some(remote); + leg.media_io_active = true; + } + call.install_media_bridge("fax-t38", vec![cancel_tx]); + } + if let Some(call) = self.calls.get(call_id) { + if let Some(leg) = call.legs.get(leg_id) { + emit_leg_state_changed_event(&self.out_tx, call_id, leg); + } + } + } + + return true; + } + + if offer.is_audio_rtp() { + if current_media_protocol == "t38-udptl" { + let resp = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let _ = socket.send_to(&resp.serialize(), target).await; + return true; + } + + let response = SipMessage::create_response( + 200, + "OK", + msg, + Some(ResponseOptions { + contact: Some(format!("", config.proxy.lan_ip, config.proxy.lan_port)), + body: Some(build_sdp(&SdpOptions { + ip: &advertise_ip, + port: rtp_port, + payload_types: &[codec_pt], + ..Default::default() + })), + content_type: Some("application/sdp".to_string()), + ..Default::default() + }), + ); + let _ = socket.send_to(&response.serialize(), target).await; + + if let Ok(remote) = format!("{}:{}", offer.address, offer.port).parse() { + if let Some(call) = self.calls.get_mut(call_id) { + if let Some(leg) = call.legs.get_mut(leg_id) { + leg.remote_media = Some(remote); + leg.media_protocol = "rtp"; + } + } + if let Some(call) = self.calls.get(call_id) { + if let Some(leg) = call.legs.get(leg_id) { + emit_leg_state_changed_event(&self.out_tx, call_id, leg); + } + } + } + return true; + } + + let resp = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let _ = socket.send_to(&resp.serialize(), target).await; + true + } + + async fn handle_device_bridge_offer( + &mut self, + call_id: &str, + source_leg_id: &str, + msg: &SipMessage, + socket: &UdpSocket, + config: &AppConfig, + ) -> bool { + let method = msg.method().unwrap_or(""); + if method != "INVITE" && method != "UPDATE" { + return false; + } + + let _offer = match msg.has_sdp_body().then(|| parse_sdp_endpoint(&msg.body)).flatten() { + Some(offer) => offer, + None => { + let not_acceptable = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let target = self + .calls + .get(call_id) + .and_then(|call| call.legs.get(source_leg_id)) + .and_then(|leg| leg.signaling_addr); + if let Some(target) = target { + let _ = socket.send_to(¬_acceptable.serialize(), target).await; + } + return true; + } + }; + + let (device_leg_id, device_addr, device_rtp_port, provider_target) = match self.calls.get(call_id) { + Some(call) => { + if call.pending_dialog_bridge.is_some() { + let pending = SipMessage::create_response(491, "Request Pending", msg, None); + if let Some(target) = call.legs.get(source_leg_id).and_then(|leg| leg.signaling_addr) { + let _ = socket.send_to(&pending.serialize(), target).await; + } + return true; + } + + let Some(device_leg) = call + .legs + .values() + .find(|leg| leg.id != source_leg_id && leg.kind == LegKind::SipDevice) + else { + let unsupported = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + if let Some(target) = call.legs.get(source_leg_id).and_then(|leg| leg.signaling_addr) { + let _ = socket.send_to(&unsupported.serialize(), target).await; + } + return true; + }; + + match ( + device_leg.id.clone(), + device_leg.signaling_addr, + device_leg.rtp_port, + call.legs.get(source_leg_id).and_then(|leg| leg.signaling_addr), + ) { + (device_leg_id, Some(device_addr), device_rtp_port, Some(provider_target)) => { + (device_leg_id, device_addr, device_rtp_port, provider_target) + } + _ => return false, + } + } + None => return false, + }; + + let mut dialog = match self.calls.get(call_id).and_then(|call| device_dialog(call, &device_leg_id, config)) { + Some(dialog) => dialog, + None => { + let not_acceptable = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let _ = socket.send_to(¬_acceptable.serialize(), provider_target).await; + return true; + } + }; + + let rewritten_body = if msg.has_sdp_body() { + Some(rewrite_sdp(&msg.body, &config.proxy.lan_ip, device_rtp_port).0) + } else { + None + }; + let target_request = dialog.create_request( + method, + rewritten_body.as_deref(), + rewritten_body.as_ref().map(|_| "application/sdp"), + None, + ); + + if let Some(call) = self.calls.get_mut(call_id) { + if let Some(device_leg) = call.legs.get_mut(&device_leg_id) { + device_leg.metadata.insert( + "dialog_local_cseq".to_string(), + serde_json::json!(dialog.local_cseq), + ); + } + call.pending_dialog_bridge = Some(PendingDialogBridge { + source_leg_id: source_leg_id.to_string(), + target_leg_id: device_leg_id.clone(), + source_request: msg.clone(), + target_request: target_request.clone(), + method: method.to_string(), + }); + } + + let _ = socket.send_to(&target_request.serialize(), device_addr).await; + true + } + + async fn handle_provider_bridge_offer( + &mut self, + call_id: &str, + source_leg_id: &str, + msg: &SipMessage, + socket: &UdpSocket, + config: &AppConfig, + ) -> bool { + let method = msg.method().unwrap_or(""); + if method != "INVITE" && method != "UPDATE" { + return false; + } + + let Some(source_target) = self + .calls + .get(call_id) + .and_then(|call| call.legs.get(source_leg_id)) + .and_then(|leg| leg.signaling_addr) + else { + return false; + }; + + let offer = match msg.has_sdp_body().then(|| parse_sdp_endpoint(&msg.body)).flatten() { + Some(offer) => offer, + None => { + let not_acceptable = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let _ = socket.send_to(¬_acceptable.serialize(), source_target).await; + return true; + } + }; + + if !offer.is_audio_rtp() && !offer.is_t38_udptl() { + let not_acceptable = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let _ = socket.send_to(¬_acceptable.serialize(), source_target).await; + return true; + } + + let (provider_leg_id, provider_rtp_port, provider_target, rewritten_body, target_request) = { + let call = match self.calls.get_mut(call_id) { + Some(call) => call, + None => return false, + }; + + if call.pending_dialog_bridge.is_some() { + let pending = SipMessage::create_response(491, "Request Pending", msg, None); + let _ = socket.send_to(&pending.serialize(), source_target).await; + return true; + } + + let Some(provider_leg) = call + .legs + .values_mut() + .find(|leg| leg.id != source_leg_id && leg.kind == LegKind::SipProvider) + else { + let unsupported = SipMessage::create_response(488, "Not Acceptable Here", msg, None); + let _ = socket.send_to(&unsupported.serialize(), source_target).await; + return true; + }; + + let advertise_ip = provider_leg + .public_ip + .clone() + .unwrap_or_else(|| config.proxy.lan_ip.clone()); + let rewritten_body = rewrite_sdp(&msg.body, &advertise_ip, provider_leg.rtp_port).0; + let provider_target = provider_leg + .sip_leg + .as_mut() + .map(|sip_leg| { + let target_request = sip_leg.dialog.as_mut().map(|dialog| { + dialog.create_request( + method, + Some(&rewritten_body), + Some("application/sdp"), + None, + ) + }); + (sip_leg.config.sip_target, target_request) + }); + + let Some((provider_target, Some(target_request))) = provider_target else { + return false; + }; + + ( + provider_leg.id.clone(), + provider_leg.rtp_port, + provider_target, + rewritten_body, + target_request, + ) + }; + + let trying = SipMessage::create_response(100, "Trying", msg, None); + let _ = socket.send_to(&trying.serialize(), source_target).await; + + if let Some(call) = self.calls.get_mut(call_id) { + if let Some(device_leg) = call.legs.get_mut(source_leg_id) { + device_leg.metadata.insert( + "dialog_local_cseq".to_string(), + serde_json::json!(msg.get_header("CSeq") + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0)), + ); + } + call.pending_dialog_bridge = Some(PendingDialogBridge { + source_leg_id: source_leg_id.to_string(), + target_leg_id: provider_leg_id, + source_request: msg.clone(), + target_request: target_request.clone(), + method: method.to_string(), + }); + } + + let _ = provider_rtp_port; + let _ = rewritten_body; + let _ = socket.send_to(&target_request.serialize(), provider_target).await; + true + } + + async fn handle_pending_dialog_bridge_response( + &mut self, + call_id: &str, + leg_id: &str, + msg: &SipMessage, + socket: &UdpSocket, + config: &AppConfig, + ) -> bool { + if !msg.is_response() { + return false; + } + + let pending = match self + .calls + .get(call_id) + .and_then(|call| call.pending_dialog_bridge.clone()) + { + Some(pending) if pending.target_leg_id == leg_id => pending, + _ => return false, + }; + + let source_leg = match self + .calls + .get(call_id) + .and_then(|call| call.legs.get(&pending.source_leg_id)) + { + Some(leg) => leg, + None => return false, + }; + let Some(source_target) = source_leg.signaling_addr else { + return false; + }; + + let code = msg.status_code().unwrap_or(500); + let body = if msg.has_sdp_body() { + let advertise_ip = match source_leg.kind { + LegKind::SipProvider => source_leg + .public_ip + .clone() + .unwrap_or_else(|| config.proxy.lan_ip.clone()), + _ => config.proxy.lan_ip.clone(), + }; + Some(rewrite_sdp(&msg.body, &advertise_ip, source_leg.rtp_port).0) + } else { + None + }; + let response = SipMessage::create_response( + code, + response_reason_phrase(msg), + &pending.source_request, + Some(ResponseOptions { + body, + content_type: msg + .has_sdp_body() + .then(|| "application/sdp".to_string()), + ..Default::default() + }), + ); + let _ = socket.send_to(&response.serialize(), source_target).await; + + if pending.method == "INVITE" { + if (200..300).contains(&code) { + let device_ack: Option<(Vec, SocketAddr)> = self + .calls + .get(call_id) + .and_then(|call| { + let target_leg = call.legs.get(leg_id)?; + if target_leg.kind != LegKind::SipDevice { + return None; + } + let mut dialog = device_dialog(call, leg_id, config)?; + dialog.process_response(msg); + Some((dialog.create_ack().serialize(), target_leg.signaling_addr?)) + }); + if let Some((ack, target)) = device_ack { + let _ = socket.send_to(&ack, target).await; + } + + let provider_ack: Option<(Vec, SocketAddr)> = self + .calls + .get_mut(call_id) + .and_then(|call| { + let target_leg = call.legs.get_mut(leg_id)?; + if target_leg.kind != LegKind::SipProvider { + return None; + } + let sip_leg = target_leg.sip_leg.as_mut()?; + let dialog = sip_leg.dialog.as_mut()?; + dialog.process_response(msg); + Some((dialog.create_ack().serialize(), sip_leg.config.sip_target)) + }); + if let Some((ack, target)) = provider_ack { + let _ = socket.send_to(&ack, target).await; + } + } else if code >= 300 { + let ack = build_non_2xx_ack(&pending.target_request, msg); + if let Some(target) = self + .calls + .get(call_id) + .and_then(|call| call.legs.get(leg_id)) + .and_then(|leg| { + leg.signaling_addr.or_else(|| { + leg.sip_leg + .as_ref() + .map(|sip_leg| sip_leg.config.sip_target) + }) + }) + { + let _ = socket.send_to(&ack.serialize(), target).await; + } + } + } + + if (200..300).contains(&code) && msg.has_sdp_body() { + if let Some(call) = self.calls.get_mut(call_id) { + if pending.source_request.has_sdp_body() { + if let Some(source_leg) = call.legs.get_mut(&pending.source_leg_id) { + apply_sdp_to_leg(source_leg, &pending.source_request.body); + } + } + if let Some(target_leg) = call.legs.get_mut(leg_id) { + apply_sdp_to_leg(target_leg, &msg.body); + } + call.pending_dialog_bridge = None; + } + self.reset_passthrough_media_bridge(call_id).await; + self.maybe_wire_passthrough_legs(call_id).await; + } else if code >= 200 { + if let Some(call) = self.calls.get_mut(call_id) { + call.pending_dialog_bridge = None; + } + } + + true + } + /// Route a message to a B2BUA leg (has SipLeg dialog management). async fn route_b2bua_message( &mut self, @@ -215,7 +907,15 @@ impl CallManager { msg: &SipMessage, from_addr: SocketAddr, socket: &UdpSocket, + config: &AppConfig, ) -> bool { + if self + .handle_pending_dialog_bridge_response(call_id, leg_id, msg, socket, config) + .await + { + return true; + } + // Process the SipLeg action first, extracting all needed data. let (action, target, codecs, rtp_socket_clone) = { let call = match self.calls.get_mut(call_id) { @@ -239,11 +939,13 @@ impl CallManager { // Mutable borrow on call/leg is now released. let mut sip_pt = codecs.first().copied().unwrap_or(9); + let mut media_protocol = "rtp"; // If the message has SDP (e.g., 200 OK answer), use the negotiated codec // instead of the offered one. if msg.has_sdp_body() { if let Some(ep) = parse_sdp_endpoint(&msg.body) { + media_protocol = endpoint_media_protocol(&ep); if let Some(pt) = ep.codec_pt { sip_pt = pt; } @@ -252,6 +954,34 @@ impl CallManager { match action { SipLegAction::None => {} + SipLegAction::InDialogRequest(_method) => { + if self + .handle_local_fax_offer(call_id, leg_id, msg, socket, config) + .await + { + return true; + } + + if self + .handle_device_bridge_offer(call_id, leg_id, msg, socket, config) + .await + { + return true; + } + + let response = if msg + .has_sdp_body() + .then(|| parse_sdp_endpoint(&msg.body)) + .flatten() + .is_some_and(|offer| offer.is_t38_udptl()) + { + SipMessage::create_response(488, "Not Acceptable Here", msg, None) + } else { + SipMessage::create_response(501, "Not Implemented", msg, None) + }; + let _ = socket.send_to(&response.serialize(), target).await; + return true; + } SipLegAction::Send(buf) => { let _ = socket.send_to(&buf, target).await; } @@ -291,31 +1021,75 @@ impl CallManager { let _ = socket.send_to(&ack_buf, target).await; // Update leg state and get remote media. - let remote = { + let (remote, outbound_fax_file) = { let call = self.calls.get_mut(call_id).unwrap(); let leg = call.legs.get_mut(leg_id).unwrap(); let sip_leg = leg.sip_leg.as_ref().unwrap(); let remote = sip_leg.remote_media; + let outbound_fax_file = outbound_audio_fax_file(leg); leg.state = LegState::Connected; leg.codec_pt = sip_pt; + leg.media_protocol = media_protocol; + leg.media_io_active = false; leg.remote_media = remote; call.state = CallState::Connected; - remote + (remote, outbound_fax_file) }; // Wire the provider leg to the mixer if remote media is known. - if let (Some(remote_addr), Some(rtp_socket)) = (remote, rtp_socket_clone) { - let channels = create_leg_channels(); - spawn_sip_inbound(rtp_socket.clone(), channels.inbound_tx); - spawn_sip_outbound(rtp_socket, remote_addr, channels.outbound_rx); - if let Some(call) = self.calls.get(call_id) { - call.add_leg_to_mixer( - leg_id, - sip_pt, - channels.inbound_rx, - channels.outbound_tx, - ) - .await; + if let Some(fax_file) = outbound_fax_file { + if let (Some(remote_addr), Some(rtp_socket)) = (remote, rtp_socket_clone) { + let (cancel_tx, cancel_rx) = watch::channel(false); + if media_protocol == "t38-udptl" { + crate::fax_engine::spawn_outbound_t38_fax( + call_id.to_string(), + leg_id.to_string(), + fax_file, + rtp_socket, + remote_addr, + self.out_tx.clone(), + cancel_rx, + ); + } else { + crate::fax_engine::spawn_outbound_audio_fax( + call_id.to_string(), + leg_id.to_string(), + fax_file, + rtp_socket, + remote_addr, + sip_pt, + self.out_tx.clone(), + cancel_rx, + ); + } + if let Some(call) = self.calls.get_mut(call_id) { + if let Some(leg) = call.legs.get_mut(leg_id) { + leg.media_io_active = true; + } + call.install_media_bridge( + if media_protocol == "t38-udptl" { + "fax-t38" + } else { + "fax-audio" + }, + vec![cancel_tx], + ); + } + } + } else if media_protocol == "rtp" { + if let (Some(remote_addr), Some(rtp_socket)) = (remote, rtp_socket_clone) { + let channels = create_leg_channels(); + spawn_sip_inbound(rtp_socket.clone(), channels.inbound_tx); + spawn_sip_outbound(rtp_socket, remote_addr, channels.outbound_rx); + if let Some(call) = self.calls.get(call_id) { + call.add_leg_to_mixer( + leg_id, + sip_pt, + channels.inbound_rx, + channels.outbound_tx, + ) + .await; + } } } @@ -352,12 +1126,18 @@ impl CallManager { { // Use the device's preferred codec from its INVITE SDP, // not the provider's negotiated codec. - let dev_pt = device_invite + let dev_ep = device_invite .has_sdp_body() .then(|| parse_sdp_endpoint(&device_invite.body)) - .flatten() + .flatten(); + let dev_pt = dev_ep + .as_ref() .and_then(|ep| ep.codec_pt) .unwrap_or(sip_pt); + let dev_media_protocol = dev_ep + .as_ref() + .map(endpoint_media_protocol) + .unwrap_or("rtp"); // Build SDP pointing device to our device_rtp port. // Use LAN IP for the device (it's on the local network). @@ -377,12 +1157,21 @@ impl CallManager { ..Default::default() }); + let device_local_tag = self + .calls + .get(call_id) + .and_then(|call| call.legs.get(&dev_leg_id)) + .and_then(|leg| leg.metadata.get("dialog_local_tag")) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .unwrap_or_else(generate_tag); + let ok = SipMessage::create_response( 200, "OK", &device_invite, Some(ResponseOptions { - to_tag: Some(generate_tag()), + to_tag: Some(device_local_tag.clone()), contact: Some(format!("", lan_ip_str, 5060)), body: Some(sdp), content_type: Some("application/sdp".to_string()), @@ -396,6 +1185,16 @@ impl CallManager { if let Some(dev_leg) = call.legs.get_mut(&dev_leg_id) { dev_leg.state = LegState::Connected; dev_leg.codec_pt = dev_pt; + dev_leg.media_protocol = dev_media_protocol; + dev_leg.media_io_active = false; + dev_leg.metadata.insert( + "dialog_local_tag".to_string(), + serde_json::json!(device_local_tag), + ); + dev_leg.metadata.insert( + "dialog_local_cseq".to_string(), + serde_json::json!(0), + ); } } if let Some(call) = self.calls.get(call_id) { @@ -405,22 +1204,24 @@ impl CallManager { } // Wire device leg to mixer. - if let Some(dev_remote_addr) = dev_remote { - let dev_channels = create_leg_channels(); - spawn_sip_inbound(dev_rtp_socket.clone(), dev_channels.inbound_tx); - spawn_sip_outbound( - dev_rtp_socket, - dev_remote_addr, - dev_channels.outbound_rx, - ); - if let Some(call) = self.calls.get(call_id) { - call.add_leg_to_mixer( - &dev_leg_id, - dev_pt, - dev_channels.inbound_rx, - dev_channels.outbound_tx, - ) - .await; + if dev_media_protocol == "rtp" { + if let Some(dev_remote_addr) = dev_remote { + let channels = create_leg_channels(); + spawn_sip_inbound(dev_rtp_socket.clone(), channels.inbound_tx); + spawn_sip_outbound( + dev_rtp_socket, + dev_remote_addr, + channels.outbound_rx, + ); + if let Some(call) = self.calls.get(call_id) { + call.add_leg_to_mixer( + &dev_leg_id, + dev_pt, + channels.inbound_rx, + channels.outbound_tx, + ) + .await; + } } } } @@ -434,6 +1235,7 @@ impl CallManager { "call_id": call_id, "provider_media_addr": remote.map(|a| a.ip().to_string()), "provider_media_port": remote.map(|a| a.port()), + "media_protocol": media_protocol, "sip_pt": sip_pt, }), ); @@ -543,6 +1345,85 @@ impl CallManager { socket: &UdpSocket, config: &AppConfig, ) -> bool { + if self + .handle_pending_dialog_bridge_response(call_id, this_leg_id, msg, socket, config) + .await + { + return true; + } + + // Single-leg system calls (voicemail/IVR) have no counterpart SIP leg. + // Handle teardown requests before we try to resolve a forward target. + if msg.is_request() { + let method = msg.method().unwrap_or(""); + let has_other_live_leg = self + .calls + .get(call_id) + .map(|call| { + call.legs + .values() + .any(|l| l.id != this_leg_id && l.state != LegState::Terminated) + }) + .unwrap_or(false); + + if !has_other_live_leg { + if method == "ACK" { + return true; + } + + if method == "INFO" { + let ok = SipMessage::create_response(200, "OK", msg, None); + let _ = socket.send_to(&ok.serialize(), from_addr).await; + return true; + } + + if method == "INVITE" || method == "UPDATE" { + if self + .handle_local_fax_offer(call_id, this_leg_id, msg, socket, config) + .await + { + return true; + } + } + + if method == "BYE" || method == "CANCEL" { + let ok = SipMessage::create_response(200, "OK", msg, None); + let _ = socket.send_to(&ok.serialize(), from_addr).await; + + let (duration, leg_payload) = { + let call = match self.calls.get_mut(call_id) { + Some(c) => c, + None => return false, + }; + let duration = call.duration_secs(); + if let Some(leg) = call.legs.get_mut(this_leg_id) { + leg.state = LegState::Terminated; + } + let leg_payload = call + .legs + .get(this_leg_id) + .map(|leg| leg_event_payload(call_id, leg)); + (duration, leg_payload) + }; + + if let Some(payload) = leg_payload { + emit_event(&self.out_tx, "leg_state_changed", payload); + } + emit_event( + &self.out_tx, + "call_ended", + serde_json::json!({ + "call_id": call_id, + "reason": if method == "BYE" { "bye" } else { "cancel" }, + "duration": duration, + }), + ); + self.terminate_call(call_id).await; + return true; + } + } + } + let call = match self.calls.get_mut(call_id) { Some(c) => c, None => return false, @@ -606,13 +1487,27 @@ impl CallManager { return true; } - // INVITE retransmit: the call already exists, re-send 100 Trying. - if method == "INVITE" { + // Initial INVITE retransmit: the call already exists, re-send 100 Trying. + // Mid-dialog re-INVITEs carry a To-tag and must be forwarded. + if method == "INVITE" && !has_to_tag(msg) { let trying = SipMessage::create_response(100, "Trying", msg, None); let _ = socket.send_to(&trying.serialize(), from_addr).await; return true; } + if (method == "INVITE" || method == "UPDATE") && other_has_sip_leg { + if self + .handle_provider_bridge_offer(call_id, this_leg_id, msg, socket, config) + .await + { + return true; + } + + let not_impl = SipMessage::create_response(501, "Not Implemented", msg, None); + let _ = socket.send_to(¬_impl.serialize(), from_addr).await; + return true; + } + if method == "BYE" { let ok = SipMessage::create_response(200, "OK", msg, None); let _ = socket.send_to(&ok.serialize(), from_addr).await; @@ -685,6 +1580,9 @@ impl CallManager { // at an address that is routable from its vantage point // (public IP for provider legs, LAN IP for everything else). if fwd.has_sdp_body() { + if let Some(leg) = call.legs.get_mut(this_leg_id) { + apply_sdp_to_leg(leg, &msg.body); + } let (new_body, _) = rewrite_sdp(&fwd.body, &advertise_ip, other_rtp_port); fwd.body = new_body; fwd.update_content_length(); @@ -705,6 +1603,11 @@ impl CallManager { ); } let _ = socket.send_to(&fwd.serialize(), forward_to).await; + if fwd.has_sdp_body() { + if let Some(leg) = call.legs.get(this_leg_id) { + emit_leg_state_changed_event(&self.out_tx, call_id, leg); + } + } return true; } @@ -722,8 +1625,8 @@ impl CallManager { fwd.update_content_length(); } - // State transitions on INVITE responses. - if cseq_method == "INVITE" { + // State transitions on INVITE / UPDATE responses. + if cseq_method == "INVITE" || cseq_method == "UPDATE" { if code == 180 || code == 183 { if call.state == CallState::SettingUp { call.state = CallState::Ringing; @@ -742,18 +1645,12 @@ impl CallManager { } else if code >= 200 && code < 300 { let mut needs_wiring = false; if let Some(leg) = call.legs.get_mut(this_leg_id) { - leg.state = LegState::Connected; - // Learn remote media and negotiated codec from SDP answer. + if cseq_method == "INVITE" { + leg.state = LegState::Connected; + } + // Learn remote media and negotiated media protocol from SDP answer. if msg.has_sdp_body() { - if let Some(ep) = parse_sdp_endpoint(&msg.body) { - if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() { - leg.remote_media = Some(addr); - } - // Use the codec from the SDP answer (what the remote actually selected). - if let Some(pt) = ep.codec_pt { - leg.codec_pt = pt; - } - } + apply_sdp_to_leg(leg, &msg.body); } needs_wiring = true; } @@ -762,20 +1659,26 @@ impl CallManager { emit_leg_state_changed_event(&self.out_tx, call_id, leg); } - if call.state != CallState::Connected { + if cseq_method == "INVITE" && call.state != CallState::Connected { call.state = CallState::Connected; + let answered_media_protocol = call + .legs + .get(this_leg_id) + .map(|leg| leg.media_protocol) + .unwrap_or("rtp"); emit_event( &self.out_tx, "call_answered", - serde_json::json!({ "call_id": call_id }), + serde_json::json!({ "call_id": call_id, "media_protocol": answered_media_protocol }), ); } // Forward the response before wiring (drop call borrow). let _ = socket.send_to(&fwd.serialize(), forward_to).await; - // Wire legs to mixer (needs &mut self, so call borrow must be released). + // Rebuild passthrough media after in-dialog SDP changes. if needs_wiring { + self.reset_passthrough_media_bridge(call_id).await; self.maybe_wire_passthrough_legs(call_id).await; } return true; @@ -797,39 +1700,124 @@ impl CallManager { false } - /// Wire passthrough legs to the mixer once both have remote media addresses. - async fn maybe_wire_passthrough_legs(&mut self, call_id: &str) { - let call = match self.calls.get(call_id) { - Some(c) => c, + async fn reset_passthrough_media_bridge(&mut self, call_id: &str) { + let active_leg_ids: Vec = match self.calls.get(call_id) { + Some(call) => call + .legs + .values() + .filter(|leg| leg.media_io_active) + .map(|leg| leg.id.clone()) + .collect(), None => return, }; - // Collect legs that need wiring (have remote_media + rtp_socket but aren't yet in mixer). - let mut to_wire: Vec<(String, u8, Arc, SocketAddr)> = Vec::new(); - for leg in call.legs.values() { - if leg.state == LegState::Connected || leg.state == LegState::Ringing { - if let (Some(rtp_socket), Some(remote)) = (&leg.rtp_socket, leg.remote_media) { - to_wire.push((leg.id.clone(), leg.codec_pt, rtp_socket.clone(), remote)); - } + if let Some(call) = self.calls.get(call_id) { + for leg_id in &active_leg_ids { + call.remove_leg_from_mixer(leg_id).await; } } - // Only wire if we have at least 2 legs ready. - if to_wire.len() < 2 { + if let Some(call) = self.calls.get_mut(call_id) { + call.clear_media_bridge(); + for leg in call.legs.values_mut() { + leg.media_io_active = false; + } + } + } + + /// Wire passthrough legs either into the audio mixer (`rtp`) or into a raw + /// UDP relay (`t38-udptl`) once both sides have compatible media. + async fn maybe_wire_passthrough_legs(&mut self, call_id: &str) { + let ready_legs: Vec<(String, &'static str, u8, Arc, SocketAddr)> = + match self.calls.get(call_id) { + Some(call) => call + .legs + .values() + .filter(|leg| leg.state == LegState::Connected || leg.state == LegState::Ringing) + .filter_map(|leg| { + Some(( + leg.id.clone(), + leg.media_protocol, + leg.codec_pt, + leg.rtp_socket.clone()?, + leg.remote_media?, + )) + }) + .collect(), + None => return, + }; + + if ready_legs.len() < 2 { return; } - let call = match self.calls.get(call_id) { - Some(c) => c, - None => return, - }; + let media_protocol = ready_legs[0].1; + if ready_legs.iter().any(|(_, protocol, _, _, _)| *protocol != media_protocol) { + return; + } - for (leg_id, codec_pt, rtp_socket, remote) in to_wire { - let channels = create_leg_channels(); - spawn_sip_inbound(rtp_socket.clone(), channels.inbound_tx); - spawn_sip_outbound(rtp_socket, remote, channels.outbound_rx); - call.add_leg_to_mixer(&leg_id, codec_pt, channels.inbound_rx, channels.outbound_tx) - .await; + if let Some(call) = self.calls.get(call_id) { + if call.media_bridge_mode.as_deref() == Some(media_protocol) + && ready_legs.iter().all(|(leg_id, _, _, _, _)| { + call.legs + .get(leg_id) + .map(|leg| leg.media_io_active) + .unwrap_or(false) + }) + { + return; + } + } + + self.reset_passthrough_media_bridge(call_id).await; + + if media_protocol == "rtp" { + if let Some(call) = self.calls.get(call_id) { + for (leg_id, _protocol, codec_pt, rtp_socket, remote) in &ready_legs { + let channels = create_leg_channels(); + spawn_sip_inbound(rtp_socket.clone(), channels.inbound_tx); + spawn_sip_outbound(rtp_socket.clone(), *remote, channels.outbound_rx); + call.add_leg_to_mixer(leg_id, *codec_pt, channels.inbound_rx, channels.outbound_tx) + .await; + } + } + + if let Some(call) = self.calls.get_mut(call_id) { + for (leg_id, ..) in &ready_legs { + if let Some(leg) = call.legs.get_mut(leg_id) { + leg.media_io_active = true; + } + } + call.note_mixer_bridge("rtp"); + } + return; + } + + if media_protocol == "t38-udptl" && ready_legs.len() == 2 { + let (a_id, _, _a_codec, a_socket, a_remote) = &ready_legs[0]; + let (b_id, _, _b_codec, b_socket, b_remote) = &ready_legs[1]; + + let (a_to_b_tx, a_to_b_rx) = mpsc::channel::>(64); + let (b_to_a_tx, b_to_a_rx) = mpsc::channel::>(64); + + let (cancel_a_tx, cancel_a_rx) = watch::channel(false); + let (cancel_b_tx, cancel_b_rx) = watch::channel(false); + let cancel_txs = vec![cancel_a_tx.clone(), cancel_b_tx.clone()]; + + spawn_raw_udp_inbound(a_socket.clone(), a_to_b_tx, cancel_a_rx.clone()); + spawn_raw_udp_outbound(b_socket.clone(), *b_remote, a_to_b_rx, cancel_a_rx); + spawn_raw_udp_inbound(b_socket.clone(), b_to_a_tx, cancel_b_rx.clone()); + spawn_raw_udp_outbound(a_socket.clone(), *a_remote, b_to_a_rx, cancel_b_rx); + + if let Some(call) = self.calls.get_mut(call_id) { + if let Some(leg) = call.legs.get_mut(a_id) { + leg.media_io_active = true; + } + if let Some(leg) = call.legs.get_mut(b_id) { + leg.media_io_active = true; + } + call.install_media_bridge("t38-udptl", cancel_txs); + } } } @@ -924,6 +1912,28 @@ impl CallManager { // They either go to the configured voicemail box or play the unrouted // greeting via the default voicemail flow. if !route.ring_all_devices && route.device_ids.is_empty() && !route.ring_browsers { + if let Some(ref fax_box_id) = route.fax_box { + let call_id = self + .route_to_fax( + &call_id, + invite, + from_addr, + &caller_number, + provider_id, + provider_config, + config, + rtp_pool, + socket, + public_ip, + fax_box_id, + ) + .await?; + return Some(InboundCallCreated { + call_id, + ring_browsers, + }); + } + let greeting_wav = if route.voicemail_box.is_some() { resolve_greeting_wav(config, route.voicemail_box.as_deref(), &tts_engine).await } else { @@ -966,6 +1976,28 @@ impl CallManager { let device_addr = match device_addr { Some(addr) => addr, None => { + if let Some(ref fax_box_id) = route.fax_box { + let call_id = self + .route_to_fax( + &call_id, + invite, + from_addr, + &caller_number, + provider_id, + provider_config, + config, + rtp_pool, + socket, + public_ip, + fax_box_id, + ) + .await?; + return Some(InboundCallCreated { + call_id, + ring_browsers, + }); + } + // No device registered → voicemail. // Resolve greeting WAV on-demand (may trigger TTS generation). let greeting_wav = @@ -1043,6 +2075,7 @@ impl CallManager { call.state = CallState::Ringing; let mut codec_pt = provider_config.codecs.first().copied().unwrap_or(9); + let mut media_protocol = "rtp"; // Provider leg — extract media and negotiated codec from SDP. let mut provider_media: Option = None; @@ -1051,6 +2084,7 @@ impl CallManager { if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() { provider_media = Some(addr); } + media_protocol = endpoint_media_protocol(&ep); // Use the codec from the provider's SDP offer (what they actually want to use). if let Some(pt) = ep.codec_pt { codec_pt = pt; @@ -1066,6 +2100,8 @@ impl CallManager { kind: LegKind::SipProvider, state: LegState::Connected, // Provider already connected (sent us the INVITE). codec_pt, + media_protocol, + media_io_active: false, sip_leg: None, sip_call_id: Some(sip_call_id.clone()), webrtc_session_id: None, @@ -1087,6 +2123,8 @@ impl CallManager { kind: LegKind::SipDevice, state: LegState::Inviting, codec_pt, + media_protocol, + media_io_active: false, sip_leg: None, sip_call_id: Some(sip_call_id.clone()), // Same SIP Call-ID for passthrough. webrtc_session_id: None, @@ -1214,6 +2252,8 @@ impl CallManager { kind: LegKind::SipProvider, state: LegState::Inviting, codec_pt, + media_protocol: "rtp", + media_io_active: false, sip_leg: Some(sip_leg), sip_call_id: Some(sip_call_id.clone()), webrtc_session_id: None, @@ -1296,6 +2336,7 @@ impl CallManager { }; let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); + let mut device_media_protocol = "rtp"; // Create call with mixer. let (mixer_cmd_tx, mixer_task) = spawn_mixer(call_id.clone(), self.out_tx.clone()); @@ -1317,6 +2358,7 @@ impl CallManager { if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() { device_media = Some(addr); } + device_media_protocol = endpoint_media_protocol(&ep); } } @@ -1327,6 +2369,8 @@ impl CallManager { kind: LegKind::SipDevice, state: LegState::Inviting, // Not connected yet — waiting for provider answer codec_pt, + media_protocol: device_media_protocol, + media_io_active: false, sip_leg: None, sip_call_id: Some(device_sip_call_id.clone()), webrtc_session_id: None, @@ -1363,8 +2407,26 @@ impl CallManager { // Build proper To URI and send INVITE. let to_uri = format!("sip:{}@{}", dialed_number, provider_config.domain); + let initial_sdp = if device_media_protocol == "t38-udptl" { + let advertise_ip = public_ip.unwrap_or(lan_ip.as_str()); + build_t38_sdp(advertise_ip, provider_rtp.port) + } else { + let advertise_ip = public_ip.unwrap_or(lan_ip.as_str()); + build_sdp(&SdpOptions { + ip: advertise_ip, + port: provider_rtp.port, + payload_types: &provider_config.codecs, + ..Default::default() + }) + }; sip_leg - .send_invite(registered_aor, &to_uri, &provider_sip_call_id, socket) + .send_invite_with_sdp( + registered_aor, + &to_uri, + &provider_sip_call_id, + socket, + initial_sdp, + ) .await; call.legs.insert( @@ -1374,6 +2436,12 @@ impl CallManager { kind: LegKind::SipProvider, state: LegState::Inviting, codec_pt, + media_protocol: if device_media_protocol == "t38-udptl" { + "t38-udptl" + } else { + "rtp" + }, + media_io_active: false, sip_leg: Some(sip_leg), sip_call_id: Some(provider_sip_call_id.clone()), webrtc_session_id: None, @@ -1452,6 +2520,8 @@ impl CallManager { kind: LegKind::SipProvider, state: LegState::Inviting, codec_pt, + media_protocol: "rtp", + media_io_active: false, sip_leg: Some(sip_leg), sip_call_id: Some(sip_call_id.clone()), webrtc_session_id: None, @@ -1530,6 +2600,8 @@ impl CallManager { kind: LegKind::SipDevice, state: LegState::Inviting, codec_pt, + media_protocol: "rtp", + media_io_active: false, sip_leg: Some(sip_leg), sip_call_id: Some(sip_call_id.clone()), webrtc_session_id: None, @@ -1806,6 +2878,251 @@ impl CallManager { } } + // ----------------------------------------------------------------------- + // Fax routing + // ----------------------------------------------------------------------- + + #[allow(clippy::too_many_arguments)] + async fn route_to_fax( + &mut self, + call_id: &str, + invite: &SipMessage, + from_addr: SocketAddr, + caller_number: &str, + provider_id: &str, + provider_config: &ProviderConfig, + config: &AppConfig, + rtp_pool: &mut RtpPortPool, + socket: &UdpSocket, + public_ip: Option<&str>, + fax_box_id: &str, + ) -> Option { + let fax_box = match config + .faxboxes + .iter() + .find(|fax_box| fax_box.id == fax_box_id && fax_box.enabled) + { + Some(fax_box) => fax_box, + None => { + emit_inbound_diagnostic( + &self.out_tx, + "fax_box_missing", + invite, + from_addr, + provider_id, + &extract_inbound_called_number(invite), + caller_number, + ); + let resp = SipMessage::create_response(404, "Not Found", invite, None); + let _ = socket.send_to(&resp.serialize(), from_addr).await; + return None; + } + }; + + let provider_offer = invite + .has_sdp_body() + .then(|| parse_sdp_endpoint(&invite.body)) + .flatten(); + let provider_media_protocol = provider_offer + .as_ref() + .map(endpoint_media_protocol) + .unwrap_or("rtp"); + + let codec_pt = if provider_media_protocol == "rtp" { + match select_audio_fax_codec(&provider_config.codecs) { + Some(codec) => codec, + None => { + emit_inbound_diagnostic( + &self.out_tx, + "fax_codec_unavailable", + invite, + from_addr, + provider_id, + &extract_inbound_called_number(invite), + caller_number, + ); + let resp = + SipMessage::create_response(488, "Not Acceptable Here", invite, None); + let _ = socket.send_to(&resp.serialize(), from_addr).await; + return None; + } + } + } else { + codec_lib::PT_PCMU + }; + + if provider_media_protocol != "rtp" && provider_media_protocol != "t38-udptl" { + emit_inbound_diagnostic( + &self.out_tx, + "fax_transport_unsupported", + invite, + from_addr, + provider_id, + &extract_inbound_called_number(invite), + caller_number, + ); + let resp = SipMessage::create_response(488, "Not Acceptable Here", invite, None); + let _ = socket.send_to(&resp.serialize(), from_addr).await; + return None; + } + + let rtp_alloc = match rtp_pool.allocate().await { + Some(a) => a, + None => { + emit_inbound_diagnostic( + &self.out_tx, + "fax_rtp_unavailable", + invite, + from_addr, + provider_id, + &extract_inbound_called_number(invite), + caller_number, + ); + let resp = SipMessage::create_response(503, "Service Unavailable", invite, None); + let _ = socket.send_to(&resp.serialize(), from_addr).await; + return None; + } + }; + + let lan_ip = &config.proxy.lan_ip; + let pub_ip = public_ip.unwrap_or(lan_ip.as_str()); + let sdp = if provider_media_protocol == "t38-udptl" { + build_t38_sdp(pub_ip, rtp_alloc.port) + } else { + sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions { + ip: pub_ip, + port: rtp_alloc.port, + payload_types: &[codec_pt], + ..Default::default() + }) + }; + + let response = SipMessage::create_response( + 200, + "OK", + invite, + Some(sip_proto::message::ResponseOptions { + to_tag: Some(sip_proto::helpers::generate_tag()), + contact: Some(format!("", lan_ip, config.proxy.lan_port)), + body: Some(sdp), + content_type: Some("application/sdp".to_string()), + ..Default::default() + }), + ); + let _ = socket.send_to(&response.serialize(), from_addr).await; + + let provider_media = if let Some(ep) = &provider_offer { + format!("{}:{}", ep.address, ep.port).parse().ok() + } else { + Some(from_addr) + } + .unwrap_or(from_addr); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let fax_dir = format!(".nogit/fax/inboxes/{}", fax_box.id); + let fax_path = format!("{fax_dir}/fax-{timestamp}.tif"); + + let (mixer_cmd_tx, mixer_task) = spawn_mixer(call_id.to_string(), self.out_tx.clone()); + let mut call = Call::new( + call_id.to_string(), + CallDirection::Inbound, + provider_id.to_string(), + mixer_cmd_tx, + mixer_task, + ); + call.state = CallState::Connected; + call.caller_number = Some(caller_number.to_string()); + + let provider_leg_id = format!("{call_id}-prov"); + let mut metadata = HashMap::new(); + metadata.insert("fax_box_id".to_string(), serde_json::json!(fax_box.id)); + metadata.insert("caller_number".to_string(), serde_json::json!(caller_number)); + metadata.insert("fax_file_path".to_string(), serde_json::json!(fax_path)); + metadata.insert( + "fax_mode".to_string(), + serde_json::json!(if provider_media_protocol == "t38-udptl" { "inbound-t38" } else { "inbound-audio" }), + ); + call.legs.insert( + provider_leg_id.clone(), + LegInfo { + id: provider_leg_id.clone(), + kind: LegKind::SipProvider, + state: LegState::Connected, + codec_pt, + media_protocol: if provider_media_protocol == "t38-udptl" { "t38-udptl" } else { "rtp" }, + media_io_active: true, + sip_leg: None, + sip_call_id: Some(invite.call_id().to_string()), + webrtc_session_id: None, + rtp_socket: Some(rtp_alloc.socket.clone()), + rtp_port: rtp_alloc.port, + public_ip: public_ip.map(|s| s.to_string()), + remote_media: Some(provider_media), + signaling_addr: Some(from_addr), + metadata, + }, + ); + + let (cancel_tx, cancel_rx) = watch::channel(false); + if provider_media_protocol == "t38-udptl" { + crate::fax_engine::spawn_inbound_t38_fax( + call_id.to_string(), + provider_leg_id.clone(), + fax_box.id.clone(), + caller_number.to_string(), + fax_path, + rtp_alloc.socket.clone(), + provider_media, + self.out_tx.clone(), + cancel_rx, + ); + call.install_media_bridge("fax-t38", vec![cancel_tx]); + } else { + crate::fax_engine::spawn_inbound_audio_fax( + call_id.to_string(), + provider_leg_id.clone(), + fax_box.id.clone(), + caller_number.to_string(), + fax_path, + rtp_alloc.socket.clone(), + provider_media, + codec_pt, + self.out_tx.clone(), + cancel_rx, + ); + call.install_media_bridge("fax-audio", vec![cancel_tx]); + } + + self.sip_index.insert( + invite.call_id().to_string(), + (call_id.to_string(), provider_leg_id.clone()), + ); + self.calls.insert(call_id.to_string(), call); + + if let Some(call) = self.calls.get(call_id) { + for leg in call.legs.values() { + emit_leg_added_event(&self.out_tx, call_id, leg); + } + } + + emit_event( + &self.out_tx, + "call_answered", + serde_json::json!({ + "call_id": call_id, + "provider_media_addr": Some(provider_media.ip().to_string()), + "provider_media_port": Some(provider_media.port()), + "media_protocol": provider_media_protocol, + "sip_pt": codec_pt, + }), + ); + + Some(call_id.to_string()) + } + // ----------------------------------------------------------------------- // Voicemail // ----------------------------------------------------------------------- @@ -1848,6 +3165,13 @@ impl CallManager { }; let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); + let provider_media_protocol = if invite.has_sdp_body() { + parse_sdp_endpoint(&invite.body) + .map(|ep| endpoint_media_protocol(&ep)) + .unwrap_or("rtp") + } else { + "rtp" + }; let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions { ip: pub_ip, @@ -1898,6 +3222,8 @@ impl CallManager { kind: LegKind::SipProvider, state: LegState::Connected, codec_pt, + media_protocol: provider_media_protocol, + media_io_active: false, sip_leg: None, sip_call_id: Some(invite.call_id().to_string()), webrtc_session_id: None, @@ -1923,6 +3249,18 @@ impl CallManager { } } + emit_event( + &self.out_tx, + "call_answered", + serde_json::json!({ + "call_id": call_id, + "provider_media_addr": Some(provider_media.ip().to_string()), + "provider_media_port": Some(provider_media.port()), + "media_protocol": provider_media_protocol, + "sip_pt": codec_pt, + }), + ); + // Build recording path. let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1999,6 +3337,13 @@ impl CallManager { }; let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); + let provider_media_protocol = if invite.has_sdp_body() { + parse_sdp_endpoint(&invite.body) + .map(|ep| endpoint_media_protocol(&ep)) + .unwrap_or("rtp") + } else { + "rtp" + }; let pub_ip = public_ip.unwrap_or(lan_ip.as_str()); let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions { @@ -2050,6 +3395,8 @@ impl CallManager { kind: LegKind::SipProvider, state: LegState::Connected, codec_pt, + media_protocol: provider_media_protocol, + media_io_active: false, sip_leg: None, sip_call_id: Some(invite.call_id().to_string()), webrtc_session_id: None, @@ -2075,6 +3422,18 @@ impl CallManager { } } + emit_event( + &self.out_tx, + "call_answered", + serde_json::json!({ + "call_id": call_id, + "provider_media_addr": Some(provider_media.ip().to_string()), + "provider_media_port": Some(provider_media.port()), + "media_protocol": provider_media_protocol, + "sip_pt": codec_pt, + }), + ); + // Generate the IVR prompt as a live chunked TTS stream so playback can // start after the first chunk instead of waiting for a full WAV render. let voice = menu.prompt_voice.as_deref().unwrap_or("af_bella"); diff --git a/rust/crates/proxy-engine/src/config.rs b/rust/crates/proxy-engine/src/config.rs index 03096d3..77171ac 100644 --- a/rust/crates/proxy-engine/src/config.rs +++ b/rust/crates/proxy-engine/src/config.rs @@ -105,6 +105,8 @@ pub struct RouteAction { pub ring_browsers: Option, #[serde(rename = "voicemailBox")] pub voicemail_box: Option, + #[serde(rename = "faxBox")] + pub fax_box: Option, #[serde(rename = "ivrMenuId")] pub ivr_menu_id: Option, #[serde(rename = "noAnswerTimeout")] @@ -161,6 +163,8 @@ pub struct AppConfig { pub devices: Vec, pub routing: RoutingConfig, #[serde(default)] + pub faxboxes: Vec, + #[serde(default)] pub voiceboxes: Vec, #[serde(default)] pub ivr: Option, @@ -191,6 +195,16 @@ pub struct VoiceboxConfig { pub max_recording_sec: Option, } +#[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, +} + // --------------------------------------------------------------------------- // IVR config // --------------------------------------------------------------------------- @@ -415,6 +429,7 @@ pub struct InboundRouteResult { pub ring_all_devices: bool, pub ring_browsers: bool, pub voicemail_box: Option, + pub fax_box: Option, pub ivr_menu_id: Option, pub no_answer_timeout: Option, } @@ -525,6 +540,7 @@ impl AppConfig { ring_all_devices: explicit_targets.is_none(), ring_browsers: route.action.ring_browsers.unwrap_or(false), voicemail_box: route.action.voicemail_box.clone(), + fax_box: route.action.fax_box.clone(), ivr_menu_id: route.action.ivr_menu_id.clone(), no_answer_timeout: route.action.no_answer_timeout, }); @@ -574,6 +590,7 @@ mod tests { extension: "100".to_string(), }], routing: RoutingConfig { routes }, + faxboxes: vec![], voiceboxes: vec![], ivr: None, } @@ -620,6 +637,7 @@ mod tests { targets: Some(vec!["desk".to_string()]), ring_browsers: Some(true), voicemail_box: None, + fax_box: None, ivr_menu_id: None, no_answer_timeout: None, provider: None, @@ -644,6 +662,7 @@ mod tests { targets: None, ring_browsers: Some(false), voicemail_box: Some("support-box".to_string()), + fax_box: None, ivr_menu_id: None, no_answer_timeout: Some(20), provider: None, diff --git a/rust/crates/proxy-engine/src/fax_engine.rs b/rust/crates/proxy-engine/src/fax_engine.rs new file mode 100644 index 0000000..d5da6bf --- /dev/null +++ b/rust/crates/proxy-engine/src/fax_engine.rs @@ -0,0 +1,1176 @@ +use crate::ipc::{emit_event, OutTx}; +use crate::rtp::{build_rtp_header, rtp_clock_increment}; +use codec_lib::{codec_sample_rate, TranscodeState, PT_PCMA, PT_PCMU}; +use spandsp::error::SpanDspError; +use spandsp::fax::FaxState; +use spandsp::spandsp_sys; +use spandsp::t30::{T30ModemSupport, T30State}; +use spandsp::t38_core::T38TerminalOptions; +use spandsp::t38_terminal::T38Terminal; +use std::collections::VecDeque; +use std::ffi::c_void; +use std::fmt; +use std::net::SocketAddr; +use std::os::raw::c_int; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::net::UdpSocket; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio::time::{self, Duration}; +use udptl::UdptlPacket; + +#[derive(Debug)] +pub enum FaxEngineError { + InvalidPath, + SpanDsp(SpanDspError), +} + +impl fmt::Display for FaxEngineError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPath => f.write_str("fax file path must be valid UTF-8"), + Self::SpanDsp(err) => err.fmt(f), + } + } +} + +impl std::error::Error for FaxEngineError {} + +impl From for FaxEngineError { + fn from(value: SpanDspError) -> Self { + Self::SpanDsp(value) + } +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy)] +pub struct FaxSessionOptions { + pub ecm: bool, + pub supported_modems: T30ModemSupport, +} + +impl Default for FaxSessionOptions { + fn default() -> Self { + Self { + ecm: true, + supported_modems: T30ModemSupport::default(), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct T38SessionOptions { + pub fax: FaxSessionOptions, + pub config: T38TerminalOptions, + pub use_tep: bool, + pub fill_bit_removal: bool, +} + +impl Default for T38SessionOptions { + fn default() -> Self { + Self { + fax: FaxSessionOptions::default(), + config: T38TerminalOptions::default(), + use_tep: false, + fill_bit_removal: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FaxTransferStats { + pub bit_rate: i32, + pub error_correcting_mode: bool, + pub pages_tx: i32, + pub pages_rx: i32, + pub image_size: i32, + pub bad_rows: i32, + pub longest_bad_row_run: i32, + pub ecm_retries: i32, + pub current_status: i32, + pub rtp_events: i32, + pub rtn_events: i32, +} + +impl From for FaxTransferStats { + fn from(value: spandsp_sys::t30_stats_t) -> Self { + Self { + bit_rate: value.bit_rate, + error_correcting_mode: value.error_correcting_mode != 0, + pages_tx: value.pages_tx, + pages_rx: value.pages_rx, + image_size: value.image_size, + bad_rows: value.bad_rows, + longest_bad_row_run: value.longest_bad_row_run, + ecm_retries: value.error_correcting_mode_retries, + current_status: value.current_status, + rtp_events: value.rtp_events, + rtn_events: value.rtn_events, + } + } +} + +fn codec_name(codec_pt: u8) -> &'static str { + match codec_pt { + PT_PCMU => "PCMU", + PT_PCMA => "PCMA", + _ => "unknown", + } +} + +fn completion_label(code: i32) -> Option { + T30State::completion_code(code).map(|err| format!("{err:?}")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct T38OutboundPacket { + pub ifp: Vec, + pub repeat_count: usize, +} + +struct FaxCallbackState { + completion_code: AtomicI32, + completed: AtomicBool, +} + +impl FaxCallbackState { + fn new() -> Self { + Self { + completion_code: AtomicI32::new(i32::MIN), + completed: AtomicBool::new(false), + } + } + + fn completion_code(&self) -> Option { + let value = self.completion_code.load(Ordering::SeqCst); + (value != i32::MIN).then_some(value) + } + + fn is_completed(&self) -> bool { + self.completed.load(Ordering::SeqCst) + } +} + +struct T38PacketQueue { + packets: Mutex>, +} + +impl T38PacketQueue { + fn new() -> Self { + Self { + packets: Mutex::new(VecDeque::new()), + } + } + + fn drain(&self) -> Vec { + let mut guard = self.packets.lock().unwrap(); + guard.drain(..).collect() + } +} + +unsafe extern "C" fn t30_phase_e_handler(user_data: *mut c_void, completion_code: c_int) { + if user_data.is_null() { + return; + } + let state = unsafe { &*(user_data as *const FaxCallbackState) }; + state + .completion_code + .store(completion_code, Ordering::SeqCst); + state.completed.store(true, Ordering::SeqCst); +} + +unsafe extern "C" fn t38_tx_packet_handler( + _core: *mut spandsp_sys::t38_core_state_t, + user_data: *mut c_void, + buf: *const u8, + len: c_int, + count: c_int, +) -> c_int { + if user_data.is_null() || buf.is_null() || len <= 0 { + return -1; + } + + let queue = unsafe { &*(user_data as *const T38PacketQueue) }; + let packet = unsafe { std::slice::from_raw_parts(buf, len as usize) }; + queue.packets.lock().unwrap().push_back(T38OutboundPacket { + ifp: packet.to_vec(), + repeat_count: count.max(1) as usize, + }); + 0 +} + +fn path_to_str(path: &Path) -> Result<&str> { + path.to_str().ok_or(FaxEngineError::InvalidPath) +} + +fn configure_t30( + t30: &T30State, + file: &Path, + receive: bool, + options: FaxSessionOptions, + callback_state: *mut c_void, +) -> Result<()> { + if receive { + t30.set_rx_file(path_to_str(file)?, -1)?; + } else { + t30.set_tx_file(path_to_str(file)?, -1, -1)?; + } + t30.set_supported_modems(options.supported_modems)?; + t30.set_ecm_capability(options.ecm)?; + unsafe { + t30.set_phase_e_handler_raw(Some(t30_phase_e_handler), callback_state); + } + Ok(()) +} + +pub struct AnalogFaxSession { + fax: FaxState, + callback_state: Box, +} + +impl AnalogFaxSession { + pub fn new_tx(file: &Path, calling_party: bool, options: FaxSessionOptions) -> Result { + let fax = FaxState::new(calling_party)?; + let callback_state = Box::new(FaxCallbackState::new()); + configure_t30( + &fax.get_t30_state()?, + file, + false, + options, + callback_state.as_ref() as *const FaxCallbackState as *mut c_void, + )?; + Ok(Self { + fax, + callback_state, + }) + } + + pub fn new_rx(file: &Path, calling_party: bool, options: FaxSessionOptions) -> Result { + let fax = FaxState::new(calling_party)?; + let callback_state = Box::new(FaxCallbackState::new()); + configure_t30( + &fax.get_t30_state()?, + file, + true, + options, + callback_state.as_ref() as *const FaxCallbackState as *mut c_void, + )?; + Ok(Self { + fax, + callback_state, + }) + } + + pub fn rx(&mut self, samples: &mut [i16]) -> usize { + self.fax.rx(samples) + } + + pub fn tx(&mut self, samples: &mut [i16]) -> usize { + self.fax.tx(samples) + } + + pub fn set_transmit_on_idle(&mut self, enabled: bool) { + self.fax.set_transmit_on_idle(enabled); + } + + pub fn call_active(&self) -> Result { + Ok(self.fax.get_t30_state()?.call_active()) + } + + pub fn stats(&self) -> Result { + Ok(self.fax.get_t30_state()?.get_transfer_statistics().into()) + } + + pub fn completion_code(&self) -> Option { + self.callback_state.completion_code() + } + + pub fn is_completed(&self) -> bool { + self.callback_state.is_completed() + } +} + +pub struct T38FaxSession { + terminal: T38Terminal, + packet_queue: Box, + callback_state: Box, +} + +impl T38FaxSession { + pub fn new_tx(file: &Path, calling_party: bool, options: T38SessionOptions) -> Result { + Self::new(file, calling_party, false, options) + } + + pub fn new_rx(file: &Path, calling_party: bool, options: T38SessionOptions) -> Result { + Self::new(file, calling_party, true, options) + } + + fn new( + file: &Path, + calling_party: bool, + receive: bool, + options: T38SessionOptions, + ) -> Result { + let packet_queue = Box::new(T38PacketQueue::new()); + let callback_state = Box::new(FaxCallbackState::new()); + let terminal = unsafe { + T38Terminal::new_raw( + calling_party, + Some(t38_tx_packet_handler), + packet_queue.as_ref() as *const T38PacketQueue as *mut c_void, + )? + }; + terminal.set_config(options.config); + terminal.set_tep_mode(options.use_tep); + terminal.set_fill_bit_removal(options.fill_bit_removal); + configure_t30( + &terminal.get_t30_state()?, + file, + receive, + options.fax, + callback_state.as_ref() as *const FaxCallbackState as *mut c_void, + )?; + + Ok(Self { + terminal, + packet_queue, + callback_state, + }) + } + + pub fn send_timeout(&mut self, elapsed_samples: i32) -> i32 { + self.terminal.send_timeout(elapsed_samples) + } + + pub fn rx_ifp_packet(&self, ifp: &[u8], seq_no: u16) -> Result<()> { + self.terminal + .get_t38_core_state()? + .rx_ifp_packet(ifp, seq_no) + .map_err(Into::into) + } + + pub fn drain_outbound_packets(&self) -> Vec { + self.packet_queue.drain() + } + + pub fn call_active(&self) -> Result { + Ok(self.terminal.get_t30_state()?.call_active()) + } + + pub fn stats(&self) -> Result { + Ok(self + .terminal + .get_t30_state()? + .get_transfer_statistics() + .into()) + } + + pub fn completion_code(&self) -> Option { + self.callback_state.completion_code() + } + + pub fn is_completed(&self) -> bool { + self.callback_state.is_completed() + } +} + +pub fn spawn_outbound_audio_fax( + call_id: String, + leg_id: String, + file_path: String, + media_socket: Arc, + remote_media: SocketAddr, + codec_pt: u8, + out_tx: OutTx, + mut cancel_rx: watch::Receiver, +) -> JoinHandle<()> { + tokio::spawn(async move { + if codec_pt != PT_PCMU && codec_pt != PT_PCMA { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "error": format!("unsupported fax codec PT{codec_pt}; outbound fax currently requires PCMU or PCMA"), + }), + ); + return; + } + + let mut session = match AnalogFaxSession::new_tx( + Path::new(&file_path), + true, + FaxSessionOptions::default(), + ) { + Ok(session) => session, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "error": error.to_string(), + }), + ); + return; + } + }; + + emit_event( + &out_tx, + "fax_started", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "codec": codec_name(codec_pt), + "remote_media": format!("{}:{}", remote_media.ip(), remote_media.port()), + }), + ); + + let mut interval = time::interval(Duration::from_millis(20)); + let mut transcoder = match TranscodeState::new() { + Ok(state) => state, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "error": format!("codec init: {error}"), + }), + ); + return; + } + }; + + let mut seq: u16 = 0; + let mut ts: u32 = 0; + let ssrc: u32 = rand::random(); + let target_rate = codec_sample_rate(codec_pt); + let mut recv_buf = vec![0u8; 2048]; + + loop { + tokio::select! { + _ = cancel_rx.changed() => { + break; + } + recv = media_socket.recv_from(&mut recv_buf) => { + match recv { + Ok((len, _src)) => { + if len <= 12 { + continue; + } + let payload_pt = recv_buf[1] & 0x7F; + if payload_pt == 101 { + continue; + } + let payload = &recv_buf[12..len]; + let (pcm, sample_rate) = match transcoder.decode_to_pcm(payload, payload_pt) { + Ok(decoded) => decoded, + Err(_) => continue, + }; + let mut pcm_8k = if sample_rate != 8000 { + match transcoder.resample(&pcm, sample_rate, 8000) { + Ok(resampled) => resampled, + Err(_) => continue, + } + } else { + pcm + }; + let _ = session.rx(&mut pcm_8k); + } + Err(_) => break, + } + } + _ = interval.tick() => { + let mut tx_pcm_8k = vec![0i16; 160]; + let generated = session.tx(&mut tx_pcm_8k); + if generated < tx_pcm_8k.len() { + tx_pcm_8k[generated..].fill(0); + } + + let tx_pcm = if target_rate != 8000 { + match transcoder.resample(&tx_pcm_8k, 8000, target_rate) { + Ok(resampled) => resampled, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "error": format!("tx resample: {error}"), + }), + ); + return; + } + } + } else { + tx_pcm_8k + }; + + let encoded = match transcoder.encode_from_pcm(&tx_pcm, codec_pt) { + Ok(encoded) => encoded, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "error": format!("tx encode: {error}"), + }), + ); + return; + } + }; + + let header = build_rtp_header(codec_pt, seq, ts, ssrc); + let mut packet = header.to_vec(); + packet.extend_from_slice(&encoded); + if media_socket.send_to(&packet, remote_media).await.is_err() { + break; + } + + seq = seq.wrapping_add(1); + ts = ts.wrapping_add(rtp_clock_increment(codec_pt)); + + if session.is_completed() { + break; + } + } + } + } + + let stats = match session.stats() { + Ok(stats) => stats, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "error": format!("stats: {error}"), + }), + ); + return; + } + }; + + let completion_code = session.completion_code(); + let success = completion_code.unwrap_or(-1) == 0; + emit_event( + &out_tx, + "fax_completed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "audio", + "file_path": file_path, + "codec": codec_name(codec_pt), + "success": success, + "completion_code": completion_code, + "completion_label": completion_code.and_then(completion_label), + "stats": { + "bit_rate": stats.bit_rate, + "error_correcting_mode": stats.error_correcting_mode, + "pages_tx": stats.pages_tx, + "pages_rx": stats.pages_rx, + "image_size": stats.image_size, + "bad_rows": stats.bad_rows, + "longest_bad_row_run": stats.longest_bad_row_run, + "ecm_retries": stats.ecm_retries, + "current_status": stats.current_status, + "rtp_events": stats.rtp_events, + "rtn_events": stats.rtn_events, + }, + }), + ); + }) +} + +pub fn spawn_inbound_audio_fax( + call_id: String, + leg_id: String, + fax_box_id: String, + caller_number: String, + file_path: String, + media_socket: Arc, + remote_media: SocketAddr, + codec_pt: u8, + out_tx: OutTx, + mut cancel_rx: watch::Receiver, +) -> JoinHandle<()> { + tokio::spawn(async move { + if codec_pt != PT_PCMU && codec_pt != PT_PCMA { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": format!("unsupported fax codec PT{codec_pt}; inbound fax currently requires PCMU or PCMA"), + }), + ); + return; + } + + let mut session = match AnalogFaxSession::new_rx( + Path::new(&file_path), + false, + FaxSessionOptions::default(), + ) { + Ok(session) => session, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": error.to_string(), + }), + ); + return; + } + }; + + emit_event( + &out_tx, + "fax_started", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "codec": codec_name(codec_pt), + "remote_media": format!("{}:{}", remote_media.ip(), remote_media.port()), + }), + ); + + let mut interval = time::interval(Duration::from_millis(20)); + let mut transcoder = match TranscodeState::new() { + Ok(state) => state, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": format!("codec init: {error}"), + }), + ); + return; + } + }; + + let mut seq: u16 = 0; + let mut ts: u32 = 0; + let ssrc: u32 = rand::random(); + let target_rate = codec_sample_rate(codec_pt); + let mut recv_buf = vec![0u8; 2048]; + + loop { + tokio::select! { + _ = cancel_rx.changed() => { + break; + } + recv = media_socket.recv_from(&mut recv_buf) => { + match recv { + Ok((len, _src)) => { + if len <= 12 { + continue; + } + let payload_pt = recv_buf[1] & 0x7F; + if payload_pt == 101 { + continue; + } + let payload = &recv_buf[12..len]; + let (pcm, sample_rate) = match transcoder.decode_to_pcm(payload, payload_pt) { + Ok(decoded) => decoded, + Err(_) => continue, + }; + let mut pcm_8k = if sample_rate != 8000 { + match transcoder.resample(&pcm, sample_rate, 8000) { + Ok(resampled) => resampled, + Err(_) => continue, + } + } else { + pcm + }; + let _ = session.rx(&mut pcm_8k); + } + Err(_) => break, + } + } + _ = interval.tick() => { + let mut tx_pcm_8k = vec![0i16; 160]; + let generated = session.tx(&mut tx_pcm_8k); + if generated < tx_pcm_8k.len() { + tx_pcm_8k[generated..].fill(0); + } + + let tx_pcm = if target_rate != 8000 { + match transcoder.resample(&tx_pcm_8k, 8000, target_rate) { + Ok(resampled) => resampled, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": format!("tx resample: {error}"), + }), + ); + return; + } + } + } else { + tx_pcm_8k + }; + + let encoded = match transcoder.encode_from_pcm(&tx_pcm, codec_pt) { + Ok(encoded) => encoded, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": format!("tx encode: {error}"), + }), + ); + return; + } + }; + + let header = build_rtp_header(codec_pt, seq, ts, ssrc); + let mut packet = header.to_vec(); + packet.extend_from_slice(&encoded); + if media_socket.send_to(&packet, remote_media).await.is_err() { + break; + } + + seq = seq.wrapping_add(1); + ts = ts.wrapping_add(rtp_clock_increment(codec_pt)); + + if session.is_completed() { + break; + } + } + } + } + + let stats = match session.stats() { + Ok(stats) => stats, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": format!("stats: {error}"), + }), + ); + return; + } + }; + + let completion_code = session.completion_code(); + let success = completion_code.unwrap_or(-1) == 0; + emit_event( + &out_tx, + "fax_completed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "audio", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "codec": codec_name(codec_pt), + "success": success, + "completion_code": completion_code, + "completion_label": completion_code.and_then(completion_label), + "stats": { + "bit_rate": stats.bit_rate, + "error_correcting_mode": stats.error_correcting_mode, + "pages_tx": stats.pages_tx, + "pages_rx": stats.pages_rx, + "image_size": stats.image_size, + "bad_rows": stats.bad_rows, + "longest_bad_row_run": stats.longest_bad_row_run, + "ecm_retries": stats.ecm_retries, + "current_status": stats.current_status, + "rtp_events": stats.rtp_events, + "rtn_events": stats.rtn_events, + }, + }), + ); + }) +} + +pub fn spawn_outbound_t38_fax( + call_id: String, + leg_id: String, + file_path: String, + media_socket: Arc, + remote_media: SocketAddr, + out_tx: OutTx, + mut cancel_rx: watch::Receiver, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut session = match T38FaxSession::new_tx( + Path::new(&file_path), + true, + T38SessionOptions::default(), + ) { + Ok(session) => session, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "t38", + "file_path": file_path, + "error": error.to_string(), + }), + ); + return; + } + }; + + emit_event( + &out_tx, + "fax_started", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "t38", + "file_path": file_path, + "codec": "T.38", + "remote_media": format!("{}:{}", remote_media.ip(), remote_media.port()), + }), + ); + + let mut interval = time::interval(Duration::from_millis(20)); + let mut udptl_seq: u16 = 0; + let mut recv_buf = vec![0u8; 2048]; + + loop { + tokio::select! { + _ = cancel_rx.changed() => { + break; + } + recv = media_socket.recv_from(&mut recv_buf) => { + match recv { + Ok((len, _src)) => { + let packet = match UdptlPacket::decode(&recv_buf[..len]) { + Ok(packet) => packet, + Err(_) => continue, + }; + let _ = session.rx_ifp_packet(&packet.primary_ifp, packet.seq_number); + } + Err(_) => break, + } + } + _ = interval.tick() => { + let _ = session.send_timeout(160); + for outbound in session.drain_outbound_packets() { + for _ in 0..outbound.repeat_count.max(1) { + let encoded = UdptlPacket::with_redundancy( + udptl_seq, + outbound.ifp.clone(), + vec![], + ) + .encode(); + if media_socket.send_to(&encoded, remote_media).await.is_err() { + break; + } + udptl_seq = udptl_seq.wrapping_add(1); + } + } + + if session.is_completed() { + break; + } + } + } + } + + let stats = match session.stats() { + Ok(stats) => stats, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "t38", + "file_path": file_path, + "error": format!("stats: {error}"), + }), + ); + return; + } + }; + + let completion_code = session.completion_code(); + let success = completion_code.unwrap_or(-1) == 0; + emit_event( + &out_tx, + "fax_completed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "outbound", + "transport": "t38", + "file_path": file_path, + "codec": "T.38", + "success": success, + "completion_code": completion_code, + "completion_label": completion_code.and_then(completion_label), + "stats": { + "bit_rate": stats.bit_rate, + "error_correcting_mode": stats.error_correcting_mode, + "pages_tx": stats.pages_tx, + "pages_rx": stats.pages_rx, + "image_size": stats.image_size, + "bad_rows": stats.bad_rows, + "longest_bad_row_run": stats.longest_bad_row_run, + "ecm_retries": stats.ecm_retries, + "current_status": stats.current_status, + "rtp_events": stats.rtp_events, + "rtn_events": stats.rtn_events, + }, + }), + ); + }) +} + +pub fn spawn_inbound_t38_fax( + call_id: String, + leg_id: String, + fax_box_id: String, + caller_number: String, + file_path: String, + media_socket: Arc, + remote_media: SocketAddr, + out_tx: OutTx, + mut cancel_rx: watch::Receiver, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut session = match T38FaxSession::new_rx( + Path::new(&file_path), + false, + T38SessionOptions::default(), + ) { + Ok(session) => session, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "t38", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": error.to_string(), + }), + ); + return; + } + }; + + emit_event( + &out_tx, + "fax_started", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "t38", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "codec": "T.38", + "remote_media": format!("{}:{}", remote_media.ip(), remote_media.port()), + }), + ); + + let mut interval = time::interval(Duration::from_millis(20)); + let mut udptl_seq: u16 = 0; + let mut recv_buf = vec![0u8; 2048]; + + loop { + tokio::select! { + _ = cancel_rx.changed() => { + break; + } + recv = media_socket.recv_from(&mut recv_buf) => { + match recv { + Ok((len, _src)) => { + let packet = match UdptlPacket::decode(&recv_buf[..len]) { + Ok(packet) => packet, + Err(_) => continue, + }; + let _ = session.rx_ifp_packet(&packet.primary_ifp, packet.seq_number); + } + Err(_) => break, + } + } + _ = interval.tick() => { + let _ = session.send_timeout(160); + for outbound in session.drain_outbound_packets() { + for _ in 0..outbound.repeat_count.max(1) { + let encoded = UdptlPacket::with_redundancy( + udptl_seq, + outbound.ifp.clone(), + vec![], + ) + .encode(); + if media_socket.send_to(&encoded, remote_media).await.is_err() { + break; + } + udptl_seq = udptl_seq.wrapping_add(1); + } + } + + if session.is_completed() { + break; + } + } + } + } + + let stats = match session.stats() { + Ok(stats) => stats, + Err(error) => { + emit_event( + &out_tx, + "fax_failed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "t38", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "error": format!("stats: {error}"), + }), + ); + return; + } + }; + + let completion_code = session.completion_code(); + let success = completion_code.unwrap_or(-1) == 0; + emit_event( + &out_tx, + "fax_completed", + serde_json::json!({ + "call_id": call_id, + "leg_id": leg_id, + "direction": "inbound", + "transport": "t38", + "fax_box_id": fax_box_id, + "caller_number": caller_number, + "file_path": file_path, + "codec": "T.38", + "success": success, + "completion_code": completion_code, + "completion_label": completion_code.and_then(completion_label), + "stats": { + "bit_rate": stats.bit_rate, + "error_correcting_mode": stats.error_correcting_mode, + "pages_tx": stats.pages_tx, + "pages_rx": stats.pages_rx, + "image_size": stats.image_size, + "bad_rows": stats.bad_rows, + "longest_bad_row_run": stats.longest_bad_row_run, + "ecm_retries": stats.ecm_retries, + "current_status": stats.current_status, + "rtp_events": stats.rtp_events, + "rtn_events": stats.rtn_events, + }, + }), + ); + }) +} diff --git a/rust/crates/proxy-engine/src/leg_io.rs b/rust/crates/proxy-engine/src/leg_io.rs index 6341daf..40a1807 100644 --- a/rust/crates/proxy-engine/src/leg_io.rs +++ b/rust/crates/proxy-engine/src/leg_io.rs @@ -10,7 +10,7 @@ use crate::mixer::RtpPacket; use std::net::SocketAddr; use std::sync::Arc; use tokio::net::UdpSocket; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; /// Channel pair for connecting a leg to the mixer. pub struct LegChannels { @@ -109,3 +109,56 @@ pub fn spawn_sip_outbound( } }) } + +/// Spawn a raw UDP inbound task for non-RTP passthrough media such as T.38 UDPTL. +pub fn spawn_raw_udp_inbound( + media_socket: Arc, + inbound_tx: mpsc::Sender>, + mut cancel_rx: watch::Receiver, +) -> 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, + remote_media: SocketAddr, + mut outbound_rx: mpsc::Receiver>, + mut cancel_rx: watch::Receiver, +) -> 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, + } + } + } + } + }) +} diff --git a/rust/crates/proxy-engine/src/main.rs b/rust/crates/proxy-engine/src/main.rs index 7a2cc80..bcb3a7b 100644 --- a/rust/crates/proxy-engine/src/main.rs +++ b/rust/crates/proxy-engine/src/main.rs @@ -9,6 +9,8 @@ mod audio_player; mod call; mod call_manager; mod config; +#[allow(dead_code)] +mod fax_engine; mod ipc; mod jitter_buffer; mod leg_io; @@ -139,6 +141,7 @@ async fn handle_command( "configure" => handle_configure(engine, out_tx, &cmd).await, "hangup" => handle_hangup(engine, out_tx, &cmd).await, "make_call" => handle_make_call(engine, out_tx, &cmd).await, + "send_fax" => handle_send_fax(engine, out_tx, &cmd).await, "add_leg" => handle_add_leg(engine, out_tx, &cmd).await, "remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await, // WebRTC commands — lock webrtc only (no engine contention). @@ -576,6 +579,162 @@ async fn handle_make_call(engine: Arc>, 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>, out_tx: &OutTx, cmd: &Command) { + let number = match cmd.params.get("number").and_then(|v| v.as_str()) { + Some(n) => n.to_string(), + None => { + respond_err(out_tx, &cmd.id, "missing number"); + return; + } + }; + let file_path = match cmd.params.get("file_path").and_then(|v| v.as_str()) { + Some(path) if std::path::Path::new(path).exists() => path.to_string(), + Some(_) => { + respond_err(out_tx, &cmd.id, "fax file does not exist"); + return; + } + None => { + respond_err(out_tx, &cmd.id, "missing file_path"); + return; + } + }; + let provider_id = cmd.params.get("provider_id").and_then(|v| v.as_str()); + + let mut eng = engine.lock().await; + let config_ref = match &eng.config { + Some(c) => c.clone(), + None => { + respond_err(out_tx, &cmd.id, "not configured"); + return; + } + }; + + let provider_config = if let Some(pid) = provider_id { + config_ref.providers.iter().find(|p| p.id == pid).cloned() + } else { + let route = config_ref.resolve_outbound_route(&number, None, &|_| true); + route.map(|r| r.provider) + }; + + let mut provider_config = match provider_config { + Some(p) => p, + None => { + respond_err(out_tx, &cmd.id, "no provider available"); + return; + } + }; + + let fax_codec = if provider_config.codecs.contains(&codec_lib::PT_PCMU) { + codec_lib::PT_PCMU + } else if provider_config.codecs.contains(&codec_lib::PT_PCMA) { + codec_lib::PT_PCMA + } else { + respond_err( + out_tx, + &cmd.id, + &format!( + "provider {} does not advertise PCMU/PCMA, which outbound fax currently requires", + provider_config.id + ), + ); + return; + }; + provider_config.codecs = vec![fax_codec]; + + let (public_ip, registered_aor) = if let Some(ps_arc) = eng + .provider_mgr + .find_by_address( + &provider_config + .outbound_proxy + .to_socket_addr() + .unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()), + ) + .await + { + let ps = ps_arc.lock().await; + (ps.public_ip.clone(), ps.registered_aor.clone()) + } else { + ( + None, + format!( + "sip:{}@{}", + provider_config.username, provider_config.domain + ), + ) + }; + + let socket = match &eng.transport { + Some(t) => t.socket(), + None => { + respond_err(out_tx, &cmd.id, "not initialized"); + return; + } + }; + + let ProxyEngine { + ref mut call_mgr, + ref mut rtp_pool, + .. + } = *eng; + let rtp_pool = rtp_pool.as_mut().unwrap(); + + let call_id = call_mgr + .make_outbound_call( + &number, + &provider_config, + &config_ref, + rtp_pool, + &socket, + public_ip.as_deref(), + ®istered_aor, + ) + .await; + + let call_id = match call_id { + Some(id) => id, + None => { + respond_err( + out_tx, + &cmd.id, + "fax origination failed — provider not registered or no ports available", + ); + return; + } + }; + + if let Some(call) = call_mgr.calls.get_mut(&call_id) { + let provider_leg_id = format!("{call_id}-prov"); + if let Some(leg) = call.legs.get_mut(&provider_leg_id) { + leg.codec_pt = fax_codec; + leg.metadata + .insert("fax_mode".to_string(), serde_json::json!("outbound-audio")); + leg.metadata + .insert("fax_file_path".to_string(), serde_json::json!(file_path)); + } + } + + emit_event( + out_tx, + "outbound_call_started", + serde_json::json!({ + "call_id": call_id, + "number": number, + "provider_id": provider_config.id, + "ring_browsers": false, + }), + ); + + respond_ok( + out_tx, + &cmd.id, + serde_json::json!({ + "call_id": call_id, + "codec": if fax_codec == codec_lib::PT_PCMU { "PCMU" } else { "PCMA" }, + }), + ); +} + /// Handle the `hangup` command. async fn handle_hangup(engine: Arc>, out_tx: &OutTx, cmd: &Command) { let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) { @@ -738,6 +897,8 @@ async fn handle_webrtc_link( kind: crate::call::LegKind::WebRtc, state: crate::call::LegState::Connected, codec_pt: codec_lib::PT_OPUS, + media_protocol: "webrtc", + media_io_active: true, sip_leg: None, sip_call_id: None, webrtc_session_id: Some(session_id.clone()), @@ -762,6 +923,7 @@ async fn handle_webrtc_link( "state": "connected", "codec": "Opus", "rtpPort": 0, + "mediaProtocol": "webrtc", "remoteMedia": null, "metadata": {}, }), @@ -1462,6 +1624,8 @@ async fn handle_add_tool_leg(engine: Arc>, out_tx: &OutTx, cm kind: crate::call::LegKind::Tool, state: crate::call::LegState::Connected, codec_pt: 0, + media_protocol: "internal", + media_io_active: true, sip_leg: None, sip_call_id: None, webrtc_session_id: None, @@ -1485,6 +1649,7 @@ async fn handle_add_tool_leg(engine: Arc>, out_tx: &OutTx, cm "state": "connected", "codec": null, "rtpPort": 0, + "mediaProtocol": "internal", "remoteMedia": null, "metadata": { "tool_type": tool_type_str }, }), diff --git a/rust/crates/proxy-engine/src/sip_leg.rs b/rust/crates/proxy-engine/src/sip_leg.rs index 2f3f372..7f7965f 100644 --- a/rust/crates/proxy-engine/src/sip_leg.rs +++ b/rust/crates/proxy-engine/src/sip_leg.rs @@ -108,6 +108,24 @@ impl SipLeg { ..Default::default() }); + self.send_invite_with_sdp(from_uri, to_uri, sip_call_id, socket, sdp) + .await; + } + + pub async fn send_invite_with_sdp( + &mut self, + from_uri: &str, + to_uri: &str, + sip_call_id: &str, + socket: &UdpSocket, + sdp: String, + ) { + let ip = self + .config + .public_ip + .as_deref() + .unwrap_or(&self.config.lan_ip); + let invite = SipMessage::create_request( "INVITE", to_uri, @@ -401,6 +419,10 @@ impl SipLeg { return SipLegAction::Send(ok.serialize()); } + if method == "INVITE" || method == "UPDATE" { + return SipLegAction::InDialogRequest(method.to_string()); + } + SipLegAction::None } @@ -436,6 +458,9 @@ pub enum SipLegAction { StateChange(LegState), /// Connected — send this ACK. ConnectedWithAck(Vec), + /// Provider sent an in-dialog request (re-INVITE / UPDATE) that needs + /// call-manager-specific handling. + InDialogRequest(String), /// Terminated with a reason. Terminated(String), /// Send 200 OK and terminate. diff --git a/rust/crates/sip-proto/src/helpers.rs b/rust/crates/sip-proto/src/helpers.rs index 6929aa6..c4b777e 100644 --- a/rust/crates/sip-proto/src/helpers.rs +++ b/rust/crates/sip-proto/src/helpers.rs @@ -4,6 +4,8 @@ use md5::{Digest, Md5}; use rand::Rng; +use crate::{Endpoint, SdpMediaKind}; + // ---- ID generators --------------------------------------------------------- /// Generate a random SIP Call-ID (32 hex chars). @@ -55,6 +57,9 @@ pub struct SdpOptions<'a> { pub ip: &'a str, pub port: u16, pub payload_types: &'a [u8], + pub media_kind: SdpMediaKind, + pub transport: &'a str, + pub media_formats: &'a [&'a str], pub session_id: Option<&'a str>, pub session_name: Option<&'a str>, pub direction: Option<&'a str>, @@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> { ip: "0.0.0.0", port: 0, payload_types: &[9, 0, 8, 101], + media_kind: SdpMediaKind::Audio, + transport: "RTP/AVP", + media_formats: &[], session_id: None, session_name: None, direction: None, @@ -83,7 +91,14 @@ pub fn build_sdp(opts: &SdpOptions) -> String { .unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64))); let session_name = opts.session_name.unwrap_or("-"); let direction = opts.direction.unwrap_or("sendrecv"); - let pts: Vec = opts.payload_types.iter().map(|pt| pt.to_string()).collect(); + let media_formats: Vec = if !opts.media_formats.is_empty() { + opts.media_formats + .iter() + .map(|fmt| fmt.to_string()) + .collect() + } else { + opts.payload_types.iter().map(|pt| pt.to_string()).collect() + }; let mut lines = vec![ "v=0".to_string(), @@ -91,16 +106,24 @@ pub fn build_sdp(opts: &SdpOptions) -> String { format!("s={session_name}"), format!("c=IN IP4 {}", opts.ip), "t=0 0".to_string(), - format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")), + format!( + "m={} {} {} {}", + opts.media_kind.as_sdp_token(), + opts.port, + opts.transport, + media_formats.join(" ") + ), ]; - for &pt in opts.payload_types { - let name = codec_name(pt); - if name != "unknown" { - lines.push(format!("a=rtpmap:{pt} {name}")); - } - if pt == 101 { - lines.push("a=fmtp:101 0-16".to_string()); + if opts.media_kind == SdpMediaKind::Audio { + for &pt in opts.payload_types { + let name = codec_name(pt); + if name != "unknown" { + lines.push(format!("a=rtpmap:{pt} {name}")); + } + if pt == 101 { + lines.push("a=fmtp:101 0-16".to_string()); + } } } @@ -199,38 +222,62 @@ pub fn compute_digest_auth( // ---- SDP parser ------------------------------------------------------------ -use crate::Endpoint; - -/// Parse the audio media port, connection address, and preferred codec from an SDP body. +/// Parse the preferred media endpoint from an SDP body. +/// +/// Audio `m=` lines are preferred when present so existing RTP call flows keep +/// their current behavior. If no audio section exists, the first media section +/// is returned, which allows T.38-only SDP offers/answers to be represented. pub fn parse_sdp_endpoint(sdp: &str) -> Option { let mut addr: Option<&str> = None; - let mut port: Option = None; - let mut codec_pt: Option = None; + let mut preferred: Option<(SdpMediaKind, u16, Option, String)> = None; + let mut fallback: Option<(SdpMediaKind, u16, Option, String)> = None; let normalized = sdp.replace("\r\n", "\n"); for raw in normalized.split('\n') { let line = raw.trim(); if let Some(rest) = line.strip_prefix("c=IN IP4 ") { addr = Some(rest.trim()); - } else if let Some(rest) = line.strip_prefix("m=audio ") { - // m=audio RTP/AVP [ ...] - let parts: Vec<&str> = rest.split_whitespace().collect(); - if !parts.is_empty() { - port = parts[0].parse().ok(); + } else if let Some(rest) = line.strip_prefix("m=") { + // m= [ ...] + let mut media_and_rest = rest.splitn(2, ' '); + let media = media_and_rest.next().unwrap_or(""); + let remainder = media_and_rest.next().unwrap_or(""); + let media_kind = SdpMediaKind::from_sdp_token(media); + if media_kind == SdpMediaKind::Unknown { + continue; } - // parts[1] is "RTP/AVP" or similar, parts[2..] are payload types. - // The first PT is the preferred codec. - if parts.len() > 2 { - codec_pt = parts[2].parse::().ok(); + + let parts: Vec<&str> = remainder.split_whitespace().collect(); + if !parts.is_empty() { + if let Ok(port) = parts[0].parse() { + let transport = parts.get(1).copied().unwrap_or("").to_string(); + let codec_pt = if media_kind == SdpMediaKind::Audio && parts.len() > 2 { + parts[2].parse::().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) { - (Some(a), Some(p)) => Some(Endpoint { + match (addr, preferred.or(fallback)) { + (Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint { address: a.to_string(), - port: p, + port, codec_pt, + media_kind, + transport, }), _ => None, } @@ -327,6 +374,40 @@ mod tests { let ep = parse_sdp_endpoint(sdp).unwrap(); assert_eq!(ep.address, "10.0.0.1"); assert_eq!(ep.port, 5060); + assert_eq!(ep.media_kind, SdpMediaKind::Audio); + assert_eq!(ep.transport, "RTP/AVP"); + assert!(ep.is_audio_rtp()); + } + + #[test] + fn test_parse_t38_sdp_endpoint() { + let sdp = concat!( + "v=0\r\n", + "c=IN IP4 203.0.113.9\r\n", + "m=image 4000 udptl t38\r\n", + "a=T38FaxVersion:0\r\n", + ); + let ep = parse_sdp_endpoint(sdp).unwrap(); + assert_eq!(ep.address, "203.0.113.9"); + assert_eq!(ep.port, 4000); + assert_eq!(ep.media_kind, SdpMediaKind::Image); + assert_eq!(ep.transport, "udptl"); + assert!(ep.is_t38_udptl()); + } + + #[test] + fn test_build_t38_sdp() { + let sdp = build_sdp(&SdpOptions { + ip: "192.168.1.1", + port: 4000, + media_kind: SdpMediaKind::Image, + transport: "udptl", + media_formats: &["t38"], + attributes: &["T38FaxVersion:0"], + ..Default::default() + }); + assert!(sdp.contains("m=image 4000 udptl t38")); + assert!(sdp.contains("a=T38FaxVersion:0")); } #[test] diff --git a/rust/crates/sip-proto/src/lib.rs b/rust/crates/sip-proto/src/lib.rs index 63ff9c0..9e7d411 100644 --- a/rust/crates/sip-proto/src/lib.rs +++ b/rust/crates/sip-proto/src/lib.rs @@ -16,4 +16,47 @@ pub struct Endpoint { pub port: u16, /// First payload type from the SDP `m=audio` line (the preferred codec). pub codec_pt: Option, + /// 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") + } } diff --git a/rust/crates/sip-proto/src/rewrite.rs b/rust/crates/sip-proto/src/rewrite.rs index 3ebf208..e911a19 100644 --- a/rust/crates/sip-proto/src/rewrite.rs +++ b/rust/crates/sip-proto/src/rewrite.rs @@ -2,7 +2,7 @@ //! //! Ported from ts/sip/rewrite.ts. -use crate::Endpoint; +use crate::{Endpoint, SdpMediaKind}; /// Replaces the host:port in every `sip:` / `sips:` URI found in `value`. pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String { @@ -57,12 +57,12 @@ pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String { result } -/// Rewrites the connection address (`c=`) and audio media port (`m=audio`) -/// in an SDP body. Returns the rewritten body together with the original -/// endpoint that was replaced (if any). +/// Rewrites the connection address (`c=`) and first supported media port +/// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the +/// rewritten body together with the original endpoint that was replaced (if any). pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option) { let mut orig_addr: Option = None; - let mut orig_port: Option = None; + let mut orig_media: Option<(SdpMediaKind, u16, String)> = None; let lines: Vec = body .replace("\r\n", "\n") @@ -71,10 +71,25 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option if let Some(rest) = line.strip_prefix("c=IN IP4 ") { orig_addr = Some(rest.trim().to_string()); format!("c=IN IP4 {ip}") - } else if line.starts_with("m=audio ") { + } else if line.starts_with("m=audio ") + || line.starts_with("m=image ") + || line.starts_with("m=application ") + { let parts: Vec<&str> = line.split(' ').collect(); if parts.len() >= 2 { - orig_port = parts[1].parse().ok(); + let media_kind = parts[0] + .strip_prefix("m=") + .map(SdpMediaKind::from_sdp_token) + .unwrap_or(SdpMediaKind::Unknown); + if orig_media.is_none() { + orig_media = parts[1].parse().ok().map(|orig_port| { + ( + media_kind, + orig_port, + parts.get(2).copied().unwrap_or("").to_string(), + ) + }); + } let mut rebuilt = parts[0].to_string(); rebuilt.push(' '); rebuilt.push_str(&port.to_string()); @@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option }) .collect(); - let original = match (orig_addr, orig_port) { - (Some(a), Some(p)) => Some(Endpoint { + let original = match (orig_addr, orig_media) { + (Some(a), Some((media_kind, p, transport))) => Some(Endpoint { address: a, port: p, codec_pt: None, + media_kind, + transport, }), _ => None, }; @@ -130,5 +147,19 @@ mod tests { let ep = orig.unwrap(); assert_eq!(ep.address, "10.0.0.1"); assert_eq!(ep.port, 5060); + assert_eq!(ep.transport, "RTP/AVP"); + } + + #[test] + fn test_rewrite_t38_sdp() { + let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=image 5060 udptl t38\r\na=T38FaxVersion:0\r\n"; + let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 4000); + assert!(rewritten.contains("c=IN IP4 192.168.1.1")); + assert!(rewritten.contains("m=image 4000 udptl t38")); + let ep = orig.unwrap(); + assert_eq!(ep.address, "10.0.0.1"); + assert_eq!(ep.port, 5060); + assert_eq!(ep.media_kind, SdpMediaKind::Image); + assert_eq!(ep.transport, "udptl"); } } diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 48c426b..3fc6436 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.25.2', + version: '1.26.0', description: 'undefined' } diff --git a/ts/config.ts b/ts/config.ts index 4d77d5e..e0b3bfc 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { IFaxBoxConfig } from './faxbox.ts'; import type { IVoiceboxConfig } from './voicebox.js'; // --------------------------------------------------------------------------- @@ -113,6 +114,9 @@ export interface ISipRouteAction { /** Voicemail fallback for matched inbound routes. */ voicemailBox?: string; + /** Fax inbox target for matched inbound routes. */ + faxBox?: string; + /** Route to an IVR menu by menu ID (skip ringing devices). */ ivrMenuId?: string; @@ -189,6 +193,7 @@ export interface IContact { // "number | undefined is not assignable to number" type errors when // passing config.voiceboxes into VoiceboxManager.init(). export type { IVoiceboxConfig }; +export type { IFaxBoxConfig }; // --------------------------------------------------------------------------- // IVR configuration @@ -255,6 +260,7 @@ export interface IAppConfig { incomingNumbers?: IIncomingNumberConfig[]; routing: IRoutingConfig; contacts: IContact[]; + faxboxes?: IFaxBoxConfig[]; voiceboxes?: IVoiceboxConfig[]; ivr?: IIvrConfig; } @@ -323,6 +329,12 @@ export function loadConfig(): IAppConfig { c.starred ??= false; } + cfg.faxboxes ??= []; + for (const fb of cfg.faxboxes) { + fb.enabled ??= true; + fb.maxMessages ??= 50; + } + // Voicebox defaults. cfg.voiceboxes ??= []; for (const vb of cfg.voiceboxes) { diff --git a/ts/faxbox.ts b/ts/faxbox.ts new file mode 100644 index 0000000..fb48541 --- /dev/null +++ b/ts/faxbox.ts @@ -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(); + 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'); + } +} diff --git a/ts/faxjobs.ts b/ts/faxjobs.ts new file mode 100644 index 0000000..426091e --- /dev/null +++ b/ts/faxjobs.ts @@ -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)`); + } +} diff --git a/ts/frontend.ts b/ts/frontend.ts index 69a1f40..f6bbf22 100644 --- a/ts/frontend.ts +++ b/ts/frontend.ts @@ -11,6 +11,8 @@ import path from 'node:path'; import http from 'node:http'; import https from 'node:https'; import { WebSocketServer, WebSocket } from 'ws'; +import type { FaxBoxManager } from './faxbox.ts'; +import type { FaxJobManager } from './faxjobs.ts'; import { handleWebRtcSignaling } from './webrtcbridge.ts'; import type { VoiceboxManager } from './voicebox.ts'; @@ -22,6 +24,8 @@ interface IHandleRequestContext { onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null; onHangupCall: (callId: string) => boolean; onConfigSaved?: () => void | Promise; + faxBoxManager?: FaxBoxManager; + faxJobManager?: FaxJobManager; voiceboxManager?: VoiceboxManager; } @@ -108,7 +112,7 @@ async function handleRequest( res: http.ServerResponse, context: IHandleRequestContext, ): Promise { - const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context; + const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager } = context; const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const method = req.method || 'GET'; @@ -147,6 +151,65 @@ async function handleRequest( } } + // API: send outbound fax. + if (url.pathname === '/api/fax' && method === 'POST') { + try { + const body = await readJsonBody(req); + const number = body?.number; + const filePath = body?.filePath; + if (!number || typeof number !== 'string') { + return sendJson(res, { ok: false, error: 'missing "number" field' }, 400); + } + if (!filePath || typeof filePath !== 'string') { + return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400); + } + const { sendFax } = await import('./proxybridge.ts'); + const callId = await sendFax(number, filePath, body?.providerId); + if (callId) { + log(`[dashboard] fax started: ${callId} -> ${number} file=${filePath}`); + return sendJson(res, { ok: true, callId }); + } + return sendJson(res, { ok: false, error: 'fax origination failed' }, 503); + } catch (e: any) { + return sendJson(res, { ok: false, error: e.message }, 400); + } + } + + // API: fax jobs. + if (url.pathname === '/api/fax/jobs' && method === 'GET' && faxJobManager) { + return sendJson(res, { ok: true, jobs: faxJobManager.getJobs() }); + } + + // API: fax inbox - list messages. + const faxListMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)$/); + if (faxListMatch && method === 'GET' && faxBoxManager) { + const boxId = faxListMatch[1]; + return sendJson(res, { ok: true, messages: faxBoxManager.getMessages(boxId) }); + } + + // API: fax inbox - stream TIFF. + const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/); + if (faxFileMatch && method === 'GET' && faxBoxManager) { + const [, boxId, msgId] = faxFileMatch; + const filePath = faxBoxManager.getMessageFilePath(boxId, msgId); + if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404); + const stat = fs.statSync(filePath); + res.writeHead(200, { + 'Content-Type': 'image/tiff', + 'Content-Length': stat.size.toString(), + 'Accept-Ranges': 'bytes', + }); + fs.createReadStream(filePath).pipe(res); + return; + } + + // API: fax inbox - delete message. + const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/); + if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) { + const [, boxId, msgId] = faxDeleteMatch; + return sendJson(res, { ok: faxBoxManager.deleteMessage(boxId, msgId) }); + } + // API: add a SIP device to a call (mid-call INVITE to desk phone). if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') { try { @@ -273,6 +336,7 @@ async function handleRequest( } } if (updates.contacts !== undefined) cfg.contacts = updates.contacts; + if (updates.faxboxes !== undefined) cfg.faxboxes = updates.faxboxes; if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes; if (updates.ivr !== undefined) cfg.ivr = updates.ivr; @@ -368,6 +432,8 @@ export function initWebUi( onStartCall, onHangupCall, onConfigSaved, + faxBoxManager, + faxJobManager, voiceboxManager, onWebRtcOffer, onWebRtcIce, @@ -387,12 +453,12 @@ export function initWebUi( const cert = fs.readFileSync(certPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8'); server = https.createServer({ cert, key }, (req, res) => - handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), + handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), ); useTls = true; } catch { server = http.createServer((req, res) => - handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), + handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }), ); } @@ -429,7 +495,9 @@ export function initWebUi( } } else if (msg.type?.startsWith('webrtc-')) { msg._remoteIp = remoteIp; - handleWebRtcSignaling(socket, msg); + if (msg.type) { + handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string }); + } } } catch { /* ignore */ } }); diff --git a/ts/proxybridge.ts b/ts/proxybridge.ts index 31c1a58..acd76cb 100644 --- a/ts/proxybridge.ts +++ b/ts/proxybridge.ts @@ -17,6 +17,9 @@ export type { ICallEndedEvent, ICallRingingEvent, IDeviceRegisteredEvent, + IFaxCompletedEvent, + IFaxFailedEvent, + IFaxStartedEvent, IIncomingCallEvent, ILegAddedEvent, ILegRemovedEvent, @@ -52,6 +55,10 @@ type TProxyCommands = { params: { number: string; device_id?: string; provider_id?: string }; result: { call_id: string }; }; + send_fax: { + params: { number: string; file_path: string; provider_id?: string }; + result: { call_id: string; codec: 'PCMU' | 'PCMA' }; + }; add_leg: { params: { call_id: string; number: string; provider_id?: string }; result: { leg_id: string }; @@ -262,6 +269,21 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s } } +export async function sendFax(number: string, filePath: string, providerId?: string): Promise { + 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. */ diff --git a/ts/runtime/proxy-events.ts b/ts/runtime/proxy-events.ts index fa37225..6edd46d 100644 --- a/ts/runtime/proxy-events.ts +++ b/ts/runtime/proxy-events.ts @@ -1,4 +1,6 @@ -import { onProxyEvent } from '../proxybridge.ts'; +import { hangupCall, onProxyEvent } from '../proxybridge.ts'; +import type { FaxBoxManager } from '../faxbox.ts'; +import type { FaxJobManager } from '../faxjobs.ts'; import type { VoiceboxManager } from '../voicebox.ts'; import type { StatusStore } from './status-store.ts'; import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts'; @@ -6,6 +8,8 @@ import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts' export interface IRegisterProxyEventHandlersOptions { log: (msg: string) => void; statusStore: StatusStore; + faxBoxManager: FaxBoxManager; + faxJobManager: FaxJobManager; voiceboxManager: VoiceboxManager; webRtcLinks: WebRtcLinkManager; getBrowserDeviceIds: () => string[]; @@ -19,6 +23,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO const { log, statusStore, + faxBoxManager, + faxJobManager, voiceboxManager, webRtcLinks, getBrowserDeviceIds, @@ -30,6 +36,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO const legMediaDetails = (data: { codec?: string | null; + mediaProtocol?: string | null; remoteMedia?: string | null; rtpPort?: number | null; }): string => { @@ -37,6 +44,9 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO if (data.codec) { parts.push(`codec=${data.codec}`); } + if (data.mediaProtocol) { + parts.push(`media=${data.mediaProtocol}`); + } if (data.remoteMedia) { parts.push(`remote=${data.remoteMedia}`); } @@ -91,6 +101,14 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`); statusStore.noteOutboundCallStarted(data); + if (data.ring_browsers === false) { + faxJobManager.noteDialing(data.call_id, data.number, data.provider_id); + } + + if (data.ring_browsers === false) { + return; + } + for (const deviceId of getBrowserDeviceIds()) { sendToBrowserDevice(deviceId, { type: 'webrtc-incoming', @@ -110,6 +128,10 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO log(`[call] ${data.call_id} connected`); } + if (data.media_protocol && data.media_protocol !== 'rtp') { + return; + } + if (!data.provider_media_addr || !data.provider_media_port) { return; } @@ -207,4 +229,37 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO onProxyEvent('voicemail_error', (data) => { log(`[voicemail] error: ${data.error} call=${data.call_id}`); }); + + onProxyEvent('fax_started', (data) => { + faxJobManager.noteStarted(data); + log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`); + }); + + onProxyEvent('fax_completed', (data) => { + faxJobManager.noteCompleted(data); + log( + `[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`, + ); + if (data.direction === 'inbound' && data.success && data.fax_box_id) { + faxBoxManager.addMessage(data.fax_box_id, { + callerNumber: data.caller_number, + fileName: data.file_path, + completionCode: data.completion_code, + completionLabel: data.completion_label, + pageCount: data.stats.pages_rx || data.stats.pages_tx, + bitRate: data.stats.bit_rate, + }); + } + if (data.direction === 'outbound' || data.fax_box_id) { + void hangupCall(data.call_id); + } + }); + + onProxyEvent('fax_failed', (data) => { + faxJobManager.noteFailed(data); + log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`); + if (data.direction === 'outbound' || data.fax_box_id) { + void hangupCall(data.call_id); + } + }); } diff --git a/ts/runtime/status-store.ts b/ts/runtime/status-store.ts index 620c546..37925bf 100644 --- a/ts/runtime/status-store.ts +++ b/ts/runtime/status-store.ts @@ -88,16 +88,12 @@ export class StatusStore { } noteDashboardCallStarted(callId: string, number: string, providerId?: string): void { - this.activeCalls.set(callId, { - id: callId, - direction: 'outbound', - callerNumber: null, - calleeNumber: number, - providerUsed: providerId || null, - state: 'setting-up', - startedAt: Date.now(), - legs: new Map(), - }); + const call = this.getOrCreateCall(callId, 'outbound'); + call.direction = 'outbound'; + call.callerNumber = null; + call.calleeNumber = number; + call.providerUsed = providerId || null; + call.state = 'setting-up'; } noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null { @@ -126,56 +122,39 @@ export class StatusStore { } noteIncomingCall(data: IIncomingCallEvent): void { - this.activeCalls.set(data.call_id, { - id: data.call_id, - direction: 'inbound', - callerNumber: data.from_uri, - calleeNumber: data.to_number, - providerUsed: data.provider_id, - state: 'ringing', - startedAt: Date.now(), - legs: new Map(), - }); - } - - noteOutboundDeviceCall(data: IOutboundCallEvent): void { - this.activeCalls.set(data.call_id, { - id: data.call_id, - direction: 'outbound', - callerNumber: data.from_device, - calleeNumber: data.to_number, - providerUsed: null, - state: 'setting-up', - startedAt: Date.now(), - legs: new Map(), - }); - } - - noteOutboundCallStarted(data: IOutboundCallStartedEvent): void { - this.activeCalls.set(data.call_id, { - id: data.call_id, - direction: 'outbound', - callerNumber: null, - calleeNumber: data.number, - providerUsed: data.provider_id, - state: 'setting-up', - startedAt: Date.now(), - legs: new Map(), - }); - } - - noteCallRinging(data: ICallRingingEvent): void { - const call = this.activeCalls.get(data.call_id); - if (call) { + const call = this.getOrCreateCall(data.call_id, 'inbound'); + call.direction = 'inbound'; + call.callerNumber = data.from_uri; + call.calleeNumber = data.to_number; + call.providerUsed = data.provider_id; + if (call.state === 'setting-up') { call.state = 'ringing'; } } + noteOutboundDeviceCall(data: IOutboundCallEvent): void { + const call = this.getOrCreateCall(data.call_id, 'outbound'); + call.direction = 'outbound'; + call.callerNumber = data.from_device; + call.calleeNumber = data.to_number; + call.providerUsed = null; + } + + noteOutboundCallStarted(data: IOutboundCallStartedEvent): void { + const call = this.getOrCreateCall(data.call_id, 'outbound'); + call.direction = 'outbound'; + call.callerNumber = call.callerNumber ?? null; + call.calleeNumber = data.number; + call.providerUsed = data.provider_id; + } + + noteCallRinging(data: ICallRingingEvent): void { + const call = this.getOrCreateCall(data.call_id); + call.state = 'ringing'; + } + noteCallAnswered(data: ICallAnsweredEvent): boolean { - const call = this.activeCalls.get(data.call_id); - if (!call) { - return false; - } + const call = this.getOrCreateCall(data.call_id); call.state = 'connected'; @@ -186,7 +165,12 @@ export class StatusStore { } leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`; - if (data.sip_pt !== undefined) { + if (data.media_protocol) { + leg.mediaProtocol = data.media_protocol; + } + if (data.media_protocol === 't38-udptl') { + leg.codec = 'T.38'; + } else if (data.sip_pt !== undefined) { leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`; } break; @@ -216,6 +200,7 @@ export class StatusStore { state: leg.state, codec: leg.codec, rtpPort: leg.rtpPort, + mediaProtocol: leg.mediaProtocol, remoteMedia: leg.remoteMedia, metadata: leg.metadata || {}, })), @@ -230,10 +215,7 @@ export class StatusStore { } noteLegAdded(data: ILegAddedEvent): void { - const call = this.activeCalls.get(data.call_id); - if (!call) { - return; - } + const call = this.getOrCreateCall(data.call_id); call.legs.set(data.leg_id, { id: data.leg_id, @@ -241,6 +223,7 @@ export class StatusStore { state: data.state, codec: data.codec ?? null, rtpPort: data.rtpPort ?? null, + mediaProtocol: data.mediaProtocol ?? null, remoteMedia: data.remoteMedia ?? null, metadata: data.metadata || {}, }); @@ -251,10 +234,7 @@ export class StatusStore { } noteLegStateChanged(data: ILegStateChangedEvent): void { - const call = this.activeCalls.get(data.call_id); - if (!call) { - return; - } + const call = this.getOrCreateCall(data.call_id); const existingLeg = call.legs.get(data.leg_id); if (existingLeg) { @@ -265,6 +245,9 @@ export class StatusStore { if (data.rtpPort !== undefined) { existingLeg.rtpPort = data.rtpPort; } + if (data.mediaProtocol !== undefined) { + existingLeg.mediaProtocol = data.mediaProtocol; + } if (data.remoteMedia !== undefined) { existingLeg.remoteMedia = data.remoteMedia; } @@ -280,6 +263,7 @@ export class StatusStore { state: data.state, codec: data.codec ?? null, rtpPort: data.rtpPort ?? null, + mediaProtocol: data.mediaProtocol ?? null, remoteMedia: data.remoteMedia ?? null, metadata: data.metadata || {}, }); @@ -323,4 +307,22 @@ export class StatusStore { } return 'webrtc'; } + + private getOrCreateCall(callId: string, direction: 'inbound' | 'outbound' = 'inbound'): IActiveCall { + let call = this.activeCalls.get(callId); + if (!call) { + call = { + id: callId, + direction, + callerNumber: null, + calleeNumber: null, + providerUsed: null, + state: 'setting-up', + startedAt: Date.now(), + legs: new Map(), + }; + this.activeCalls.set(callId, call); + } + return call; + } } diff --git a/ts/shared/proxy-events.ts b/ts/shared/proxy-events.ts index ef83b03..37a1aa1 100644 --- a/ts/shared/proxy-events.ts +++ b/ts/shared/proxy-events.ts @@ -18,6 +18,7 @@ export interface IOutboundCallStartedEvent { call_id: string; number: string; provider_id: string; + ring_browsers?: boolean; } export interface ICallRingingEvent { @@ -28,6 +29,7 @@ export interface ICallAnsweredEvent { call_id: string; provider_media_addr?: string; provider_media_port?: number; + media_protocol?: string; sip_pt?: number; } @@ -67,6 +69,7 @@ export interface ILegAddedEvent { state: string; codec?: string | null; rtpPort?: number | null; + mediaProtocol?: string | null; remoteMedia?: string | null; metadata?: Record; } @@ -82,6 +85,7 @@ export interface ILegStateChangedEvent { state: string; codec?: string | null; rtpPort?: number | null; + mediaProtocol?: string | null; remoteMedia?: string | null; metadata?: Record; } @@ -128,6 +132,56 @@ export interface IVoicemailErrorEvent { error: string; } +export interface IFaxStartedEvent { + call_id: string; + leg_id: string; + direction: 'outbound' | 'inbound'; + transport: 'audio' | 't38'; + file_path: string; + fax_box_id?: string; + caller_number?: string; + codec?: string; + remote_media?: string; +} + +export interface IFaxCompletedEvent { + call_id: string; + leg_id: string; + direction: 'outbound' | 'inbound'; + transport: 'audio' | 't38'; + file_path: string; + fax_box_id?: string; + caller_number?: string; + codec?: string; + success: boolean; + completion_code?: number | null; + completion_label?: string | null; + stats: { + bit_rate: number; + error_correcting_mode: boolean; + pages_tx: number; + pages_rx: number; + image_size: number; + bad_rows: number; + longest_bad_row_run: number; + ecm_retries: number; + current_status: number; + rtp_events: number; + rtn_events: number; + }; +} + +export interface IFaxFailedEvent { + call_id: string; + leg_id: string; + direction: 'outbound' | 'inbound'; + transport: 'audio' | 't38'; + file_path: string; + fax_box_id?: string; + caller_number?: string; + error: string; +} + export type TProxyEventMap = { provider_registered: IProviderRegisteredEvent; device_registered: IDeviceRegisteredEvent; @@ -148,4 +202,7 @@ export type TProxyEventMap = { voicemail_started: IVoicemailStartedEvent; recording_done: IRecordingDoneEvent; voicemail_error: IVoicemailErrorEvent; + fax_started: IFaxStartedEvent; + fax_completed: IFaxCompletedEvent; + fax_failed: IFaxFailedEvent; }; diff --git a/ts/shared/status.ts b/ts/shared/status.ts index b1e80e2..4080765 100644 --- a/ts/shared/status.ts +++ b/ts/shared/status.ts @@ -26,6 +26,7 @@ export interface IActiveLeg { state: string; codec: string | null; rtpPort: number | null; + mediaProtocol: string | null; remoteMedia: string | null; metadata: Record; } diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index ca8b309..7350a25 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.ts @@ -9,6 +9,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { loadConfig, type IAppConfig } from './config.ts'; +import { FaxBoxManager } from './faxbox.ts'; +import { FaxJobManager } from './faxjobs.ts'; import { broadcastWs, initWebUi } from './frontend.ts'; import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts'; import { VoiceboxManager } from './voicebox.ts'; @@ -35,8 +37,12 @@ const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const statusStore = new StatusStore(appConfig); const webRtcLinks = new WebRtcLinkManager(); +const faxBoxManager = new FaxBoxManager(log); +const faxJobManager = new FaxJobManager(log); const voiceboxManager = new VoiceboxManager(log); +faxBoxManager.init(appConfig.faxboxes ?? []); +faxJobManager.init(); voiceboxManager.init(appConfig.voiceboxes ?? []); initWebRtcSignaling({ log }); @@ -61,6 +67,7 @@ function buildProxyConfig(config: IAppConfig): Record { providers: config.providers, devices: config.devices, routing: config.routing, + faxboxes: config.faxboxes ?? [], voiceboxes: config.voiceboxes ?? [], ivr: config.ivr, }; @@ -93,6 +100,7 @@ async function reloadConfig(): Promise { appConfig = nextConfig; statusStore.updateConfig(nextConfig); + faxBoxManager.init(nextConfig.faxboxes ?? []); voiceboxManager.init(nextConfig.voiceboxes ?? []); if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) { @@ -123,6 +131,8 @@ async function startProxyEngine(): Promise { registerProxyEventHandlers({ log, statusStore, + faxBoxManager, + faxJobManager, voiceboxManager, webRtcLinks, getBrowserDeviceIds: getAllBrowserDeviceIds, @@ -167,6 +177,8 @@ initWebUi({ return true; }, onConfigSaved: reloadConfig, + faxBoxManager, + faxJobManager, voiceboxManager, onWebRtcOffer: async (sessionId, sdp, ws) => { log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`); @@ -187,7 +199,7 @@ initWebUi({ log('[webrtc] ERROR: no answer SDP from Rust'); }, onWebRtcIce: async (sessionId, candidate) => { - await webrtcIce(sessionId, candidate); + await webrtcIce(sessionId, candidate as Parameters[1]); }, onWebRtcClose: async (sessionId) => { webRtcLinks.removeSession(sessionId); diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 48c426b..3fc6436 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.25.2', + version: '1.26.0', description: 'undefined' } diff --git a/ts_web/elements/sipproxy-view-calls.ts b/ts_web/elements/sipproxy-view-calls.ts index afe3137..be6a517 100644 --- a/ts_web/elements/sipproxy-view-calls.ts +++ b/ts_web/elements/sipproxy-view-calls.ts @@ -175,36 +175,240 @@ export class SipproxyViewCalls extends DeesElement { .call-body { padding: 12px 16px 16px; + display: grid; + gap: 16px; } - .legs-table { - width: 100%; - border-collapse: collapse; - font-size: 0.75rem; - margin-bottom: 12px; + .call-overview { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr); + gap: 14px; } - .legs-table th { - text-align: left; - color: #64748b; - font-weight: 500; - font-size: 0.65rem; + .call-route-card, + .call-facts-card, + .legs-section { + border-radius: 14px; + border: 1px solid rgba(51, 65, 85, 0.75); + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.92) 0%, rgba(8, 15, 31, 0.88) 100%); + box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08); + } + + .call-route-card, + .call-facts-card { + padding: 14px; + } + + .section-kicker { + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; - letter-spacing: 0.04em; - padding: 6px 8px; - border-bottom: 1px solid var(--dees-color-border-default, #334155); + color: #64748b; } - .legs-table td { - padding: 8px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.7rem; - border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5)); - vertical-align: middle; + .route-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; + margin-top: 12px; } - .legs-table tr:last-child td { + .route-party { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(71, 85, 105, 0.45); + } + + .route-party.align-end { + text-align: right; + align-items: flex-end; + } + + .route-party-label { + font-size: 0.64rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; + } + + .route-party-value { + min-width: 0; + font-size: 0.95rem; + font-weight: 600; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .route-arrow { + width: 34px; + height: 34px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + color: #93c5fd; + background: rgba(30, 41, 59, 0.8); + border: 1px solid rgba(59, 130, 246, 0.35); + } + + .call-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + } + + .subtle-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border-radius: 999px; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.03em; + color: #cbd5e1; + background: rgba(30, 41, 59, 0.9); + border: 1px solid rgba(71, 85, 105, 0.45); + } + + .call-facts-card { + display: grid; + gap: 8px; + } + + .fact-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid rgba(51, 65, 85, 0.55); + } + + .fact-row:last-child { border-bottom: none; + padding-bottom: 0; + } + + .fact-label { + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; + } + + .fact-value { + font-family: 'JetBrains Mono', monospace; + font-size: 0.76rem; + text-align: right; + color: #e2e8f0; + word-break: break-word; + } + + .legs-section { + padding: 14px; + display: grid; + gap: 12px; + } + + .legs-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + } + + .legs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; + } + + .leg-card { + display: grid; + gap: 12px; + padding: 12px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(71, 85, 105, 0.4); + } + + .leg-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + } + + .leg-card-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .leg-card-id { + font-family: 'JetBrains Mono', monospace; + font-size: 0.64rem; + color: #64748b; + word-break: break-all; + text-align: right; + } + + .leg-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + } + + .leg-fact { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .leg-fact-wide { + grid-column: 1 / -1; + } + + .leg-fact-label { + font-size: 0.62rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; + } + + .leg-fact-value { + font-family: 'JetBrains Mono', monospace; + font-size: 0.76rem; + color: #e2e8f0; + word-break: break-word; + } + + .leg-actions { + display: flex; + justify-content: flex-end; + } + + .no-legs { + padding: 16px; + border-radius: 12px; + border: 1px dashed rgba(71, 85, 105, 0.55); + color: #64748b; + font-size: 0.75rem; + text-align: center; } .card-actions { @@ -247,6 +451,34 @@ export class SipproxyViewCalls extends DeesElement { .empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; } .empty-state-text { font-size: 0.9rem; font-weight: 500; } .empty-state-sub { font-size: 0.75rem; margin-top: 4px; } + + @media (max-width: 820px) { + .call-overview { + grid-template-columns: 1fr; + } + + .route-line { + grid-template-columns: 1fr; + } + + .route-arrow { + justify-self: center; + transform: rotate(90deg); + } + + .route-party.align-end { + text-align: left; + align-items: flex-start; + } + + .leg-card-top { + flex-direction: column; + } + + .leg-card-id { + text-align: left; + } + } `, ]; @@ -550,61 +782,110 @@ export class SipproxyViewCalls extends DeesElement {
${call.id}
- ${call.legs.length - ? html` - - - - - - - - - - - - - - +
+
+
Call Route
+
+
+
From
+
${call.callerNumber || 'Unknown caller'}
+
+
${directionIcon(call.direction)}
+
+
To
+
${call.calleeNumber || 'System'}
+
+
+
+ ${call.legs.length} ${call.legs.length === 1 ? 'leg' : 'legs'} + ${call.providerUsed || 'system handled'} + started ${fmtTime(call.startedAt)} +
+
+ +
+
Session
+
+ State + ${STATE_LABELS[call.state] || call.state} +
+
+ Direction + ${call.direction} +
+
+ Duration + ${fmtDuration(call.duration)} +
+
+ Provider + ${call.providerUsed || '--'} +
+
+
+ +
+
+
Active Legs
+ ${call.legs.length} +
+ + ${call.legs.length + ? html` +
${call.legs.map( (leg) => html` -
- - - - - - - - - + + `, )} - -
TypeStateRemotePortCodecPkts InPkts Out
- - ${LEG_TYPE_LABELS[leg.type] || leg.type} - - - - ${leg.state} - - - ${leg.remoteMedia || '--'} - ${leg.rtpPort ?? '--'} - ${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''} - ${leg.pktReceived}${leg.pktSent} +
+
+
+ + ${LEG_TYPE_LABELS[leg.type] || leg.type} + + + ${STATE_LABELS[leg.state] || leg.state} + +
+
${leg.id}
+
+ +
+
+ Codec + ${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''} +
+
+ RTP Port + ${leg.rtpPort ?? '--'} +
+
+ Remote Media + ${leg.remoteMedia || '--'} +
+
+ Packets In + ${leg.pktReceived} +
+
+ Packets Out + ${leg.pktSent} +
+
+ +
-
- ` - : html`
- No legs -
`} +
+ ` + : html`
No legs reported yet. SIP/system legs should appear here as soon as the call is wired.
`} +