Files
siprouter/CLAUDE.md

104 lines
7.4 KiB
Markdown
Raw Normal View History

# Project Notes
## Architecture: Hub Model in Rust (Call as Centerpiece)
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.
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).
### Key Rust files (`rust/crates/proxy-engine/src/`)
- `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
### Key TS files (control plane)
- `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)
### Rust SIP protocol library
`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.
## Event-push architecture for device status
Device status flows **via push events**, not pull-based IPC queries:
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
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.
## 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