diff --git a/changelog.md b/changelog.md index 001c68c..a883680 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-10 - 1.12.0 - feat(proxy-engine) +add Rust-based outbound calling, WebRTC bridging, and voicemail handling + +- adds outbound call origination through the Rust proxy engine with dashboard make_call support +- routes unanswered inbound calls to voicemail, including greeting playback, beep generation, and WAV message recording +- introduces Rust WebRTC session handling and SIP audio bridging, replacing the previous TypeScript WebRTC path +- moves SIP registration and routing responsibilities further into the Rust proxy engine and removes legacy TypeScript call/SIP modules + ## 2026-04-10 - 1.11.0 - feat(rust-proxy-engine) add a Rust SIP proxy engine with shared SIP and codec libraries diff --git a/package.json b/package.json index 71e2375..7cea0f3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "@design.estate/dees-element": "^2.2.4", "@push.rocks/smartrust": "^1.3.2", "@push.rocks/smartstate": "^2.3.0", - "werift": "^0.22.9", "ws": "^8.20.0" }, "devDependencies": { diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ab8bc33..f3e003b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,6 +8,103 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aead" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc95d1bdb8e6666b2b217308eeeb09f2d6728d104be3e31916cc74d15420331" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", + "rand_core 0.6.4", +] + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884391ef1066acaa41e766ba8f596341b96e93ce34f9a43e7d24bf0a0eaf0561" +dependencies = [ + "aes-soft", + "aesni", + "cipher 0.2.5", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if 1.0.4", + "cipher 0.3.0", + "cpufeatures 0.2.17", + "opaque-debug", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if 1.0.4", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash", + "subtle", +] + +[[package]] +name = "aes-soft" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be14c7498ea50828a38d0e24a765ed2effe92a705885b57d029cd67d45744072" +dependencies = [ + "cipher 0.2.5", + "opaque-debug", +] + +[[package]] +name = "aesni" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2e11f5e94c2f7d386164cc2aa1f97823fed6f259e486940a71c174dd01b0ce" +dependencies = [ + "cipher 0.2.5", + "opaque-debug", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -35,6 +132,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "array-init" version = "2.1.0" @@ -47,6 +153,90 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ff05a702273012438132f449575dbc804e27b2f3cbe3069aa237d26c98fa33" +dependencies = [ + "asn1-rs-derive 0.1.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" +dependencies = [ + "asn1-rs-derive 0.4.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b7511298d5b7784b40b092d9e9dcd3a627a5707e4b5e507931ab0d44eeebf" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "audiopus" version = "0.3.0-rc.0" @@ -73,6 +263,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -85,6 +287,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "2.0.1" @@ -105,6 +316,12 @@ dependencies = [ "virtue", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -120,6 +337,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-modes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a0e8073e8baa88212fb5823574c02ebccb395136ba9a164ab89379ec6072f0" +dependencies = [ + "block-padding", + "cipher 0.2.5", +] + +[[package]] +name = "block-padding" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -159,6 +398,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aca1a8fbc20b50ac9673ff014abfb2b5f4085ee1a850d408f14a159c5853ac7" +dependencies = [ + "aead 0.3.2", + "cipher 0.2.5", + "subtle", +] + [[package]] name = "cedarwood" version = "0.4.6" @@ -168,6 +418,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -180,8 +436,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if", - "cpufeatures", + "cfg-if 1.0.4", + "cpufeatures 0.3.0", "rand_core 0.10.0", ] @@ -203,6 +459,34 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58b52a9840ffff5d4d0058ae529fa066a75e794e3125546acfc61c23ad755e49" +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -222,6 +506,12 @@ dependencies = [ "rubato", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.10.1" @@ -247,6 +537,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -256,13 +555,40 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", ] [[package]] @@ -272,25 +598,163 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "dary_heap" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "pem-rfc7468 0.6.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "pem-rfc7468", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" +dependencies = [ + "asn1-rs 0.3.1", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" +dependencies = [ + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -300,6 +764,46 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07adf7be193b71cc36b193d0f5fe60b918a3a9db4dad0449f57bcfd519704a3" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f91d4cfa921f1c05904dc3c57b4a32c38aed3340cce209f3a6fd1478babafc4" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0314b72bed045f3a68671b3c86328386762c93f82d98c65c3cb5e5f573dd68" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -308,6 +812,18 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -329,6 +845,40 @@ dependencies = [ "rustfft", ] +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint", + "der 0.6.1", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.6.0", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -346,7 +896,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -384,7 +934,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e9290c43fb0b524a7191b3bc326bd4252bb1e080e27f4667641ad91bc845471" dependencies = [ - "bitflags", + "bitflags 2.11.0", "byte-slice-cast", "ezk", ] @@ -423,12 +973,34 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -456,6 +1028,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.32" @@ -512,7 +1093,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -564,15 +1145,26 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -581,7 +1173,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "r-efi 5.3.0", "wasip2", @@ -593,7 +1185,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "r-efi 6.0.0", "rand_core 0.10.0", @@ -601,6 +1193,27 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -633,6 +1246,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac-sha256" version = "1.1.14" @@ -661,12 +1298,121 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "include-flate" version = "0.3.2" @@ -687,7 +1433,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -712,6 +1458,40 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "interceptor" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c142385498b53584546abbfa50188b2677af8e4f879da1ee5d905cb7de5b97a" +dependencies = [ + "async-trait", + "bytes", + "log", + "rand 0.8.5", + "rtcp", + "rtp", + "thiserror", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -751,13 +1531,23 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kokoro-tts" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68e5d46e20a28fa5fd313d9ffcf4bbcf41570e64841d3944c832eef6b98d208b" dependencies = [ - "bincode", + "bincode 2.0.1", "cc", "chinese-number", "futures", @@ -772,6 +1562,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -814,6 +1610,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -851,8 +1653,8 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if", - "digest", + "cfg-if 1.0.4", + "digest 0.10.7", ] [[package]] @@ -861,6 +1663,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -868,7 +1685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys", ] @@ -904,6 +1721,18 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if 1.0.4", + "libc", + "memoffset", +] + [[package]] name = "nnnoiseless" version = "0.5.2" @@ -914,6 +1743,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -958,20 +1797,44 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e20717fa0541f39bd146692035c37bedfa532b3e5071b35761082407546b2a" +dependencies = [ + "asn1-rs 0.3.1", +] + +[[package]] +name = "oid-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" +dependencies = [ + "asn1-rs 0.5.2", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags", - "cfg-if", + "bitflags 2.11.0", + "cfg-if 1.0.4", "foreign-types", "libc", "once_cell", @@ -987,7 +1850,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1022,7 +1885,7 @@ dependencies = [ name = "opus-codec" version = "0.2.0" dependencies = [ - "base64", + "base64 0.22.1", "codec-lib", "serde", "serde_json", @@ -1052,6 +1915,28 @@ dependencies = [ "ureq", ] +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "p384" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc8c5bf642dde52bb9e87c0ecd8ca5a76faac2eeed98dedb7c717997e1080aa" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1068,13 +1953,31 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall", "smallvec", "windows-link", ] +[[package]] +name = "pem" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +dependencies = [ + "base64 0.13.1", +] + +[[package]] +name = "pem-rfc7468" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -1146,7 +2049,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1161,12 +2064,34 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b" +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1182,6 +2107,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1204,7 +2138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1235,7 +2169,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1251,13 +2185,16 @@ dependencies = [ name = "proxy-engine" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "codec-lib", + "hound", + "rand 0.8.5", "regex-lite", "serde", "serde_json", "sip-proto", "tokio", + "webrtc", ] [[package]] @@ -1313,6 +2250,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1334,6 +2280,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +[[package]] +name = "rcgen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbe84efe2f38dea12e9bfc1f65377fdf03e53a18cb3b995faedf7934c7e785b" +dependencies = [ + "pem", + "ring", + "time", + "x509-parser 0.14.0", + "yasna", +] + [[package]] name = "realfft" version = "3.5.0" @@ -1349,7 +2308,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1393,12 +2352,49 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b9cb77e81b1d036c2543436df7c5ac85839b1a774a826a9f2607c57094d1a23" +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + [[package]] name = "rle-decode-fast" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +[[package]] +name = "rtcp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6423493804221c276d27f3cc383cd5cbe1a1f10f210909fd4951b579b01293cd" +dependencies = [ + "bytes", + "thiserror", + "webrtc-util", +] + [[package]] name = "rtcp-types" version = "0.1.0" @@ -1408,6 +2404,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rtp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b728adb99b88d932f2f0622b540bf7ccb196f81e9823b5b0eeb166526c88138c" +dependencies = [ + "bytes", + "rand 0.8.5", + "serde", + "thiserror", + "webrtc-util", +] + [[package]] name = "rtp-types" version = "0.1.2" @@ -1436,6 +2445,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustfft" version = "6.4.1" @@ -1450,19 +2468,41 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", "windows-sys", ] +[[package]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64 0.13.1", + "log", + "ring", + "sct", + "webpki", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1472,6 +2512,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "schannel" version = "0.1.29" @@ -1487,13 +2533,49 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sdp" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d22a5ef407871893fd72b4562ee15e4742269b173959db4b8df6f538c414e13" +dependencies = [ + "rand 0.8.5", + "substring", + "thiserror", + "url", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der 0.6.1", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -1543,7 +2625,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1559,6 +2641,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1575,6 +2679,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "sip-proto" version = "0.1.0" @@ -1610,6 +2724,25 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1631,12 +2764,85 @@ dependencies = [ "winapi", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strength_reduce" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "stun" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e94b1ec00bad60e6410e058b52f1c66de3dc5fe4d62d09b3e52bb7d3b73e25" +dependencies = [ + "base64 0.13.1", + "crc", + "lazy_static", + "md-5", + "rand 0.8.5", + "ring", + "subtle", + "thiserror", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1648,6 +2854,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1678,7 +2907,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1688,10 +2917,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1700,6 +2931,26 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.51.1" @@ -1712,7 +2963,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys", ] @@ -1725,7 +2976,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1767,6 +3018,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "turn" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4712ee30d123ec7ae26d1e1b218395a16c87cdbaf4b3925d170d684af62ea5e8" +dependencies = [ + "async-trait", + "base64 0.13.1", + "futures", + "log", + "md-5", + "rand 0.8.5", + "ring", + "stun", + "thiserror", + "tokio", + "webrtc-util", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1785,6 +3055,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "unty" version = "0.0.4" @@ -1797,8 +3083,8 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", - "der", + "base64 0.22.1", + "der 0.8.0", "log", "native-tls", "percent-encoding", @@ -1815,18 +3101,47 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http", "httparse", "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1845,6 +3160,21 @@ version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1869,6 +3199,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if 1.0.4", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1897,12 +3272,32 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -1912,6 +3307,215 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60dde9fd592872bc371b3842e4616bc4c6984242e3cd2a7d7cb771db278601b" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "cfg-if 0.1.10", + "hex", + "interceptor", + "lazy_static", + "log", + "rand 0.8.5", + "rcgen", + "regex", + "ring", + "rtcp", + "rtp", + "rustls", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror", + "time", + "tokio", + "turn", + "url", + "waitgroup", + "webrtc-data", + "webrtc-dtls", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3c7ba7d11733e448d8d2d054814e97c558f52293f0e0a2eb05840f28b3be12" +dependencies = [ + "bytes", + "derive_builder", + "log", + "thiserror", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-dtls" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a00f4242f2db33307347bd5be53263c52a0331c96c14292118c9a6bb48d267" +dependencies = [ + "aes 0.6.0", + "aes-gcm", + "async-trait", + "bincode 1.3.3", + "block-modes", + "byteorder", + "ccm", + "curve25519-dalek 3.2.0", + "der-parser 8.2.0", + "elliptic-curve", + "hkdf", + "hmac", + "log", + "p256", + "p384", + "rand 0.8.5", + "rand_core 0.6.4", + "rcgen", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "signature", + "subtle", + "thiserror", + "tokio", + "webpki", + "webrtc-util", + "x25519-dalek", + "x509-parser 0.13.2", +] + +[[package]] +name = "webrtc-ice" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465a03cc11e9a7d7b4f9f99870558fe37a102b65b93f8045392fef7c67b39e80" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "rand 0.8.5", + "serde", + "serde_json", + "stun", + "thiserror", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f08dfd7a6e3987e255c4dbe710dde5d94d0f0574f8a21afa95d171376c143106" +dependencies = [ + "log", + "socket2 0.4.10", + "thiserror", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8e3711a321f6a375973144f48065cf705316ab6709672954aace020c668eb6" +dependencies = [ + "byteorder", + "bytes", + "rand 0.8.5", + "rtp", + "thiserror", +] + +[[package]] +name = "webrtc-sctp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df742d91cfbd982f6ab2bfd45a7c3ddfce5b2f55913b2f63877404d1b3259db" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "rand 0.8.5", + "thiserror", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683b597b3c6af47ff11e695697f881bc42acfd8feeb0d4eb20a5ae9caaee6ae" +dependencies = [ + "aead 0.4.3", + "aes 0.7.5", + "aes-gcm", + "byteorder", + "bytes", + "ctr 0.8.0", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f1db1727772c05cf7a2cfece52c3aca8045ca1e176cd517d323489aa3c6d87" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "cc", + "ipnet", + "lazy_static", + "libc", + "log", + "nix", + "rand 0.8.5", + "thiserror", + "tokio", + "winapi", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1979,7 +3583,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1995,7 +3599,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2007,7 +3611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -2037,6 +3641,93 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9bace5b5589ffead1afb76e43e34cff39cd0f3ce7e170ae0c29e53b88eb1c" +dependencies = [ + "asn1-rs 0.3.1", + "base64 0.13.1", + "data-encoding", + "der-parser 7.0.0", + "lazy_static", + "nom", + "oid-registry 0.4.0", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0ecbeb7b67ce215e40e3cc7f2ff902f94a223acf44995934763467e7b1febc8" +dependencies = [ + "asn1-rs 0.5.2", + "base64 0.13.1", + "data-encoding", + "der-parser 8.2.0", + "lazy_static", + "nom", + "oid-registry 0.6.1", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure 0.13.2", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2054,7 +3745,28 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure 0.13.2", ] [[package]] @@ -2062,6 +3774,53 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zmij" diff --git a/rust/crates/proxy-engine/Cargo.toml b/rust/crates/proxy-engine/Cargo.toml index c48afc4..c9d9d94 100644 --- a/rust/crates/proxy-engine/Cargo.toml +++ b/rust/crates/proxy-engine/Cargo.toml @@ -15,3 +15,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" regex-lite = "0.1" +webrtc = "0.8" +rand = "0.8" +hound = "3.5" diff --git a/rust/crates/proxy-engine/src/audio_player.rs b/rust/crates/proxy-engine/src/audio_player.rs new file mode 100644 index 0000000..4c5332a --- /dev/null +++ b/rust/crates/proxy-engine/src/audio_player.rs @@ -0,0 +1,173 @@ +//! Audio player — reads a WAV file and streams it as RTP packets. + +use crate::rtp::{build_rtp_header, rtp_clock_increment}; +use codec_lib::{codec_sample_rate, TranscodeState}; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::time::{self, Duration}; + +/// Play a WAV file as RTP to a destination. +/// Returns when playback is complete. +pub async fn play_wav_file( + file_path: &str, + socket: Arc, + dest: SocketAddr, + codec_pt: u8, + ssrc: u32, +) -> Result { + let path = Path::new(file_path); + if !path.exists() { + return Err(format!("WAV file not found: {file_path}")); + } + + // Read WAV file. + let mut reader = + hound::WavReader::open(path).map_err(|e| format!("open WAV {file_path}: {e}"))?; + let spec = reader.spec(); + let wav_rate = spec.sample_rate; + + // Read all samples as i16. + let samples: Vec = if spec.bits_per_sample == 16 { + reader + .samples::() + .filter_map(|s| s.ok()) + .collect() + } else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float { + reader + .samples::() + .filter_map(|s| s.ok()) + .map(|s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16) + .collect() + } else { + return Err(format!( + "unsupported WAV format: {}bit {:?}", + spec.bits_per_sample, spec.sample_format + )); + }; + + if samples.is_empty() { + return Ok(0); + } + + // Create codec state for encoding. + let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?; + + // Resample to target codec rate. + let target_rate = codec_sample_rate(codec_pt); + let resampled = if wav_rate != target_rate { + transcoder + .resample(&samples, wav_rate, target_rate) + .map_err(|e| format!("resample: {e}"))? + } else { + samples + }; + + // Calculate frame size (20ms of audio at target rate). + let frame_samples = (target_rate as usize) / 50; // 20ms = 1/50 second + + // Stream as RTP at 20ms intervals. + let mut seq: u16 = 0; + let mut ts: u32 = 0; + let mut offset = 0; + let mut interval = time::interval(Duration::from_millis(20)); + let mut frames_sent = 0u32; + + while offset < resampled.len() { + interval.tick().await; + + let end = (offset + frame_samples).min(resampled.len()); + let frame = &resampled[offset..end]; + + // Pad short final frame with silence. + let frame_data = if frame.len() < frame_samples { + let mut padded = frame.to_vec(); + padded.resize(frame_samples, 0); + padded + } else { + frame.to_vec() + }; + + // Encode to target codec. + let encoded = match transcoder.encode_from_pcm(&frame_data, codec_pt) { + Ok(e) if !e.is_empty() => e, + _ => { + offset += frame_samples; + continue; + } + }; + + // Build RTP packet. + let header = build_rtp_header(codec_pt, seq, ts, ssrc); + let mut packet = header.to_vec(); + packet.extend_from_slice(&encoded); + + let _ = socket.send_to(&packet, dest).await; + + seq = seq.wrapping_add(1); + ts = ts.wrapping_add(rtp_clock_increment(codec_pt)); + offset += frame_samples; + frames_sent += 1; + } + + Ok(frames_sent) +} + +/// Generate and play a beep tone (sine wave) as RTP. +pub async fn play_beep( + socket: Arc, + dest: SocketAddr, + codec_pt: u8, + ssrc: u32, + start_seq: u16, + start_ts: u32, + freq_hz: u32, + duration_ms: u32, +) -> Result<(u16, u32), String> { + let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?; + let target_rate = codec_sample_rate(codec_pt); + let frame_samples = (target_rate as usize) / 50; + let total_samples = (target_rate as usize * duration_ms as usize) / 1000; + + // Generate sine wave. + let amplitude = 16000i16; + let sine: Vec = (0..total_samples) + .map(|i| { + let t = i as f64 / target_rate as f64; + (amplitude as f64 * (2.0 * std::f64::consts::PI * freq_hz as f64 * t).sin()) as i16 + }) + .collect(); + + let mut seq = start_seq; + let mut ts = start_ts; + let mut offset = 0; + let mut interval = time::interval(Duration::from_millis(20)); + + while offset < sine.len() { + interval.tick().await; + + let end = (offset + frame_samples).min(sine.len()); + let mut frame = sine[offset..end].to_vec(); + frame.resize(frame_samples, 0); + + let encoded = match transcoder.encode_from_pcm(&frame, codec_pt) { + Ok(e) if !e.is_empty() => e, + _ => { + offset += frame_samples; + continue; + } + }; + + let header = build_rtp_header(codec_pt, seq, ts, ssrc); + let mut packet = header.to_vec(); + packet.extend_from_slice(&encoded); + let _ = socket.send_to(&packet, dest).await; + + seq = seq.wrapping_add(1); + ts = ts.wrapping_add(rtp_clock_increment(codec_pt)); + offset += frame_samples; + } + + Ok((seq, ts)) +} diff --git a/rust/crates/proxy-engine/src/call_manager.rs b/rust/crates/proxy-engine/src/call_manager.rs index db33685..ba44045 100644 --- a/rust/crates/proxy-engine/src/call_manager.rs +++ b/rust/crates/proxy-engine/src/call_manager.rs @@ -241,15 +241,25 @@ impl CallManager { .unwrap_or("") .to_string(); - // Resolve target device (first registered device for now). + // Resolve target device (first registered device). let device_addr = match self.resolve_first_device(config, registrar) { Some(addr) => addr, None => { - // No device available — could route to voicemail - // For now, send 480 Temporarily Unavailable. - let resp = SipMessage::create_response(480, "Temporarily Unavailable", invite, None); - let _ = socket.send_to(&resp.serialize(), from_addr).await; - return None; + // No device registered — route to voicemail. + return self + .route_to_voicemail( + &call_id, + invite, + from_addr, + &caller_number, + provider_id, + provider_config, + config, + rtp_pool, + socket, + public_ip, + ) + .await; } }; @@ -487,6 +497,225 @@ impl CallManager { self.calls.contains_key(sip_call_id) } + // --- Dashboard outbound call (B2BUA) --- + + /// Initiate an outbound call from the dashboard. + /// Builds an INVITE from scratch and sends it to the provider. + /// The browser connects separately via WebRTC and gets linked to this call. + pub async fn make_outbound_call( + &mut self, + number: &str, + provider_config: &ProviderConfig, + config: &AppConfig, + rtp_pool: &mut RtpPortPool, + socket: &UdpSocket, + public_ip: Option<&str>, + registered_aor: &str, + ) -> Option { + let call_id = self.next_call_id(); + let lan_ip = &config.proxy.lan_ip; + let lan_port = config.proxy.lan_port; + let pub_ip = public_ip.unwrap_or(lan_ip.as_str()); + + let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() { + Some(a) => a, + None => return None, + }; + + // Allocate RTP port for the provider leg. + let rtp_alloc = match rtp_pool.allocate().await { + Some(a) => a, + None => return None, + }; + + // Build the SIP Call-ID for this new dialog. + let sip_call_id = sip_proto::helpers::generate_call_id(None); + + // Build SDP offer. + let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions { + ip: pub_ip, + port: rtp_alloc.port, + payload_types: &provider_config.codecs, + ..Default::default() + }); + + // Build INVITE. + let to_uri = format!("sip:{number}@{}", provider_config.domain); + let invite = SipMessage::create_request( + "INVITE", + &to_uri, + sip_proto::message::RequestOptions { + via_host: pub_ip.to_string(), + via_port: lan_port, + via_transport: None, + via_branch: Some(sip_proto::helpers::generate_branch()), + from_uri: registered_aor.to_string(), + from_display_name: None, + from_tag: Some(sip_proto::helpers::generate_tag()), + to_uri: to_uri.clone(), + to_display_name: None, + to_tag: None, + call_id: Some(sip_call_id.clone()), + cseq: Some(1), + contact: Some(format!("")), + max_forwards: Some(70), + body: Some(sdp), + content_type: Some("application/sdp".to_string()), + extra_headers: Some(vec![ + ("User-Agent".to_string(), "SipRouter/1.0".to_string()), + ("Allow".to_string(), "INVITE, ACK, OPTIONS, CANCEL, BYE, INFO".to_string()), + ]), + }, + ); + + // Send INVITE to provider. + let _ = socket.send_to(&invite.serialize(), provider_dest).await; + + // Create call entry — device_addr is a dummy (WebRTC will be linked later). + let dummy_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let call = PassthroughCall { + id: call_id.clone(), + sip_call_id: sip_call_id.clone(), + state: CallState::SettingUp, + direction: CallDirection::Outbound, + created_at: Instant::now(), + caller_number: Some(registered_aor.to_string()), + callee_number: Some(number.to_string()), + provider_id: provider_config.id.clone(), + provider_addr: provider_dest, + provider_media: None, + device_addr: dummy_addr, + device_media: None, + rtp_port: rtp_alloc.port, + rtp_socket: rtp_alloc.socket.clone(), + pkt_from_device: 0, + pkt_from_provider: 0, + }; + self.calls.insert(sip_call_id, call); + + Some(call_id) + } + + // --- Voicemail --- + + /// Route a call to voicemail: answer the INVITE, play greeting, record message. + async fn route_to_voicemail( + &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>, + ) -> Option { + let lan_ip = &config.proxy.lan_ip; + let pub_ip = public_ip.unwrap_or(lan_ip.as_str()); + + // Allocate RTP port for the voicemail session. + let rtp_alloc = match rtp_pool.allocate().await { + Some(a) => a, + None => { + let resp = + SipMessage::create_response(503, "Service Unavailable", invite, None); + let _ = socket.send_to(&resp.serialize(), from_addr).await; + return None; + } + }; + + // Determine provider's preferred codec. + let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); // default G.722 + + // Build SDP with our RTP port. + let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions { + ip: pub_ip, + port: rtp_alloc.port, + payload_types: &provider_config.codecs, + ..Default::default() + }); + + // Answer the INVITE with 200 OK. + 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; + + // Extract provider media from original SDP. + let provider_media = if invite.has_sdp_body() { + sip_proto::helpers::parse_sdp_endpoint(&invite.body) + .and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok()) + } else { + Some(from_addr) // fallback to signaling address + }; + let provider_media = provider_media.unwrap_or(from_addr); + + // Create a voicemail call entry for BYE routing. + let call = PassthroughCall { + id: call_id.to_string(), + sip_call_id: invite.call_id().to_string(), + state: CallState::Voicemail, + direction: CallDirection::Inbound, + created_at: std::time::Instant::now(), + caller_number: Some(caller_number.to_string()), + callee_number: None, + provider_id: provider_id.to_string(), + provider_addr: from_addr, + provider_media: Some(provider_media), + device_addr: from_addr, // no device — just use provider addr as placeholder + device_media: None, + rtp_port: rtp_alloc.port, + rtp_socket: rtp_alloc.socket.clone(), + pkt_from_device: 0, + pkt_from_provider: 0, + }; + self.calls.insert(invite.call_id().to_string(), call); + + // Build recording file path. + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let recording_dir = format!(".nogit/voicemail/default"); + let recording_path = format!("{recording_dir}/msg-{timestamp}.wav"); + + // Look for a greeting WAV file. + let greeting_wav = find_greeting_wav(); + + // Spawn the voicemail session. + let out_tx = self.out_tx.clone(); + let call_id_owned = call_id.to_string(); + let caller_owned = caller_number.to_string(); + let rtp_socket = rtp_alloc.socket; + tokio::spawn(async move { + crate::voicemail::run_voicemail_session( + rtp_socket, + provider_media, + codec_pt, + greeting_wav, + recording_path, + 120_000, // max 120 seconds + call_id_owned, + caller_owned, + out_tx, + ) + .await; + }); + + Some(call_id.to_string()) + } + // --- Internal helpers --- fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option { @@ -495,10 +724,25 @@ impl CallManager { return Some(addr); } } - None + None // No device registered — caller goes to voicemail. } } +/// Find a voicemail greeting WAV file. +fn find_greeting_wav() -> Option { + // Check common locations for a pre-generated greeting. + let candidates = [ + ".nogit/voicemail/default/greeting.wav", + ".nogit/voicemail/greeting.wav", + ]; + for path in &candidates { + if std::path::Path::new(path).exists() { + return Some(path.to_string()); + } + } + None // No greeting found — voicemail will just play the beep. +} + /// Rewrite SDP for provider→device direction (use LAN IP). fn rewrite_sdp_for_device(msg: &mut SipMessage, lan_ip: &str, rtp_port: u16) { if msg.has_sdp_body() { diff --git a/rust/crates/proxy-engine/src/config.rs b/rust/crates/proxy-engine/src/config.rs index 6de94c8..745190b 100644 --- a/rust/crates/proxy-engine/src/config.rs +++ b/rust/crates/proxy-engine/src/config.rs @@ -14,8 +14,18 @@ pub struct Endpoint { } impl Endpoint { + /// Resolve to a SocketAddr. Handles both IP addresses and hostnames. pub fn to_socket_addr(&self) -> Option { - format!("{}:{}", self.address, self.port).parse().ok() + // Try direct parse first (IP address). + if let Ok(addr) = format!("{}:{}", self.address, self.port).parse() { + return Some(addr); + } + // DNS resolution for hostnames. + use std::net::ToSocketAddrs; + format!("{}:{}", self.address, self.port) + .to_socket_addrs() + .ok() + .and_then(|mut addrs| addrs.next()) } } diff --git a/rust/crates/proxy-engine/src/main.rs b/rust/crates/proxy-engine/src/main.rs index 51b5a6f..ba41bb2 100644 --- a/rust/crates/proxy-engine/src/main.rs +++ b/rust/crates/proxy-engine/src/main.rs @@ -6,15 +6,19 @@ /// /// No raw SIP ever touches TypeScript. +mod audio_player; mod call; mod call_manager; mod config; mod dtmf; mod ipc; mod provider; +mod recorder; mod registrar; mod rtp; mod sip_transport; +mod voicemail; +mod webrtc_engine; use crate::call_manager::CallManager; use crate::config::AppConfig; @@ -23,6 +27,7 @@ use crate::provider::ProviderManager; use crate::registrar::Registrar; use crate::rtp::RtpPortPool; use crate::sip_transport::SipTransport; +use crate::webrtc_engine::WebRtcEngine; use sip_proto::message::SipMessage; use std::net::SocketAddr; use std::sync::Arc; @@ -37,6 +42,7 @@ struct ProxyEngine { provider_mgr: ProviderManager, registrar: Registrar, call_mgr: CallManager, + webrtc: WebRtcEngine, rtp_pool: Option, out_tx: OutTx, } @@ -49,6 +55,7 @@ impl ProxyEngine { provider_mgr: ProviderManager::new(out_tx.clone()), registrar: Registrar::new(out_tx.clone()), call_mgr: CallManager::new(out_tx.clone()), + webrtc: WebRtcEngine::new(out_tx.clone()), rtp_pool: None, out_tx, } @@ -111,7 +118,12 @@ async fn handle_command(engine: Arc>, out_tx: &OutTx, cmd: Co match cmd.method.as_str() { "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, "get_status" => handle_get_status(engine, out_tx, &cmd).await, + "webrtc_offer" => handle_webrtc_offer(engine, out_tx, &cmd).await, + "webrtc_ice" => handle_webrtc_ice(engine, out_tx, &cmd).await, + "webrtc_link" => handle_webrtc_link(engine, out_tx, &cmd).await, + "webrtc_close" => handle_webrtc_close(engine, out_tx, &cmd).await, _ => respond_err(out_tx, &cmd.id, &format!("unknown command: {}", cmd.method)), } } @@ -413,6 +425,78 @@ async fn handle_get_status(engine: Arc>, out_tx: &OutTx, cmd: respond_ok(out_tx, &cmd.id, serde_json::json!({ "calls": calls })); } +/// Handle `make_call` — initiate an outbound call to a number via a provider. +async fn handle_make_call(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 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; } + }; + + // Resolve provider. + let provider_config = if let Some(pid) = provider_id { + config_ref.providers.iter().find(|p| p.id == pid).cloned() + } else { + // Use route resolution or first provider. + let route = config_ref.resolve_outbound_route(&number, None, &|_| true); + route.map(|r| r.provider) + }; + + let provider_config = match provider_config { + Some(p) => p, + None => { respond_err(out_tx, &cmd.id, "no provider available"); return; } + }; + + // Get public IP and registered AOR from provider state. + 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 { + // Fallback — construct AOR from config. + (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; + + match call_id { + Some(id) => { + emit_event(out_tx, "outbound_call_started", serde_json::json!({ + "call_id": id, + "number": number, + "provider_id": provider_config.id, + })); + respond_ok(out_tx, &cmd.id, serde_json::json!({ "call_id": id })); + } + None => { + respond_err(out_tx, &cmd.id, "call origination failed — provider not registered or no ports available"); + } + } +} + /// 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()) { @@ -438,3 +522,105 @@ async fn handle_hangup(engine: Arc>, out_tx: &OutTx, cmd: &Co respond_err(out_tx, &cmd.id, &format!("call {call_id} not found")); } } + +/// Handle `webrtc_offer` — browser sends SDP offer, we create PeerConnection and return answer. +async fn handle_webrtc_offer(engine: Arc>, out_tx: &OutTx, cmd: &Command) { + let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; } + }; + let offer_sdp = match cmd.params.get("sdp").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing sdp"); return; } + }; + + let mut eng = engine.lock().await; + match eng.webrtc.handle_offer(&session_id, &offer_sdp).await { + Ok(answer_sdp) => { + respond_ok(out_tx, &cmd.id, serde_json::json!({ + "session_id": session_id, + "sdp": answer_sdp, + })); + } + Err(e) => respond_err(out_tx, &cmd.id, &e), + } +} + +/// Handle `webrtc_ice` — forward ICE candidate from browser to Rust PeerConnection. +async fn handle_webrtc_ice(engine: Arc>, out_tx: &OutTx, cmd: &Command) { + let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; } + }; + let candidate = cmd.params.get("candidate").and_then(|v| v.as_str()).unwrap_or(""); + let sdp_mid = cmd.params.get("sdp_mid").and_then(|v| v.as_str()); + let sdp_mline_index = cmd.params.get("sdp_mline_index").and_then(|v| v.as_u64()).map(|v| v as u16); + + let eng = engine.lock().await; + match eng.webrtc.add_ice_candidate(&session_id, candidate, sdp_mid, sdp_mline_index).await { + Ok(()) => respond_ok(out_tx, &cmd.id, serde_json::json!({})), + Err(e) => respond_err(out_tx, &cmd.id, &e), + } +} + +/// Handle `webrtc_link` — link a WebRTC session to a SIP call for audio bridging. +async fn handle_webrtc_link(engine: Arc>, out_tx: &OutTx, cmd: &Command) { + let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; } + }; + let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing call_id"); return; } + }; + let provider_addr = match cmd.params.get("provider_media_addr").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing provider_media_addr"); return; } + }; + let provider_port = match cmd.params.get("provider_media_port").and_then(|v| v.as_u64()) { + Some(p) => p as u16, + None => { respond_err(out_tx, &cmd.id, "missing provider_media_port"); return; } + }; + let sip_pt = cmd.params.get("sip_pt").and_then(|v| v.as_u64()).unwrap_or(9) as u8; + + let provider_media: SocketAddr = match format!("{provider_addr}:{provider_port}").parse() { + Ok(a) => a, + Err(e) => { respond_err(out_tx, &cmd.id, &format!("bad address: {e}")); return; } + }; + + let mut eng = engine.lock().await; + let sip_socket = match &eng.transport { + Some(t) => t.socket(), + None => { respond_err(out_tx, &cmd.id, "not initialized"); return; } + }; + + let bridge_info = crate::webrtc_engine::SipBridgeInfo { + provider_media, + sip_pt, + sip_socket, + }; + + if eng.webrtc.link_to_sip(&session_id, &call_id, bridge_info).await { + respond_ok(out_tx, &cmd.id, serde_json::json!({ + "session_id": session_id, + "call_id": call_id, + "bridged": true, + })); + } else { + respond_err(out_tx, &cmd.id, &format!("session {session_id} not found")); + } +} + +/// Handle `webrtc_close` — close a WebRTC session. +async fn handle_webrtc_close(engine: Arc>, out_tx: &OutTx, cmd: &Command) { + let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; } + }; + + let mut eng = engine.lock().await; + match eng.webrtc.close_session(&session_id).await { + Ok(()) => respond_ok(out_tx, &cmd.id, serde_json::json!({})), + Err(e) => respond_err(out_tx, &cmd.id, &e), + } +} diff --git a/rust/crates/proxy-engine/src/recorder.rs b/rust/crates/proxy-engine/src/recorder.rs new file mode 100644 index 0000000..464e4c5 --- /dev/null +++ b/rust/crates/proxy-engine/src/recorder.rs @@ -0,0 +1,132 @@ +//! Audio recorder — receives RTP packets and writes a WAV file. + +use codec_lib::TranscodeState; +use std::path::Path; + +/// Active recording session. +pub struct Recorder { + writer: hound::WavWriter>, + transcoder: TranscodeState, + source_pt: u8, + total_samples: u64, + sample_rate: u32, + max_samples: Option, + file_path: String, +} + +impl Recorder { + /// Create a new recorder that writes to a WAV file. + /// `source_pt` is the RTP payload type of the incoming audio. + /// `max_duration_ms` optionally limits the recording length. + pub fn new( + file_path: &str, + source_pt: u8, + max_duration_ms: Option, + ) -> Result { + // Ensure parent directory exists. + if let Some(parent) = Path::new(file_path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create dir: {e}"))?; + } + + let sample_rate = 8000u32; // Record at 8kHz (standard telephony) + let spec = hound::WavSpec { + channels: 1, + sample_rate, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + + let writer = hound::WavWriter::create(file_path, spec) + .map_err(|e| format!("create WAV {file_path}: {e}"))?; + + let transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?; + + let max_samples = max_duration_ms.map(|ms| (sample_rate as u64 * ms) / 1000); + + Ok(Self { + writer, + transcoder, + source_pt, + total_samples: 0, + sample_rate, + max_samples, + file_path: file_path.to_string(), + }) + } + + /// Process an incoming RTP packet (full packet with header). + /// Returns true if recording should continue, false if max duration reached. + pub fn process_rtp(&mut self, data: &[u8]) -> bool { + if data.len() <= 12 { + return true; // Too short, skip. + } + + let pt = data[1] & 0x7F; + // Skip telephone-event (DTMF) packets. + if pt == 101 { + return true; + } + + let payload = &data[12..]; + if payload.is_empty() { + return true; + } + + // Decode to PCM. + let (pcm, rate) = match self.transcoder.decode_to_pcm(payload, self.source_pt) { + Ok(result) => result, + Err(_) => return true, // Decode failed, skip packet. + }; + + // Resample to 8kHz if needed. + let pcm_8k = if rate != self.sample_rate { + match self.transcoder.resample(&pcm, rate, self.sample_rate) { + Ok(r) => r, + Err(_) => return true, + } + } else { + pcm + }; + + // Write samples. + for &sample in &pcm_8k { + if let Err(_) = self.writer.write_sample(sample) { + return false; + } + self.total_samples += 1; + + if let Some(max) = self.max_samples { + if self.total_samples >= max { + return false; // Max duration reached. + } + } + } + + true + } + + /// Stop recording and finalize the WAV file. + pub fn stop(self) -> RecordingResult { + let duration_ms = if self.sample_rate > 0 { + (self.total_samples * 1000) / self.sample_rate as u64 + } else { + 0 + }; + + // Writer is finalized on drop (writes RIFF header sizes). + drop(self.writer); + + RecordingResult { + file_path: self.file_path, + duration_ms, + total_samples: self.total_samples, + } + } +} + +pub struct RecordingResult { + pub file_path: String, + pub duration_ms: u64, + pub total_samples: u64, +} diff --git a/rust/crates/proxy-engine/src/voicemail.rs b/rust/crates/proxy-engine/src/voicemail.rs new file mode 100644 index 0000000..06d40a0 --- /dev/null +++ b/rust/crates/proxy-engine/src/voicemail.rs @@ -0,0 +1,137 @@ +//! Voicemail session — answer → play greeting → beep → record → done. + +use crate::audio_player::{play_beep, play_wav_file}; +use crate::ipc::{emit_event, OutTx}; +use crate::recorder::Recorder; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::UdpSocket; + +/// Run a voicemail session on an RTP port. +/// +/// 1. Plays the greeting WAV file to the caller +/// 2. Plays a beep tone +/// 3. Records the caller's message until BYE or max duration +/// +/// The RTP receive loop is separate — it feeds packets to the recorder +/// via the returned channel. +pub async fn run_voicemail_session( + rtp_socket: Arc, + provider_media: SocketAddr, + codec_pt: u8, + greeting_wav: Option, + recording_path: String, + max_recording_ms: u64, + call_id: String, + caller_number: String, + out_tx: OutTx, +) { + let ssrc: u32 = rand::random(); + + emit_event( + &out_tx, + "voicemail_started", + serde_json::json!({ + "call_id": call_id, + "caller_number": caller_number, + }), + ); + + // Step 1: Play greeting. + let mut next_seq: u16 = 0; + let mut next_ts: u32 = 0; + + if let Some(wav_path) = &greeting_wav { + match play_wav_file(wav_path, rtp_socket.clone(), provider_media, codec_pt, ssrc).await { + Ok(frames) => { + next_seq = frames as u16; + next_ts = frames * crate::rtp::rtp_clock_increment(codec_pt); + } + Err(e) => { + emit_event( + &out_tx, + "voicemail_error", + serde_json::json!({ "call_id": call_id, "error": format!("greeting: {e}") }), + ); + } + } + } + + // Step 2: Play beep (1kHz, 500ms). + match play_beep( + rtp_socket.clone(), + provider_media, + codec_pt, + ssrc, + next_seq, + next_ts, + 1000, + 500, + ) + .await + { + Ok((_seq, _ts)) => {} + Err(e) => { + emit_event( + &out_tx, + "voicemail_error", + serde_json::json!({ "call_id": call_id, "error": format!("beep: {e}") }), + ); + } + } + + // Step 3: Record incoming audio. + let recorder = match Recorder::new(&recording_path, codec_pt, Some(max_recording_ms)) { + Ok(r) => r, + Err(e) => { + emit_event( + &out_tx, + "voicemail_error", + serde_json::json!({ "call_id": call_id, "error": format!("recorder: {e}") }), + ); + return; + } + }; + + // Receive RTP and feed to recorder. + let result = record_from_socket(rtp_socket, recorder, max_recording_ms).await; + + // Step 4: Done — emit recording result. + emit_event( + &out_tx, + "recording_done", + serde_json::json!({ + "call_id": call_id, + "file_path": result.file_path, + "duration_ms": result.duration_ms, + "caller_number": caller_number, + }), + ); +} + +/// Read RTP packets from the socket and feed them to the recorder. +/// Returns when the socket errors out (BYE closes the call/socket) +/// or max duration is reached. +async fn record_from_socket( + socket: Arc, + mut recorder: Recorder, + max_ms: u64, +) -> crate::recorder::RecordingResult { + let mut buf = vec![0u8; 65535]; + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_ms + 2000); + + loop { + let timeout = tokio::time::timeout_at(deadline, socket.recv_from(&mut buf)); + match timeout.await { + Ok(Ok((n, _addr))) => { + if !recorder.process_rtp(&buf[..n]) { + break; // Max duration reached. + } + } + Ok(Err(_)) => break, // Socket error (closed). + Err(_) => break, // Timeout (max duration + grace). + } + } + + recorder.stop() +} diff --git a/rust/crates/proxy-engine/src/webrtc_engine.rs b/rust/crates/proxy-engine/src/webrtc_engine.rs new file mode 100644 index 0000000..b3852ec --- /dev/null +++ b/rust/crates/proxy-engine/src/webrtc_engine.rs @@ -0,0 +1,389 @@ +//! WebRTC engine — manages browser PeerConnections with SIP audio bridging. +//! +//! Browser Opus audio → Rust PeerConnection → transcode via codec-lib → SIP RTP +//! SIP RTP → transcode via codec-lib → Rust PeerConnection → Browser Opus + +use crate::ipc::{emit_event, OutTx}; +use crate::rtp::{build_rtp_header, rtp_clock_increment}; +use codec_lib::{TranscodeState, PT_G722, PT_OPUS}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::sync::Mutex; +use webrtc::api::media_engine::MediaEngine; +use webrtc::api::APIBuilder; +use webrtc::ice_transport::ice_candidate::RTCIceCandidateInit; +use webrtc::peer_connection::configuration::RTCConfiguration; +use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; +use webrtc::peer_connection::sdp::session_description::RTCSessionDescription; +use webrtc::peer_connection::RTCPeerConnection; +use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability; +use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP; +use webrtc::track::track_local::{TrackLocal, TrackLocalWriter}; + +/// SIP-side bridge info for a WebRTC session. +#[derive(Clone)] +pub struct SipBridgeInfo { + /// Provider's media endpoint (RTP destination). + pub provider_media: SocketAddr, + /// Provider's codec payload type (e.g. 9 for G.722). + pub sip_pt: u8, + /// The SIP UDP socket for sending RTP to the provider. + pub sip_socket: Arc, +} + +/// A managed WebRTC session. +struct WebRtcSession { + pc: Arc, + local_track: Arc, + call_id: Option, + /// SIP bridge — set when the session is linked to a call. + sip_bridge: Arc>>, +} + +/// Manages all WebRTC sessions. +pub struct WebRtcEngine { + sessions: HashMap, + out_tx: OutTx, +} + +impl WebRtcEngine { + pub fn new(out_tx: OutTx) -> Self { + Self { + sessions: HashMap::new(), + out_tx, + } + } + + /// Handle a WebRTC offer from a browser. + pub async fn handle_offer( + &mut self, + session_id: &str, + offer_sdp: &str, + ) -> Result { + let mut media_engine = MediaEngine::default(); + media_engine + .register_default_codecs() + .map_err(|e| format!("register codecs: {e}"))?; + + let api = APIBuilder::new() + .with_media_engine(media_engine) + .build(); + + let config = RTCConfiguration { + ice_servers: vec![], + ..Default::default() + }; + + let pc = api + .new_peer_connection(config) + .await + .map_err(|e| format!("create peer connection: {e}"))?; + let pc = Arc::new(pc); + + // Local audio track for sending audio to browser (Opus). + let local_track = Arc::new(TrackLocalStaticRTP::new( + RTCRtpCodecCapability { + mime_type: "audio/opus".to_string(), + clock_rate: 48000, + channels: 1, + ..Default::default() + }, + "audio".to_string(), + "siprouter".to_string(), + )); + + let _sender = pc + .add_track(local_track.clone() as Arc) + .await + .map_err(|e| format!("add track: {e}"))?; + + // Shared SIP bridge info (populated when linked to a call). + let sip_bridge: Arc>> = Arc::new(Mutex::new(None)); + + // ICE candidate handler. + let out_tx_ice = self.out_tx.clone(); + let sid_ice = session_id.to_string(); + pc.on_ice_candidate(Box::new(move |candidate| { + let out_tx = out_tx_ice.clone(); + let sid = sid_ice.clone(); + Box::pin(async move { + if let Some(c) = candidate { + if let Ok(json) = c.to_json() { + emit_event( + &out_tx, + "webrtc_ice_candidate", + serde_json::json!({ + "session_id": sid, + "candidate": json.candidate, + "sdp_mid": json.sdp_mid, + "sdp_mline_index": json.sdp_mline_index, + }), + ); + } + } + }) + })); + + // Connection state handler. + let out_tx_state = self.out_tx.clone(); + let sid_state = session_id.to_string(); + pc.on_peer_connection_state_change(Box::new(move |state| { + let out_tx = out_tx_state.clone(); + let sid = sid_state.clone(); + Box::pin(async move { + let state_str = match state { + RTCPeerConnectionState::Connected => "connected", + RTCPeerConnectionState::Disconnected => "disconnected", + RTCPeerConnectionState::Failed => "failed", + RTCPeerConnectionState::Closed => "closed", + RTCPeerConnectionState::New => "new", + RTCPeerConnectionState::Connecting => "connecting", + _ => "unknown", + }; + emit_event( + &out_tx, + "webrtc_state", + serde_json::json!({ "session_id": sid, "state": state_str }), + ); + }) + })); + + // Track handler — receives Opus audio from the browser. + // When SIP bridge is set, transcodes and forwards to provider. + let out_tx_track = self.out_tx.clone(); + let sid_track = session_id.to_string(); + let sip_bridge_for_track = sip_bridge.clone(); + pc.on_track(Box::new(move |track, _receiver, _transceiver| { + let out_tx = out_tx_track.clone(); + let sid = sid_track.clone(); + let bridge = sip_bridge_for_track.clone(); + Box::pin(async move { + let codec_info = track.codec(); + emit_event( + &out_tx, + "webrtc_track", + serde_json::json!({ + "session_id": sid, + "kind": track.kind().to_string(), + "codec": codec_info.capability.mime_type, + }), + ); + + // Spawn the browser→SIP audio forwarding task. + tokio::spawn(browser_to_sip_loop(track, bridge, out_tx, sid)); + }) + })); + + // Set remote offer. + let offer = RTCSessionDescription::offer(offer_sdp.to_string()) + .map_err(|e| format!("parse offer: {e}"))?; + pc.set_remote_description(offer) + .await + .map_err(|e| format!("set remote description: {e}"))?; + + // Create answer. + let answer = pc + .create_answer(None) + .await + .map_err(|e| format!("create answer: {e}"))?; + let answer_sdp = answer.sdp.clone(); + pc.set_local_description(answer) + .await + .map_err(|e| format!("set local description: {e}"))?; + + self.sessions.insert( + session_id.to_string(), + WebRtcSession { + pc, + local_track, + call_id: None, + sip_bridge, + }, + ); + + Ok(answer_sdp) + } + + /// Link a WebRTC session to a SIP call — sets up the audio bridge. + pub async fn link_to_sip( + &mut self, + session_id: &str, + call_id: &str, + bridge_info: SipBridgeInfo, + ) -> bool { + if let Some(session) = self.sessions.get_mut(session_id) { + session.call_id = Some(call_id.to_string()); + let mut bridge = session.sip_bridge.lock().await; + *bridge = Some(bridge_info); + true + } else { + false + } + } + + /// Send transcoded audio from the SIP side to the browser. + /// Called by the RTP relay when it receives a packet from the provider. + pub async fn forward_sip_to_browser( + &self, + session_id: &str, + sip_rtp_payload: &[u8], + sip_pt: u8, + ) -> Result<(), String> { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| format!("session {session_id} not found"))?; + + // Transcode SIP codec → Opus. + // We create a temporary TranscodeState per packet for simplicity. + // TODO: Use a per-session persistent state for proper codec continuity. + let mut transcoder = TranscodeState::new().map_err(|e| format!("codec: {e}"))?; + let opus_payload = transcoder + .transcode(sip_rtp_payload, sip_pt, PT_OPUS, Some("to_browser")) + .map_err(|e| format!("transcode: {e}"))?; + + if opus_payload.is_empty() { + return Ok(()); + } + + // Build RTP header for Opus. + // TODO: Track seq/ts/ssrc per session for proper continuity. + let header = build_rtp_header(PT_OPUS, 0, 0, 0); + let mut packet = header.to_vec(); + packet.extend_from_slice(&opus_payload); + + session + .local_track + .write(&packet) + .await + .map(|_| ()) + .map_err(|e| format!("write: {e}")) + } + + pub async fn add_ice_candidate( + &self, + session_id: &str, + candidate: &str, + sdp_mid: Option<&str>, + sdp_mline_index: Option, + ) -> Result<(), String> { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| format!("session {session_id} not found"))?; + + let init = RTCIceCandidateInit { + candidate: candidate.to_string(), + sdp_mid: sdp_mid.map(|s| s.to_string()), + sdp_mline_index, + ..Default::default() + }; + + session + .pc + .add_ice_candidate(init) + .await + .map_err(|e| format!("add ICE: {e}")) + } + + pub async fn close_session(&mut self, session_id: &str) -> Result<(), String> { + if let Some(session) = self.sessions.remove(session_id) { + session.pc.close().await.map_err(|e| format!("close: {e}"))?; + } + Ok(()) + } + + pub fn has_session(&self, session_id: &str) -> bool { + self.sessions.contains_key(session_id) + } +} + +/// Browser → SIP audio forwarding loop. +/// Reads Opus RTP from the browser, transcodes to the SIP codec, sends to provider. +async fn browser_to_sip_loop( + track: Arc, + sip_bridge: Arc>>, + out_tx: OutTx, + session_id: String, +) { + // Create a persistent codec state for this direction. + let mut transcoder = match TranscodeState::new() { + Ok(t) => t, + Err(e) => { + emit_event( + &out_tx, + "webrtc_error", + serde_json::json!({ "session_id": session_id, "error": format!("codec init: {e}") }), + ); + return; + } + }; + + let mut buf = vec![0u8; 1500]; + let mut count = 0u64; + let mut to_sip_seq: u16 = 0; + let mut to_sip_ts: u32 = 0; + let to_sip_ssrc: u32 = rand::random(); + + loop { + match track.read(&mut buf).await { + Ok((rtp_packet, _attributes)) => { + count += 1; + + // Get the SIP bridge info (may not be set yet if call isn't linked). + let bridge = sip_bridge.lock().await; + let bridge_info = match bridge.as_ref() { + Some(b) => b.clone(), + None => continue, // Not linked to a SIP call yet — drop the packet. + }; + drop(bridge); // Release lock before doing I/O. + + // Extract Opus payload from the RTP packet (skip 12-byte header). + let payload = &rtp_packet.payload; + if payload.is_empty() { + continue; + } + + // Transcode Opus → SIP codec (e.g. G.722). + let sip_payload = match transcoder.transcode( + payload, + PT_OPUS, + bridge_info.sip_pt, + Some("to_sip"), + ) { + Ok(p) if !p.is_empty() => p, + _ => continue, + }; + + // Build SIP RTP packet. + let header = build_rtp_header(bridge_info.sip_pt, to_sip_seq, to_sip_ts, to_sip_ssrc); + let mut sip_rtp = header.to_vec(); + sip_rtp.extend_from_slice(&sip_payload); + + to_sip_seq = to_sip_seq.wrapping_add(1); + to_sip_ts = to_sip_ts.wrapping_add(rtp_clock_increment(bridge_info.sip_pt)); + + // Send to provider. + let _ = bridge_info + .sip_socket + .send_to(&sip_rtp, bridge_info.provider_media) + .await; + + if count == 1 || count == 50 || count % 500 == 0 { + emit_event( + &out_tx, + "webrtc_audio_tx", + serde_json::json!({ + "session_id": session_id, + "direction": "browser_to_sip", + "packet_count": count, + }), + ); + } + } + Err(_) => break, // Track ended. + } + } +} diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 414db0b..cf6150f 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.11.0', + version: '1.12.0', description: 'undefined' } diff --git a/ts/announcement.ts b/ts/announcement.ts index 559f8a0..70eea76 100644 --- a/ts/announcement.ts +++ b/ts/announcement.ts @@ -14,9 +14,26 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { Buffer } from 'node:buffer'; -import { buildRtpHeader, rtpClockIncrement } from './call/leg.ts'; import { encodePcm, isCodecReady } from './opusbridge.ts'; +/** RTP clock increment per 20ms frame for each codec. */ +function rtpClockIncrement(pt: number): number { + if (pt === 111) return 960; + if (pt === 9) return 160; + return 160; +} + +/** Build a fresh RTP header. */ +function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer { + const hdr = Buffer.alloc(12); + hdr[0] = 0x80; + hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f); + hdr.writeUInt16BE(seq & 0xffff, 2); + hdr.writeUInt32BE(ts >>> 0, 4); + hdr.writeUInt32BE(ssrc >>> 0, 8); + return hdr; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- diff --git a/ts/call/audio-recorder.ts b/ts/call/audio-recorder.ts deleted file mode 100644 index 649f13c..0000000 --- a/ts/call/audio-recorder.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Audio recorder — captures RTP packets from a single direction, - * decodes them to PCM, and writes a WAV file. - * - * Uses the Rust codec bridge to transcode incoming audio (G.722, Opus, - * PCMU, PCMA) to PCMU, then decodes mu-law to 16-bit PCM in TypeScript. - * Output: 8kHz 16-bit mono WAV (standard telephony quality). - * - * Supports: - * - Max recording duration limit - * - Silence detection (stop after N seconds of silence) - * - Manual stop - * - DTMF packets (PT 101) are automatically skipped - */ - -import { Buffer } from 'node:buffer'; -import fs from 'node:fs'; -import path from 'node:path'; -import { WavWriter } from './wav-writer.ts'; -import type { IWavWriterResult } from './wav-writer.ts'; -import { transcode, createSession, destroySession } from '../opusbridge.ts'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface IRecordingOptions { - /** Output directory for WAV files. */ - outputDir: string; - /** Target sample rate for the WAV output (default 8000). */ - sampleRate?: number; - /** Maximum recording duration in seconds. 0 = unlimited. Default 120. */ - maxDurationSec?: number; - /** Stop after this many consecutive seconds of silence. 0 = disabled. Default 5. */ - silenceTimeoutSec?: number; - /** Silence threshold: max PCM amplitude below this is "silent". Default 200. */ - silenceThreshold?: number; - /** Logging function. */ - log: (msg: string) => void; -} - -export interface IRecordingResult { - /** Full path to the WAV file. */ - filePath: string; - /** Duration in milliseconds. */ - durationMs: number; - /** Sample rate of the WAV. */ - sampleRate: number; - /** Size of the WAV file in bytes. */ - fileSize: number; - /** Why the recording was stopped. */ - stopReason: TRecordingStopReason; -} - -export type TRecordingStopReason = 'manual' | 'max-duration' | 'silence' | 'cancelled'; - -// --------------------------------------------------------------------------- -// Mu-law decode table (ITU-T G.711) -// --------------------------------------------------------------------------- - -/** Pre-computed mu-law → 16-bit linear PCM lookup table (256 entries). */ -const MULAW_DECODE: Int16Array = buildMulawDecodeTable(); - -function buildMulawDecodeTable(): Int16Array { - const table = new Int16Array(256); - for (let i = 0; i < 256; i++) { - // Invert all bits per mu-law standard. - let mu = ~i & 0xff; - const sign = mu & 0x80; - const exponent = (mu >> 4) & 0x07; - const mantissa = mu & 0x0f; - let magnitude = ((mantissa << 1) + 33) << (exponent + 2); - magnitude -= 0x84; // Bias adjustment - table[i] = sign ? -magnitude : magnitude; - } - return table; -} - -/** Decode a PCMU payload to 16-bit LE PCM. */ -function decodeMulaw(mulaw: Buffer): Buffer { - const pcm = Buffer.alloc(mulaw.length * 2); - for (let i = 0; i < mulaw.length; i++) { - pcm.writeInt16LE(MULAW_DECODE[mulaw[i]], i * 2); - } - return pcm; -} - -// --------------------------------------------------------------------------- -// AudioRecorder -// --------------------------------------------------------------------------- - -export class AudioRecorder { - /** Current state. */ - state: 'idle' | 'recording' | 'stopped' = 'idle'; - - /** Called when recording stops automatically (silence or max duration). */ - onStopped: ((result: IRecordingResult) => void) | null = null; - - private outputDir: string; - private sampleRate: number; - private maxDurationSec: number; - private silenceTimeoutSec: number; - private silenceThreshold: number; - private log: (msg: string) => void; - - private wavWriter: WavWriter | null = null; - private filePath: string = ''; - private codecSessionId: string | null = null; - private stopReason: TRecordingStopReason = 'manual'; - - // Silence detection. - private consecutiveSilentFrames = 0; - /** Number of 20ms frames that constitute silence timeout. */ - private silenceFrameThreshold = 0; - - // Max duration timer. - private maxDurationTimer: ReturnType | null = null; - - // Processing queue to avoid concurrent transcodes. - private processQueue: Promise = Promise.resolve(); - - constructor(options: IRecordingOptions) { - this.outputDir = options.outputDir; - this.sampleRate = options.sampleRate ?? 8000; - this.maxDurationSec = options.maxDurationSec ?? 120; - this.silenceTimeoutSec = options.silenceTimeoutSec ?? 5; - this.silenceThreshold = options.silenceThreshold ?? 200; - this.log = options.log; - } - - /** - * Start recording. Creates the output directory, WAV file, and codec session. - * @param fileId - unique ID for the recording file name - */ - async start(fileId?: string): Promise { - if (this.state !== 'idle') return; - - // Ensure output directory exists. - if (!fs.existsSync(this.outputDir)) { - fs.mkdirSync(this.outputDir, { recursive: true }); - } - - // Generate file path. - const id = fileId ?? `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - this.filePath = path.join(this.outputDir, `${id}.wav`); - - // Create a codec session for isolated decoding. - this.codecSessionId = `recorder-${id}`; - await createSession(this.codecSessionId); - - // Open WAV writer. - this.wavWriter = new WavWriter({ - filePath: this.filePath, - sampleRate: this.sampleRate, - }); - this.wavWriter.open(); - - // Silence detection threshold: frames in timeout period. - this.silenceFrameThreshold = this.silenceTimeoutSec > 0 - ? Math.ceil((this.silenceTimeoutSec * 1000) / 20) - : 0; - this.consecutiveSilentFrames = 0; - - // Max duration timer. - if (this.maxDurationSec > 0) { - this.maxDurationTimer = setTimeout(() => { - if (this.state === 'recording') { - this.stopReason = 'max-duration'; - this.log(`[recorder] max duration reached (${this.maxDurationSec}s)`); - this.stop().then((result) => this.onStopped?.(result)); - } - }, this.maxDurationSec * 1000); - } - - this.state = 'recording'; - this.stopReason = 'manual'; - this.log(`[recorder] started → ${this.filePath}`); - } - - /** - * Feed an RTP packet. Strips the 12-byte header, transcodes the payload - * to PCMU via the Rust bridge, decodes to PCM, and writes to WAV. - * Skips telephone-event (DTMF) and comfort noise packets. - */ - processRtp(data: Buffer): void { - if (this.state !== 'recording') return; - if (data.length < 13) return; // too short - - const pt = data[1] & 0x7f; - - // Skip DTMF (telephone-event) and comfort noise. - if (pt === 101 || pt === 13) return; - - const payload = data.subarray(12); - if (payload.length === 0) return; - - // Queue processing to avoid concurrent transcodes corrupting codec state. - this.processQueue = this.processQueue.then(() => this.decodeAndWrite(payload, pt)); - } - - /** Decode a single RTP payload to PCM and write to WAV. */ - private async decodeAndWrite(payload: Buffer, pt: number): Promise { - if (this.state !== 'recording' || !this.wavWriter) return; - - let pcm: Buffer; - - if (pt === 0) { - // PCMU: decode directly in TypeScript (no Rust round-trip needed). - pcm = decodeMulaw(payload); - } else { - // All other codecs: transcode to PCMU via Rust, then decode mu-law. - const mulaw = await transcode(payload, pt, 0, this.codecSessionId ?? undefined); - if (!mulaw) return; - pcm = decodeMulaw(mulaw); - } - - // Silence detection. - if (this.silenceFrameThreshold > 0) { - if (isSilent(pcm, this.silenceThreshold)) { - this.consecutiveSilentFrames++; - if (this.consecutiveSilentFrames >= this.silenceFrameThreshold) { - this.stopReason = 'silence'; - this.log(`[recorder] silence detected (${this.silenceTimeoutSec}s)`); - this.stop().then((result) => this.onStopped?.(result)); - return; - } - } else { - this.consecutiveSilentFrames = 0; - } - } - - this.wavWriter.write(pcm); - } - - /** - * Stop recording and finalize the WAV file. - */ - async stop(): Promise { - if (this.state === 'stopped' || this.state === 'idle') { - return { - filePath: this.filePath, - durationMs: 0, - sampleRate: this.sampleRate, - fileSize: 0, - stopReason: this.stopReason, - }; - } - - this.state = 'stopped'; - - // Wait for pending decode operations to finish. - await this.processQueue; - - // Clear timers. - if (this.maxDurationTimer) { - clearTimeout(this.maxDurationTimer); - this.maxDurationTimer = null; - } - - // Finalize WAV. - let wavResult: IWavWriterResult | null = null; - if (this.wavWriter) { - wavResult = this.wavWriter.close(); - this.wavWriter = null; - } - - // Destroy codec session. - if (this.codecSessionId) { - await destroySession(this.codecSessionId); - this.codecSessionId = null; - } - - const result: IRecordingResult = { - filePath: this.filePath, - durationMs: wavResult?.durationMs ?? 0, - sampleRate: this.sampleRate, - fileSize: wavResult?.fileSize ?? 0, - stopReason: this.stopReason, - }; - - this.log(`[recorder] stopped (${result.stopReason}): ${result.durationMs}ms → ${this.filePath}`); - return result; - } - - /** Cancel recording — stops and deletes the WAV file. */ - async cancel(): Promise { - this.stopReason = 'cancelled'; - await this.stop(); - - // Delete the incomplete file. - try { - if (fs.existsSync(this.filePath)) { - fs.unlinkSync(this.filePath); - this.log(`[recorder] cancelled — deleted ${this.filePath}`); - } - } catch { /* best effort */ } - } - - /** Clean up all resources. */ - destroy(): void { - if (this.state === 'recording') { - this.cancel(); - } - this.onStopped = null; - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Check if a PCM buffer is "silent" (max amplitude below threshold). */ -function isSilent(pcm: Buffer, threshold: number): boolean { - let maxAmp = 0; - for (let i = 0; i < pcm.length - 1; i += 2) { - const sample = pcm.readInt16LE(i); - const abs = sample < 0 ? -sample : sample; - if (abs > maxAmp) maxAmp = abs; - // Early exit: already above threshold. - if (maxAmp >= threshold) return false; - } - return true; -} diff --git a/ts/call/call-manager.ts b/ts/call/call-manager.ts deleted file mode 100644 index 9a79146..0000000 --- a/ts/call/call-manager.ts +++ /dev/null @@ -1,1632 +0,0 @@ -/** - * CallManager — central registry and factory for all calls. - * - * Replaces the scattered state across sipproxy.ts (rtpSessions), - * calloriginator.ts (originatedCalls/callIdIndex), and - * webrtcbridge.ts (sessions). - * - * Responsibilities: - * - SIP message routing by Call-ID - * - Factory methods for each call scenario - * - Per-call provider selection - * - Status aggregation for the dashboard - * - Transfer and dynamic leg management - */ - -import { Buffer } from 'node:buffer'; -import { playAnnouncement, playAnnouncementToWebRtc, isAnnouncementReady } from '../announcement.ts'; -import { - SipMessage, - buildSdp, - parseSdpEndpoint, - rewriteSdp, - rewriteSipUri, - generateTag, - buildMwiBody, -} from '../sip/index.ts'; -import type { IEndpoint } from '../sip/index.ts'; -import type { IAppConfig, IProviderConfig } from '../config.ts'; -import { getProvider, getDevice, resolveOutboundRoute, resolveInboundRoute } from '../config.ts'; -import { RtpPortPool } from './rtp-port-pool.ts'; -import { Call } from './call.ts'; -import { SipLeg } from './sip-leg.ts'; -import type { ISipLegConfig } from './sip-leg.ts'; -import { WebRtcLeg } from './webrtc-leg.ts'; -import type { IWebRtcLegConfig } from './webrtc-leg.ts'; -import type { ICallStatus, ICallHistoryEntry } from './types.ts'; -import type { ProviderState } from '../providerstate.ts'; -import { - getRegisteredDevice, - isKnownDeviceAddress, -} from '../registrar.ts'; -import { WebSocket } from 'ws'; -import { SystemLeg } from './system-leg.ts'; -import type { ISystemLegConfig } from './system-leg.ts'; -import { PromptCache } from './prompt-cache.ts'; -import { VoiceboxManager } from '../voicebox.ts'; -import type { IVoicemailMessage } from '../voicebox.ts'; -import { IvrEngine } from '../ivr.ts'; -import type { IIvrConfig, TIvrAction, IVoiceboxConfig as IVoiceboxCfg } from '../config.ts'; - -// --------------------------------------------------------------------------- -// CallManager config -// --------------------------------------------------------------------------- - -export interface ICallManagerConfig { - appConfig: IAppConfig; - sendSip: (buf: Buffer, dest: IEndpoint) => void; - log: (msg: string) => void; - broadcastWs: (type: string, data: unknown) => void; - getProviderState: (providerId: string) => ProviderState | undefined; - getAllBrowserDeviceIds: () => string[]; - sendToBrowserDevice: (deviceId: string, data: unknown) => boolean; - getBrowserDeviceWs: (deviceId: string) => WebSocket | null; - /** Prompt cache for IVR/voicemail audio playback. */ - promptCache?: PromptCache; - /** Voicebox manager for voicemail storage and retrieval. */ - voiceboxManager?: VoiceboxManager; -} - -// --------------------------------------------------------------------------- -// CallManager -// --------------------------------------------------------------------------- - -export class CallManager { - private calls = new Map(); - /** Maps SIP Call-ID -> Call (for routing incoming SIP messages). */ - private sipCallIdIndex = new Map(); - private portPool: RtpPortPool; - private config: ICallManagerConfig; - private nextCallNum = 0; - - /** Completed call history (most recent first, capped). */ - private callHistory: ICallHistoryEntry[] = []; - private static readonly MAX_HISTORY = 100; - - /** Standalone WebRTC legs created from webrtc-offer (before linked to a call). */ - private standaloneWebRtcLegs = new Map(); - - /** Pending browser calls: callId -> { provider, number, ps, rtpAllocation }. */ - private pendingBrowserCalls = new Map(); - - /** Passthrough calls: SIP Call-ID -> passthrough context. */ - private passthroughCalls = new Map(); - - constructor(config: ICallManagerConfig) { - this.config = config; - const { min, max } = config.appConfig.proxy.rtpPortRange; - this.portPool = new RtpPortPool(min, max, config.log); - } - - // ------------------------------------------------------------------------- - // SIP message routing - // ------------------------------------------------------------------------- - - /** - * Route an incoming SIP message to the correct call/leg. - * Returns true if handled, false if this message doesn't belong to any call. - */ - routeSipMessage(msg: SipMessage, rinfo: { address: string; port: number }): boolean { - // Check passthrough calls first — they need direction-based routing. - const pt = this.passthroughCalls.get(msg.callId); - if (pt) { - this.handlePassthroughMessage(pt, msg, rinfo); - return true; - } - - // B2BUA calls — route by SIP Call-ID to the correct leg. - const call = this.sipCallIdIndex.get(msg.callId); - if (!call) return false; - - const leg = call.getLegBySipCallId(msg.callId); - if (leg) { - leg.handleSipMessage(msg, { address: rinfo.address, port: rinfo.port }); - return true; - } - - return true; - } - - /** - * Handle a SIP message for a passthrough call. - * Determines direction by source IP and forwards to the other side with rewriting. - */ - private handlePassthroughMessage( - pt: (typeof this.passthroughCalls extends Map ? V : never), - msg: SipMessage, - rinfo: { address: string; port: number }, - ): void { - const fromProvider = rinfo.address === pt.providerAddress; - const lanIp = this.config.appConfig.proxy.lanIp; - const lanPort = this.config.appConfig.proxy.lanPort; - const pub = pt.ps.publicIp || lanIp; - - if (fromProvider) { - // Provider -> device: rewrite and forward. - if (msg.isRequest) { - if (msg.isDialogEstablishing) { - msg.prependHeader('Record-Route', ``); - } - if (msg.method === 'BYE') { - this.config.sendSip(msg.serialize(), pt.deviceTarget); - pt.call.hangup(); - this.passthroughCalls.delete(msg.callId); - return; - } - // INVITE retransmits — just forward. - msg.setRequestUri(rewriteSipUri(msg.requestUri!, pt.deviceTarget.address, pt.deviceTarget.port)); - } - // Rewrite SDP (provider media → proxy LAN IP). - if (msg.hasSdpBody) { - const { body, original } = rewriteSdp(msg.body, lanIp, pt.rtpPort); - msg.body = body; - msg.updateContentLength(); - if (original) pt.providerMedia = original; - } - this.config.sendSip(msg.serialize(), pt.deviceTarget); - } else { - // Device -> provider: rewrite and forward. - if (msg.isRequest) { - if (msg.method === 'BYE') { - this.config.sendSip(msg.serialize(), pt.providerConfig.outboundProxy); - pt.call.hangup(); - this.passthroughCalls.delete(msg.callId); - return; - } - } - - // Intercept busy/unavailable responses — route to voicemail if configured. - // When the device rejects the call (486 Busy, 480 Unavailable, 600/603 Decline), - // answer the provider's INVITE with our own SDP and start voicemail. - if (msg.isResponse && msg.cseqMethod?.toUpperCase() === 'INVITE') { - const code = msg.statusCode; - if (code === 486 || code === 480 || code === 600 || code === 603) { - const callId = pt.call.id; - const boxId = this.findVoiceboxForCall(pt.call); - if (boxId) { - this.config.log(`[call-mgr] device responded ${code} — routing to voicemail box "${boxId}"`); - - // Build a 200 OK with our own SDP to answer the provider's INVITE. - const sdpBody = buildSdp({ - address: pub, - port: pt.rtpPort, - payloadTypes: pt.providerConfig.codecs || [9, 0, 8, 101], - }); - // We need to construct the 200 OK as if *we* are answering the provider. - // The original INVITE from the provider used the passthrough SIP Call-ID. - // Build a response using the forwarded INVITE's headers. - const ok200 = SipMessage.createResponse(200, 'OK', msg, { - body: sdpBody, - contentType: 'application/sdp', - contact: ``, - }); - this.config.sendSip(ok200.serialize(), pt.providerConfig.outboundProxy); - - // Now route to voicemail. - this.routeToVoicemail(callId, boxId); - return; - } - } - } - - // Rewrite Contact. - const contact = msg.getHeader('Contact'); - if (contact) { - const nc = rewriteSipUri(contact, pub, lanPort); - if (nc !== contact) msg.setHeader('Contact', nc); - } - // Rewrite SDP (device media → proxy public IP). - if (msg.hasSdpBody) { - const { body, original } = rewriteSdp(msg.body, pub, pt.rtpPort); - msg.body = body; - msg.updateContentLength(); - if (original) pt.deviceMedia = original; - } - // Start silence on 200 OK to INVITE. - if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') { - if (pt.providerConfig.quirks.earlyMediaSilence && pt.providerMedia) { - this.startPassthroughSilence({ pktReceived: 0, pktSent: 0, rtpSock: pt.rtpSock } as any, pt.providerMedia, pt.providerConfig); - } - } - this.config.sendSip(msg.serialize(), pt.providerConfig.outboundProxy); - } - } - - // ------------------------------------------------------------------------- - // Outbound call (click-to-call from UI) - // ------------------------------------------------------------------------- - - /** - * Start an outbound call from the dashboard. - * Dials the device first (leg A), then the provider (leg B) when device answers. - */ - createOutboundCall(number: string, deviceId?: string, providerId?: string): Call | null { - // Resolve provider via routing (or explicit providerId override). - let provider: IProviderConfig | null; - let dialNumber = number; - - if (providerId) { - provider = getProvider(this.config.appConfig, providerId); - } else { - const routeResult = resolveOutboundRoute( - this.config.appConfig, - number, - deviceId, - (pid) => !!this.config.getProviderState(pid)?.registeredAor, - ); - if (routeResult) { - provider = routeResult.provider; - dialNumber = routeResult.transformedNumber; - } else { - provider = null; - } - } - - if (!provider) { - this.config.log('[call-mgr] no provider found'); - return null; - } - - const ps = this.config.getProviderState(provider.id); - if (!ps) { - this.config.log(`[call-mgr] provider state not found for ${provider.id}`); - return null; - } - - const aor = ps.registeredAor; - if (!aor) { - this.config.log('[call-mgr] cannot originate — no registered AOR'); - return null; - } - - // Allocate RTP for device leg. - const rtpA = this.portPool.allocate(); - if (!rtpA) { - this.config.log('[call-mgr] cannot originate — port pool exhausted'); - return null; - } - - const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`; - const call = new Call({ - id: callId, - direction: 'outbound', - portPool: this.portPool, - log: this.config.log, - onChange: (c) => this.handleCallChange(c), - }); - call.calleeNumber = number; - call.providerUsed = provider.displayName; - this.calls.set(callId, call); - - const isBrowser = deviceId?.startsWith('browser-') ?? false; - - if (isBrowser && deviceId) { - // Browser device — DON'T create WebRtcLeg yet. - // The browser will send webrtc-offer (creating a standalone WebRtcLeg), - // then webrtc-accept (which links the leg to this call and starts the provider). - this.pendingBrowserCalls.set(callId, { - provider, - number: dialNumber, - ps, - rtpPort: rtpA.port, - rtpSock: rtpA.sock, - }); - - // Notify browser of incoming call. - call.state = 'ringing'; - this.config.sendToBrowserDevice(deviceId, { - type: 'webrtc-incoming', - callId, - from: number, - deviceId, - }); - this.config.log(`[call-mgr] ${callId} notified browser device ${deviceId}`); - - } else { - // SIP device — create SipLeg with INVITE. - const deviceTarget = this.resolveDeviceTarget(deviceId); - if (!deviceTarget) { - this.config.log('[call-mgr] cannot resolve device'); - this.portPool.release(rtpA.port); - this.calls.delete(callId); - return null; - } - - const sipLegConfig: ISipLegConfig = { - role: 'device', - lanIp: this.config.appConfig.proxy.lanIp, - lanPort: this.config.appConfig.proxy.lanPort, - getPublicIp: () => ps.publicIp, - sendSip: this.config.sendSip, - log: this.config.log, - sipTarget: deviceTarget, - rtpPort: rtpA.port, - rtpSock: rtpA.sock, - }; - - const legA = new SipLeg(`${callId}-dev`, sipLegConfig); - - legA.onConnected = (leg) => { - this.config.log(`[call-mgr] ${callId} device answered — starting provider leg`); - - // Play announcement to the device while dialing the provider. - if (isAnnouncementReady() && leg.remoteMedia) { - playAnnouncement( - (pkt) => leg.rtpSock.send(pkt, leg.remoteMedia!.port, leg.remoteMedia!.address), - leg.ssrc, - ); - this.config.log(`[call-mgr] ${callId} playing announcement to device`); - } - - // Start dialing provider in parallel with announcement. - this.startProviderLeg(call, provider, dialNumber, ps); - }; - - legA.onTerminated = (leg) => { - call.handleLegTerminated(leg.id); - }; - - legA.onStateChange = () => call.notifyLegStateChange(legA); - - call.addLeg(legA); - - const sipCallIdA = `${callId}-a`; - legA.sendInvite({ - fromUri: `sip:${number}@${this.config.appConfig.proxy.lanIp}`, - fromDisplayName: `Mediated: ${number}`, - toUri: `sip:user@${deviceTarget.address}`, - callId: sipCallIdA, - }); - this.sipCallIdIndex.set(sipCallIdA, call); - } - - return call; - } - - /** - * Browser accepted the call — link the standalone WebRtcLeg to the call - * and start the provider leg. - */ - acceptBrowserCall(callId: string, sessionId?: string): boolean { - const call = this.calls.get(callId); - if (!call) { - this.config.log(`[call-mgr] acceptBrowserCall: call ${callId} not found`); - return false; - } - - const pending = this.pendingBrowserCalls.get(callId); - if (!pending) { - this.config.log(`[call-mgr] acceptBrowserCall: no pending browser call for ${callId}`); - return false; - } - - // Find the standalone WebRtcLeg created from webrtc-offer. - let webrtcLeg: WebRtcLeg | null = null; - if (sessionId) { - webrtcLeg = this.standaloneWebRtcLegs.get(sessionId) ?? null; - } - - if (!webrtcLeg) { - this.config.log(`[call-mgr] acceptBrowserCall: WebRTC session ${sessionId} not found — waiting`); - // The offer might not have been processed yet. Retry briefly. - return false; - } - - // Remove from standalone tracking. - this.standaloneWebRtcLegs.delete(sessionId!); - this.pendingBrowserCalls.delete(callId); - - // Attach the WebRtcLeg to the call. - webrtcLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id); - call.addLeg(webrtcLeg); - - this.config.log(`[call-mgr] ${callId} browser linked (session=${sessionId}) — starting provider leg`); - - // Play announcement to browser while dialing provider. - // Uses the WebRtcLeg's shared fromSipCounters so that when provider audio - // starts, seq/ts continue seamlessly (no jitter buffer discontinuity). - if (isAnnouncementReady()) { - const cancel = playAnnouncementToWebRtc( - (pkt) => webrtcLeg.sendDirectToBrowser(pkt), - webrtcLeg.fromSipSsrc, - webrtcLeg.fromSipCounters, - ); - webrtcLeg.announcementCancel = cancel; - this.config.log(`[call-mgr] ${callId} playing announcement to browser`); - } - - // Start the provider leg in parallel. - this.startProviderLeg(call, pending.provider, pending.number, pending.ps); - return true; - } - - // ------------------------------------------------------------------------- - // Provider leg (leg B) - // ------------------------------------------------------------------------- - - private startProviderLeg(call: Call, provider: IProviderConfig, number: string, ps: ProviderState): void { - const rtpB = this.portPool.allocate(); - if (!rtpB) { - this.config.log(`[call-mgr] ${call.id} cannot start provider leg — port pool exhausted`); - call.hangup(); - return; - } - - const pub = ps.publicIp || this.config.appConfig.proxy.lanIp; - const aor = ps.registeredAor!; - - const sipLegConfig: ISipLegConfig = { - role: 'provider', - lanIp: this.config.appConfig.proxy.lanIp, - lanPort: this.config.appConfig.proxy.lanPort, - getPublicIp: () => ps.publicIp, - sendSip: this.config.sendSip, - log: this.config.log, - provider, - sipTarget: provider.outboundProxy, - rtpPort: rtpB.port, - rtpSock: rtpB.sock, - payloadTypes: provider.codecs, - getRegisteredAor: () => ps.registeredAor, - getSipPassword: () => provider.password, - }; - - const legB = new SipLeg(`${call.id}-prov`, sipLegConfig); - - legB.onConnected = async (leg) => { - this.config.log(`[call-mgr] ${call.id} CONNECTED to provider`); - - // Set up transcoding between WebRTC and SIP legs if needed. - const webrtcLeg = call.getLegByType('webrtc') as WebRtcLeg | null; - if (webrtcLeg && leg.remoteMedia) { - const sipPT = provider.codecs?.[0] ?? 9; - await webrtcLeg.setupTranscoders(sipPT); - // Browser→SIP: route transcoded audio through the SipLeg's socket - // so the provider never sees the WebRtcLeg's port (avoids symmetric RTP double-path). - webrtcLeg.remoteMedia = leg.remoteMedia; - webrtcLeg.onSendToProvider = (data, dest) => { - legB.rtpSock.send(data, dest.port, dest.address); - }; - // SIP→browser: provider RTP arrives at SipLeg's socket → onRtpReceived → - // Call hub forwardRtp → WebRtcLeg.sendRtp → forwardToBrowser (transcodes to Opus). - this.config.log(`[call-mgr] ${call.id} WebRTC<->SIP bridge: sip:${legB.rtpPort} <-> provider ${leg.remoteMedia.address}:${leg.remoteMedia.port}`); - } - - call.notifyLegStateChange(leg); - }; - - legB.onTerminated = (leg) => { - call.handleLegTerminated(leg.id); - }; - - legB.onStateChange = () => call.notifyLegStateChange(legB); - - call.addLeg(legB); - - const sipCallIdB = `${call.id}-b`; - const destUri = `sip:${number}@${provider.domain}`; - - legB.sendInvite({ - fromUri: aor, - toUri: destUri, - callId: sipCallIdB, - }); - this.sipCallIdIndex.set(sipCallIdB, call); - - call.state = 'ringing'; - } - - // ------------------------------------------------------------------------- - // Inbound call (provider -> device) - // ------------------------------------------------------------------------- - - /** - * Handle an inbound INVITE from a provider. - * Uses passthrough routing (direction by source IP, not per-leg SIP Call-ID). - */ - createInboundCall(ps: ProviderState, invite: SipMessage, rinfo: IEndpoint): Call | null { - const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`; - const provider = ps.config; - const lanIp = this.config.appConfig.proxy.lanIp; - const lanPort = this.config.appConfig.proxy.lanPort; - - const call = new Call({ - id: callId, - direction: 'inbound', - portPool: this.portPool, - log: this.config.log, - onChange: (c) => this.handleCallChange(c), - }); - call.providerUsed = provider.displayName; - - const from = invite.getHeader('From'); - call.callerNumber = from ? SipMessage.extractUri(from) || 'Unknown' : 'Unknown'; - - this.calls.set(callId, call); - - // Allocate a single RTP relay port (shared by both directions, like old passthrough). - const rtpAlloc = this.portPool.allocate(); - if (!rtpAlloc) { - this.config.log('[call-mgr] cannot handle inbound — port pool exhausted'); - this.calls.delete(callId); - return null; - } - - // Resolve inbound routing — determine target devices and browser ring. - const calledNumber = SipMessage.extractUri(invite.requestUri || '') || ''; - const routeResult = resolveInboundRoute(this.config.appConfig, provider.id, calledNumber, call.callerNumber); - const targetDeviceIds = routeResult.deviceIds.length - ? routeResult.deviceIds - : this.config.appConfig.devices.map((d) => d.id); - const deviceTarget = this.resolveFirstDevice(targetDeviceIds); - if (!deviceTarget) { - this.config.log('[call-mgr] cannot handle inbound — no device target'); - this.portPool.release(rtpAlloc.port); - this.calls.delete(callId); - return null; - } - - // Set up bidirectional RTP relay (like old passthrough). - let deviceMedia: IEndpoint | null = null; - let providerMedia: IEndpoint | null = null; - - rtpAlloc.sock.on('message', (data: Buffer, pktInfo: { address: string; port: number }) => { - // Forward based on source address. - if (deviceMedia && pktInfo.address === deviceMedia.address && pktInfo.port === deviceMedia.port) { - if (providerMedia) rtpAlloc.sock.send(data, providerMedia.port, providerMedia.address); - } else if (providerMedia && pktInfo.address === providerMedia.address && pktInfo.port === providerMedia.port) { - if (deviceMedia) rtpAlloc.sock.send(data, deviceMedia.port, deviceMedia.address); - } else if (isKnownDeviceAddress(pktInfo.address)) { - if (!deviceMedia) deviceMedia = { address: pktInfo.address, port: pktInfo.port }; - if (providerMedia) rtpAlloc.sock.send(data, providerMedia.port, providerMedia.address); - } else { - if (!providerMedia) providerMedia = { address: pktInfo.address, port: pktInfo.port }; - if (deviceMedia) rtpAlloc.sock.send(data, deviceMedia.port, deviceMedia.address); - } - }); - - // Register as passthrough call (routes by source IP, not by leg). - const ptCtx = { - call, - providerAddress: rinfo.address, - providerPort: rinfo.port, - providerConfig: provider, - ps, - deviceTarget, - rtpPort: rtpAlloc.port, - rtpSock: rtpAlloc.sock, - deviceMedia, - providerMedia, - }; - this.passthroughCalls.set(invite.callId, ptCtx); - - // Rewrite and forward the INVITE to the device. - const fwdInvite = SipMessage.parse(invite.serialize())!; - fwdInvite.setRequestUri(rewriteSipUri(fwdInvite.requestUri!, deviceTarget.address, deviceTarget.port)); - fwdInvite.prependHeader('Record-Route', ``); - - if (fwdInvite.hasSdpBody) { - const { body, original } = rewriteSdp(fwdInvite.body, lanIp, rtpAlloc.port); - fwdInvite.body = body; - fwdInvite.updateContentLength(); - if (original) ptCtx.providerMedia = original; - } - - this.config.sendSip(fwdInvite.serialize(), deviceTarget); - - // Notify browsers if route says so. - if (routeResult.ringBrowsers) { - const ids = this.config.getAllBrowserDeviceIds(); - for (const deviceIdBrowser of ids) { - this.config.sendToBrowserDevice(deviceIdBrowser, { - type: 'webrtc-incoming', - callId, - from: call.callerNumber, - deviceId: deviceIdBrowser, - }); - } - if (ids.length) { - this.config.log(`[call-mgr] notified ${ids.length} browser(s) of inbound call`); - } - } - - call.state = 'ringing'; - - // --- IVR / Voicemail routing --- - if (routeResult.ivrMenuId && this.config.appConfig.ivr?.enabled) { - // Route directly to IVR — don't ring devices. - this.config.log(`[call-mgr] inbound call ${callId} routed to IVR menu "${routeResult.ivrMenuId}"`); - // Respond 200 OK to the provider INVITE first. - const okForProvider = SipMessage.createResponse(200, 'OK', invite, { - body: fwdInvite.body, // rewritten SDP - contentType: 'application/sdp', - }); - this.config.sendSip(okForProvider.serialize(), rinfo); - this.routeToIvr(callId, this.config.appConfig.ivr); - } else if (routeResult.voicemailBox) { - // Route directly to voicemail — don't ring devices. - this.config.log(`[call-mgr] inbound call ${callId} routed directly to voicemail box "${routeResult.voicemailBox}"`); - const okForProvider = SipMessage.createResponse(200, 'OK', invite, { - body: fwdInvite.body, - contentType: 'application/sdp', - }); - this.config.sendSip(okForProvider.serialize(), rinfo); - this.routeToVoicemail(callId, routeResult.voicemailBox); - } else { - // Normal ringing — start voicemail no-answer timer if applicable. - const vm = this.config.voiceboxManager; - if (vm) { - // Find first voicebox for the target devices. - const boxId = this.findVoiceboxForDevices(targetDeviceIds); - if (boxId) { - const box = vm.getBox(boxId); - if (box?.enabled) { - const timeoutSec = routeResult.noAnswerTimeout ?? box.noAnswerTimeoutSec ?? 25; - setTimeout(() => { - const c = this.calls.get(callId); - if (c && c.state === 'ringing') { - this.config.log(`[call-mgr] no answer after ${timeoutSec}s — routing to voicemail box "${boxId}"`); - this.routeToVoicemail(callId, boxId); - } - }, timeoutSec * 1000); - } - } - } - } - - return call; - } - - // ------------------------------------------------------------------------- - // Passthrough call (device -> provider through proxy) - // ------------------------------------------------------------------------- - - /** - * Handle an outbound SIP message from a device that doesn't match any existing call. - * Creates a passthrough Call if it's an INVITE, otherwise forwards raw. - */ - handlePassthroughOutbound(msg: SipMessage, rinfo: IEndpoint, provider: IProviderConfig, ps: ProviderState): boolean { - if (msg.method !== 'INVITE') return false; - - const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`; - const call = new Call({ - id: callId, - direction: 'outbound', - portPool: this.portPool, - log: this.config.log, - onChange: (c) => this.handleCallChange(c), - }); - call.providerUsed = provider.displayName; - - // Extract callee from Request-URI. - const ruri = msg.requestUri || ''; - call.calleeNumber = ruri; - - this.calls.set(callId, call); - - // Allocate RTP. - const rtpAlloc = this.portPool.allocate(); - if (!rtpAlloc) { - this.config.log('[call-mgr] passthrough: port pool exhausted'); - this.calls.delete(callId); - return false; - } - - // Create a passthrough "call" using the original SIP Call-ID for indexing. - // Both the device and provider sides share the same SIP Call-ID. - this.sipCallIdIndex.set(msg.callId, call); - - // Create device leg (tracks the device side). - const devLegConfig: ISipLegConfig = { - role: 'device', - lanIp: this.config.appConfig.proxy.lanIp, - lanPort: this.config.appConfig.proxy.lanPort, - getPublicIp: () => ps.publicIp, - sendSip: this.config.sendSip, - log: this.config.log, - sipTarget: { address: rinfo.address, port: rinfo.port }, - rtpPort: rtpAlloc.port, - rtpSock: rtpAlloc.sock, - }; - - const devLeg = new SipLeg(`${callId}-dev`, devLegConfig); - devLeg.acceptIncoming(msg); - devLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id); - call.addLeg(devLeg); - - // Now forward the INVITE to the provider with SDP rewriting. - const pub = ps.publicIp || this.config.appConfig.proxy.lanIp; - - if (msg.isDialogEstablishing) { - msg.prependHeader('Record-Route', ``); - } - - // Rewrite Contact. - const contact = msg.getHeader('Contact'); - if (contact) { - const nc = rewriteSipUri(contact, pub, this.config.appConfig.proxy.lanPort); - if (nc !== contact) msg.setHeader('Contact', nc); - } - - // Rewrite SDP. - if (msg.hasSdpBody) { - const { body, original } = rewriteSdp(msg.body, pub, rtpAlloc.port); - msg.body = body; - msg.updateContentLength(); - if (original) { - devLeg.remoteMedia = original; - } - } - - this.config.sendSip(msg.serialize(), provider.outboundProxy); - call.state = 'ringing'; - return true; - } - - /** - * Handle an inbound SIP message from a provider for a passthrough call. - * Rewrites and forwards to the device. - */ - handlePassthroughInbound(call: Call, msg: SipMessage, rinfo: IEndpoint, ps: ProviderState): void { - const deviceLeg = call.getLegByType('sip-device') as SipLeg | null; - if (!deviceLeg) return; - - const lanIp = this.config.appConfig.proxy.lanIp; - const lanPort = this.config.appConfig.proxy.lanPort; - - // For responses, learn provider media from SDP. - if (msg.isResponse && msg.hasSdpBody && deviceLeg.rtpPort) { - const { body, original } = rewriteSdp(msg.body, lanIp, deviceLeg.rtpPort); - msg.body = body; - msg.updateContentLength(); - if (original) { - // Provider's media endpoint — set on the provider side. - const provLeg = call.getLegByType('sip-provider') as SipLeg | null; - if (provLeg) provLeg.remoteMedia = original; - // For passthrough, the RTP relay needs both endpoints. - // Since we use a single RTP socket, we store provider media - // so the relay can forward. - } - } - - // For requests (like BYE), detect call termination. - if (msg.isRequest) { - if (msg.isDialogEstablishing) { - msg.prependHeader('Record-Route', ``); - } - if (msg.method === 'BYE') { - // Forward BYE to device, then clean up call. - this.config.sendSip(msg.serialize(), deviceLeg.config.sipTarget); - call.hangup(); - return; - } - // Rewrite Request-URI. - msg.setRequestUri(rewriteSipUri(msg.requestUri!, deviceLeg.config.sipTarget.address, deviceLeg.config.sipTarget.port)); - } - - // Start silence if this is a 200 OK to INVITE. - if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') { - // Silence and NAT priming happen in the SipLeg itself via quirks. - // For passthrough, we manually trigger it if provider has earlyMediaSilence. - const provider = ps.config; - if (provider.quirks.earlyMediaSilence && deviceLeg.rtpSock) { - const provMedia = parseSdpEndpoint(msg.body); - if (provMedia) { - this.startPassthroughSilence(deviceLeg, provMedia, provider); - } - } - } - - this.config.sendSip(msg.serialize(), deviceLeg.config.sipTarget); - } - - /** - * Handle an outbound SIP response from device for a passthrough call. - */ - handlePassthroughOutboundResponse(call: Call, msg: SipMessage, rinfo: IEndpoint, provider: IProviderConfig, ps: ProviderState): void { - const pub = ps.publicIp || this.config.appConfig.proxy.lanIp; - const devLeg = call.getLegByType('sip-device') as SipLeg | null; - - // Rewrite Contact. - const contact = msg.getHeader('Contact'); - if (contact) { - const nc = rewriteSipUri(contact, pub, this.config.appConfig.proxy.lanPort); - if (nc !== contact) msg.setHeader('Contact', nc); - } - - // Rewrite SDP (responses going to provider). - if (msg.hasSdpBody && devLeg) { - const { body, original } = rewriteSdp(msg.body, pub, devLeg.rtpPort); - msg.body = body; - msg.updateContentLength(); - if (original) { - devLeg.remoteMedia = original; - } - } - - // Detect BYE from device. - if (msg.isRequest && msg.method === 'BYE') { - this.config.sendSip(msg.serialize(), provider.outboundProxy); - call.hangup(); - return; - } - - // Start silence on 200 OK to INVITE. - if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') { - if (provider.quirks.earlyMediaSilence && devLeg) { - const provMedia = devLeg.remoteMedia; // provider endpoint from SDP - // Silence will be started by the provider leg or passthrough helper. - } - } - - this.config.sendSip(msg.serialize(), provider.outboundProxy); - } - - private startPassthroughSilence(devLeg: SipLeg, provMedia: IEndpoint, provider: IProviderConfig): void { - const PT = provider.quirks.silencePayloadType ?? 9; - const MAX = provider.quirks.silenceMaxPackets ?? 250; - const PAYLOAD = 160; - let seq = Math.floor(Math.random() * 0xffff); - let rtpTs = Math.floor(Math.random() * 0xffffffff); - const ssrc = Math.floor(Math.random() * 0xffffffff); - let count = 0; - - const timer = setInterval(() => { - if (devLeg.pktReceived > 0 || devLeg.pktSent > 0 || count >= MAX) { - clearInterval(timer); - return; - } - const pkt = Buffer.alloc(12 + PAYLOAD); - pkt[0] = 0x80; - pkt[1] = PT; - pkt.writeUInt16BE(seq & 0xffff, 2); - pkt.writeUInt32BE(rtpTs >>> 0, 4); - pkt.writeUInt32BE(ssrc >>> 0, 8); - devLeg.rtpSock.send(pkt, provMedia.port, provMedia.address); - seq++; - rtpTs += PAYLOAD; - count++; - }, 20); - } - - // ------------------------------------------------------------------------- - // Hangup - // ------------------------------------------------------------------------- - - hangup(callId: string): boolean { - const call = this.calls.get(callId); - if (!call) return false; - call.hangup(); - return true; - } - - // ------------------------------------------------------------------------- - // Dynamic leg management - // ------------------------------------------------------------------------- - - /** - * Add a new SIP device leg to an existing call. - * This enables adding participants to a call dynamically. - */ - addDeviceToCall(callId: string, deviceId: string): boolean { - const call = this.calls.get(callId); - if (!call) return false; - - const deviceTarget = this.resolveDeviceTarget(deviceId); - if (!deviceTarget) return false; - - const rtpAlloc = this.portPool.allocate(); - if (!rtpAlloc) return false; - - // Find a provider state for SDP building. - const providerLeg = call.getLegByType('sip-provider') as SipLeg | null; - const provider = providerLeg?.config.provider; - - const sipLegConfig: ISipLegConfig = { - role: 'device', - lanIp: this.config.appConfig.proxy.lanIp, - lanPort: this.config.appConfig.proxy.lanPort, - getPublicIp: () => null, - sendSip: this.config.sendSip, - log: this.config.log, - sipTarget: deviceTarget, - rtpPort: rtpAlloc.port, - rtpSock: rtpAlloc.sock, - }; - - const legId = `${callId}-dev${call.legCount}`; - const newLeg = new SipLeg(legId, sipLegConfig); - newLeg.onConnected = () => call.notifyLegStateChange(newLeg); - newLeg.onTerminated = (leg) => call.removeLeg(leg.id); - newLeg.onStateChange = () => call.notifyLegStateChange(newLeg); - - call.addLeg(newLeg); - - const sipCallId = `${callId}-${legId}`; - newLeg.sendInvite({ - fromUri: `sip:${call.calleeNumber || 'conf'}@${this.config.appConfig.proxy.lanIp}`, - fromDisplayName: `Conf: ${call.calleeNumber || 'conference'}`, - toUri: `sip:user@${deviceTarget.address}`, - callId: sipCallId, - }); - this.sipCallIdIndex.set(sipCallId, call); - - return true; - } - - /** - * Dial out via a provider and add the answered leg to an existing call. - * This enables adding external participants by phone number. - */ - addExternalToCall(callId: string, number: string, providerId?: string): boolean { - const call = this.calls.get(callId); - if (!call) return false; - - // Resolve provider via routing (or explicit providerId override). - let provider: IProviderConfig | null; - let dialNumber = number; - - if (providerId) { - provider = getProvider(this.config.appConfig, providerId); - } else { - const routeResult = resolveOutboundRoute( - this.config.appConfig, - number, - undefined, - (pid) => !!this.config.getProviderState(pid)?.registeredAor, - ); - if (routeResult) { - provider = routeResult.provider; - dialNumber = routeResult.transformedNumber; - } else { - provider = null; - } - } - - if (!provider) { - this.config.log(`[call-mgr] addExternalToCall: no provider`); - return false; - } - - const ps = this.config.getProviderState(provider.id); - if (!ps?.registeredAor) { - this.config.log(`[call-mgr] addExternalToCall: provider ${provider.id} not registered`); - return false; - } - - const rtpAlloc = this.portPool.allocate(); - if (!rtpAlloc) return false; - - const sipLegConfig: ISipLegConfig = { - role: 'provider', - lanIp: this.config.appConfig.proxy.lanIp, - lanPort: this.config.appConfig.proxy.lanPort, - getPublicIp: () => ps.publicIp, - sendSip: this.config.sendSip, - log: this.config.log, - provider, - sipTarget: provider.outboundProxy, - rtpPort: rtpAlloc.port, - rtpSock: rtpAlloc.sock, - payloadTypes: provider.codecs, - getRegisteredAor: () => ps.registeredAor, - getSipPassword: () => provider.password, - }; - - const legId = `${callId}-ext${call.legCount}`; - const newLeg = new SipLeg(legId, sipLegConfig); - - newLeg.onConnected = (leg) => { - this.config.log(`[call-mgr] ${callId} external ${number} answered`); - call.notifyLegStateChange(leg); - }; - newLeg.onTerminated = (leg) => call.removeLeg(leg.id); - newLeg.onStateChange = () => call.notifyLegStateChange(newLeg); - - call.addLeg(newLeg); - - const sipCallId = `${callId}-${legId}`; - const destUri = `sip:${dialNumber}@${provider.domain}`; - newLeg.sendInvite({ - fromUri: ps.registeredAor, - toUri: destUri, - callId: sipCallId, - }); - this.sipCallIdIndex.set(sipCallId, call); - - this.config.log(`[call-mgr] ${callId} dialing external ${dialNumber} via ${provider.displayName}`); - return true; - } - - /** Remove a leg from a call by ID. */ - removeLegFromCall(callId: string, legId: string): boolean { - const call = this.calls.get(callId); - if (!call) return false; - - const leg = call.getLeg(legId); - if (!leg) return false; - - if (leg.type === 'sip-device' || leg.type === 'sip-provider') { - (leg as SipLeg).sendHangup(); - } - call.removeLeg(legId); - return true; - } - - // ------------------------------------------------------------------------- - // Transfer - // ------------------------------------------------------------------------- - - /** - * Transfer a leg from one call to another. - * Detaches the leg from sourceCall and adds it to targetCall. - */ - transferLeg(sourceCallId: string, legId: string, targetCallId: string): boolean { - const sourceCall = this.calls.get(sourceCallId); - const targetCall = this.calls.get(targetCallId); - if (!sourceCall || !targetCall) return false; - - const leg = sourceCall.detachLeg(legId); - if (!leg) return false; - - targetCall.addLeg(leg); - this.config.log(`[call-mgr] transferred leg ${legId} from ${sourceCallId} to ${targetCallId}`); - - // Clean up source call if empty. - if (sourceCall.legCount === 0) { - sourceCall.hangup(); - } - - return true; - } - - // ------------------------------------------------------------------------- - // WebRTC signaling integration - // ------------------------------------------------------------------------- - - /** - * Handle a WebRTC offer from a browser. - * Creates a standalone WebRtcLeg (not yet attached to a call). - * The leg will be linked to a call when webrtc-accept arrives. - */ - async handleWebRtcOffer(sessionId: string, offerSdp: string, ws: WebSocket): Promise { - // Check if there's already a WebRtcLeg for this session (in a call). - for (const call of this.calls.values()) { - for (const leg of call.getLegs()) { - if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) { - await (leg as WebRtcLeg).handleOffer(offerSdp); - return true; - } - } - } - - // Create a standalone WebRtcLeg (will be linked to a call on webrtc-accept). - const rtpAlloc = this.portPool.allocate(); - if (!rtpAlloc) { - this.config.log(`[call-mgr] webrtc-offer: port pool exhausted`); - return false; - } - - const webrtcLeg = new WebRtcLeg(`webrtc-${sessionId.slice(0, 8)}`, { - ws, - sessionId, - rtpPort: rtpAlloc.port, - rtpSock: rtpAlloc.sock, - log: this.config.log, - }); - - this.standaloneWebRtcLegs.set(sessionId, webrtcLeg); - await webrtcLeg.handleOffer(offerSdp); - - this.config.log(`[call-mgr] standalone WebRtcLeg created for session ${sessionId.slice(0, 8)}`); - return true; - } - - /** Route an ICE candidate to the correct WebRtcLeg (in a call or standalone). */ - async handleWebRtcIce(sessionId: string, candidate: any): Promise { - // Check standalone legs first (most common during setup). - const standalone = this.standaloneWebRtcLegs.get(sessionId); - if (standalone) { - await standalone.addIceCandidate(candidate); - return true; - } - - // Check legs in active calls. - for (const call of this.calls.values()) { - for (const leg of call.getLegs()) { - if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) { - await (leg as WebRtcLeg).addIceCandidate(candidate); - return true; - } - } - } - return false; - } - - /** Handle WebRTC hangup. */ - handleWebRtcHangup(sessionId: string): void { - // Check standalone legs. - const standalone = this.standaloneWebRtcLegs.get(sessionId); - if (standalone) { - standalone.teardown(); - if (standalone.rtpPort) this.portPool.release(standalone.rtpPort); - this.standaloneWebRtcLegs.delete(sessionId); - return; - } - - // Check legs in active calls. - for (const call of this.calls.values()) { - for (const leg of call.getLegs()) { - if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) { - call.removeLeg(leg.id); - return; - } - } - } - } - - // ------------------------------------------------------------------------- - // Voicemail routing - // ------------------------------------------------------------------------- - - /** - * Route a call to voicemail. Cancels ringing devices, creates a SystemLeg, - * plays the greeting, then starts recording. - */ - routeToVoicemail(callId: string, boxId: string): void { - const call = this.calls.get(callId); - if (!call) return; - - const vm = this.config.voiceboxManager; - const pc = this.config.promptCache; - if (!vm || !pc) { - this.config.log(`[call-mgr] voicemail not available (manager or prompt cache missing)`); - return; - } - - const box = vm.getBox(boxId); - if (!box) { - this.config.log(`[call-mgr] voicebox "${boxId}" not found`); - return; - } - - // Cancel all ringing/device legs — keep only provider leg(s). - const legsToRemove: string[] = []; - for (const leg of call.getLegs()) { - if (leg.type === 'sip-device' || leg.type === 'webrtc') { - legsToRemove.push(leg.id); - } - } - for (const legId of legsToRemove) { - const leg = call.getLeg(legId); - if (leg && (leg.type === 'sip-device' || leg.type === 'sip-provider')) { - (leg as SipLeg).sendHangup(); // CANCEL ringing devices - } - call.removeLeg(legId); - } - - // Cancel passthrough tracking for this call (if applicable). - for (const [sipCallId, pt] of this.passthroughCalls) { - if (pt.call === call) { - // Keep the RTP socket — the SystemLeg will use it indirectly through the hub. - this.passthroughCalls.delete(sipCallId); - break; - } - } - - // Create a SystemLeg. - const systemLegId = `${callId}-vm`; - const systemLeg = new SystemLeg(systemLegId, { - log: this.config.log, - promptCache: pc, - callerCodecPt: 9, // SIP callers use G.722 by default - onDtmfDigit: (digit) => { - // '#' during recording = stop and save. - if (digit.digit === '#' && systemLeg.mode === 'voicemail-recording') { - this.config.log(`[call-mgr] voicemail: caller pressed # — stopping recording`); - systemLeg.stopRecording().then((result) => { - if (result && result.durationMs > 500) { - this.saveVoicemailMessage(boxId, call, result); - } - call.hangup(); - }); - } - }, - onRecordingComplete: (result) => { - if (result.durationMs > 500) { - this.saveVoicemailMessage(boxId, call, result); - } - }, - }); - - call.addLeg(systemLeg); - call.state = 'voicemail'; - - // Determine greeting prompt ID. - const greetingPromptId = `voicemail-greeting-${boxId}`; - const beepPromptId = 'voicemail-beep'; - - // Play greeting, then beep, then start recording. - systemLeg.mode = 'voicemail-greeting'; - - const startSequence = () => { - systemLeg.playPrompt(greetingPromptId, () => { - // Greeting done — play beep. - systemLeg.playPrompt(beepPromptId, () => { - // Beep done — start recording. - const recordDir = vm.getBoxDir(boxId); - const fileId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - systemLeg.startRecording(recordDir, fileId); - this.config.log(`[call-mgr] voicemail recording started for box "${boxId}"`); - }); - }); - }; - - // Check if the greeting prompt is already cached; if not, generate it. - if (pc.has(greetingPromptId)) { - startSequence(); - } else { - // Generate the greeting on-the-fly. - const wavPath = vm.getCustomGreetingWavPath(boxId); - const generatePromise = wavPath - ? pc.loadWavPrompt(greetingPromptId, wavPath) - : pc.generatePrompt(greetingPromptId, vm.getGreetingText(boxId), vm.getGreetingVoice(boxId)); - - generatePromise.then(() => { - if (call.state !== 'terminated') startSequence(); - }); - } - } - - /** Save a voicemail message after recording completes. */ - private saveVoicemailMessage(boxId: string, call: Call, result: import('./audio-recorder.ts').IRecordingResult): void { - const vm = this.config.voiceboxManager; - if (!vm) return; - - const fileName = result.filePath.split('/').pop() || 'unknown.wav'; - const msg: IVoicemailMessage = { - id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, - boxId, - callerNumber: call.callerNumber || 'Unknown', - timestamp: Date.now(), - durationMs: result.durationMs, - fileName, - heard: false, - }; - - vm.saveMessage(msg); - this.config.log(`[call-mgr] voicemail saved: ${msg.id} (${result.durationMs}ms) in box "${boxId}"`); - - // Send MWI NOTIFY to the associated device. - this.sendMwiNotify(boxId); - } - - /** Send MWI (Message Waiting Indicator) NOTIFY to a device for a voicebox. */ - private sendMwiNotify(boxId: string): void { - const vm = this.config.voiceboxManager; - if (!vm) return; - - const reg = getRegisteredDevice(boxId); - if (!reg?.contact) return; // Device not registered — skip. - - const newCount = vm.getUnheardCount(boxId); - const totalCount = vm.getTotalCount(boxId); - const oldCount = totalCount - newCount; - - const lanIp = this.config.appConfig.proxy.lanIp; - const lanPort = this.config.appConfig.proxy.lanPort; - const accountUri = `sip:${boxId}@${lanIp}`; - const targetUri = `sip:${reg.aor || boxId}@${reg.contact.address}:${reg.contact.port}`; - - const mwi = buildMwiBody(newCount, oldCount, accountUri); - const notify = SipMessage.createRequest('NOTIFY', targetUri, { - via: { host: lanIp, port: lanPort }, - from: { uri: accountUri }, - to: { uri: targetUri }, - contact: ``, - body: mwi.body, - contentType: mwi.contentType, - extraHeaders: mwi.extraHeaders, - }); - - this.config.sendSip(notify.serialize(), reg.contact); - this.config.log(`[call-mgr] MWI NOTIFY sent to ${boxId}: ${newCount} new, ${oldCount} old`); - } - - // ------------------------------------------------------------------------- - // IVR routing - // ------------------------------------------------------------------------- - - /** - * Route a call to IVR. Creates a SystemLeg and starts the IVR engine. - */ - routeToIvr(callId: string, ivrConfig: IIvrConfig): void { - const call = this.calls.get(callId); - if (!call) return; - - const pc = this.config.promptCache; - if (!pc) { - this.config.log(`[call-mgr] IVR not available (prompt cache missing)`); - return; - } - - // Cancel all ringing device legs. - const legsToRemove: string[] = []; - for (const leg of call.getLegs()) { - if (leg.type === 'sip-device' || leg.type === 'webrtc') { - legsToRemove.push(leg.id); - } - } - for (const legId of legsToRemove) { - const leg = call.getLeg(legId); - if (leg && (leg.type === 'sip-device' || leg.type === 'sip-provider')) { - (leg as SipLeg).sendHangup(); - } - call.removeLeg(legId); - } - - // Remove passthrough tracking. - for (const [sipCallId, pt] of this.passthroughCalls) { - if (pt.call === call) { - this.passthroughCalls.delete(sipCallId); - break; - } - } - - // Create SystemLeg for IVR. - const systemLegId = `${callId}-ivr`; - const systemLeg = new SystemLeg(systemLegId, { - log: this.config.log, - promptCache: pc, - callerCodecPt: 9, - }); - - call.addLeg(systemLeg); - call.state = 'ivr'; - systemLeg.mode = 'ivr'; - - // Create IVR engine. - const ivrEngine = new IvrEngine( - ivrConfig, - systemLeg, - (action: TIvrAction) => this.handleIvrAction(callId, action, ivrEngine, systemLeg), - this.config.log, - ); - - // Wire DTMF digits to the IVR engine. - systemLeg.config.onDtmfDigit = (digit) => { - ivrEngine.handleDigit(digit.digit); - }; - - // Start the IVR. - ivrEngine.start(); - } - - /** Handle an action from the IVR engine. */ - private handleIvrAction( - callId: string, - action: TIvrAction, - ivrEngine: IvrEngine, - systemLeg: SystemLeg, - ): void { - const call = this.calls.get(callId); - if (!call) return; - - switch (action.type) { - case 'route-extension': { - // Tear down IVR and ring the target device. - ivrEngine.destroy(); - call.removeLeg(systemLeg.id); - - const extTarget = this.resolveDeviceTarget(action.extensionId); - if (!extTarget) { - this.config.log(`[call-mgr] IVR: extension "${action.extensionId}" not found — hanging up`); - call.hangup(); - break; - } - - const rtpExt = this.portPool.allocate(); - if (!rtpExt) { - this.config.log(`[call-mgr] IVR: port pool exhausted — hanging up`); - call.hangup(); - break; - } - - const ps = [...this.config.appConfig.providers] - .map((p) => this.config.getProviderState(p.id)) - .find((s) => s?.publicIp); - - const extLegConfig: ISipLegConfig = { - role: 'device', - lanIp: this.config.appConfig.proxy.lanIp, - lanPort: this.config.appConfig.proxy.lanPort, - getPublicIp: () => ps?.publicIp ?? null, - sendSip: this.config.sendSip, - log: this.config.log, - sipTarget: extTarget, - rtpPort: rtpExt.port, - rtpSock: rtpExt.sock, - }; - - const extLeg = new SipLeg(`${callId}-ext`, extLegConfig); - extLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id); - extLeg.onStateChange = () => call.notifyLegStateChange(extLeg); - call.addLeg(extLeg); - call.state = 'ringing'; - - const sipCallIdExt = `${callId}-ext-${Date.now()}`; - extLeg.sendInvite({ - fromUri: `sip:${call.callerNumber || 'unknown'}@${this.config.appConfig.proxy.lanIp}`, - fromDisplayName: call.callerNumber || 'Unknown', - toUri: `sip:user@${extTarget.address}`, - callId: sipCallIdExt, - }); - this.sipCallIdIndex.set(sipCallIdExt, call); - this.config.log(`[call-mgr] IVR: ringing extension "${action.extensionId}"`); - break; - } - - case 'route-voicemail': { - ivrEngine.destroy(); - call.removeLeg(systemLeg.id); - this.routeToVoicemail(callId, action.boxId); - break; - } - - case 'transfer': { - ivrEngine.destroy(); - call.removeLeg(systemLeg.id); - - // Resolve provider for outbound dial. - const xferRoute = resolveOutboundRoute( - this.config.appConfig, - action.number, - undefined, - (pid) => !!this.config.getProviderState(pid)?.registeredAor, - ); - if (!xferRoute) { - this.config.log(`[call-mgr] IVR: no provider for transfer to ${action.number} — hanging up`); - call.hangup(); - break; - } - - const xferPs = this.config.getProviderState(xferRoute.provider.id); - if (!xferPs) { - call.hangup(); - break; - } - - this.startProviderLeg(call, xferRoute.provider, xferRoute.transformedNumber, xferPs); - this.config.log(`[call-mgr] IVR: transferring to ${action.number} via ${xferRoute.provider.displayName}`); - break; - } - - case 'hangup': { - ivrEngine.destroy(); - call.hangup(); - break; - } - - default: - break; - } - } - - /** Find the voicebox for a call (uses all device IDs or fallback to first enabled). */ - private findVoiceboxForCall(call: Call): string | null { - const allDeviceIds = this.config.appConfig.devices.map((d) => d.id); - return this.findVoiceboxForDevices(allDeviceIds); - } - - /** Find the first voicebox ID associated with a set of target device IDs. */ - private findVoiceboxForDevices(deviceIds: string[]): string | null { - const voiceboxes = this.config.appConfig.voiceboxes ?? []; - for (const deviceId of deviceIds) { - const box = voiceboxes.find((vb) => vb.id === deviceId); - if (box?.enabled) return box.id; - } - // Fallback: first enabled voicebox. - const first = voiceboxes.find((vb) => vb.enabled); - return first?.id ?? null; - } - - // ------------------------------------------------------------------------- - // Status - // ------------------------------------------------------------------------- - - getStatus(): ICallStatus[] { - const result: ICallStatus[] = []; - for (const call of this.calls.values()) { - if (call.state !== 'terminated') { - result.push(call.getStatus()); - } - } - return result; - } - - getHistory(): ICallHistoryEntry[] { - return this.callHistory; - } - - getCall(callId: string): Call | null { - return this.calls.get(callId) ?? null; - } - - getAllCalls(): Call[] { - return [...this.calls.values()]; - } - - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- - - private handleCallChange(call: Call): void { - this.config.broadcastWs('call-update', call.getStatus()); - - // Clean up terminated calls after a delay. - if (call.state === 'terminated') { - // Record in call history. - const status = call.getStatus(); - this.callHistory.unshift({ - id: status.id, - direction: status.direction, - callerNumber: status.callerNumber, - calleeNumber: status.calleeNumber, - providerUsed: status.providerUsed, - startedAt: status.createdAt, - duration: status.duration, - }); - if (this.callHistory.length > CallManager.MAX_HISTORY) { - this.callHistory.length = CallManager.MAX_HISTORY; - } - - // Remove SIP Call-ID index entries. - for (const [sipCallId, c] of this.sipCallIdIndex) { - if (c === call) this.sipCallIdIndex.delete(sipCallId); - } - // Remove from calls map after delay (so UI can show "terminated"). - setTimeout(() => this.calls.delete(call.id), 5000); - } - } - - private resolveDeviceTarget(deviceId?: string): IEndpoint | null { - if (!deviceId) { - // Default to first configured device. - const d = this.config.appConfig.devices[0]; - if (!d) return null; - const reg = getRegisteredDevice(d.id); - return reg?.contact || { address: d.expectedAddress, port: 5060 }; - } - const reg = getRegisteredDevice(deviceId); - if (reg?.contact) return reg.contact; - const dc = this.config.appConfig.devices.find((d) => d.id === deviceId); - if (dc) return { address: dc.expectedAddress, port: 5060 }; - return null; - } - - private resolveFirstDevice(deviceIds: string[]): IEndpoint | null { - for (const id of deviceIds) { - const result = this.resolveDeviceTarget(id); - if (result) return result; - } - // Fallback to first configured device. - return this.resolveDeviceTarget(); - } -} diff --git a/ts/call/call.ts b/ts/call/call.ts deleted file mode 100644 index 8e180a1..0000000 --- a/ts/call/call.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Call — the hub entity in the hub model. - * - * A Call owns N legs and bridges their media. For 2-party calls, RTP packets - * from leg A are forwarded to leg B and vice versa. For N>2 party calls, - * packets from each leg are forwarded to all other legs (fan-out). - * - * Transcoding is applied per-leg when codecs differ. - */ - -import { Buffer } from 'node:buffer'; -import type { ILeg } from './leg.ts'; -import type { TCallState, TCallDirection, ICallStatus } from './types.ts'; -import { RtpPortPool } from './rtp-port-pool.ts'; -import type { SipLeg } from './sip-leg.ts'; - -export class Call { - readonly id: string; - state: TCallState = 'setting-up'; - direction: TCallDirection; - readonly createdAt: number; - - callerNumber: string | null = null; - calleeNumber: string | null = null; - providerUsed: string | null = null; - - /** All legs in this call. */ - private legs = new Map(); - - /** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */ - private nativeCodec: number | null = null; - - /** Port pool reference for cleanup. */ - private portPool: RtpPortPool; - private log: (msg: string) => void; - private onChange: ((call: Call) => void) | null = null; - - constructor(options: { - id: string; - direction: TCallDirection; - portPool: RtpPortPool; - log: (msg: string) => void; - onChange?: (call: Call) => void; - }) { - this.id = options.id; - this.direction = options.direction; - this.createdAt = Date.now(); - this.portPool = options.portPool; - this.log = options.log; - this.onChange = options.onChange ?? null; - } - - // ------------------------------------------------------------------------- - // Leg management - // ------------------------------------------------------------------------- - - /** Add a leg to this call and wire up media forwarding. */ - addLeg(leg: ILeg): void { - this.legs.set(leg.id, leg); - - // Wire up RTP forwarding: when this leg receives a packet, forward to all other legs. - leg.onRtpReceived = (data: Buffer) => { - this.forwardRtp(leg.id, data); - }; - - this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`); - this.updateState(); - } - - /** Remove a leg from this call, tear it down, and release its port. */ - removeLeg(legId: string): void { - const leg = this.legs.get(legId); - if (!leg) return; - - leg.onRtpReceived = null; - leg.teardown(); - if (leg.rtpPort) { - this.portPool.release(leg.rtpPort); - } - this.legs.delete(legId); - - this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`); - this.updateState(); - } - - getLeg(legId: string): ILeg | null { - return this.legs.get(legId) ?? null; - } - - getLegs(): ILeg[] { - return [...this.legs.values()]; - } - - getLegByType(type: string): ILeg | null { - for (const leg of this.legs.values()) { - if (leg.type === type) return leg; - } - return null; - } - - getLegBySipCallId(sipCallId: string): ILeg | null { - for (const leg of this.legs.values()) { - if (leg.sipCallId === sipCallId) return leg; - } - return null; - } - - get legCount(): number { - return this.legs.size; - } - - // ------------------------------------------------------------------------- - // Media forwarding (the hub) - // ------------------------------------------------------------------------- - - private forwardRtp(fromLegId: string, data: Buffer): void { - for (const [id, leg] of this.legs) { - if (id === fromLegId) continue; - if (leg.state !== 'connected') continue; - - // For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally. - // For SIP legs, forward the raw packet (same codec path) or let the leg handle it. - // The Call hub does NOT transcode — that's the leg's responsibility. - leg.sendRtp(data); - } - } - - // ------------------------------------------------------------------------- - // State management - // ------------------------------------------------------------------------- - - private updateState(): void { - if (this.state === 'terminated' || this.state === 'terminating') return; - - const legs = [...this.legs.values()]; - if (legs.length === 0) { - this.state = 'terminated'; - } else if (legs.every((l) => l.state === 'terminated')) { - this.state = 'terminated'; - } else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) { - // If a system leg is connected, report voicemail/ivr state for the dashboard. - const systemLeg = legs.find((l) => l.type === 'system'); - if (systemLeg) { - // Keep voicemail/ivr state if already set; otherwise set connected. - if (this.state !== 'voicemail' && this.state !== 'ivr') { - this.state = 'connected'; - } - } else { - this.state = 'connected'; - } - } else if (legs.some((l) => l.state === 'ringing')) { - this.state = 'ringing'; - } else { - this.state = 'setting-up'; - } - - this.onChange?.(this); - } - - /** Notify the call that a leg's state has changed. */ - notifyLegStateChange(_leg: ILeg): void { - this.updateState(); - } - - // ------------------------------------------------------------------------- - // Hangup - // ------------------------------------------------------------------------- - - /** Tear down all legs and terminate the call. */ - hangup(): void { - if (this.state === 'terminated' || this.state === 'terminating') return; - this.state = 'terminating'; - this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`); - - for (const [id, leg] of this.legs) { - // Send BYE/CANCEL for SIP legs (system legs have no SIP signaling). - if (leg.type === 'sip-device' || leg.type === 'sip-provider') { - (leg as SipLeg).sendHangup(); - } - leg.teardown(); - if (leg.rtpPort) { - this.portPool.release(leg.rtpPort); - } - } - this.legs.clear(); - - this.state = 'terminated'; - this.onChange?.(this); - } - - /** - * Handle a BYE from one leg — tear down the other legs. - * Called by CallManager when a SipLeg receives a BYE. - */ - handleLegTerminated(terminatedLegId: string): void { - const terminatedLeg = this.legs.get(terminatedLegId); - if (!terminatedLeg) return; - - // Remove the terminated leg. - terminatedLeg.onRtpReceived = null; - if (terminatedLeg.rtpPort) { - this.portPool.release(terminatedLeg.rtpPort); - } - this.legs.delete(terminatedLegId); - - // If this is a 2-party call, hang up the other leg too. - if (this.legs.size <= 1) { - for (const [id, leg] of this.legs) { - // Send BYE/CANCEL for SIP legs (system legs just get torn down). - if (leg.type === 'sip-device' || leg.type === 'sip-provider') { - (leg as SipLeg).sendHangup(); - } - leg.teardown(); - if (leg.rtpPort) { - this.portPool.release(leg.rtpPort); - } - } - this.legs.clear(); - this.state = 'terminated'; - this.log(`[call:${this.id}] terminated`); - this.onChange?.(this); - } else { - this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`); - this.updateState(); - } - } - - // ------------------------------------------------------------------------- - // Transfer - // ------------------------------------------------------------------------- - - /** - * Detach a leg from this call (without tearing it down). - * The leg can then be added to another call. - */ - detachLeg(legId: string): ILeg | null { - const leg = this.legs.get(legId); - if (!leg) return null; - - leg.onRtpReceived = null; - this.legs.delete(legId); - - this.log(`[call:${this.id}] detached leg ${legId}`); - this.updateState(); - return leg; - } - - // ------------------------------------------------------------------------- - // Status - // ------------------------------------------------------------------------- - - getStatus(): ICallStatus { - return { - id: this.id, - state: this.state, - direction: this.direction, - callerNumber: this.callerNumber, - calleeNumber: this.calleeNumber, - providerUsed: this.providerUsed, - createdAt: this.createdAt, - duration: Math.floor((Date.now() - this.createdAt) / 1000), - legs: [...this.legs.values()].map((l) => l.getStatus()), - }; - } -} diff --git a/ts/call/dtmf-detector.ts b/ts/call/dtmf-detector.ts deleted file mode 100644 index 43b1610..0000000 --- a/ts/call/dtmf-detector.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * DTMF detection — parses RFC 2833 telephone-event RTP packets - * and SIP INFO (application/dtmf-relay) messages. - * - * Designed to be attached to any leg or RTP stream. The detector - * deduplicates repeated telephone-event packets (same digit is sent - * multiple times with increasing duration) and fires a callback - * once per detected digit. - */ - -import { Buffer } from 'node:buffer'; -import type { SipMessage } from '../sip/index.ts'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** A single detected DTMF digit. */ -export interface IDtmfDigit { - /** The digit character: '0'-'9', '*', '#', 'A'-'D'. */ - digit: string; - /** Duration in milliseconds. */ - durationMs: number; - /** Detection source. */ - source: 'rfc2833' | 'sip-info'; - /** Wall-clock timestamp when the digit was detected. */ - timestamp: number; -} - -/** Callback fired once per detected DTMF digit. */ -export type TDtmfCallback = (digit: IDtmfDigit) => void; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** RFC 2833 event ID → character mapping. */ -const EVENT_CHARS: string[] = [ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - '*', '#', 'A', 'B', 'C', 'D', -]; - -/** Safety timeout: report digit if no End packet arrives within this many ms. */ -const SAFETY_TIMEOUT_MS = 200; - -// --------------------------------------------------------------------------- -// DtmfDetector -// --------------------------------------------------------------------------- - -/** - * Detects DTMF digits from RFC 2833 RTP packets and SIP INFO messages. - * - * Usage: - * ``` - * const detector = new DtmfDetector(log); - * detector.onDigit = (d) => console.log('DTMF:', d.digit); - * // Feed every RTP packet (detector checks PT internally): - * detector.processRtp(rtpPacket); - * // Or feed a SIP INFO message: - * detector.processSipInfo(sipMsg); - * ``` - */ -export class DtmfDetector { - /** Callback fired once per detected digit. */ - onDigit: TDtmfCallback | null = null; - - /** Negotiated telephone-event payload type (default 101). */ - private telephoneEventPt: number; - - /** Clock rate for duration calculation (default 8000 Hz). */ - private clockRate: number; - - // -- Deduplication state for RFC 2833 -- - /** Event ID of the digit currently being received. */ - private currentEventId: number | null = null; - /** RTP timestamp of the first packet for the current event. */ - private currentEventTs: number | null = null; - /** Whether the current event has already been reported. */ - private currentEventReported = false; - /** Latest duration value seen (in clock ticks). */ - private currentEventDuration = 0; - /** Latest volume value seen (dBm0, 0 = loudest). */ - private currentEventVolume = 0; - /** Safety timer: fires if no End packet arrives. */ - private safetyTimer: ReturnType | null = null; - - private log: (msg: string) => void; - - constructor( - log: (msg: string) => void, - telephoneEventPt = 101, - clockRate = 8000, - ) { - this.log = log; - this.telephoneEventPt = telephoneEventPt; - this.clockRate = clockRate; - } - - // ------------------------------------------------------------------------- - // RFC 2833 RTP processing - // ------------------------------------------------------------------------- - - /** - * Feed an RTP packet. Checks PT; ignores non-DTMF packets. - * Expects the full RTP packet (12-byte header + payload). - */ - processRtp(data: Buffer): void { - if (data.length < 16) return; // 12-byte header + 4-byte telephone-event payload minimum - - const pt = data[1] & 0x7f; - if (pt !== this.telephoneEventPt) return; - - // Parse RTP header fields we need. - const marker = (data[1] & 0x80) !== 0; - const rtpTimestamp = data.readUInt32BE(4); - - // Parse telephone-event payload (4 bytes starting at offset 12). - const eventId = data[12]; - const endBit = (data[13] & 0x80) !== 0; - const volume = data[13] & 0x3f; - const duration = data.readUInt16BE(14); - - // Validate event ID. - if (eventId >= EVENT_CHARS.length) return; - - // Detect new event: marker bit, different event ID, or different RTP timestamp. - const isNewEvent = - marker || - eventId !== this.currentEventId || - rtpTimestamp !== this.currentEventTs; - - if (isNewEvent) { - // If there was an unreported previous event, report it now (fallback). - this.reportPendingEvent(); - - // Start tracking the new event. - this.currentEventId = eventId; - this.currentEventTs = rtpTimestamp; - this.currentEventReported = false; - this.currentEventDuration = duration; - this.currentEventVolume = volume; - - // Start safety timer. - this.clearSafetyTimer(); - this.safetyTimer = setTimeout(() => { - this.reportPendingEvent(); - }, SAFETY_TIMEOUT_MS); - } - - // Update duration (it increases with each retransmission). - if (duration > this.currentEventDuration) { - this.currentEventDuration = duration; - } - - // Report on End bit (first time only). - if (endBit && !this.currentEventReported) { - this.currentEventReported = true; - this.clearSafetyTimer(); - - const digit = EVENT_CHARS[eventId]; - const durationMs = (this.currentEventDuration / this.clockRate) * 1000; - - this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms)`); - this.onDigit?.({ - digit, - durationMs, - source: 'rfc2833', - timestamp: Date.now(), - }); - } - } - - /** Report a pending (unreported) event — called by safety timer or on new event start. */ - private reportPendingEvent(): void { - if ( - this.currentEventId !== null && - !this.currentEventReported && - this.currentEventId < EVENT_CHARS.length - ) { - this.currentEventReported = true; - this.clearSafetyTimer(); - - const digit = EVENT_CHARS[this.currentEventId]; - const durationMs = (this.currentEventDuration / this.clockRate) * 1000; - - this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms, safety timeout)`); - this.onDigit?.({ - digit, - durationMs, - source: 'rfc2833', - timestamp: Date.now(), - }); - } - } - - private clearSafetyTimer(): void { - if (this.safetyTimer) { - clearTimeout(this.safetyTimer); - this.safetyTimer = null; - } - } - - // ------------------------------------------------------------------------- - // SIP INFO processing - // ------------------------------------------------------------------------- - - /** - * Parse a SIP INFO message carrying DTMF. - * Supports Content-Type: application/dtmf-relay (Signal=X / Duration=Y). - */ - processSipInfo(msg: SipMessage): void { - const ct = (msg.getHeader('Content-Type') || '').toLowerCase(); - if (!ct.includes('application/dtmf-relay') && !ct.includes('application/dtmf')) return; - - const body = msg.body || ''; - - if (ct.includes('application/dtmf-relay')) { - // Format: "Signal= 5\r\nDuration= 160\r\n" - const signalMatch = body.match(/Signal\s*=\s*(\S+)/i); - const durationMatch = body.match(/Duration\s*=\s*(\d+)/i); - if (!signalMatch) return; - - const signal = signalMatch[1]; - const durationTicks = durationMatch ? parseInt(durationMatch[1], 10) : 160; - - // Validate digit. - if (signal.length !== 1 || !/[0-9*#A-Da-d]/.test(signal)) return; - const digit = signal.toUpperCase(); - const durationMs = (durationTicks / this.clockRate) * 1000; - - this.log(`[dtmf] SIP INFO digit '${digit}' (${Math.round(durationMs)}ms)`); - this.onDigit?.({ - digit, - durationMs, - source: 'sip-info', - timestamp: Date.now(), - }); - } else if (ct.includes('application/dtmf')) { - // Simple format: just the digit character in the body. - const digit = body.trim().toUpperCase(); - if (digit.length !== 1 || !/[0-9*#A-D]/.test(digit)) return; - - this.log(`[dtmf] SIP INFO digit '${digit}' (application/dtmf)`); - this.onDigit?.({ - digit, - durationMs: 250, // default duration - source: 'sip-info', - timestamp: Date.now(), - }); - } - } - - // ------------------------------------------------------------------------- - // Lifecycle - // ------------------------------------------------------------------------- - - /** Reset detection state (e.g., between calls). */ - reset(): void { - this.currentEventId = null; - this.currentEventTs = null; - this.currentEventReported = false; - this.currentEventDuration = 0; - this.currentEventVolume = 0; - this.clearSafetyTimer(); - } - - /** Clean up timers and references. */ - destroy(): void { - this.clearSafetyTimer(); - this.onDigit = null; - } -} diff --git a/ts/call/index.ts b/ts/call/index.ts deleted file mode 100644 index ffec977..0000000 --- a/ts/call/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { TCallState, TLegState, TLegType, TCallDirection, ICallStatus, ILegStatus, ICallHistoryEntry } from './types.ts'; -export type { ILeg } from './leg.ts'; -export { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts'; -export { RtpPortPool } from './rtp-port-pool.ts'; -export type { IRtpAllocation } from './rtp-port-pool.ts'; -export { SipLeg } from './sip-leg.ts'; -export type { ISipLegConfig } from './sip-leg.ts'; -export { WebRtcLeg } from './webrtc-leg.ts'; -export type { IWebRtcLegConfig } from './webrtc-leg.ts'; -export { Call } from './call.ts'; -export { CallManager } from './call-manager.ts'; -export type { ICallManagerConfig } from './call-manager.ts'; diff --git a/ts/call/leg.ts b/ts/call/leg.ts deleted file mode 100644 index 110c843..0000000 --- a/ts/call/leg.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * ILeg interface — abstract connection from a Call hub to an endpoint. - * - * Concrete implementations: SipLeg (SIP devices + providers) and WebRtcLeg (browsers). - * Shared RTP utilities (header building, clock rates) are also defined here. - */ - -import { Buffer } from 'node:buffer'; -import type dgram from 'node:dgram'; -import type { IEndpoint } from '../sip/index.ts'; -import type { TLegState, TLegType, ILegStatus } from './types.ts'; -import type { IRtpTranscoder } from '../codec.ts'; -import type { SipDialog } from '../sip/index.ts'; -import type { SipMessage } from '../sip/index.ts'; - -// --------------------------------------------------------------------------- -// ILeg interface -// --------------------------------------------------------------------------- - -export interface ILeg { - readonly id: string; - readonly type: TLegType; - state: TLegState; - - /** The SIP Call-ID used by this leg (for CallManager routing). */ - readonly sipCallId: string; - - /** Where this leg sends/receives RTP. */ - readonly rtpPort: number | null; - readonly rtpSock: dgram.Socket | null; - remoteMedia: IEndpoint | null; - - /** Negotiated codec payload type (e.g. 9 = G.722, 111 = Opus). */ - codec: number | null; - - /** Transcoder for converting to this leg's codec (set by Call when codecs differ). */ - transcoder: IRtpTranscoder | null; - - /** Packet counters. */ - pktSent: number; - pktReceived: number; - - /** SIP dialog (SipLegs only, null for WebRtcLegs). */ - readonly dialog: SipDialog | null; - - /** - * Send an RTP packet toward this leg's remote endpoint. - * If a transcoder is set, the Call should transcode before calling this. - */ - sendRtp(data: Buffer): void; - - /** - * Callback set by the owning Call — invoked when this leg receives an RTP packet. - * The Call uses this to forward to other legs. - */ - onRtpReceived: ((data: Buffer) => void) | null; - - /** - * Handle an incoming SIP message routed to this leg (SipLegs only). - * Returns a SipMessage response if one needs to be sent, or null. - */ - handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void; - - /** Release all resources (sockets, peer connections, etc.). */ - teardown(): void; - - /** Status snapshot for the dashboard. */ - getStatus(): ILegStatus; -} - -// --------------------------------------------------------------------------- -// Shared RTP utilities -// --------------------------------------------------------------------------- - -/** RTP clock increment per 20ms frame for each codec. */ -export function rtpClockIncrement(pt: number): number { - if (pt === 111) return 960; // Opus: 48000 Hz x 0.02s - if (pt === 9) return 160; // G.722: 8000 Hz x 0.02s (SDP clock rate quirk) - return 160; // PCMU/PCMA: 8000 Hz x 0.02s -} - -/** Build a fresh RTP header with correct PT, timestamp, seq, SSRC. */ -export function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer { - const hdr = Buffer.alloc(12); - hdr[0] = 0x80; // V=2 - hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f); - hdr.writeUInt16BE(seq & 0xffff, 2); - hdr.writeUInt32BE(ts >>> 0, 4); - hdr.writeUInt32BE(ssrc >>> 0, 8); - return hdr; -} - -/** Codec name for status display. */ -export function codecDisplayName(pt: number | null): string | null { - if (pt === null) return null; - switch (pt) { - case 0: return 'PCMU'; - case 8: return 'PCMA'; - case 9: return 'G.722'; - case 111: return 'Opus'; - case 101: return 'telephone-event'; - default: return `PT${pt}`; - } -} diff --git a/ts/call/prompt-cache.ts b/ts/call/prompt-cache.ts index d509bb9..9d0d499 100644 --- a/ts/call/prompt-cache.ts +++ b/ts/call/prompt-cache.ts @@ -17,9 +17,26 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { Buffer } from 'node:buffer'; -import { buildRtpHeader, rtpClockIncrement } from './leg.ts'; import { encodePcm, isCodecReady } from '../opusbridge.ts'; +/** RTP clock increment per 20ms frame for each codec. */ +function rtpClockIncrement(pt: number): number { + if (pt === 111) return 960; + if (pt === 9) return 160; + return 160; +} + +/** Build a fresh RTP header. */ +function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer { + const hdr = Buffer.alloc(12); + hdr[0] = 0x80; + hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f); + hdr.writeUInt16BE(seq & 0xffff, 2); + hdr.writeUInt32BE(ts >>> 0, 4); + hdr.writeUInt32BE(ssrc >>> 0, 8); + return hdr; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- diff --git a/ts/call/rtp-port-pool.ts b/ts/call/rtp-port-pool.ts deleted file mode 100644 index 1d22c30..0000000 --- a/ts/call/rtp-port-pool.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Unified RTP port pool — replaces the three separate allocators - * in sipproxy.ts, calloriginator.ts, and webrtcbridge.ts. - * - * Allocates even-numbered UDP ports from a configured range. - * Each allocation binds a dgram socket and returns it ready to use. - */ - -import dgram from 'node:dgram'; - -export interface IRtpAllocation { - port: number; - sock: dgram.Socket; -} - -export class RtpPortPool { - private min: number; - private max: number; - private allocated = new Map(); - private log: (msg: string) => void; - - constructor(min: number, max: number, log: (msg: string) => void) { - this.min = min % 2 === 0 ? min : min + 1; // ensure even start - this.max = max; - this.log = log; - } - - /** - * Allocate an even-numbered port and bind a UDP socket to it. - * Returns null if the pool is exhausted. - */ - allocate(): IRtpAllocation | null { - for (let port = this.min; port < this.max; port += 2) { - if (this.allocated.has(port)) continue; - - const sock = dgram.createSocket('udp4'); - try { - sock.bind(port, '0.0.0.0'); - } catch { - try { sock.close(); } catch { /* ignore */ } - continue; - } - this.allocated.set(port, sock); - this.log(`[rtp-pool] allocated port ${port} (${this.allocated.size} in use)`); - return { port, sock }; - } - this.log('[rtp-pool] WARN: port pool exhausted'); - return null; - } - - /** - * Release a port back to the pool and close its socket. - */ - release(port: number): void { - const sock = this.allocated.get(port); - if (!sock) return; - try { sock.close(); } catch { /* ignore */ } - this.allocated.delete(port); - this.log(`[rtp-pool] released port ${port} (${this.allocated.size} in use)`); - } - - /** Number of currently allocated ports. */ - get size(): number { - return this.allocated.size; - } - - /** Total capacity (number of even ports in range). */ - get capacity(): number { - return Math.floor((this.max - this.min) / 2); - } -} diff --git a/ts/call/sip-leg.ts b/ts/call/sip-leg.ts deleted file mode 100644 index 3144e62..0000000 --- a/ts/call/sip-leg.ts +++ /dev/null @@ -1,633 +0,0 @@ -/** - * SipLeg — a SIP connection from the Call hub to a device or provider. - * - * Wraps a SipDialog and an RTP socket. Handles: - * - INVITE/ACK/BYE/CANCEL lifecycle - * - SDP rewriting (LAN IP for devices, public IP for providers) - * - Digest auth for provider legs (407/401) - * - Early-media silence for providers with quirks - * - Record-Route insertion for dialog-establishing requests - */ - -import dgram from 'node:dgram'; -import { Buffer } from 'node:buffer'; -import { - SipMessage, - SipDialog, - buildSdp, - parseSdpEndpoint, - rewriteSdp, - rewriteSipUri, - parseDigestChallenge, - computeDigestAuth, - generateTag, -} from '../sip/index.ts'; -import type { IEndpoint } from '../sip/index.ts'; -import type { IProviderConfig, IQuirks } from '../config.ts'; -import type { TLegState, TLegType, ILegStatus } from './types.ts'; -import type { ILeg } from './leg.ts'; -import { codecDisplayName } from './leg.ts'; -import type { IRtpTranscoder } from '../codec.ts'; - -// --------------------------------------------------------------------------- -// SipLeg config -// --------------------------------------------------------------------------- - -export interface ISipLegConfig { - /** Whether this leg faces a device (LAN) or a provider (WAN). */ - role: 'device' | 'provider'; - - /** Proxy LAN IP (for SDP rewriting toward devices). */ - lanIp: string; - /** Proxy LAN port (for Via, Contact, Record-Route). */ - lanPort: number; - - /** Public IP (for SDP rewriting toward providers). */ - getPublicIp: () => string | null; - - /** Send a SIP message via the main UDP socket. */ - sendSip: (buf: Buffer, dest: IEndpoint) => void; - /** Logging function. */ - log: (msg: string) => void; - - /** Provider config (for provider legs: auth, codecs, quirks, outbound proxy). */ - provider?: IProviderConfig; - - /** The endpoint to send SIP messages to (device address or provider outbound proxy). */ - sipTarget: IEndpoint; - - /** RTP port and socket (pre-allocated from the pool). */ - rtpPort: number; - rtpSock: dgram.Socket; - - /** Payload types to offer in SDP. */ - payloadTypes?: number[]; - - /** Registered AOR (for From header in provider leg). */ - getRegisteredAor?: () => string | null; - /** SIP password (for digest auth). */ - getSipPassword?: () => string | null; -} - -// --------------------------------------------------------------------------- -// SipLeg -// --------------------------------------------------------------------------- - -export class SipLeg implements ILeg { - readonly id: string; - readonly type: TLegType; - state: TLegState = 'inviting'; - readonly config: ISipLegConfig; - - /** The SIP dialog for this leg. */ - dialog: SipDialog | null = null; - - /** Original INVITE (needed for CANCEL). */ - invite: SipMessage | null = null; - - /** Original unauthenticated INVITE (for re-ACKing retransmitted 407s). */ - private origInvite: SipMessage | null = null; - - /** Whether we've attempted digest auth on this leg. */ - private authAttempted = false; - - /** RTP socket and port. */ - readonly rtpPort: number; - readonly rtpSock: dgram.Socket; - - /** Remote media endpoint (learned from SDP). */ - remoteMedia: IEndpoint | null = null; - - /** Negotiated codec. */ - codec: number | null = null; - - /** Transcoder (set by Call when codecs differ between legs). */ - transcoder: IRtpTranscoder | null = null; - - /** Stable SSRC for this leg (used for silence + forwarded audio). */ - readonly ssrc: number = (Math.random() * 0xffffffff) >>> 0; - - /** Packet counters. */ - pktSent = 0; - pktReceived = 0; - - /** Callback set by Call to receive RTP. */ - onRtpReceived: ((data: Buffer) => void) | null = null; - - /** Silence stream timer (for provider quirks). */ - private silenceTimer: ReturnType | null = null; - - /** Callbacks for lifecycle events. */ - onStateChange: ((leg: SipLeg) => void) | null = null; - onConnected: ((leg: SipLeg) => void) | null = null; - onTerminated: ((leg: SipLeg) => void) | null = null; - - /** Callback for SIP INFO messages (used for DTMF relay). */ - onInfoReceived: ((msg: SipMessage) => void) | null = null; - - constructor(id: string, config: ISipLegConfig) { - this.id = id; - this.type = config.role === 'device' ? 'sip-device' : 'sip-provider'; - this.config = config; - this.rtpPort = config.rtpPort; - this.rtpSock = config.rtpSock; - - // Set up RTP receive handler. - this.rtpSock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => { - this.pktReceived++; - - // Learn remote media endpoint from first packet if not yet known. - if (!this.remoteMedia) { - this.remoteMedia = { address: rinfo.address, port: rinfo.port }; - this.config.log(`[sip-leg:${this.id}] learned remote media: ${rinfo.address}:${rinfo.port}`); - } - - // Forward to the Call hub. - if (this.onRtpReceived) { - this.onRtpReceived(data); - } - }); - - this.rtpSock.on('error', (e: Error) => { - this.config.log(`[sip-leg:${this.id}] rtp error: ${e.message}`); - }); - } - - get sipCallId(): string { - return this.dialog?.callId || 'no-dialog'; - } - - // ------------------------------------------------------------------------- - // Outbound INVITE (B2BUA mode — create a new dialog) - // ------------------------------------------------------------------------- - - /** - * Send an INVITE to establish this leg. - * Creates a new SipDialog (UAC side). - */ - sendInvite(options: { - fromUri: string; - toUri: string; - callId: string; - fromTag?: string; - fromDisplayName?: string; - cseq?: number; - extraHeaders?: [string, string][]; - }): void { - const ip = this.type === 'sip-provider' - ? (this.config.getPublicIp() || this.config.lanIp) - : this.config.lanIp; - const pts = this.config.payloadTypes || [9, 0, 8, 101]; - - const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts }); - - const invite = SipMessage.createRequest('INVITE', options.toUri, { - via: { host: ip, port: this.config.lanPort }, - from: { uri: options.fromUri, displayName: options.fromDisplayName, tag: options.fromTag }, - to: { uri: options.toUri }, - callId: options.callId, - cseq: options.cseq, - contact: ``, - body: sdp, - contentType: 'application/sdp', - extraHeaders: options.extraHeaders, - }); - - this.invite = invite; - this.dialog = SipDialog.fromUacInvite(invite, ip, this.config.lanPort); - this.state = 'inviting'; - - this.config.log(`[sip-leg:${this.id}] INVITE -> ${this.config.sipTarget.address}:${this.config.sipTarget.port}`); - this.config.sendSip(invite.serialize(), this.config.sipTarget); - } - - // ------------------------------------------------------------------------- - // Passthrough mode — forward a SIP message with rewriting - // ------------------------------------------------------------------------- - - /** - * Accept an incoming INVITE as a UAS (for passthrough inbound calls). - * Creates a SipDialog on the UAS side. - */ - acceptIncoming(invite: SipMessage): void { - const localTag = generateTag(); - this.dialog = SipDialog.fromUasInvite(invite, localTag, this.config.lanIp, this.config.lanPort); - this.invite = invite; - this.state = 'inviting'; - - // Learn remote media from SDP. - if (invite.hasSdpBody) { - const ep = parseSdpEndpoint(invite.body); - if (ep) { - this.remoteMedia = ep; - this.config.log(`[sip-leg:${this.id}] media from SDP: ${ep.address}:${ep.port}`); - } - } - } - - /** - * Forward a SIP message through this leg with SDP rewriting. - * Used for passthrough calls where the proxy relays messages. - */ - forwardMessage(msg: SipMessage, dest: IEndpoint): void { - const rewriteIp = this.type === 'sip-provider' - ? (this.config.getPublicIp() || this.config.lanIp) - : this.config.lanIp; - - // Rewrite SDP if present. - if (msg.hasSdpBody) { - const { body, original } = rewriteSdp(msg.body, rewriteIp, this.rtpPort); - msg.body = body; - msg.updateContentLength(); - if (original) { - this.remoteMedia = original; - this.config.log(`[sip-leg:${this.id}] media from SDP rewrite: ${original.address}:${original.port}`); - } - } - - // Record-Route for dialog-establishing requests. - if (msg.isRequest && msg.isDialogEstablishing) { - msg.prependHeader('Record-Route', ``); - } - - // Rewrite Contact. - if (this.type === 'sip-provider') { - const contact = msg.getHeader('Contact'); - if (contact) { - const nc = rewriteSipUri(contact, rewriteIp, this.config.lanPort); - if (nc !== contact) msg.setHeader('Contact', nc); - } - } - - // Rewrite Request-URI for inbound messages going to device. - if (this.type === 'sip-device' && msg.isRequest) { - msg.setRequestUri(rewriteSipUri(msg.requestUri!, dest.address, dest.port)); - } - - this.config.sendSip(msg.serialize(), dest); - } - - // ------------------------------------------------------------------------- - // SIP message handling (routed by CallManager) - // ------------------------------------------------------------------------- - - handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void { - if (msg.isResponse) { - this.handleResponse(msg, rinfo); - } else { - this.handleRequest(msg, rinfo); - } - } - - private handleResponse(msg: SipMessage, _rinfo: IEndpoint): void { - const code = msg.statusCode ?? 0; - const method = msg.cseqMethod?.toUpperCase(); - - this.config.log(`[sip-leg:${this.id}] <- ${code} (${method})`); - - if (method === 'INVITE') { - this.handleInviteResponse(msg, code); - } - // BYE/CANCEL responses don't need action beyond logging. - } - - private handleInviteResponse(msg: SipMessage, code: number): void { - // Handle retransmitted 407 for the original unauthenticated INVITE. - if (this.authAttempted && this.dialog) { - const responseCSeqNum = parseInt((msg.getHeader('CSeq') || '').split(/\s+/)[0], 10); - if (responseCSeqNum < this.dialog.localCSeq && code >= 400) { - if (this.origInvite) { - const ack = buildNon2xxAck(this.origInvite, msg); - this.config.sendSip(ack.serialize(), this.config.sipTarget); - } - return; - } - } - - // Handle 407 Proxy Authentication Required. - if (code === 407 && this.type === 'sip-provider') { - this.handleAuthChallenge(msg); - return; - } - - // Update dialog state. - if (this.dialog) { - this.dialog.processResponse(msg); - } - - if (code === 180 || code === 183) { - this.state = 'ringing'; - this.onStateChange?.(this); - } else if (code >= 200 && code < 300) { - // ACK the 200 OK. - if (this.dialog) { - const ack = this.dialog.createAck(); - this.config.sendSip(ack.serialize(), this.config.sipTarget); - this.config.log(`[sip-leg:${this.id}] ACK sent`); - } - - // If already connected (200 retransmit), just re-ACK. - if (this.state === 'connected') { - this.config.log(`[sip-leg:${this.id}] re-ACK (200 retransmit)`); - return; - } - - // Learn media endpoint from SDP. - if (msg.hasSdpBody) { - const ep = parseSdpEndpoint(msg.body); - if (ep) { - this.remoteMedia = ep; - this.config.log(`[sip-leg:${this.id}] media = ${ep.address}:${ep.port}`); - } - } - - this.state = 'connected'; - this.config.log(`[sip-leg:${this.id}] CONNECTED`); - - // Start silence for provider legs with early media quirks. - if (this.type === 'sip-provider') { - this.startSilence(); - } - - // Prime the RTP path. - if (this.remoteMedia) { - this.primeRtp(this.remoteMedia); - } - - this.onConnected?.(this); - this.onStateChange?.(this); - } else if (code >= 300) { - this.config.log(`[sip-leg:${this.id}] rejected ${code}`); - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - this.onTerminated?.(this); - this.onStateChange?.(this); - } - } - - private handleAuthChallenge(msg: SipMessage): void { - if (this.authAttempted) { - this.config.log(`[sip-leg:${this.id}] 407 after auth attempt — credentials rejected`); - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - this.onTerminated?.(this); - return; - } - this.authAttempted = true; - - const challenge = msg.getHeader('Proxy-Authenticate'); - if (!challenge) { - this.config.log(`[sip-leg:${this.id}] 407 but no Proxy-Authenticate`); - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - this.onTerminated?.(this); - return; - } - - const parsed = parseDigestChallenge(challenge); - if (!parsed) { - this.config.log(`[sip-leg:${this.id}] could not parse digest challenge`); - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - this.onTerminated?.(this); - return; - } - - const password = this.config.getSipPassword?.(); - const aor = this.config.getRegisteredAor?.(); - if (!password || !aor) { - this.config.log(`[sip-leg:${this.id}] 407 but no password or AOR`); - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - this.onTerminated?.(this); - return; - } - - const username = aor.replace(/^sips?:/, '').split('@')[0]; - const destUri = this.invite?.requestUri || ''; - - const authValue = computeDigestAuth({ - username, - password, - realm: parsed.realm, - nonce: parsed.nonce, - method: 'INVITE', - uri: destUri, - algorithm: parsed.algorithm, - opaque: parsed.opaque, - }); - - // ACK the 407. - if (this.invite) { - const ack407 = buildNon2xxAck(this.invite, msg); - this.config.sendSip(ack407.serialize(), this.config.sipTarget); - this.config.log(`[sip-leg:${this.id}] ACK-407 sent`); - } - - // Keep original INVITE for re-ACKing retransmitted 407s. - this.origInvite = this.invite; - - // Resend INVITE with auth, same From tag, incremented CSeq. - const ip = this.config.getPublicIp() || this.config.lanIp; - const fromTag = this.dialog!.localTag; - const pts = this.config.payloadTypes || [9, 0, 8, 101]; - - const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts }); - - const inviteAuth = SipMessage.createRequest('INVITE', destUri, { - via: { host: ip, port: this.config.lanPort }, - from: { uri: aor, tag: fromTag }, - to: { uri: destUri }, - callId: this.dialog!.callId, - cseq: 2, - contact: ``, - body: sdp, - contentType: 'application/sdp', - extraHeaders: [['Proxy-Authorization', authValue]], - }); - - this.invite = inviteAuth; - this.dialog!.localCSeq = 2; - - this.config.log(`[sip-leg:${this.id}] resending INVITE with auth`); - this.config.sendSip(inviteAuth.serialize(), this.config.sipTarget); - } - - private handleRequest(msg: SipMessage, rinfo: IEndpoint): void { - const method = msg.method; - this.config.log(`[sip-leg:${this.id}] <- ${method} from ${rinfo.address}:${rinfo.port}`); - - if (method === 'BYE') { - // Send 200 OK to the BYE. - const ok = SipMessage.createResponse(200, 'OK', msg); - this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port }); - - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - this.onTerminated?.(this); - this.onStateChange?.(this); - } - if (method === 'INFO') { - // Respond 200 OK to the INFO request. - const ok = SipMessage.createResponse(200, 'OK', msg); - this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port }); - - // Forward to DTMF handler (if attached). - this.onInfoReceived?.(msg); - } - // Other in-dialog requests (re-INVITE, etc.) can be handled here in the future. - } - - // ------------------------------------------------------------------------- - // Send BYE / CANCEL - // ------------------------------------------------------------------------- - - /** Send BYE (if confirmed) or CANCEL (if early) to tear down this leg. */ - sendHangup(): void { - if (!this.dialog) return; - - if (this.dialog.state === 'confirmed') { - const bye = this.dialog.createRequest('BYE'); - this.config.sendSip(bye.serialize(), this.config.sipTarget); - this.config.log(`[sip-leg:${this.id}] BYE sent`); - } else if (this.dialog.state === 'early' && this.invite) { - const cancel = this.dialog.createCancel(this.invite); - this.config.sendSip(cancel.serialize(), this.config.sipTarget); - this.config.log(`[sip-leg:${this.id}] CANCEL sent`); - } - - this.state = 'terminating'; - this.dialog.terminate(); - } - - // ------------------------------------------------------------------------- - // RTP - // ------------------------------------------------------------------------- - - sendRtp(data: Buffer): void { - if (!this.remoteMedia) return; - this.rtpSock.send(data, this.remoteMedia.port, this.remoteMedia.address); - this.pktSent++; - } - - /** Send a 1-byte UDP packet to punch NAT hole. */ - private primeRtp(peer: IEndpoint): void { - try { - this.rtpSock.send(Buffer.alloc(1), peer.port, peer.address); - this.config.log(`[sip-leg:${this.id}] RTP primed -> ${peer.address}:${peer.port}`); - } catch (e: any) { - this.config.log(`[sip-leg:${this.id}] prime error: ${e.message}`); - } - } - - // ------------------------------------------------------------------------- - // Silence stream (provider quirks) - // ------------------------------------------------------------------------- - - private startSilence(): void { - if (this.silenceTimer) return; - const quirks = this.config.provider?.quirks; - if (!quirks?.earlyMediaSilence) return; - if (!this.remoteMedia) return; - - const PT = quirks.silencePayloadType ?? 9; - const MAX = quirks.silenceMaxPackets ?? 250; - const PAYLOAD = 160; - let seq = Math.floor(Math.random() * 0xffff); - let rtpTs = Math.floor(Math.random() * 0xffffffff); - let count = 0; - - // Use proper silence byte for the codec (0x00 is NOT silence for most codecs). - const silenceByte = silenceByteForPT(PT); - - this.silenceTimer = setInterval(() => { - if (this.pktReceived > 0 || count >= MAX) { - clearInterval(this.silenceTimer!); - this.silenceTimer = null; - this.config.log(`[sip-leg:${this.id}] silence stop after ${count} pkts`); - return; - } - const pkt = Buffer.alloc(12 + PAYLOAD, silenceByte); - // RTP header (first 12 bytes). - pkt[0] = 0x80; - pkt[1] = PT; - pkt.writeUInt16BE(seq & 0xffff, 2); - pkt.writeUInt32BE(rtpTs >>> 0, 4); - pkt.writeUInt32BE(this.ssrc >>> 0, 8); // stable SSRC - this.rtpSock.send(pkt, this.remoteMedia!.port, this.remoteMedia!.address); - seq++; - rtpTs += PAYLOAD; - count++; - }, 20); - - this.config.log(`[sip-leg:${this.id}] silence start -> ${this.remoteMedia.address}:${this.remoteMedia.port} (ssrc=${this.ssrc})`); - } - - // ------------------------------------------------------------------------- - // Lifecycle - // ------------------------------------------------------------------------- - - teardown(): void { - if (this.silenceTimer) { - clearInterval(this.silenceTimer); - this.silenceTimer = null; - } - this.state = 'terminated'; - if (this.dialog) this.dialog.terminate(); - // Note: RTP socket is NOT closed here — the RtpPortPool manages that. - } - - getStatus(): ILegStatus { - return { - id: this.id, - type: this.type, - state: this.state, - remoteMedia: this.remoteMedia, - rtpPort: this.rtpPort, - pktSent: this.pktSent, - pktReceived: this.pktReceived, - codec: codecDisplayName(this.codec), - transcoding: this.transcoder !== null, - }; - } -} - -// --------------------------------------------------------------------------- -// Helper: proper silence byte per codec -// --------------------------------------------------------------------------- - -/** Return the byte value representing digital silence for a given RTP payload type. */ -function silenceByteForPT(pt: number): number { - switch (pt) { - case 0: return 0xFF; // PCMU: μ-law silence (zero amplitude) - case 8: return 0xD5; // PCMA: A-law silence (zero amplitude) - case 9: return 0xD5; // G.722: sub-band silence (zero amplitude) - default: return 0xFF; // safe default - } -} - -// --------------------------------------------------------------------------- -// Helper: ACK for non-2xx (same transaction) -// --------------------------------------------------------------------------- - -function buildNon2xxAck(originalInvite: SipMessage, response: SipMessage): SipMessage { - const via = originalInvite.getHeader('Via') || ''; - const from = originalInvite.getHeader('From') || ''; - const toFromResponse = response.getHeader('To') || ''; - const callId = originalInvite.callId; - const cseqNum = parseInt((originalInvite.getHeader('CSeq') || '1').split(/\s+/)[0], 10); - - return new SipMessage( - `ACK ${originalInvite.requestUri} SIP/2.0`, - [ - ['Via', via], - ['From', from], - ['To', toFromResponse], - ['Call-ID', callId], - ['CSeq', `${cseqNum} ACK`], - ['Max-Forwards', '70'], - ['Content-Length', '0'], - ], - '', - ); -} diff --git a/ts/call/system-leg.ts b/ts/call/system-leg.ts deleted file mode 100644 index 3d5cfe5..0000000 --- a/ts/call/system-leg.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * SystemLeg — virtual ILeg for IVR menus and voicemail. - * - * Plugs into the Call hub exactly like SipLeg or WebRtcLeg: - * - Receives caller audio via sendRtp() (called by Call.forwardRtp) - * - Plays prompts by firing onRtpReceived (picked up by Call.forwardRtp → caller's leg) - * - Detects DTMF from caller's audio (RFC 2833 telephone-event) - * - Records caller's audio to WAV files (for voicemail) - * - * No UDP socket or SIP dialog needed — purely virtual. - */ - -import { Buffer } from 'node:buffer'; -import type dgram from 'node:dgram'; -import type { IEndpoint } from '../sip/index.ts'; -import type { SipMessage } from '../sip/index.ts'; -import type { SipDialog } from '../sip/index.ts'; -import type { IRtpTranscoder } from '../codec.ts'; -import type { ILeg } from './leg.ts'; -import type { TLegState, TLegType, ILegStatus } from './types.ts'; -import { DtmfDetector } from './dtmf-detector.ts'; -import type { IDtmfDigit } from './dtmf-detector.ts'; -import { AudioRecorder } from './audio-recorder.ts'; -import type { IRecordingResult } from './audio-recorder.ts'; -import { PromptCache, playPromptG722, playPromptOpus } from './prompt-cache.ts'; -import type { ICachedPrompt } from './prompt-cache.ts'; -import { buildRtpHeader, rtpClockIncrement } from './leg.ts'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type TSystemLegMode = 'ivr' | 'voicemail-greeting' | 'voicemail-recording' | 'idle'; - -export interface ISystemLegConfig { - /** Logging function. */ - log: (msg: string) => void; - /** The prompt cache for TTS playback. */ - promptCache: PromptCache; - /** - * Codec payload type used by the caller's leg. - * Determines whether G.722 (9) or Opus (111) frames are played. - * Default: 9 (G.722, typical for SIP callers). - */ - callerCodecPt?: number; - /** Called when a DTMF digit is detected. */ - onDtmfDigit?: (digit: IDtmfDigit) => void; - /** Called when a voicemail recording is complete. */ - onRecordingComplete?: (result: IRecordingResult) => void; - /** Called when the SystemLeg wants to signal an IVR action. */ - onAction?: (action: string, data?: any) => void; -} - -// --------------------------------------------------------------------------- -// SystemLeg -// --------------------------------------------------------------------------- - -export class SystemLeg implements ILeg { - readonly id: string; - readonly type: TLegType = 'system'; - state: TLegState = 'connected'; // Immediately "connected" — no setup phase. - - /** Current operating mode. */ - mode: TSystemLegMode = 'idle'; - - // --- ILeg required fields (virtual — no real network resources) --- - readonly sipCallId: string; - readonly rtpPort: number | null = null; - readonly rtpSock: dgram.Socket | null = null; - remoteMedia: IEndpoint | null = null; - codec: number | null = null; - transcoder: IRtpTranscoder | null = null; - pktSent = 0; - pktReceived = 0; - readonly dialog: SipDialog | null = null; - - /** - * Set by Call.addLeg() — firing this injects audio into the Call hub, - * which forwards it to the caller's leg. - */ - onRtpReceived: ((data: Buffer) => void) | null = null; - - // --- Internal components --- - private dtmfDetector: DtmfDetector; - private recorder: AudioRecorder | null = null; - private promptCache: PromptCache; - private promptCancel: (() => void) | null = null; - private callerCodecPt: number; - private log: (msg: string) => void; - readonly config: ISystemLegConfig; - - /** Stable SSRC for all prompt playback (random, stays constant for the leg's lifetime). */ - private ssrc: number; - - /** Sequence/timestamp counters for Opus prompt playback (shared for seamless transitions). */ - private opusCounters = { seq: 0, ts: 0 }; - - constructor(id: string, config: ISystemLegConfig) { - this.id = id; - this.sipCallId = `system-${id}`; // Virtual Call-ID — not a real SIP dialog. - this.config = config; - this.log = config.log; - this.promptCache = config.promptCache; - this.callerCodecPt = config.callerCodecPt ?? 9; // Default G.722 - - this.ssrc = (Math.random() * 0xffffffff) >>> 0; - this.opusCounters.seq = Math.floor(Math.random() * 0xffff); - this.opusCounters.ts = Math.floor(Math.random() * 0xffffffff); - - // Initialize DTMF detector. - this.dtmfDetector = new DtmfDetector(this.log); - this.dtmfDetector.onDigit = (digit) => { - this.log(`[system-leg:${this.id}] DTMF '${digit.digit}' (${digit.source})`); - this.config.onDtmfDigit?.(digit); - }; - } - - // ------------------------------------------------------------------------- - // ILeg: sendRtp — receives caller's audio from the Call hub - // ------------------------------------------------------------------------- - - /** - * Called by the Call hub (via forwardRtp) to deliver the caller's audio - * to this leg. We use this for DTMF detection and recording. - */ - sendRtp(data: Buffer): void { - this.pktReceived++; - - // Feed DTMF detector (it checks PT internally, ignores non-101 packets). - this.dtmfDetector.processRtp(data); - - // Feed recorder if active. - if (this.mode === 'voicemail-recording' && this.recorder) { - this.recorder.processRtp(data); - } - } - - // ------------------------------------------------------------------------- - // ILeg: handleSipMessage — handles SIP INFO for DTMF - // ------------------------------------------------------------------------- - - /** - * Handle a SIP message routed to this leg. Only SIP INFO (DTMF) is relevant. - */ - handleSipMessage(msg: SipMessage, _rinfo: IEndpoint): void { - if (msg.method === 'INFO') { - this.dtmfDetector.processSipInfo(msg); - } - } - - // ------------------------------------------------------------------------- - // Prompt playback - // ------------------------------------------------------------------------- - - /** - * Play a cached prompt by ID. - * The audio is injected into the Call hub via onRtpReceived. - * - * @param promptId - ID of the prompt in the PromptCache - * @param onDone - called when playback completes (not on cancel) - * @returns true if playback started, false if prompt not found - */ - playPrompt(promptId: string, onDone?: () => void): boolean { - const prompt = this.promptCache.get(promptId); - if (!prompt) { - this.log(`[system-leg:${this.id}] prompt "${promptId}" not found`); - onDone?.(); - return false; - } - - // Cancel any in-progress playback. - this.cancelPrompt(); - - this.log(`[system-leg:${this.id}] playing prompt "${promptId}" (${prompt.durationMs}ms)`); - - // Select G.722 or Opus frames based on caller codec. - if (this.callerCodecPt === 111) { - // WebRTC caller: play Opus frames. - this.promptCancel = playPromptOpus( - prompt, - (pkt) => this.injectPacket(pkt), - this.ssrc, - this.opusCounters, - () => { - this.promptCancel = null; - onDone?.(); - }, - ); - } else { - // SIP caller: play G.722 frames (works for all SIP codecs since the - // SipLeg's RTP socket sends whatever we give it — the provider's - // media endpoint accepts the codec negotiated in the SDP). - this.promptCancel = playPromptG722( - prompt, - (pkt) => this.injectPacket(pkt), - this.ssrc, - () => { - this.promptCancel = null; - onDone?.(); - }, - ); - } - - return this.promptCancel !== null; - } - - /** - * Play a sequence of prompts, one after another. - */ - playPromptSequence(promptIds: string[], onDone?: () => void): void { - let index = 0; - const playNext = () => { - if (index >= promptIds.length) { - onDone?.(); - return; - } - const id = promptIds[index++]; - if (!this.playPrompt(id, playNext)) { - // Prompt not found — skip and play next. - playNext(); - } - }; - playNext(); - } - - /** Cancel any in-progress prompt playback. */ - cancelPrompt(): void { - if (this.promptCancel) { - this.promptCancel(); - this.promptCancel = null; - } - } - - /** Whether a prompt is currently playing. */ - get isPlaying(): boolean { - return this.promptCancel !== null; - } - - /** - * Inject an RTP packet into the Call hub. - * This simulates "receiving" audio on this leg — the hub - * will forward it to the caller's leg. - */ - private injectPacket(pkt: Buffer): void { - this.pktSent++; - this.onRtpReceived?.(pkt); - } - - // ------------------------------------------------------------------------- - // Recording - // ------------------------------------------------------------------------- - - /** - * Start recording the caller's audio. - * @param outputDir - directory to write the WAV file - * @param fileId - unique ID for the file name - */ - async startRecording(outputDir: string, fileId?: string): Promise { - if (this.recorder) { - await this.recorder.stop(); - } - - this.recorder = new AudioRecorder({ - outputDir, - log: this.log, - maxDurationSec: 120, - silenceTimeoutSec: 5, - }); - - this.recorder.onStopped = (result) => { - this.log(`[system-leg:${this.id}] recording auto-stopped (${result.stopReason})`); - this.config.onRecordingComplete?.(result); - }; - - this.mode = 'voicemail-recording'; - await this.recorder.start(fileId); - } - - /** - * Stop recording and finalize the WAV file. - */ - async stopRecording(): Promise { - if (!this.recorder) return null; - - const result = await this.recorder.stop(); - this.recorder = null; - return result; - } - - /** Cancel recording — stops and deletes the file. */ - async cancelRecording(): Promise { - if (this.recorder) { - await this.recorder.cancel(); - this.recorder = null; - } - } - - // ------------------------------------------------------------------------- - // Lifecycle - // ------------------------------------------------------------------------- - - /** Release all resources. */ - teardown(): void { - this.cancelPrompt(); - - // Stop recording gracefully. - if (this.recorder && this.recorder.state === 'recording') { - this.recorder.stop().then((result) => { - this.config.onRecordingComplete?.(result); - }); - this.recorder = null; - } - - this.dtmfDetector.destroy(); - this.state = 'terminated'; - this.mode = 'idle'; - this.onRtpReceived = null; - - this.log(`[system-leg:${this.id}] torn down`); - } - - /** Status snapshot for the dashboard. */ - getStatus(): ILegStatus { - return { - id: this.id, - type: this.type, - state: this.state, - remoteMedia: null, - rtpPort: null, - pktSent: this.pktSent, - pktReceived: this.pktReceived, - codec: this.callerCodecPt === 111 ? 'Opus' : 'G.722', - transcoding: false, - }; - } -} diff --git a/ts/call/types.ts b/ts/call/types.ts deleted file mode 100644 index f626d48..0000000 --- a/ts/call/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Hub model type definitions — Call, Leg, and status types. - */ - -import type { IEndpoint } from '../sip/index.ts'; - -// --------------------------------------------------------------------------- -// State types -// --------------------------------------------------------------------------- - -export type TCallState = - | 'setting-up' - | 'ringing' - | 'connected' - | 'on-hold' - | 'voicemail' - | 'ivr' - | 'transferring' - | 'terminating' - | 'terminated'; - -export type TLegState = - | 'inviting' - | 'ringing' - | 'connected' - | 'on-hold' - | 'terminating' - | 'terminated'; - -export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'system'; - -export type TCallDirection = 'inbound' | 'outbound' | 'internal'; - -// --------------------------------------------------------------------------- -// Status interfaces (for frontend dashboard) -// --------------------------------------------------------------------------- - -export interface ILegStatus { - id: string; - type: TLegType; - state: TLegState; - remoteMedia: IEndpoint | null; - rtpPort: number | null; - pktSent: number; - pktReceived: number; - codec: string | null; - transcoding: boolean; -} - -export interface ICallStatus { - id: string; - state: TCallState; - direction: TCallDirection; - callerNumber: string | null; - calleeNumber: string | null; - providerUsed: string | null; - createdAt: number; - duration: number; - legs: ILegStatus[]; -} - -export interface ICallHistoryEntry { - id: string; - direction: TCallDirection; - callerNumber: string | null; - calleeNumber: string | null; - providerUsed: string | null; - startedAt: number; - duration: number; -} diff --git a/ts/call/wav-writer.ts b/ts/call/wav-writer.ts deleted file mode 100644 index 35287a6..0000000 --- a/ts/call/wav-writer.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Streaming WAV file writer — opens a file, writes a placeholder header, - * appends raw PCM data in chunks, and finalizes (patches sizes) on close. - * - * Produces standard RIFF/WAVE format compatible with the WAV parser - * in announcement.ts (extractPcmFromWav). - */ - -import fs from 'node:fs'; -import { Buffer } from 'node:buffer'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface IWavWriterOptions { - /** Full path to the output WAV file. */ - filePath: string; - /** Sample rate in Hz (e.g. 16000). */ - sampleRate: number; - /** Number of channels (default 1 = mono). */ - channels?: number; - /** Bits per sample (default 16). */ - bitsPerSample?: number; -} - -export interface IWavWriterResult { - /** Full path to the WAV file. */ - filePath: string; - /** Total duration in milliseconds. */ - durationMs: number; - /** Sample rate of the output file. */ - sampleRate: number; - /** Total number of audio samples written. */ - totalSamples: number; - /** File size in bytes. */ - fileSize: number; -} - -// --------------------------------------------------------------------------- -// WAV header constants -// --------------------------------------------------------------------------- - -/** Standard WAV header size: RIFF(12) + fmt(24) + data-header(8) = 44 bytes. */ -const HEADER_SIZE = 44; - -// --------------------------------------------------------------------------- -// WavWriter -// --------------------------------------------------------------------------- - -export class WavWriter { - private fd: number | null = null; - private totalDataBytes = 0; - private closed = false; - - private filePath: string; - private sampleRate: number; - private channels: number; - private bitsPerSample: number; - - constructor(options: IWavWriterOptions) { - this.filePath = options.filePath; - this.sampleRate = options.sampleRate; - this.channels = options.channels ?? 1; - this.bitsPerSample = options.bitsPerSample ?? 16; - } - - /** Open the file and write a placeholder 44-byte WAV header. */ - open(): void { - if (this.fd !== null) throw new Error('WavWriter already open'); - - this.fd = fs.openSync(this.filePath, 'w'); - this.totalDataBytes = 0; - this.closed = false; - - // Write 44 bytes of zeros as placeholder — patched in close(). - const placeholder = Buffer.alloc(HEADER_SIZE); - fs.writeSync(this.fd, placeholder, 0, HEADER_SIZE, 0); - } - - /** Append raw 16-bit little-endian PCM samples. */ - write(pcm: Buffer): void { - if (this.fd === null || this.closed) return; - if (pcm.length === 0) return; - - fs.writeSync(this.fd, pcm, 0, pcm.length); - this.totalDataBytes += pcm.length; - } - - /** - * Finalize: rewrite the RIFF and data chunk sizes in the header, close the file. - * Returns metadata about the written WAV. - */ - close(): IWavWriterResult { - if (this.fd === null || this.closed) { - return { - filePath: this.filePath, - durationMs: 0, - sampleRate: this.sampleRate, - totalSamples: 0, - fileSize: HEADER_SIZE, - }; - } - - this.closed = true; - - const blockAlign = this.channels * (this.bitsPerSample / 8); - const byteRate = this.sampleRate * blockAlign; - const fileSize = HEADER_SIZE + this.totalDataBytes; - - // Build the complete 44-byte header. - const hdr = Buffer.alloc(HEADER_SIZE); - let offset = 0; - - // RIFF chunk descriptor. - hdr.write('RIFF', offset); offset += 4; - hdr.writeUInt32LE(fileSize - 8, offset); offset += 4; // ChunkSize = fileSize - 8 - hdr.write('WAVE', offset); offset += 4; - - // fmt sub-chunk. - hdr.write('fmt ', offset); offset += 4; - hdr.writeUInt32LE(16, offset); offset += 4; // Subchunk1Size (PCM = 16) - hdr.writeUInt16LE(1, offset); offset += 2; // AudioFormat (1 = PCM) - hdr.writeUInt16LE(this.channels, offset); offset += 2; - hdr.writeUInt32LE(this.sampleRate, offset); offset += 4; - hdr.writeUInt32LE(byteRate, offset); offset += 4; - hdr.writeUInt16LE(blockAlign, offset); offset += 2; - hdr.writeUInt16LE(this.bitsPerSample, offset); offset += 2; - - // data sub-chunk. - hdr.write('data', offset); offset += 4; - hdr.writeUInt32LE(this.totalDataBytes, offset); offset += 4; - - // Patch the header at the beginning of the file. - fs.writeSync(this.fd, hdr, 0, HEADER_SIZE, 0); - fs.closeSync(this.fd); - this.fd = null; - - const bytesPerSample = this.bitsPerSample / 8; - const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels)); - const durationMs = (totalSamples / this.sampleRate) * 1000; - - return { - filePath: this.filePath, - durationMs: Math.round(durationMs), - sampleRate: this.sampleRate, - totalSamples, - fileSize, - }; - } - - /** Current recording duration in milliseconds. */ - get durationMs(): number { - const bytesPerSample = this.bitsPerSample / 8; - const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels)); - return (totalSamples / this.sampleRate) * 1000; - } - - /** Whether the writer is still open and accepting data. */ - get isOpen(): boolean { - return this.fd !== null && !this.closed; - } -} diff --git a/ts/call/webrtc-leg.ts b/ts/call/webrtc-leg.ts deleted file mode 100644 index 2346a92..0000000 --- a/ts/call/webrtc-leg.ts +++ /dev/null @@ -1,417 +0,0 @@ -/** - * WebRtcLeg — a WebRTC connection from the Call hub to a browser client. - * - * Wraps a werift RTCPeerConnection and handles: - * - WebRTC offer/answer/ICE negotiation - * - Opus <-> G.722/PCMU/PCMA transcoding via Rust IPC - * - RTP header rebuilding with correct PT, timestamp, SSRC - */ - -import dgram from 'node:dgram'; -import { Buffer } from 'node:buffer'; -import { WebSocket } from 'ws'; -import type { IEndpoint } from '../sip/index.ts'; -import type { TLegState, ILegStatus } from './types.ts'; -import type { ILeg } from './leg.ts'; -import { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts'; -import { createTranscoder, OPUS_PT } from '../codec.ts'; -import type { IRtpTranscoder } from '../codec.ts'; -import { createSession, destroySession } from '../opusbridge.ts'; -import type { SipDialog } from '../sip/index.ts'; -import type { SipMessage } from '../sip/index.ts'; - -// --------------------------------------------------------------------------- -// WebRtcLeg config -// --------------------------------------------------------------------------- - -export interface IWebRtcLegConfig { - /** The browser's WebSocket connection. */ - ws: WebSocket; - /** The browser's session ID. */ - sessionId: string; - /** RTP port and socket (pre-allocated from the pool). */ - rtpPort: number; - rtpSock: dgram.Socket; - /** Logging function. */ - log: (msg: string) => void; -} - -// --------------------------------------------------------------------------- -// WebRtcLeg -// --------------------------------------------------------------------------- - -export class WebRtcLeg implements ILeg { - readonly id: string; - readonly type = 'webrtc' as const; - state: TLegState = 'inviting'; - readonly sessionId: string; - - /** The werift RTCPeerConnection instance. */ - private pc: any = null; - - /** RTP socket for bridging to SIP. */ - readonly rtpSock: dgram.Socket; - readonly rtpPort: number; - - /** Remote media endpoint (the other side of the bridge, set by Call). */ - remoteMedia: IEndpoint | null = null; - - /** Negotiated WebRTC codec payload type. */ - codec: number | null = null; - - /** Transcoders for WebRTC <-> SIP conversion. */ - transcoder: IRtpTranscoder | null = null; // used by Call for fan-out - private toSipTranscoder: IRtpTranscoder | null = null; - private fromSipTranscoder: IRtpTranscoder | null = null; - - /** RTP counters for outgoing (to SIP) direction. */ - private toSipSeq = 0; - private toSipTs = 0; - private toSipSsrc = (Math.random() * 0xffffffff) >>> 0; - - /** RTP counters for incoming (from SIP) direction. - * Initialized to random values so announcements and provider audio share - * a continuous sequence — prevents the browser jitter buffer from discarding - * packets after the announcement→provider transition. */ - readonly fromSipCounters = { - seq: Math.floor(Math.random() * 0xffff), - ts: Math.floor(Math.random() * 0xffffffff), - }; - fromSipSsrc = (Math.random() * 0xffffffff) >>> 0; - - /** Packet counters. */ - pktSent = 0; - pktReceived = 0; - - /** Callback set by Call. */ - onRtpReceived: ((data: Buffer) => void) | null = null; - - /** Callback to send transcoded RTP to the provider via the SipLeg's socket. - * Set by CallManager when the bridge is established. If null, falls back to own rtpSock. */ - onSendToProvider: ((data: Buffer, dest: IEndpoint) => void) | null = null; - - /** Lifecycle callbacks. */ - onConnected: ((leg: WebRtcLeg) => void) | null = null; - onTerminated: ((leg: WebRtcLeg) => void) | null = null; - - /** Cancel handle for an in-progress announcement. */ - announcementCancel: (() => void) | null = null; - - private ws: WebSocket; - private config: IWebRtcLegConfig; - private pendingIceCandidates: any[] = []; - - // SipDialog is not applicable for WebRTC legs. - readonly dialog: SipDialog | null = null; - readonly sipCallId: string; - - constructor(id: string, config: IWebRtcLegConfig) { - this.id = id; - this.sessionId = config.sessionId; - this.ws = config.ws; - this.rtpSock = config.rtpSock; - this.rtpPort = config.rtpPort; - this.config = config; - this.sipCallId = `webrtc-${id}`; - - // Log RTP arriving on this socket (symmetric RTP from provider). - // Audio forwarding is handled by the Call hub: SipLeg → forwardRtp → WebRtcLeg.sendRtp. - // We do NOT transcode here to avoid double-processing (the SipLeg also receives these packets). - let sipRxCount = 0; - this.rtpSock.on('message', (data: Buffer) => { - sipRxCount++; - if (sipRxCount === 1 || sipRxCount === 50 || sipRxCount % 500 === 0) { - this.config.log(`[webrtc-leg:${this.id}] SIP->browser rtp #${sipRxCount} (${data.length}b) [symmetric, ignored]`); - } - }); - } - - // ------------------------------------------------------------------------- - // WebRTC offer/answer - // ------------------------------------------------------------------------- - - /** - * Handle a WebRTC offer from the browser. Creates the PeerConnection, - * sets remote offer, creates answer, and sends it back. - */ - async handleOffer(offerSdp: string): Promise { - this.config.log(`[webrtc-leg:${this.id}] received offer`); - - try { - const werift = await import('werift'); - - this.pc = new werift.RTCPeerConnection({ iceServers: [] }); - - // Add sendrecv transceiver before setRemoteDescription. - this.pc.addTransceiver('audio', { direction: 'sendrecv' }); - - // Handle incoming audio from browser. - this.pc.ontrack = (event: any) => { - const track = event.track; - this.config.log(`[webrtc-leg:${this.id}] got track: ${track.kind}`); - - let rxCount = 0; - track.onReceiveRtp.subscribe((rtp: any) => { - if (!this.remoteMedia) return; - rxCount++; - if (rxCount === 1 || rxCount === 50 || rxCount % 500 === 0) { - this.config.log(`[webrtc-leg:${this.id}] browser->SIP rtp #${rxCount}`); - } - - this.forwardToSip(rtp, rxCount); - }); - }; - - // ICE candidate handling. - this.pc.onicecandidate = (candidate: any) => { - if (candidate) { - const json = candidate.toJSON?.() || candidate; - this.wsSend({ type: 'webrtc-ice', sessionId: this.sessionId, candidate: json }); - } - }; - - this.pc.onconnectionstatechange = () => { - this.config.log(`[webrtc-leg:${this.id}] connection state: ${this.pc.connectionState}`); - if (this.pc.connectionState === 'connected') { - this.state = 'connected'; - this.onConnected?.(this); - } else if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') { - this.state = 'terminated'; - this.onTerminated?.(this); - } - }; - - if (this.pc.oniceconnectionstatechange !== undefined) { - this.pc.oniceconnectionstatechange = () => { - this.config.log(`[webrtc-leg:${this.id}] ICE state: ${this.pc.iceConnectionState}`); - }; - } - - // Set remote offer and create answer. - await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp }); - const answer = await this.pc.createAnswer(); - await this.pc.setLocalDescription(answer); - - const sdp: string = this.pc.localDescription!.sdp; - - // Detect negotiated codec. - const mAudio = sdp.match(/m=audio\s+\d+\s+\S+\s+(\d+)/); - if (mAudio) { - this.codec = parseInt(mAudio[1], 10); - this.config.log(`[webrtc-leg:${this.id}] negotiated audio PT=${this.codec}`); - } - - // Extract sender SSRC from SDP. - const ssrcMatch = sdp.match(/a=ssrc:(\d+)\s/); - if (ssrcMatch) { - this.fromSipSsrc = parseInt(ssrcMatch[1], 10); - } - // Also try from sender object. - const senders = this.pc.getSenders(); - if (senders[0]) { - const senderSsrc = (senders[0] as any).ssrc ?? (senders[0] as any)._ssrc; - if (senderSsrc) this.fromSipSsrc = senderSsrc; - } - - // Send answer to browser. - this.wsSend({ type: 'webrtc-answer', sessionId: this.sessionId, sdp }); - this.config.log(`[webrtc-leg:${this.id}] sent answer, rtp port=${this.rtpPort}`); - - // Process buffered ICE candidates. - for (const c of this.pendingIceCandidates) { - try { await this.pc.addIceCandidate(c); } catch { /* ignore */ } - } - this.pendingIceCandidates = []; - - } catch (err: any) { - this.config.log(`[webrtc-leg:${this.id}] offer error: ${err.message}`); - this.wsSend({ type: 'webrtc-error', sessionId: this.sessionId, error: err.message }); - this.state = 'terminated'; - this.onTerminated?.(this); - } - } - - /** Add an ICE candidate from the browser. */ - async addIceCandidate(candidate: any): Promise { - if (!this.pc) { - this.pendingIceCandidates.push(candidate); - return; - } - try { - if (candidate) await this.pc.addIceCandidate(candidate); - } catch (err: any) { - this.config.log(`[webrtc-leg:${this.id}] ICE error: ${err.message}`); - } - } - - // ------------------------------------------------------------------------- - // Transcoding setup - // ------------------------------------------------------------------------- - - /** Codec session ID for isolated Rust codec state (unique per leg). */ - private codecSessionId = `webrtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - /** - * Set up transcoders for bridging between WebRTC and SIP codecs. - * Called by the Call when the remote media endpoint is known. - * Creates an isolated Rust codec session so concurrent calls don't - * corrupt each other's stateful codec state (Opus/G.722 ADPCM). - */ - async setupTranscoders(sipPT: number): Promise { - const webrtcPT = this.codec ?? OPUS_PT; - // Create isolated codec session for this leg. - await createSession(this.codecSessionId); - this.toSipTranscoder = createTranscoder(webrtcPT, sipPT, this.codecSessionId, 'to_sip'); - this.fromSipTranscoder = createTranscoder(sipPT, webrtcPT, this.codecSessionId, 'to_browser'); - const mode = this.toSipTranscoder ? `transcoding PT ${webrtcPT}<->${sipPT}` : `pass-through PT ${webrtcPT}`; - this.config.log(`[webrtc-leg:${this.id}] ${mode} (session: ${this.codecSessionId})`); - } - - // ------------------------------------------------------------------------- - // RTP forwarding - // ------------------------------------------------------------------------- - - /** Forward RTP from SIP side to browser via WebRTC. */ - private forwardToBrowser(data: Buffer, count: number): void { - const sender = this.pc?.getSenders()[0]; - if (!sender) return; - - if (this.fromSipTranscoder && data.length > 12) { - const payload = Buffer.from(data.subarray(12)); - // Stop announcement if still playing — provider audio takes over. - if (this.announcementCancel) { - this.announcementCancel(); - this.announcementCancel = null; - } - // Capture seq/ts BEFORE async transcode to preserve ordering. - const toPT = this.fromSipTranscoder.toPT; - const seq = this.fromSipCounters.seq++; - const ts = this.fromSipCounters.ts; - this.fromSipCounters.ts += rtpClockIncrement(toPT); - const result = this.fromSipTranscoder.payload(payload); - const sendTranscoded = (transcoded: Buffer) => { - if (transcoded.length === 0) return; // transcoding failed - try { - const hdr = buildRtpHeader(toPT, seq, ts, this.fromSipSsrc, false); - const out = Buffer.concat([hdr, transcoded]); - const r = sender.sendRtp(out); - if (r instanceof Promise) r.catch(() => {}); - } catch { /* ignore */ } - }; - if (result instanceof Promise) result.then(sendTranscoded).catch(() => {}); - else sendTranscoded(result); - } else if (!this.fromSipTranscoder) { - // No transcoder — either same codec or not set up yet. - // Only forward if we don't expect transcoding. - if (this.codec === null) { - try { sender.sendRtp(data); } catch { /* ignore */ } - } - } - } - - /** Forward RTP from browser to SIP side. */ - private forwardToSip(rtp: any, count: number): void { - if (!this.remoteMedia) return; - - if (this.toSipTranscoder) { - const payload: Buffer = rtp.payload; - if (!payload || payload.length === 0) return; - // Capture seq/ts BEFORE async transcode to preserve ordering. - const toPT = this.toSipTranscoder.toPT; - const seq = this.toSipSeq++; - const ts = this.toSipTs; - this.toSipTs += rtpClockIncrement(toPT); - const result = this.toSipTranscoder.payload(payload); - const sendTranscoded = (transcoded: Buffer) => { - if (transcoded.length === 0) return; // transcoding failed - const hdr = buildRtpHeader(toPT, seq, ts, this.toSipSsrc, false); - const out = Buffer.concat([hdr, transcoded]); - if (this.onSendToProvider) { - this.onSendToProvider(out, this.remoteMedia!); - } else { - this.rtpSock.send(out, this.remoteMedia!.port, this.remoteMedia!.address); - } - this.pktSent++; - }; - if (result instanceof Promise) result.then(sendTranscoded).catch(() => {}); - else sendTranscoded(result); - } else if (this.codec === null) { - // Same codec (no transcoding needed) — pass through. - const raw = rtp.serialize(); - if (this.onSendToProvider) { - this.onSendToProvider(raw, this.remoteMedia); - } else { - this.rtpSock.send(raw, this.remoteMedia.port, this.remoteMedia.address); - } - this.pktSent++; - } - // If codec is set but transcoder is null, drop the packet — transcoder not ready yet. - // This prevents raw Opus from being sent to a G.722 endpoint. - } - - /** - * Send RTP to the browser via WebRTC (used by Call hub for fan-out). - * This transcodes and sends through the PeerConnection, NOT to a UDP address. - */ - sendRtp(data: Buffer): void { - this.forwardToBrowser(data, this.pktSent); - this.pktSent++; - } - - /** - * Send a pre-encoded RTP packet directly to the browser via PeerConnection. - * Used for announcements — the packet must already be in the correct codec (Opus). - */ - sendDirectToBrowser(pkt: Buffer): void { - const sender = this.pc?.getSenders()[0]; - if (!sender) return; - try { - const r = sender.sendRtp(pkt); - if (r instanceof Promise) r.catch(() => {}); - } catch { /* ignore */ } - } - - /** No-op: WebRTC legs don't process SIP messages. */ - handleSipMessage(_msg: SipMessage, _rinfo: IEndpoint): void { - // WebRTC legs don't handle SIP messages. - } - - // ------------------------------------------------------------------------- - // Lifecycle - // ------------------------------------------------------------------------- - - teardown(): void { - this.state = 'terminated'; - try { this.pc?.close(); } catch { /* ignore */ } - this.pc = null; - // Destroy the isolated Rust codec session for this leg. - destroySession(this.codecSessionId).catch(() => {}); - // Note: RTP socket is NOT closed here — the RtpPortPool manages that. - } - - getStatus(): ILegStatus { - return { - id: this.id, - type: this.type, - state: this.state, - remoteMedia: this.remoteMedia, - rtpPort: this.rtpPort, - pktSent: this.pktSent, - pktReceived: this.pktReceived, - codec: codecDisplayName(this.codec), - transcoding: this.toSipTranscoder !== null || this.fromSipTranscoder !== null, - }; - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private wsSend(data: unknown): void { - try { - if (this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(data)); - } - } catch { /* ignore */ } - } -} diff --git a/ts/codec.ts b/ts/codec.ts deleted file mode 100644 index af5d208..0000000 --- a/ts/codec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Audio codec translation layer for bridging between WebRTC and SIP. - * - * All actual codec work (Opus, G.722, PCMU, PCMA) is done in Rust via - * the smartrust bridge. This module provides the RTP-level transcoding - * interface used by the webrtcbridge. - */ - -import { Buffer } from 'node:buffer'; -import { transcode, isCodecReady } from './opusbridge.ts'; - -/** Opus dynamic payload type (standard WebRTC assignment). */ -export const OPUS_PT = 111; - -export interface IRtpTranscoder { - /** Transcode an RTP payload. Always async (Rust IPC). */ - payload(data: Buffer): Promise; - fromPT: number; - toPT: number; -} - -/** - * Create a transcoder that converts RTP payloads between two codecs. - * Returns null if the codecs are the same or the Rust bridge isn't ready. - * - * @param sessionId - optional Rust codec session for isolated state per call - */ -export function createTranscoder(fromPT: number, toPT: number, sessionId?: string, direction?: string): IRtpTranscoder | null { - if (fromPT === toPT) return null; - if (!isCodecReady()) return null; - - return { - fromPT, - toPT, - async payload(data: Buffer): Promise { - const result = await transcode(data, fromPT, toPT, sessionId, direction); - return result || Buffer.alloc(0); // return empty on failure — never pass raw codec bytes - }, - }; -} diff --git a/ts/config.ts b/ts/config.ts index 8526241..bcc6cb1 100644 --- a/ts/config.ts +++ b/ts/config.ts @@ -8,7 +8,15 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { IEndpoint } from './sip/index.ts'; + +// --------------------------------------------------------------------------- +// Shared types (previously in ts/sip/types.ts, now inlined) +// --------------------------------------------------------------------------- + +export interface IEndpoint { + address: string; + port: number; +} // --------------------------------------------------------------------------- // Config interfaces @@ -319,175 +327,5 @@ export function loadConfig(): IAppConfig { return cfg; } -// --------------------------------------------------------------------------- -// Pattern matching -// --------------------------------------------------------------------------- - -/** - * Test a value against a pattern string. - * - undefined/empty pattern: matches everything (wildcard) - * - Prefix: "pattern*" matches values starting with "pattern" - * - Regex: "/pattern/" or "/pattern/i" compiles as RegExp - * - Otherwise: exact match - */ -export function matchesPattern(pattern: string | undefined, value: string): boolean { - if (!pattern) return true; - - // Prefix match: "+49*" - if (pattern.endsWith('*')) { - return value.startsWith(pattern.slice(0, -1)); - } - - // Regex match: "/^\\+49/" or "/pattern/i" - if (pattern.startsWith('/')) { - const lastSlash = pattern.lastIndexOf('/'); - if (lastSlash > 0) { - const re = new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1)); - return re.test(value); - } - } - - // Exact match. - return value === pattern; -} - -// --------------------------------------------------------------------------- -// Route resolution -// --------------------------------------------------------------------------- - -/** Result of resolving an outbound route. */ -export interface IOutboundRouteResult { - provider: IProviderConfig; - transformedNumber: string; -} - -/** Result of resolving an inbound route. */ -export interface IInboundRouteResult { - /** Device IDs to ring (empty = all devices). */ - deviceIds: string[]; - ringBrowsers: boolean; - /** If set, route directly to this voicemail box (skip ringing). */ - voicemailBox?: string; - /** If set, route to this IVR menu (skip ringing). */ - ivrMenuId?: string; - /** Override for no-answer timeout in seconds. */ - noAnswerTimeout?: number; -} - -/** - * Resolve which provider to use for an outbound call, and transform the number. - * - * @param cfg - app config - * @param dialedNumber - the number being dialed - * @param sourceDeviceId - optional device originating the call - * @param isProviderRegistered - callback to check if a provider is currently registered - */ -export function resolveOutboundRoute( - cfg: IAppConfig, - dialedNumber: string, - sourceDeviceId?: string, - isProviderRegistered?: (providerId: string) => boolean, -): IOutboundRouteResult | null { - const routes = cfg.routing.routes - .filter((r) => r.enabled && r.match.direction === 'outbound') - .sort((a, b) => b.priority - a.priority); - - for (const route of routes) { - const m = route.match; - - if (!matchesPattern(m.numberPattern, dialedNumber)) continue; - if (m.sourceDevice && m.sourceDevice !== sourceDeviceId) continue; - - // Find a registered provider (primary + failovers). - const candidates = [route.action.provider, ...(route.action.failoverProviders || [])].filter(Boolean) as string[]; - for (const pid of candidates) { - const provider = getProvider(cfg, pid); - if (!provider) continue; - if (isProviderRegistered && !isProviderRegistered(pid)) continue; - - // Apply number transformation. - let num = dialedNumber; - if (route.action.stripPrefix && num.startsWith(route.action.stripPrefix)) { - num = num.slice(route.action.stripPrefix.length); - } - if (route.action.prependPrefix) { - num = route.action.prependPrefix + num; - } - - return { provider, transformedNumber: num }; - } - - // Route matched but no provider is available — continue to next route. - } - - // Fallback: first available provider. - const fallback = cfg.providers[0]; - return fallback ? { provider: fallback, transformedNumber: dialedNumber } : null; -} - -/** - * Resolve which devices/browsers to ring for an inbound call. - * - * @param cfg - app config - * @param providerId - the provider the call is coming from - * @param calledNumber - the DID / called number (from Request-URI) - * @param callerNumber - the caller ID (from From header) - */ -export function resolveInboundRoute( - cfg: IAppConfig, - providerId: string, - calledNumber: string, - callerNumber: string, -): IInboundRouteResult { - const routes = cfg.routing.routes - .filter((r) => r.enabled && r.match.direction === 'inbound') - .sort((a, b) => b.priority - a.priority); - - for (const route of routes) { - const m = route.match; - - if (m.sourceProvider && m.sourceProvider !== providerId) continue; - if (!matchesPattern(m.numberPattern, calledNumber)) continue; - if (!matchesPattern(m.callerPattern, callerNumber)) continue; - - return { - deviceIds: route.action.targets || [], - ringBrowsers: route.action.ringBrowsers ?? false, - voicemailBox: route.action.voicemailBox, - ivrMenuId: route.action.ivrMenuId, - noAnswerTimeout: route.action.noAnswerTimeout, - }; - } - - // Fallback: ring all devices + browsers. - return { deviceIds: [], ringBrowsers: true }; -} - -// --------------------------------------------------------------------------- -// Lookup helpers -// --------------------------------------------------------------------------- - -export function getProvider(cfg: IAppConfig, id: string): IProviderConfig | null { - return cfg.providers.find((p) => p.id === id) ?? null; -} - -export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null { - return cfg.devices.find((d) => d.id === id) ?? null; -} - -/** - * @deprecated Use resolveOutboundRoute() instead. Kept for backward compat. - */ -export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null { - const result = resolveOutboundRoute(cfg, ''); - return result?.provider ?? null; -} - -/** - * @deprecated Use resolveInboundRoute() instead. Kept for backward compat. - */ -export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] { - const result = resolveInboundRoute(cfg, providerId, '', ''); - if (!result.deviceIds.length) return cfg.devices; - return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[]; -} +// Route resolution, pattern matching, and provider/device lookup +// are now handled entirely by the Rust proxy-engine. diff --git a/ts/frontend.ts b/ts/frontend.ts index b2cc62b..ea5cf45 100644 --- a/ts/frontend.ts +++ b/ts/frontend.ts @@ -11,10 +11,13 @@ import path from 'node:path'; import http from 'node:http'; import https from 'node:https'; import { WebSocketServer, WebSocket } from 'ws'; -import type { CallManager } from './call/index.ts'; import { handleWebRtcSignaling } from './webrtcbridge.ts'; import type { VoiceboxManager } from './voicebox.ts'; +// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine. +// Kept as `any` type for backward compat with the function signature until full WebRTC port. +type CallManager = any; + const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json'); // --------------------------------------------------------------------------- @@ -336,6 +339,10 @@ export function initWebUi( onHangupCall: (callId: string) => boolean, onConfigSaved?: () => void, callManager?: CallManager, + /** WebRTC signaling handlers — forwarded to Rust proxy-engine. */ + onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise, + onWebRtcIce?: (sessionId: string, candidate: any) => Promise, + onWebRtcClose?: (sessionId: string) => Promise, voiceboxManager?: VoiceboxManager, ): void { const WEB_PORT = 3060; @@ -372,17 +379,23 @@ export function initWebUi( socket.on('message', (raw) => { try { const msg = JSON.parse(raw.toString()); - if (msg.type === 'webrtc-accept' && msg.callId) { - log(`[webrtc] browser accepted call ${msg.callId} session=${msg.sessionId || 'none'}`); - const ok = callManager?.acceptBrowserCall(msg.callId, msg.sessionId) ?? false; - log(`[webrtc] acceptBrowserCall result: ${ok}`); - } else if (msg.type === 'webrtc-offer' && msg.sessionId) { - callManager?.handleWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) => - log(`[webrtc] offer error: ${e.message}`)); + if (msg.type === 'webrtc-offer' && msg.sessionId) { + // Forward to Rust proxy-engine for WebRTC handling. + if (onWebRtcOffer) { + onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) => + log(`[webrtc] offer error: ${e.message}`)); + } } else if (msg.type === 'webrtc-ice' && msg.sessionId) { - callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {}); + if (onWebRtcIce) { + onWebRtcIce(msg.sessionId, msg.candidate).catch(() => {}); + } } else if (msg.type === 'webrtc-hangup' && msg.sessionId) { - callManager?.handleWebRtcHangup(msg.sessionId); + if (onWebRtcClose) { + onWebRtcClose(msg.sessionId).catch(() => {}); + } + } else if (msg.type === 'webrtc-accept' && msg.callId) { + // TODO: Wire to Rust call linking. + log(`[webrtc] accept: call=${msg.callId} session=${msg.sessionId || 'none'}`); } else if (msg.type?.startsWith('webrtc-')) { msg._remoteIp = remoteIp; handleWebRtcSignaling(socket as any, msg); diff --git a/ts/ivr.ts b/ts/ivr.ts deleted file mode 100644 index eb67ccc..0000000 --- a/ts/ivr.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * IVR engine — state machine that navigates callers through menus - * based on DTMF digit input. - * - * The IvrEngine is instantiated per-call and drives a SystemLeg: - * - Plays menu prompts via the SystemLeg's prompt playback - * - Receives DTMF digits and resolves them to actions - * - Fires an onAction callback for the CallManager to execute - * (route to extension, voicemail, transfer, etc.) - */ - -import type { IIvrConfig, IIvrMenu, TIvrAction } from './config.ts'; -import type { SystemLeg } from './call/system-leg.ts'; - -// --------------------------------------------------------------------------- -// IVR Engine -// --------------------------------------------------------------------------- - -export class IvrEngine { - private config: IIvrConfig; - private systemLeg: SystemLeg; - private onAction: (action: TIvrAction) => void; - private log: (msg: string) => void; - - /** The currently active menu. */ - private currentMenu: IIvrMenu | null = null; - - /** How many times the current menu has been replayed (for retry limit). */ - private retryCount = 0; - - /** Timer for digit input timeout. */ - private digitTimeout: ReturnType | null = null; - - /** Whether the engine is waiting for a digit (prompt finished playing). */ - private waitingForDigit = false; - - /** Whether the engine has been destroyed. */ - private destroyed = false; - - constructor( - config: IIvrConfig, - systemLeg: SystemLeg, - onAction: (action: TIvrAction) => void, - log: (msg: string) => void, - ) { - this.config = config; - this.systemLeg = systemLeg; - this.onAction = onAction; - this.log = log; - } - - // ------------------------------------------------------------------------- - // Public API - // ------------------------------------------------------------------------- - - /** - * Start the IVR — navigates to the entry menu and plays its prompt. - */ - start(): void { - const entryMenu = this.getMenu(this.config.entryMenuId); - if (!entryMenu) { - this.log(`[ivr] entry menu "${this.config.entryMenuId}" not found — hanging up`); - this.onAction({ type: 'hangup' }); - return; - } - - this.navigateToMenu(entryMenu); - } - - /** - * Handle a DTMF digit from the caller. - */ - handleDigit(digit: string): void { - if (this.destroyed || !this.currentMenu) return; - - // Clear the timeout — caller pressed something. - this.clearDigitTimeout(); - - // Cancel any playing prompt (caller interrupted it). - this.systemLeg.cancelPrompt(); - this.waitingForDigit = false; - - this.log(`[ivr] digit '${digit}' in menu "${this.currentMenu.id}"`); - - // Look up the digit in the current menu. - const entry = this.currentMenu.entries.find((e) => e.digit === digit); - if (entry) { - this.executeAction(entry.action); - } else { - this.log(`[ivr] invalid digit '${digit}' in menu "${this.currentMenu.id}"`); - this.executeAction(this.currentMenu.invalidAction); - } - } - - /** - * Clean up timers and state. - */ - destroy(): void { - this.destroyed = true; - this.clearDigitTimeout(); - this.currentMenu = null; - } - - // ------------------------------------------------------------------------- - // Internal - // ------------------------------------------------------------------------- - - /** Navigate to a menu: play its prompt, then wait for digit. */ - private navigateToMenu(menu: IIvrMenu): void { - if (this.destroyed) return; - - this.currentMenu = menu; - this.waitingForDigit = false; - this.clearDigitTimeout(); - - const promptId = `ivr-menu-${menu.id}`; - this.log(`[ivr] playing menu "${menu.id}" prompt`); - - this.systemLeg.playPrompt(promptId, () => { - if (this.destroyed) return; - // Prompt finished — start digit timeout. - this.waitingForDigit = true; - this.startDigitTimeout(); - }); - } - - /** Start the timeout timer for digit input. */ - private startDigitTimeout(): void { - const timeoutSec = this.currentMenu?.timeoutSec ?? 5; - - this.digitTimeout = setTimeout(() => { - if (this.destroyed || !this.currentMenu) return; - this.log(`[ivr] digit timeout in menu "${this.currentMenu.id}"`); - this.handleTimeout(); - }, timeoutSec * 1000); - } - - /** Handle timeout (no digit pressed). */ - private handleTimeout(): void { - if (!this.currentMenu) return; - - this.retryCount++; - const maxRetries = this.currentMenu.maxRetries ?? 3; - - if (this.retryCount >= maxRetries) { - this.log(`[ivr] max retries (${maxRetries}) reached in menu "${this.currentMenu.id}"`); - this.executeAction(this.currentMenu.timeoutAction); - } else { - this.log(`[ivr] retry ${this.retryCount}/${maxRetries} in menu "${this.currentMenu.id}"`); - // Replay the current menu. - this.navigateToMenu(this.currentMenu); - } - } - - /** Execute an IVR action. */ - private executeAction(action: TIvrAction): void { - if (this.destroyed) return; - - switch (action.type) { - case 'submenu': { - const submenu = this.getMenu(action.menuId); - if (submenu) { - this.retryCount = 0; - this.navigateToMenu(submenu); - } else { - this.log(`[ivr] submenu "${action.menuId}" not found — hanging up`); - this.onAction({ type: 'hangup' }); - } - break; - } - - case 'repeat': { - if (this.currentMenu) { - this.navigateToMenu(this.currentMenu); - } - break; - } - - case 'play-message': { - // Play a message prompt, then return to the current menu. - this.systemLeg.playPrompt(action.promptId, () => { - if (this.destroyed || !this.currentMenu) return; - this.navigateToMenu(this.currentMenu); - }); - break; - } - - default: - // All other actions (route-extension, route-voicemail, transfer, hangup) - // are handled by the CallManager via the onAction callback. - this.log(`[ivr] action: ${action.type}`); - this.onAction(action); - break; - } - } - - /** Look up a menu by ID. */ - private getMenu(menuId: string): IIvrMenu | null { - return this.config.menus.find((m) => m.id === menuId) ?? null; - } - - /** Clear the digit timeout timer. */ - private clearDigitTimeout(): void { - if (this.digitTimeout) { - clearTimeout(this.digitTimeout); - this.digitTimeout = null; - } - } -} diff --git a/ts/providerstate.ts b/ts/providerstate.ts deleted file mode 100644 index aa12177..0000000 --- a/ts/providerstate.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Per-provider runtime state and upstream registration. - * - * Each configured provider gets its own ProviderState instance tracking - * registration status, public IP, and the periodic REGISTER cycle. - */ - -import { Buffer } from 'node:buffer'; -import { - SipMessage, - generateCallId, - generateTag, - generateBranch, - parseDigestChallenge, - computeDigestAuth, -} from './sip/index.ts'; -import type { IEndpoint } from './sip/index.ts'; -import type { IProviderConfig } from './config.ts'; - -// --------------------------------------------------------------------------- -// Provider state -// --------------------------------------------------------------------------- - -export class ProviderState { - readonly config: IProviderConfig; - publicIp: string | null; - isRegistered = false; - registeredAor: string; - - // Registration transaction state. - private regCallId: string; - private regCSeq = 0; - private regFromTag: string; - private regTimer: ReturnType | null = null; - private sendSip: ((buf: Buffer, dest: IEndpoint) => void) | null = null; - private logFn: ((msg: string) => void) | null = null; - private onRegistrationChange: ((provider: ProviderState) => void) | null = null; - - constructor(config: IProviderConfig, publicIpSeed: string | null) { - this.config = config; - this.publicIp = publicIpSeed; - this.registeredAor = `sip:${config.username}@${config.domain}`; - this.regCallId = generateCallId(); - this.regFromTag = generateTag(); - } - - private log(msg: string): void { - this.logFn?.(`[provider:${this.config.id}] ${msg}`); - } - - // ------------------------------------------------------------------------- - // Upstream registration - // ------------------------------------------------------------------------- - - /** - * Start the periodic REGISTER cycle with this provider. - */ - startRegistration( - lanIp: string, - lanPort: number, - sendSip: (buf: Buffer, dest: IEndpoint) => void, - log: (msg: string) => void, - onRegistrationChange: (provider: ProviderState) => void, - ): void { - this.sendSip = sendSip; - this.logFn = log; - this.onRegistrationChange = onRegistrationChange; - - // Initial registration. - this.sendRegister(lanIp, lanPort); - - // Re-register periodically. - const intervalMs = (this.config.registerIntervalSec * 0.85) * 1000; - this.regTimer = setInterval(() => this.sendRegister(lanIp, lanPort), intervalMs); - } - - stopRegistration(): void { - if (this.regTimer) { - clearInterval(this.regTimer); - this.regTimer = null; - } - } - - private sendRegister(lanIp: string, lanPort: number): void { - this.regCSeq++; - const pub = this.publicIp || lanIp; - const { config } = this; - - const register = SipMessage.createRequest('REGISTER', `sip:${config.domain}`, { - via: { host: pub, port: lanPort }, - from: { uri: this.registeredAor, tag: this.regFromTag }, - to: { uri: this.registeredAor }, - callId: this.regCallId, - cseq: this.regCSeq, - contact: ``, - maxForwards: 70, - extraHeaders: [ - ['Expires', String(config.registerIntervalSec)], - ['User-Agent', 'SipRouter/1.0'], - ['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'], - ], - }); - - this.log(`REGISTER -> ${config.outboundProxy.address}:${config.outboundProxy.port} (CSeq ${this.regCSeq})`); - this.sendSip!(register.serialize(), config.outboundProxy); - } - - /** - * Handle an incoming SIP response that belongs to this provider's registration. - * Returns true if the message was consumed. - */ - handleRegistrationResponse(msg: SipMessage): boolean { - if (!msg.isResponse) return false; - if (msg.callId !== this.regCallId) return false; - if (msg.cseqMethod?.toUpperCase() !== 'REGISTER') return false; - - const code = msg.statusCode ?? 0; - this.log(`REGISTER <- ${code}`); - - if (code === 200) { - const wasRegistered = this.isRegistered; - this.isRegistered = true; - if (!wasRegistered) { - this.log('registered'); - this.onRegistrationChange?.(this); - } - return true; - } - - if (code === 401 || code === 407) { - const challengeHeader = code === 401 - ? msg.getHeader('WWW-Authenticate') - : msg.getHeader('Proxy-Authenticate'); - - if (!challengeHeader) { - this.log(`${code} but no challenge header`); - return true; - } - - const challenge = parseDigestChallenge(challengeHeader); - if (!challenge) { - this.log(`${code} could not parse digest challenge`); - return true; - } - - const authValue = computeDigestAuth({ - username: this.config.username, - password: this.config.password, - realm: challenge.realm, - nonce: challenge.nonce, - method: 'REGISTER', - uri: `sip:${this.config.domain}`, - algorithm: challenge.algorithm, - opaque: challenge.opaque, - }); - - // Resend REGISTER with auth. - this.regCSeq++; - const pub = this.publicIp || 'unknown'; - // We need lanIp/lanPort but don't have them here — reconstruct from Via. - const via = msg.getHeader('Via') || ''; - const viaHost = via.match(/SIP\/2\.0\/UDP\s+([^;:]+)/)?.[1] || pub; - const viaPort = parseInt(via.match(/:(\d+)/)?.[1] || '5070', 10); - - const register = SipMessage.createRequest('REGISTER', `sip:${this.config.domain}`, { - via: { host: viaHost, port: viaPort }, - from: { uri: this.registeredAor, tag: this.regFromTag }, - to: { uri: this.registeredAor }, - callId: this.regCallId, - cseq: this.regCSeq, - contact: ``, - maxForwards: 70, - extraHeaders: [ - [code === 401 ? 'Authorization' : 'Proxy-Authorization', authValue], - ['Expires', String(this.config.registerIntervalSec)], - ['User-Agent', 'SipRouter/1.0'], - ['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'], - ], - }); - - this.log(`REGISTER -> (with auth, CSeq ${this.regCSeq})`); - this.sendSip!(register.serialize(), this.config.outboundProxy); - return true; - } - - if (code >= 400) { - const wasRegistered = this.isRegistered; - this.isRegistered = false; - if (wasRegistered) { - this.log(`registration lost (${code})`); - this.onRegistrationChange?.(this); - } - return true; - } - - return true; // consume 1xx etc. - } - - /** - * Update public IP from Via received= parameter. - */ - detectPublicIp(via: string): void { - const m = via.match(/received=([\d.]+)/); - if (m && m[1] !== this.publicIp) { - this.log(`publicIp = ${m[1]}`); - this.publicIp = m[1]; - } - } -} - -// --------------------------------------------------------------------------- -// Provider state management -// --------------------------------------------------------------------------- - -let providerStates: Map; - -export function initProviderStates( - providers: IProviderConfig[], - publicIpSeed: string | null, -): Map { - providerStates = new Map(); - for (const p of providers) { - providerStates.set(p.id, new ProviderState(p, publicIpSeed)); - } - return providerStates; -} - -export function getProviderState(id: string): ProviderState | null { - return providerStates?.get(id) ?? null; -} - -/** - * Sync running provider states with updated config. - * - New providers: create state + start registration. - * - Removed providers: stop registration + delete state. - * - Changed providers: stop old, create new, start registration (preserves detected publicIp). - */ -export function syncProviderStates( - newProviders: IProviderConfig[], - publicIpSeed: string | null, - lanIp: string, - lanPort: number, - sendSip: (buf: Buffer, dest: IEndpoint) => void, - log: (msg: string) => void, - onRegistrationChange: (provider: ProviderState) => void, -): void { - if (!providerStates) return; - - const newIds = new Set(newProviders.map(p => p.id)); - const oldIds = new Set(providerStates.keys()); - - // Remove providers no longer in config. - for (const id of oldIds) { - if (!newIds.has(id)) { - const ps = providerStates.get(id)!; - ps.stopRegistration(); - providerStates.delete(id); - log(`[provider:${id}] removed`); - } - } - - for (const p of newProviders) { - if (!oldIds.has(p.id)) { - // New provider. - const ps = new ProviderState(p, publicIpSeed); - providerStates.set(p.id, ps); - ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange); - log(`[provider:${p.id}] added — registration started`); - } else { - // Existing provider — check if config changed. - const existing = providerStates.get(p.id)!; - if (JSON.stringify(existing.config) !== JSON.stringify(p)) { - existing.stopRegistration(); - const ps = new ProviderState(p, existing.publicIp || publicIpSeed); - providerStates.set(p.id, ps); - ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange); - log(`[provider:${p.id}] config changed — re-registering`); - } - } - } -} - -/** - * Find which provider sent a packet, by matching the source address - * against all providers' outbound proxy addresses. - */ -export function getProviderByUpstreamAddress(address: string, port: number): ProviderState | null { - if (!providerStates) return null; - for (const ps of providerStates.values()) { - if (ps.config.outboundProxy.address === address && ps.config.outboundProxy.port === port) { - return ps; - } - } - return null; -} - -/** - * Check whether a response belongs to any provider's registration transaction. - */ -export function handleProviderRegistrationResponse(msg: SipMessage): boolean { - if (!providerStates || !msg.isResponse) return false; - for (const ps of providerStates.values()) { - if (ps.handleRegistrationResponse(msg)) return true; - } - return false; -} diff --git a/ts/proxybridge.ts b/ts/proxybridge.ts index 64591e5..c363f61 100644 --- a/ts/proxybridge.ts +++ b/ts/proxybridge.ts @@ -157,6 +157,24 @@ export async function configureProxyEngine(config: Record): Pro } } +/** + * Initiate an outbound call via Rust. Returns the call ID or null on failure. + */ +export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise { + if (!bridge || !initialized) return null; + try { + const result = await bridge.sendCommand('make_call', { + number, + device_id: deviceId, + provider_id: providerId, + } as any); + return (result as any)?.call_id || null; + } catch (e: any) { + logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`); + return null; + } +} + /** * Send a hangup command. */ @@ -170,6 +188,66 @@ export async function hangupCall(callId: string): Promise { } } +/** + * Send a WebRTC offer to the proxy engine. Returns the SDP answer. + */ +export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> { + if (!bridge || !initialized) return null; + try { + const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any); + return result as any; + } catch (e: any) { + logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`); + return null; + } +} + +/** + * Forward an ICE candidate to the proxy engine. + */ +export async function webrtcIce(sessionId: string, candidate: any): Promise { + if (!bridge || !initialized) return; + try { + await bridge.sendCommand('webrtc_ice', { + session_id: sessionId, + candidate: candidate?.candidate || candidate, + sdp_mid: candidate?.sdpMid, + sdp_mline_index: candidate?.sdpMLineIndex, + } as any); + } catch { /* ignore */ } +} + +/** + * Link a WebRTC session to a SIP call — enables audio bridging. + * The browser's Opus audio will be transcoded and sent to the provider. + */ +export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise { + if (!bridge || !initialized) return false; + try { + await bridge.sendCommand('webrtc_link', { + session_id: sessionId, + call_id: callId, + provider_media_addr: providerMediaAddr, + provider_media_port: providerMediaPort, + sip_pt: sipPt, + } as any); + return true; + } catch (e: any) { + logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`); + return false; + } +} + +/** + * Close a WebRTC session. + */ +export async function webrtcClose(sessionId: string): Promise { + if (!bridge || !initialized) return; + try { + await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any); + } catch { /* ignore */ } +} + /** * Subscribe to an event from the proxy engine. * Event names: incoming_call, outbound_device_call, call_ringing, diff --git a/ts/registrar.ts b/ts/registrar.ts index ea2a5f8..23cb10c 100644 --- a/ts/registrar.ts +++ b/ts/registrar.ts @@ -1,118 +1,27 @@ /** - * Local SIP registrar — accepts REGISTER from devices and browser clients. + * Browser device registration. * - * Devices point their SIP registration at the proxy instead of the upstream - * provider. The registrar responds with 200 OK and stores the device's - * current contact (source IP:port). Browser softphones register via - * WebSocket signaling. + * SIP device registration is now handled entirely by the Rust proxy-engine. + * This module only handles browser softphone registration via WebSocket. */ import { createHash } from 'node:crypto'; -import { - SipMessage, - generateTag, -} from './sip/index.ts'; /** Hash a string to a 6-char hex ID. */ export function shortHash(input: string): string { return createHash('sha256').update(input).digest('hex').slice(0, 6); } -import type { IEndpoint } from './sip/index.ts'; -import type { IDeviceConfig } from './config.ts'; // --------------------------------------------------------------------------- -// Types +// Browser device registration // --------------------------------------------------------------------------- -export interface IRegisteredDevice { - deviceConfig: IDeviceConfig; - contact: IEndpoint | null; - registeredAt: number; - expiresAt: number; - aor: string; - connected: boolean; - isBrowser: boolean; -} - -export interface IDeviceStatusEntry { - id: string; - displayName: string; - contact: IEndpoint | null; - aor: string; - connected: boolean; - isBrowser: boolean; -} - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -const registeredDevices = new Map(); -const browserDevices = new Map(); -let knownDevices: IDeviceConfig[] = []; -let logFn: (msg: string) => void = () => {}; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function initRegistrar( - devices: IDeviceConfig[], - log: (msg: string) => void, -): void { - knownDevices = devices; - logFn = log; -} - -/** - * Process a REGISTER from a SIP device. Returns a 200 OK response to send back, - * or null if this REGISTER should not be handled by the local registrar. - */ -export function handleDeviceRegister( - msg: SipMessage, - rinfo: IEndpoint, -): SipMessage | null { - if (msg.method !== 'REGISTER') return null; - - const device = knownDevices.find((d) => d.expectedAddress === rinfo.address); - if (!device) return null; - - const from = msg.getHeader('From'); - const aor = from ? SipMessage.extractUri(from) || `sip:${device.extension}@${rinfo.address}` : `sip:${device.extension}@${rinfo.address}`; - - const MAX_EXPIRES = 300; - const expiresHeader = msg.getHeader('Expires'); - const requested = expiresHeader ? parseInt(expiresHeader, 10) : 3600; - const expires = Math.min(requested, MAX_EXPIRES); - - const entry: IRegisteredDevice = { - deviceConfig: device, - contact: { address: rinfo.address, port: rinfo.port }, - registeredAt: Date.now(), - expiresAt: Date.now() + expires * 1000, - aor, - connected: true, - isBrowser: false, - }; - registeredDevices.set(device.id, entry); - - logFn(`[registrar] ${device.displayName} (${device.id}) registered from ${rinfo.address}:${rinfo.port} expires=${expires}`); - - const contact = msg.getHeader('Contact') || ``; - const response = SipMessage.createResponse(200, 'OK', msg, { - toTag: generateTag(), - contact, - extraHeaders: [['Expires', String(expires)]], - }); - - return response; -} +const browserDevices = new Map(); /** * Register a browser softphone as a device. */ export function registerBrowserDevice(sessionId: string, userAgent?: string, remoteIp?: string): void { - // Extract a short browser name from the UA string. let browserName = 'Browser'; if (userAgent) { if (userAgent.includes('Firefox/')) browserName = 'Firefox'; @@ -121,21 +30,11 @@ export function registerBrowserDevice(sessionId: string, userAgent?: string, rem else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) browserName = 'Safari'; } - const entry: IRegisteredDevice = { - deviceConfig: { - id: `browser-${shortHash(sessionId)}`, - displayName: browserName, - expectedAddress: remoteIp || '127.0.0.1', - extension: 'webrtc', - }, - contact: null, - registeredAt: Date.now(), - expiresAt: Date.now() + 60 * 1000, // 60s — browser must re-register to stay alive - aor: `sip:webrtc@browser`, - connected: true, - isBrowser: true, - }; - browserDevices.set(sessionId, entry); + browserDevices.set(sessionId, { + deviceId: `browser-${shortHash(sessionId)}`, + displayName: browserName, + remoteIp: remoteIp || '127.0.0.1', + }); } /** @@ -144,96 +43,3 @@ export function registerBrowserDevice(sessionId: string, userAgent?: string, rem export function unregisterBrowserDevice(sessionId: string): void { browserDevices.delete(sessionId); } - -/** - * Get a registered device by its config ID. - */ -export function getRegisteredDevice(deviceId: string): IRegisteredDevice | null { - const entry = registeredDevices.get(deviceId); - if (!entry) return null; - if (Date.now() > entry.expiresAt) { - registeredDevices.delete(deviceId); - return null; - } - return entry; -} - -/** - * Get a registered device by source IP address. - */ -export function getRegisteredDeviceByAddress(address: string): IRegisteredDevice | null { - for (const entry of registeredDevices.values()) { - if (entry.contact?.address === address && Date.now() <= entry.expiresAt) { - return entry; - } - } - return null; -} - -/** - * Check whether an address belongs to a known device (by config expectedAddress). - */ -export function isKnownDeviceAddress(address: string): boolean { - return knownDevices.some((d) => d.expectedAddress === address); -} - -/** - * Get all devices for the dashboard. - * - Configured devices always show (connected or not). - * - Browser devices only show while connected. - */ -export function getAllDeviceStatuses(): IDeviceStatusEntry[] { - const now = Date.now(); - const result: IDeviceStatusEntry[] = []; - - // Configured devices — always show. - for (const dc of knownDevices) { - const reg = registeredDevices.get(dc.id); - const connected = reg ? now <= reg.expiresAt : false; - if (reg && now > reg.expiresAt) { - registeredDevices.delete(dc.id); - } - result.push({ - id: dc.id, - displayName: dc.displayName, - contact: connected && reg ? reg.contact : null, - aor: reg?.aor || `sip:${dc.extension}@${dc.expectedAddress}`, - connected, - isBrowser: false, - }); - } - - // Browser devices — only while connected. - for (const [, entry] of browserDevices) { - const ip = entry.deviceConfig.expectedAddress; - result.push({ - id: entry.deviceConfig.id, - displayName: entry.deviceConfig.displayName, - contact: ip && ip !== '127.0.0.1' ? { address: ip, port: 0 } : null, - aor: entry.aor, - connected: true, - isBrowser: true, - }); - } - - return result; -} - -/** - * Get all currently registered (connected) SIP devices. - */ -export function getAllRegisteredDevices(): IRegisteredDevice[] { - const now = Date.now(); - const result: IRegisteredDevice[] = []; - for (const [id, entry] of registeredDevices) { - if (now > entry.expiresAt) { - registeredDevices.delete(id); - } else { - result.push(entry); - } - } - for (const [, entry] of browserDevices) { - result.push(entry); - } - return result; -} diff --git a/ts/sip/dialog.ts b/ts/sip/dialog.ts deleted file mode 100644 index feb0a92..0000000 --- a/ts/sip/dialog.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * SipDialog — tracks the state of a SIP dialog (RFC 3261 §12). - * - * A dialog is created by a dialog-establishing request (INVITE, SUBSCRIBE, …) - * and its 1xx/2xx response. It manages local/remote tags, CSeq counters, - * the route set, and provides helpers to build in-dialog requests (ACK, BYE, - * re-INVITE, …). - * - * Usage: - * ```ts - * // Caller (UAC) side — create from the outgoing INVITE we just sent: - * const dialog = SipDialog.fromUacInvite(invite); - * - * // When a 200 OK arrives: - * dialog.processResponse(response200); - * - * // Build ACK for the 2xx: - * const ack = dialog.createAck(); - * - * // Later — hang up: - * const bye = dialog.createRequest('BYE'); - * ``` - */ - -import { SipMessage } from './message.ts'; -import { generateTag, generateBranch } from './helpers.ts'; -import { Buffer } from 'node:buffer'; - -export type TDialogState = 'early' | 'confirmed' | 'terminated'; - -export class SipDialog { - callId: string; - localTag: string; - remoteTag: string | null = null; - localUri: string; - remoteUri: string; - localCSeq: number; - remoteCSeq: number = 0; - routeSet: string[] = []; - remoteTarget: string; // Contact URI of the remote party - state: TDialogState = 'early'; - - // Transport info for sending in-dialog messages. - localHost: string; - localPort: number; - - constructor(options: { - callId: string; - localTag: string; - remoteTag?: string; - localUri: string; - remoteUri: string; - localCSeq: number; - remoteTarget: string; - localHost: string; - localPort: number; - routeSet?: string[]; - }) { - this.callId = options.callId; - this.localTag = options.localTag; - this.remoteTag = options.remoteTag ?? null; - this.localUri = options.localUri; - this.remoteUri = options.remoteUri; - this.localCSeq = options.localCSeq; - this.remoteTarget = options.remoteTarget; - this.localHost = options.localHost; - this.localPort = options.localPort; - this.routeSet = options.routeSet ?? []; - } - - // ------------------------------------------------------------------------- - // Factory: create dialog from an outgoing INVITE (UAC side) - // ------------------------------------------------------------------------- - - /** - * Create a dialog from an INVITE we are sending. - * The dialog enters "early" state; call `processResponse()` when - * provisional or final responses arrive. - */ - static fromUacInvite(invite: SipMessage, localHost: string, localPort: number): SipDialog { - const from = invite.getHeader('From') || ''; - const to = invite.getHeader('To') || ''; - return new SipDialog({ - callId: invite.callId, - localTag: SipMessage.extractTag(from) || generateTag(), - localUri: SipMessage.extractUri(from) || '', - remoteUri: SipMessage.extractUri(to) || '', - localCSeq: parseInt((invite.getHeader('CSeq') || '1').split(/\s+/)[0], 10), - remoteTarget: invite.requestUri || SipMessage.extractUri(to) || '', - localHost: localHost, - localPort: localPort, - }); - } - - // ------------------------------------------------------------------------- - // Factory: create dialog from an incoming INVITE (UAS side) - // ------------------------------------------------------------------------- - - /** - * Create a dialog from an INVITE we received. - * Typically used when acting as a UAS (e.g. for call-back scenarios). - */ - static fromUasInvite(invite: SipMessage, localTag: string, localHost: string, localPort: number): SipDialog { - const from = invite.getHeader('From') || ''; - const to = invite.getHeader('To') || ''; - const contact = invite.getHeader('Contact'); - return new SipDialog({ - callId: invite.callId, - localTag, - remoteTag: SipMessage.extractTag(from) || undefined, - localUri: SipMessage.extractUri(to) || '', - remoteUri: SipMessage.extractUri(from) || '', - localCSeq: 0, - remoteTarget: (contact ? SipMessage.extractUri(contact) : null) || SipMessage.extractUri(from) || '', - localHost, - localPort, - }); - } - - // ------------------------------------------------------------------------- - // Response processing - // ------------------------------------------------------------------------- - - /** - * Update dialog state from a received response. - * - 1xx with To-tag → early dialog - * - 2xx → confirmed dialog - * - 3xx–6xx → terminated - */ - processResponse(response: SipMessage): void { - const to = response.getHeader('To') || ''; - const tag = SipMessage.extractTag(to); - const code = response.statusCode ?? 0; - // Always update remoteTag from 2xx (RFC 3261 §12.1.2: tag in 2xx is definitive). - if (tag && (code >= 200 && code < 300)) { - this.remoteTag = tag; - } else if (tag && !this.remoteTag) { - this.remoteTag = tag; - } - - // Update remote target from Contact. - const contact = response.getHeader('Contact'); - if (contact) { - const uri = SipMessage.extractUri(contact); - if (uri) this.remoteTarget = uri; - } - - // Record-Route → route set (in reverse for UAC). - if (this.state === 'early') { - const rr: string[] = []; - for (const [n, v] of response.headers) { - if (n.toLowerCase() === 'record-route') rr.push(v); - } - if (rr.length) this.routeSet = rr.reverse(); - } - - if (code >= 200 && code < 300) { - this.state = 'confirmed'; - } else if (code >= 300) { - this.state = 'terminated'; - } - } - - // ------------------------------------------------------------------------- - // Request building - // ------------------------------------------------------------------------- - - /** - * Build an in-dialog request (BYE, re-INVITE, INFO, …). - * Automatically increments the local CSeq. - */ - createRequest(method: string, options?: { - body?: string; - contentType?: string; - extraHeaders?: [string, string][]; - }): SipMessage { - this.localCSeq++; - const branch = generateBranch(); - - const headers: [string, string][] = [ - ['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`], - ['From', `<${this.localUri}>;tag=${this.localTag}`], - ['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`], - ['Call-ID', this.callId], - ['CSeq', `${this.localCSeq} ${method}`], - ['Max-Forwards', '70'], - ]; - - // Route set → Route headers. - for (const route of this.routeSet) { - headers.push(['Route', route]); - } - - headers.push(['Contact', ``]); - - if (options?.extraHeaders) headers.push(...options.extraHeaders); - - const body = options?.body || ''; - if (body && options?.contentType) { - headers.push(['Content-Type', options.contentType]); - } - headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]); - - // Determine Request-URI from route set or remote target. - let ruri = this.remoteTarget; - if (this.routeSet.length) { - const topRoute = SipMessage.extractUri(this.routeSet[0]); - if (topRoute && topRoute.includes(';lr')) { - ruri = this.remoteTarget; // loose routing — RURI stays as remote target - } else if (topRoute) { - ruri = topRoute; // strict routing — top route becomes RURI - } - } - - return new SipMessage(`${method} ${ruri} SIP/2.0`, headers, body); - } - - /** - * Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4). - * ACK for 2xx is a new transaction, so it gets its own Via/branch. - */ - createAck(): SipMessage { - const branch = generateBranch(); - - const headers: [string, string][] = [ - ['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`], - ['From', `<${this.localUri}>;tag=${this.localTag}`], - ['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`], - ['Call-ID', this.callId], - ['CSeq', `${this.localCSeq} ACK`], - ['Max-Forwards', '70'], - ]; - - for (const route of this.routeSet) { - headers.push(['Route', route]); - } - - headers.push(['Content-Length', '0']); - - let ruri = this.remoteTarget; - if (this.routeSet.length) { - const topRoute = SipMessage.extractUri(this.routeSet[0]); - if (topRoute && topRoute.includes(';lr')) { - ruri = this.remoteTarget; - } else if (topRoute) { - ruri = topRoute; - } - } - - return new SipMessage(`ACK ${ruri} SIP/2.0`, headers, ''); - } - - /** - * Build a CANCEL for the original INVITE (same branch, CSeq). - * Used before the dialog is confirmed. - */ - createCancel(originalInvite: SipMessage): SipMessage { - const via = originalInvite.getHeader('Via') || ''; - const from = originalInvite.getHeader('From') || ''; - const to = originalInvite.getHeader('To') || ''; - - const headers: [string, string][] = [ - ['Via', via], - ['From', from], - ['To', to], - ['Call-ID', this.callId], - ['CSeq', `${this.localCSeq} CANCEL`], - ['Max-Forwards', '70'], - ['Content-Length', '0'], - ]; - - const ruri = originalInvite.requestUri || this.remoteTarget; - return new SipMessage(`CANCEL ${ruri} SIP/2.0`, headers, ''); - } - - /** Transition the dialog to terminated state. */ - terminate(): void { - this.state = 'terminated'; - } -} diff --git a/ts/sip/helpers.ts b/ts/sip/helpers.ts deleted file mode 100644 index 3874352..0000000 --- a/ts/sip/helpers.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * SIP helper utilities — ID generation and SDP construction. - */ - -import { randomBytes, createHash } from 'node:crypto'; - -// --------------------------------------------------------------------------- -// ID generators -// --------------------------------------------------------------------------- - -/** Generate a random SIP Call-ID. */ -export function generateCallId(domain?: string): string { - const id = randomBytes(16).toString('hex'); - return domain ? `${id}@${domain}` : id; -} - -/** Generate a random SIP From/To tag. */ -export function generateTag(): string { - return randomBytes(8).toString('hex'); -} - -/** Generate a RFC 3261 compliant Via branch (starts with z9hG4bK magic cookie). */ -export function generateBranch(): string { - return `z9hG4bK-${randomBytes(8).toString('hex')}`; -} - -// --------------------------------------------------------------------------- -// Codec registry -// --------------------------------------------------------------------------- - -const CODEC_NAMES: Record = { - 0: 'PCMU/8000', - 3: 'GSM/8000', - 4: 'G723/8000', - 8: 'PCMA/8000', - 9: 'G722/8000', - 18: 'G729/8000', - 101: 'telephone-event/8000', -}; - -/** Look up the rtpmap name for a static payload type. */ -export function codecName(pt: number): string { - return CODEC_NAMES[pt] || `unknown/${pt}`; -} - -// --------------------------------------------------------------------------- -// SDP builder -// --------------------------------------------------------------------------- - -export interface ISdpOptions { - /** IP address for the c= and o= lines. */ - ip: string; - /** Audio port for the m=audio line. */ - port: number; - /** RTP payload type numbers (e.g. [9, 0, 8, 101]). */ - payloadTypes?: number[]; - /** SDP session ID (random if omitted). */ - sessionId?: string; - /** Session name for the s= line (defaults to '-'). */ - sessionName?: string; - /** Direction attribute (defaults to 'sendrecv'). */ - direction?: 'sendrecv' | 'recvonly' | 'sendonly' | 'inactive'; - /** Extra a= lines to append (without "a=" prefix). */ - attributes?: string[]; -} - -/** - * Build a minimal SDP body suitable for SIP INVITE offers/answers. - * - * ```ts - * const sdp = buildSdp({ - * ip: '192.168.5.66', - * port: 20000, - * payloadTypes: [9, 0, 101], - * }); - * ``` - */ -export function buildSdp(options: ISdpOptions): string { - const { - ip, - port, - payloadTypes = [9, 0, 8, 101], - sessionId = String(Math.floor(Math.random() * 1e9)), - sessionName = '-', - direction = 'sendrecv', - attributes = [], - } = options; - - const lines: string[] = [ - 'v=0', - `o=- ${sessionId} ${sessionId} IN IP4 ${ip}`, - `s=${sessionName}`, - `c=IN IP4 ${ip}`, - 't=0 0', - `m=audio ${port} RTP/AVP ${payloadTypes.join(' ')}`, - ]; - - for (const pt of payloadTypes) { - const name = CODEC_NAMES[pt]; - if (name) lines.push(`a=rtpmap:${pt} ${name}`); - if (pt === 101) lines.push(`a=fmtp:101 0-16`); - } - - lines.push(`a=${direction}`); - for (const attr of attributes) lines.push(`a=${attr}`); - lines.push(''); // trailing CRLF - - return lines.join('\r\n'); -} - -// --------------------------------------------------------------------------- -// SIP Digest authentication (RFC 2617) -// --------------------------------------------------------------------------- - -export interface IDigestChallenge { - realm: string; - nonce: string; - algorithm?: string; - opaque?: string; - qop?: string; -} - -/** - * Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value - * into its constituent fields. - */ -export function parseDigestChallenge(header: string): IDigestChallenge | null { - if (!header.toLowerCase().startsWith('digest ')) return null; - const params = header.slice(7); - const get = (key: string): string | undefined => { - const re = new RegExp(`${key}\\s*=\\s*"([^"]*)"`, 'i'); - const m = params.match(re); - if (m) return m[1]; - // unquoted value - const re2 = new RegExp(`${key}\\s*=\\s*([^,\\s]+)`, 'i'); - const m2 = params.match(re2); - return m2 ? m2[1] : undefined; - }; - const realm = get('realm'); - const nonce = get('nonce'); - if (!realm || !nonce) return null; - return { realm, nonce, algorithm: get('algorithm'), opaque: get('opaque'), qop: get('qop') }; -} - -function md5(s: string): string { - return createHash('md5').update(s).digest('hex'); -} - -/** - * Compute a SIP Digest `Proxy-Authorization` or `Authorization` header value. - */ -export function computeDigestAuth(options: { - username: string; - password: string; - realm: string; - nonce: string; - method: string; - uri: string; - algorithm?: string; - opaque?: string; -}): string { - const ha1 = md5(`${options.username}:${options.realm}:${options.password}`); - const ha2 = md5(`${options.method}:${options.uri}`); - const response = md5(`${ha1}:${options.nonce}:${ha2}`); - - let header = `Digest username="${options.username}", realm="${options.realm}", ` + - `nonce="${options.nonce}", uri="${options.uri}", response="${response}", ` + - `algorithm=${options.algorithm || 'MD5'}`; - if (options.opaque) header += `, opaque="${options.opaque}"`; - return header; -} - -/** - * Parse the audio media port and connection address from an SDP body. - * Returns null when no c= + m=audio pair is found. - */ -export function parseSdpEndpoint(sdp: string): { address: string; port: number } | null { - let addr: string | null = null; - let port: number | null = null; - for (const raw of sdp.replace(/\r\n/g, '\n').split('\n')) { - const line = raw.trim(); - if (line.startsWith('c=IN IP4 ')) { - addr = line.slice('c=IN IP4 '.length).trim(); - } else if (line.startsWith('m=audio ')) { - const parts = line.split(' '); - if (parts.length >= 2) port = parseInt(parts[1], 10); - } - } - return addr && port ? { address: addr, port } : null; -} - -// --------------------------------------------------------------------------- -// MWI (Message Waiting Indicator) — RFC 3842 -// --------------------------------------------------------------------------- - -/** - * Build a SIP NOTIFY request for Message Waiting Indicator. - * - * Sent out-of-dialog to notify a device about voicemail message counts. - * Uses the message-summary event package per RFC 3842. - */ -export interface IMwiOptions { - /** Proxy LAN IP and port (Via / From / Contact). */ - proxyHost: string; - proxyPort: number; - /** Target device URI (e.g. "sip:user@192.168.5.100:5060"). */ - targetUri: string; - /** Account URI for the voicebox (used in the From header). */ - accountUri: string; - /** Number of new (unheard) voice messages. */ - newMessages: number; - /** Number of old (heard) voice messages. */ - oldMessages: number; -} - -/** - * Build the body and headers for an MWI NOTIFY (RFC 3842 message-summary). - * - * Returns the body string and extra headers needed. The caller builds - * the SipMessage via SipMessage.createRequest('NOTIFY', ...). - */ -export function buildMwiBody(newMessages: number, oldMessages: number, accountUri: string): { - body: string; - contentType: string; - extraHeaders: [string, string][]; -} { - const hasNew = newMessages > 0; - const body = - `Messages-Waiting: ${hasNew ? 'yes' : 'no'}\r\n` + - `Message-Account: ${accountUri}\r\n` + - `Voice-Message: ${newMessages}/${oldMessages}\r\n`; - - return { - body, - contentType: 'application/simple-message-summary', - extraHeaders: [ - ['Event', 'message-summary'], - ['Subscription-State', 'terminated;reason=noresource'], - ], - }; -} diff --git a/ts/sip/index.ts b/ts/sip/index.ts deleted file mode 100644 index 49c3ac8..0000000 --- a/ts/sip/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { SipMessage } from './message.ts'; -export { SipDialog } from './dialog.ts'; -export type { TDialogState } from './dialog.ts'; -export { rewriteSipUri, rewriteSdp } from './rewrite.ts'; -export { - generateCallId, - generateTag, - generateBranch, - codecName, - buildSdp, - parseSdpEndpoint, - parseDigestChallenge, - computeDigestAuth, - buildMwiBody, -} from './helpers.ts'; -export type { ISdpOptions, IDigestChallenge, IMwiOptions } from './helpers.ts'; -export type { IEndpoint } from './types.ts'; diff --git a/ts/sip/message.ts b/ts/sip/message.ts deleted file mode 100644 index b64ad48..0000000 --- a/ts/sip/message.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * SipMessage — parse, inspect, mutate, and serialize SIP messages. - * - * Provides a fluent (builder-style) API so callers can chain header - * manipulations before serializing: - * - * const buf = SipMessage.parse(raw)! - * .setHeader('Contact', newContact) - * .prependHeader('Record-Route', rr) - * .updateContentLength() - * .serialize(); - */ - -import { Buffer } from 'node:buffer'; -import { generateCallId, generateTag, generateBranch } from './helpers.ts'; - -const SIP_FIRST_LINE_RE = /^(?:[A-Z]+\s+\S+\s+SIP\/\d\.\d|SIP\/\d\.\d\s+\d+\s+)/; - -export class SipMessage { - startLine: string; - headers: [string, string][]; - body: string; - - constructor(startLine: string, headers: [string, string][], body: string) { - this.startLine = startLine; - this.headers = headers; - this.body = body; - } - - // ------------------------------------------------------------------------- - // Parsing - // ------------------------------------------------------------------------- - - static parse(buf: Buffer): SipMessage | null { - if (!buf.length) return null; - if (buf[0] < 0x41 || buf[0] > 0x7a) return null; - - let text: string; - try { text = buf.toString('utf8'); } catch { return null; } - - let head: string; - let body: string; - let sep = text.indexOf('\r\n\r\n'); - if (sep !== -1) { - head = text.slice(0, sep); - body = text.slice(sep + 4); - } else { - sep = text.indexOf('\n\n'); - if (sep !== -1) { - head = text.slice(0, sep); - body = text.slice(sep + 2); - } else { - head = text; - body = ''; - } - } - - const lines = head.replace(/\r\n/g, '\n').split('\n'); - if (!lines.length || !lines[0]) return null; - const startLine = lines[0]; - if (!SIP_FIRST_LINE_RE.test(startLine)) return null; - - const headers: [string, string][] = []; - for (const line of lines.slice(1)) { - if (!line.trim()) continue; - const colon = line.indexOf(':'); - if (colon === -1) continue; - headers.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]); - } - return new SipMessage(startLine, headers, body); - } - - // ------------------------------------------------------------------------- - // Serialization - // ------------------------------------------------------------------------- - - serialize(): Buffer { - const head = [this.startLine, ...this.headers.map(([n, v]) => `${n}: ${v}`)].join('\r\n') + '\r\n\r\n'; - return Buffer.concat([Buffer.from(head, 'utf8'), Buffer.from(this.body || '', 'utf8')]); - } - - // ------------------------------------------------------------------------- - // Inspectors - // ------------------------------------------------------------------------- - - get isRequest(): boolean { - return !this.startLine.startsWith('SIP/'); - } - - get isResponse(): boolean { - return this.startLine.startsWith('SIP/'); - } - - /** Request method (INVITE, REGISTER, ...) or null for responses. */ - get method(): string | null { - if (!this.isRequest) return null; - return this.startLine.split(' ')[0]; - } - - /** Response status code or null for requests. */ - get statusCode(): number | null { - if (!this.isResponse) return null; - return parseInt(this.startLine.split(' ')[1], 10); - } - - get callId(): string { - return this.getHeader('Call-ID') || 'noid'; - } - - /** Method from the CSeq header (e.g. "INVITE"). */ - get cseqMethod(): string | null { - const cseq = this.getHeader('CSeq'); - if (!cseq) return null; - const parts = cseq.trim().split(/\s+/); - return parts.length >= 2 ? parts[1] : null; - } - - /** True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE. */ - get isDialogEstablishing(): boolean { - return /^(INVITE|SUBSCRIBE|REFER|NOTIFY|UPDATE)\s/.test(this.startLine); - } - - /** True when the body carries an SDP payload. */ - get hasSdpBody(): boolean { - const ct = (this.getHeader('Content-Type') || '').toLowerCase(); - return !!this.body && ct.startsWith('application/sdp'); - } - - // ------------------------------------------------------------------------- - // Header accessors (fluent) - // ------------------------------------------------------------------------- - - getHeader(name: string): string | null { - const nl = name.toLowerCase(); - for (const [n, v] of this.headers) if (n.toLowerCase() === nl) return v; - return null; - } - - /** Overwrites the first header with the given name, or appends it. */ - setHeader(name: string, value: string): this { - const nl = name.toLowerCase(); - for (const h of this.headers) { - if (h[0].toLowerCase() === nl) { h[1] = value; return this; } - } - this.headers.push([name, value]); - return this; - } - - /** Inserts a header at the top of the header list. */ - prependHeader(name: string, value: string): this { - this.headers.unshift([name, value]); - return this; - } - - /** Removes all headers with the given name. */ - removeHeader(name: string): this { - const nl = name.toLowerCase(); - this.headers = this.headers.filter(([n]) => n.toLowerCase() !== nl); - return this; - } - - /** Recalculates Content-Length to match the current body. */ - updateContentLength(): this { - const len = Buffer.byteLength(this.body || '', 'utf8'); - return this.setHeader('Content-Length', String(len)); - } - - // ------------------------------------------------------------------------- - // Start-line mutation - // ------------------------------------------------------------------------- - - /** Replaces the Request-URI (second token) of a request start line. */ - setRequestUri(uri: string): this { - if (!this.isRequest) return this; - const parts = this.startLine.split(' '); - if (parts.length >= 2) { - parts[1] = uri; - this.startLine = parts.join(' '); - } - return this; - } - - /** Returns the Request-URI (second token) of a request start line. */ - get requestUri(): string | null { - if (!this.isRequest) return null; - return this.startLine.split(' ')[1] || null; - } - - // ------------------------------------------------------------------------- - // Factory methods — build new SIP messages from scratch - // ------------------------------------------------------------------------- - - /** - * Build a new SIP request. - * - * ```ts - * const invite = SipMessage.createRequest('INVITE', 'sip:user@host', { - * from: { uri: 'sip:me@proxy', tag: 'abc' }, - * to: { uri: 'sip:user@host' }, - * via: { host: '192.168.5.66', port: 5070 }, - * contact: '', - * }); - * ``` - */ - static createRequest(method: string, requestUri: string, options: { - via: { host: string; port: number; transport?: string; branch?: string }; - from: { uri: string; displayName?: string; tag?: string }; - to: { uri: string; displayName?: string; tag?: string }; - callId?: string; - cseq?: number; - contact?: string; - maxForwards?: number; - body?: string; - contentType?: string; - extraHeaders?: [string, string][]; - }): SipMessage { - const branch = options.via.branch || generateBranch(); - const transport = options.via.transport || 'UDP'; - const fromTag = options.from.tag || generateTag(); - const callId = options.callId || generateCallId(); - const cseq = options.cseq ?? 1; - - const fromDisplay = options.from.displayName ? `"${options.from.displayName}" ` : ''; - const toDisplay = options.to.displayName ? `"${options.to.displayName}" ` : ''; - const toTag = options.to.tag ? `;tag=${options.to.tag}` : ''; - - const headers: [string, string][] = [ - ['Via', `SIP/2.0/${transport} ${options.via.host}:${options.via.port};branch=${branch};rport`], - ['From', `${fromDisplay}<${options.from.uri}>;tag=${fromTag}`], - ['To', `${toDisplay}<${options.to.uri}>${toTag}`], - ['Call-ID', callId], - ['CSeq', `${cseq} ${method}`], - ['Max-Forwards', String(options.maxForwards ?? 70)], - ]; - - if (options.contact) { - headers.push(['Contact', options.contact]); - } - - if (options.extraHeaders) { - headers.push(...options.extraHeaders); - } - - const body = options.body || ''; - if (body && options.contentType) { - headers.push(['Content-Type', options.contentType]); - } - headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]); - - return new SipMessage(`${method} ${requestUri} SIP/2.0`, headers, body); - } - - /** - * Build a SIP response to an incoming request. - * - * Copies Via, From, To, Call-ID, and CSeq from the original request. - */ - static createResponse( - statusCode: number, - reasonPhrase: string, - request: SipMessage, - options?: { - toTag?: string; - contact?: string; - body?: string; - contentType?: string; - extraHeaders?: [string, string][]; - }, - ): SipMessage { - const headers: [string, string][] = []; - - // Copy all Via headers (order matters). - for (const [n, v] of request.headers) { - if (n.toLowerCase() === 'via') headers.push(['Via', v]); - } - - // From — copied verbatim. - const from = request.getHeader('From'); - if (from) headers.push(['From', from]); - - // To — add tag if provided and not already present. - let to = request.getHeader('To') || ''; - if (options?.toTag && !to.includes('tag=')) { - to += `;tag=${options.toTag}`; - } - headers.push(['To', to]); - - headers.push(['Call-ID', request.callId]); - - const cseq = request.getHeader('CSeq'); - if (cseq) headers.push(['CSeq', cseq]); - - if (options?.contact) headers.push(['Contact', options.contact]); - if (options?.extraHeaders) headers.push(...options.extraHeaders); - - const body = options?.body || ''; - if (body && options?.contentType) { - headers.push(['Content-Type', options.contentType]); - } - headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]); - - return new SipMessage(`SIP/2.0 ${statusCode} ${reasonPhrase}`, headers, body); - } - - /** Extract the tag from a From or To header value. */ - static extractTag(headerValue: string): string | null { - const m = headerValue.match(/;tag=([^\s;>]+)/); - return m ? m[1] : null; - } - - /** Extract the URI from an addr-spec or name-addr (From/To/Contact). */ - static extractUri(headerValue: string): string | null { - const m = headerValue.match(/<([^>]+)>/); - return m ? m[1] : headerValue.trim().split(/[;>]/)[0] || null; - } -} diff --git a/ts/sip/readme.md b/ts/sip/readme.md deleted file mode 100644 index 317fba6..0000000 --- a/ts/sip/readme.md +++ /dev/null @@ -1,228 +0,0 @@ -# ts/sip — SIP Protocol Library - -A zero-dependency SIP (Session Initiation Protocol) library for Deno / Node. -Provides parsing, construction, mutation, and dialog management for SIP -messages, plus helpers for SDP bodies and URI rewriting. - -## Modules - -| File | Purpose | -|------|---------| -| `message.ts` | `SipMessage` class — parse, inspect, mutate, serialize | -| `dialog.ts` | `SipDialog` class — track dialog state, build in-dialog requests | -| `helpers.ts` | ID generators, codec registry, SDP builder/parser | -| `rewrite.ts` | SIP URI and SDP body rewriting | -| `types.ts` | Shared types (`IEndpoint`) | -| `index.ts` | Barrel re-export | - -## Quick Start - -```ts -import { - SipMessage, - SipDialog, - buildSdp, - parseSdpEndpoint, - rewriteSipUri, - rewriteSdp, - generateCallId, - generateTag, - generateBranch, -} from './sip/index.ts'; -``` - -## SipMessage - -### Parsing - -```ts -import { Buffer } from 'node:buffer'; - -const raw = Buffer.from( - 'INVITE sip:user@example.com SIP/2.0\r\n' + - 'Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK776\r\n' + - 'From: ;tag=abc\r\n' + - 'To: \r\n' + - 'Call-ID: a84b4c76e66710@10.0.0.1\r\n' + - 'CSeq: 1 INVITE\r\n' + - 'Content-Length: 0\r\n\r\n' -); - -const msg = SipMessage.parse(raw); -// msg.method → "INVITE" -// msg.isRequest → true -// msg.callId → "a84b4c76e66710@10.0.0.1" -// msg.cseqMethod → "INVITE" -// msg.isDialogEstablishing → true -``` - -### Fluent mutation - -All setter methods return `this` for chaining: - -```ts -const buf = SipMessage.parse(raw)! - .setHeader('Contact', '') - .prependHeader('Record-Route', '') - .updateContentLength() - .serialize(); -``` - -### Building requests from scratch - -```ts -const invite = SipMessage.createRequest('INVITE', 'sip:+4930123@voip.example.com', { - via: { host: '192.168.5.66', port: 5070 }, - from: { uri: 'sip:alice@example.com', displayName: 'Alice' }, - to: { uri: 'sip:+4930123@voip.example.com' }, - contact: '', - body: sdpBody, - contentType: 'application/sdp', -}); -// Call-ID, From tag, Via branch are auto-generated if not provided. -``` - -### Building responses - -```ts -const ok = SipMessage.createResponse(200, 'OK', incomingInvite, { - toTag: generateTag(), - contact: '', - body: answerSdp, - contentType: 'application/sdp', -}); -``` - -### Inspectors - -| Property | Type | Description | -|----------|------|-------------| -| `isRequest` | `boolean` | True for requests (INVITE, BYE, ...) | -| `isResponse` | `boolean` | True for responses (SIP/2.0 200 OK, ...) | -| `method` | `string \| null` | Request method or null | -| `statusCode` | `number \| null` | Response status code or null | -| `callId` | `string` | Call-ID header value | -| `cseqMethod` | `string \| null` | Method from CSeq header | -| `requestUri` | `string \| null` | Request-URI (second token of start line) | -| `isDialogEstablishing` | `boolean` | INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE | -| `hasSdpBody` | `boolean` | Body present with Content-Type: application/sdp | - -### Static helpers - -```ts -SipMessage.extractTag(';tag=abc') // → "abc" -SipMessage.extractUri('"Alice" ') // → "sip:alice@x.com" -``` - -## SipDialog - -Tracks dialog state per RFC 3261 §12. A dialog is created from a -dialog-establishing request and updated as responses arrive. - -### UAC (caller) side - -```ts -// 1. Build and send INVITE -const invite = SipMessage.createRequest('INVITE', destUri, { ... }); -const dialog = SipDialog.fromUacInvite(invite, '192.168.5.66', 5070); - -// 2. Process responses as they arrive -dialog.processResponse(trying100); // state stays 'early' -dialog.processResponse(ringing180); // state stays 'early', remoteTag learned -dialog.processResponse(ok200); // state → 'confirmed' - -// 3. ACK the 200 -const ack = dialog.createAck(); - -// 4. In-dialog requests -const bye = dialog.createRequest('BYE'); -dialog.terminate(); -``` - -### UAS (callee) side - -```ts -const dialog = SipDialog.fromUasInvite(incomingInvite, generateTag(), localHost, localPort); -``` - -### CANCEL (before answer) - -```ts -const cancel = dialog.createCancel(originalInvite); -``` - -### Dialog states - -`'early'` → `'confirmed'` → `'terminated'` - -## Helpers - -### ID generation - -```ts -generateCallId() // → "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5" -generateCallId('example.com') // → "a3f8b2c1...@example.com" -generateTag() // → "1a2b3c4d5e6f7a8b" -generateBranch() // → "z9hG4bK-1a2b3c4d5e6f7a8b" -``` - -### SDP builder - -```ts -const sdp = buildSdp({ - ip: '192.168.5.66', - port: 20000, - payloadTypes: [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event - direction: 'sendrecv', -}); -``` - -### SDP parser - -```ts -const ep = parseSdpEndpoint(sdpBody); -// → { address: '10.0.0.1', port: 20000 } or null -``` - -### Codec names - -```ts -codecName(9) // → "G722/8000" -codecName(0) // → "PCMU/8000" -codecName(101) // → "telephone-event/8000" -``` - -## Rewriting - -### SIP URI - -Replaces the host:port in all `sip:` / `sips:` URIs found in a header value: - -```ts -rewriteSipUri('', '203.0.113.1', 5070) -// → '' -``` - -### SDP body - -Rewrites the connection address and audio media port, returning the original -endpoint that was replaced: - -```ts -const { body, original } = rewriteSdp(sdpBody, '203.0.113.1', 20000); -// original → { address: '10.0.0.1', port: 8000 } -``` - -## Architecture Notes - -This library is intentionally low-level — it operates on individual messages -and dialogs rather than providing a full SIP stack with transport and -transaction layers. This makes it suitable for building: - -- **SIP proxies** — parse, rewrite headers/SDP, serialize, forward -- **B2BUA (back-to-back user agents)** — manage two dialogs, bridge media -- **SIP testing tools** — craft and send arbitrary messages -- **Protocol analyzers** — parse and inspect SIP traffic - -The library does not manage sockets, timers, or retransmissions — those -concerns belong to the application layer. diff --git a/ts/sip/rewrite.ts b/ts/sip/rewrite.ts deleted file mode 100644 index a80d16f..0000000 --- a/ts/sip/rewrite.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * SIP URI and SDP body rewriting helpers. - */ - -import type { IEndpoint } from './types.ts'; - -const SIP_URI_RE = /(sips?:)([^@>;,\s]+@)?([^>;,\s:]+)(:\d+)?/g; - -/** - * Replaces the host:port in every `sip:` / `sips:` URI found in `value`. - */ -export function rewriteSipUri(value: string, host: string, port: number): string { - return value.replace(SIP_URI_RE, (_m, scheme: string, userpart?: string) => - `${scheme}${userpart || ''}${host}:${port}`); -} - -/** - * 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). - */ -export function rewriteSdp( - body: string, - ip: string, - port: number, -): { body: string; original: IEndpoint | null } { - let origAddr: string | null = null; - let origPort: number | null = null; - - const out = body - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => { - if (line.startsWith('c=IN IP4 ')) { - origAddr = line.slice('c=IN IP4 '.length).trim(); - return `c=IN IP4 ${ip}`; - } - if (line.startsWith('m=audio ')) { - const parts = line.split(' '); - if (parts.length >= 2) { - origPort = parseInt(parts[1], 10); - parts[1] = String(port); - } - return parts.join(' '); - } - return line; - }) - .join('\r\n'); - - return { - body: out, - original: origAddr && origPort ? { address: origAddr, port: origPort } : null, - }; -} diff --git a/ts/sip/types.ts b/ts/sip/types.ts deleted file mode 100644 index 9dfcd84..0000000 --- a/ts/sip/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Shared SIP types. - */ - -export interface IEndpoint { - address: string; - port: number; -} diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index c2fe010..4bf7070 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.ts @@ -33,7 +33,11 @@ import { configureProxyEngine, onProxyEvent, hangupCall, + makeCall, shutdownProxyEngine, + webrtcOffer, + webrtcIce, + webrtcClose, } from './proxybridge.ts'; import type { IIncomingCallEvent, @@ -152,12 +156,25 @@ initWebRtcSignaling({ log }); // --------------------------------------------------------------------------- function getStatus() { + // Merge SIP devices (from Rust) + browser devices (from TS WebSocket). + const devices = [...deviceStatuses.values()]; + for (const bid of getAllBrowserDeviceIds()) { + devices.push({ + id: bid, + displayName: 'Browser', + address: null, + port: 0, + connected: true, + isBrowser: true, + }); + } + return { instanceId, uptime: Math.floor((Date.now() - startTime) / 1000), lanIp: appConfig.proxy.lanIp, providers: [...providerStatuses.values()], - devices: [...deviceStatuses.values()], + devices, calls: [...activeCalls.values()].map((c) => ({ ...c, duration: Math.floor((Date.now() - c.startedAt) / 1000), @@ -243,6 +260,19 @@ async function startProxyEngine(): Promise { }); }); + onProxyEvent('outbound_call_started', (data: any) => { + log(`[call] outbound started: ${data.call_id} → ${data.number} via ${data.provider_id}`); + 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(), + }); + }); + onProxyEvent('call_ringing', (data: { call_id: string }) => { const call = activeCalls.get(data.call_id); if (call) call.state = 'ringing'; @@ -278,6 +308,49 @@ async function startProxyEngine(): Promise { log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`); }); + // WebRTC events from Rust — forward ICE candidates to browser via WebSocket. + onProxyEvent('webrtc_ice_candidate', (data: any) => { + // Find the browser's WebSocket by session ID and send the ICE candidate. + broadcastWs('webrtc-ice', { + sessionId: data.session_id, + candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index }, + }); + }); + + onProxyEvent('webrtc_state', (data: any) => { + log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`); + }); + + onProxyEvent('webrtc_track', (data: any) => { + log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`); + }); + + onProxyEvent('webrtc_audio_rx', (data: any) => { + if (data.packet_count === 1 || data.packet_count === 50) { + log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`); + } + }); + + // Voicemail events. + onProxyEvent('voicemail_started', (data: any) => { + log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`); + }); + + onProxyEvent('recording_done', (data: any) => { + log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`); + // Save voicemail metadata via VoiceboxManager. + voiceboxManager.addMessage?.('default', { + callerNumber: data.caller_number || 'Unknown', + callerName: null, + fileName: data.file_path, + durationMs: data.duration_ms, + }); + }); + + onProxyEvent('voicemail_error', (data: any) => { + log(`[voicemail] error: ${data.error} call=${data.call_id}`); + }); + // Send full config to Rust — this binds the SIP socket and starts registrations. const configured = await configureProxyEngine({ proxy: appConfig.proxy, @@ -330,12 +403,28 @@ async function startProxyEngine(): Promise { initWebUi( getStatus, log, - (number, _deviceId, _providerId) => { + (number, deviceId, providerId) => { // Outbound calls from dashboard — send make_call command to Rust. - // For now, log only. Full implementation needs make_call in Rust. - log(`[dashboard] start call requested: ${number}`); - // TODO: send make_call command when implemented in Rust - return null; + log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`); + // Fire-and-forget — the async result comes via events. + makeCall(number, deviceId, providerId).then((callId) => { + if (callId) { + log(`[dashboard] call started: ${callId}`); + activeCalls.set(callId, { + id: callId, + direction: 'outbound', + callerNumber: null, + calleeNumber: number, + providerUsed: providerId || null, + state: 'setting-up', + startedAt: Date.now(), + }); + } else { + log(`[dashboard] call failed for ${number}`); + } + }); + // Return a temporary ID so the frontend doesn't show "failed" immediately. + return { id: `pending-${Date.now()}` }; }, (callId) => { hangupCall(callId); @@ -377,8 +466,23 @@ initWebUi( log(`[config] reload failed: ${e.message}`); } }, - undefined, // callManager — WebRTC calls handled separately in Phase 2 + undefined, // callManager — legacy, replaced by Rust proxy-engine voiceboxManager, + // WebRTC signaling → forwarded to Rust proxy-engine. + async (sessionId, sdp, ws) => { + log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)}`); + const result = await webrtcOffer(sessionId, sdp); + if (result?.sdp) { + ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp })); + log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`); + } + }, + async (sessionId, candidate) => { + await webrtcIce(sessionId, candidate); + }, + async (sessionId) => { + await webrtcClose(sessionId); + }, ); // --------------------------------------------------------------------------- diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 414db0b..cf6150f 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.11.0', + version: '1.12.0', description: 'undefined' }