Merge pull request 'slice-5: add rented-transport green-zone half (T2/T6/T9/T10)' (#17) from slice-5/rented-transport-dev-b into main
Some checks failed
CI / fmt (push) Successful in 1m32s
CI / clippy (push) Successful in 1m50s
CI / test (1.85) (push) Successful in 4m8s
CI / deny (push) Has been cancelled
CI / twilio-live (manual only) (push) Has been cancelled
CI / test (stable) (push) Has started running
Some checks failed
CI / fmt (push) Successful in 1m32s
CI / clippy (push) Successful in 1m50s
CI / test (1.85) (push) Successful in 4m8s
CI / deny (push) Has been cancelled
CI / twilio-live (manual only) (push) Has been cancelled
CI / test (stable) (push) Has started running
This commit was merged in pull request #17.
This commit is contained in:
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
@@ -7,6 +7,12 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# Manual trigger for the twilio-live job (pre-release validation against
|
||||
# real Twilio credentials). Never runs per-PR — the routine clippy/test
|
||||
# jobs use default features (MockCallControlClient); the live
|
||||
# TwilioCallControlClient is feature-gated behind `twilio-live` + only the
|
||||
# maintainer exercises it before tagging a release (spec §1.2 + plan T6/T10).
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -80,3 +86,34 @@ jobs:
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check
|
||||
|
||||
# The live TwilioCallControlClient is feature-gated behind `twilio-live`
|
||||
# (reqwest + rustls-tls + tracing + serde_json pulled in only when the
|
||||
# feature is on). This job exercises it against REAL Twilio credentials.
|
||||
# It runs ONLY on manual `workflow_dispatch` (never per-PR) — the routine
|
||||
# clippy/test jobs above use default features (MockCallControlClient).
|
||||
# The maintainer triggers this before tagging a release, after setting the
|
||||
# TWILIO_* secrets in the repo settings (spec §1.2 + plan T10 Step 3).
|
||||
twilio-live:
|
||||
name: twilio-live (manual only)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- name: Install libopus (media crate FFI dep)
|
||||
run: apt-get update && apt-get install -y libopus-dev
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Clippy with twilio-live feature
|
||||
run: cargo clippy --all --all-targets --features=twilio-live -- -D warnings
|
||||
- name: Twilio-live tests (includes ignored live e2e; needs TWILIO_* secrets)
|
||||
env:
|
||||
RUTSTER_TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
|
||||
RUTSTER_TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}
|
||||
RUTSTER_TWILIO_MEDIA_BIND: 0.0.0.0:8081
|
||||
RUTSTER_TWILIO_WEBHOOK_BASE: ${{ secrets.TWILIO_WEBHOOK_BASE }}
|
||||
RUTSTER_TWILIO_TEST_TO: ${{ secrets.TWILIO_TEST_TO }}
|
||||
RUTSTER_TWILIO_TEST_FROM: ${{ secrets.TWILIO_TEST_FROM }}
|
||||
run: cargo test --all --features=twilio-live -- --include-ignored
|
||||
|
||||
421
Cargo.lock
generated
421
Cargo.lock
generated
@@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -118,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -282,6 +282,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -326,6 +343,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -492,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -597,8 +623,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -620,8 +648,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -693,6 +724,23 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -701,13 +749,21 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -729,6 +785,12 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is"
|
||||
version = "0.10.0"
|
||||
@@ -802,6 +864,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -843,7 +911,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -871,7 +939,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1016,6 +1084,62 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@@ -1058,6 +1182,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -1096,6 +1231,21 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.7"
|
||||
@@ -1135,6 +1285,58 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -1160,7 +1362,21 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1169,9 +1385,21 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -1191,6 +1419,7 @@ dependencies = [
|
||||
"rutster-media",
|
||||
"rutster-tap",
|
||||
"rutster-tap-echo",
|
||||
"rutster-trunk",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1280,6 +1509,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rutster-trunk"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -1393,7 +1630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -1447,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1529,6 +1766,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -1551,7 +1791,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1663,7 +1903,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1677,6 +1917,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
@@ -1705,6 +1955,24 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -1779,6 +2047,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
@@ -1830,6 +2104,12 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.2"
|
||||
@@ -1870,6 +2150,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -1898,6 +2187,16 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
@@ -1930,12 +2229,50 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1945,6 +2282,70 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
@@ -59,3 +59,10 @@ async-trait = "0.1"
|
||||
# client sends before WS upgrade; `openai_client::openai_headers` produces
|
||||
# the header pairs the caller stuffs into it.
|
||||
http = "1"
|
||||
# reqwest 0.12: HTTP client for the green-zone Twilio REST call-control
|
||||
# client (slice-5 T6, feature-gated behind `twilio-live`). `rustls-tls`
|
||||
# (not native-tls/OpenSSL) keeps the TLS stack pure-Rust + consistent with
|
||||
# str0m's existing aws-lc-rs crypto provider (ADR-0001 "memory-safe by
|
||||
# construction"). `default-features = false` drops the default
|
||||
# native-tls/OpenSSL backend. `json` for the Calls.json response parsing.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
25
README.md
25
README.md
@@ -51,12 +51,14 @@ pip install -r examples/openai_realtime_brain/requirements.txt
|
||||
OPENAI_API_KEY=sk-... python examples/openai_realtime_brain/openai_realtime_brain.py
|
||||
```
|
||||
|
||||
> **Status:** Slices 1–3 (WebRTC media core, WS tap, OpenAI Realtime brain) are merged to
|
||||
> `main`. Slice 4 (barge-in / VAD-driven playout kill) is the active build target, in flight
|
||||
> on the `slice-4-dev-a-reflex` + `slice-4-dev-b-tap` branches. Design:
|
||||
> [`docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md`](docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md).
|
||||
> Implementation plan:
|
||||
> [`docs/superpowers/plans/2026-07-01-slice-4-barge-in.md`](docs/superpowers/plans/2026-07-01-slice-4-barge-in.md).
|
||||
> **Status:** Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain,
|
||||
> barge-in / VAD-driven playout kill) are merged to `main`. Slice 4½
|
||||
> (sim/benchmark harness, [ADR-0010](docs/adr/0010-spearhead-benchmark-sim-harness.md))
|
||||
> + step 5 (PSTN via rented Twilio Media Streams transport,
|
||||
> [ADR-0007](docs/adr/0007-trunk-rented-transport.md)) are the active build targets,
|
||||
> in flight on their respective branches. ADR-0007 honored: rutster parses zero
|
||||
> SIP bytes. See the
|
||||
> [slice-5 design](docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -162,12 +164,15 @@ exactly as integrators did on top of Asterisk.
|
||||
|
||||
## Status
|
||||
|
||||
Slices 1–3 (WebRTC media core, WS tap, OpenAI Realtime brain) are merged to `main`;
|
||||
spearhead steps 4–6 remain. The
|
||||
Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain, barge-in /
|
||||
VAD-driven playout kill) are merged to `main`; spearhead steps 4½ (sim/benchmark
|
||||
harness, [ADR-0010](docs/adr/0010-spearhead-benchmark-sim-harness.md)) + 5 (PSTN
|
||||
via rented transport, [ADR-0007](docs/adr/0007-trunk-rented-transport.md)) + 6
|
||||
(spend cap) remain. The
|
||||
[vision revision](docs/superpowers/specs/2026-06-26-vision-revision-design.md)
|
||||
and ADRs define the architecture; the
|
||||
[slice-4 design](docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md)
|
||||
documents the active build.
|
||||
[slice-5 design](docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md)
|
||||
documents the active build. ADR-0007 honored: rutster parses zero SIP bytes.
|
||||
|
||||
## First proof (the spearhead)
|
||||
|
||||
|
||||
@@ -5,4 +5,46 @@ version = "0.0.0"
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Rented carrier transport — CPaaS media-leg ingress / out-of-tree SBC; no first-party SIP (filled in step 5, ADR-0007)."
|
||||
description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)."
|
||||
|
||||
[dependencies]
|
||||
# async-trait: lets us declare `async fn` in the CallControlClient trait while
|
||||
# still using it as a trait object (`Box<dyn CallControlClient>`) in the route
|
||||
# handlers. Native async-fn-in-traits (stable since 1.75) does NOT support
|
||||
# `dyn` dispatch without manual desugaring — async_trait rewrites the signature
|
||||
# to `fn -> Pin<Box<dyn Future + Send>>` for us (spec §3.4).
|
||||
async-trait = { workspace = true }
|
||||
# url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the
|
||||
# operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`.
|
||||
url = { workspace = true }
|
||||
# reqwest + tracing + serde_json are OPTIONAL — only pulled in when the
|
||||
# `twilio-live` feature is enabled (the live `TwilioCallControlClient`, T6).
|
||||
# This keeps the default CI build (default-features-off) free of reqwest's
|
||||
# transitive dep tree, so per-PR `cargo deny check` stays lean + cargo's
|
||||
# resolve is fast for the common case. The maintainer's manual
|
||||
# `cargo test --features=twilio-live` + the twilio-live CI job (T10) pull them in.
|
||||
# `serde_json` parses the Calls.json REST response body (the `sid` field).
|
||||
reqwest = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute
|
||||
# (`#[tokio::test]` expands to a `#[test]` that spins up a current-thread
|
||||
# runtime + drives the async fn to completion). The library itself is
|
||||
# runtime-agnostic: async_trait's desugared futures poll on whatever runtime
|
||||
# the caller drives them with — the mock's bodies are synchronous (lock + push,
|
||||
# no `.await`), so no tokio dependency leaks into the library's public surface.
|
||||
tokio = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`
|
||||
# so the routine CI gate stays feature-default-off: `MockCallControlClient` is
|
||||
# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live`
|
||||
# + the e2e suite only when validating a release (against real Twilio creds).
|
||||
# Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain).
|
||||
# `dep:reqwest` + `dep:tracing` are the optional-dep enable syntax
|
||||
# (`feature = "dep:foo"` enables `foo` without exposing it as an implicit
|
||||
# feature named `foo` — the explicit form is forward-compatible + unambiguous).
|
||||
twilio-live = ["dep:reqwest", "dep:tracing", "dep:serde_json"]
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
//! # rutster-trunk
|
||||
//!
|
||||
//! **Status:** stub. Fills in at spearhead step 5 (a real phone number via rented transport).
|
||||
//!
|
||||
//! Carrier / PSTN reach for rutster — **rented transport, no first-party SIP**
|
||||
//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate will hold the
|
||||
//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate holds the
|
||||
//! rented-transport ingress: the **CPaaS media-leg adapter** — a WebSocket server accepting a
|
||||
//! provider raw-media fork (Twilio Media Streams / Telnyx) plus the provider call-control
|
||||
//! client — and later the glue for an **out-of-tree SBC** when PSTN media must stay on-prem.
|
||||
//! It owns **no SIP stack**: SIP lives outside the trust boundary, in the rented transport
|
||||
//! (green zone — [ADR-0008](../../../docs/adr/0008-fob-and-green-zone.md)).
|
||||
//!
|
||||
//! Slice 1's WebRTC-only ingress needs none of this — this stub exists to lock the crate
|
||||
//! boundary without anticipating code (spec §2.2). Its future dependency direction is
|
||||
//! `rutster-trunk` → `rutster-call-model` + `rutster-media`: PSTN audio is resampled to the
|
||||
//! canonical tap format and handed to the media plane as a media-leg ingress, parallel to
|
||||
//! WebRTC.
|
||||
//! ## Spearhead step 5 — what's here vs what's deferred
|
||||
//!
|
||||
//! The provider call-control seam ([`provider`]) lands in T2: the
|
||||
//! `CallControlClient` trait, `MockCallControlClient`, and `TwilioCredentials` (with
|
||||
//! redacted `Debug`). The live `TwilioCallControlClient` (T6) is feature-gated behind
|
||||
//! `twilio-live`. The WSS ingress (`g711`, `twilio_media_streams`, `session`,
|
||||
//! `loop_driver`) lands in the parallel FOB-side tasks (T1/T3/T4) — see the slice-5 spec.
|
||||
//!
|
||||
//! ## Dependency direction
|
||||
//!
|
||||
//! `rutster-trunk` → `rutster-call-model` + `rutster-media` (once the FOB-side media path
|
||||
//! lands): PSTN audio is resampled to the canonical tap format and handed to the media plane
|
||||
//! as a media-leg ingress, parallel to WebRTC. The provider (call-control) side is
|
||||
//! green-zone and depends on no FOB media types — only on `url` + `async-trait` (T2).
|
||||
|
||||
pub mod provider;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Stub crates lock boundaries; the compile-test is the lock.
|
||||
use crate::provider::MockCallControlClient;
|
||||
|
||||
/// Stub crates lock boundaries; the compile-test is the lock. Now that the
|
||||
/// provider module is populated (T2), this test also guards the crate-level
|
||||
/// re-exports compile against the public API surface.
|
||||
#[test]
|
||||
fn crate_compiles() {}
|
||||
fn crate_compiles() {
|
||||
// Touch the public API so the test exercises the re-export wiring.
|
||||
let _mock = MockCallControlClient::new();
|
||||
}
|
||||
}
|
||||
|
||||
204
crates/rutster-trunk/src/provider/mock.rs
Normal file
204
crates/rutster-trunk/src/provider/mock.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
//! # MockCallControlClient — in-process test double for `CallControlClient`
|
||||
//!
|
||||
//! CI tests + the PSTN-sim e2e integration tests (T7, T8) use this mock; the
|
||||
//! live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`
|
||||
//! and runs only on the maintainer's machine against real Twilio credentials.
|
||||
//!
|
||||
//! ## Why `std::sync::Mutex` (not `tokio::sync::Mutex`)
|
||||
//!
|
||||
//! The mock's async fn bodies are **synchronous** — they lock, push a record,
|
||||
//! unlock, and return. There is no `.await` while holding the lock, so the
|
||||
//! std mutex (cheaper, no async-overhead, no runtime coupling) is correct.
|
||||
//! `tokio::sync::Mutex` is for guards held across `.await` points; using it
|
||||
//! here would be cargo-cult. The async fns exist only to satisfy the trait's
|
||||
//! `async` signature (desugared by `async_trait` to `Pin<Box<...>>`); their
|
||||
//! bodies do not actually suspend.
|
||||
//!
|
||||
//! ## Poison recovery
|
||||
//!
|
||||
//! `std::sync::Mutex` "poisons" on panic — a previous lock holder that panicked
|
||||
//! leaves the mutex poisoned, and `.lock()` returns `Err(PoisonError)`. For a
|
||||
//! *test double*, a poisoned mutex means a previous test assertion failed; the
|
||||
//! right move is to recover the inner value (`.into_inner()`) rather than
|
||||
//! propagate the poison (which would cascade one test failure into every
|
||||
//! subsequent test). `lock_or_recover` does this idiomatically — and avoids
|
||||
//! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library
|
||||
//! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration
|
||||
//! tests in `tests/` import it).
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{CallControlClient, CallControlError, SpendToken};
|
||||
|
||||
/// Recover a `MutexGuard` even if the mutex is poisoned.
|
||||
///
|
||||
/// Poison indicates a prior lock-holder panicked (a test assertion failed
|
||||
/// while holding the lock). For a test double, propagating the poison would
|
||||
/// cascade the failure into every subsequent test that touches the mock;
|
||||
/// recovering the inner guard (`PoisonError::into_inner`) localizes the
|
||||
/// failure. This is the standard documented recovery pattern for non-invariant
|
||||
/// mutexes (see `std::sync::PoisonError::into_inner` docs).
|
||||
fn lock_or_recover<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// A captured `originate` call — for test assertions.
|
||||
///
|
||||
/// `spend_token_present` (not the token itself) is recorded because `SpendToken`
|
||||
/// is opaque in step 6; the mock only needs to verify whether a token was
|
||||
/// supplied, not inspect it. This keeps the mock forward-compatible with step
|
||||
/// 6's real (opaque, possibly non-`Clone`) token type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OriginateRecord {
|
||||
/// The `to_phone` arg passed to `originate`.
|
||||
pub to: String,
|
||||
/// The `from_phone` arg passed to `originate`.
|
||||
pub from: String,
|
||||
/// Whether the caller passed `Some(SpendToken)` (step 6 will; step 5 passes `None`).
|
||||
pub spend_token_present: bool,
|
||||
}
|
||||
|
||||
/// In-process test double for [`CallControlClient`]. No network IO.
|
||||
///
|
||||
/// `originate` returns a synthetic `CA...` correlation ID (matching Twilio's
|
||||
/// CallSid format — `CA` + 32 hex chars — so downstream code that parses the
|
||||
/// prefix is exercised honestly) and records the call. `hangup` records the
|
||||
/// terminate. Both are infallible in the mock (no real provider to fail); the
|
||||
/// `Result` wrappers return `Ok` so test wiring that propagates the `Result`
|
||||
/// is exercised identically to the live path.
|
||||
pub struct MockCallControlClient {
|
||||
/// Monotonic counter for synthetic CallSid generation. `AtomicU64` rather
|
||||
/// than a `Mutex<u64>` because an atomic increment needs no guard (no
|
||||
/// critical section) — cheaper and lock-poison-free.
|
||||
counter: std::sync::atomic::AtomicU64,
|
||||
originated: Mutex<Vec<OriginateRecord>>,
|
||||
hung_up: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MockCallControlClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
counter: std::sync::atomic::AtomicU64::new(0),
|
||||
originated: Mutex::new(Vec::new()),
|
||||
hung_up: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of every `originate` call the mock received, in arrival order.
|
||||
pub fn originated_calls(&self) -> Vec<OriginateRecord> {
|
||||
lock_or_recover(&self.originated).clone()
|
||||
}
|
||||
|
||||
/// Snapshot of every `correlation_id` passed to `hangup`, in arrival order.
|
||||
pub fn hung_up_calls(&self) -> Vec<String> {
|
||||
lock_or_recover(&self.hung_up).clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockCallControlClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CallControlClient for MockCallControlClient {
|
||||
async fn originate(
|
||||
&self,
|
||||
to_phone: &str,
|
||||
from_phone: &str,
|
||||
spend_token: Option<SpendToken>,
|
||||
) -> Result<String, CallControlError> {
|
||||
let id = self
|
||||
.counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// Twilio CallSids are "CA" + 32 hex chars. The mock mirrors that shape
|
||||
// so any code that parses the prefix/length exercises the real path.
|
||||
let call_sid = format!("CA{id:032x}");
|
||||
|
||||
let mut originated = lock_or_recover(&self.originated);
|
||||
originated.push(OriginateRecord {
|
||||
to: to_phone.to_owned(),
|
||||
from: from_phone.to_owned(),
|
||||
spend_token_present: spend_token.is_some(),
|
||||
});
|
||||
|
||||
Ok(call_sid)
|
||||
}
|
||||
|
||||
async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> {
|
||||
let mut hung_up = lock_or_recover(&self.hung_up);
|
||||
// Idempotent in the mock (matching the live provider): recording the
|
||||
// same id twice is not an error, matching Twilio's `Status=completed`
|
||||
// semantics. A dedup check would be test-specific logic the real
|
||||
// provider doesn't expose.
|
||||
hung_up.push(correlation_id.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn originate_returns_call_sid_and_records_call() {
|
||||
let mock = MockCallControlClient::new();
|
||||
let sid = mock
|
||||
.originate("+15551234567", "+15550000000", None)
|
||||
.await
|
||||
.expect("mock originate never errors");
|
||||
|
||||
// Twilio CallSid shape: "CA" + 32 hex chars = 34 chars total.
|
||||
assert!(sid.starts_with("CA"), "expected CA prefix, got {sid}");
|
||||
assert_eq!(sid.len(), 34, "expected 34-char CallSid, got {sid}");
|
||||
|
||||
let calls = mock.originated_calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].to, "+15551234567");
|
||||
assert_eq!(calls[0].from, "+15550000000");
|
||||
assert!(
|
||||
!calls[0].spend_token_present,
|
||||
"step 5 passes None for spend_token"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn originate_produces_distinct_call_sids_per_call() {
|
||||
let mock = MockCallControlClient::new();
|
||||
let sid_a = mock.originate("+1", "+2", None).await.unwrap();
|
||||
let sid_b = mock.originate("+3", "+4", None).await.unwrap();
|
||||
assert_ne!(sid_a, sid_b, "each originate must get a unique CallSid");
|
||||
assert_eq!(mock.originated_calls().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hangup_records_correlation_id_and_is_idempotent() {
|
||||
let mock = MockCallControlClient::new();
|
||||
mock.hangup("CAdeadbeef").await.unwrap();
|
||||
// Calling again with the same id is a no-op success (live provider is idempotent).
|
||||
mock.hangup("CAdeadbeef").await.unwrap();
|
||||
mock.hangup("CAcafef00d").await.unwrap();
|
||||
|
||||
let hung = mock.hung_up_calls();
|
||||
// The mock records every call (no dedup) — 2 for the repeat + 1 for the other.
|
||||
assert_eq!(hung.len(), 3);
|
||||
assert_eq!(
|
||||
hung.iter().filter(|s| s.as_str() == "CAdeadbeef").count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spend_token_some_is_recorded_as_present() {
|
||||
// Step 5 callers pass None, but the trait accepts Some(SpendToken) for
|
||||
// forward-compat with step 6. The mock records presence only.
|
||||
let mock = MockCallControlClient::new();
|
||||
mock.originate("+1", "+2", Some(SpendToken)).await.unwrap();
|
||||
let calls = mock.originated_calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert!(calls[0].spend_token_present);
|
||||
}
|
||||
}
|
||||
240
crates/rutster-trunk/src/provider/mod.rs
Normal file
240
crates/rutster-trunk/src/provider/mod.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
//! # Provider — the call-control seam (green zone, ADR-0008)
|
||||
//!
|
||||
//! This module locks the boundary between rutster's FOB (the memory-safe Rust
|
||||
//! trust domain that owns the per-call media vertical) and the rented carrier
|
||||
//! transport (a CPaaS provider — Twilio for the spearhead MVP, Telnyx or others
|
||||
//! later). Per [ADR-0007](../../../../docs/adr/0007-trunk-rented-transport.md),
|
||||
//! rutster parses **zero SIP bytes**: PSTN reach is *rented transport*.
|
||||
//! Per [ADR-0008](../../../../docs/adr/0008-fob-and-green-zone.md), the call-control
|
||||
//! client lives in the **green zone** — arm's length, behind a trait — because it
|
||||
//! is neither hot-path (it fires only on originate + inbound-webhook receipt) nor
|
||||
//! security-constitutive (the actual media never transits it; only REST commands do).
|
||||
//!
|
||||
//! ## Why a trait (not a concrete Twilio client inline)
|
||||
//!
|
||||
//! The trait locks the seam so the **next provider** (Telnyx, etc.) is an
|
||||
//! *implementation*, not a *refactor*. The binary's route handlers depend on
|
||||
//! `dyn CallControlClient`, not on `TwilioCallControlClient` directly — swapping
|
||||
//! providers is a constructor change in `main.rs`, not a rewrite of the handlers.
|
||||
//! This is the same "lock the seam" discipline that `AudioPipe` applies to the
|
||||
//! media direction.
|
||||
//!
|
||||
//! ## Credential isolation (ADR-0009 — load-bearing for spearhead step 6)
|
||||
//!
|
||||
//! [`TwilioCredentials`] lives **only** in this crate (`rutster-trunk`). It is
|
||||
//! `pub` to the binary crate (`crates/rutster/`) so `config.rs` can construct
|
||||
//! it from env vars and hand it to `TwilioCallControlClient::new`, but it is
|
||||
//! **never re-exported through the workspace root**, never appears in
|
||||
//! `rutster-media`'s or `rutster-tap`'s public API, and never crosses the
|
||||
//! WebSocket tap protocol to the brain. The brain carries ONLY audio (PCM
|
||||
//! in/out), function-call events, and `speech_started`/`speech_stopped`
|
||||
//! advisories — never the account SID, the `auth_token`, the REST endpoint
|
||||
//! URL, or the `CallSid`. T10's static assertion test re-checks this
|
||||
//! invariant mechanically.
|
||||
//!
|
||||
//! The `auth_token` field's [`Debug`](std::fmt::Debug) impl is **hand-written**
|
||||
//! (NOT derived) so the token is rendered as `<redacted>` — derived `Debug`
|
||||
//! would print the secret in any `tracing::debug!` or `{:?}` log line. This is
|
||||
//! the same posture as `api_key.rs`'s redacted Debug (slice-3).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
pub mod mock;
|
||||
|
||||
pub use mock::{MockCallControlClient, OriginateRecord};
|
||||
|
||||
// The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`.
|
||||
// Including it unconditionally would pull `reqwest` into the default CI build's
|
||||
// dep graph (against the lean-default-features-off posture); the `#[cfg]` here
|
||||
// keeps the routine gate clean. `#[cfg(feature = ...)]` on a `pub mod` declaration
|
||||
// means the module is only compiled when the feature is enabled — `lib.rs`'s
|
||||
// `pub mod provider;` is unconditional (the trait + mock + credentials are always
|
||||
// available); only the live REST client is gated.
|
||||
#[cfg(feature = "twilio-live")]
|
||||
pub mod twilio;
|
||||
#[cfg(feature = "twilio-live")]
|
||||
pub use twilio::TwilioCallControlClient;
|
||||
|
||||
/// Call-control operations a rented-transport provider exposes.
|
||||
///
|
||||
/// `Send + Sync` so it can live behind an `Arc<dyn CallControlClient>` shared
|
||||
/// across the axum route handlers' tokio tasks. The `#[async_trait]` attribute
|
||||
/// (from the `async-trait` crate) rewrites each `async fn` into a synchronous
|
||||
/// `fn -> Pin<Box<dyn Future<Output = ...> + Send>>` — that boxing is *why* the
|
||||
/// trait is usable as a trait object (`dyn CallControlClient`). Native
|
||||
/// `async fn` in traits (stable since Rust 1.75) does not yet support `dyn`
|
||||
/// dispatch, so the macro is the pragmatic bridge (spec §3.4).
|
||||
#[async_trait::async_trait]
|
||||
pub trait CallControlClient: Send + Sync {
|
||||
/// Originate an outbound call to `to_phone` from `from_phone`.
|
||||
///
|
||||
/// The provider answers the PSTN call itself, opens a Media Streams fork
|
||||
/// back to rutster, and returns a **provider-assigned correlation ID**
|
||||
/// (Twilio: the `CallSid`, a `CA...` string). The binary correlates this
|
||||
/// ID with its own `ChannelId` for log/CDR join.
|
||||
///
|
||||
/// `spend_token` is the **pre-paved seam for spearhead step 6** (the spend
|
||||
/// cap). This slice (step 5) passes `None` everywhere — no spend gate yet.
|
||||
/// Step 6's spend gate will mint a token before any originate is dispatched
|
||||
/// and pass `Some(token)` here; the live impl will reject `None` once the
|
||||
/// gate is live. **Do not remove this parameter** "to simplify" — step 6
|
||||
/// needs the signature stable so it is additive, not a refactor.
|
||||
async fn originate(
|
||||
&self,
|
||||
to_phone: &str,
|
||||
from_phone: &str,
|
||||
spend_token: Option<SpendToken>,
|
||||
) -> Result<String, CallControlError>;
|
||||
|
||||
/// Hang up an in-progress call. Idempotent — calling with an already-ended
|
||||
/// `correlation_id` returns `Ok(())` (the provider's REST API is itself
|
||||
/// idempotent on `Status=completed`). Returns after the provider
|
||||
/// acknowledges the terminate.
|
||||
async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>;
|
||||
}
|
||||
|
||||
/// A provider call-control failure. A newtype over `String` rather than a
|
||||
/// rich enum because the MVP's only concern is "did it work" (the route handler
|
||||
/// maps `Err` → HTTP 502); structured error variants (auth-failed vs
|
||||
/// rate-limited vs network) land with the second provider (Telnyx) when the
|
||||
/// distinction starts to matter operationally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CallControlError(pub String);
|
||||
|
||||
impl std::fmt::Display for CallControlError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "call-control error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CallControlError {}
|
||||
|
||||
/// A placeholder for the spearhead step-6 **spend-gate token**.
|
||||
///
|
||||
/// This slice (step 5) passes `None` for `spend_token` on every `originate`
|
||||
/// call — the spend/abuse gate ([ADR-0009](../../../../docs/adr/0009-spend-gate-honest-rescope.md))
|
||||
/// does not exist yet. The type exists so the trait signature is **locked**
|
||||
/// now: step 6 will flesh `SpendToken` out into a real carrier (minted by the
|
||||
/// spend gate, carrying the per-call budget) without changing
|
||||
/// `CallControlClient::originate`'s signature — every existing call site stays
|
||||
/// valid; only the `None` → `Some(token)` migration is incremental.
|
||||
///
|
||||
/// A unit struct (not a zero-field tuple struct) purely for construction
|
||||
/// ergonomics: `SpendToken` (no parens) is how the spend gate will build it.
|
||||
pub struct SpendToken;
|
||||
|
||||
/// Twilio account credentials + the operator's media-ingress topology.
|
||||
///
|
||||
/// Lives **only** in `rutster-trunk` (ADR-0009 — provider credentials never
|
||||
/// reach the brain). `pub` to the binary crate so `config::twilio_credentials`
|
||||
/// can construct it; never re-exported through the workspace.
|
||||
///
|
||||
/// `Debug` is **hand-implemented** below to redact `auth_token` — deriving
|
||||
/// `Debug` would print the secret to any `tracing::debug!` / `{:?}` / panic
|
||||
/// backtrace line. The redaction is a security-constitutive choice, not a
|
||||
/// convenience.
|
||||
///
|
||||
/// The `twilio-live` feature gates the live client (`TwilioCallControlClient`,
|
||||
/// T6) that consumes this struct. The struct itself is unconditionally
|
||||
/// compiled because `config.rs`'s env parser returns `Option<TwilioCredentials>`
|
||||
/// regardless of feature flags — the binary holds the parsed config even when
|
||||
/// the live REST client is compiled out of the CI default build.
|
||||
#[derive(Clone)]
|
||||
pub struct TwilioCredentials {
|
||||
/// Twilio account SID (the `AC...` identifier). Not a secret per se (it
|
||||
/// appears in URLs), but treated as sensitive for defense-in-depth.
|
||||
pub account_sid: String,
|
||||
/// Twilio auth token — **NEVER logged** (see the hand-impl `Debug` below),
|
||||
/// never passed to the brain, never placed in a tracing field. The live
|
||||
/// client uses it for HTTP basic auth on the REST API only.
|
||||
pub auth_token: String,
|
||||
/// The local socket address where rutster's `TwilioMediaStreamsServer`
|
||||
/// (T3) binds to accept inbound Twilio Media Streams WSS connections.
|
||||
/// Twilio is told to fork audio here (via the TwiML `<Connect>` directive).
|
||||
pub media_streams_bind: SocketAddr,
|
||||
/// The operator's public base URL — the address Twilio calls back via the
|
||||
/// webhook (`POST /v1/trunk/webhook`) for inbound-call signaling. In local
|
||||
/// dev this is `http://localhost:8080`; production is a public HTTPS URL.
|
||||
pub webhook_base: url::Url,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TwilioCredentials {
|
||||
/// Hand-implemented so `auth_token` renders as `<redacted>`.
|
||||
///
|
||||
/// Do NOT replace with `#[derive(Debug)]` — that would print the secret
|
||||
/// token in any `{:?}` formatting (panic backtraces, `tracing::debug!`,
|
||||
/// test-failure diffs). The other fields are operator-configured and
|
||||
/// safe to log. This mirrors `api_key.rs`'s redacted-Debug pattern
|
||||
/// (slice-3) — the codebase's standing posture on secrets.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TwilioCredentials")
|
||||
.field("account_sid", &self.account_sid)
|
||||
.field("auth_token", &"<redacted>")
|
||||
.field("media_streams_bind", &self.media_streams_bind)
|
||||
.field("webhook_base", &self.webhook_base)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twilio_credentials_debug_redacts_auth_token() {
|
||||
let creds = TwilioCredentials {
|
||||
account_sid: "AC_test_sid".into(),
|
||||
auth_token: "super-secret-token-do-not-log".into(),
|
||||
media_streams_bind: "0.0.0.0:8081".parse().unwrap(),
|
||||
webhook_base: "https://example.com".parse().unwrap(),
|
||||
};
|
||||
let rendered = format!("{:?}", creds);
|
||||
assert!(
|
||||
!rendered.contains("super-secret-token-do-not-log"),
|
||||
"auth_token leaked into Debug output: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("<redacted>"),
|
||||
"Debug output should mark auth_token as <redacted>: {rendered}"
|
||||
);
|
||||
// Non-secret fields remain visible for operator log-correlation.
|
||||
assert!(rendered.contains("AC_test_sid"));
|
||||
assert!(rendered.contains("0.0.0.0:8081"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_control_error_displays_and_contains_message() {
|
||||
let err = CallControlError("boom".into());
|
||||
assert_eq!(format!("{err}"), "call-control error: boom");
|
||||
// std::error::Error bounds check (compiles only if the impl is present).
|
||||
let _: &dyn std::error::Error = &err;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spend_token_is_unit_constructable_for_future_step6_use() {
|
||||
// This slice passes None; the construction here proves the type is
|
||||
// usable as Some(SpendToken) once step 6 mints real tokens.
|
||||
let _token = SpendToken;
|
||||
let none: Option<SpendToken> = None;
|
||||
assert!(none.is_none());
|
||||
}
|
||||
|
||||
/// ADR-0009 static assertion (spec §7 done-criteria #10): `TwilioCredentials`
|
||||
/// lives ONLY in the `rutster-trunk` crate. This test pins the type's
|
||||
/// locality — it compiles only because `crate::provider::TwilioCredentials`
|
||||
/// resolves (the type's canonical home). Sibling crates (`rutster-media`,
|
||||
/// `rutster-tap`) do NOT depend on `rutster-trunk`, so they cannot re-export
|
||||
/// the type — the invariant is structural (the Cargo dep graph). If someone
|
||||
/// later adds a `pub use` re-export through the workspace root or a sibling,
|
||||
/// this test's doc + the dep-graph change would both need revisiting
|
||||
/// (a reviewable Cargo.toml edit). The binary's `config::twilio_credentials`
|
||||
/// is the single expected import path (`rutster_trunk::provider::TwilioCredentials`).
|
||||
#[test]
|
||||
fn twilio_credentials_is_trunk_local_adr0009() {
|
||||
// Compile-time path resolution: if TwilioCredentials moved out of this
|
||||
// crate, this line fails to compile (the test breaks the build).
|
||||
fn _accepts_trunk_local_credentials(_: crate::provider::TwilioCredentials) {}
|
||||
// Touch the fn so it's not dead-code-eliminated before the type check.
|
||||
let _ = _accepts_trunk_local_credentials;
|
||||
}
|
||||
}
|
||||
280
crates/rutster-trunk/src/provider/twilio.rs
Normal file
280
crates/rutster-trunk/src/provider/twilio.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! # TwilioCallControlClient — the live Twilio REST call-control client (green zone)
|
||||
//!
|
||||
//! This module is **feature-gated behind `twilio-live`** — it is NOT compiled in
|
||||
//! the default CI build (which uses [`MockCallControlClient`](super::MockCallControlClient)).
|
||||
//! The maintainer enables it with `cargo test --features=twilio-live` when validating
|
||||
//! a release against real Twilio credentials.
|
||||
//!
|
||||
//! ## Green zone (ADR-0008)
|
||||
//!
|
||||
//! This client is arm's-length from the FOB hot path: it fires only on
|
||||
//! originate (outbound call) + hangup (teardown). It is NOT on the 20 ms media
|
||||
//! tick. The brain never holds a reference to it; the brain never sees the
|
||||
//! [`TwilioCredentials`] (ADR-0009). Only the binary's route handlers (T5) construct
|
||||
//! + call this client.
|
||||
//!
|
||||
//! ## What it talks to
|
||||
//!
|
||||
//! Twilio's REST API (<https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls.json>)
|
||||
//! over HTTPS with HTTP basic auth (`account_sid:auth_token`). The `originate` call
|
||||
//! POSTs a form body containing `To`, `From`, + a `Twiml` parameter whose value is a
|
||||
//! `<Response><Connect><Stream url="…"/></Connect></Response>` document instructing
|
||||
//! Twilio to open a Media Streams WebSocket fork against our `/twilio/media-stream`
|
||||
//! endpoint. `hangup` POSTs `Status=completed` to the per-call resource.
|
||||
//!
|
||||
//! ## Credential isolation (ADR-0009 — load-bearing)
|
||||
//!
|
||||
//! `auth_token` is used ONLY for the HTTP basic-auth header on the REST call. It is
|
||||
//! **never** logged: `tracing::debug!` fields expose only caller-controlled values
|
||||
//! (`to`, `from`) + the last 4 chars of the `CallSid` (for log correlation). The
|
||||
//! `TwilioCredentials::Debug` impl (in [`super::TwilioCredentials`]) redacts the
|
||||
//! token. This is the codebase's standing posture on secrets (cf. `api_key.rs`,
|
||||
//! slice-3).
|
||||
//!
|
||||
//! ## Why `reqwest` (not `hyper` direct or hand-rolled)
|
||||
//!
|
||||
//! `reqwest` is the de-facto maintained Rust HTTP client; it wraps `hyper` + a TLS
|
||||
//! backend. We pin `rustls-tls` (not native-tls/OpenSSL) at the workspace level to
|
||||
//! keep the TLS stack pure-Rust, consistent with `str0m`'s existing `aws-lc-rs`
|
||||
//! crypto provider (ADR-0001 "memory-safe by construction"). The client is optional
|
||||
//! (gated by `dep:reqwest`) so the default CI resolve stays lean.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{CallControlClient, CallControlError, TwilioCredentials};
|
||||
|
||||
/// Live Twilio call-control client. REST against Twilio's Calls API.
|
||||
///
|
||||
/// Construct with [`TwilioCallControlClient::new`] (passing a [`TwilioCredentials`]
|
||||
/// built by `config::twilio_credentials` in the binary crate). The `reqwest::Client`
|
||||
/// is reused across calls (connection pooling); construct once at startup, not per-call.
|
||||
pub struct TwilioCallControlClient {
|
||||
credentials: TwilioCredentials,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TwilioCallControlClient {
|
||||
/// Construct a live client. The `reqwest::Client` is created with default options
|
||||
/// (rustls-tls backend, connection pool). The caller passes the credentials built
|
||||
/// from env by the binary's `config::twilio_credentials` parser.
|
||||
pub fn new(credentials: TwilioCredentials) -> Self {
|
||||
Self {
|
||||
credentials,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the public Media Streams WSS endpoint URL the TwiML `<Stream>` directive
|
||||
/// points Twilio at, from the operator's `webhook_base`. Twilio connects BACK to us
|
||||
/// over WebSocket to fork the call's audio, so the URL must be the *public* address
|
||||
/// (a reverse proxy maps the public `:443` → the local `media_streams_bind`).
|
||||
///
|
||||
/// `https` → `wss`, `http` → `ws` (local dev may use plaintext WS per spec §1.2's
|
||||
/// TLS-deferral; production uses `wss`). The path `/twilio/media-stream` matches the
|
||||
/// route `TwilioMediaStreamsServer::router` (T3) mounts.
|
||||
fn stream_url(&self) -> String {
|
||||
let scheme = match self.credentials.webhook_base.scheme() {
|
||||
"https" => "wss",
|
||||
"http" => "ws",
|
||||
other => other,
|
||||
};
|
||||
let host = self
|
||||
.credentials
|
||||
.webhook_base
|
||||
.host_str()
|
||||
.unwrap_or("localhost");
|
||||
match self.credentials.webhook_base.port() {
|
||||
Some(port) => format!("{scheme}://{host}:{port}/twilio/media-stream"),
|
||||
None => format!("{scheme}://{host}/twilio/media-stream"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CallControlClient for TwilioCallControlClient {
|
||||
async fn originate(
|
||||
&self,
|
||||
to_phone: &str,
|
||||
from_phone: &str,
|
||||
_spend_token: Option<super::SpendToken>,
|
||||
) -> Result<String, CallControlError> {
|
||||
// The TwiML instructs Twilio to <Connect> a <Stream> back to our WSS endpoint.
|
||||
// Twilio forks the call's audio (µ-law @ 8 kHz) over the WS in real time;
|
||||
// TwilioMediaStreamsServer (T3) receives + decodes via G711Codec (T1).
|
||||
let twiml = format!(
|
||||
"<Response><Connect><Stream url=\"{}\"/></Connect></Response>",
|
||||
self.stream_url()
|
||||
);
|
||||
let url = format!(
|
||||
"https://api.twilio.com/2010-04-01/Accounts/{}/Calls.json",
|
||||
self.credentials.account_sid
|
||||
);
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.basic_auth(
|
||||
&self.credentials.account_sid,
|
||||
Some(&self.credentials.auth_token),
|
||||
)
|
||||
.form(&[("To", to_phone), ("From", from_phone), ("Twiml", &twiml)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CallControlError(format!("twilio originate transport: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CallControlError(format!("twilio originate body parse: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(no message field)");
|
||||
return Err(CallControlError(format!(
|
||||
"twilio originate rejected ({status}): {msg}"
|
||||
)));
|
||||
}
|
||||
|
||||
let call_sid = body
|
||||
.get("sid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| CallControlError("twilio response missing 'sid' field".into()))?
|
||||
.to_owned();
|
||||
|
||||
// Log ONLY caller-controlled fields + the last 4 chars of the CallSid
|
||||
// (for log correlation). auth_token is NEVER logged (ADR-0009).
|
||||
let last4 = &call_sid[call_sid.len().saturating_sub(4)..];
|
||||
tracing::debug!(
|
||||
to = to_phone,
|
||||
from = from_phone,
|
||||
sid_last4 = last4,
|
||||
"twilio originate acknowledged"
|
||||
);
|
||||
|
||||
Ok(call_sid)
|
||||
}
|
||||
|
||||
async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> {
|
||||
let url = format!(
|
||||
"https://api.twilio.com/2010-04-01/Accounts/{}/Calls/{}.json",
|
||||
self.credentials.account_sid, correlation_id
|
||||
);
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.basic_auth(
|
||||
&self.credentials.account_sid,
|
||||
Some(&self.credentials.auth_token),
|
||||
)
|
||||
.form(&[("Status", "completed")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CallControlError(format!("twilio hangup transport: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(CallControlError(format!(
|
||||
"twilio hangup rejected ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let last4 = &correlation_id[correlation_id.len().saturating_sub(4)..];
|
||||
tracing::debug!(sid_last4 = last4, "twilio hangup acknowledged");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "twilio-live"))]
|
||||
mod live_tests {
|
||||
//! Live e2e tests against real Twilio. `#[ignore]` because they place a real
|
||||
//! outbound call (charged against your Twilio account). Run manually:
|
||||
//!
|
||||
//! ```bash
|
||||
//! RUTSTER_TWILIO_ACCOUNT_SID=AC... \
|
||||
//! RUTSTER_TWILIO_AUTH_TOKEN=... \
|
||||
//! RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081 \
|
||||
//! RUTSTER_TWILIO_WEBHOOK_BASE=https://your.public.host \
|
||||
//! RUTSTER_TWILIO_TEST_TO=+1555... \
|
||||
//! RUTSTER_TWILIO_TEST_FROM=+1555... \
|
||||
//! cargo test --features=twilio-live -p rutster-trunk -- --include-ignored live_tests
|
||||
//! ```
|
||||
//!
|
||||
//! These read env vars directly (not via the binary's `config::twilio_credentials`)
|
||||
//! because the trunk crate cannot depend on the binary crate. `#[ignore]` tests are
|
||||
//! single-run (the maintainer invokes one at a time), so the env-mutation race that
|
||||
//! the pure-function `config.rs` pattern guards against does not apply here.
|
||||
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
fn creds_from_env() -> Option<TwilioCredentials> {
|
||||
let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?;
|
||||
let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?;
|
||||
let media_streams_bind: SocketAddr = std::env::var("RUTSTER_TWILIO_MEDIA_BIND")
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let webhook_base: url::Url = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE")
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()?;
|
||||
Some(TwilioCredentials {
|
||||
account_sid,
|
||||
auth_token,
|
||||
media_streams_bind,
|
||||
webhook_base,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "places a real outbound Twilio call (billed). manual run only."]
|
||||
async fn originate_real_call_returns_call_sid() {
|
||||
let creds = creds_from_env()
|
||||
.expect("set RUTSTER_TWILIO_{ACCOUNT_SID,AUTH_TOKEN,MEDIA_BIND,WEBHOOK_BASE}");
|
||||
let client = TwilioCallControlClient::new(creds);
|
||||
let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO");
|
||||
let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM");
|
||||
|
||||
let call_sid = client.originate(&to, &from, None).await.expect("originate");
|
||||
assert!(
|
||||
call_sid.starts_with("CA"),
|
||||
"expected CA-prefixed CallSid, got {call_sid}"
|
||||
);
|
||||
|
||||
// Tear down immediately so the test number doesn't actually ring.
|
||||
client.hangup(&call_sid).await.expect("hangup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_url_derives_wss_from_https_webhook_base() {
|
||||
let creds = TwilioCredentials {
|
||||
account_sid: "AC_test".into(),
|
||||
auth_token: "tok".into(),
|
||||
media_streams_bind: "0.0.0.0:8081".parse().unwrap(),
|
||||
webhook_base: "https://example.com".parse().unwrap(),
|
||||
};
|
||||
let client = TwilioCallControlClient::new(creds);
|
||||
assert_eq!(client.stream_url(), "wss://example.com/twilio/media-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_url_derives_ws_from_http_webhook_base() {
|
||||
let creds = TwilioCredentials {
|
||||
account_sid: "AC_test".into(),
|
||||
auth_token: "tok".into(),
|
||||
media_streams_bind: "127.0.0.1:8081".parse().unwrap(),
|
||||
webhook_base: "http://localhost:8080".parse().unwrap(),
|
||||
};
|
||||
let client = TwilioCallControlClient::new(creds);
|
||||
assert_eq!(
|
||||
client.stream_url(),
|
||||
"ws://localhost:8080/twilio/media-stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ description = "Rutster binary: axum signaling + media driver + static browser te
|
||||
rutster-media = { path = "../rutster-media" }
|
||||
rutster-call-model = { path = "../rutster-call-model" }
|
||||
rutster-tap = { path = "../rutster-tap" }
|
||||
# rutster-trunk: the rented-transport crate owns `TwilioCredentials` (T2) +
|
||||
# the env parser's return type. The binary's `config::twilio_credentials`
|
||||
# (T6) constructs it from env vars + hands it to the live
|
||||
# `TwilioCallControlClient` (when `--features=twilio-live` is enabled on the
|
||||
# trunk crate). ADR-0009: `TwilioCredentials` is imported here but never
|
||||
# re-exported through the workspace root / never sent over the tap WS protocol.
|
||||
rutster-trunk = { path = "../rutster-trunk" }
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use rutster_media::MediaAddressConfig;
|
||||
use rutster_trunk::provider::TwilioCredentials;
|
||||
use url::Url;
|
||||
|
||||
/// Resolve the HTTP bind address from `RUTSTER_HTTP_BIND`.
|
||||
///
|
||||
@@ -93,6 +95,74 @@ pub fn drain_deadline(raw: Option<String>) -> Result<std::time::Duration, String
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the four `RUTSTER_TWILIO_*` env vars into a [`TwilioCredentials`].
|
||||
///
|
||||
/// Returns `Ok(None)` when ALL four vars are unset — the binary runs WebRTC-only
|
||||
/// (slice-1–3 ingress + slice-4 barge-in + slice-4½ sim) without trunk support.
|
||||
/// Returns `Ok(Some(creds))` when all four are present + parse cleanly. Returns
|
||||
/// `Err(msg)` on partial config (some vars set, some unset — an operator
|
||||
/// misconfiguration that must fail-fast at startup, not silently run WebRTC-only)
|
||||
/// or on malformed values (a non-parseable `MEDIA_BIND` or `WEBHOOK_BASE`).
|
||||
///
|
||||
/// Pure function over `Option<String>` inputs (matching the rest of this module's
|
||||
/// convention) so unit tests never mutate process env. `main.rs` is the only caller
|
||||
/// that touches `std::env` — it passes the `std::env::var(...).ok()` results here.
|
||||
///
|
||||
/// `TwilioCredentials` lives in `rutster-trunk` (ADR-0009 — provider credentials
|
||||
/// never reach the brain); this parser only constructs the struct, it does not
|
||||
/// re-export it through the workspace.
|
||||
///
|
||||
/// The match destructures all four `Option`s at once: the `(None,None,None,None)`
|
||||
/// arm is the all-unset → disabled case; the `(Some,Some,Some,Some)` arm extracts
|
||||
/// owned `String`s (so no `.unwrap()` — the pattern itself proves non-`None`); the
|
||||
/// catch-all arm reports which subset was set, for an actionable operator error.
|
||||
pub fn twilio_credentials(
|
||||
account_sid: Option<String>,
|
||||
auth_token: Option<String>,
|
||||
media_streams_bind: Option<String>,
|
||||
webhook_base: Option<String>,
|
||||
) -> Result<Option<TwilioCredentials>, String> {
|
||||
let (account_sid, auth_token, media_streams_bind_raw, webhook_base_raw) =
|
||||
match (account_sid, auth_token, media_streams_bind, webhook_base) {
|
||||
(None, None, None, None) => return Ok(None),
|
||||
(Some(a), Some(t), Some(m), Some(w)) => (a, t, m, w),
|
||||
(a, t, m, w) => {
|
||||
let mut got: Vec<&str> = Vec::new();
|
||||
if a.is_some() {
|
||||
got.push("RUTSTER_TWILIO_ACCOUNT_SID");
|
||||
}
|
||||
if t.is_some() {
|
||||
got.push("RUTSTER_TWILIO_AUTH_TOKEN");
|
||||
}
|
||||
if m.is_some() {
|
||||
got.push("RUTSTER_TWILIO_MEDIA_BIND");
|
||||
}
|
||||
if w.is_some() {
|
||||
got.push("RUTSTER_TWILIO_WEBHOOK_BASE");
|
||||
}
|
||||
return Err(format!(
|
||||
"partial Twilio config: set all four RUTSTER_TWILIO_* vars or none \
|
||||
(got only: {})",
|
||||
got.join(", ")
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let media_streams_bind = media_streams_bind_raw
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| format!("RUTSTER_TWILIO_MEDIA_BIND is not a socket address: {e}"))?;
|
||||
let webhook_base = webhook_base_raw
|
||||
.parse::<Url>()
|
||||
.map_err(|e| format!("RUTSTER_TWILIO_WEBHOOK_BASE is not a URL: {e}"))?;
|
||||
|
||||
Ok(Some(TwilioCredentials {
|
||||
account_sid,
|
||||
auth_token,
|
||||
media_streams_bind,
|
||||
webhook_base,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -186,4 +256,64 @@ mod tests {
|
||||
assert_eq!(cfg.advertised_ip.unwrap().to_string(), "203.0.113.7");
|
||||
assert_eq!(cfg.port_range, Some((10000, 10100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twilio_credentials_none_when_all_unset() {
|
||||
assert!(
|
||||
twilio_credentials(None, None, None, None)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twilio_credentials_parses_all_four() {
|
||||
let creds = twilio_credentials(
|
||||
Some("AC_test_sid".into()),
|
||||
Some("secret-token".into()),
|
||||
Some("0.0.0.0:8081".into()),
|
||||
Some("https://example.com".into()),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("all-four-present should yield Some");
|
||||
assert_eq!(creds.account_sid, "AC_test_sid");
|
||||
assert_eq!(
|
||||
creds.media_streams_bind,
|
||||
"0.0.0.0:8081".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(creds.webhook_base.as_str(), "https://example.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twilio_credentials_error_on_partial_config_names_missing_vars() {
|
||||
// Only MEDIA_BIND set — the error must name what WAS set so the operator
|
||||
// knows which subset to complete or clear.
|
||||
let err = twilio_credentials(None, None, Some("0.0.0.0:8081".into()), None).unwrap_err();
|
||||
assert!(err.contains("partial"), "msg: {err}");
|
||||
assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twilio_credentials_error_on_malformed_media_bind() {
|
||||
let err = twilio_credentials(
|
||||
Some("AC_x".into()),
|
||||
Some("tok".into()),
|
||||
Some("not-a-socket-addr".into()),
|
||||
Some("https://example.com".into()),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twilio_credentials_error_on_malformed_webhook_base() {
|
||||
let err = twilio_credentials(
|
||||
Some("AC_x".into()),
|
||||
Some("tok".into()),
|
||||
Some("0.0.0.0:8081".into()),
|
||||
Some("not-a-url".into()),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("RUTSTER_TWILIO_WEBHOOK_BASE"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
Get Rutster running and hear your own voice echoed back in under 5 minutes.
|
||||
|
||||
> **Status:** Slices 1–3 are merged to `main`. Slice 4 (barge-in / VAD-driven playout
|
||||
> kill) is the active build target, in flight on the `slice-4-dev-a-reflex` +
|
||||
> `slice-4-dev-b-tap` branches. This quickstart exercises the slice-1 WebRTC media
|
||||
> loopback, which remains the simplest end-to-end demo on `main`. See
|
||||
> [`docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md`](superpowers/specs/2026-07-01-slice-4-barge-in-design.md)
|
||||
> **Status:** Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain,
|
||||
> barge-in / VAD-driven playout kill) are merged to `main`. Slice 4½
|
||||
> (sim/benchmark harness) + step 5 (PSTN via rented Twilio Media Streams
|
||||
> transport) are the active build targets, in flight. This quickstart's first
|
||||
> section exercises the slice-1 WebRTC media loopback (the simplest end-to-end
|
||||
> demo on `main`); the optional "Make a real phone call" section below covers
|
||||
> step-5 PSTN ingress. See
|
||||
> [`docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md`](superpowers/specs/2026-07-05-slice-5-rented-transport-design.md)
|
||||
> for the active build target's design.
|
||||
|
||||
---
|
||||
@@ -75,6 +78,86 @@ RUST_LOG=rutster=debug cargo run
|
||||
|
||||
---
|
||||
|
||||
## Make a real phone call (PSTN via Twilio Media Streams, optional)
|
||||
|
||||
The WebRTC loopback above proves the media core + the reflex loop. Spearhead
|
||||
step 5 takes it to a **real phone number**: a PSTN caller dials your Twilio
|
||||
number, Twilio forks the call's audio over a WebSocket to rutster, and the
|
||||
same reflex loop (barge-in, brain tap) runs against the PSTN leg — **no
|
||||
first-party SIP stack** ([ADR-0007](adr/0007-trunk-rented-transport.md)).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A [Twilio account](https://www.twilio.com/) (trial works).
|
||||
- A Twilio phone number capable of Voice + Media Streams.
|
||||
- A publicly-reachable HTTPS URL for rutster (Twilio must call back to you).
|
||||
For local dev, use [ngrok](https://ngrok.com/) to tunnel `localhost:8080`
|
||||
to a public URL: `ngrok http 8080`.
|
||||
|
||||
### Twilio credentials
|
||||
|
||||
Set these environment variables to enable PSTN ingress:
|
||||
|
||||
| Env var | Purpose | Example |
|
||||
|---|---|---|
|
||||
| `RUTSTER_TWILIO_ACCOUNT_SID` | Twilio account ID | `ACxxx...` |
|
||||
| `RUTSTER_TWILIO_AUTH_TOKEN` | Twilio auth token (secret; never logged) | `xxx...` |
|
||||
| `RUTSTER_TWILIO_MEDIA_BIND` | Where rutster's Media Streams WSS server binds | `0.0.0.0:8081` |
|
||||
| `RUTSTER_TWILIO_WEBHOOK_BASE` | Your public URL Twilio calls back to | `https://your.ngrok.io` |
|
||||
|
||||
Without these set, the binary runs WebRTC-only (slices 1–4 ingress + barge-in).
|
||||
With them set, the binary accepts PSTN fork calls against `/twilio/media-stream`.
|
||||
|
||||
### Run with Twilio enabled
|
||||
|
||||
The live Twilio REST client is feature-gated behind `twilio-live` — the routine
|
||||
CI gate uses the in-process mock; you only need the live client for a real call:
|
||||
|
||||
```bash
|
||||
export RUTSTER_TWILIO_ACCOUNT_SID=AC...
|
||||
export RUTSTER_TWILIO_AUTH_TOKEN=...
|
||||
export RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081
|
||||
export RUTSTER_TWILIO_WEBHOOK_BASE=https://your.ngrok.io
|
||||
cargo run --features=twilio-live
|
||||
```
|
||||
|
||||
### Point Twilio at rutster
|
||||
|
||||
1. In the Twilio console, configure your phone number's **A CALL COMES IN**
|
||||
webhook to `POST` to `https://your.ngrok.io/v1/trunk/webhook`.
|
||||
2. Twilio answers an inbound call, hits your webhook; rutster responds with
|
||||
TwiML instructing Twilio to `<Connect><Stream>` against
|
||||
`wss://your.ngrok.io/twilio/media-stream`.
|
||||
3. Twilio opens the WSS; rutster's `TwilioMediaStreamsServer` receives the
|
||||
call's audio (µ-law @ 8 kHz), decodes via `G711Codec` to 24 kHz PCM, and
|
||||
feeds the same reflex loop + brain tap as a WebRTC leg.
|
||||
|
||||
### Make an outbound call
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/trunk/sessions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"to":"+15551234567","from":"+15550000000"}'
|
||||
```
|
||||
|
||||
The handler calls `TwilioCallControlClient::originate`; Twilio places the call
|
||||
and forks the audio back — the PSTN caller reaches the reflex loop.
|
||||
|
||||
> **Auth note:** the `/v1/trunk/*` routes are **not authenticated** in slice 5
|
||||
> (authn defers to slice 6, the spend cap). Do not expose them to the public
|
||||
> internet without a reverse-proxy auth layer.
|
||||
|
||||
### What's different from WebRTC
|
||||
|
||||
Nothing, architecturally. The PSTN leg enters the same `MediaThread` tick loop
|
||||
via a `MediaLeg::Trunk(TrunkSession)` variant (the WebRTC leg is `MediaLeg::WebRTC`).
|
||||
The reflex stack (`Reflex<TapAudioPipe>` + `LocalVadReflex`) is **reused
|
||||
verbatim** from slice 4 — barge-in fires on PSTN caller speech the same way it
|
||||
fires on WebRTC caller speech. The only difference is the ingress: str0m's RTP
|
||||
decode (WebRTC) vs the Media Streams WSS pump + G.711 codec (trunk).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause / fix |
|
||||
|
||||
Reference in New Issue
Block a user