4 Commits

Author SHA1 Message Date
7ed76a9488 v1.20.3
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 19:02:52 +00:00
a9fdfe5733 fix(ts-config,proxybridge,voicebox): align voicebox config types and add missing proxy bridge command definitions 2026-04-11 19:02:52 +00:00
6fcdf4291a v1.20.2
Some checks failed
Docker (tags) / release (push) Failing after 3s
2026-04-11 18:40:56 +00:00
81441e7853 fix(proxy-engine): fix inbound route browser ringing and provider-facing SDP advertisement while preventing RTP port exhaustion 2026-04-11 18:40:56 +00:00
23 changed files with 419 additions and 522 deletions

114
CLAUDE.md
View File

@@ -1,41 +1,103 @@
# Project Notes
## Architecture: Hub Model (Call as Centerpiece)
## Architecture: Hub Model in Rust (Call as Centerpiece)
All call logic lives in `ts/call/`. The Call is the central entity with N legs.
The call hub lives in the Rust proxy-engine (`rust/crates/proxy-engine/`). TypeScript is the **control plane only** — it configures the engine, sends high-level commands (`hangup`, `make_call`, `webrtc_offer`, etc.), and receives events (`incoming_call`, `call_answered`, `device_registered`, `webrtc_audio_rx`, …). No raw SIP/RTP ever touches TypeScript.
### Key Files
- `ts/call/call-manager.ts` — singleton registry, factory methods, SIP routing
- `ts/call/call.ts` — the hub: owns legs, media forwarding
- `ts/call/sip-leg.ts` — SIP device/provider connection (wraps SipDialog)
- `ts/call/webrtc-leg.ts` — browser WebRTC connection (wraps werift PeerConnection)
- `ts/call/rtp-port-pool.ts` — unified RTP port pool
- `ts/sipproxy.ts` — thin bootstrap wiring everything together
- `ts/webrtcbridge.ts` — browser device registration (signaling only)
The `Call` is still the central entity: it owns N legs and a central mixer task that provides mix-minus audio to all participants. Legs can be `SipProvider`, `SipDevice`, `WebRtc`, or `Tool` (recording/transcription observer).
### WebRTC Browser Call Flow (Critical)
### Key Rust files (`rust/crates/proxy-engine/src/`)
The browser call flow has a specific signaling order that MUST be followed:
- `call_manager.rs` — singleton registry, call factory methods, SIP routing (inbound/outbound/passthrough), B2BUA state machine, inbound route resolution
- `call.rs` — the `Call` hub + `LegInfo` struct, owns legs and the mixer task
- `sip_leg.rs` — full SIP dialog management for B2BUA legs (INVITE, 407 auth retry, BYE, CANCEL, early media)
- `rtp.rs` — RTP port pool (uses `Weak<UdpSocket>` so calls auto-release ports on drop) + RTP header helpers
- `mixer.rs` — 20 ms-tick mix-minus engine (48 kHz f32 internal, per-leg transcoding via `codec-lib`, per-leg denoising)
- `jitter_buffer.rs` — per-leg reordering/packet-loss compensation
- `leg_io.rs` — spawns inbound/outbound RTP I/O tasks per SIP leg
- `webrtc_engine.rs` — browser WebRTC sessions (werift-rs based), ICE/DTLS/SRTP
- `provider.rs` — SIP trunk registrations, public-IP detection via Via `received=`
- `registrar.rs` — accepts REGISTER from SIP phones, tracks contacts (push-based device status)
- `config.rs``AppConfig` deserialized from TS, route resolvers (`resolve_outbound_route`, `resolve_inbound_route`)
- `main.rs` — IPC command dispatcher (`handle_command`), event emitter, top-level SIP packet router
- `sip_transport.rs` — owning wrapper around the main SIP UDP socket
- `voicemail.rs` / `recorder.rs` / `audio_player.rs` / `tts.rs` — media subsystems
- `tool_leg.rs` — per-source observer audio for recording/transcription tools
- `ipc.rs` — event-emission helper used throughout
1. `POST /api/call` with browser deviceId → CallManager creates Call, saves pending state, notifies browser via `webrtc-incoming`
2. Browser sends `webrtc-offer` (with its own `sessionId`) → CallManager creates a **standalone** WebRtcLeg (NOT attached to any call yet)
3. Browser sends `webrtc-accept` (with `callId` + `sessionId`) → CallManager links the standalone WebRtcLeg to the Call, then starts the SIP provider leg
### Key TS files (control plane)
**The WebRtcLeg CANNOT be created at call creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives.
- `ts/sipproxy.ts` — entrypoint, wires the proxy engine bridge + web UI + WebRTC signaling
- `ts/proxybridge.ts``@push.rocks/smartrust` bridge to the Rust binary, typed `TProxyCommands` map
- `ts/config.ts` — JSON config loader (`IAppConfig`, `IProviderConfig`, etc.), sent to Rust via `configure`
- `ts/voicebox.ts` — voicemail metadata persistence (WAV files live in `.nogit/voicemail/{boxId}/`)
- `ts/webrtcbridge.ts` — browser WebSocket signaling, browser device registry (`deviceIdToWs`)
- `ts/call/prompt-cache.ts` — the only remaining file under `ts/call/` (IVR prompt caching)
### WebRTC Audio Return Channel (Critical)
### Rust SIP protocol library
The SIP→browser audio path works through the Call hub:
`rust/crates/sip-proto/` is a zero-dependency SIP data library (parse/build/mutate/serialize messages, dialog management, SDP helpers, digest auth). Do not add transport or timer logic there — it's purely data-level.
1. Provider sends RTP to SipLeg's socket
2. SipLeg's `onRtpReceived` fires → Call hub's `forwardRtp`
3. Call hub calls `webrtcLeg.sendRtp(data)` → which calls `forwardToBrowser()`
4. `forwardToBrowser` transcodes (G.722→Opus) and sends via `sender.sendRtp()` (WebRTC PeerConnection)
## Event-push architecture for device status
**`WebRtcLeg.sendRtp()` MUST feed into `forwardToBrowser()`** (the WebRTC PeerConnection path), NOT send to a UDP address. This was a bug that caused one-way audio.
Device status flows **via push events**, not pull-based IPC queries:
The browser→SIP direction works independently: `ontrack.onReceiveRtp``forwardToSip()` → transcodes → sends directly to provider's media endpoint via UDP.
1. Rust emits `device_registered` when a phone REGISTERs
2. TS `sipproxy.ts` maintains a `deviceStatuses` Map, updated from the event
3. Map snapshot goes into the WebSocket `status` broadcast
4. Web UI (`ts_web/elements/sipproxy-devices.ts`) reads it from the push stream
### SIP Protocol Library
There used to be a `get_status` pull IPC for this, but it was never called from TS and has been removed. If a new dashboard ever needs a pull-based snapshot, the push Map is the right source to read from.
`ts/sip/` is a zero-dependency SIP protocol library. Do not add transport or timer logic there — it's purely data-level (parse/build/mutate/serialize).
## Inbound routing (wired in Commit 4 of the cleanup PR)
Inbound route resolution goes through `config.resolve_inbound_route(provider_id, called_number, caller_number)` inside `create_inbound_call` (call_manager.rs). The result carries a `ring_browsers` flag that propagates to the `incoming_call` event; `ts/sipproxy.ts` gates the `webrtc-incoming` browser fan-out behind that flag.
**Known limitations / TODOs** (documented in code at `create_inbound_call`):
- Multi-target inbound fork is not yet implemented — only the first registered device from `route.device_ids` is rung.
- `ring_browsers` is **informational only**: browsers see a toast but do not race the SIP device to answer. True first-to-answer-wins requires a multi-leg fork + per-leg CANCEL, which is not built yet.
- `voicemail_box`, `ivr_menu_id`, `no_answer_timeout` are resolved but not yet honored downstream.
## WebRTC Browser Call Flow (Critical)
The browser call signaling order is strict:
1. Browser initiates outbound via a TS API (e.g. `POST /api/call`) — TS creates a pending call in the Rust engine via `make_call` and notifies the browser with a `webrtc-incoming` push.
2. Browser sends `webrtc-offer` (with its own `sessionId`) → Rust `handle_webrtc_offer` creates a **standalone** WebRTC session (NOT attached to any call yet).
3. Browser sends `webrtc_link` (with `callId` + `sessionId`) → Rust links the standalone session to the Call and wires the WebRTC leg through the mixer.
**The WebRTC leg cannot be fully attached at call-creation time** because the browser's session ID is unknown until the `webrtc-offer` arrives.
### WebRTC audio return channel (Critical)
The SIP→browser audio path goes through the mixer, not a direct RTP relay:
1. Provider sends RTP → received on the provider leg's UDP socket (`leg_io::spawn_sip_inbound`)
2. Packet flows through `jitter_buffer` → mixer's inbound mpsc channel
3. Mixer decodes/resamples/denoises, computes mix-minus per leg
4. WebRTC leg receives its mix-minus frame, encodes to Opus, and pushes via the WebRTC engine's peer connection sender
Browser→SIP works symmetrically: `ontrack.onReceiveRtp` → WebRTC leg's outbound mpsc → mixer → other legs' inbound channels.
## SDP/Record-Route NAT (fixed in Commit 3 of the cleanup PR)
The proxy tracks a `public_ip: Option<String>` on every `LegInfo` (populated from provider-leg construction sites). When `route_passthrough_message` rewrites SDP (`c=` line) or emits a `Record-Route`, it picks `advertise_ip` based on the destination leg's kind:
- `SipProvider``other.public_ip.unwrap_or(lan_ip)` (provider reaches us via public IP)
- `SipDevice` / `WebRtc` / `Tool` / `Media``lan_ip` (everything else is LAN or proxy-internal)
This fixed a real NAT-traversal bug where the proxy advertised its RFC1918 LAN IP to the provider in SDP, causing one-way or no audio for device-originated inbound traffic behind NAT.
## Build & development
- **Build:** `pnpm run buildRust` (never `cargo build` directly — tsrust cross-compiles for both `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`)
- **Cross-compile setup:** the aarch64 target requires `gcc-aarch64-linux-gnu` + `libstdc++6-arm64-cross` (Debian/Ubuntu). See `rust/.cargo/config.toml` for the linker wiring. A committed symlink at `rust/.cargo/crosslibs/aarch64/libstdc++.so``/usr/aarch64-linux-gnu/lib/libstdc++.so.6` avoids needing the `libstdc++-13-dev-arm64-cross` package.
- **Bundle web UI:** `pnpm run bundle` (esbuild, output: `dist_ts_web/bundle.js`)
- **Full build:** `pnpm run build` (= `buildRust && bundle`)
- **Start server:** `pnpm run start` (runs `tsx ts/sipproxy.ts`)
## Persistent files
- `.nogit/config.json` — app config (providers, devices, routes, voiceboxes, IVR menus)
- `.nogit/voicemail/{boxId}/` — voicemail WAV files + `messages.json` index
- `.nogit/prompts/` — cached TTS prompts for IVR menus

View File

@@ -1,5 +1,22 @@
# Changelog
## 2026-04-11 - 1.20.3 - fix(ts-config,proxybridge,voicebox)
align voicebox config types and add missing proxy bridge command definitions
- Reuses the canonical IVoiceboxConfig type from voicebox.ts in config.ts to eliminate duplicated type definitions and optionality mismatches.
- Makes voicemail timing and limits optional in voicebox config so defaults can be applied consistently during initialization.
- Adds VoiceboxManager.addMessage and updates recording handling to use it directly for persisted voicemail metadata.
- Extends proxy bridge command typings with add_leg, remove_leg, and WebRTC signaling commands, and tightens sendCommand typing.
## 2026-04-11 - 1.20.2 - fix(proxy-engine)
fix inbound route browser ringing and provider-facing SDP advertisement while preventing RTP port exhaustion
- Honor inbound routing `ringBrowsers` when emitting incoming call events so browser toast notifications can be suppressed per route.
- Rewrite SDP and Record-Route using the destination leg's routable address, using `public_ip` for provider legs and LAN IP for device and internal legs.
- Store provider leg public IP metadata on legs to support correct per-destination SIP message rewriting.
- Change the RTP port pool to track sockets with `Weak<UdpSocket>` so ports are reclaimed automatically after calls end, avoiding leaked allocations and eventual 503 failures on new calls.
- Remove unused dashboard/status, DTMF, relay, and transport helper code paths as part of engine cleanup.
## 2026-04-11 - 1.20.1 - fix(docker)
install required native build tools for Rust dependencies in the build image

View File

@@ -1,6 +1,6 @@
{
"name": "siprouter",
"version": "1.20.1",
"version": "1.20.3",
"private": true,
"type": "module",
"scripts": {

30
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,30 @@
# Cross-compile configuration for the proxy-engine crate.
#
# tsrust builds for both x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu
# from an x86_64 host. Without this config, cargo invokes the host `cc` to
# link aarch64 objects and fails with
# rust-lld: error: <obj.o> is incompatible with elf64-x86-64
#
# Required Debian/Ubuntu packages for the aarch64 target to work:
# sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
# libc6-dev-arm64-cross libstdc++6-arm64-cross
#
# The `libstdc++.so` dev symlink (needed by the -lstdc++ flag that the
# kokoro-tts/ort build scripts emit) is provided by this repo at
# ./crosslibs/aarch64/libstdc++.so, pointing at the versioned shared
# library installed by `libstdc++6-arm64-cross`. This avoids requiring
# the `libstdc++-13-dev-arm64-cross` package, which is not always
# installed alongside the runtime.
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "link-arg=-L.cargo/crosslibs/aarch64"]
# Tell cc-rs-based build scripts (ring, zstd-sys, audiopus_sys, ort-sys) to
# use the aarch64 cross toolchain when compiling C sources for the aarch64
# target. Without these, they'd default to the host `cc` and produce x86_64
# objects that the aarch64 linker then rejects.
[env]
CC_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-gcc"
CXX_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-g++"
AR_aarch64_unknown_linux_gnu = "aarch64-linux-gnu-ar"

View File

@@ -0,0 +1 @@
/usr/aarch64-linux-gnu/lib/libstdc++.so.6

View File

@@ -27,6 +27,10 @@ pub enum CallState {
}
impl CallState {
/// Wire-format string for events/dashboards. Not currently emitted —
/// call state changes flow as typed events (`call_answered`, etc.) —
/// but kept for future status-snapshot work.
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
Self::SettingUp => "setting-up",
@@ -45,6 +49,8 @@ pub enum CallDirection {
}
impl CallDirection {
/// Wire-format string. See CallState::as_str.
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
Self::Inbound => "inbound",
@@ -59,8 +65,13 @@ pub enum LegKind {
SipProvider,
SipDevice,
WebRtc,
Media, // voicemail playback, IVR, recording
Tool, // observer leg for recording, transcription, etc.
/// Voicemail playback, IVR prompt playback, recording — not yet wired up
/// as a distinct leg kind (those paths currently use the mixer's role
/// system instead). Kept behind allow so adding a real media leg later
/// doesn't require re-introducing the variant.
#[allow(dead_code)]
Media,
Tool, // observer leg for recording, transcription, etc.
}
impl LegKind {
@@ -107,11 +118,22 @@ pub struct LegInfo {
/// For SIP legs: the SIP Call-ID for message routing.
pub sip_call_id: Option<String>,
/// For WebRTC legs: the session ID in WebRtcEngine.
///
/// Populated at leg creation but not yet consumed by the hub —
/// WebRTC session lookup currently goes through the session registry
/// directly. Kept for introspection/debugging.
#[allow(dead_code)]
pub webrtc_session_id: Option<String>,
/// The RTP socket allocated for this leg.
pub rtp_socket: Option<Arc<UdpSocket>>,
/// The RTP port number.
pub rtp_port: u16,
/// Public IP to advertise in SDP/Record-Route when THIS leg is the
/// destination of a rewrite. Populated only for provider legs; `None`
/// for LAN SIP devices, WebRTC browsers, media, and tool legs (which
/// are reachable via `lan_ip`). See `route_passthrough_message` for
/// the per-destination advertise-IP logic.
pub public_ip: Option<String>,
/// The remote media endpoint (learned from SDP or address learning).
pub remote_media: Option<SocketAddr>,
/// SIP signaling address (provider or device).
@@ -124,14 +146,21 @@ pub struct LegInfo {
/// A multiparty call with N legs and a central mixer.
pub struct Call {
// Duplicated from the HashMap key in CallManager. Kept for future
// status-snapshot work.
#[allow(dead_code)]
pub id: String,
pub state: CallState,
// Populated at call creation but not currently consumed — dashboard
// pull snapshots are gone (push events only).
#[allow(dead_code)]
pub direction: CallDirection,
pub created_at: Instant,
// Metadata.
pub caller_number: Option<String>,
pub callee_number: Option<String>,
#[allow(dead_code)]
pub provider_id: String,
/// Original INVITE from the device (for device-originated outbound calls).
@@ -211,42 +240,4 @@ impl Call {
handle.abort();
}
}
/// Produce a JSON status snapshot for the dashboard.
pub fn to_status_json(&self) -> serde_json::Value {
let legs: Vec<serde_json::Value> = self
.legs
.values()
.filter(|l| l.state != LegState::Terminated)
.map(|l| {
let metadata: serde_json::Value = if l.metadata.is_empty() {
serde_json::json!({})
} else {
serde_json::Value::Object(
l.metadata.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
)
};
serde_json::json!({
"id": l.id,
"type": l.kind.as_str(),
"state": l.state.as_str(),
"codec": sip_proto::helpers::codec_name(l.codec_pt),
"rtpPort": l.rtp_port,
"remoteMedia": l.remote_media.map(|a| format!("{}:{}", a.ip(), a.port())),
"metadata": metadata,
})
})
.collect();
serde_json::json!({
"id": self.id,
"state": self.state.as_str(),
"direction": self.direction.as_str(),
"callerNumber": self.caller_number,
"calleeNumber": self.callee_number,
"providerUsed": self.provider_id,
"duration": self.duration_secs(),
"legs": legs,
})
}
}

View File

@@ -20,6 +20,14 @@ use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
/// Result of creating an inbound call — carries both the call id and
/// whether browsers should be notified (flows from the matched inbound
/// route's `ring_browsers` flag, or the fallback default).
pub struct InboundCallCreated {
pub call_id: String,
pub ring_browsers: bool,
}
/// Emit a `leg_added` event with full leg information.
/// Free function (not a method) to avoid `&self` borrow conflicts when `self.calls` is borrowed.
fn emit_leg_added_event(tx: &OutTx, call_id: &str, leg: &LegInfo) {
@@ -94,26 +102,6 @@ impl CallManager {
self.sip_index.contains_key(sip_call_id)
}
/// Get an RTP socket for a call's provider leg (used by webrtc_link).
pub fn get_call_provider_rtp_socket(&self, call_id: &str) -> Option<Arc<UdpSocket>> {
let call = self.calls.get(call_id)?;
for leg in call.legs.values() {
if leg.kind == LegKind::SipProvider {
return leg.rtp_socket.clone();
}
}
None
}
/// Get all active call statuses for the dashboard.
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
self.calls
.values()
.filter(|c| c.state != CallState::Terminated)
.map(|c| c.to_status_json())
.collect()
}
// -----------------------------------------------------------------------
// SIP message routing
// -----------------------------------------------------------------------
@@ -426,8 +414,8 @@ impl CallManager {
// Find the counterpart leg.
let other_leg = call.legs.values().find(|l| l.id != this_leg_id && l.state != LegState::Terminated);
let (other_addr, other_rtp_port, other_leg_id) = match other_leg {
Some(l) => (l.signaling_addr, l.rtp_port, l.id.clone()),
let (other_addr, other_rtp_port, other_leg_id, other_kind, other_public_ip) = match other_leg {
Some(l) => (l.signaling_addr, l.rtp_port, l.id.clone(), l.kind, l.public_ip.clone()),
None => return false,
};
let forward_to = match other_addr {
@@ -438,8 +426,14 @@ impl CallManager {
let lan_ip = config.proxy.lan_ip.clone();
let lan_port = config.proxy.lan_port;
// Get this leg's RTP port (for SDP rewriting — tell the other side to send RTP here).
let this_rtp_port = call.legs.get(this_leg_id).map(|l| l.rtp_port).unwrap_or(0);
// Pick the IP to advertise to the destination leg. Provider legs face
// the public internet and need `public_ip`; every other leg kind is
// on-LAN (or proxy-internal) and takes `lan_ip`. This rule is applied
// both to the SDP `c=` line and the Record-Route header below.
let advertise_ip: String = match other_kind {
LegKind::SipProvider => other_public_ip.unwrap_or_else(|| lan_ip.clone()),
_ => lan_ip.clone(),
};
// Check if the other leg is a B2BUA leg (has SipLeg for proper dialog mgmt).
let other_has_sip_leg = call.legs.get(&other_leg_id)
@@ -533,10 +527,11 @@ impl CallManager {
// Forward other requests with SDP rewriting.
let mut fwd = msg.clone();
// Rewrite SDP to point the other side to this leg's RTP port
// (so we receive their audio on our socket).
// Rewrite SDP so the destination leg sends RTP to our proxy port
// at an address that is routable from its vantage point
// (public IP for provider legs, LAN IP for everything else).
if fwd.has_sdp_body() {
let (new_body, _) = rewrite_sdp(&fwd.body, &lan_ip, other_rtp_port);
let (new_body, _) = rewrite_sdp(&fwd.body, &advertise_ip, other_rtp_port);
fwd.body = new_body;
fwd.update_content_length();
}
@@ -548,7 +543,8 @@ impl CallManager {
}
}
if fwd.is_dialog_establishing() {
fwd.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
// Record-Route must also be routable from the destination leg.
fwd.prepend_header("Record-Route", &format!("<sip:{advertise_ip}:{lan_port};lr>"));
}
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
return true;
@@ -560,15 +556,10 @@ impl CallManager {
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
let mut fwd = msg.clone();
// Rewrite SDP so the forward-to side sends RTP to the correct leg port.
// Rewrite SDP so the forward-to side sends RTP to the correct
// leg port at a routable address (see `advertise_ip` above).
if fwd.has_sdp_body() {
let rewrite_ip = if this_kind == LegKind::SipDevice {
// Response from device → send to provider: use LAN/public IP.
&lan_ip
} else {
&lan_ip
};
let (new_body, _) = rewrite_sdp(&fwd.body, rewrite_ip, other_rtp_port);
let (new_body, _) = rewrite_sdp(&fwd.body, &advertise_ip, other_rtp_port);
fwd.body = new_body;
fwd.update_content_length();
}
@@ -690,7 +681,7 @@ impl CallManager {
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
) -> Option<InboundCallCreated> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
@@ -707,17 +698,41 @@ impl CallManager {
.unwrap_or("")
.to_string();
// Resolve target device.
let device_addr = match self.resolve_first_device(config, registrar) {
// Resolve via the configured inbound routing table. This honors
// user-defined routes from the UI (numberPattern, callerPattern,
// sourceProvider, targets, ringBrowsers). If no route matches, the
// fallback returns an empty `device_ids` and `ring_browsers: true`,
// which preserves pre-routing behavior via the `resolve_first_device`
// fallback below.
//
// TODO: Multi-target inbound fork is not yet implemented.
// - `route.device_ids` beyond the first registered target are ignored.
// - `ring_browsers` is informational only — browsers see a toast but
// do not race the SIP device. First-to-answer-wins requires a
// multi-leg fork + per-leg CANCEL, which is not built yet.
// - `voicemail_box`, `ivr_menu_id`, `no_answer_timeout` are not honored.
let route = config.resolve_inbound_route(provider_id, &called_number, &caller_number);
let ring_browsers = route.ring_browsers;
// Pick the first registered device from the matched targets, or fall
// back to any-registered-device if the route has no resolved targets.
let device_addr = route
.device_ids
.iter()
.find_map(|id| registrar.get_device_contact(id))
.or_else(|| self.resolve_first_device(config, registrar));
let device_addr = match device_addr {
Some(addr) => addr,
None => {
// No device registered → voicemail.
return self
let call_id = self
.route_to_voicemail(
&call_id, invite, from_addr, &caller_number,
provider_id, provider_config, config, rtp_pool, socket, public_ip,
)
.await;
.await?;
return Some(InboundCallCreated { call_id, ring_browsers });
}
};
@@ -781,6 +796,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(provider_rtp.socket.clone()),
rtp_port: provider_rtp.port,
public_ip: public_ip.map(|s| s.to_string()),
remote_media: provider_media,
signaling_addr: Some(from_addr),
metadata: HashMap::new(),
@@ -801,6 +817,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(device_rtp.socket.clone()),
rtp_port: device_rtp.port,
public_ip: None,
remote_media: None, // Learned from device's 200 OK.
signaling_addr: Some(device_addr),
metadata: HashMap::new(),
@@ -844,7 +861,7 @@ impl CallManager {
}
}
Some(call_id)
Some(InboundCallCreated { call_id, ring_browsers })
}
/// Initiate an outbound B2BUA call from the dashboard.
@@ -920,6 +937,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(rtp_alloc.socket.clone()),
rtp_port: rtp_alloc.port,
public_ip: public_ip.map(|s| s.to_string()),
remote_media: None,
signaling_addr: Some(provider_dest),
metadata: HashMap::new(),
@@ -1030,6 +1048,7 @@ impl CallManager {
sip_leg: None,
sip_call_id: Some(device_sip_call_id.clone()),
webrtc_session_id: None,
public_ip: None,
rtp_socket: Some(device_rtp.socket.clone()),
rtp_port: device_rtp.port,
remote_media: device_media,
@@ -1076,6 +1095,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(provider_rtp.socket.clone()),
rtp_port: provider_rtp.port,
public_ip: public_ip.map(|s| s.to_string()),
remote_media: None,
signaling_addr: Some(provider_dest),
metadata: HashMap::new(),
@@ -1114,7 +1134,7 @@ impl CallManager {
public_ip: Option<&str>,
registered_aor: &str,
) -> Option<String> {
let call = self.calls.get(call_id)?;
self.calls.get(call_id)?; // existence check; the call is re-fetched via get_mut below
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
@@ -1151,6 +1171,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(rtp_alloc.socket.clone()),
rtp_port: rtp_alloc.port,
public_ip: public_ip.map(|s| s.to_string()),
remote_media: None,
signaling_addr: Some(provider_dest),
metadata: HashMap::new(),
@@ -1182,7 +1203,7 @@ impl CallManager {
socket: &UdpSocket,
) -> Option<String> {
let device_addr = registrar.get_device_contact(device_id)?;
let call = self.calls.get(call_id)?;
self.calls.get(call_id)?; // existence check; the call is re-fetched via get_mut below
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
@@ -1221,6 +1242,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(rtp_alloc.socket.clone()),
rtp_port: rtp_alloc.port,
public_ip: None,
remote_media: None,
signaling_addr: Some(device_addr),
metadata: HashMap::new(),
@@ -1581,6 +1603,7 @@ impl CallManager {
webrtc_session_id: None,
rtp_socket: Some(rtp_alloc.socket.clone()),
rtp_port: rtp_alloc.port,
public_ip: public_ip.map(|s| s.to_string()),
remote_media: Some(provider_media),
signaling_addr: Some(from_addr),
metadata: HashMap::new(),

View File

@@ -30,6 +30,11 @@ impl Endpoint {
}
/// Provider quirks for codec/protocol workarounds.
//
// Deserialized from provider config for TS parity. Early-media silence
// injection and related workarounds are not yet ported to the Rust engine,
// so every field is populated by serde but not yet consumed.
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Quirks {
#[serde(rename = "earlyMediaSilence")]
@@ -44,6 +49,9 @@ pub struct Quirks {
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderConfig {
pub id: String,
// UI label — populated by serde for parity with the TS config, not
// consumed at runtime.
#[allow(dead_code)]
#[serde(rename = "displayName")]
pub display_name: String,
pub domain: String,
@@ -54,6 +62,8 @@ pub struct ProviderConfig {
#[serde(rename = "registerIntervalSec")]
pub register_interval_sec: u32,
pub codecs: Vec<u8>,
// Workaround knobs populated by serde but not yet acted upon — see Quirks.
#[allow(dead_code)]
pub quirks: Quirks,
}
@@ -84,6 +94,10 @@ pub struct RouteMatch {
/// Route action.
#[derive(Debug, Clone, Deserialize)]
// Several fields (voicemail_box, ivr_menu_id, no_answer_timeout) are read
// by resolve_inbound_route but not yet honored downstream — see the
// multi-target TODO in CallManager::create_inbound_call.
#[allow(dead_code)]
pub struct RouteAction {
pub targets: Option<Vec<String>>,
#[serde(rename = "ringBrowsers")]
@@ -106,7 +120,11 @@ pub struct RouteAction {
/// A routing rule.
#[derive(Debug, Clone, Deserialize)]
pub struct Route {
// `id` and `name` are UI identifiers, populated by serde but not
// consumed by the resolvers.
#[allow(dead_code)]
pub id: String,
#[allow(dead_code)]
pub name: String,
pub priority: i32,
pub enabled: bool,
@@ -192,10 +210,18 @@ pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
/// Result of resolving an outbound route.
pub struct OutboundRouteResult {
pub provider: ProviderConfig,
// TODO: prefix rewriting is unfinished — this is computed but the
// caller ignores it and uses the raw dialed number.
#[allow(dead_code)]
pub transformed_number: String,
}
/// Result of resolving an inbound route.
//
// `device_ids` and `ring_browsers` are consumed by create_inbound_call.
// The remaining fields (voicemail_box, ivr_menu_id, no_answer_timeout)
// are resolved but not yet acted upon — see the multi-target TODO.
#[allow(dead_code)]
pub struct InboundRouteResult {
pub device_ids: Vec<String>,
pub ring_browsers: bool,

View File

@@ -1,200 +0,0 @@
//! DTMF detection — parses RFC 2833 telephone-event RTP packets.
//!
//! Deduplicates repeated packets (same digit sent multiple times with
//! increasing duration) and fires once per detected digit.
//!
//! Ported from ts/call/dtmf-detector.ts.
use crate::ipc::{emit_event, OutTx};
/// RFC 2833 event ID → character mapping.
const EVENT_CHARS: &[char] = &[
'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: u64 = 200;
/// DTMF detector for a single RTP stream.
pub struct DtmfDetector {
/// Negotiated telephone-event payload type (default 101).
telephone_event_pt: u8,
/// Clock rate for duration calculation (default 8000 Hz).
clock_rate: u32,
/// Call ID for event emission.
call_id: String,
// Deduplication state.
current_event_id: Option<u8>,
current_event_ts: Option<u32>,
current_event_reported: bool,
current_event_duration: u16,
out_tx: OutTx,
}
impl DtmfDetector {
pub fn new(call_id: String, out_tx: OutTx) -> Self {
Self {
telephone_event_pt: 101,
clock_rate: 8000,
call_id,
current_event_id: None,
current_event_ts: None,
current_event_reported: false,
current_event_duration: 0,
out_tx,
}
}
/// Feed an RTP packet. Checks PT; ignores non-DTMF packets.
/// Returns Some(digit_char) if a digit was detected.
pub fn process_rtp(&mut self, data: &[u8]) -> Option<char> {
if data.len() < 16 {
return None; // 12-byte header + 4-byte telephone-event minimum
}
let pt = data[1] & 0x7F;
if pt != self.telephone_event_pt {
return None;
}
let marker = (data[1] & 0x80) != 0;
let rtp_timestamp = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
// Parse telephone-event payload.
let event_id = data[12];
let end_bit = (data[13] & 0x80) != 0;
let duration = u16::from_be_bytes([data[14], data[15]]);
if event_id as usize >= EVENT_CHARS.len() {
return None;
}
// Detect new event.
let is_new = marker
|| self.current_event_id != Some(event_id)
|| self.current_event_ts != Some(rtp_timestamp);
if is_new {
// Report pending unreported event.
let pending = self.report_pending();
self.current_event_id = Some(event_id);
self.current_event_ts = Some(rtp_timestamp);
self.current_event_reported = false;
self.current_event_duration = duration;
if pending.is_some() {
return pending;
}
}
if duration > self.current_event_duration {
self.current_event_duration = duration;
}
// Report on End bit (first time only).
if end_bit && !self.current_event_reported {
self.current_event_reported = true;
let digit = EVENT_CHARS[event_id as usize];
let duration_ms = (self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"duration_ms": duration_ms.round() as u32,
"source": "rfc2833",
}),
);
return Some(digit);
}
None
}
/// Report a pending unreported event.
fn report_pending(&mut self) -> Option<char> {
if let Some(event_id) = self.current_event_id {
if !self.current_event_reported && (event_id as usize) < EVENT_CHARS.len() {
self.current_event_reported = true;
let digit = EVENT_CHARS[event_id as usize];
let duration_ms =
(self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"duration_ms": duration_ms.round() as u32,
"source": "rfc2833",
}),
);
return Some(digit);
}
}
None
}
/// Process a SIP INFO message body for DTMF.
pub fn process_sip_info(&mut self, content_type: &str, body: &str) -> Option<char> {
let ct = content_type.to_ascii_lowercase();
if ct.contains("application/dtmf-relay") {
// Format: "Signal= 5\r\nDuration= 160\r\n"
let signal = body
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("signal"))
.and_then(|l| l.split('=').nth(1))
.map(|s| s.trim().to_string())?;
if signal.len() != 1 {
return None;
}
let digit = signal.chars().next()?.to_ascii_uppercase();
if !"0123456789*#ABCD".contains(digit) {
return None;
}
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"source": "sip-info",
}),
);
return Some(digit);
}
if ct.contains("application/dtmf") {
let digit = body.trim().chars().next()?.to_ascii_uppercase();
if !"0123456789*#ABCD".contains(digit) {
return None;
}
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"source": "sip-info",
}),
);
return Some(digit);
}
None
}
}

View File

@@ -10,7 +10,6 @@ mod audio_player;
mod call;
mod call_manager;
mod config;
mod dtmf;
mod ipc;
mod jitter_buffer;
mod leg_io;
@@ -140,7 +139,6 @@ async fn handle_command(
"configure" => handle_configure(engine, out_tx, &cmd).await,
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
"get_status" => handle_get_status(engine, out_tx, &cmd).await,
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
// WebRTC commands — lock webrtc only (no engine contention).
@@ -330,7 +328,7 @@ async fn handle_sip_packet(
..
} = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr
let inbound = call_mgr
.create_inbound_call(
&msg,
from_addr,
@@ -344,7 +342,7 @@ async fn handle_sip_packet(
)
.await;
if let Some(call_id) = call_id {
if let Some(inbound) = inbound {
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
let from_header = msg.get_header("From").unwrap_or("");
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
@@ -357,10 +355,11 @@ async fn handle_sip_packet(
&eng.out_tx,
"incoming_call",
serde_json::json!({
"call_id": call_id,
"call_id": inbound.call_id,
"from_uri": from_uri,
"to_number": called_number,
"provider_id": provider_id,
"ring_browsers": inbound.ring_browsers,
}),
);
}
@@ -383,7 +382,7 @@ async fn handle_sip_packet(
let route_result = config_ref.resolve_outbound_route(
&dialed_number,
device_id.as_deref(),
&|pid: &str| {
&|_pid: &str| {
// Can't call async here — use a sync check.
// For now, assume all configured providers are available.
true
@@ -454,13 +453,6 @@ async fn handle_sip_packet(
);
}
/// Handle `get_status` — return active call statuses from Rust.
async fn handle_get_status(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let eng = engine.lock().await;
let calls = eng.call_mgr.get_all_statuses();
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<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
@@ -665,6 +657,7 @@ async fn handle_webrtc_link(
webrtc_session_id: Some(session_id.clone()),
rtp_socket: None,
rtp_port: 0,
public_ip: None,
remote_media: None,
signaling_addr: None,
metadata: std::collections::HashMap::new(),
@@ -1116,6 +1109,7 @@ async fn handle_add_tool_leg(
webrtc_session_id: None,
rtp_socket: None,
rtp_port: 0,
public_ip: None,
remote_media: None,
signaling_addr: None,
metadata,

View File

@@ -39,6 +39,10 @@ pub struct RtpPacket {
/// RTP sequence number for reordering.
pub seq: u16,
/// RTP timestamp from the original packet header.
///
/// Set on inbound RTP but not yet consumed downstream — reserved for
/// future jitter/sync work in the mixer.
#[allow(dead_code)]
pub timestamp: u32,
}
@@ -136,8 +140,6 @@ pub enum MixerCommand {
timeout_ms: u32,
result_tx: oneshot::Sender<InteractionResult>,
},
/// Cancel an in-progress interaction (e.g., leg being removed).
CancelInteraction { leg_id: String },
/// Add a tool leg that receives per-source unmerged audio.
AddToolLeg {
@@ -295,16 +297,6 @@ async fn mixer_loop(
let _ = result_tx.send(InteractionResult::Cancelled);
}
}
Ok(MixerCommand::CancelInteraction { leg_id }) => {
if let Some(slot) = legs.get_mut(&leg_id) {
if let LegRole::Isolated(ref mut state) = slot.role {
if let Some(tx) = state.result_tx.take() {
let _ = tx.send(InteractionResult::Cancelled);
}
}
slot.role = LegRole::Participant;
}
}
Ok(MixerCommand::AddToolLeg {
leg_id,
tool_type,

View File

@@ -331,17 +331,6 @@ impl ProviderManager {
}
None
}
/// Check if a provider is currently registered.
pub async fn is_registered(&self, provider_id: &str) -> bool {
for ps_arc in &self.providers {
let ps = ps_arc.lock().await;
if ps.config.id == provider_id {
return ps.is_registered;
}
}
false
}
}
/// Registration loop for a single provider.

View File

@@ -178,5 +178,8 @@ impl Recorder {
pub struct RecordingResult {
pub file_path: String,
pub duration_ms: u64,
// Running-sample total kept for parity with the TS recorder; not yet
// surfaced through any event or dashboard field.
#[allow(dead_code)]
pub total_samples: u64,
}

View File

@@ -19,11 +19,19 @@ const MAX_EXPIRES: u32 = 300;
#[derive(Debug, Clone)]
pub struct RegisteredDevice {
pub device_id: String,
// These fields are populated at REGISTER time for logging/debugging but are
// not read back — device identity flows via the `device_registered` push
// event, not via struct queries. Kept behind allow(dead_code) because
// removing them would churn handle_register for no runtime benefit.
#[allow(dead_code)]
pub display_name: String,
#[allow(dead_code)]
pub extension: String,
pub contact_addr: SocketAddr,
#[allow(dead_code)]
pub registered_at: Instant,
pub expires_at: Instant,
#[allow(dead_code)]
pub aor: String,
}
@@ -134,11 +142,6 @@ impl Registrar {
Some(entry.contact_addr)
}
/// Check if a source address belongs to a known device.
pub fn is_known_device_address(&self, addr: &str) -> bool {
self.devices.iter().any(|d| d.expected_address == addr)
}
/// Find a registered device by its source IP address.
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
let ip = addr.ip().to_string();
@@ -146,26 +149,4 @@ impl Registrar {
e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at
})
}
/// Get all device statuses for the dashboard.
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
let now = Instant::now();
let mut result = Vec::new();
for dc in &self.devices {
let reg = self.registered.get(&dc.id);
let connected = reg.map(|r| now <= r.expires_at).unwrap_or(false);
result.push(serde_json::json!({
"id": dc.id,
"displayName": dc.display_name,
"address": reg.filter(|_| connected).map(|r| r.contact_addr.ip().to_string()),
"port": reg.filter(|_| connected).map(|r| r.contact_addr.port()),
"aor": reg.map(|r| r.aor.as_str()).unwrap_or(""),
"connected": connected,
"isBrowser": false,
}));
}
result
}
}

View File

@@ -1,17 +1,19 @@
//! RTP port pool and media forwarding.
//! RTP port pool for media sockets.
//!
//! Manages a pool of even-numbered UDP ports for RTP media.
//! Each port gets a bound tokio UdpSocket. Supports:
//! - Direct forwarding (SIP-to-SIP, no transcoding)
//! - Transcoding forwarding (via codec-lib, e.g. G.722 ↔ Opus)
//! - Silence generation
//! - NAT priming
//! Manages a pool of even-numbered UDP ports for RTP media. `allocate()`
//! hands back an `Arc<UdpSocket>` to the caller (stored on the owning
//! `LegInfo`), while the pool itself keeps only a `Weak<UdpSocket>`. When
//! the call terminates and `LegInfo` is dropped, the strong refcount
//! reaches zero, the socket is closed, and `allocate()` prunes the dead
//! weak ref the next time it scans that slot — so the port automatically
//! becomes available for reuse without any explicit `release()` plumbing.
//!
//! Ported from ts/call/rtp-port-pool.ts + sip-leg.ts RTP handling.
//! This fixes the previous leak where the pool held `Arc<UdpSocket>` and
//! `release()` was never called, eventually exhausting the port range and
//! causing "503 Service Unavailable" on new calls.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::{Arc, Weak};
use tokio::net::UdpSocket;
/// A single RTP port allocation.
@@ -24,7 +26,7 @@ pub struct RtpAllocation {
pub struct RtpPortPool {
min: u16,
max: u16,
allocated: HashMap<u16, Arc<UdpSocket>>,
allocated: HashMap<u16, Weak<UdpSocket>>,
}
impl RtpPortPool {
@@ -41,11 +43,19 @@ impl RtpPortPool {
pub async fn allocate(&mut self) -> Option<RtpAllocation> {
let mut port = self.min;
while port < self.max {
// Prune a dead weak ref at this slot: if the last strong Arc
// (held by the owning LegInfo) was dropped when the call ended,
// the socket is already closed and the slot is free again.
if let Some(weak) = self.allocated.get(&port) {
if weak.strong_count() == 0 {
self.allocated.remove(&port);
}
}
if !self.allocated.contains_key(&port) {
match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
Ok(sock) => {
let sock = Arc::new(sock);
self.allocated.insert(port, sock.clone());
self.allocated.insert(port, Arc::downgrade(&sock));
return Some(RtpAllocation { port, socket: sock });
}
Err(_) => {
@@ -57,83 +67,6 @@ impl RtpPortPool {
}
None // Pool exhausted.
}
/// Release a port back to the pool.
pub fn release(&mut self, port: u16) {
self.allocated.remove(&port);
// Socket is dropped when the last Arc reference goes away.
}
pub fn size(&self) -> usize {
self.allocated.len()
}
pub fn capacity(&self) -> usize {
((self.max - self.min) / 2) as usize
}
}
/// An active RTP relay between two endpoints.
/// Receives on `local_socket` and forwards to `remote_addr`.
pub struct RtpRelay {
pub local_port: u16,
pub local_socket: Arc<UdpSocket>,
pub remote_addr: Option<SocketAddr>,
/// If set, transcode packets using this codec session before forwarding.
pub transcode: Option<TranscodeConfig>,
/// Packets received counter.
pub pkt_received: u64,
/// Packets sent counter.
pub pkt_sent: u64,
}
pub struct TranscodeConfig {
pub from_pt: u8,
pub to_pt: u8,
pub session_id: String,
}
impl RtpRelay {
pub fn new(port: u16, socket: Arc<UdpSocket>) -> Self {
Self {
local_port: port,
local_socket: socket,
remote_addr: None,
transcode: None,
pkt_received: 0,
pkt_sent: 0,
}
}
pub fn set_remote(&mut self, addr: SocketAddr) {
self.remote_addr = Some(addr);
}
}
/// Send a 1-byte NAT priming packet to open a pinhole.
pub async fn prime_nat(socket: &UdpSocket, remote: SocketAddr) {
let _ = socket.send_to(&[0u8], remote).await;
}
/// Build an RTP silence frame for PCMU (payload type 0).
pub fn silence_frame_pcmu() -> Vec<u8> {
// 12-byte RTP header + 160 bytes of µ-law silence (0xFF)
let mut frame = vec![0u8; 172];
frame[0] = 0x80; // V=2
frame[1] = 0; // PT=0 (PCMU)
// seq, timestamp, ssrc left as 0 — caller should set these
frame[12..].fill(0xFF); // µ-law silence
frame
}
/// Build an RTP silence frame for G.722 (payload type 9).
pub fn silence_frame_g722() -> Vec<u8> {
// 12-byte RTP header + 160 bytes of G.722 silence
let mut frame = vec![0u8; 172];
frame[0] = 0x80; // V=2
frame[1] = 9; // PT=9 (G.722)
// G.722 silence: all zeros is valid silence
frame
}
/// Build an RTP header with the given parameters.

View File

@@ -16,7 +16,6 @@ use sip_proto::helpers::{
};
use sip_proto::message::{RequestOptions, SipMessage};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
/// State of a SIP leg.
@@ -40,6 +39,9 @@ pub struct SipLegConfig {
/// SIP target endpoint (provider outbound proxy or device address).
pub sip_target: SocketAddr,
/// Provider credentials (for 407 auth).
// username is carried for parity with the provider config but digest auth
// rebuilds the username from the registered AOR, so this slot is never read.
#[allow(dead_code)]
pub username: Option<String>,
pub password: Option<String>,
pub registered_aor: Option<String>,
@@ -51,6 +53,10 @@ pub struct SipLegConfig {
/// A SIP leg with full dialog management.
pub struct SipLeg {
// Leg identity is tracked via the enclosing LegInfo's key in the call's
// leg map; SipLeg itself never reads this field back. Kept to preserve
// the (id, config) constructor shape used by the call manager.
#[allow(dead_code)]
pub id: String,
pub state: LegState,
pub config: SipLegConfig,
@@ -411,11 +417,6 @@ impl SipLeg {
dialog.terminate();
Some(msg.serialize())
}
/// Get the SIP Call-ID for routing.
pub fn sip_call_id(&self) -> Option<&str> {
self.dialog.as_ref().map(|d| d.call_id.as_str())
}
}
/// Actions produced by the SipLeg message handler.

View File

@@ -27,22 +27,6 @@ impl SipTransport {
self.socket.clone()
}
/// Send a raw SIP message to a destination.
pub async fn send_to(&self, data: &[u8], dest: SocketAddr) -> Result<usize, String> {
self.socket
.send_to(data, dest)
.await
.map_err(|e| format!("send to {dest}: {e}"))
}
/// Send a raw SIP message to an address:port pair.
pub async fn send_to_addr(&self, data: &[u8], addr: &str, port: u16) -> Result<usize, String> {
let dest: SocketAddr = format!("{addr}:{port}")
.parse()
.map_err(|e| format!("bad address {addr}:{port}: {e}"))?;
self.send_to(data, dest).await
}
/// Spawn the UDP receive loop. Calls the handler for every received packet.
pub fn spawn_receiver<F>(
&self,

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.20.1',
version: '1.20.3',
description: 'undefined'
}

View File

@@ -8,6 +8,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { IVoiceboxConfig } from './voicebox.js';
// ---------------------------------------------------------------------------
// Shared types (previously in ts/sip/types.ts, now inlined)
@@ -160,24 +161,13 @@ export interface IContact {
// Voicebox configuration
// ---------------------------------------------------------------------------
export interface IVoiceboxConfig {
/** Unique ID — typically matches device ID or extension. */
id: string;
/** Whether this voicebox is active. */
enabled: boolean;
/** Custom TTS greeting text. */
greetingText?: string;
/** TTS voice ID (default 'af_bella'). */
greetingVoice?: string;
/** Path to uploaded WAV greeting (overrides TTS). */
greetingWavPath?: string;
/** Seconds to wait before routing to voicemail (default 25). */
noAnswerTimeoutSec?: number;
/** Maximum recording duration in seconds (default 120). */
maxRecordingSec?: number;
/** Maximum stored messages per box (default 50). */
maxMessages?: number;
}
// Canonical definition lives in voicebox.ts (imported at the top of this
// file) — re-exported here so consumers can import everything from a
// single config module without pulling in the voicebox implementation.
// This used to be a duplicated interface and caused
// "number | undefined is not assignable to number" type errors when
// passing config.voiceboxes into VoiceboxManager.init().
export type { IVoiceboxConfig };
// ---------------------------------------------------------------------------
// IVR configuration

View File

@@ -41,6 +41,14 @@ type TProxyCommands = {
params: { call_id: string };
result: { file_path: string; duration_ms: number };
};
add_leg: {
params: { call_id: string; number: string; provider_id?: string };
result: { leg_id: string };
};
remove_leg: {
params: { call_id: string; leg_id: string };
result: Record<string, never>;
};
add_device_leg: {
params: { call_id: string; device_id: string };
result: { leg_id: string };
@@ -83,6 +91,34 @@ type TProxyCommands = {
params: { model: string; voices: string; voice: string; text: string; output: string };
result: { output: string };
};
// WebRTC signaling — bridged from the browser via the TS control plane.
webrtc_offer: {
params: { session_id: string; sdp: string };
result: { sdp: string };
};
webrtc_ice: {
params: {
session_id: string;
candidate: string;
sdp_mid?: string;
sdp_mline_index?: number;
};
result: Record<string, never>;
};
webrtc_link: {
params: {
session_id: string;
call_id: string;
provider_media_addr: string;
provider_media_port: number;
sip_pt?: number;
};
result: Record<string, never>;
};
webrtc_close: {
params: { session_id: string };
result: Record<string, never>;
};
};
// ---------------------------------------------------------------------------
@@ -94,6 +130,11 @@ export interface IIncomingCallEvent {
from_uri: string;
to_number: string;
provider_id: string;
/** Whether registered browsers should see a `webrtc-incoming` toast for
* this call. Set by the Rust engine from the matched inbound route's
* `ringBrowsers` flag (defaults to `true` when no route matches, so
* deployments without explicit routes preserve pre-routing behavior). */
ring_browsers?: boolean;
}
export interface IOutboundCallEvent {
@@ -517,7 +558,7 @@ export async function sendProxyCommand<K extends keyof TProxyCommands>(
params: TProxyCommands[K]['params'],
): Promise<TProxyCommands[K]['result']> {
if (!bridge || !initialized) throw new Error('proxy engine not initialized');
return bridge.sendCommand(method as string, params as any) as any;
return bridge.sendCommand(method, params) as Promise<TProxyCommands[K]['result']>;
}
/** Shut down the proxy engine. */

View File

@@ -273,15 +273,23 @@ async function startProxyEngine(): Promise<void> {
legs: new Map(),
});
// Notify browsers of incoming call.
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId: bid,
});
// Notify browsers of the incoming call, but only if the matched inbound
// route asked for it. `ring_browsers !== false` preserves today's
// ring-by-default behavior for any Rust release that predates this
// field or for the fallback "no route matched" case (where Rust still
// sends `true`). Note: this is an informational toast — browsers do
// NOT race the SIP device to answer. First-to-answer-wins requires
// a multi-leg fork which is not yet implemented.
if (data.ring_browsers !== false) {
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId: bid,
});
}
}
});
@@ -493,7 +501,7 @@ async function startProxyEngine(): Promise<void> {
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', {
voiceboxManager.addMessage('default', {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,

View File

@@ -29,12 +29,14 @@ export interface IVoiceboxConfig {
greetingVoice?: string;
/** Path to uploaded WAV greeting (overrides TTS). */
greetingWavPath?: string;
/** Seconds to wait before routing to voicemail (default 25). */
noAnswerTimeoutSec: number;
/** Maximum recording duration in seconds (default 120). */
maxRecordingSec: number;
/** Maximum stored messages per box (default 50). */
maxMessages: number;
/** Seconds to wait before routing to voicemail. Defaults to 25 when
* absent — both the config loader and `VoiceboxManager.init` apply
* the default via `??=`. */
noAnswerTimeoutSec?: number;
/** Maximum recording duration in seconds. Defaults to 120. */
maxRecordingSec?: number;
/** Maximum stored messages per box. Defaults to 50. */
maxMessages?: number;
}
export interface IVoicemailMessage {
@@ -148,6 +150,35 @@ export class VoiceboxManager {
// Message CRUD
// -------------------------------------------------------------------------
/**
* Convenience wrapper around `saveMessage` — used by the `recording_done`
* event handler, which has a raw recording path + caller info and needs
* to persist metadata. Generates `id`, sets `timestamp = now`, defaults
* `heard = false`, and normalizes `fileName` to a basename (the WAV is
* expected to already live in the box's directory).
*/
addMessage(
boxId: string,
info: {
callerNumber: string;
callerName?: string | null;
fileName: string;
durationMs: number;
},
): void {
const msg: IVoicemailMessage = {
id: crypto.randomUUID(),
boxId,
callerNumber: info.callerNumber,
callerName: info.callerName ?? undefined,
timestamp: Date.now(),
durationMs: info.durationMs,
fileName: path.basename(info.fileName),
heard: false,
};
this.saveMessage(msg);
}
/**
* Save a new voicemail message.
* The WAV file should already exist at the expected path.

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.20.1',
version: '1.20.3',
description: 'undefined'
}