Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cff70ab179 | |||
| 51f7560730 | |||
| 5a280c5c41 | |||
| 59d8c2557c | |||
| cfadd7a2b6 | |||
| 80f710f6d8 | |||
| 9ea57cd659 | |||
| c40c726dc3 | |||
| 37ba7501fa | |||
| 24924a1aea | |||
| 7ed76a9488 | |||
| a9fdfe5733 | |||
| 6fcdf4291a | |||
| 81441e7853 | |||
| 21ffc1d017 | |||
| 2f16c5efae | |||
| 254d7f3633 | |||
| 67537664df | |||
| 54129dcdae | |||
| 8c6556dae3 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
.nogit/
|
||||
nogit/
|
||||
.git/
|
||||
.playwright-mcp/
|
||||
.vscode/
|
||||
test/
|
||||
dist_rust/
|
||||
dist_ts_web/
|
||||
rust/target/
|
||||
sip_trace.log
|
||||
sip_trace_*.log
|
||||
proxy.out
|
||||
proxy_v2.out
|
||||
*.pid
|
||||
.server.pid
|
||||
32
.gitea/workflows/docker_tags.yaml
Normal file
32
.gitea/workflows/docker_tags.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Docker (tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:dbase_dind
|
||||
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
|
||||
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @git.zone/tsdocker
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
tsdocker login
|
||||
tsdocker build
|
||||
tsdocker push
|
||||
@@ -8,5 +8,16 @@
|
||||
"production": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tsrust": {
|
||||
"targets": ["linux_amd64", "linux_arm64"]
|
||||
},
|
||||
"@git.zone/tsdocker": {
|
||||
"registries": ["code.foss.global"],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/siprouter",
|
||||
"dockerregistry.lossless.digital": "serve.zone/siprouter"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
}
|
||||
|
||||
114
CLAUDE.md
114
CLAUDE.md
@@ -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
|
||||
|
||||
74
Dockerfile
Normal file
74
Dockerfile
Normal file
@@ -0,0 +1,74 @@
|
||||
# gitzone dockerfile_service
|
||||
## STAGE 1 // BUILD
|
||||
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||
|
||||
# System build tools that the Rust dep tree needs beyond the base image:
|
||||
# - cmake : used by the `cmake` crate (transitive via ort_sys / a webrtc
|
||||
# sub-crate) to build a C/C++ library from source when a
|
||||
# prebuilt-binary download path doesn't apply.
|
||||
# - pkg-config : used by audiopus_sys and other *-sys crates to locate libs
|
||||
# on the native target (safe no-op if they vendor their own).
|
||||
# These are normally pre-installed on dev machines but not in ht-docker-node:lts.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cmake \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# buildx sets TARGETARCH automatically for each platform it's building:
|
||||
# linux/amd64 -> TARGETARCH=amd64
|
||||
# linux/arm64 -> TARGETARCH=arm64
|
||||
# We use it to tell tsrust to build ONLY the current container's arch. This
|
||||
# overrides the `@git.zone/tsrust.targets` list in .smartconfig.json, which is
|
||||
# right for local dev / CI (where you want both binaries) but wrong for per-
|
||||
# platform Docker stages (each stage would then also try to cross-compile to
|
||||
# the OTHER arch — which fails in the arm64 stage because no reverse cross-
|
||||
# toolchain is installed).
|
||||
#
|
||||
# With --target set, tsrust builds a single target natively within whichever
|
||||
# platform this stage is running under (native on amd64, QEMU-emulated on arm64).
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY ./ /app
|
||||
WORKDIR /app
|
||||
RUN pnpm config set store-dir .pnpm-store
|
||||
RUN rm -rf node_modules && pnpm install
|
||||
|
||||
# tsrust --target takes precedence over .smartconfig.json's targets array.
|
||||
# Writes dist_rust/proxy-engine_linux_amd64 or dist_rust/proxy-engine_linux_arm64.
|
||||
# The TS layer (ts/proxybridge.ts buildLocalPaths) picks the right one at runtime
|
||||
# via process.arch.
|
||||
RUN pnpm exec tsrust --target linux_${TARGETARCH}
|
||||
|
||||
# Web bundle (esbuild — pure JS, uses the platform's native esbuild binary
|
||||
# installed by pnpm above, so no cross-bundling concerns).
|
||||
RUN pnpm run bundle
|
||||
|
||||
# Drop pnpm store to keep the image smaller. node_modules stays because the
|
||||
# runtime entrypoint is tsx and siprouter has no separate dist_ts/ to run from.
|
||||
RUN rm -rf .pnpm-store
|
||||
|
||||
## STAGE 2 // PRODUCTION
|
||||
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
|
||||
|
||||
# gcompat + libstdc++ let the glibc-linked proxy-engine binary run on Alpine.
|
||||
RUN apk add --no-cache gcompat libstdc++
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
|
||||
ENV SIPROUTER_MODE=OCI_CONTAINER
|
||||
ENV NODE_ENV=production
|
||||
|
||||
LABEL org.opencontainers.image.title="siprouter" \
|
||||
org.opencontainers.image.description="SIP proxy with Rust data plane and WebRTC bridge" \
|
||||
org.opencontainers.image.source="https://code.foss.global/serve.zone/siprouter"
|
||||
|
||||
# 5070 SIP signaling (UDP+TCP)
|
||||
# 5061 SIP-TLS (optional, UDP+TCP)
|
||||
# 3060 Web UI / WebSocket (HTTP or HTTPS, auto-detected from .nogit/cert.pem)
|
||||
# 20000-20200/udp RTP media range (must match config.proxy.rtpPortRange)
|
||||
EXPOSE 5070/udp 5070/tcp 5061/udp 5061/tcp 3060/tcp 20000-20200/udp
|
||||
|
||||
# exec replaces sh as PID 1 with tsx, so SIGINT/SIGTERM reach Node and
|
||||
# ts/sipproxy.ts' shutdown handler (which calls shutdownProxyEngine) runs cleanly.
|
||||
CMD ["sh", "-c", "exec ./node_modules/.bin/tsx ts/sipproxy.ts"]
|
||||
73
changelog.md
73
changelog.md
@@ -1,5 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-14 - 1.23.0 - feat(runtime)
|
||||
refactor runtime state and proxy event handling for typed WebRTC linking and shared status models
|
||||
|
||||
- extract proxy event handling into dedicated runtime modules for status tracking and WebRTC session-to-call linking
|
||||
- introduce shared typed proxy event and status interfaces used by both backend and web UI
|
||||
- update web UI server initialization to use structured options and await async config save hooks
|
||||
- simplify browser signaling by routing WebRTC offer/ICE handling through frontend-to-Rust integration
|
||||
- align device status rendering with the new address/port fields in dashboard views
|
||||
|
||||
## 2026-04-12 - 1.22.0 - feat(proxy-engine)
|
||||
add on-demand TTS caching for voicemail and IVR prompts
|
||||
|
||||
- Route inbound calls directly to configured IVR menus and track them with a dedicated IVR call state
|
||||
- Generate voicemail greetings and IVR menu prompts inside the Rust proxy engine on demand instead of precomputing prompts in TypeScript
|
||||
- Add cacheable TTS output with sidecar metadata and enable Kokoro CMUdict support for improved prompt generation
|
||||
- Extend proxy configuration to include voiceboxes and IVR menus, and update documentation to reflect Kokoro-only prompt generation
|
||||
|
||||
## 2026-04-11 - 1.21.0 - feat(providers)
|
||||
replace provider creation modal with a guided multi-step setup flow
|
||||
|
||||
- Adds a stepper-based provider creation flow with provider type selection, connection, credentials, advanced settings, and review steps.
|
||||
- Applies built-in templates for Sipgate and O2/Alice from the selected provider type instead of separate add actions.
|
||||
- Adds a final review step with generated provider ID preview and duplicate ID collision handling before saving.
|
||||
|
||||
## 2026-04-11 - 1.20.5 - fix(readme)
|
||||
improve architecture and call flow documentation with Mermaid diagrams
|
||||
|
||||
- Replace ASCII architecture and audio pipeline diagrams with Mermaid diagrams for better readability
|
||||
- Document the WebRTC browser call setup sequence, including offer handling and session-to-call linking
|
||||
|
||||
## 2026-04-11 - 1.20.4 - fix(deps)
|
||||
bump @design.estate/dees-catalog to ^3.71.1
|
||||
|
||||
- Updates the @design.estate/dees-catalog dependency from ^3.70.0 to ^3.71.1 in package.json.
|
||||
|
||||
## 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
|
||||
|
||||
- Add cmake and pkg-config to the Docker build stage so Rust native dependencies can compile successfully in the container
|
||||
- Document why these tools are needed for transitive Rust crates that build or detect native libraries
|
||||
|
||||
## 2026-04-11 - 1.20.0 - feat(docker)
|
||||
add multi-arch Docker build and tagged release pipeline
|
||||
|
||||
- Add a production Dockerfile for building and running the SIP router with the Rust proxy engine and web bundle
|
||||
- Configure tsdocker and tsrust for linux/amd64 and linux/arm64 image builds and registry mapping
|
||||
- Add a tag-triggered Gitea workflow to build and push Docker images
|
||||
- Update runtime binary resolution to load architecture-specific Rust artifacts in Docker and CI environments
|
||||
- Add Docker-related package scripts, dependency updates, and ignore rules for container builds
|
||||
|
||||
## 2026-04-11 - 1.19.2 - fix(web-ui)
|
||||
normalize lucide icon names across SIP proxy views
|
||||
|
||||
- Updates icon identifiers to the expected PascalCase lucide format in app navigation, calls, IVR, overview, providers, and voicemail views.
|
||||
- Fixes UI icon rendering for stats cards and action menus such as transfer, delete, status, and call direction indicators.
|
||||
|
||||
## 2026-04-10 - 1.19.1 - fix(readme)
|
||||
refresh documentation for jitter buffering, voicemail, and WebSocket signaling details
|
||||
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
{
|
||||
"name": "siprouter",
|
||||
"version": "1.19.1",
|
||||
"version": "1.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"bundle": "node node_modules/.pnpm/esbuild@0.27.7/node_modules/esbuild/bin/esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
||||
"buildRust": "tsrust",
|
||||
"build": "pnpm run buildRust && pnpm run bundle",
|
||||
"build:docker": "tsdocker build --verbose",
|
||||
"release:docker": "tsdocker push --verbose",
|
||||
"start": "tsx ts/sipproxy.ts",
|
||||
"restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out"
|
||||
},
|
||||
"dependencies": {
|
||||
"@design.estate/dees-catalog": "^3.70.0",
|
||||
"@design.estate/dees-catalog": "^3.77.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsdocker": "^2.2.4",
|
||||
"@git.zone/tsrust": "^1.3.2",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/ws": "^8.18.1"
|
||||
|
||||
686
pnpm-lock.yaml
generated
686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
109
readme.md
109
readme.md
@@ -20,7 +20,7 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
|
||||
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60–120ms), Opus PLC for lost packets, and hold/resume detection
|
||||
- 📧 **Voicemail** — Configurable voicemail boxes with TTS greetings, recording, and web playback
|
||||
- 🔢 **IVR Menus** — DTMF-navigable interactive voice response with nested menus, routing actions, and custom prompts
|
||||
- 🗣️ **Neural TTS** — Kokoro-powered announcements and greetings with 25+ voice presets, backed by espeak-ng fallback
|
||||
- 🗣️ **Neural TTS** — Kokoro-powered greetings and IVR prompts with 25+ voice presets
|
||||
- 🎙️ **Call Recording** — Per-source separated WAV recording at 48kHz via tool legs
|
||||
- 🖥️ **Web Dashboard** — Real-time SPA with 9 views: live calls, browser phone, routing, voicemail, IVR, contacts, providers, and streaming logs
|
||||
|
||||
@@ -28,39 +28,26 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Browser Softphone │
|
||||
│ (WebRTC via WebSocket signaling) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│ Opus/WebRTC
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ siprouter │
|
||||
│ │
|
||||
│ TypeScript Control Plane │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Config · WebRTC Signaling │ │
|
||||
│ │ REST API · Web Dashboard │ │
|
||||
│ │ Voicebox Manager · TTS Cache │ │
|
||||
│ └────────────┬───────────────────┘ │
|
||||
│ JSON-over-stdio IPC │
|
||||
│ ┌────────────┴───────────────────┐ │
|
||||
│ │ Rust proxy-engine (data plane) │ │
|
||||
│ │ │ │
|
||||
│ │ SIP Stack · Dialog SM · Auth │ │
|
||||
│ │ Call Manager · N-Leg Mixer │ │
|
||||
│ │ 48kHz f32 Bus · Jitter Buffer │ │
|
||||
│ │ Codec Engine · RTP Port Pool │ │
|
||||
│ │ WebRTC Engine · Kokoro TTS │ │
|
||||
│ │ Voicemail · IVR · Recording │ │
|
||||
│ └────┬──────────────────┬────────┘ │
|
||||
└───────┤──────────────────┤───────────┘
|
||||
│ │
|
||||
┌──────┴──────┐ ┌──────┴──────┐
|
||||
│ SIP Devices │ │ SIP Trunk │
|
||||
│ (HT801 etc) │ │ Providers │
|
||||
└─────────────┘ └─────────────┘
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Browser["🌐 Browser Softphone<br/>(WebRTC via WebSocket signaling)"]
|
||||
Devices["📞 SIP Devices<br/>(HT801, desk phones, ATAs)"]
|
||||
Trunks["☎️ SIP Trunk Providers<br/>(sipgate, easybell, …)"]
|
||||
|
||||
subgraph Router["siprouter"]
|
||||
direction TB
|
||||
subgraph TS["TypeScript Control Plane"]
|
||||
TSBits["Config · WebRTC Signaling<br/>REST API · Web Dashboard<br/>Voicebox Manager · TTS Cache"]
|
||||
end
|
||||
subgraph Rust["Rust proxy-engine (data plane)"]
|
||||
RustBits["SIP Stack · Dialog SM · Auth<br/>Call Manager · N-Leg Mixer<br/>48kHz f32 Bus · Jitter Buffer<br/>Codec Engine · RTP Port Pool<br/>WebRTC Engine · Kokoro TTS<br/>Voicemail · IVR · Recording"]
|
||||
end
|
||||
TS <-->|"JSON-over-stdio IPC"| Rust
|
||||
end
|
||||
|
||||
Browser <-->|"Opus / WebRTC"| TS
|
||||
Rust <-->|"SIP / RTP"| Devices
|
||||
Rust <-->|"SIP / RTP"| Trunks
|
||||
```
|
||||
|
||||
### 🧠 Key Design Decisions
|
||||
@@ -71,6 +58,37 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
|
||||
- **Per-Session Codec Isolation** — Each call leg gets its own encoder/decoder/resampler/denoiser state — no cross-call corruption.
|
||||
- **SDP Codec Negotiation** — Outbound encoding uses the codec actually negotiated in SDP answers, not just the first offered codec.
|
||||
|
||||
### 📲 WebRTC Browser Call Flow
|
||||
|
||||
Browser calls are set up in a strict three-step dance — the WebRTC leg cannot be attached at call-creation time because the browser's session ID is only known once the SDP offer arrives:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant TS as TypeScript (sipproxy.ts)
|
||||
participant R as Rust proxy-engine
|
||||
participant P as SIP Provider
|
||||
|
||||
B->>TS: POST /api/call
|
||||
TS->>R: make_call (pending call, no WebRTC leg yet)
|
||||
R-->>TS: call_created
|
||||
TS-->>B: webrtc-incoming (callId)
|
||||
|
||||
B->>TS: webrtc-offer (sessionId, SDP)
|
||||
TS->>R: handle_webrtc_offer
|
||||
R-->>TS: webrtc-answer (SDP)
|
||||
TS-->>B: webrtc-answer
|
||||
Note over R: Standalone WebRTC session<br/>(not yet attached to call)
|
||||
|
||||
B->>TS: webrtc_link (callId + sessionId)
|
||||
TS->>R: link session → call
|
||||
R->>R: wire WebRTC leg through mixer
|
||||
R->>P: SIP INVITE
|
||||
P-->>R: 200 OK + SDP
|
||||
R-->>TS: call_answered
|
||||
Note over B,P: Bidirectional Opus ↔ codec-transcoded<br/>audio flows through the mixer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
@@ -80,7 +98,6 @@ siprouter sits between your SIP trunk providers and your endpoints — hardware
|
||||
- **Node.js** ≥ 20 with `tsx` globally available
|
||||
- **pnpm** for package management
|
||||
- **Rust** toolchain (for building the proxy engine)
|
||||
- **espeak-ng** (optional, for TTS fallback)
|
||||
|
||||
### Install & Build
|
||||
|
||||
@@ -172,7 +189,7 @@ Create `.nogit/config.json`:
|
||||
|
||||
### TTS Setup (Optional)
|
||||
|
||||
For neural announcements and voicemail greetings, download the Kokoro TTS model:
|
||||
For neural voicemail greetings and IVR prompts, download the Kokoro TTS model:
|
||||
|
||||
```bash
|
||||
mkdir -p .nogit/tts
|
||||
@@ -182,7 +199,7 @@ curl -L -o .nogit/tts/voices.bin \
|
||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
|
||||
```
|
||||
|
||||
Without the model files, TTS falls back to `espeak-ng`. Without either, announcements are skipped — everything else works fine.
|
||||
Without the model files, TTS prompts (IVR menus, voicemail greetings) are skipped — everything else works fine.
|
||||
|
||||
### Run
|
||||
|
||||
@@ -209,7 +226,6 @@ siprouter/
|
||||
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
||||
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
||||
│ ├── registrar.ts # Browser softphone registration
|
||||
│ ├── announcement.ts # TTS announcement generator (espeak-ng / Kokoro)
|
||||
│ ├── voicebox.ts # Voicemail box management
|
||||
│ └── call/
|
||||
│ └── prompt-cache.ts # Named audio prompt WAV management
|
||||
@@ -246,9 +262,17 @@ The `proxy-engine` binary handles all real-time audio processing with a **48kHz
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
```
|
||||
Inbound: Wire RTP → Jitter Buffer → Decode → Resample to 48kHz → Denoise (RNNoise) → Mix Bus
|
||||
Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire RTP
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Inbound["Inbound path (per leg)"]
|
||||
direction LR
|
||||
IN_RTP["Wire RTP"] --> IN_JB["Jitter Buffer"] --> IN_DEC["Decode"] --> IN_RS["Resample → 48 kHz"] --> IN_DN["Denoise (RNNoise)"] --> IN_BUS["Mix Bus"]
|
||||
end
|
||||
|
||||
subgraph Outbound["Outbound path (per leg)"]
|
||||
direction LR
|
||||
OUT_BUS["Mix Bus"] --> OUT_MM["Mix-Minus"] --> OUT_RS["Resample → codec rate"] --> OUT_ENC["Encode"] --> OUT_RTP["Wire RTP"]
|
||||
end
|
||||
```
|
||||
|
||||
- **Adaptive jitter buffer** — per-leg `BTreeMap`-based buffer keyed by RTP sequence number. Delivers exactly one frame per 20ms mixer tick in sequence order. Adaptive target depth starts at 3 frames (60ms) and adjusts between 2–6 frames based on observed network jitter. Handles hold/resume by detecting large forward sequence jumps and resetting cleanly.
|
||||
@@ -262,13 +286,12 @@ Outbound: Mix Bus → Mix-Minus → Resample to codec rate → Encode → Wire
|
||||
|
||||
## 🗣️ Neural TTS
|
||||
|
||||
Announcements and voicemail greetings are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process:
|
||||
Voicemail greetings and IVR prompts are synthesized using [Kokoro TTS](https://github.com/mzdk100/kokoro) — an 82M parameter neural model running via ONNX Runtime directly in the Rust process:
|
||||
|
||||
- **24 kHz, 16-bit mono** output
|
||||
- **25+ voice presets** — American/British, male/female (e.g., `af_bella`, `am_adam`, `bf_emma`, `bm_george`)
|
||||
- **~800ms** synthesis time for a 3-second phrase
|
||||
- Lazy-loaded on first use — no startup cost if TTS is unused
|
||||
- Falls back to `espeak-ng` if the ONNX model is not available
|
||||
|
||||
---
|
||||
|
||||
|
||||
30
rust/.cargo/config.toml
Normal file
30
rust/.cargo/config.toml
Normal 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"
|
||||
1
rust/.cargo/crosslibs/aarch64/libstdc++.so
Symbolic link
1
rust/.cargo/crosslibs/aarch64/libstdc++.so
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/aarch64-linux-gnu/lib/libstdc++.so.6
|
||||
10
rust/Cargo.lock
generated
10
rust/Cargo.lock
generated
@@ -532,6 +532,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmudict-fast"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9f73004e928ed46c3e7fd7406d2b12c8674153295f08af084b49860276dc02"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codec-lib"
|
||||
version = "0.1.0"
|
||||
@@ -1730,6 +1739,7 @@ dependencies = [
|
||||
"bincode 2.0.1",
|
||||
"cc",
|
||||
"chinese-number",
|
||||
"cmudict-fast",
|
||||
"futures",
|
||||
"jieba-rs",
|
||||
"log",
|
||||
|
||||
@@ -19,7 +19,7 @@ regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
hound = "3.5"
|
||||
kokoro-tts = { version = "0.3", default-features = false }
|
||||
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
|
||||
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
||||
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||
"tls-native-vendored"
|
||||
|
||||
@@ -23,16 +23,22 @@ pub enum CallState {
|
||||
Ringing,
|
||||
Connected,
|
||||
Voicemail,
|
||||
Ivr,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
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",
|
||||
Self::Ringing => "ringing",
|
||||
Self::Connected => "connected",
|
||||
Self::Voicemail => "voicemail",
|
||||
Self::Ivr => "ivr",
|
||||
Self::Terminated => "terminated",
|
||||
}
|
||||
}
|
||||
@@ -45,6 +51,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 +67,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 +120,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 +148,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 +242,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,24 @@ use crate::mixer::spawn_mixer;
|
||||
use crate::registrar::Registrar;
|
||||
use crate::rtp::RtpPortPool;
|
||||
use crate::sip_leg::{SipLeg, SipLegAction, SipLegConfig};
|
||||
use crate::tts::TtsEngine;
|
||||
use sip_proto::helpers::{build_sdp, generate_call_id, generate_tag, parse_sdp_endpoint, SdpOptions};
|
||||
use sip_proto::message::{ResponseOptions, SipMessage};
|
||||
use sip_proto::rewrite::{rewrite_sdp, rewrite_sip_uri};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// 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.
|
||||
@@ -94,26 +105,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 +417,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 +429,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 +530,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 +546,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 +559,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 +684,8 @@ impl CallManager {
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
) -> Option<String> {
|
||||
tts_engine: Arc<Mutex<TtsEngine>>,
|
||||
) -> 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 +702,65 @@ 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.
|
||||
let route = config.resolve_inbound_route(provider_id, &called_number, &caller_number);
|
||||
let ring_browsers = route.ring_browsers;
|
||||
|
||||
// IVR routing: if the route targets an IVR menu, go there directly.
|
||||
if let Some(ref ivr_menu_id) = route.ivr_menu_id {
|
||||
if let Some(ivr) = &config.ivr {
|
||||
if ivr.enabled {
|
||||
if let Some(menu) = ivr.menus.iter().find(|m| m.id == *ivr_menu_id) {
|
||||
let call_id = self
|
||||
.route_to_ivr(
|
||||
&call_id, invite, from_addr, &caller_number,
|
||||
provider_id, provider_config, config, rtp_pool, socket,
|
||||
public_ip, menu, &tts_engine,
|
||||
)
|
||||
.await?;
|
||||
return Some(InboundCallCreated { call_id, 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
|
||||
// Resolve greeting WAV on-demand (may trigger TTS generation).
|
||||
let greeting_wav = resolve_greeting_wav(
|
||||
config,
|
||||
route.voicemail_box.as_deref(),
|
||||
&tts_engine,
|
||||
).await;
|
||||
let call_id = self
|
||||
.route_to_voicemail(
|
||||
&call_id, invite, from_addr, &caller_number,
|
||||
provider_id, provider_config, config, rtp_pool, socket, public_ip,
|
||||
greeting_wav,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
return Some(InboundCallCreated { call_id, ring_browsers });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -781,6 +824,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 +845,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 +889,7 @@ impl CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
Some(call_id)
|
||||
Some(InboundCallCreated { call_id, ring_browsers })
|
||||
}
|
||||
|
||||
/// Initiate an outbound B2BUA call from the dashboard.
|
||||
@@ -920,6 +965,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 +1076,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 +1123,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 +1162,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 +1199,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 +1231,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 +1270,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(),
|
||||
@@ -1514,6 +1564,7 @@ impl CallManager {
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
greeting_wav: Option<String>,
|
||||
) -> Option<String> {
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
@@ -1581,6 +1632,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(),
|
||||
@@ -1607,8 +1659,6 @@ impl CallManager {
|
||||
.as_millis();
|
||||
let recording_dir = "nogit/voicemail/default".to_string();
|
||||
let recording_path = format!("{recording_dir}/msg-{timestamp}.wav");
|
||||
let greeting_wav = find_greeting_wav();
|
||||
|
||||
let out_tx = self.out_tx.clone();
|
||||
let call_id_owned = call_id.to_string();
|
||||
let caller_owned = caller_number.to_string();
|
||||
@@ -1625,6 +1675,211 @@ impl CallManager {
|
||||
Some(call_id.to_string())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IVR routing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn route_to_ivr(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
invite: &SipMessage,
|
||||
from_addr: SocketAddr,
|
||||
caller_number: &str,
|
||||
provider_id: &str,
|
||||
provider_config: &ProviderConfig,
|
||||
config: &AppConfig,
|
||||
rtp_pool: &mut RtpPortPool,
|
||||
socket: &UdpSocket,
|
||||
public_ip: Option<&str>,
|
||||
menu: &crate::config::IvrMenuConfig,
|
||||
tts_engine: &Arc<Mutex<TtsEngine>>,
|
||||
) -> Option<String> {
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
|
||||
let rtp_alloc = match rtp_pool.allocate().await {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let resp = SipMessage::create_response(503, "Service Unavailable", invite, None);
|
||||
let _ = socket.send_to(&resp.serialize(), from_addr).await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9);
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
|
||||
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
|
||||
ip: pub_ip,
|
||||
port: rtp_alloc.port,
|
||||
payload_types: &provider_config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let response = SipMessage::create_response(
|
||||
200, "OK", invite,
|
||||
Some(sip_proto::message::ResponseOptions {
|
||||
to_tag: Some(sip_proto::helpers::generate_tag()),
|
||||
contact: Some(format!("<sip:{}:{}>", lan_ip, config.proxy.lan_port)),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let _ = socket.send_to(&response.serialize(), from_addr).await;
|
||||
|
||||
let provider_media = if invite.has_sdp_body() {
|
||||
parse_sdp_endpoint(&invite.body)
|
||||
.and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok())
|
||||
} else {
|
||||
Some(from_addr)
|
||||
};
|
||||
let provider_media = provider_media.unwrap_or(from_addr);
|
||||
|
||||
// Create call with IVR state.
|
||||
let (mixer_cmd_tx, mixer_task) = spawn_mixer(call_id.to_string(), self.out_tx.clone());
|
||||
let mut call = Call::new(
|
||||
call_id.to_string(),
|
||||
CallDirection::Inbound,
|
||||
provider_id.to_string(),
|
||||
mixer_cmd_tx.clone(),
|
||||
mixer_task,
|
||||
);
|
||||
call.state = CallState::Ivr;
|
||||
call.caller_number = Some(caller_number.to_string());
|
||||
|
||||
let provider_leg_id = format!("{call_id}-prov");
|
||||
call.legs.insert(
|
||||
provider_leg_id.clone(),
|
||||
LegInfo {
|
||||
id: provider_leg_id.clone(),
|
||||
kind: LegKind::SipProvider,
|
||||
state: LegState::Connected,
|
||||
codec_pt,
|
||||
sip_leg: None,
|
||||
sip_call_id: Some(invite.call_id().to_string()),
|
||||
webrtc_session_id: None,
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
rtp_port: rtp_alloc.port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
remote_media: Some(provider_media),
|
||||
signaling_addr: Some(from_addr),
|
||||
metadata: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
self.sip_index.insert(
|
||||
invite.call_id().to_string(),
|
||||
(call_id.to_string(), provider_leg_id.clone()),
|
||||
);
|
||||
self.calls.insert(call_id.to_string(), call);
|
||||
|
||||
// Emit leg_added for the provider leg.
|
||||
if let Some(call) = self.calls.get(call_id) {
|
||||
for leg in call.legs.values() {
|
||||
emit_leg_added_event(&self.out_tx, call_id, leg);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate IVR prompt on-demand via TTS (cached).
|
||||
let voice = menu.prompt_voice.as_deref().unwrap_or("af_bella");
|
||||
let prompt_output = format!(".nogit/tts/ivr-menu-{}.wav", menu.id);
|
||||
let prompt_params = serde_json::json!({
|
||||
"model": ".nogit/tts/kokoro-v1.0.onnx",
|
||||
"voices": ".nogit/tts/voices.bin",
|
||||
"voice": voice,
|
||||
"text": &menu.prompt_text,
|
||||
"output": &prompt_output,
|
||||
"cacheable": true,
|
||||
});
|
||||
|
||||
let prompt_wav = {
|
||||
let mut tts = tts_engine.lock().await;
|
||||
match tts.generate(&prompt_params).await {
|
||||
Ok(_) => Some(prompt_output),
|
||||
Err(e) => {
|
||||
eprintln!("[ivr] TTS generation failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load prompt and run interaction via the mixer.
|
||||
let out_tx = self.out_tx.clone();
|
||||
let call_id_owned = call_id.to_string();
|
||||
let expected_digits: Vec<char> = menu
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.digit.chars().next())
|
||||
.collect();
|
||||
let timeout_ms = menu.timeout_sec.unwrap_or(5) * 1000;
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Load prompt PCM frames if available.
|
||||
let prompt_frames = prompt_wav.as_ref().and_then(|wav| {
|
||||
crate::audio_player::load_prompt_pcm_frames(wav).ok()
|
||||
});
|
||||
|
||||
if let Some(frames) = prompt_frames {
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
let _ = mixer_cmd_tx
|
||||
.send(crate::mixer::MixerCommand::StartInteraction {
|
||||
leg_id: provider_leg_id.clone(),
|
||||
prompt_pcm_frames: frames,
|
||||
expected_digits: expected_digits.clone(),
|
||||
timeout_ms,
|
||||
result_tx,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Wait for digit or timeout.
|
||||
let safety = tokio::time::Duration::from_millis(timeout_ms as u64 + 30000);
|
||||
let result = match tokio::time::timeout(safety, result_rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => crate::mixer::InteractionResult::Cancelled,
|
||||
Err(_) => crate::mixer::InteractionResult::Timeout,
|
||||
};
|
||||
|
||||
match &result {
|
||||
crate::mixer::InteractionResult::Digit(d) => {
|
||||
eprintln!("[ivr] caller pressed '{d}' on call {call_id_owned}");
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"ivr_digit",
|
||||
serde_json::json!({
|
||||
"call_id": call_id_owned,
|
||||
"digit": d.to_string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
crate::mixer::InteractionResult::Timeout => {
|
||||
eprintln!("[ivr] timeout on call {call_id_owned}");
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"ivr_timeout",
|
||||
serde_json::json!({ "call_id": call_id_owned }),
|
||||
);
|
||||
}
|
||||
crate::mixer::InteractionResult::Cancelled => {
|
||||
eprintln!("[ivr] cancelled on call {call_id_owned}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("[ivr] no prompt available for call {call_id_owned}, ending");
|
||||
emit_event(
|
||||
&out_tx,
|
||||
"ivr_error",
|
||||
serde_json::json!({
|
||||
"call_id": call_id_owned,
|
||||
"error": "no prompt available",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Some(call_id.to_string())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1639,13 +1894,56 @@ impl CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_greeting_wav() -> Option<String> {
|
||||
let candidates = [
|
||||
/// Resolve the greeting WAV for a voicemail box.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Pre-recorded WAV from voicebox config (`greetingWavPath`)
|
||||
/// 2. On-demand TTS generation from greeting text (cached via `cacheable: true`)
|
||||
/// 3. Legacy hardcoded paths (`.nogit/voicemail/default/greeting.wav`, etc.)
|
||||
/// 4. None — voicemail session plays beep only
|
||||
async fn resolve_greeting_wav(
|
||||
config: &AppConfig,
|
||||
voicebox_id: Option<&str>,
|
||||
tts_engine: &Arc<Mutex<TtsEngine>>,
|
||||
) -> Option<String> {
|
||||
// 1. Look up voicebox config.
|
||||
let vb = voicebox_id
|
||||
.and_then(|id| config.voiceboxes.iter().find(|v| v.id == id && v.enabled));
|
||||
|
||||
if let Some(vb) = vb {
|
||||
// 2. Pre-recorded WAV takes priority.
|
||||
if let Some(ref wav) = vb.greeting_wav_path {
|
||||
if Path::new(wav).exists() {
|
||||
return Some(wav.clone());
|
||||
}
|
||||
}
|
||||
// 3. TTS on-demand with caching.
|
||||
let text = vb.greeting_text.as_deref().unwrap_or(
|
||||
"The person you are trying to reach is not available. Please leave a message after the tone.",
|
||||
);
|
||||
let voice = vb.greeting_voice.as_deref().unwrap_or("af_bella");
|
||||
let output = format!(".nogit/tts/voicemail-greeting-{}.wav", vb.id);
|
||||
|
||||
let params = serde_json::json!({
|
||||
"model": ".nogit/tts/kokoro-v1.0.onnx",
|
||||
"voices": ".nogit/tts/voices.bin",
|
||||
"voice": voice,
|
||||
"text": text,
|
||||
"output": &output,
|
||||
"cacheable": true,
|
||||
});
|
||||
let mut tts = tts_engine.lock().await;
|
||||
if tts.generate(¶ms).await.is_ok() {
|
||||
return Some(output);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: legacy hardcoded paths.
|
||||
for path in &[
|
||||
".nogit/voicemail/default/greeting.wav",
|
||||
".nogit/voicemail/greeting.wav",
|
||||
];
|
||||
for path in &candidates {
|
||||
if std::path::Path::new(path).exists() {
|
||||
] {
|
||||
if Path::new(path).exists() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -141,6 +159,10 @@ pub struct AppConfig {
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
pub devices: Vec<DeviceConfig>,
|
||||
pub routing: RoutingConfig,
|
||||
#[serde(default)]
|
||||
pub voiceboxes: Vec<VoiceboxConfig>,
|
||||
#[serde(default)]
|
||||
pub ivr: Option<IvrConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -148,6 +170,59 @@ pub struct RoutingConfig {
|
||||
pub routes: Vec<Route>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voicebox config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct VoiceboxConfig {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "greetingText")]
|
||||
pub greeting_text: Option<String>,
|
||||
#[serde(rename = "greetingVoice")]
|
||||
pub greeting_voice: Option<String>,
|
||||
#[serde(rename = "greetingWavPath")]
|
||||
pub greeting_wav_path: Option<String>,
|
||||
#[serde(rename = "maxRecordingSec")]
|
||||
pub max_recording_sec: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IvrConfig {
|
||||
pub enabled: bool,
|
||||
pub menus: Vec<IvrMenuConfig>,
|
||||
#[serde(rename = "entryMenuId")]
|
||||
pub entry_menu_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IvrMenuConfig {
|
||||
pub id: String,
|
||||
#[serde(rename = "promptText")]
|
||||
pub prompt_text: String,
|
||||
#[serde(rename = "promptVoice")]
|
||||
pub prompt_voice: Option<String>,
|
||||
pub entries: Vec<IvrMenuEntry>,
|
||||
#[serde(rename = "timeoutSec")]
|
||||
pub timeout_sec: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IvrMenuEntry {
|
||||
pub digit: String,
|
||||
pub action: String,
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matching (ported from ts/config.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -192,10 +267,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ mod audio_player;
|
||||
mod call;
|
||||
mod call_manager;
|
||||
mod config;
|
||||
mod dtmf;
|
||||
mod ipc;
|
||||
mod jitter_buffer;
|
||||
mod leg_io;
|
||||
@@ -51,11 +50,12 @@ struct ProxyEngine {
|
||||
registrar: Registrar,
|
||||
call_mgr: CallManager,
|
||||
rtp_pool: Option<RtpPortPool>,
|
||||
tts_engine: Arc<Mutex<tts::TtsEngine>>,
|
||||
out_tx: OutTx,
|
||||
}
|
||||
|
||||
impl ProxyEngine {
|
||||
fn new(out_tx: OutTx) -> Self {
|
||||
fn new(out_tx: OutTx, tts_engine: Arc<Mutex<tts::TtsEngine>>) -> Self {
|
||||
Self {
|
||||
config: None,
|
||||
transport: None,
|
||||
@@ -63,6 +63,7 @@ impl ProxyEngine {
|
||||
registrar: Registrar::new(out_tx.clone()),
|
||||
call_mgr: CallManager::new(out_tx.clone()),
|
||||
rtp_pool: None,
|
||||
tts_engine,
|
||||
out_tx,
|
||||
}
|
||||
}
|
||||
@@ -89,15 +90,16 @@ async fn main() {
|
||||
// Emit ready event.
|
||||
emit_event(&out_tx, "ready", serde_json::json!({}));
|
||||
|
||||
// Shared engine state (SIP side).
|
||||
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone())));
|
||||
// TTS engine — separate internal lock, lazy-loads model on first use.
|
||||
let tts_engine = Arc::new(Mutex::new(tts::TtsEngine::new()));
|
||||
|
||||
// Shared engine state (SIP side). TTS engine is stored inside so the
|
||||
// SIP packet handler path can reach it for on-demand voicemail/IVR generation.
|
||||
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone(), tts_engine)));
|
||||
|
||||
// WebRTC engine — separate lock to avoid deadlock with SIP handlers.
|
||||
let webrtc = Arc::new(Mutex::new(WebRtcEngine::new(out_tx.clone())));
|
||||
|
||||
// TTS engine — separate lock, lazy-loads model on first use.
|
||||
let tts_engine = Arc::new(Mutex::new(tts::TtsEngine::new()));
|
||||
|
||||
// Read commands from stdin.
|
||||
let stdin = tokio::io::stdin();
|
||||
let reader = BufReader::new(stdin);
|
||||
@@ -118,12 +120,11 @@ async fn main() {
|
||||
|
||||
let engine = engine.clone();
|
||||
let webrtc = webrtc.clone();
|
||||
let tts_engine = tts_engine.clone();
|
||||
let out_tx = out_tx.clone();
|
||||
|
||||
// Handle commands — some are async, so we spawn.
|
||||
tokio::spawn(async move {
|
||||
handle_command(engine, webrtc, tts_engine, &out_tx, cmd).await;
|
||||
handle_command(engine, webrtc, &out_tx, cmd).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -131,7 +132,6 @@ async fn main() {
|
||||
async fn handle_command(
|
||||
engine: Arc<Mutex<ProxyEngine>>,
|
||||
webrtc: Arc<Mutex<WebRtcEngine>>,
|
||||
tts_engine: Arc<Mutex<tts::TtsEngine>>,
|
||||
out_tx: &OutTx,
|
||||
cmd: Command,
|
||||
) {
|
||||
@@ -140,7 +140,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).
|
||||
@@ -157,8 +156,8 @@ async fn handle_command(
|
||||
"add_tool_leg" => handle_add_tool_leg(engine, out_tx, &cmd).await,
|
||||
"remove_tool_leg" => handle_remove_tool_leg(engine, out_tx, &cmd).await,
|
||||
"set_leg_metadata" => handle_set_leg_metadata(engine, out_tx, &cmd).await,
|
||||
// TTS command — lock tts_engine only (no SIP/WebRTC contention).
|
||||
"generate_tts" => handle_generate_tts(tts_engine, out_tx, &cmd).await,
|
||||
// TTS command — gets tts_engine from inside ProxyEngine.
|
||||
"generate_tts" => handle_generate_tts(engine, out_tx, &cmd).await,
|
||||
_ => respond_err(out_tx, &cmd.id, &format!("unknown command: {}", cmd.method)),
|
||||
}
|
||||
}
|
||||
@@ -327,10 +326,12 @@ async fn handle_sip_packet(
|
||||
ref registrar,
|
||||
ref mut call_mgr,
|
||||
ref mut rtp_pool,
|
||||
ref tts_engine,
|
||||
..
|
||||
} = *eng;
|
||||
let tts_clone = tts_engine.clone();
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
let call_id = call_mgr
|
||||
let inbound = call_mgr
|
||||
.create_inbound_call(
|
||||
&msg,
|
||||
from_addr,
|
||||
@@ -341,10 +342,11 @@ async fn handle_sip_packet(
|
||||
rtp_pool,
|
||||
socket,
|
||||
public_ip.as_deref(),
|
||||
tts_clone,
|
||||
)
|
||||
.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 +359,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 +386,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 +457,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 +661,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 +1113,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,
|
||||
@@ -1237,10 +1235,11 @@ async fn handle_set_leg_metadata(
|
||||
|
||||
/// Handle `generate_tts` — synthesize text to a WAV file using Kokoro TTS.
|
||||
async fn handle_generate_tts(
|
||||
tts_engine: Arc<Mutex<tts::TtsEngine>>,
|
||||
engine: Arc<Mutex<ProxyEngine>>,
|
||||
out_tx: &OutTx,
|
||||
cmd: &Command,
|
||||
) {
|
||||
let tts_engine = engine.lock().await.tts_engine.clone();
|
||||
let mut tts = tts_engine.lock().await;
|
||||
match tts.generate(&cmd.params).await {
|
||||
Ok(result) => respond_ok(out_tx, &cmd.id, result),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! Text-to-speech engine — synthesizes text to WAV files using Kokoro neural TTS.
|
||||
//!
|
||||
//! The model is loaded lazily on first use. If the model/voices files are not
|
||||
//! present, the generate command returns an error and the TS side falls back
|
||||
//! to espeak-ng.
|
||||
//! present, the generate command returns an error and the caller skips the prompt.
|
||||
//!
|
||||
//! Caching is handled internally via a `.meta` sidecar file next to each WAV.
|
||||
//! When `cacheable` is true, the engine checks whether the existing WAV was
|
||||
//! generated from the same text+voice; if so it returns immediately (cache hit).
|
||||
//! Callers never need to check for cached files — that is entirely this module's
|
||||
//! responsibility.
|
||||
|
||||
use kokoro_tts::{KokoroTts, Voice};
|
||||
use std::path::Path;
|
||||
@@ -32,6 +37,8 @@ impl TtsEngine {
|
||||
/// - `voice`: voice name (e.g. "af_bella")
|
||||
/// - `text`: text to synthesize
|
||||
/// - `output`: output WAV file path
|
||||
/// - `cacheable`: if true, skip synthesis when the output WAV already
|
||||
/// matches the same text+voice (checked via a `.meta` sidecar file)
|
||||
pub async fn generate(&mut self, params: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let model_path = params.get("model").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'model' param")?;
|
||||
@@ -43,11 +50,19 @@ impl TtsEngine {
|
||||
.ok_or("missing 'text' param")?;
|
||||
let output_path = params.get("output").and_then(|v| v.as_str())
|
||||
.ok_or("missing 'output' param")?;
|
||||
let cacheable = params.get("cacheable").and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if text.is_empty() {
|
||||
return Err("empty text".into());
|
||||
}
|
||||
|
||||
// Cache check: if cacheable and the sidecar matches, return immediately.
|
||||
if cacheable && self.is_cache_hit(output_path, text, voice_name) {
|
||||
eprintln!("[tts] cache hit: {output_path}");
|
||||
return Ok(serde_json::json!({ "output": output_path }));
|
||||
}
|
||||
|
||||
// Check that model/voices files exist.
|
||||
if !Path::new(model_path).exists() {
|
||||
return Err(format!("model not found: {model_path}"));
|
||||
@@ -56,6 +71,11 @@ impl TtsEngine {
|
||||
return Err(format!("voices not found: {voices_path}"));
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// Lazy-load or reload if paths changed.
|
||||
if self.tts.is_none()
|
||||
|| self.loaded_model_path != model_path
|
||||
@@ -95,9 +115,41 @@ impl TtsEngine {
|
||||
}
|
||||
writer.finalize().map_err(|e| format!("WAV finalize: {e}"))?;
|
||||
|
||||
// Write sidecar for future cache checks.
|
||||
if cacheable {
|
||||
self.write_cache_meta(output_path, text, voice_name);
|
||||
}
|
||||
|
||||
eprintln!("[tts] wrote {output_path}");
|
||||
Ok(serde_json::json!({ "output": output_path }))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cache helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Check if the WAV + sidecar on disk match the given text+voice.
|
||||
fn is_cache_hit(&self, output_path: &str, text: &str, voice: &str) -> bool {
|
||||
let meta_path = format!("{output_path}.meta");
|
||||
if !Path::new(output_path).exists() || !Path::new(&meta_path).exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::read_to_string(&meta_path) {
|
||||
Ok(contents) => contents == Self::cache_key(text, voice),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the sidecar `.meta` file next to the WAV.
|
||||
fn write_cache_meta(&self, output_path: &str, text: &str, voice: &str) {
|
||||
let meta_path = format!("{output_path}.meta");
|
||||
let _ = std::fs::write(&meta_path, Self::cache_key(text, voice));
|
||||
}
|
||||
|
||||
/// Build the cache key from text + voice.
|
||||
fn cache_key(text: &str, voice: &str) -> String {
|
||||
format!("{}\0{}", text, voice)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map voice name string to Kokoro Voice enum variant.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.19.1',
|
||||
version: '1.23.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* TTS announcement module — generates announcement WAV files at startup.
|
||||
*
|
||||
* Engine priority: espeak-ng (formant TTS, fast) → Kokoro neural TTS via
|
||||
* proxy-engine → disabled.
|
||||
*
|
||||
* The generated WAV is left on disk for Rust's audio_player / start_interaction
|
||||
* to play during calls. No encoding or RTP playback happens in TypeScript.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { sendProxyCommand, isProxyReady } from './proxybridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
||||
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
|
||||
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
|
||||
|
||||
// Kokoro fallback constants.
|
||||
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
|
||||
const KOKORO_VOICES = 'voices.bin';
|
||||
const KOKORO_VOICE = 'af_bella';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TTS generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if espeak-ng is available on the system. */
|
||||
function isEspeakAvailable(): boolean {
|
||||
try {
|
||||
execSync('which espeak-ng', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate announcement WAV via espeak-ng (primary engine). */
|
||||
function generateViaEspeak(wavPath: string, text: string, log: (msg: string) => void): boolean {
|
||||
log('[tts] generating announcement audio via espeak-ng...');
|
||||
try {
|
||||
execSync(
|
||||
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
|
||||
{ timeout: 10000, stdio: 'pipe' },
|
||||
);
|
||||
log('[tts] espeak-ng WAV generated');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log(`[tts] espeak-ng failed: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate announcement WAV via Kokoro TTS (fallback, runs inside proxy-engine). */
|
||||
async function generateViaKokoro(wavPath: string, text: string, log: (msg: string) => void): Promise<boolean> {
|
||||
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
|
||||
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
|
||||
|
||||
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) {
|
||||
log('[tts] Kokoro model/voices not found — Kokoro fallback unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isProxyReady()) {
|
||||
log('[tts] proxy-engine not ready — Kokoro fallback unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
log('[tts] generating announcement audio via Kokoro TTS (fallback)...');
|
||||
try {
|
||||
await sendProxyCommand('generate_tts', {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
voice: KOKORO_VOICE,
|
||||
text,
|
||||
output: wavPath,
|
||||
});
|
||||
log('[tts] Kokoro WAV generated (via proxy-engine)');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log(`[tts] Kokoro failed: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pre-generate the announcement WAV file.
|
||||
* Must be called after the proxy engine is initialized.
|
||||
*
|
||||
* Engine priority: espeak-ng → Kokoro → disabled.
|
||||
*/
|
||||
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_WAV)) {
|
||||
let generated = false;
|
||||
|
||||
// Try espeak-ng first.
|
||||
if (isEspeakAvailable()) {
|
||||
generated = generateViaEspeak(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
|
||||
} else {
|
||||
log('[tts] espeak-ng not installed — trying Kokoro fallback');
|
||||
}
|
||||
|
||||
// Fall back to Kokoro (via proxy-engine).
|
||||
if (!generated) {
|
||||
generated = await generateViaKokoro(CACHE_WAV, ANNOUNCEMENT_TEXT, log);
|
||||
}
|
||||
|
||||
if (!generated) {
|
||||
log('[tts] no TTS engine available — announcements disabled');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log('[tts] announcement WAV ready');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log(`[tts] init error: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the path to the cached announcement WAV, or null if not generated. */
|
||||
export function getAnnouncementWavPath(): string | null {
|
||||
return fs.existsSync(CACHE_WAV) ? CACHE_WAV : null;
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* PromptCache — manages named audio prompt WAV files for IVR and voicemail.
|
||||
*
|
||||
* Generates WAV files via espeak-ng (primary) or Kokoro TTS through the
|
||||
* proxy-engine (fallback). Also supports loading pre-existing WAV files
|
||||
* and programmatic tone generation.
|
||||
*
|
||||
* All audio playback happens in Rust (audio_player / start_interaction).
|
||||
* This module only manages WAV files on disk.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { sendProxyCommand, isProxyReady } from '../proxybridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A cached prompt — just a WAV file path and metadata. */
|
||||
export interface ICachedPrompt {
|
||||
/** Unique prompt identifier. */
|
||||
id: string;
|
||||
/** Path to the WAV file on disk. */
|
||||
wavPath: string;
|
||||
/** Total duration in milliseconds (approximate, from WAV header). */
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TTS helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
|
||||
|
||||
/** Check if espeak-ng is available. */
|
||||
function isEspeakAvailable(): boolean {
|
||||
try {
|
||||
execSync('which espeak-ng', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate WAV via espeak-ng. */
|
||||
function generateViaEspeak(wavPath: string, text: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
`espeak-ng -v en-us -s 150 -w "${wavPath}" "${text}"`,
|
||||
{ timeout: 10000, stdio: 'pipe' },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate WAV via Kokoro TTS (runs inside proxy-engine). */
|
||||
async function generateViaKokoro(wavPath: string, text: string, voice: string): Promise<boolean> {
|
||||
const modelPath = path.join(TTS_DIR, 'kokoro-v1.0.onnx');
|
||||
const voicesPath = path.join(TTS_DIR, 'voices.bin');
|
||||
if (!fs.existsSync(modelPath) || !fs.existsSync(voicesPath)) return false;
|
||||
if (!isProxyReady()) return false;
|
||||
|
||||
try {
|
||||
await sendProxyCommand('generate_tts', {
|
||||
model: modelPath,
|
||||
voices: voicesPath,
|
||||
voice,
|
||||
text,
|
||||
output: wavPath,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a WAV file's duration from its header. */
|
||||
function getWavDurationMs(wavPath: string): number {
|
||||
try {
|
||||
const wav = fs.readFileSync(wavPath);
|
||||
if (wav.length < 44) return 0;
|
||||
if (wav.toString('ascii', 0, 4) !== 'RIFF') return 0;
|
||||
|
||||
let sampleRate = 16000;
|
||||
let dataSize = 0;
|
||||
let bitsPerSample = 16;
|
||||
let channels = 1;
|
||||
let offset = 12;
|
||||
|
||||
while (offset < wav.length - 8) {
|
||||
const chunkId = wav.toString('ascii', offset, offset + 4);
|
||||
const chunkSize = wav.readUInt32LE(offset + 4);
|
||||
if (chunkId === 'fmt ') {
|
||||
channels = wav.readUInt16LE(offset + 10);
|
||||
sampleRate = wav.readUInt32LE(offset + 12);
|
||||
bitsPerSample = wav.readUInt16LE(offset + 22);
|
||||
}
|
||||
if (chunkId === 'data') {
|
||||
dataSize = chunkSize;
|
||||
}
|
||||
offset += 8 + chunkSize;
|
||||
if (offset % 2 !== 0) offset++;
|
||||
}
|
||||
|
||||
const bytesPerSample = (bitsPerSample / 8) * channels;
|
||||
const totalSamples = bytesPerSample > 0 ? dataSize / bytesPerSample : 0;
|
||||
return sampleRate > 0 ? Math.round((totalSamples / sampleRate) * 1000) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptCache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PromptCache {
|
||||
private prompts = new Map<string, ICachedPrompt>();
|
||||
private log: (msg: string) => void;
|
||||
private espeakAvailable: boolean | null = null;
|
||||
|
||||
constructor(log: (msg: string) => void) {
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get a cached prompt by ID. */
|
||||
get(id: string): ICachedPrompt | null {
|
||||
return this.prompts.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** Check if a prompt is cached. */
|
||||
has(id: string): boolean {
|
||||
return this.prompts.has(id);
|
||||
}
|
||||
|
||||
/** List all cached prompt IDs. */
|
||||
listIds(): string[] {
|
||||
return [...this.prompts.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TTS prompt WAV and cache its path.
|
||||
* Uses espeak-ng (primary) or Kokoro (fallback).
|
||||
*/
|
||||
async generatePrompt(id: string, text: string, voice = 'af_bella'): Promise<ICachedPrompt | null> {
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
|
||||
|
||||
// Check espeak availability once.
|
||||
if (this.espeakAvailable === null) {
|
||||
this.espeakAvailable = isEspeakAvailable();
|
||||
}
|
||||
|
||||
// Generate WAV if not already on disk.
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
let generated = false;
|
||||
if (this.espeakAvailable) {
|
||||
generated = generateViaEspeak(wavPath, text);
|
||||
}
|
||||
if (!generated) {
|
||||
generated = await generateViaKokoro(wavPath, text, voice);
|
||||
}
|
||||
if (!generated) {
|
||||
this.log(`[prompt-cache] failed to generate TTS for "${id}"`);
|
||||
return null;
|
||||
}
|
||||
this.log(`[prompt-cache] generated WAV for "${id}"`);
|
||||
}
|
||||
|
||||
return this.registerWav(id, wavPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a pre-existing WAV file as a prompt.
|
||||
*/
|
||||
async loadWavPrompt(id: string, wavPath: string): Promise<ICachedPrompt | null> {
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
this.log(`[prompt-cache] WAV not found: ${wavPath}`);
|
||||
return null;
|
||||
}
|
||||
return this.registerWav(id, wavPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a beep tone WAV and cache it.
|
||||
*/
|
||||
async generateBeep(
|
||||
id: string,
|
||||
freqHz = 1000,
|
||||
durationMs = 500,
|
||||
amplitude = 8000,
|
||||
): Promise<ICachedPrompt | null> {
|
||||
fs.mkdirSync(TTS_DIR, { recursive: true });
|
||||
const wavPath = path.join(TTS_DIR, `prompt-${id}.wav`);
|
||||
|
||||
if (!fs.existsSync(wavPath)) {
|
||||
// Generate 16kHz 16-bit mono sine wave WAV.
|
||||
const sampleRate = 16000;
|
||||
const totalSamples = Math.floor((sampleRate * durationMs) / 1000);
|
||||
const pcm = Buffer.alloc(totalSamples * 2);
|
||||
|
||||
for (let i = 0; i < totalSamples; i++) {
|
||||
const t = i / sampleRate;
|
||||
const fadeLen = Math.floor(sampleRate * 0.01); // 10ms fade
|
||||
let envelope = 1.0;
|
||||
if (i < fadeLen) envelope = i / fadeLen;
|
||||
else if (i > totalSamples - fadeLen) envelope = (totalSamples - i) / fadeLen;
|
||||
|
||||
const sample = Math.round(Math.sin(2 * Math.PI * freqHz * t) * amplitude * envelope);
|
||||
pcm.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * 2);
|
||||
}
|
||||
|
||||
// Write WAV file.
|
||||
const headerSize = 44;
|
||||
const dataSize = pcm.length;
|
||||
const wav = Buffer.alloc(headerSize + dataSize);
|
||||
|
||||
// RIFF header
|
||||
wav.write('RIFF', 0);
|
||||
wav.writeUInt32LE(36 + dataSize, 4);
|
||||
wav.write('WAVE', 8);
|
||||
|
||||
// fmt chunk
|
||||
wav.write('fmt ', 12);
|
||||
wav.writeUInt32LE(16, 16); // chunk size
|
||||
wav.writeUInt16LE(1, 20); // PCM format
|
||||
wav.writeUInt16LE(1, 22); // mono
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * 2, 28); // byte rate
|
||||
wav.writeUInt16LE(2, 32); // block align
|
||||
wav.writeUInt16LE(16, 34); // bits per sample
|
||||
|
||||
// data chunk
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(dataSize, 40);
|
||||
pcm.copy(wav, 44);
|
||||
|
||||
fs.writeFileSync(wavPath, wav);
|
||||
this.log(`[prompt-cache] beep WAV generated for "${id}"`);
|
||||
}
|
||||
|
||||
return this.registerWav(id, wavPath);
|
||||
}
|
||||
|
||||
/** Remove a prompt from the cache. */
|
||||
remove(id: string): void {
|
||||
this.prompts.delete(id);
|
||||
}
|
||||
|
||||
/** Clear all cached prompts. */
|
||||
clear(): void {
|
||||
this.prompts.clear();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private registerWav(id: string, wavPath: string): ICachedPrompt {
|
||||
const durationMs = getWavDurationMs(wavPath);
|
||||
const prompt: ICachedPrompt = { id, wavPath, durationMs };
|
||||
this.prompts.set(id, prompt);
|
||||
this.log(`[prompt-cache] cached "${id}": ${wavPath} (${(durationMs / 1000).toFixed(1)}s)`);
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
26
ts/config.ts
26
ts/config.ts
@@ -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
|
||||
|
||||
@@ -14,12 +14,36 @@ import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||
import type { VoiceboxManager } from './voicebox.ts';
|
||||
|
||||
// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine.
|
||||
// Kept as `any` type for backward compat with the function signature until full WebRTC port.
|
||||
type CallManager = any;
|
||||
|
||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
||||
|
||||
interface IHandleRequestContext {
|
||||
getStatus: () => unknown;
|
||||
log: (msg: string) => void;
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
|
||||
onHangupCall: (callId: string) => boolean;
|
||||
onConfigSaved?: () => void | Promise<void>;
|
||||
voiceboxManager?: VoiceboxManager;
|
||||
}
|
||||
|
||||
interface IWebUiOptions extends IHandleRequestContext {
|
||||
port: number;
|
||||
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>;
|
||||
onWebRtcIce?: (sessionId: string, candidate: unknown) => Promise<void>;
|
||||
onWebRtcClose?: (sessionId: string) => Promise<void>;
|
||||
onWebRtcAccept?: (callId: string, sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface IWebRtcSocketMessage {
|
||||
type?: string;
|
||||
sessionId?: string;
|
||||
callId?: string;
|
||||
sdp?: string;
|
||||
candidate?: unknown;
|
||||
userAgent?: string;
|
||||
_remoteIp?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket broadcast
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,14 +106,9 @@ function loadStaticFiles(): void {
|
||||
async function handleRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
getStatus: () => unknown,
|
||||
log: (msg: string) => void,
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
context: IHandleRequestContext,
|
||||
): Promise<void> {
|
||||
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager } = context;
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const method = req.method || 'GET';
|
||||
|
||||
@@ -258,7 +277,7 @@ async function handleRequest(
|
||||
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
||||
log('[config] updated config.json');
|
||||
onConfigSaved?.();
|
||||
await onConfigSaved?.();
|
||||
return sendJson(res, { ok: true });
|
||||
} catch (e: any) {
|
||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||
@@ -339,21 +358,21 @@ async function handleRequest(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function initWebUi(
|
||||
getStatus: () => unknown,
|
||||
log: (msg: string) => void,
|
||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
|
||||
onHangupCall: (callId: string) => boolean,
|
||||
onConfigSaved?: () => void,
|
||||
callManager?: CallManager,
|
||||
voiceboxManager?: VoiceboxManager,
|
||||
/** WebRTC signaling handlers — forwarded to Rust proxy-engine. */
|
||||
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>,
|
||||
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
|
||||
onWebRtcClose?: (sessionId: string) => Promise<void>,
|
||||
/** Called when browser sends webrtc-accept (callId + sessionId linking). */
|
||||
onWebRtcAccept?: (callId: string, sessionId: string) => void,
|
||||
options: IWebUiOptions,
|
||||
): void {
|
||||
const WEB_PORT = 3060;
|
||||
const {
|
||||
port,
|
||||
getStatus,
|
||||
log,
|
||||
onStartCall,
|
||||
onHangupCall,
|
||||
onConfigSaved,
|
||||
voiceboxManager,
|
||||
onWebRtcOffer,
|
||||
onWebRtcIce,
|
||||
onWebRtcClose,
|
||||
onWebRtcAccept,
|
||||
} = options;
|
||||
|
||||
loadStaticFiles();
|
||||
|
||||
@@ -367,12 +386,12 @@ export function initWebUi(
|
||||
const cert = fs.readFileSync(certPath, 'utf8');
|
||||
const key = fs.readFileSync(keyPath, 'utf8');
|
||||
server = https.createServer({ cert, key }, (req, res) =>
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
useTls = true;
|
||||
} catch {
|
||||
server = http.createServer((req, res) =>
|
||||
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }),
|
||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,12 +405,12 @@ export function initWebUi(
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
const msg = JSON.parse(raw.toString()) as IWebRtcSocketMessage;
|
||||
if (msg.type === 'webrtc-offer' && msg.sessionId) {
|
||||
// Forward to Rust proxy-engine for WebRTC handling.
|
||||
if (onWebRtcOffer) {
|
||||
if (onWebRtcOffer && typeof msg.sdp === 'string') {
|
||||
log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`);
|
||||
onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
|
||||
onWebRtcOffer(msg.sessionId, msg.sdp, socket).catch((e: any) =>
|
||||
log(`[webrtc] offer error: ${e.message}`));
|
||||
}
|
||||
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
|
||||
@@ -409,7 +428,7 @@ export function initWebUi(
|
||||
}
|
||||
} else if (msg.type?.startsWith('webrtc-')) {
|
||||
msg._remoteIp = remoteIp;
|
||||
handleWebRtcSignaling(socket as any, msg);
|
||||
handleWebRtcSignaling(socket, msg);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
@@ -418,8 +437,8 @@ export function initWebUi(
|
||||
socket.on('error', () => wsClients.delete(socket));
|
||||
});
|
||||
|
||||
server.listen(WEB_PORT, '0.0.0.0', () => {
|
||||
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`);
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${port}`);
|
||||
});
|
||||
|
||||
setInterval(() => broadcastWs('status', getStatus()), 1000);
|
||||
|
||||
@@ -4,13 +4,36 @@
|
||||
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
|
||||
* - Sends configuration
|
||||
* - Receives high-level events (incoming_call, call_ended, etc.)
|
||||
* - Sends high-level commands (hangup, make_call, play_audio)
|
||||
* - Sends high-level commands (hangup, make_call, add_leg, webrtc_offer)
|
||||
*
|
||||
* No raw SIP ever touches TypeScript.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { RustBridge } from '@push.rocks/smartrust';
|
||||
import type { TProxyEventMap } from './shared/proxy-events.ts';
|
||||
export type {
|
||||
ICallAnsweredEvent,
|
||||
ICallEndedEvent,
|
||||
ICallRingingEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
IIncomingCallEvent,
|
||||
ILegAddedEvent,
|
||||
ILegRemovedEvent,
|
||||
ILegStateChangedEvent,
|
||||
IOutboundCallEvent,
|
||||
IOutboundCallStartedEvent,
|
||||
IProviderRegisteredEvent,
|
||||
IRecordingDoneEvent,
|
||||
ISipUnhandledEvent,
|
||||
IVoicemailErrorEvent,
|
||||
IVoicemailStartedEvent,
|
||||
IWebRtcAudioRxEvent,
|
||||
IWebRtcIceCandidateEvent,
|
||||
IWebRtcStateEvent,
|
||||
IWebRtcTrackEvent,
|
||||
TProxyEventMap,
|
||||
} from './shared/proxy-events.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command type map for smartrust
|
||||
@@ -29,18 +52,14 @@ type TProxyCommands = {
|
||||
params: { number: string; device_id?: string; provider_id?: string };
|
||||
result: { call_id: string };
|
||||
};
|
||||
play_audio: {
|
||||
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
|
||||
result: Record<string, never>;
|
||||
add_leg: {
|
||||
params: { call_id: string; number: string; provider_id?: string };
|
||||
result: { leg_id: string };
|
||||
};
|
||||
start_recording: {
|
||||
params: { call_id: string; file_path: string; max_duration_ms?: number };
|
||||
remove_leg: {
|
||||
params: { call_id: string; leg_id: string };
|
||||
result: Record<string, never>;
|
||||
};
|
||||
stop_recording: {
|
||||
params: { call_id: string };
|
||||
result: { file_path: string; duration_ms: number };
|
||||
};
|
||||
add_device_leg: {
|
||||
params: { call_id: string; device_id: string };
|
||||
result: { leg_id: string };
|
||||
@@ -80,50 +99,39 @@ type TProxyCommands = {
|
||||
result: Record<string, never>;
|
||||
};
|
||||
generate_tts: {
|
||||
params: { model: string; voices: string; voice: string; text: string; output: string };
|
||||
params: { model: string; voices: string; voice: string; text: string; output: string; cacheable?: boolean };
|
||||
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>;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types from Rust
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IIncomingCallEvent {
|
||||
call_id: string;
|
||||
from_uri: string;
|
||||
to_number: string;
|
||||
provider_id: string;
|
||||
}
|
||||
|
||||
export interface IOutboundCallEvent {
|
||||
call_id: string;
|
||||
from_device: string | null;
|
||||
to_number: string;
|
||||
}
|
||||
|
||||
export interface ICallEndedEvent {
|
||||
call_id: string;
|
||||
reason: string;
|
||||
duration: number;
|
||||
from_side?: string;
|
||||
}
|
||||
|
||||
export interface IProviderRegisteredEvent {
|
||||
provider_id: string;
|
||||
registered: boolean;
|
||||
public_ip: string | null;
|
||||
}
|
||||
|
||||
export interface IDeviceRegisteredEvent {
|
||||
device_id: string;
|
||||
display_name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
aor: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,10 +140,34 @@ let bridge: RustBridge<TProxyCommands> | null = null;
|
||||
let initialized = false;
|
||||
let logFn: ((msg: string) => void) | undefined;
|
||||
|
||||
type TWebRtcIceCandidate = {
|
||||
candidate?: string;
|
||||
sdpMid?: string;
|
||||
sdpMLineIndex?: number;
|
||||
} | string;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function buildLocalPaths(): string[] {
|
||||
const root = process.cwd();
|
||||
// Map Node's process.arch to tsrust's friendly target name.
|
||||
// tsrust writes multi-target binaries as <bin>_<os>_<arch>,
|
||||
// e.g. proxy-engine_linux_amd64 / proxy-engine_linux_arm64.
|
||||
const archSuffix =
|
||||
process.arch === 'arm64' ? 'linux_arm64' :
|
||||
process.arch === 'x64' ? 'linux_amd64' :
|
||||
null;
|
||||
const multiTarget = archSuffix
|
||||
? [path.join(root, 'dist_rust', `proxy-engine_${archSuffix}`)]
|
||||
: [];
|
||||
return [
|
||||
// 1. Multi-target output matching the running host arch (Docker image, CI, multi-target dev).
|
||||
...multiTarget,
|
||||
// 2. Single-target (unsuffixed) output — legacy/fallback when tsrust runs without targets.
|
||||
path.join(root, 'dist_rust', 'proxy-engine'),
|
||||
// 3. Direct cargo builds for dev iteration.
|
||||
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
|
||||
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
|
||||
];
|
||||
@@ -176,8 +208,8 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
|
||||
initialized = true;
|
||||
log?.('[proxy-engine] spawned and ready');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log?.(`[proxy-engine] init error: ${e.message}`);
|
||||
} catch (error: unknown) {
|
||||
log?.(`[proxy-engine] init error: ${errorMessage(error)}`);
|
||||
bridge = null;
|
||||
return false;
|
||||
}
|
||||
@@ -187,14 +219,14 @@ export async function initProxyEngine(log?: (msg: string) => void): Promise<bool
|
||||
* Send the full app config to the proxy engine.
|
||||
* This binds the SIP socket, starts provider registrations, etc.
|
||||
*/
|
||||
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> {
|
||||
export async function configureProxyEngine(config: TProxyCommands['configure']['params']): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
const result = await bridge.sendCommand('configure', config as any);
|
||||
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`);
|
||||
const result = await sendProxyCommand('configure', config);
|
||||
logFn?.(`[proxy-engine] configured, SIP bound on ${result.bound || '?'}`);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] configure error: ${e.message}`);
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] configure error: ${errorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -205,14 +237,14 @@ export async function configureProxyEngine(config: Record<string, unknown>): Pro
|
||||
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('make_call', {
|
||||
const result = await sendProxyCommand('make_call', {
|
||||
number,
|
||||
device_id: deviceId,
|
||||
provider_id: providerId,
|
||||
} as any);
|
||||
return (result as any)?.call_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`);
|
||||
});
|
||||
return result.call_id || null;
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] make_call error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -223,7 +255,7 @@ export async function makeCall(number: string, deviceId?: string, providerId?: s
|
||||
export async function hangupCall(callId: string): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('hangup', { call_id: callId } as any);
|
||||
await sendProxyCommand('hangup', { call_id: callId });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -236,10 +268,9 @@ export async function hangupCall(callId: string): Promise<boolean> {
|
||||
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any);
|
||||
return result as any;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
|
||||
return await sendProxyCommand('webrtc_offer', { session_id: sessionId, sdp });
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] webrtc_offer error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -247,15 +278,15 @@ export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp
|
||||
/**
|
||||
* Forward an ICE candidate to the proxy engine.
|
||||
*/
|
||||
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> {
|
||||
export async function webrtcIce(sessionId: string, candidate: TWebRtcIceCandidate): Promise<void> {
|
||||
if (!bridge || !initialized) return;
|
||||
try {
|
||||
await bridge.sendCommand('webrtc_ice', {
|
||||
await sendProxyCommand('webrtc_ice', {
|
||||
session_id: sessionId,
|
||||
candidate: candidate?.candidate || candidate,
|
||||
sdp_mid: candidate?.sdpMid,
|
||||
sdp_mline_index: candidate?.sdpMLineIndex,
|
||||
} as any);
|
||||
candidate: typeof candidate === 'string' ? candidate : candidate.candidate || '',
|
||||
sdp_mid: typeof candidate === 'string' ? undefined : candidate.sdpMid,
|
||||
sdp_mline_index: typeof candidate === 'string' ? undefined : candidate.sdpMLineIndex,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -266,16 +297,16 @@ export async function webrtcIce(sessionId: string, candidate: any): Promise<void
|
||||
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('webrtc_link', {
|
||||
await sendProxyCommand('webrtc_link', {
|
||||
session_id: sessionId,
|
||||
call_id: callId,
|
||||
provider_media_addr: providerMediaAddr,
|
||||
provider_media_port: providerMediaPort,
|
||||
sip_pt: sipPt,
|
||||
} as any);
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`);
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] webrtc_link error: ${errorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -286,14 +317,14 @@ export async function webrtcLink(sessionId: string, callId: string, providerMedi
|
||||
export async function addLeg(callId: string, number: string, providerId?: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('add_leg', {
|
||||
const result = await sendProxyCommand('add_leg', {
|
||||
call_id: callId,
|
||||
number,
|
||||
provider_id: providerId,
|
||||
} as any);
|
||||
return (result as any)?.leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] add_leg error: ${e?.message || e}`);
|
||||
});
|
||||
return result.leg_id || null;
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] add_leg error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -304,10 +335,10 @@ export async function addLeg(callId: string, number: string, providerId?: string
|
||||
export async function removeLeg(callId: string, legId: string): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('remove_leg', { call_id: callId, leg_id: legId } as any);
|
||||
await sendProxyCommand('remove_leg', { call_id: callId, leg_id: legId });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] remove_leg error: ${e?.message || e}`);
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] remove_leg error: ${errorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -318,7 +349,7 @@ export async function removeLeg(callId: string, legId: string): Promise<boolean>
|
||||
export async function webrtcClose(sessionId: string): Promise<void> {
|
||||
if (!bridge || !initialized) return;
|
||||
try {
|
||||
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any);
|
||||
await sendProxyCommand('webrtc_close', { session_id: sessionId });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -332,13 +363,13 @@ export async function webrtcClose(sessionId: string): Promise<void> {
|
||||
export async function addDeviceLeg(callId: string, deviceId: string): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('add_device_leg', {
|
||||
const result = await sendProxyCommand('add_device_leg', {
|
||||
call_id: callId,
|
||||
device_id: deviceId,
|
||||
} as any);
|
||||
return (result as any)?.leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] add_device_leg error: ${e?.message || e}`);
|
||||
});
|
||||
return result.leg_id || null;
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] add_device_leg error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -353,14 +384,14 @@ export async function transferLeg(
|
||||
): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('transfer_leg', {
|
||||
await sendProxyCommand('transfer_leg', {
|
||||
source_call_id: sourceCallId,
|
||||
leg_id: legId,
|
||||
target_call_id: targetCallId,
|
||||
} as any);
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] transfer_leg error: ${e?.message || e}`);
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] transfer_leg error: ${errorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -376,15 +407,15 @@ export async function replaceLeg(
|
||||
): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('replace_leg', {
|
||||
const result = await sendProxyCommand('replace_leg', {
|
||||
call_id: callId,
|
||||
old_leg_id: oldLegId,
|
||||
number,
|
||||
provider_id: providerId,
|
||||
} as any);
|
||||
return (result as any)?.new_leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] replace_leg error: ${e?.message || e}`);
|
||||
});
|
||||
return result.new_leg_id || null;
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] replace_leg error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -402,16 +433,15 @@ export async function startInteraction(
|
||||
): Promise<{ result: 'digit' | 'timeout' | 'cancelled'; digit?: string } | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('start_interaction', {
|
||||
return await sendProxyCommand('start_interaction', {
|
||||
call_id: callId,
|
||||
leg_id: legId,
|
||||
prompt_wav: promptWav,
|
||||
expected_digits: expectedDigits,
|
||||
timeout_ms: timeoutMs,
|
||||
} as any);
|
||||
return result as any;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] start_interaction error: ${e?.message || e}`);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] start_interaction error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -427,14 +457,14 @@ export async function addToolLeg(
|
||||
): Promise<string | null> {
|
||||
if (!bridge || !initialized) return null;
|
||||
try {
|
||||
const result = await bridge.sendCommand('add_tool_leg', {
|
||||
const result = await sendProxyCommand('add_tool_leg', {
|
||||
call_id: callId,
|
||||
tool_type: toolType,
|
||||
config,
|
||||
} as any);
|
||||
return (result as any)?.tool_leg_id || null;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] add_tool_leg error: ${e?.message || e}`);
|
||||
});
|
||||
return result.tool_leg_id || null;
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] add_tool_leg error: ${errorMessage(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -445,13 +475,13 @@ export async function addToolLeg(
|
||||
export async function removeToolLeg(callId: string, toolLegId: string): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('remove_tool_leg', {
|
||||
await sendProxyCommand('remove_tool_leg', {
|
||||
call_id: callId,
|
||||
tool_leg_id: toolLegId,
|
||||
} as any);
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] remove_tool_leg error: ${e?.message || e}`);
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] remove_tool_leg error: ${errorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -467,15 +497,15 @@ export async function setLegMetadata(
|
||||
): Promise<boolean> {
|
||||
if (!bridge || !initialized) return false;
|
||||
try {
|
||||
await bridge.sendCommand('set_leg_metadata', {
|
||||
await sendProxyCommand('set_leg_metadata', {
|
||||
call_id: callId,
|
||||
leg_id: legId,
|
||||
key,
|
||||
value,
|
||||
} as any);
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logFn?.(`[proxy-engine] set_leg_metadata error: ${e?.message || e}`);
|
||||
} catch (error: unknown) {
|
||||
logFn?.(`[proxy-engine] set_leg_metadata error: ${errorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -487,7 +517,7 @@ export async function setLegMetadata(
|
||||
* dtmf_digit, recording_done, tool_recording_done, tool_transcription_done,
|
||||
* leg_added, leg_removed, sip_unhandled
|
||||
*/
|
||||
export function onProxyEvent(event: string, handler: (data: any) => void): void {
|
||||
export function onProxyEvent<K extends keyof TProxyEventMap>(event: K, handler: (data: TProxyEventMap[K]) => void): void {
|
||||
if (!bridge) throw new Error('proxy engine not initialized');
|
||||
bridge.on(`management:${event}`, handler);
|
||||
}
|
||||
@@ -503,7 +533,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. */
|
||||
|
||||
187
ts/runtime/proxy-events.ts
Normal file
187
ts/runtime/proxy-events.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { onProxyEvent } from '../proxybridge.ts';
|
||||
import type { VoiceboxManager } from '../voicebox.ts';
|
||||
import type { StatusStore } from './status-store.ts';
|
||||
import type { IProviderMediaInfo, WebRtcLinkManager } from './webrtc-linking.ts';
|
||||
|
||||
export interface IRegisterProxyEventHandlersOptions {
|
||||
log: (msg: string) => void;
|
||||
statusStore: StatusStore;
|
||||
voiceboxManager: VoiceboxManager;
|
||||
webRtcLinks: WebRtcLinkManager;
|
||||
getBrowserDeviceIds: () => string[];
|
||||
sendToBrowserDevice: (deviceId: string, data: unknown) => boolean;
|
||||
broadcast: (type: string, data: unknown) => void;
|
||||
onLinkWebRtcSession: (callId: string, sessionId: string, media: IProviderMediaInfo) => void;
|
||||
onCloseWebRtcSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersOptions): void {
|
||||
const {
|
||||
log,
|
||||
statusStore,
|
||||
voiceboxManager,
|
||||
webRtcLinks,
|
||||
getBrowserDeviceIds,
|
||||
sendToBrowserDevice,
|
||||
broadcast,
|
||||
onLinkWebRtcSession,
|
||||
onCloseWebRtcSession,
|
||||
} = options;
|
||||
|
||||
onProxyEvent('provider_registered', (data) => {
|
||||
const previous = statusStore.noteProviderRegistered(data);
|
||||
if (previous) {
|
||||
if (data.registered && !previous.wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
|
||||
} else if (!data.registered && previous.wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registration lost`);
|
||||
}
|
||||
}
|
||||
broadcast('registration', { providerId: data.provider_id, registered: data.registered });
|
||||
});
|
||||
|
||||
onProxyEvent('device_registered', (data) => {
|
||||
if (statusStore.noteDeviceRegistered(data)) {
|
||||
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('incoming_call', (data) => {
|
||||
log(`[call] incoming: ${data.from_uri} -> ${data.to_number} via ${data.provider_id} (${data.call_id})`);
|
||||
statusStore.noteIncomingCall(data);
|
||||
|
||||
if (data.ring_browsers === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const deviceId of getBrowserDeviceIds()) {
|
||||
sendToBrowserDevice(deviceId, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.from_uri,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_device_call', (data) => {
|
||||
log(`[call] outbound: device ${data.from_device} -> ${data.to_number} (${data.call_id})`);
|
||||
statusStore.noteOutboundDeviceCall(data);
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_call_started', (data) => {
|
||||
log(`[call] outbound started: ${data.call_id} -> ${data.number} via ${data.provider_id}`);
|
||||
statusStore.noteOutboundCallStarted(data);
|
||||
|
||||
for (const deviceId of getBrowserDeviceIds()) {
|
||||
sendToBrowserDevice(deviceId, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.number,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('call_ringing', (data) => {
|
||||
statusStore.noteCallRinging(data);
|
||||
});
|
||||
|
||||
onProxyEvent('call_answered', (data) => {
|
||||
if (statusStore.noteCallAnswered(data)) {
|
||||
log(`[call] ${data.call_id} connected`);
|
||||
}
|
||||
|
||||
if (!data.provider_media_addr || !data.provider_media_port) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = webRtcLinks.noteCallAnswered(data.call_id, {
|
||||
addr: data.provider_media_addr,
|
||||
port: data.provider_media_port,
|
||||
sipPt: data.sip_pt ?? 9,
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
|
||||
return;
|
||||
}
|
||||
|
||||
onLinkWebRtcSession(data.call_id, target.sessionId, target.media);
|
||||
});
|
||||
|
||||
onProxyEvent('call_ended', (data) => {
|
||||
if (statusStore.noteCallEnded(data)) {
|
||||
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
|
||||
}
|
||||
|
||||
broadcast('webrtc-call-ended', { callId: data.call_id });
|
||||
|
||||
const sessionId = webRtcLinks.cleanupCall(data.call_id);
|
||||
if (sessionId) {
|
||||
onCloseWebRtcSession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('sip_unhandled', (data) => {
|
||||
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
|
||||
});
|
||||
|
||||
onProxyEvent('leg_added', (data) => {
|
||||
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
|
||||
statusStore.noteLegAdded(data);
|
||||
});
|
||||
|
||||
onProxyEvent('leg_removed', (data) => {
|
||||
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
|
||||
statusStore.noteLegRemoved(data);
|
||||
});
|
||||
|
||||
onProxyEvent('leg_state_changed', (data) => {
|
||||
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} -> ${data.state}`);
|
||||
statusStore.noteLegStateChanged(data);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_ice_candidate', (data) => {
|
||||
broadcast('webrtc-ice', {
|
||||
sessionId: data.session_id,
|
||||
candidate: {
|
||||
candidate: data.candidate,
|
||||
sdpMid: data.sdp_mid,
|
||||
sdpMLineIndex: data.sdp_mline_index,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_state', (data) => {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_track', (data) => {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_audio_rx', (data) => {
|
||||
if (data.packet_count === 1 || data.packet_count === 50) {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('voicemail_started', (data) => {
|
||||
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
|
||||
});
|
||||
|
||||
onProxyEvent('recording_done', (data) => {
|
||||
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
|
||||
voiceboxManager.addMessage('default', {
|
||||
callerNumber: data.caller_number || 'Unknown',
|
||||
callerName: null,
|
||||
fileName: data.file_path,
|
||||
durationMs: data.duration_ms,
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('voicemail_error', (data) => {
|
||||
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
|
||||
});
|
||||
}
|
||||
313
ts/runtime/status-store.ts
Normal file
313
ts/runtime/status-store.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { IAppConfig } from '../config.ts';
|
||||
import type {
|
||||
ICallAnsweredEvent,
|
||||
ICallEndedEvent,
|
||||
ICallRingingEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
IIncomingCallEvent,
|
||||
ILegAddedEvent,
|
||||
ILegRemovedEvent,
|
||||
ILegStateChangedEvent,
|
||||
IOutboundCallEvent,
|
||||
IOutboundCallStartedEvent,
|
||||
IProviderRegisteredEvent,
|
||||
} from '../shared/proxy-events.ts';
|
||||
import type {
|
||||
IActiveCall,
|
||||
ICallHistoryEntry,
|
||||
IDeviceStatus,
|
||||
IProviderStatus,
|
||||
IStatusSnapshot,
|
||||
TLegType,
|
||||
} from '../shared/status.ts';
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
const CODEC_NAMES: Record<number, string> = {
|
||||
0: 'PCMU',
|
||||
8: 'PCMA',
|
||||
9: 'G.722',
|
||||
111: 'Opus',
|
||||
};
|
||||
|
||||
export class StatusStore {
|
||||
private appConfig: IAppConfig;
|
||||
private providerStatuses = new Map<string, IProviderStatus>();
|
||||
private deviceStatuses = new Map<string, IDeviceStatus>();
|
||||
private activeCalls = new Map<string, IActiveCall>();
|
||||
private callHistory: ICallHistoryEntry[] = [];
|
||||
|
||||
constructor(appConfig: IAppConfig) {
|
||||
this.appConfig = appConfig;
|
||||
this.rebuildConfigState();
|
||||
}
|
||||
|
||||
updateConfig(appConfig: IAppConfig): void {
|
||||
this.appConfig = appConfig;
|
||||
this.rebuildConfigState();
|
||||
}
|
||||
|
||||
buildStatusSnapshot(
|
||||
instanceId: string,
|
||||
startTime: number,
|
||||
browserDeviceIds: string[],
|
||||
voicemailCounts: Record<string, number>,
|
||||
): IStatusSnapshot {
|
||||
const devices = [...this.deviceStatuses.values()];
|
||||
for (const deviceId of browserDeviceIds) {
|
||||
devices.push({
|
||||
id: deviceId,
|
||||
displayName: 'Browser',
|
||||
address: null,
|
||||
port: 0,
|
||||
aor: null,
|
||||
connected: true,
|
||||
isBrowser: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
lanIp: this.appConfig.proxy.lanIp,
|
||||
providers: [...this.providerStatuses.values()],
|
||||
devices,
|
||||
calls: [...this.activeCalls.values()].map((call) => ({
|
||||
...call,
|
||||
duration: Math.floor((Date.now() - call.startedAt) / 1000),
|
||||
legs: [...call.legs.values()].map((leg) => ({
|
||||
...leg,
|
||||
pktSent: 0,
|
||||
pktReceived: 0,
|
||||
transcoding: false,
|
||||
})),
|
||||
})),
|
||||
callHistory: this.callHistory,
|
||||
contacts: this.appConfig.contacts || [],
|
||||
voicemailCounts,
|
||||
};
|
||||
}
|
||||
|
||||
noteDashboardCallStarted(callId: string, number: string, providerId?: string): void {
|
||||
this.activeCalls.set(callId, {
|
||||
id: callId,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: number,
|
||||
providerUsed: providerId || null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteProviderRegistered(data: IProviderRegisteredEvent): { wasRegistered: boolean } | null {
|
||||
const provider = this.providerStatuses.get(data.provider_id);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wasRegistered = provider.registered;
|
||||
provider.registered = data.registered;
|
||||
provider.publicIp = data.public_ip;
|
||||
return { wasRegistered };
|
||||
}
|
||||
|
||||
noteDeviceRegistered(data: IDeviceRegisteredEvent): boolean {
|
||||
const device = this.deviceStatuses.get(data.device_id);
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
|
||||
device.address = data.address;
|
||||
device.port = data.port;
|
||||
device.aor = data.aor;
|
||||
device.connected = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
noteIncomingCall(data: IIncomingCallEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'inbound',
|
||||
callerNumber: data.from_uri,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'ringing',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteOutboundDeviceCall(data: IOutboundCallEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: data.from_device,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteOutboundCallStarted(data: IOutboundCallStartedEvent): void {
|
||||
this.activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: data.number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
noteCallRinging(data: ICallRingingEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
call.state = 'ringing';
|
||||
}
|
||||
}
|
||||
|
||||
noteCallAnswered(data: ICallAnsweredEvent): boolean {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
call.state = 'connected';
|
||||
|
||||
if (data.provider_media_addr && data.provider_media_port) {
|
||||
for (const leg of call.legs.values()) {
|
||||
if (leg.type !== 'sip-provider') {
|
||||
continue;
|
||||
}
|
||||
|
||||
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
|
||||
if (data.sip_pt !== undefined) {
|
||||
leg.codec = CODEC_NAMES[data.sip_pt] || `PT${data.sip_pt}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
noteCallEnded(data: ICallEndedEvent): boolean {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.callHistory.unshift({
|
||||
id: call.id,
|
||||
direction: call.direction,
|
||||
callerNumber: call.callerNumber,
|
||||
calleeNumber: call.calleeNumber,
|
||||
providerUsed: call.providerUsed,
|
||||
startedAt: call.startedAt,
|
||||
duration: data.duration,
|
||||
legs: [...call.legs.values()].map((leg) => ({
|
||||
id: leg.id,
|
||||
type: leg.type,
|
||||
metadata: leg.metadata || {},
|
||||
})),
|
||||
});
|
||||
|
||||
if (this.callHistory.length > MAX_HISTORY) {
|
||||
this.callHistory.pop();
|
||||
}
|
||||
|
||||
this.activeCalls.delete(data.call_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
noteLegAdded(data: ILegAddedEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
type: data.kind,
|
||||
state: data.state,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
|
||||
noteLegRemoved(data: ILegRemovedEvent): void {
|
||||
this.activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
|
||||
}
|
||||
|
||||
noteLegStateChanged(data: ILegStateChangedEvent): void {
|
||||
const call = this.activeCalls.get(data.call_id);
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingLeg = call.legs.get(data.leg_id);
|
||||
if (existingLeg) {
|
||||
existingLeg.state = data.state;
|
||||
if (data.metadata) {
|
||||
existingLeg.metadata = data.metadata;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
type: this.inferLegType(data.leg_id),
|
||||
state: data.state,
|
||||
codec: null,
|
||||
rtpPort: null,
|
||||
remoteMedia: null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
|
||||
private rebuildConfigState(): void {
|
||||
const nextProviderStatuses = new Map<string, IProviderStatus>();
|
||||
for (const provider of this.appConfig.providers) {
|
||||
const previous = this.providerStatuses.get(provider.id);
|
||||
nextProviderStatuses.set(provider.id, {
|
||||
id: provider.id,
|
||||
displayName: provider.displayName,
|
||||
registered: previous?.registered ?? false,
|
||||
publicIp: previous?.publicIp ?? null,
|
||||
});
|
||||
}
|
||||
this.providerStatuses = nextProviderStatuses;
|
||||
|
||||
const nextDeviceStatuses = new Map<string, IDeviceStatus>();
|
||||
for (const device of this.appConfig.devices) {
|
||||
const previous = this.deviceStatuses.get(device.id);
|
||||
nextDeviceStatuses.set(device.id, {
|
||||
id: device.id,
|
||||
displayName: device.displayName,
|
||||
address: previous?.address ?? null,
|
||||
port: previous?.port ?? 0,
|
||||
aor: previous?.aor ?? null,
|
||||
connected: previous?.connected ?? false,
|
||||
isBrowser: false,
|
||||
});
|
||||
}
|
||||
this.deviceStatuses = nextDeviceStatuses;
|
||||
}
|
||||
|
||||
private inferLegType(legId: string): TLegType {
|
||||
if (legId.includes('-prov')) {
|
||||
return 'sip-provider';
|
||||
}
|
||||
if (legId.includes('-dev')) {
|
||||
return 'sip-device';
|
||||
}
|
||||
return 'webrtc';
|
||||
}
|
||||
}
|
||||
66
ts/runtime/webrtc-linking.ts
Normal file
66
ts/runtime/webrtc-linking.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface IProviderMediaInfo {
|
||||
addr: string;
|
||||
port: number;
|
||||
sipPt: number;
|
||||
}
|
||||
|
||||
export interface IWebRtcLinkTarget {
|
||||
sessionId: string;
|
||||
media: IProviderMediaInfo;
|
||||
}
|
||||
|
||||
export class WebRtcLinkManager {
|
||||
private sessionToCall = new Map<string, string>();
|
||||
private callToSession = new Map<string, string>();
|
||||
private pendingCallMedia = new Map<string, IProviderMediaInfo>();
|
||||
|
||||
acceptCall(callId: string, sessionId: string): IProviderMediaInfo | null {
|
||||
const previousCallId = this.sessionToCall.get(sessionId);
|
||||
if (previousCallId && previousCallId !== callId) {
|
||||
this.callToSession.delete(previousCallId);
|
||||
}
|
||||
|
||||
const previousSessionId = this.callToSession.get(callId);
|
||||
if (previousSessionId && previousSessionId !== sessionId) {
|
||||
this.sessionToCall.delete(previousSessionId);
|
||||
}
|
||||
|
||||
this.sessionToCall.set(sessionId, callId);
|
||||
this.callToSession.set(callId, sessionId);
|
||||
|
||||
const pendingMedia = this.pendingCallMedia.get(callId) ?? null;
|
||||
if (pendingMedia) {
|
||||
this.pendingCallMedia.delete(callId);
|
||||
}
|
||||
return pendingMedia;
|
||||
}
|
||||
|
||||
noteCallAnswered(callId: string, media: IProviderMediaInfo): IWebRtcLinkTarget | null {
|
||||
const sessionId = this.callToSession.get(callId);
|
||||
if (!sessionId) {
|
||||
this.pendingCallMedia.set(callId, media);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sessionId, media };
|
||||
}
|
||||
|
||||
removeSession(sessionId: string): string | null {
|
||||
const callId = this.sessionToCall.get(sessionId) ?? null;
|
||||
this.sessionToCall.delete(sessionId);
|
||||
if (callId) {
|
||||
this.callToSession.delete(callId);
|
||||
}
|
||||
return callId;
|
||||
}
|
||||
|
||||
cleanupCall(callId: string): string | null {
|
||||
const sessionId = this.callToSession.get(callId) ?? null;
|
||||
this.callToSession.delete(callId);
|
||||
this.pendingCallMedia.delete(callId);
|
||||
if (sessionId) {
|
||||
this.sessionToCall.delete(sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
145
ts/shared/proxy-events.ts
Normal file
145
ts/shared/proxy-events.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { TLegType } from './status.ts';
|
||||
|
||||
export interface IIncomingCallEvent {
|
||||
call_id: string;
|
||||
from_uri: string;
|
||||
to_number: string;
|
||||
provider_id: string;
|
||||
ring_browsers?: boolean;
|
||||
}
|
||||
|
||||
export interface IOutboundCallEvent {
|
||||
call_id: string;
|
||||
from_device: string | null;
|
||||
to_number: string;
|
||||
}
|
||||
|
||||
export interface IOutboundCallStartedEvent {
|
||||
call_id: string;
|
||||
number: string;
|
||||
provider_id: string;
|
||||
}
|
||||
|
||||
export interface ICallRingingEvent {
|
||||
call_id: string;
|
||||
}
|
||||
|
||||
export interface ICallAnsweredEvent {
|
||||
call_id: string;
|
||||
provider_media_addr?: string;
|
||||
provider_media_port?: number;
|
||||
sip_pt?: number;
|
||||
}
|
||||
|
||||
export interface ICallEndedEvent {
|
||||
call_id: string;
|
||||
reason: string;
|
||||
duration: number;
|
||||
from_side?: string;
|
||||
}
|
||||
|
||||
export interface IProviderRegisteredEvent {
|
||||
provider_id: string;
|
||||
registered: boolean;
|
||||
public_ip: string | null;
|
||||
}
|
||||
|
||||
export interface IDeviceRegisteredEvent {
|
||||
device_id: string;
|
||||
display_name: string;
|
||||
address: string;
|
||||
port: number;
|
||||
aor: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export interface ISipUnhandledEvent {
|
||||
method_or_status: string;
|
||||
call_id?: string;
|
||||
from_addr: string;
|
||||
from_port: number;
|
||||
}
|
||||
|
||||
export interface ILegAddedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
kind: TLegType;
|
||||
state: string;
|
||||
codec?: string | null;
|
||||
rtpPort?: number | null;
|
||||
remoteMedia?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ILegRemovedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
}
|
||||
|
||||
export interface ILegStateChangedEvent {
|
||||
call_id: string;
|
||||
leg_id: string;
|
||||
state: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IWebRtcIceCandidateEvent {
|
||||
session_id: string;
|
||||
candidate: string;
|
||||
sdp_mid?: string;
|
||||
sdp_mline_index?: number;
|
||||
}
|
||||
|
||||
export interface IWebRtcStateEvent {
|
||||
session_id?: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface IWebRtcTrackEvent {
|
||||
session_id?: string;
|
||||
kind: string;
|
||||
codec: string;
|
||||
}
|
||||
|
||||
export interface IWebRtcAudioRxEvent {
|
||||
session_id?: string;
|
||||
packet_count: number;
|
||||
}
|
||||
|
||||
export interface IVoicemailStartedEvent {
|
||||
call_id: string;
|
||||
caller_number?: string;
|
||||
}
|
||||
|
||||
export interface IRecordingDoneEvent {
|
||||
file_path: string;
|
||||
duration_ms: number;
|
||||
caller_number?: string;
|
||||
}
|
||||
|
||||
export interface IVoicemailErrorEvent {
|
||||
call_id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type TProxyEventMap = {
|
||||
provider_registered: IProviderRegisteredEvent;
|
||||
device_registered: IDeviceRegisteredEvent;
|
||||
incoming_call: IIncomingCallEvent;
|
||||
outbound_device_call: IOutboundCallEvent;
|
||||
outbound_call_started: IOutboundCallStartedEvent;
|
||||
call_ringing: ICallRingingEvent;
|
||||
call_answered: ICallAnsweredEvent;
|
||||
call_ended: ICallEndedEvent;
|
||||
sip_unhandled: ISipUnhandledEvent;
|
||||
leg_added: ILegAddedEvent;
|
||||
leg_removed: ILegRemovedEvent;
|
||||
leg_state_changed: ILegStateChangedEvent;
|
||||
webrtc_ice_candidate: IWebRtcIceCandidateEvent;
|
||||
webrtc_state: IWebRtcStateEvent;
|
||||
webrtc_track: IWebRtcTrackEvent;
|
||||
webrtc_audio_rx: IWebRtcAudioRxEvent;
|
||||
voicemail_started: IVoicemailStartedEvent;
|
||||
recording_done: IRecordingDoneEvent;
|
||||
voicemail_error: IVoicemailErrorEvent;
|
||||
};
|
||||
89
ts/shared/status.ts
Normal file
89
ts/shared/status.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { IContact } from '../config.ts';
|
||||
|
||||
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
|
||||
export type TCallDirection = 'inbound' | 'outbound';
|
||||
|
||||
export interface IProviderStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
registered: boolean;
|
||||
publicIp: string | null;
|
||||
}
|
||||
|
||||
export interface IDeviceStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
address: string | null;
|
||||
port: number;
|
||||
aor: string | null;
|
||||
connected: boolean;
|
||||
isBrowser: boolean;
|
||||
}
|
||||
|
||||
export interface IActiveLeg {
|
||||
id: string;
|
||||
type: TLegType;
|
||||
state: string;
|
||||
codec: string | null;
|
||||
rtpPort: number | null;
|
||||
remoteMedia: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IActiveCall {
|
||||
id: string;
|
||||
direction: TCallDirection;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
legs: Map<string, IActiveLeg>;
|
||||
}
|
||||
|
||||
export interface IHistoryLeg {
|
||||
id: string;
|
||||
type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: TCallDirection;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
legs: IHistoryLeg[];
|
||||
}
|
||||
|
||||
export interface ILegStatus extends IActiveLeg {
|
||||
pktSent: number;
|
||||
pktReceived: number;
|
||||
transcoding: boolean;
|
||||
}
|
||||
|
||||
export interface ICallStatus {
|
||||
id: string;
|
||||
direction: TCallDirection;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
legs: ILegStatus[];
|
||||
}
|
||||
|
||||
export interface IStatusSnapshot {
|
||||
instanceId: string;
|
||||
uptime: number;
|
||||
lanIp: string;
|
||||
providers: IProviderStatus[];
|
||||
devices: IDeviceStatus[];
|
||||
calls: ICallStatus[];
|
||||
callHistory: ICallHistoryEntry[];
|
||||
contacts: IContact[];
|
||||
voicemailCounts: Record<string, number>;
|
||||
}
|
||||
710
ts/sipproxy.ts
710
ts/sipproxy.ts
@@ -1,36 +1,20 @@
|
||||
/**
|
||||
* SIP proxy — entry point.
|
||||
* SIP proxy bootstrap.
|
||||
*
|
||||
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
|
||||
* TypeScript is the control plane:
|
||||
* - Loads config and pushes it to Rust
|
||||
* - Receives high-level events (incoming calls, registration, etc.)
|
||||
* - Drives the web dashboard
|
||||
* - Manages IVR, voicemail, announcements
|
||||
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
|
||||
*
|
||||
* No raw SIP ever touches TypeScript.
|
||||
* Spawns the Rust proxy-engine, wires runtime state/event handling,
|
||||
* and starts the web dashboard plus browser signaling layer.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadConfig } from './config.ts';
|
||||
import type { IAppConfig } from './config.ts';
|
||||
import { loadConfig, type IAppConfig } from './config.ts';
|
||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||
import {
|
||||
initWebRtcSignaling,
|
||||
sendToBrowserDevice,
|
||||
getAllBrowserDeviceIds,
|
||||
getBrowserDeviceWs,
|
||||
} from './webrtcbridge.ts';
|
||||
import { initAnnouncement } from './announcement.ts';
|
||||
import { PromptCache } from './call/prompt-cache.ts';
|
||||
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
|
||||
import { VoiceboxManager } from './voicebox.ts';
|
||||
import {
|
||||
initProxyEngine,
|
||||
configureProxyEngine,
|
||||
onProxyEvent,
|
||||
hangupCall,
|
||||
makeCall,
|
||||
shutdownProxyEngine,
|
||||
@@ -38,640 +22,200 @@ import {
|
||||
webrtcIce,
|
||||
webrtcLink,
|
||||
webrtcClose,
|
||||
addLeg,
|
||||
removeLeg,
|
||||
} from './proxybridge.ts';
|
||||
import type {
|
||||
IIncomingCallEvent,
|
||||
IOutboundCallEvent,
|
||||
ICallEndedEvent,
|
||||
IProviderRegisteredEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
} from './proxybridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
|
||||
import { StatusStore } from './runtime/status-store.ts';
|
||||
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
|
||||
|
||||
let appConfig: IAppConfig = loadConfig();
|
||||
|
||||
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const startTime = Date.now();
|
||||
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const statusStore = new StatusStore(appConfig);
|
||||
const webRtcLinks = new WebRtcLinkManager();
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
initWebRtcSignaling({ log });
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
function log(msg: string): void {
|
||||
const line = `${now()} ${msg}\n`;
|
||||
function log(message: string): void {
|
||||
const line = `${now()} ${message}\n`;
|
||||
fs.appendFileSync(LOG_PATH, line);
|
||||
process.stdout.write(line);
|
||||
broadcastWs('log', { message: msg });
|
||||
broadcastWs('log', { message });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shadow state — maintained from Rust events for the dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IProviderStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
registered: boolean;
|
||||
publicIp: string | null;
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
interface IDeviceStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
address: string | null;
|
||||
port: number;
|
||||
connected: boolean;
|
||||
isBrowser: boolean;
|
||||
}
|
||||
|
||||
interface IActiveLeg {
|
||||
id: string;
|
||||
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
|
||||
state: string;
|
||||
codec: string | null;
|
||||
rtpPort: number | null;
|
||||
remoteMedia: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface IActiveCall {
|
||||
id: string;
|
||||
direction: string;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
legs: Map<string, IActiveLeg>;
|
||||
}
|
||||
|
||||
interface IHistoryLeg {
|
||||
id: string;
|
||||
type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: string;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
legs: IHistoryLeg[];
|
||||
}
|
||||
|
||||
const providerStatuses = new Map<string, IProviderStatus>();
|
||||
const deviceStatuses = new Map<string, IDeviceStatus>();
|
||||
const activeCalls = new Map<string, IActiveCall>();
|
||||
const callHistory: ICallHistoryEntry[] = [];
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
// WebRTC session ↔ call linking state.
|
||||
// Both pieces (session accept + call media info) can arrive in any order.
|
||||
const webrtcSessionToCall = new Map<string, string>(); // sessionId → callId
|
||||
const webrtcCallToSession = new Map<string, string>(); // callId → sessionId
|
||||
const pendingCallMedia = new Map<string, { addr: string; port: number; sipPt: number }>(); // callId → provider media info
|
||||
|
||||
// Initialize provider statuses from config (all start as unregistered).
|
||||
for (const p of appConfig.providers) {
|
||||
providerStatuses.set(p.id, {
|
||||
id: p.id,
|
||||
displayName: p.displayName,
|
||||
registered: false,
|
||||
publicIp: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize device statuses from config.
|
||||
for (const d of appConfig.devices) {
|
||||
deviceStatuses.set(d.id, {
|
||||
id: d.id,
|
||||
displayName: d.displayName,
|
||||
address: null,
|
||||
port: 0,
|
||||
connected: false,
|
||||
isBrowser: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize subsystems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const promptCache = new PromptCache(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
|
||||
// WebRTC signaling (browser device registration).
|
||||
initWebRtcSignaling({ log });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status snapshot (fed to web dashboard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getStatus() {
|
||||
// Merge SIP devices (from Rust) + browser devices (from TS WebSocket).
|
||||
const devices = [...deviceStatuses.values()];
|
||||
for (const bid of getAllBrowserDeviceIds()) {
|
||||
devices.push({
|
||||
id: bid,
|
||||
displayName: 'Browser',
|
||||
address: null,
|
||||
port: 0,
|
||||
connected: true,
|
||||
isBrowser: true,
|
||||
});
|
||||
}
|
||||
|
||||
function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
|
||||
return {
|
||||
instanceId,
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
lanIp: appConfig.proxy.lanIp,
|
||||
providers: [...providerStatuses.values()],
|
||||
devices,
|
||||
calls: [...activeCalls.values()].map((c) => ({
|
||||
...c,
|
||||
duration: Math.floor((Date.now() - c.startedAt) / 1000),
|
||||
legs: [...c.legs.values()].map((l) => ({
|
||||
id: l.id,
|
||||
type: l.type,
|
||||
state: l.state,
|
||||
codec: l.codec,
|
||||
rtpPort: l.rtpPort,
|
||||
remoteMedia: l.remoteMedia,
|
||||
metadata: l.metadata || {},
|
||||
pktSent: 0,
|
||||
pktReceived: 0,
|
||||
transcoding: false,
|
||||
})),
|
||||
})),
|
||||
callHistory,
|
||||
contacts: appConfig.contacts || [],
|
||||
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
|
||||
proxy: config.proxy,
|
||||
providers: config.providers,
|
||||
devices: config.devices,
|
||||
routing: config.routing,
|
||||
voiceboxes: config.voiceboxes ?? [],
|
||||
ivr: config.ivr,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start Rust proxy engine
|
||||
// ---------------------------------------------------------------------------
|
||||
function getStatus() {
|
||||
return statusStore.buildStatusSnapshot(
|
||||
instanceId,
|
||||
startTime,
|
||||
getAllBrowserDeviceIds(),
|
||||
voiceboxManager.getAllUnheardCounts(),
|
||||
);
|
||||
}
|
||||
|
||||
function requestWebRtcLink(callId: string, sessionId: string, media: IProviderMediaInfo): void {
|
||||
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
|
||||
void webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
|
||||
log(`[webrtc] link result: ${ok}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function configureRuntime(config: IAppConfig): Promise<boolean> {
|
||||
return configureProxyEngine(buildProxyConfig(config));
|
||||
}
|
||||
|
||||
async function reloadConfig(): Promise<void> {
|
||||
try {
|
||||
const previousConfig = appConfig;
|
||||
const nextConfig = loadConfig();
|
||||
|
||||
appConfig = nextConfig;
|
||||
statusStore.updateConfig(nextConfig);
|
||||
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||
|
||||
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
||||
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
|
||||
}
|
||||
if (nextConfig.proxy.webUiPort !== previousConfig.proxy.webUiPort) {
|
||||
log('[config] proxy.webUiPort changed; restart required for web UI rebinding');
|
||||
}
|
||||
|
||||
const configured = await configureRuntime(nextConfig);
|
||||
if (configured) {
|
||||
log('[config] reloaded - proxy engine reconfigured');
|
||||
} else {
|
||||
log('[config] reload failed - proxy engine rejected config');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
log(`[config] reload failed: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function startProxyEngine(): Promise<void> {
|
||||
const ok = await initProxyEngine(log);
|
||||
if (!ok) {
|
||||
const started = await initProxyEngine(log);
|
||||
if (!started) {
|
||||
log('[FATAL] failed to start proxy engine');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Subscribe to events from Rust BEFORE sending configure.
|
||||
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
|
||||
const ps = providerStatuses.get(data.provider_id);
|
||||
if (ps) {
|
||||
const wasRegistered = ps.registered;
|
||||
ps.registered = data.registered;
|
||||
ps.publicIp = data.public_ip;
|
||||
if (data.registered && !wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
|
||||
} else if (!data.registered && wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registration lost`);
|
||||
}
|
||||
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
|
||||
const ds = deviceStatuses.get(data.device_id);
|
||||
if (ds) {
|
||||
ds.address = data.address;
|
||||
ds.port = data.port;
|
||||
ds.connected = true;
|
||||
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
|
||||
log(`[call] incoming: ${data.from_uri} → ${data.to_number} via ${data.provider_id} (${data.call_id})`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'inbound',
|
||||
callerNumber: data.from_uri,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'ringing',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
|
||||
log(`[call] outbound: device ${data.from_device} → ${data.to_number} (${data.call_id})`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: data.from_device,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_call_started', (data: any) => {
|
||||
log(`[call] outbound started: ${data.call_id} → ${data.number} via ${data.provider_id}`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: data.number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
|
||||
// Notify all browser devices — they can connect via WebRTC to listen/talk.
|
||||
const browserIds = getAllBrowserDeviceIds();
|
||||
for (const bid of browserIds) {
|
||||
sendToBrowserDevice(bid, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.number,
|
||||
deviceId: bid,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('call_ringing', (data: { call_id: string }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) call.state = 'ringing';
|
||||
});
|
||||
|
||||
onProxyEvent('call_answered', (data: { call_id: string; provider_media_addr?: string; provider_media_port?: number; sip_pt?: number }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
call.state = 'connected';
|
||||
log(`[call] ${data.call_id} connected`);
|
||||
|
||||
// Enrich provider leg with media info from the answered event.
|
||||
if (data.provider_media_addr && data.provider_media_port) {
|
||||
for (const leg of call.legs.values()) {
|
||||
if (leg.type === 'sip-provider') {
|
||||
leg.remoteMedia = `${data.provider_media_addr}:${data.provider_media_port}`;
|
||||
if (data.sip_pt !== undefined) {
|
||||
const codecNames: Record<number, string> = { 0: 'PCMU', 8: 'PCMA', 9: 'G.722', 111: 'Opus' };
|
||||
leg.codec = codecNames[data.sip_pt] || `PT${data.sip_pt}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to link WebRTC session to this call for audio bridging.
|
||||
if (data.provider_media_addr && data.provider_media_port) {
|
||||
const sessionId = webrtcCallToSession.get(data.call_id);
|
||||
if (sessionId) {
|
||||
// Both session and media info available — link now.
|
||||
const sipPt = data.sip_pt ?? 9;
|
||||
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${data.call_id} media=${data.provider_media_addr}:${data.provider_media_port} pt=${sipPt}`);
|
||||
webrtcLink(sessionId, data.call_id, data.provider_media_addr, data.provider_media_port, sipPt).then((ok) => {
|
||||
log(`[webrtc] link result: ${ok}`);
|
||||
});
|
||||
} else {
|
||||
// Session not yet accepted — store media info for when it arrives.
|
||||
pendingCallMedia.set(data.call_id, {
|
||||
addr: data.provider_media_addr,
|
||||
port: data.provider_media_port,
|
||||
sipPt: data.sip_pt ?? 9,
|
||||
});
|
||||
log(`[webrtc] media info cached for call=${data.call_id}, waiting for session accept`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
|
||||
// Snapshot legs with metadata for history.
|
||||
const historyLegs: IHistoryLeg[] = [];
|
||||
for (const [, leg] of call.legs) {
|
||||
historyLegs.push({
|
||||
id: leg.id,
|
||||
type: leg.type,
|
||||
metadata: leg.metadata || {},
|
||||
});
|
||||
}
|
||||
// Move to history.
|
||||
callHistory.unshift({
|
||||
id: call.id,
|
||||
direction: call.direction,
|
||||
callerNumber: call.callerNumber,
|
||||
calleeNumber: call.calleeNumber,
|
||||
startedAt: call.startedAt,
|
||||
duration: data.duration,
|
||||
legs: historyLegs,
|
||||
});
|
||||
if (callHistory.length > MAX_HISTORY) callHistory.pop();
|
||||
activeCalls.delete(data.call_id);
|
||||
|
||||
// Notify browser(s) that the call ended.
|
||||
broadcastWs('webrtc-call-ended', { callId: data.call_id });
|
||||
|
||||
// Clean up WebRTC session mappings.
|
||||
const sessionId = webrtcCallToSession.get(data.call_id);
|
||||
if (sessionId) {
|
||||
webrtcCallToSession.delete(data.call_id);
|
||||
webrtcSessionToCall.delete(sessionId);
|
||||
webrtcClose(sessionId).catch(() => {});
|
||||
}
|
||||
pendingCallMedia.delete(data.call_id);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('sip_unhandled', (data: any) => {
|
||||
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
|
||||
});
|
||||
|
||||
// Leg events (multiparty) — update shadow state so the dashboard shows legs.
|
||||
onProxyEvent('leg_added', (data: any) => {
|
||||
log(`[leg] added: call=${data.call_id} leg=${data.leg_id} kind=${data.kind} state=${data.state}`);
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
type: data.kind,
|
||||
state: data.state,
|
||||
codec: data.codec ?? null,
|
||||
rtpPort: data.rtpPort ?? null,
|
||||
remoteMedia: data.remoteMedia ?? null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('leg_removed', (data: any) => {
|
||||
log(`[leg] removed: call=${data.call_id} leg=${data.leg_id}`);
|
||||
activeCalls.get(data.call_id)?.legs.delete(data.leg_id);
|
||||
});
|
||||
|
||||
onProxyEvent('leg_state_changed', (data: any) => {
|
||||
log(`[leg] state: call=${data.call_id} leg=${data.leg_id} → ${data.state}`);
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (!call) return;
|
||||
const leg = call.legs.get(data.leg_id);
|
||||
if (leg) {
|
||||
leg.state = data.state;
|
||||
if (data.metadata) leg.metadata = data.metadata;
|
||||
} else {
|
||||
// Initial legs (provider/device) don't emit leg_added — create on first state change.
|
||||
const legId: string = data.leg_id;
|
||||
const type = legId.includes('-prov') ? 'sip-provider' : legId.includes('-dev') ? 'sip-device' : 'webrtc';
|
||||
call.legs.set(data.leg_id, {
|
||||
id: data.leg_id,
|
||||
type,
|
||||
state: data.state,
|
||||
codec: null,
|
||||
rtpPort: null,
|
||||
remoteMedia: null,
|
||||
metadata: data.metadata || {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// WebRTC events from Rust — forward ICE candidates to browser via WebSocket.
|
||||
onProxyEvent('webrtc_ice_candidate', (data: any) => {
|
||||
// Find the browser's WebSocket by session ID and send the ICE candidate.
|
||||
broadcastWs('webrtc-ice', {
|
||||
sessionId: data.session_id,
|
||||
candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index },
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_state', (data: any) => {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_track', (data: any) => {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
|
||||
});
|
||||
|
||||
onProxyEvent('webrtc_audio_rx', (data: any) => {
|
||||
if (data.packet_count === 1 || data.packet_count === 50) {
|
||||
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Voicemail events.
|
||||
onProxyEvent('voicemail_started', (data: any) => {
|
||||
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
|
||||
});
|
||||
|
||||
onProxyEvent('recording_done', (data: any) => {
|
||||
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
|
||||
// Save voicemail metadata via VoiceboxManager.
|
||||
voiceboxManager.addMessage?.('default', {
|
||||
callerNumber: data.caller_number || 'Unknown',
|
||||
callerName: null,
|
||||
fileName: data.file_path,
|
||||
durationMs: data.duration_ms,
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('voicemail_error', (data: any) => {
|
||||
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
|
||||
});
|
||||
|
||||
// Send full config to Rust — this binds the SIP socket and starts registrations.
|
||||
const configured = await configureProxyEngine({
|
||||
proxy: appConfig.proxy,
|
||||
providers: appConfig.providers,
|
||||
devices: appConfig.devices,
|
||||
routing: appConfig.routing,
|
||||
registerProxyEventHandlers({
|
||||
log,
|
||||
statusStore,
|
||||
voiceboxManager,
|
||||
webRtcLinks,
|
||||
getBrowserDeviceIds: getAllBrowserDeviceIds,
|
||||
sendToBrowserDevice,
|
||||
broadcast: broadcastWs,
|
||||
onLinkWebRtcSession: requestWebRtcLink,
|
||||
onCloseWebRtcSession: (sessionId) => {
|
||||
void webrtcClose(sessionId);
|
||||
},
|
||||
});
|
||||
|
||||
const configured = await configureRuntime(appConfig);
|
||||
if (!configured) {
|
||||
log('[FATAL] failed to configure proxy engine');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
|
||||
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
|
||||
const providerList = appConfig.providers.map((provider) => provider.displayName).join(', ');
|
||||
const deviceList = appConfig.devices.map((device) => device.displayName).join(', ');
|
||||
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
|
||||
|
||||
// Generate TTS audio (WAV files on disk, played by Rust audio_player).
|
||||
try {
|
||||
await initAnnouncement(log);
|
||||
|
||||
// Pre-generate prompts.
|
||||
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
|
||||
for (const vb of appConfig.voiceboxes ?? []) {
|
||||
if (!vb.enabled) continue;
|
||||
const promptId = `voicemail-greeting-${vb.id}`;
|
||||
if (vb.greetingWavPath) {
|
||||
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
|
||||
} else {
|
||||
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
if (appConfig.ivr?.enabled) {
|
||||
for (const menu of appConfig.ivr.menus) {
|
||||
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
|
||||
} catch (e) {
|
||||
log(`[tts] init failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
initWebUi(
|
||||
initWebUi({
|
||||
port: appConfig.proxy.webUiPort,
|
||||
getStatus,
|
||||
log,
|
||||
(number, deviceId, providerId) => {
|
||||
// Outbound calls from dashboard — send make_call command to Rust.
|
||||
onStartCall: (number, deviceId, providerId) => {
|
||||
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
|
||||
// Fire-and-forget — the async result comes via events.
|
||||
makeCall(number, deviceId, providerId).then((callId) => {
|
||||
void makeCall(number, deviceId, providerId).then((callId) => {
|
||||
if (callId) {
|
||||
log(`[dashboard] call started: ${callId}`);
|
||||
activeCalls.set(callId, {
|
||||
id: callId,
|
||||
direction: 'outbound',
|
||||
callerNumber: null,
|
||||
calleeNumber: number,
|
||||
providerUsed: providerId || null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
legs: new Map(),
|
||||
});
|
||||
statusStore.noteDashboardCallStarted(callId, number, providerId);
|
||||
} else {
|
||||
log(`[dashboard] call failed for ${number}`);
|
||||
}
|
||||
});
|
||||
// Return a temporary ID so the frontend doesn't show "failed" immediately.
|
||||
|
||||
return { id: `pending-${Date.now()}` };
|
||||
},
|
||||
(callId) => {
|
||||
hangupCall(callId);
|
||||
onHangupCall: (callId) => {
|
||||
void hangupCall(callId);
|
||||
return true;
|
||||
},
|
||||
() => {
|
||||
// Config saved — reconfigure Rust engine.
|
||||
try {
|
||||
const fresh = loadConfig();
|
||||
Object.assign(appConfig, fresh);
|
||||
|
||||
// Update shadow state.
|
||||
for (const p of fresh.providers) {
|
||||
if (!providerStatuses.has(p.id)) {
|
||||
providerStatuses.set(p.id, {
|
||||
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const d of fresh.devices) {
|
||||
if (!deviceStatuses.has(d.id)) {
|
||||
deviceStatuses.set(d.id, {
|
||||
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-send config to Rust.
|
||||
configureProxyEngine({
|
||||
proxy: fresh.proxy,
|
||||
providers: fresh.providers,
|
||||
devices: fresh.devices,
|
||||
routing: fresh.routing,
|
||||
}).then((ok) => {
|
||||
if (ok) log('[config] reloaded — proxy engine reconfigured');
|
||||
else log('[config] reload failed — proxy engine rejected config');
|
||||
});
|
||||
} catch (e: any) {
|
||||
log(`[config] reload failed: ${e.message}`);
|
||||
}
|
||||
},
|
||||
undefined, // callManager — legacy, replaced by Rust proxy-engine
|
||||
voiceboxManager, // voiceboxManager
|
||||
// WebRTC signaling → forwarded to Rust proxy-engine.
|
||||
async (sessionId, sdp, ws) => {
|
||||
onConfigSaved: reloadConfig,
|
||||
voiceboxManager,
|
||||
onWebRtcOffer: async (sessionId, sdp, ws) => {
|
||||
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
|
||||
if (!sdp || typeof sdp !== 'string' || sdp.length < 10) {
|
||||
log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
|
||||
const result = await webrtcOffer(sessionId, sdp);
|
||||
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
|
||||
if (result?.sdp) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
|
||||
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
|
||||
} else {
|
||||
log(`[webrtc] ERROR: no answer SDP from Rust`);
|
||||
return;
|
||||
}
|
||||
|
||||
log('[webrtc] ERROR: no answer SDP from Rust');
|
||||
},
|
||||
async (sessionId, candidate) => {
|
||||
onWebRtcIce: async (sessionId, candidate) => {
|
||||
await webrtcIce(sessionId, candidate);
|
||||
},
|
||||
async (sessionId) => {
|
||||
onWebRtcClose: async (sessionId) => {
|
||||
webRtcLinks.removeSession(sessionId);
|
||||
await webrtcClose(sessionId);
|
||||
},
|
||||
// onWebRtcAccept — browser has accepted a call, linking session to call.
|
||||
(callId: string, sessionId: string) => {
|
||||
onWebRtcAccept: (callId, sessionId) => {
|
||||
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
|
||||
|
||||
// Store bidirectional mapping.
|
||||
webrtcSessionToCall.set(sessionId, callId);
|
||||
webrtcCallToSession.set(callId, sessionId);
|
||||
|
||||
// Check if we already have media info for this call (provider answered first).
|
||||
const media = pendingCallMedia.get(callId);
|
||||
if (media) {
|
||||
pendingCallMedia.delete(callId);
|
||||
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
|
||||
webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
|
||||
log(`[webrtc] link result: ${ok}`);
|
||||
});
|
||||
} else {
|
||||
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
||||
const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
|
||||
if (pendingMedia) {
|
||||
requestWebRtcLink(callId, sessionId, pendingMedia);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
void startProxyEngine();
|
||||
|
||||
startProxyEngine();
|
||||
process.on('SIGINT', () => {
|
||||
log('SIGINT, exiting');
|
||||
shutdownProxyEngine();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });
|
||||
process.on('SIGTERM', () => {
|
||||
log('SIGTERM, exiting');
|
||||
shutdownProxyEngine();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* - Browser device registration/unregistration via WebSocket
|
||||
* - WS → deviceId mapping
|
||||
*
|
||||
* All WebRTC media logic (PeerConnection, RTP, transcoding) lives in
|
||||
* ts/call/webrtc-leg.ts and is managed by the CallManager.
|
||||
* All WebRTC media logic (PeerConnection, RTP, transcoding, mixer wiring)
|
||||
* lives in the Rust proxy-engine. This module only tracks browser sessions.
|
||||
*/
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
@@ -39,7 +39,7 @@ export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void {
|
||||
|
||||
/**
|
||||
* Handle a WebRTC signaling message from a browser client.
|
||||
* Only handles registration; offer/ice/hangup are routed through CallManager.
|
||||
* Only handles registration; offer/ice/hangup are routed through frontend.ts.
|
||||
*/
|
||||
export function handleWebRtcSignaling(
|
||||
ws: WebSocket,
|
||||
@@ -51,7 +51,7 @@ export function handleWebRtcSignaling(
|
||||
handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp);
|
||||
}
|
||||
// Other webrtc-* types (offer, ice, hangup, accept) are handled
|
||||
// by the CallManager via frontend.ts WebSocket handler.
|
||||
// by the frontend.ts WebSocket handler and forwarded to Rust.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,13 +64,6 @@ export function sendToBrowserDevice(deviceId: string, data: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebSocket for a browser device (used by CallManager to create WebRtcLegs).
|
||||
*/
|
||||
export function getBrowserDeviceWs(deviceId: string): WebSocket | null {
|
||||
return deviceIdToWs.get(deviceId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered browser device IDs.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.19.1',
|
||||
version: '1.23.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const VIEW_TABS = [
|
||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||
{ name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail },
|
||||
{ name: 'IVR', iconName: 'lucide:list-tree', element: SipproxyViewIvr },
|
||||
{ name: 'IVR', iconName: 'lucide:ListTree', element: SipproxyViewIvr },
|
||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||
|
||||
@@ -41,11 +41,10 @@ export class SipproxyDevices extends DeesElement {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
key: 'address',
|
||||
header: 'Contact',
|
||||
renderer: (_val: any, row: any) => {
|
||||
const c = row.contact;
|
||||
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
|
||||
const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -422,7 +422,7 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Transfer',
|
||||
iconName: 'lucide:arrow-right-left',
|
||||
iconName: 'lucide:ArrowRightLeft',
|
||||
action: async (modalRef: any) => {
|
||||
if (!targetCallId || !targetLegId) {
|
||||
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
||||
@@ -620,7 +620,7 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
title: 'Inbound',
|
||||
value: inboundCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-incoming',
|
||||
icon: 'lucide:PhoneIncoming',
|
||||
description: 'Incoming calls',
|
||||
},
|
||||
{
|
||||
@@ -628,7 +628,7 @@ export class SipproxyViewCalls extends DeesElement {
|
||||
title: 'Outbound',
|
||||
value: outboundCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-outgoing',
|
||||
icon: 'lucide:PhoneOutgoing',
|
||||
description: 'Outgoing calls',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,7 +140,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
title: 'Total Menus',
|
||||
value: ivr.menus.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:list-tree',
|
||||
icon: 'lucide:ListTree',
|
||||
description: 'IVR menu definitions',
|
||||
},
|
||||
{
|
||||
@@ -148,7 +148,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
title: 'Entry Menu',
|
||||
value: entryMenu?.name || '(none)',
|
||||
type: 'text' as any,
|
||||
icon: 'lucide:door-open',
|
||||
icon: 'lucide:DoorOpen',
|
||||
description: entryMenu ? `ID: ${entryMenu.id}` : 'No entry menu set',
|
||||
},
|
||||
{
|
||||
@@ -156,7 +156,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
title: 'Status',
|
||||
value: ivr.enabled ? 'Enabled' : 'Disabled',
|
||||
type: 'text' as any,
|
||||
icon: ivr.enabled ? 'lucide:check-circle' : 'lucide:x-circle',
|
||||
icon: ivr.enabled ? 'lucide:CheckCircle' : 'lucide:XCircle',
|
||||
color: ivr.enabled ? 'hsl(142.1 76.2% 36.3%)' : 'hsl(0 84.2% 60.2%)',
|
||||
description: ivr.enabled ? 'IVR is active' : 'IVR is inactive',
|
||||
},
|
||||
@@ -228,7 +228,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Set as Entry',
|
||||
iconName: 'lucide:door-open' as any,
|
||||
iconName: 'lucide:DoorOpen' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.setEntryMenu(item.id);
|
||||
@@ -236,7 +236,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2' as any,
|
||||
iconName: 'lucide:Trash2' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IIvrMenu }) => {
|
||||
await this.confirmDeleteMenu(item);
|
||||
@@ -295,7 +295,7 @@ export class SipproxyViewIvr extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
action: async (modalRef: any) => {
|
||||
const ivr = this.getIvrConfig();
|
||||
const menus = ivr.menus.filter((m) => m.id !== menu.id);
|
||||
|
||||
@@ -107,7 +107,7 @@ export class SipproxyViewOverview extends DeesElement {
|
||||
title: 'Inbound Calls',
|
||||
value: inboundCalls,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-incoming',
|
||||
icon: 'lucide:PhoneIncoming',
|
||||
description: 'Currently active',
|
||||
},
|
||||
{
|
||||
@@ -115,7 +115,7 @@ export class SipproxyViewOverview extends DeesElement {
|
||||
title: 'Outbound Calls',
|
||||
value: outboundCalls,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-outgoing',
|
||||
icon: 'lucide:PhoneOutgoing',
|
||||
description: 'Currently active',
|
||||
},
|
||||
{
|
||||
@@ -186,11 +186,10 @@ export class SipproxyViewOverview extends DeesElement {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
key: 'address',
|
||||
header: 'Contact',
|
||||
renderer: (_val: any, row: any) => {
|
||||
const c = row.contact;
|
||||
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
|
||||
const text = row.address ? (row.port ? `${row.address}:${row.port}` : row.address) : '--';
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
title: 'Registered',
|
||||
value: registered,
|
||||
type: 'number',
|
||||
icon: 'lucide:check-circle',
|
||||
icon: 'lucide:CheckCircle',
|
||||
color: 'hsl(142.1 76.2% 36.3%)',
|
||||
description: 'Active registrations',
|
||||
},
|
||||
@@ -95,7 +95,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
title: 'Unregistered',
|
||||
value: unregistered,
|
||||
type: 'number',
|
||||
icon: 'lucide:alert-circle',
|
||||
icon: 'lucide:AlertCircle',
|
||||
color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined,
|
||||
description: unregistered > 0 ? 'Needs attention' : 'All healthy',
|
||||
},
|
||||
@@ -153,7 +153,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.confirmDelete(actionData.item);
|
||||
@@ -164,173 +164,346 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Sipgate',
|
||||
iconName: 'lucide:phone',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal(PROVIDER_TEMPLATES.sipgate, 'Sipgate');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add O2/Alice',
|
||||
iconName: 'lucide:phone',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal(PROVIDER_TEMPLATES.o2, 'O2/Alice');
|
||||
await this.openAddStepper();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- add provider modal --------------------------------------------------
|
||||
// ---- add provider stepper ------------------------------------------------
|
||||
|
||||
private async openAddModal(
|
||||
template?: typeof PROVIDER_TEMPLATES.sipgate,
|
||||
templateName?: string,
|
||||
) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
private async openAddStepper() {
|
||||
const { DeesStepper } = await import('@design.estate/dees-catalog');
|
||||
type TDeesStepper = InstanceType<typeof DeesStepper>;
|
||||
// IStep / menuOptions types: we keep content typing loose (`any[]`) to
|
||||
// avoid having to import tsclass IMenuItem just for one parameter annotation.
|
||||
|
||||
const formData = {
|
||||
displayName: templateName || '',
|
||||
domain: template?.domain || '',
|
||||
outboundProxyAddress: template?.outboundProxy?.address || '',
|
||||
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060),
|
||||
type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice';
|
||||
interface IAccumulator {
|
||||
providerType: TProviderType;
|
||||
displayName: string;
|
||||
domain: string;
|
||||
outboundProxyAddress: string;
|
||||
outboundProxyPort: string;
|
||||
username: string;
|
||||
password: string;
|
||||
// Advanced — exposed in step 4
|
||||
registerIntervalSec: string;
|
||||
codecs: string;
|
||||
earlyMediaSilence: boolean;
|
||||
}
|
||||
|
||||
const accumulator: IAccumulator = {
|
||||
providerType: 'Custom',
|
||||
displayName: '',
|
||||
domain: '',
|
||||
outboundProxyAddress: '',
|
||||
outboundProxyPort: '5060',
|
||||
username: '',
|
||||
password: '',
|
||||
registerIntervalSec: String(template?.registerIntervalSec ?? 300),
|
||||
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101',
|
||||
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false,
|
||||
registerIntervalSec: '300',
|
||||
codecs: '9, 0, 8, 101',
|
||||
earlyMediaSilence: false,
|
||||
};
|
||||
|
||||
const heading = template
|
||||
? `Add ${templateName} Provider`
|
||||
: 'Add Provider';
|
||||
// Snapshot the currently-selected step's form (if any) into accumulator.
|
||||
const snapshotActiveForm = async (stepper: TDeesStepper) => {
|
||||
const form = stepper.activeForm;
|
||||
if (!form) return;
|
||||
const data: Record<string, any> = await form.collectFormData();
|
||||
Object.assign(accumulator, data);
|
||||
};
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading,
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
// Overwrite template-owned fields. Keep user-owned fields (username,
|
||||
// password) untouched. displayName is replaced only when empty or still
|
||||
// holds a branded auto-fill.
|
||||
const applyTemplate = (type: TProviderType) => {
|
||||
const tpl =
|
||||
type === 'Sipgate' ? PROVIDER_TEMPLATES.sipgate
|
||||
: type === 'O2/Alice' ? PROVIDER_TEMPLATES.o2
|
||||
: null;
|
||||
if (!tpl) return;
|
||||
accumulator.domain = tpl.domain;
|
||||
accumulator.outboundProxyAddress = tpl.outboundProxy.address;
|
||||
accumulator.outboundProxyPort = String(tpl.outboundProxy.port);
|
||||
accumulator.registerIntervalSec = String(tpl.registerIntervalSec);
|
||||
accumulator.codecs = tpl.codecs.join(', ');
|
||||
accumulator.earlyMediaSilence = tpl.quirks.earlyMediaSilence;
|
||||
if (
|
||||
!accumulator.displayName ||
|
||||
accumulator.displayName === 'Sipgate' ||
|
||||
accumulator.displayName === 'O2/Alice'
|
||||
) {
|
||||
accumulator.displayName = type;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step builders (called after step 1 so accumulator is populated) ---
|
||||
|
||||
const buildConnectionStep = (): any => ({
|
||||
title: 'Connection',
|
||||
content: html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.key=${'displayName'}
|
||||
.label=${'Display Name'}
|
||||
.value=${formData.displayName}
|
||||
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
|
||||
.value=${accumulator.displayName}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'domain'}
|
||||
.label=${'Domain'}
|
||||
.value=${formData.domain}
|
||||
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
|
||||
.value=${accumulator.domain}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyAddress'}
|
||||
.label=${'Outbound Proxy Address'}
|
||||
.value=${formData.outboundProxyAddress}
|
||||
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
|
||||
.value=${accumulator.outboundProxyAddress}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'outboundProxyPort'}
|
||||
.label=${'Outbound Proxy Port'}
|
||||
.value=${formData.outboundProxyPort}
|
||||
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
|
||||
.value=${accumulator.outboundProxyPort}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Continue',
|
||||
iconName: 'lucide:arrow-right',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
await snapshotActiveForm(stepper);
|
||||
stepper.goNext();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buildCredentialsStep = (): any => ({
|
||||
title: 'Credentials',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.key=${'username'}
|
||||
.label=${'Username / Auth ID'}
|
||||
.value=${formData.username}
|
||||
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
|
||||
.value=${accumulator.username}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'password'}
|
||||
.label=${'Password'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${formData.password}
|
||||
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
|
||||
.value=${accumulator.password}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Continue',
|
||||
iconName: 'lucide:arrow-right',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
await snapshotActiveForm(stepper);
|
||||
stepper.goNext();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buildAdvancedStep = (): any => ({
|
||||
title: 'Advanced',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.key=${'registerIntervalSec'}
|
||||
.label=${'Register Interval (sec)'}
|
||||
.value=${formData.registerIntervalSec}
|
||||
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
|
||||
.value=${accumulator.registerIntervalSec}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'codecs'}
|
||||
.label=${'Codecs (comma-separated payload types)'}
|
||||
.value=${formData.codecs}
|
||||
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
|
||||
.value=${accumulator.codecs}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'earlyMediaSilence'}
|
||||
.label=${'Early Media Silence (quirk)'}
|
||||
.value=${formData.earlyMediaSilence}
|
||||
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
|
||||
.value=${accumulator.earlyMediaSilence}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
name: 'Continue',
|
||||
iconName: 'lucide:arrow-right',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
await snapshotActiveForm(stepper);
|
||||
// Rebuild the review step so its rendering reflects the latest
|
||||
// accumulator values (the review step captures values at build time).
|
||||
stepper.steps = [...stepper.steps.slice(0, 4), buildReviewStep()];
|
||||
await (stepper as any).updateComplete;
|
||||
stepper.goNext();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.displayName.trim() || !formData.domain.trim()) {
|
||||
deesCatalog.DeesToast.error('Display name and domain are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const providerId = slugify(formData.displayName);
|
||||
const codecs = formData.codecs
|
||||
],
|
||||
});
|
||||
|
||||
const buildReviewStep = (): any => {
|
||||
const resolvedId = slugify(accumulator.displayName);
|
||||
return {
|
||||
title: 'Review & Create',
|
||||
content: html`
|
||||
<dees-panel>
|
||||
<div
|
||||
style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:.85rem;padding:8px 4px;"
|
||||
>
|
||||
<div style="color:#94a3b8;">Type</div>
|
||||
<div>${accumulator.providerType}</div>
|
||||
<div style="color:#94a3b8;">Display Name</div>
|
||||
<div>${accumulator.displayName}</div>
|
||||
<div style="color:#94a3b8;">ID</div>
|
||||
<div style="font-family:'JetBrains Mono',monospace;">${resolvedId}</div>
|
||||
<div style="color:#94a3b8;">Domain</div>
|
||||
<div>${accumulator.domain}</div>
|
||||
<div style="color:#94a3b8;">Outbound Proxy</div>
|
||||
<div>
|
||||
${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort}
|
||||
</div>
|
||||
<div style="color:#94a3b8;">Username</div>
|
||||
<div>${accumulator.username}</div>
|
||||
<div style="color:#94a3b8;">Password</div>
|
||||
<div>${'*'.repeat(Math.min(accumulator.password.length, 12))}</div>
|
||||
<div style="color:#94a3b8;">Register Interval</div>
|
||||
<div>${accumulator.registerIntervalSec}s</div>
|
||||
<div style="color:#94a3b8;">Codecs</div>
|
||||
<div>${accumulator.codecs}</div>
|
||||
<div style="color:#94a3b8;">Early-Media Silence</div>
|
||||
<div>${accumulator.earlyMediaSilence ? 'yes' : 'no'}</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Create Provider',
|
||||
iconName: 'lucide:check',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
// Collision-resolve id against current state.
|
||||
const existing = (this.appData.providers || []).map((p) => p.id);
|
||||
let uniqueId = resolvedId;
|
||||
let suffix = 2;
|
||||
while (existing.includes(uniqueId)) {
|
||||
uniqueId = `${resolvedId}-${suffix++}`;
|
||||
}
|
||||
|
||||
const parsedCodecs = accumulator.codecs
|
||||
.split(',')
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
|
||||
const newProvider: any = {
|
||||
id: providerId,
|
||||
displayName: formData.displayName.trim(),
|
||||
domain: formData.domain.trim(),
|
||||
id: uniqueId,
|
||||
displayName: accumulator.displayName.trim(),
|
||||
domain: accumulator.domain.trim(),
|
||||
outboundProxy: {
|
||||
address: formData.outboundProxyAddress.trim() || formData.domain.trim(),
|
||||
port: parseInt(formData.outboundProxyPort, 10) || 5060,
|
||||
address:
|
||||
accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(),
|
||||
port: parseInt(accumulator.outboundProxyPort, 10) || 5060,
|
||||
},
|
||||
username: formData.username.trim(),
|
||||
password: formData.password,
|
||||
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||
codecs,
|
||||
username: accumulator.username.trim(),
|
||||
password: accumulator.password,
|
||||
registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300,
|
||||
codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101],
|
||||
quirks: {
|
||||
earlyMediaSilence: formData.earlyMediaSilence,
|
||||
earlyMediaSilence: accumulator.earlyMediaSilence,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await appState.apiSaveConfig({
|
||||
addProvider: newProvider,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to save provider');
|
||||
try {
|
||||
const result = await appState.apiSaveConfig({
|
||||
addProvider: newProvider,
|
||||
});
|
||||
if (result.ok) {
|
||||
await stepper.destroy();
|
||||
deesCatalog.DeesToast.success(
|
||||
`Provider "${newProvider.displayName}" created`,
|
||||
);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to save provider');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create provider:', err);
|
||||
deesCatalog.DeesToast.error('Failed to create provider');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// --- Step 1: Provider Type ------------------------------------------------
|
||||
//
|
||||
// Note: `DeesStepper.createAndShow()` dismisses on backdrop click; a user
|
||||
// mid-form could lose work. Acceptable for v1 — revisit if users complain.
|
||||
|
||||
const typeOptions: { option: string; key: TProviderType }[] = [
|
||||
{ option: 'Custom', key: 'Custom' },
|
||||
{ option: 'Sipgate', key: 'Sipgate' },
|
||||
{ option: 'O2 / Alice', key: 'O2/Alice' },
|
||||
];
|
||||
const currentTypeOption =
|
||||
typeOptions.find((o) => o.key === accumulator.providerType) || null;
|
||||
|
||||
const typeStep: any = {
|
||||
title: 'Choose provider type',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
.key=${'providerType'}
|
||||
.label=${'Provider Type'}
|
||||
.options=${typeOptions}
|
||||
.selectedOption=${currentTypeOption}
|
||||
.enableSearch=${false}
|
||||
.required=${true}
|
||||
></dees-input-dropdown>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Continue',
|
||||
iconName: 'lucide:arrow-right',
|
||||
action: async (stepper: TDeesStepper) => {
|
||||
// `dees-input-dropdown.value` is an object `{option, key, payload?}`,
|
||||
// not a plain string — extract the `key` directly instead of using
|
||||
// the generic `snapshotActiveForm` helper (which would clobber
|
||||
// `accumulator.providerType`'s string type via Object.assign).
|
||||
const form = stepper.activeForm;
|
||||
if (form) {
|
||||
const data: Record<string, any> = await form.collectFormData();
|
||||
const selected = data.providerType;
|
||||
if (selected && typeof selected === 'object' && selected.key) {
|
||||
accumulator.providerType = selected.key as TProviderType;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create provider:', err);
|
||||
deesCatalog.DeesToast.error('Failed to create provider');
|
||||
}
|
||||
if (!accumulator.providerType) {
|
||||
accumulator.providerType = 'Custom';
|
||||
}
|
||||
applyTemplate(accumulator.providerType);
|
||||
// (Re)build steps 2-5 with current accumulator values.
|
||||
stepper.steps = [
|
||||
typeStep,
|
||||
buildConnectionStep(),
|
||||
buildCredentialsStep(),
|
||||
buildAdvancedStep(),
|
||||
buildReviewStep(),
|
||||
];
|
||||
await (stepper as any).updateComplete;
|
||||
stepper.goNext();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
await DeesStepper.createAndShow({ steps: [typeStep] });
|
||||
}
|
||||
|
||||
// ---- edit provider modal -------------------------------------------------
|
||||
@@ -579,7 +752,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
const result = await appState.apiSaveConfig({
|
||||
|
||||
@@ -239,7 +239,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
action: async (modalRef: any) => {
|
||||
try {
|
||||
await fetch(
|
||||
@@ -281,7 +281,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
title: 'Unheard Messages',
|
||||
value: unheard,
|
||||
type: 'number',
|
||||
icon: 'lucide:bell-ring',
|
||||
icon: 'lucide:BellRing',
|
||||
color: unheard > 0 ? 'hsl(0 84.2% 60.2%)' : 'hsl(142.1 76.2% 36.3%)',
|
||||
description: unheard > 0 ? 'Needs attention' : 'All caught up',
|
||||
},
|
||||
@@ -372,7 +372,7 @@ export class SipproxyViewVoicemail extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
iconName: 'lucide:Trash2',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.deleteMessage(actionData.item as IVoicemailMessage);
|
||||
|
||||
@@ -2,72 +2,12 @@
|
||||
* Application state — receives live updates from the proxy via WebSocket.
|
||||
*/
|
||||
|
||||
export interface IProviderStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
registered: boolean;
|
||||
publicIp: string | null;
|
||||
}
|
||||
import type { IContact } from '../../ts/config.ts';
|
||||
import type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus } from '../../ts/shared/status.ts';
|
||||
|
||||
export interface IDeviceStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
contact: { address: string; port: number } | null;
|
||||
aor: string;
|
||||
connected: boolean;
|
||||
isBrowser: boolean;
|
||||
}
|
||||
|
||||
export interface ILegStatus {
|
||||
id: string;
|
||||
type: 'sip-device' | 'sip-provider' | 'webrtc' | 'tool';
|
||||
state: string;
|
||||
remoteMedia: { address: string; port: number } | null;
|
||||
rtpPort: number | null;
|
||||
pktSent: number;
|
||||
pktReceived: number;
|
||||
codec: string | null;
|
||||
transcoding: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICallStatus {
|
||||
id: string;
|
||||
state: string;
|
||||
direction: 'inbound' | 'outbound' | 'internal';
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
createdAt: number;
|
||||
duration: number;
|
||||
legs: ILegStatus[];
|
||||
}
|
||||
|
||||
export interface IHistoryLeg {
|
||||
id: string;
|
||||
type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound' | 'internal';
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
legs?: IHistoryLeg[];
|
||||
}
|
||||
|
||||
export interface IContact {
|
||||
id: string;
|
||||
name: string;
|
||||
number: string;
|
||||
company?: string;
|
||||
notes?: string;
|
||||
starred?: boolean;
|
||||
}
|
||||
export type { IContact };
|
||||
export type { ICallHistoryEntry, ICallStatus, IDeviceStatus, IProviderStatus };
|
||||
export type { ILegStatus } from '../../ts/shared/status.ts';
|
||||
|
||||
export interface IAppState {
|
||||
connected: boolean;
|
||||
|
||||
Reference in New Issue
Block a user