Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60fbb4be2b | |||
| fd90c9c73e | |||
| 690e19eff8 | |||
| c18f2f7ca1 | |||
| 3e2fee16c1 | |||
| 04e706715f | |||
| 980a1500f5 |
@@ -19,5 +19,19 @@
|
|||||||
"dockerregistry.lossless.digital": "serve.zone/siprouter"
|
"dockerregistry.lossless.digital": "serve.zone/siprouter"
|
||||||
},
|
},
|
||||||
"platforms": ["linux/amd64", "linux/arm64"]
|
"platforms": ["linux/amd64", "linux/arm64"]
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"release": {
|
||||||
|
"targets": {
|
||||||
|
"git": {
|
||||||
|
"enabled": true,
|
||||||
|
"remote": "origin"
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"enabled": true,
|
||||||
|
"engine": "tsdocker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -8,9 +8,14 @@ FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
|||||||
# prebuilt-binary download path doesn't apply.
|
# prebuilt-binary download path doesn't apply.
|
||||||
# - pkg-config : used by audiopus_sys and other *-sys crates to locate libs
|
# - 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).
|
# on the native target (safe no-op if they vendor their own).
|
||||||
|
# - libtiff-dev/libjpeg-dev : required by spandsp-sys fax/T.38 bindings.
|
||||||
|
# - libclang-dev: required by bindgen-based Rust build scripts.
|
||||||
# These are normally pre-installed on dev machines but not in ht-docker-node:lts.
|
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
cmake \
|
cmake \
|
||||||
|
libclang-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libtiff-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -51,7 +56,8 @@ RUN rm -rf .pnpm-store
|
|||||||
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS 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.
|
# gcompat + libstdc++ let the glibc-linked proxy-engine binary run on Alpine.
|
||||||
RUN apk add --no-cache gcompat libstdc++
|
# tiff + libjpeg-turbo provide the fax engine's dynamic image codec libs.
|
||||||
|
RUN apk add --no-cache gcompat libstdc++ libjpeg-turbo tiff
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app /app
|
COPY --from=build /app /app
|
||||||
|
|||||||
+23
-1
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-05-22 - 1.27.1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- install fax build and runtime libraries for Docker images and update release tooling dependencies (docker)
|
||||||
|
- add libclang, libjpeg, and libtiff development packages required for Rust bindgen and fax-related native builds
|
||||||
|
- add libjpeg-turbo and tiff runtime libraries to the Alpine production image for the fax engine
|
||||||
|
- bump build and release tooling packages including @git.zone/tsbundle, @git.zone/tsdocker, @git.zone/tsrust, @git.zone/tswatch, and esbuild
|
||||||
|
|
||||||
|
## 2026-05-21 - 1.27.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- persist siprouter config and media through SmartData and SmartBucket (storage)
|
||||||
|
- store runtime config, voicemail metadata, fax jobs, and fax inbox metadata in SmartData
|
||||||
|
- store voicemail audio, custom greetings, and fax payloads in SmartBucket while keeping local cache paths for Rust media access
|
||||||
|
- migrate legacy local voicemail and fax metadata/media into SmartData and SmartBucket on startup
|
||||||
|
- enable gitzone Docker release publishing through the configured tsdocker target
|
||||||
|
|
||||||
## 2026-04-20 - 1.26.0 - feat(fax)
|
## 2026-04-20 - 1.26.0 - feat(fax)
|
||||||
add fax routing, job tracking, inbox management, and T.38/UDPTL media support
|
add fax routing, job tracking, inbox management, and T.38/UDPTL media support
|
||||||
|
|
||||||
@@ -337,4 +359,4 @@ Initial SIP-aware proxy for Grandstream HT801 ↔ easybell connectivity.
|
|||||||
- Added SDP rewriting and per-call RTP relay sockets
|
- Added SDP rewriting and per-call RTP relay sockets
|
||||||
- Added NAT priming and G.722 silence streaming after `200 OK` so easybell detects inbound media promptly
|
- Added NAT priming and G.722 silence streaming after `200 OK` so easybell detects inbound media promptly
|
||||||
- Inserted `Record-Route` so in-dialog ACK/BYE/re-INVITE continue through the proxy
|
- Inserted `Record-Route` so in-dialog ACK/BYE/re-INVITE continue through the proxy
|
||||||
- Included captured device setting snapshots and setup documentation for diagnosing registration issues
|
- Included captured device setting snapshots and setup documentation for diagnosing registration issues
|
||||||
|
|||||||
+23
-10
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "siprouter",
|
"name": "siprouter",
|
||||||
"version": "1.26.0",
|
"version": "1.27.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"bundle": "esbuild ts_web/index.ts --bundle --format=esm --outfile=dist_ts_web/bundle.js --platform=browser --target=es2022 --minify",
|
||||||
"buildRust": "tsrust",
|
"buildRust": "tsrust",
|
||||||
"build": "pnpm run buildRust && pnpm run bundle",
|
"build": "pnpm run buildRust && pnpm run bundle",
|
||||||
"build:docker": "tsdocker build --verbose",
|
"build:docker": "tsdocker build --verbose",
|
||||||
@@ -13,18 +13,31 @@
|
|||||||
"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"
|
"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": {
|
"dependencies": {
|
||||||
"@design.estate/dees-catalog": "^3.77.0",
|
"@design.estate/dees-catalog": "^3.81.0",
|
||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/smartrust": "^1.3.2",
|
"@push.rocks/smartbucket": "^4.6.1",
|
||||||
"@push.rocks/smartstate": "^2.3.0",
|
"@push.rocks/smartdata": "^7.1.7",
|
||||||
|
"@push.rocks/smartrust": "^1.4.0",
|
||||||
|
"@push.rocks/smartstate": "^2.3.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbundle": "^2.10.0",
|
"@git.zone/tsbundle": "^2.10.4",
|
||||||
"@git.zone/tsdocker": "^2.2.4",
|
"@git.zone/tsdocker": "^2.3.0",
|
||||||
"@git.zone/tsrust": "^1.3.2",
|
"@git.zone/tsrust": "^1.3.4",
|
||||||
"@git.zone/tswatch": "^3.3.2",
|
"@git.zone/tswatch": "^3.3.5",
|
||||||
"@types/ws": "^8.18.1"
|
"@types/node": "^25.8.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"esbuild": "^0.28.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@design.estate/dees-catalog"
|
||||||
|
],
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"mongodb-memory-server"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2278
-688
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
mongodb-memory-server: true
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- '@design.estate/dees-catalog'
|
||||||
@@ -1,140 +1,104 @@
|
|||||||
# @serve.zone/siprouter
|
# siprouter
|
||||||
|
|
||||||
A production-grade **SIP B2BUA + WebRTC bridge** built with TypeScript and Rust. Routes calls between SIP providers, SIP hardware devices, and browser softphones — with real-time codec transcoding, adaptive jitter buffering, ML noise suppression, neural TTS, voicemail, IVR menus, and a slick web dashboard.
|
siprouter is a TypeScript control plane plus Rust media/data plane for SIP routing, SIP device registration, SIP trunk calls, browser WebRTC softphones, voicemail/fax storage, and a live operations dashboard. It is intentionally split so TypeScript owns configuration, REST/WebSocket APIs, and UI glue while the Rust `proxy-engine` owns SIP, RTP, WebRTC media, codecs, mixing, jitter handling, fax transport, and real-time call state.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
---
|
## Current Capabilities
|
||||||
|
|
||||||
## 🔥 What It Does
|
- SIP B2BUA behavior for SIP providers and LAN SIP devices, including dialog state, provider registration, digest auth retry, SDP negotiation, BYE/CANCEL handling, and routing decisions in Rust.
|
||||||
|
- Browser WebRTC softphone signaling through the TypeScript dashboard, with WebRTC media sessions implemented in the Rust engine.
|
||||||
|
- A call hub model: each call owns multiple legs and a 20 ms tick mix-minus mixer at a 48 kHz f32 internal bus.
|
||||||
|
- Codec handling for Opus, G.722, PCMU, and PCMA through `codec-lib`, including per-leg transcoding, resampling, packet loss concealment, and jitter buffering.
|
||||||
|
- Device registration push events from Rust into TypeScript, then into the dashboard status stream.
|
||||||
|
- Route configuration for inbound and outbound calls with provider/device matching, number patterns, failover provider fields, browser notification flags, voicemail, fax, and IVR-related action fields.
|
||||||
|
- Voicemail metadata and WAV storage through `VoiceboxManager` under `.nogit/voicemail/{boxId}/`.
|
||||||
|
- Fax box and fax job metadata storage under `.nogit/fax/`, backed by Rust fax handling that includes audio fax and T.38-related code paths.
|
||||||
|
- Web dashboard and REST API on the configured `webUiPort`, served over HTTPS if `.nogit/cert.pem` and `.nogit/key.pem` exist, otherwise HTTP.
|
||||||
|
|
||||||
siprouter sits between your SIP trunk providers and your endpoints — hardware phones, ATAs, browser softphones — and handles **everything** in between:
|
## Important Accuracy Notes
|
||||||
|
|
||||||
- 📞 **SIP B2BUA** — Terminates and re-originates calls with full RFC 3261 dialog state management, digest auth, and SDP negotiation
|
- TypeScript does not handle raw SIP or RTP. It sends high-level commands to the Rust engine over `@push.rocks/smartrust` and receives high-level events back.
|
||||||
- 🌐 **WebRTC Bridge** — Browser-based softphone with bidirectional Opus audio to the SIP network
|
- Browser WebRTC calls use a strict two-stage link flow. The browser first creates a standalone WebRTC session with `webrtc-offer`; only after `webrtc-accept`/linking can Rust attach that session to the call mixer.
|
||||||
- 🎛️ **Multi-Provider Trunking** — Register with multiple SIP providers simultaneously (sipgate, easybell, etc.) with automatic failover
|
- Inbound route resolution is wired in Rust, but multi-target inbound forking is not implemented yet. Only the first registered target device from an inbound route is rung.
|
||||||
- 🎧 **48kHz f32 Audio Engine** — High-fidelity internal audio bus at 48kHz/32-bit float with native Opus float encode/decode, FFT-based resampling, and per-leg ML noise suppression
|
- `ringBrowsers` currently controls browser notifications. It is not first-answer-wins call racing against SIP devices.
|
||||||
- 🔀 **N-Leg Mix-Minus Mixer** — Conference-grade mixing with dynamic leg add/remove, transfer, and per-source audio separation
|
- `voicemailBox`, `ivrMenuId`, and `noAnswerTimeout` are part of resolved inbound route data, but the project notes mark downstream honoring of those fields as not complete yet.
|
||||||
- 🎯 **Adaptive Jitter Buffer** — Per-leg jitter buffering with sequence-based reordering, adaptive depth (60–120ms), Opus PLC for lost packets, and hold/resume detection
|
- The `/api/transfer` HTTP endpoint currently returns `501 not yet implemented`.
|
||||||
- 📧 **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 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
|
|
||||||
|
|
||||||
---
|
## Architecture
|
||||||
|
|
||||||
## 🏗️ Architecture
|
```text
|
||||||
|
Browser dashboard and softphone
|
||||||
```mermaid
|
|
|
||||||
flowchart TB
|
| HTTP + WebSocket signaling
|
||||||
Browser["🌐 Browser Softphone<br/>(WebRTC via WebSocket signaling)"]
|
v
|
||||||
Devices["📞 SIP Devices<br/>(HT801, desk phones, ATAs)"]
|
TypeScript control plane
|
||||||
Trunks["☎️ SIP Trunk Providers<br/>(sipgate, easybell, …)"]
|
ts/sipproxy.ts
|
||||||
|
ts/frontend.ts
|
||||||
subgraph Router["siprouter"]
|
ts/webrtcbridge.ts
|
||||||
direction TB
|
ts/config.ts
|
||||||
subgraph TS["TypeScript Control Plane"]
|
ts/proxybridge.ts
|
||||||
TSBits["Config · WebRTC Signaling<br/>REST API · Web Dashboard<br/>Voicebox Manager · TTS Cache"]
|
|
|
||||||
end
|
| JSON-over-stdio via @push.rocks/smartrust
|
||||||
subgraph Rust["Rust proxy-engine (data plane)"]
|
v
|
||||||
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"]
|
Rust proxy-engine
|
||||||
end
|
SIP transport and dialog state
|
||||||
TS <-->|"JSON-over-stdio IPC"| Rust
|
Call manager and call hub
|
||||||
end
|
RTP port pool and RTP I/O
|
||||||
|
48 kHz f32 mix-minus mixer
|
||||||
Browser <-->|"Opus / WebRTC"| TS
|
WebRTC sessions
|
||||||
Rust <-->|"SIP / RTP"| Devices
|
Fax, voicemail, TTS, recorder, tool legs
|
||||||
Rust <-->|"SIP / RTP"| Trunks
|
|
|
||||||
|
| SIP/RTP/UDPTL/WebRTC media
|
||||||
|
v
|
||||||
|
SIP providers, SIP devices, and browser clients
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🧠 Key Design Decisions
|
## Key Files
|
||||||
|
|
||||||
- **Hub Model** — Every call is a hub with N legs. Each leg is a `SipLeg` (device/provider) or `WebRtcLeg` (browser). Legs can be dynamically added, removed, or transferred without tearing down the call.
|
| Path | Role |
|
||||||
- **Rust Data Plane** — All SIP protocol handling, codec transcoding, mixing, and RTP I/O runs in native Rust for real-time performance. TypeScript handles config, signaling, REST API, and dashboard.
|
| --- | --- |
|
||||||
- **48kHz f32 Internal Bus** — Audio is processed at maximum quality internally. Encoding/decoding to wire format (G.722, PCMU, Opus) happens solely at the leg boundary.
|
| `ts/sipproxy.ts` | Process entry point. Loads config, starts web UI, starts Rust, wires event handlers, and handles shutdown. |
|
||||||
- **Per-Session Codec Isolation** — Each call leg gets its own encoder/decoder/resampler/denoiser state — no cross-call corruption.
|
| `ts/config.ts` | `.nogit/config.json` schema, defaults, and validation. |
|
||||||
- **SDP Codec Negotiation** — Outbound encoding uses the codec actually negotiated in SDP answers, not just the first offered codec.
|
| `ts/proxybridge.ts` | Typed command bridge to the Rust `proxy-engine` binary. |
|
||||||
|
| `ts/frontend.ts` | HTTP API, static dashboard serving, status WebSocket, and WebRTC message routing. |
|
||||||
|
| `ts/webrtcbridge.ts` | Browser device registration and WebSocket-to-device mapping. |
|
||||||
|
| `ts/voicebox.ts` | Voicemail box config, WAV metadata, unheard counts, and message CRUD. |
|
||||||
|
| `ts/faxbox.ts` | Fax inbox metadata and TIFF file tracking. |
|
||||||
|
| `ts/faxjobs.ts` | Outbound/inbound fax job state persistence. |
|
||||||
|
| `rust/crates/proxy-engine/src/call_manager.rs` | Central call registry, SIP routing, B2BUA state, route resolution, fax metadata, and call orchestration. |
|
||||||
|
| `rust/crates/proxy-engine/src/mixer.rs` | 20 ms mix-minus engine with 48 kHz f32 processing, codec boundaries, jitter, PLC, DTMF, and tool-leg audio. |
|
||||||
|
| `rust/crates/proxy-engine/src/webrtc_engine.rs` | Browser WebRTC sessions. |
|
||||||
|
| `rust/crates/proxy-engine/src/fax_engine.rs` | Fax transfer engine using `spandsp` and `udptl`. |
|
||||||
|
| `rust/crates/sip-proto/` | Zero-dependency SIP data library for parsing, serializing, dialogs, SDP helpers, digest auth, and URI rewriting. |
|
||||||
|
| `ts_web/` | Lit/dees-element dashboard views and WebRTC browser client state. |
|
||||||
|
|
||||||
### 📲 WebRTC Browser Call Flow
|
## Configuration
|
||||||
|
|
||||||
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:
|
Create `.nogit/config.json` in the repository root before starting the service.
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Node.js** ≥ 20 with `tsx` globally available
|
|
||||||
- **pnpm** for package management
|
|
||||||
- **Rust** toolchain (for building the proxy engine)
|
|
||||||
|
|
||||||
### Install & Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone and install dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Build the Rust proxy-engine binary
|
|
||||||
pnpm run buildRust
|
|
||||||
|
|
||||||
# Bundle the web frontend
|
|
||||||
pnpm run bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Create `.nogit/config.json`:
|
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"lanIp": "192.168.1.100", // Your server's LAN IP
|
"lanIp": "192.168.1.100",
|
||||||
"lanPort": 5070, // SIP signaling port
|
"lanPort": 5070,
|
||||||
"publicIpSeed": "stun.example.com", // STUN server for public IP discovery
|
"publicIpSeed": null,
|
||||||
"rtpPortRange": { "min": 20000, "max": 20200 }, // RTP port pool (even ports)
|
"rtpPortRange": { "min": 20000, "max": 20200 },
|
||||||
"webUiPort": 3060 // Dashboard + REST API port
|
"webUiPort": 3060
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
{
|
{
|
||||||
"id": "my-trunk",
|
"id": "main-trunk",
|
||||||
"displayName": "My SIP Provider",
|
"displayName": "Main SIP trunk",
|
||||||
"domain": "sip.provider.com",
|
"domain": "sip.example.net",
|
||||||
"outboundProxy": { "address": "sip.provider.com", "port": 5060 },
|
"outboundProxy": { "address": "sip.example.net", "port": 5060 },
|
||||||
"username": "user",
|
"username": "trunk-user",
|
||||||
"password": "pass",
|
"password": "trunk-password",
|
||||||
"codecs": [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event
|
"registerIntervalSec": 300,
|
||||||
"registerIntervalSec": 300
|
"codecs": [9, 0, 8, 101],
|
||||||
|
"quirks": { "earlyMediaSilence": false }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"devices": [
|
"devices": [
|
||||||
@@ -148,13 +112,21 @@ Create `.nogit/config.json`:
|
|||||||
"routing": {
|
"routing": {
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
"id": "inbound-main-did",
|
"id": "outbound-default",
|
||||||
"name": "Main DID",
|
"name": "Outbound via main trunk",
|
||||||
|
"priority": 100,
|
||||||
|
"enabled": true,
|
||||||
|
"match": { "direction": "outbound" },
|
||||||
|
"action": { "provider": "main-trunk" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inbound-main",
|
||||||
|
"name": "Inbound main number",
|
||||||
"priority": 200,
|
"priority": 200,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"match": {
|
"match": {
|
||||||
"direction": "inbound",
|
"direction": "inbound",
|
||||||
"sourceProvider": "my-trunk",
|
"sourceProvider": "main-trunk",
|
||||||
"numberPattern": "+49421219694"
|
"numberPattern": "+49421219694"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
@@ -162,267 +134,111 @@ Create `.nogit/config.json`:
|
|||||||
"ringBrowsers": true,
|
"ringBrowsers": true,
|
||||||
"voicemailBox": "main"
|
"voicemailBox": "main"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "inbound-support-did",
|
|
||||||
"name": "Support DID",
|
|
||||||
"priority": 190,
|
|
||||||
"enabled": true,
|
|
||||||
"match": {
|
|
||||||
"direction": "inbound",
|
|
||||||
"sourceProvider": "my-trunk",
|
|
||||||
"numberPattern": "+49421219695"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"ivrMenuId": "support-menu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "outbound-default",
|
|
||||||
"name": "Route via trunk",
|
|
||||||
"priority": 100,
|
|
||||||
"enabled": true,
|
|
||||||
"match": { "direction": "outbound" },
|
|
||||||
"action": { "provider": "my-trunk" }
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"contacts": [],
|
||||||
"voiceboxes": [
|
"voiceboxes": [
|
||||||
{
|
{
|
||||||
"id": "main",
|
"id": "main",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"greetingText": "Please leave a message after the beep.",
|
"greetingText": "Please leave a message after the tone.",
|
||||||
"greetingVoice": "af_bella",
|
"greetingVoice": "af_bella",
|
||||||
"noAnswerTimeoutSec": 25,
|
"noAnswerTimeoutSec": 25,
|
||||||
"maxRecordingSec": 120,
|
"maxRecordingSec": 120,
|
||||||
"maxMessages": 50
|
"maxMessages": 50
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contacts": [
|
"faxboxes": [],
|
||||||
{ "id": "1", "name": "Alice", "number": "+491234567890", "starred": true }
|
"ivr": {
|
||||||
]
|
"enabled": false,
|
||||||
|
"entryMenuId": "main-menu",
|
||||||
|
"menus": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Inbound number ownership is explicit: add one inbound route per DID (or DID prefix) and scope it with `sourceProvider` when a provider delivers multiple external numbers.
|
## Persistent Files
|
||||||
|
|
||||||
### TTS Setup (Optional)
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `.nogit/config.json` | Main app config. |
|
||||||
|
| `.nogit/cert.pem` and `.nogit/key.pem` | Optional HTTPS certificate for the dashboard. |
|
||||||
|
| `.nogit/voicemail/{boxId}/` | Voicemail WAV files and `messages.json`. |
|
||||||
|
| `.nogit/fax/inboxes/{boxId}/` | Fax inbox files and metadata. |
|
||||||
|
| `.nogit/fax/jobs.json` | Fax job state. |
|
||||||
|
| `.nogit/prompts/` | Cached prompt/TTS assets used by call flows. |
|
||||||
|
| `sip_trace.log` | Runtime log written by `ts/sipproxy.ts`. |
|
||||||
|
|
||||||
For neural voicemail greetings and IVR prompts, download the Kokoro TTS model:
|
## HTTP and WebSocket API
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/status` | Full status snapshot for providers, devices, calls, and dashboard state. |
|
||||||
|
| `POST /api/call` | Originate an outbound call. |
|
||||||
|
| `POST /api/hangup` | Hang up a call. |
|
||||||
|
| `POST /api/fax` | Start an outbound fax. |
|
||||||
|
| `GET /api/fax/jobs` | List fax jobs. |
|
||||||
|
| `GET /api/fax/inboxes/:boxId` | List fax inbox messages. |
|
||||||
|
| `GET /api/fax/inboxes/:boxId/:messageId/file` | Stream a fax TIFF. |
|
||||||
|
| `DELETE /api/fax/inboxes/:boxId/:messageId` | Delete a fax message. |
|
||||||
|
| `POST /api/call/:id/addleg` | Add a registered SIP device leg to an active call. |
|
||||||
|
| `POST /api/call/:id/addexternal` | Add an external dial-out leg to an active call. |
|
||||||
|
| `POST /api/call/:id/removeleg` | Remove a leg from a call. |
|
||||||
|
| `POST /api/transfer` | Present but returns `501 not yet implemented`. |
|
||||||
|
| `GET /api/config` | Read sanitized config. |
|
||||||
|
| `POST /api/config` | Update config and trigger runtime reload where possible. |
|
||||||
|
| `GET /api/voicemail/:boxId` | List voicemail messages. |
|
||||||
|
| `GET /api/voicemail/:boxId/unheard` | Get unheard voicemail count. |
|
||||||
|
| `GET /api/voicemail/:boxId/:messageId/audio` | Stream voicemail WAV audio. |
|
||||||
|
| `POST /api/voicemail/:boxId/:messageId/heard` | Mark voicemail as heard. |
|
||||||
|
| `DELETE /api/voicemail/:boxId/:messageId` | Delete voicemail metadata and WAV file. |
|
||||||
|
| `WS /ws` | Status updates, logs, WebRTC signaling, and browser phone events. |
|
||||||
|
|
||||||
|
## Build and Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .nogit/tts
|
pnpm install
|
||||||
curl -L -o .nogit/tts/kokoro-v1.0.onnx \
|
|
||||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/kokoro-v1.0.onnx
|
|
||||||
curl -L -o .nogit/tts/voices.bin \
|
|
||||||
https://github.com/mzdk100/kokoro/releases/download/V1.0/voices.bin
|
|
||||||
```
|
|
||||||
|
|
||||||
Without the model files, TTS prompts (IVR menus, voicemail greetings) are skipped — everything else works fine.
|
|
||||||
|
|
||||||
### Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
The SIP proxy starts on the configured port and the web dashboard is available at `https://<your-ip>:3060`.
|
|
||||||
|
|
||||||
### HTTPS (Optional)
|
|
||||||
|
|
||||||
Place `cert.pem` and `key.pem` in `.nogit/` for TLS on the dashboard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📂 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
siprouter/
|
|
||||||
├── ts/ # TypeScript control plane
|
|
||||||
│ ├── sipproxy.ts # Main entry — bootstraps everything
|
|
||||||
│ ├── config.ts # Config loader & validation
|
|
||||||
│ ├── proxybridge.ts # Rust proxy-engine IPC bridge (smartrust)
|
|
||||||
│ ├── frontend.ts # Web dashboard HTTP/WS server + REST API
|
|
||||||
│ ├── webrtcbridge.ts # WebRTC signaling layer
|
|
||||||
│ ├── registrar.ts # Browser softphone registration
|
|
||||||
│ ├── voicebox.ts # Voicemail box management
|
|
||||||
│ └── call/
|
|
||||||
│ └── prompt-cache.ts # Named audio prompt WAV management
|
|
||||||
│
|
|
||||||
├── ts_web/ # Web frontend (Lit-based SPA)
|
|
||||||
│ ├── elements/ # Web components (9 dashboard views)
|
|
||||||
│ └── state/ # App state, WebRTC client, notifications
|
|
||||||
│
|
|
||||||
├── rust/ # Rust workspace (the data plane)
|
|
||||||
│ └── crates/
|
|
||||||
│ ├── codec-lib/ # Audio codec library (Opus/G.722/PCMU/PCMA)
|
|
||||||
│ ├── sip-proto/ # Zero-dependency SIP protocol library
|
|
||||||
│ └── proxy-engine/ # Main binary — SIP engine + mixer + RTP
|
|
||||||
│
|
|
||||||
├── html/ # Static HTML shell
|
|
||||||
├── .nogit/ # Secrets, config, TTS models (gitignored)
|
|
||||||
└── dist_rust/ # Compiled Rust binary (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎧 Audio Engine (Rust)
|
|
||||||
|
|
||||||
The `proxy-engine` binary handles all real-time audio processing with a **48kHz f32 internal bus** — encoding and decoding happens only at leg boundaries.
|
|
||||||
|
|
||||||
### Supported Codecs
|
|
||||||
|
|
||||||
| Codec | PT | Native Rate | Use Case |
|
|
||||||
|-------|:--:|:-----------:|----------|
|
|
||||||
| **Opus** | 111 | 48 kHz | WebRTC browsers (native float encode/decode — zero i16 quantization) |
|
|
||||||
| **G.722** | 9 | 16 kHz | HD SIP devices & providers |
|
|
||||||
| **PCMU** (G.711 µ-law) | 0 | 8 kHz | Legacy SIP |
|
|
||||||
| **PCMA** (G.711 A-law) | 8 | 8 kHz | Legacy SIP |
|
|
||||||
|
|
||||||
### Audio Pipeline
|
|
||||||
|
|
||||||
```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.
|
|
||||||
- **Packet loss concealment (PLC)** — on missing packets, Opus legs invoke the decoder's built-in PLC (`decode(None)`) to synthesize a smooth fill frame. Non-Opus legs (G.722, PCMU) apply exponential fade (0.85×) toward silence to avoid hard discontinuities.
|
|
||||||
- **FFT-based resampling** via `rubato` — high-quality sinc interpolation with canonical 20ms chunk sizes to ensure consistent resampler state across frames, preventing filter discontinuities
|
|
||||||
- **ML noise suppression** via `nnnoiseless` (RNNoise) — per-leg inbound denoising with SIMD acceleration (AVX/SSE). Skipped for WebRTC legs (browsers already denoise via getUserMedia)
|
|
||||||
- **Mix-minus mixing** — each participant hears everyone except themselves, accumulated in f64 precision
|
|
||||||
- **RFC 3550 compliant header parsing** — properly handles CSRC lists and header extensions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗣️ Neural TTS
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📧 Voicemail
|
|
||||||
|
|
||||||
- Configurable voicemail boxes with custom TTS greetings (text + voice) or uploaded WAV
|
|
||||||
- Automatic routing on no-answer timeout (configurable, default 25s)
|
|
||||||
- Recording with configurable max duration (default 120s) and message count limit (default 50)
|
|
||||||
- Unheard message tracking for MWI (message waiting indication)
|
|
||||||
- Web dashboard playback and management
|
|
||||||
- WAV storage in `.nogit/voicemail/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔢 IVR (Interactive Voice Response)
|
|
||||||
|
|
||||||
- DTMF-navigable menus with configurable entries
|
|
||||||
- Actions: route to extension, route to voicemail, transfer, submenu, hangup, repeat prompt
|
|
||||||
- Custom TTS prompts per menu
|
|
||||||
- Nested menu support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Web Dashboard & REST API
|
|
||||||
|
|
||||||
### Dashboard Views
|
|
||||||
|
|
||||||
| View | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| 📊 **Overview** | Stats tiles — uptime, providers, devices, active calls |
|
|
||||||
| 📞 **Calls** | Active calls with leg details, codec info, add/remove legs, transfer, hangup |
|
|
||||||
| ☎️ **Phone** | Browser softphone — mic/speaker selection, audio meters, dial pad, incoming call popup |
|
|
||||||
| 🔀 **Routes** | Routing rule management — match/action model with priority |
|
|
||||||
| 📧 **Voicemail** | Voicemail box management + message playback |
|
|
||||||
| 🔢 **IVR** | IVR menu builder — DTMF entries, TTS prompts, nested menus |
|
|
||||||
| 👤 **Contacts** | Contact management with click-to-call |
|
|
||||||
| 🔌 **Providers** | SIP trunk configuration and registration status |
|
|
||||||
| 📋 **Log** | Live streaming log viewer |
|
|
||||||
|
|
||||||
### REST API
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/api/status` | GET | Full system status (providers, devices, calls, history) |
|
|
||||||
| `/api/call` | POST | Originate a call |
|
|
||||||
| `/api/hangup` | POST | Hang up a call |
|
|
||||||
| `/api/call/:id/addleg` | POST | Add a device leg to an active call |
|
|
||||||
| `/api/call/:id/addexternal` | POST | Add an external participant via provider |
|
|
||||||
| `/api/call/:id/removeleg` | POST | Remove a leg from a call |
|
|
||||||
| `/api/transfer` | POST | Transfer a call |
|
|
||||||
| `/api/config` | GET | Read current configuration |
|
|
||||||
| `/api/config` | POST | Update configuration (hot-reload) |
|
|
||||||
| `/api/voicemail/:box` | GET | List voicemail messages |
|
|
||||||
| `/api/voicemail/:box/unheard` | GET | Get unheard message count |
|
|
||||||
| `/api/voicemail/:box/:id/audio` | GET | Stream voicemail audio |
|
|
||||||
| `/api/voicemail/:box/:id/heard` | POST | Mark a voicemail message as heard |
|
|
||||||
| `/api/voicemail/:box/:id` | DELETE | Delete a voicemail message |
|
|
||||||
|
|
||||||
### WebSocket Events
|
|
||||||
|
|
||||||
Connect to `/ws` for real-time push:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{ "type": "status", "data": { ... } } // Full status snapshot (1s interval)
|
|
||||||
{ "type": "log", "data": { "message": "..." } } // Log lines in real-time
|
|
||||||
{ "type": "call-update", "data": { ... } } // Call state change notification
|
|
||||||
{ "type": "webrtc-answer", "data": { ... } } // WebRTC SDP answer for browser calls
|
|
||||||
{ "type": "webrtc-error", "data": { ... } } // WebRTC signaling error
|
|
||||||
```
|
|
||||||
|
|
||||||
Browser → server signaling:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{ "type": "webrtc-offer", "data": { ... } } // Browser sends SDP offer
|
|
||||||
{ "type": "webrtc-accept", "data": { ... } } // Browser accepts incoming call
|
|
||||||
{ "type": "webrtc-ice", "data": { ... } } // ICE candidate exchange
|
|
||||||
{ "type": "webrtc-hangup", "data": { ... } } // Browser hangs up
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔌 Ports
|
|
||||||
|
|
||||||
| Port | Protocol | Purpose |
|
|
||||||
|------|----------|---------|
|
|
||||||
| 5070 (configurable) | UDP | SIP signaling |
|
|
||||||
| 20000–20200 (configurable) | UDP | RTP media (even ports, per-call allocation) |
|
|
||||||
| 3060 (configurable) | TCP | Web dashboard + WebSocket + REST API |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start in dev mode
|
|
||||||
pnpm start
|
|
||||||
|
|
||||||
# Build Rust proxy-engine
|
|
||||||
pnpm run buildRust
|
pnpm run buildRust
|
||||||
|
|
||||||
# Bundle web frontend
|
|
||||||
pnpm run bundle
|
pnpm run bundle
|
||||||
|
pnpm start
|
||||||
# Build + bundle + restart background server
|
|
||||||
pnpm run restartBackground
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Full build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker build scripts are also present:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build:docker
|
||||||
|
pnpm run release:docker
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm run buildRust` uses `tsrust`. Per the project notes, do not replace that with a direct `cargo build` when validating the packaged Rust output. The configured build path cross-compiles the Rust engine for Linux amd64 and arm64 targets.
|
||||||
|
|
||||||
|
## Project Map
|
||||||
|
|
||||||
|
```text
|
||||||
|
siprouter/
|
||||||
|
├── ts/ # TypeScript control plane
|
||||||
|
├── ts_web/ # Browser dashboard
|
||||||
|
├── rust/
|
||||||
|
│ └── crates/
|
||||||
|
│ ├── codec-lib/ # Codec and transcoding helpers
|
||||||
|
│ ├── proxy-engine/ # Rust SIP/RTP/WebRTC/fax engine
|
||||||
|
│ └── sip-proto/ # SIP message/dialog/SDP library
|
||||||
|
├── html/ # Dashboard HTML shell
|
||||||
|
├── dist_rust/ # Built Rust binaries
|
||||||
|
├── dist_ts_web/ # Bundled web UI
|
||||||
|
└── .nogit/ # Local config, secrets, voicemail, fax, prompts
|
||||||
|
```
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||||
|
|
||||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.26.0',
|
version: '1.27.1',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-81
@@ -1,13 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Application configuration — loaded from .nogit/config.json.
|
* Application configuration models and normalization helpers.
|
||||||
*
|
*
|
||||||
* All network addresses, credentials, provider settings, device definitions,
|
* All network addresses, credentials, provider settings, device definitions,
|
||||||
* and routing rules come from this single config file. No hardcoded values
|
* and routing rules are persisted through SmartData.
|
||||||
* in source.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { IFaxBoxConfig } from './faxbox.ts';
|
import type { IFaxBoxConfig } from './faxbox.ts';
|
||||||
import type { IVoiceboxConfig } from './voicebox.js';
|
import type { IVoiceboxConfig } from './voicebox.js';
|
||||||
|
|
||||||
@@ -266,97 +263,197 @@ export interface IAppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Loader
|
// Defaults and normalization
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
function requiredInitialEnv(keyArg: string): string {
|
||||||
|
const value = process.env[keyArg];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required initial config environment variable: ${keyArg}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(): IAppConfig {
|
function numberFromEnv(keyArg: string, fallbackArg: number): number {
|
||||||
let raw: string;
|
const value = process.env[keyArg];
|
||||||
|
if (!value) return fallbackArg;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallbackArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConfig(cfg: IAppConfig): IAppConfig {
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
// Basic validation.
|
||||||
} catch {
|
if (!cfg.proxy) throw new Error('config: missing "proxy" section');
|
||||||
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
|
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
|
||||||
}
|
if (!cfg.proxy.lanPort) throw new Error('config: missing proxy.lanPort');
|
||||||
|
if (!cfg.proxy.rtpPortRange?.min || !cfg.proxy.rtpPortRange?.max) {
|
||||||
const cfg = JSON.parse(raw) as IAppConfig;
|
throw new Error('config: missing proxy.rtpPortRange.min/max');
|
||||||
|
|
||||||
// Basic validation.
|
|
||||||
if (!cfg.proxy) throw new Error('config: missing "proxy" section');
|
|
||||||
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
|
|
||||||
if (!cfg.proxy.lanPort) throw new Error('config: missing proxy.lanPort');
|
|
||||||
if (!cfg.proxy.rtpPortRange?.min || !cfg.proxy.rtpPortRange?.max) {
|
|
||||||
throw new Error('config: missing proxy.rtpPortRange.min/max');
|
|
||||||
}
|
|
||||||
cfg.proxy.webUiPort ??= 3060;
|
|
||||||
cfg.proxy.publicIpSeed ??= null;
|
|
||||||
|
|
||||||
cfg.providers ??= [];
|
|
||||||
for (const p of cfg.providers) {
|
|
||||||
if (!p.id || !p.domain || !p.outboundProxy || !p.username || !p.password) {
|
|
||||||
throw new Error(`config: provider "${p.id || '?'}" missing required fields`);
|
|
||||||
}
|
}
|
||||||
p.displayName ??= p.id;
|
cfg.proxy.webUiPort ??= 3060;
|
||||||
p.registerIntervalSec ??= 300;
|
cfg.proxy.publicIpSeed ??= null;
|
||||||
p.codecs ??= [9, 0, 8, 101];
|
|
||||||
p.quirks ??= { earlyMediaSilence: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(cfg.devices) || !cfg.devices.length) {
|
cfg.providers ??= [];
|
||||||
throw new Error('config: need at least one device');
|
for (const p of cfg.providers) {
|
||||||
}
|
if (!p.id || !p.domain || !p.outboundProxy || !p.username || !p.password) {
|
||||||
for (const d of cfg.devices) {
|
throw new Error(`config: provider "${p.id || '?'}" missing required fields`);
|
||||||
if (!d.id || !d.expectedAddress) {
|
}
|
||||||
throw new Error(`config: device "${d.id || '?'}" missing required fields`);
|
p.displayName ??= p.id;
|
||||||
|
p.registerIntervalSec ??= 300;
|
||||||
|
p.codecs ??= [9, 0, 8, 101];
|
||||||
|
p.quirks ??= { earlyMediaSilence: false };
|
||||||
}
|
}
|
||||||
d.displayName ??= d.id;
|
|
||||||
d.extension ??= '100';
|
if (!Array.isArray(cfg.devices) || !cfg.devices.length) {
|
||||||
|
throw new Error('config: need at least one device');
|
||||||
|
}
|
||||||
|
for (const d of cfg.devices) {
|
||||||
|
if (!d.id || !d.expectedAddress) {
|
||||||
|
throw new Error(`config: device "${d.id || '?'}" missing required fields`);
|
||||||
|
}
|
||||||
|
d.displayName ??= d.id;
|
||||||
|
d.extension ??= '100';
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.incomingNumbers ??= [];
|
||||||
|
for (const incoming of cfg.incomingNumbers) {
|
||||||
|
if (!incoming.id) incoming.id = `incoming-${Date.now()}`;
|
||||||
|
incoming.label ??= incoming.id;
|
||||||
|
incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single';
|
||||||
|
incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49';
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.routing ??= { routes: [] };
|
||||||
|
cfg.routing.routes ??= [];
|
||||||
|
|
||||||
|
cfg.contacts ??= [];
|
||||||
|
for (const c of cfg.contacts) {
|
||||||
|
c.starred ??= false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.faxboxes ??= [];
|
||||||
|
for (const fb of cfg.faxboxes) {
|
||||||
|
fb.enabled ??= true;
|
||||||
|
fb.maxMessages ??= 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.voiceboxes ??= [];
|
||||||
|
for (const vb of cfg.voiceboxes) {
|
||||||
|
vb.enabled ??= true;
|
||||||
|
vb.noAnswerTimeoutSec ??= 25;
|
||||||
|
vb.maxRecordingSec ??= 120;
|
||||||
|
vb.maxMessages ??= 50;
|
||||||
|
vb.greetingVoice ??= 'af_bella';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.ivr) {
|
||||||
|
cfg.ivr.enabled ??= false;
|
||||||
|
cfg.ivr.menus ??= [];
|
||||||
|
for (const menu of cfg.ivr.menus) {
|
||||||
|
menu.timeoutSec ??= 5;
|
||||||
|
menu.maxRetries ??= 3;
|
||||||
|
menu.entries ??= [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg.incomingNumbers ??= [];
|
export function createInitialConfigFromEnv(): IAppConfig {
|
||||||
for (const incoming of cfg.incomingNumbers) {
|
return normalizeConfig({
|
||||||
if (!incoming.id) incoming.id = `incoming-${Date.now()}`;
|
proxy: {
|
||||||
incoming.label ??= incoming.id;
|
lanIp: requiredInitialEnv('SIPROUTER_LAN_IP'),
|
||||||
incoming.mode ??= incoming.pattern ? 'regex' : incoming.rangeStart || incoming.rangeEnd ? 'range' : 'single';
|
lanPort: numberFromEnv('SIPROUTER_LAN_PORT', 5070),
|
||||||
incoming.countryCode ??= incoming.mode === 'regex' ? undefined : '+49';
|
publicIpSeed: process.env.SIPROUTER_PUBLIC_IP || null,
|
||||||
}
|
rtpPortRange: {
|
||||||
|
min: numberFromEnv('SIPROUTER_RTP_PORT_MIN', 20000),
|
||||||
|
max: numberFromEnv('SIPROUTER_RTP_PORT_MAX', 20200),
|
||||||
|
},
|
||||||
|
webUiPort: numberFromEnv('SIPROUTER_WEB_UI_PORT', 3060),
|
||||||
|
},
|
||||||
|
providers: [],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: process.env.SIPROUTER_INITIAL_DEVICE_ID || 'desk-phone',
|
||||||
|
displayName: process.env.SIPROUTER_INITIAL_DEVICE_DISPLAY_NAME || 'Desk Phone',
|
||||||
|
expectedAddress: requiredInitialEnv('SIPROUTER_INITIAL_DEVICE_ADDRESS'),
|
||||||
|
extension: process.env.SIPROUTER_INITIAL_DEVICE_EXTENSION || '100',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
incomingNumbers: [],
|
||||||
|
routing: { routes: [] },
|
||||||
|
contacts: [],
|
||||||
|
faxboxes: [],
|
||||||
|
voiceboxes: [],
|
||||||
|
ivr: {
|
||||||
|
enabled: false,
|
||||||
|
entryMenuId: 'main-menu',
|
||||||
|
menus: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cfg.routing ??= { routes: [] };
|
export function maskConfig(configArg: IAppConfig): IAppConfig {
|
||||||
cfg.routing.routes ??= [];
|
return {
|
||||||
|
...configArg,
|
||||||
|
providers: configArg.providers?.map((providerArg) => ({
|
||||||
|
...providerArg,
|
||||||
|
password: providerArg.password ? '••••••' : providerArg.password,
|
||||||
|
})) || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
cfg.contacts ??= [];
|
export function applyConfigUpdates(configArg: IAppConfig, updatesArg: any): IAppConfig {
|
||||||
for (const c of cfg.contacts) {
|
const cfg = JSON.parse(JSON.stringify(configArg)) as IAppConfig;
|
||||||
c.starred ??= false;
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.faxboxes ??= [];
|
if (updatesArg.providers) {
|
||||||
for (const fb of cfg.faxboxes) {
|
for (const up of updatesArg.providers) {
|
||||||
fb.enabled ??= true;
|
const existing = cfg.providers?.find((p: any) => p.id === up.id);
|
||||||
fb.maxMessages ??= 50;
|
if (existing) {
|
||||||
}
|
if (up.displayName !== undefined) existing.displayName = up.displayName;
|
||||||
|
if (up.password && up.password !== '••••••') existing.password = up.password;
|
||||||
// Voicebox defaults.
|
if (up.domain !== undefined) existing.domain = up.domain;
|
||||||
cfg.voiceboxes ??= [];
|
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
|
||||||
for (const vb of cfg.voiceboxes) {
|
if (up.username !== undefined) existing.username = up.username;
|
||||||
vb.enabled ??= true;
|
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
|
||||||
vb.noAnswerTimeoutSec ??= 25;
|
if (up.codecs !== undefined) existing.codecs = up.codecs;
|
||||||
vb.maxRecordingSec ??= 120;
|
if (up.quirks !== undefined) existing.quirks = up.quirks;
|
||||||
vb.maxMessages ??= 50;
|
}
|
||||||
vb.greetingVoice ??= 'af_bella';
|
|
||||||
}
|
|
||||||
|
|
||||||
// IVR defaults.
|
|
||||||
if (cfg.ivr) {
|
|
||||||
cfg.ivr.enabled ??= false;
|
|
||||||
cfg.ivr.menus ??= [];
|
|
||||||
for (const menu of cfg.ivr.menus) {
|
|
||||||
menu.timeoutSec ??= 5;
|
|
||||||
menu.maxRetries ??= 3;
|
|
||||||
menu.entries ??= [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg;
|
if (updatesArg.addProvider) {
|
||||||
|
cfg.providers ??= [];
|
||||||
|
cfg.providers.push(updatesArg.addProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatesArg.removeProvider) {
|
||||||
|
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updatesArg.removeProvider);
|
||||||
|
if (cfg.routing?.routes) {
|
||||||
|
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
|
||||||
|
r.match?.sourceProvider !== updatesArg.removeProvider &&
|
||||||
|
r.action?.provider !== updatesArg.removeProvider
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatesArg.devices) {
|
||||||
|
for (const ud of updatesArg.devices) {
|
||||||
|
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
|
||||||
|
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updatesArg.incomingNumbers !== undefined) cfg.incomingNumbers = updatesArg.incomingNumbers;
|
||||||
|
if (updatesArg.routing?.routes) cfg.routing.routes = updatesArg.routing.routes;
|
||||||
|
if (updatesArg.contacts !== undefined) cfg.contacts = updatesArg.contacts;
|
||||||
|
if (updatesArg.faxboxes !== undefined) cfg.faxboxes = updatesArg.faxboxes;
|
||||||
|
if (updatesArg.voiceboxes !== undefined) cfg.voiceboxes = updatesArg.voiceboxes;
|
||||||
|
if (updatesArg.ivr !== undefined) cfg.ivr = updatesArg.ivr;
|
||||||
|
|
||||||
|
return normalizeConfig(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route resolution, pattern matching, and provider/device lookup
|
// Route resolution, pattern matching, and provider/device lookup
|
||||||
|
|||||||
+93
-45
@@ -1,6 +1,9 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { SiprouterStorage } from './storage.ts';
|
||||||
|
|
||||||
export interface IFaxBoxConfig {
|
export interface IFaxBoxConfig {
|
||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -13,6 +16,7 @@ export interface IFaxMessage {
|
|||||||
callerNumber?: string;
|
callerNumber?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
objectKey?: string;
|
||||||
completionCode?: number | null;
|
completionCode?: number | null;
|
||||||
completionLabel?: string | null;
|
completionLabel?: string | null;
|
||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
@@ -21,24 +25,28 @@ export interface IFaxMessage {
|
|||||||
|
|
||||||
export class FaxBoxManager {
|
export class FaxBoxManager {
|
||||||
private boxes = new Map<string, IFaxBoxConfig>();
|
private boxes = new Map<string, IFaxBoxConfig>();
|
||||||
|
private messagesByBox = new Map<string, IFaxMessage[]>();
|
||||||
private readonly basePath: string;
|
private readonly basePath: string;
|
||||||
private readonly log: (msg: string) => void;
|
private readonly log: (msg: string) => void;
|
||||||
|
private readonly storage: SiprouterStorage;
|
||||||
|
|
||||||
constructor(log: (msg: string) => void) {
|
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||||
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
|
this.basePath = path.join(process.cwd(), '.nogit', 'fax', 'inboxes');
|
||||||
this.log = log;
|
this.log = log;
|
||||||
|
this.storage = storageArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
init(faxBoxConfigs: IFaxBoxConfig[]): void {
|
async init(faxBoxConfigs: IFaxBoxConfig[]): Promise<void> {
|
||||||
this.boxes.clear();
|
this.boxes.clear();
|
||||||
|
|
||||||
for (const cfg of faxBoxConfigs) {
|
for (const cfg of faxBoxConfigs) {
|
||||||
cfg.enabled ??= true;
|
cfg.enabled ??= true;
|
||||||
cfg.maxMessages ??= 50;
|
cfg.maxMessages ??= 50;
|
||||||
this.boxes.set(cfg.id, cfg);
|
this.boxes.set(cfg.id, cfg);
|
||||||
|
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(this.basePath, { recursive: true });
|
await fsPromises.mkdir(this.basePath, { recursive: true });
|
||||||
this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`);
|
this.log(`[faxbox] initialized ${this.boxes.size} fax box(es)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +58,13 @@ export class FaxBoxManager {
|
|||||||
return path.join(this.basePath, boxId);
|
return path.join(this.basePath, boxId);
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(
|
async prepareOutboundFaxFile(filePathArg: string): Promise<string> {
|
||||||
|
const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
|
||||||
|
await fsPromises.access(localPath);
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessage(
|
||||||
boxId: string,
|
boxId: string,
|
||||||
info: {
|
info: {
|
||||||
callerNumber?: string;
|
callerNumber?: string;
|
||||||
@@ -60,90 +74,124 @@ export class FaxBoxManager {
|
|||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
bitRate?: number;
|
bitRate?: number;
|
||||||
},
|
},
|
||||||
): void {
|
): Promise<void> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName);
|
||||||
|
const objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${id}.tif`, localPath);
|
||||||
|
|
||||||
const msg: IFaxMessage = {
|
const msg: IFaxMessage = {
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
boxId,
|
boxId,
|
||||||
callerNumber: info.callerNumber,
|
callerNumber: info.callerNumber,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
fileName: path.basename(info.fileName),
|
fileName: path.basename(localPath),
|
||||||
|
objectKey,
|
||||||
completionCode: info.completionCode ?? null,
|
completionCode: info.completionCode ?? null,
|
||||||
completionLabel: info.completionLabel ?? null,
|
completionLabel: info.completionLabel ?? null,
|
||||||
pageCount: info.pageCount,
|
pageCount: info.pageCount,
|
||||||
bitRate: info.bitRate,
|
bitRate: info.bitRate,
|
||||||
};
|
};
|
||||||
this.saveMessage(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMessage(msg: IFaxMessage): void {
|
const messages = this.getMessages(boxId);
|
||||||
const boxDir = this.getBoxDir(msg.boxId);
|
|
||||||
fs.mkdirSync(boxDir, { recursive: true });
|
|
||||||
|
|
||||||
const messages = this.loadMessages(msg.boxId);
|
|
||||||
messages.unshift(msg);
|
messages.unshift(msg);
|
||||||
|
await this.enforceLimit(boxId, messages);
|
||||||
const box = this.boxes.get(msg.boxId);
|
await this.writeMessages(boxId, messages);
|
||||||
const maxMessages = box?.maxMessages ?? 50;
|
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
||||||
while (messages.length > maxMessages) {
|
|
||||||
const old = messages.pop()!;
|
|
||||||
const oldPath = path.join(boxDir, old.fileName);
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.writeMessages(msg.boxId, messages);
|
|
||||||
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
|
this.log(`[faxbox] saved fax ${msg.id} in box "${msg.boxId}" (${msg.fileName})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMessages(boxId: string): IFaxMessage[] {
|
getMessages(boxId: string): IFaxMessage[] {
|
||||||
return this.loadMessages(boxId);
|
return [...(this.messagesByBox.get(boxId) || [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
getMessage(boxId: string, messageId: string): IFaxMessage | null {
|
getMessage(boxId: string, messageId: string): IFaxMessage | null {
|
||||||
return this.loadMessages(boxId).find((m) => m.id === messageId) ?? null;
|
const messages = this.messagesByBox.get(boxId) || [];
|
||||||
|
return messages.find((m) => m.id === messageId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMessageFilePath(boxId: string, messageId: string): string | null {
|
async getMessageFilePath(boxId: string, messageId: string): Promise<string | null> {
|
||||||
const msg = this.getMessage(boxId, messageId);
|
const msg = this.getMessage(boxId, messageId);
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
|
if (msg.objectKey) {
|
||||||
|
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
|
||||||
|
}
|
||||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||||
return fs.existsSync(filePath) ? filePath : null;
|
return fs.existsSync(filePath) ? filePath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteMessage(boxId: string, messageId: string): boolean {
|
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
||||||
const messages = this.loadMessages(boxId);
|
const messages = this.messagesByBox.get(boxId) || [];
|
||||||
const idx = messages.findIndex((m) => m.id === messageId);
|
const idx = messages.findIndex((m) => m.id === messageId);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
|
|
||||||
const msg = messages[idx];
|
const msg = messages[idx];
|
||||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
await this.storage.removeObject(msg.objectKey);
|
||||||
try {
|
if (!msg.objectKey) {
|
||||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
|
||||||
} catch {}
|
}
|
||||||
|
|
||||||
messages.splice(idx, 1);
|
messages.splice(idx, 1);
|
||||||
this.writeMessages(boxId, messages);
|
await this.writeMessages(boxId, messages);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private messagesPath(boxId: string): string {
|
private async enforceLimit(boxId: string, messages: IFaxMessage[]): Promise<void> {
|
||||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
const box = this.boxes.get(boxId);
|
||||||
|
const maxMessages = box?.maxMessages ?? 50;
|
||||||
|
while (messages.length > maxMessages) {
|
||||||
|
const old = messages.pop()!;
|
||||||
|
await this.storage.removeObject(old.objectKey);
|
||||||
|
if (!old.objectKey) {
|
||||||
|
await fsPromises.rm(path.join(this.getBoxDir(boxId), old.fileName), { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadMessages(boxId: string): IFaxMessage[] {
|
private async loadMessages(boxId: string): Promise<IFaxMessage[]> {
|
||||||
const filePath = this.messagesPath(boxId);
|
const storedMessages = await this.storage.getFaxMessages(boxId);
|
||||||
|
if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages);
|
||||||
|
|
||||||
|
const filePath = path.join(this.getBoxDir(boxId), 'messages.json');
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(filePath)) return [];
|
if (!fs.existsSync(filePath)) return [];
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as IFaxMessage[];
|
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||||
|
const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IFaxMessage[]);
|
||||||
|
await this.storage.writeFaxMessages(boxId, legacyMessages);
|
||||||
|
return legacyMessages;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeMessages(boxId: string, messages: IFaxMessage[]): void {
|
private async ensureMessageObjects(boxId: string, messages: IFaxMessage[]): Promise<IFaxMessage[]> {
|
||||||
const boxDir = this.getBoxDir(boxId);
|
let changed = false;
|
||||||
fs.mkdirSync(boxDir, { recursive: true });
|
|
||||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
for (const msg of messages) {
|
||||||
|
if (!msg.id) {
|
||||||
|
msg.id = crypto.randomUUID();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (msg.objectKey) continue;
|
||||||
|
|
||||||
|
const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName);
|
||||||
|
if (!fs.existsSync(localPath)) continue;
|
||||||
|
|
||||||
|
const extension = path.extname(localPath) || '.tif';
|
||||||
|
msg.objectKey = await this.storage.putFileObject(`fax/inboxes/${boxId}/${msg.id}${extension}`, localPath);
|
||||||
|
msg.fileName = path.basename(localPath);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await this.storage.writeFaxMessages(boxId, messages);
|
||||||
|
this.log(`[faxbox] migrated legacy messages for box "${boxId}" to smartbucket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeMessages(boxId: string, messages: IFaxMessage[]): Promise<void> {
|
||||||
|
this.messagesByBox.set(boxId, [...messages]);
|
||||||
|
await this.storage.writeFaxMessages(boxId, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-41
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { SiprouterStorage } from './storage.ts';
|
||||||
import type {
|
import type {
|
||||||
IFaxCompletedEvent,
|
IFaxCompletedEvent,
|
||||||
IFaxFailedEvent,
|
IFaxFailedEvent,
|
||||||
@@ -16,6 +17,7 @@ export interface IFaxJob {
|
|||||||
status: 'dialing' | 'started' | 'completed' | 'failed';
|
status: 'dialing' | 'started' | 'completed' | 'failed';
|
||||||
transport?: 'audio' | 't38';
|
transport?: 'audio' | 't38';
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
|
objectKey?: string;
|
||||||
codec?: string;
|
codec?: string;
|
||||||
remoteMedia?: string;
|
remoteMedia?: string;
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
@@ -28,25 +30,21 @@ export interface IFaxJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class FaxJobManager {
|
export class FaxJobManager {
|
||||||
private readonly basePath: string;
|
private jobs: IFaxJob[] = [];
|
||||||
private readonly jobsPath: string;
|
|
||||||
private readonly log: (msg: string) => void;
|
private readonly log: (msg: string) => void;
|
||||||
|
private readonly storage: SiprouterStorage;
|
||||||
|
|
||||||
constructor(log: (msg: string) => void) {
|
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||||
this.basePath = path.join(process.cwd(), '.nogit', 'fax');
|
|
||||||
this.jobsPath = path.join(this.basePath, 'jobs.json');
|
|
||||||
this.log = log;
|
this.log = log;
|
||||||
|
this.storage = storageArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
init(): void {
|
async init(): Promise<void> {
|
||||||
fs.mkdirSync(this.basePath, { recursive: true });
|
this.jobs = await this.storage.getFaxJobs();
|
||||||
if (!fs.existsSync(this.jobsPath)) {
|
|
||||||
this.writeJobs([]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
noteDialing(callId: string, number: string, providerId: string): void {
|
async noteDialing(callId: string, number: string, providerId: string): Promise<void> {
|
||||||
const jobs = this.loadJobs();
|
const jobs = this.jobs;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = jobs.find((job) => job.callId === callId);
|
const existing = jobs.find((job) => job.callId === callId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -65,62 +63,61 @@ export class FaxJobManager {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.writeJobs(jobs);
|
await this.writeJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
noteStarted(event: IFaxStartedEvent): void {
|
async noteStarted(event: IFaxStartedEvent): Promise<void> {
|
||||||
const jobs = this.loadJobs();
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
|
const job = this.getOrCreateJob(event.call_id, event.direction, now);
|
||||||
job.status = 'started';
|
job.status = 'started';
|
||||||
job.transport = event.transport;
|
job.transport = event.transport;
|
||||||
job.filePath = event.file_path;
|
job.filePath = event.file_path;
|
||||||
|
await this.ensureOutboundFileObject(job, event.file_path);
|
||||||
job.codec = event.codec;
|
job.codec = event.codec;
|
||||||
job.remoteMedia = event.remote_media;
|
job.remoteMedia = event.remote_media;
|
||||||
job.updatedAt = now;
|
job.updatedAt = now;
|
||||||
this.writeJobs(jobs);
|
await this.writeJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
noteCompleted(event: IFaxCompletedEvent): void {
|
async noteCompleted(event: IFaxCompletedEvent): Promise<void> {
|
||||||
const jobs = this.loadJobs();
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
|
const job = this.getOrCreateJob(event.call_id, event.direction, now);
|
||||||
job.status = 'completed';
|
job.status = 'completed';
|
||||||
job.transport = event.transport;
|
job.transport = event.transport;
|
||||||
job.filePath = event.file_path;
|
job.filePath = event.file_path;
|
||||||
|
await this.ensureOutboundFileObject(job, event.file_path);
|
||||||
job.codec = event.codec;
|
job.codec = event.codec;
|
||||||
job.success = event.success;
|
job.success = event.success;
|
||||||
job.completionCode = event.completion_code ?? null;
|
job.completionCode = event.completion_code ?? null;
|
||||||
job.completionLabel = event.completion_label ?? null;
|
job.completionLabel = event.completion_label ?? null;
|
||||||
job.stats = event.stats;
|
job.stats = event.stats;
|
||||||
job.updatedAt = now;
|
job.updatedAt = now;
|
||||||
this.writeJobs(jobs);
|
await this.writeJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
noteFailed(event: IFaxFailedEvent): void {
|
async noteFailed(event: IFaxFailedEvent): Promise<void> {
|
||||||
const jobs = this.loadJobs();
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const job = this.getOrCreateJob(jobs, event.call_id, event.direction, now);
|
const job = this.getOrCreateJob(event.call_id, event.direction, now);
|
||||||
job.status = 'failed';
|
job.status = 'failed';
|
||||||
job.transport = event.transport;
|
job.transport = event.transport;
|
||||||
job.filePath = event.file_path;
|
job.filePath = event.file_path;
|
||||||
|
await this.ensureOutboundFileObject(job, event.file_path);
|
||||||
job.error = event.error;
|
job.error = event.error;
|
||||||
job.success = false;
|
job.success = false;
|
||||||
job.updatedAt = now;
|
job.updatedAt = now;
|
||||||
this.writeJobs(jobs);
|
await this.writeJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
getJobs(): IFaxJob[] {
|
getJobs(): IFaxJob[] {
|
||||||
return this.loadJobs();
|
return [...this.jobs];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrCreateJob(
|
private getOrCreateJob(
|
||||||
jobs: IFaxJob[],
|
|
||||||
callId: string,
|
callId: string,
|
||||||
direction: 'outbound' | 'inbound',
|
direction: 'outbound' | 'inbound',
|
||||||
now: number,
|
now: number,
|
||||||
): IFaxJob {
|
): IFaxJob {
|
||||||
let job = jobs.find((entry) => entry.callId === callId);
|
let job = this.jobs.find((entry) => entry.callId === callId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
job = {
|
job = {
|
||||||
id: callId,
|
id: callId,
|
||||||
@@ -130,24 +127,23 @@ export class FaxJobManager {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
jobs.unshift(job);
|
this.jobs.unshift(job);
|
||||||
}
|
}
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadJobs(): IFaxJob[] {
|
private async ensureOutboundFileObject(jobArg: IFaxJob, filePathArg: string | undefined): Promise<void> {
|
||||||
try {
|
if (jobArg.direction !== 'outbound' || jobArg.objectKey || !filePathArg) return;
|
||||||
const content = fs.readFileSync(this.jobsPath, 'utf8');
|
|
||||||
const parsed = JSON.parse(content);
|
const localPath = path.isAbsolute(filePathArg) ? filePathArg : path.join(process.cwd(), filePathArg);
|
||||||
return Array.isArray(parsed) ? parsed as IFaxJob[] : [];
|
if (!fs.existsSync(localPath)) return;
|
||||||
} catch {
|
|
||||||
return [];
|
const extension = path.extname(localPath) || '.tif';
|
||||||
}
|
jobArg.objectKey = await this.storage.putFileObject(`fax/outbound/${jobArg.callId}${extension}`, localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeJobs(jobs: IFaxJob[]): void {
|
private async writeJobs(): Promise<void> {
|
||||||
fs.mkdirSync(this.basePath, { recursive: true });
|
await this.storage.writeFaxJobs(this.jobs);
|
||||||
fs.writeFileSync(this.jobsPath, JSON.stringify(jobs, null, 2));
|
this.log(`[fax] persisted ${this.jobs.length} job(s)`);
|
||||||
this.log(`[fax] persisted ${jobs.length} job(s)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-78
@@ -11,19 +11,19 @@ import path from 'node:path';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { maskConfig, type IAppConfig } from './config.ts';
|
||||||
import type { FaxBoxManager } from './faxbox.ts';
|
import type { FaxBoxManager } from './faxbox.ts';
|
||||||
import type { FaxJobManager } from './faxjobs.ts';
|
import type { FaxJobManager } from './faxjobs.ts';
|
||||||
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
||||||
import type { VoiceboxManager } from './voicebox.ts';
|
import type { VoiceboxManager } from './voicebox.ts';
|
||||||
|
|
||||||
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
|
|
||||||
|
|
||||||
interface IHandleRequestContext {
|
interface IHandleRequestContext {
|
||||||
getStatus: () => unknown;
|
getStatus: () => unknown;
|
||||||
|
getConfig: () => IAppConfig;
|
||||||
|
updateConfig: (updatesArg: any) => Promise<IAppConfig>;
|
||||||
log: (msg: string) => void;
|
log: (msg: string) => void;
|
||||||
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null;
|
onStartCall: (number: string, deviceId?: string, providerId?: string) => Promise<{ id: string } | null>;
|
||||||
onHangupCall: (callId: string) => boolean;
|
onHangupCall: (callId: string) => boolean;
|
||||||
onConfigSaved?: () => void | Promise<void>;
|
|
||||||
faxBoxManager?: FaxBoxManager;
|
faxBoxManager?: FaxBoxManager;
|
||||||
faxJobManager?: FaxJobManager;
|
faxJobManager?: FaxJobManager;
|
||||||
voiceboxManager?: VoiceboxManager;
|
voiceboxManager?: VoiceboxManager;
|
||||||
@@ -112,7 +112,7 @@ async function handleRequest(
|
|||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
context: IHandleRequestContext,
|
context: IHandleRequestContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager } = context;
|
const { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager } = context;
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
const method = req.method || 'GET';
|
const method = req.method || 'GET';
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ async function handleRequest(
|
|||||||
if (!number || typeof number !== 'string') {
|
if (!number || typeof number !== 'string') {
|
||||||
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
||||||
}
|
}
|
||||||
const call = onStartCall(number, body?.deviceId, body?.providerId);
|
const call = await onStartCall(number, body?.deviceId, body?.providerId);
|
||||||
if (call) return sendJson(res, { ok: true, callId: call.id });
|
if (call) return sendJson(res, { ok: true, callId: call.id });
|
||||||
return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
|
return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -156,13 +156,16 @@ async function handleRequest(
|
|||||||
try {
|
try {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
const number = body?.number;
|
const number = body?.number;
|
||||||
const filePath = body?.filePath;
|
let filePath = body?.filePath;
|
||||||
if (!number || typeof number !== 'string') {
|
if (!number || typeof number !== 'string') {
|
||||||
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
|
||||||
}
|
}
|
||||||
if (!filePath || typeof filePath !== 'string') {
|
if (!filePath || typeof filePath !== 'string') {
|
||||||
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
|
return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
|
||||||
}
|
}
|
||||||
|
if (faxBoxManager) {
|
||||||
|
filePath = await faxBoxManager.prepareOutboundFaxFile(filePath);
|
||||||
|
}
|
||||||
const { sendFax } = await import('./proxybridge.ts');
|
const { sendFax } = await import('./proxybridge.ts');
|
||||||
const callId = await sendFax(number, filePath, body?.providerId);
|
const callId = await sendFax(number, filePath, body?.providerId);
|
||||||
if (callId) {
|
if (callId) {
|
||||||
@@ -191,7 +194,7 @@ async function handleRequest(
|
|||||||
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
|
const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
|
||||||
if (faxFileMatch && method === 'GET' && faxBoxManager) {
|
if (faxFileMatch && method === 'GET' && faxBoxManager) {
|
||||||
const [, boxId, msgId] = faxFileMatch;
|
const [, boxId, msgId] = faxFileMatch;
|
||||||
const filePath = faxBoxManager.getMessageFilePath(boxId, msgId);
|
const filePath = await faxBoxManager.getMessageFilePath(boxId, msgId);
|
||||||
if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -207,7 +210,7 @@ async function handleRequest(
|
|||||||
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
|
const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
|
||||||
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
|
if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
|
||||||
const [, boxId, msgId] = faxDeleteMatch;
|
const [, boxId, msgId] = faxDeleteMatch;
|
||||||
return sendJson(res, { ok: faxBoxManager.deleteMessage(boxId, msgId) });
|
return sendJson(res, { ok: await faxBoxManager.deleteMessage(boxId, msgId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: add a SIP device to a call (mid-call INVITE to desk phone).
|
// API: add a SIP device to a call (mid-call INVITE to desk phone).
|
||||||
@@ -272,10 +275,7 @@ async function handleRequest(
|
|||||||
// API: get config (sans passwords).
|
// API: get config (sans passwords).
|
||||||
if (url.pathname === '/api/config' && method === 'GET') {
|
if (url.pathname === '/api/config' && method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
return sendJson(res, maskConfig(getConfig()));
|
||||||
const cfg = JSON.parse(raw);
|
|
||||||
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
|
|
||||||
return sendJson(res, safe);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return sendJson(res, { ok: false, error: e.message }, 500);
|
return sendJson(res, { ok: false, error: e.message }, 500);
|
||||||
}
|
}
|
||||||
@@ -285,65 +285,9 @@ async function handleRequest(
|
|||||||
if (url.pathname === '/api/config' && method === 'POST') {
|
if (url.pathname === '/api/config' && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const updates = await readJsonBody(req);
|
const updates = await readJsonBody(req);
|
||||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
const config = await updateConfig(updates);
|
||||||
const cfg = JSON.parse(raw);
|
log('[config] updated smartdata config');
|
||||||
|
return sendJson(res, { ok: true, config: maskConfig(config) });
|
||||||
// Update existing providers.
|
|
||||||
if (updates.providers) {
|
|
||||||
for (const up of updates.providers) {
|
|
||||||
const existing = cfg.providers?.find((p: any) => p.id === up.id);
|
|
||||||
if (existing) {
|
|
||||||
if (up.displayName !== undefined) existing.displayName = up.displayName;
|
|
||||||
if (up.password && up.password !== '••••••') existing.password = up.password;
|
|
||||||
if (up.domain !== undefined) existing.domain = up.domain;
|
|
||||||
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
|
|
||||||
if (up.username !== undefined) existing.username = up.username;
|
|
||||||
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
|
|
||||||
if (up.codecs !== undefined) existing.codecs = up.codecs;
|
|
||||||
if (up.quirks !== undefined) existing.quirks = up.quirks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a new provider.
|
|
||||||
if (updates.addProvider) {
|
|
||||||
cfg.providers ??= [];
|
|
||||||
cfg.providers.push(updates.addProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove a provider.
|
|
||||||
if (updates.removeProvider) {
|
|
||||||
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
|
|
||||||
// Clean up routing references — remove routes that reference this provider.
|
|
||||||
if (cfg.routing?.routes) {
|
|
||||||
cfg.routing.routes = cfg.routing.routes.filter((r: any) =>
|
|
||||||
r.match?.sourceProvider !== updates.removeProvider &&
|
|
||||||
r.action?.provider !== updates.removeProvider
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.devices) {
|
|
||||||
for (const ud of updates.devices) {
|
|
||||||
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
|
|
||||||
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updates.incomingNumbers !== undefined) cfg.incomingNumbers = updates.incomingNumbers;
|
|
||||||
if (updates.routing) {
|
|
||||||
if (updates.routing.routes) {
|
|
||||||
cfg.routing.routes = updates.routing.routes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
|
||||||
if (updates.faxboxes !== undefined) cfg.faxboxes = updates.faxboxes;
|
|
||||||
if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes;
|
|
||||||
if (updates.ivr !== undefined) cfg.ivr = updates.ivr;
|
|
||||||
|
|
||||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
||||||
log('[config] updated config.json');
|
|
||||||
await onConfigSaved?.();
|
|
||||||
return sendJson(res, { ok: true });
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return sendJson(res, { ok: false, error: e.message }, 400);
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
||||||
}
|
}
|
||||||
@@ -367,7 +311,7 @@ async function handleRequest(
|
|||||||
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
|
const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
|
||||||
if (vmAudioMatch && method === 'GET' && voiceboxManager) {
|
if (vmAudioMatch && method === 'GET' && voiceboxManager) {
|
||||||
const [, boxId, msgId] = vmAudioMatch;
|
const [, boxId, msgId] = vmAudioMatch;
|
||||||
const audioPath = voiceboxManager.getMessageAudioPath(boxId, msgId);
|
const audioPath = await voiceboxManager.getMessageAudioPath(boxId, msgId);
|
||||||
if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
|
||||||
const stat = fs.statSync(audioPath);
|
const stat = fs.statSync(audioPath);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -383,14 +327,14 @@ async function handleRequest(
|
|||||||
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
|
const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
|
||||||
if (vmHeardMatch && method === 'POST' && voiceboxManager) {
|
if (vmHeardMatch && method === 'POST' && voiceboxManager) {
|
||||||
const [, boxId, msgId] = vmHeardMatch;
|
const [, boxId, msgId] = vmHeardMatch;
|
||||||
return sendJson(res, { ok: voiceboxManager.markHeard(boxId, msgId) });
|
return sendJson(res, { ok: await voiceboxManager.markHeard(boxId, msgId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: voicemail - delete message.
|
// API: voicemail - delete message.
|
||||||
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
|
const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
|
||||||
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
|
if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
|
||||||
const [, boxId, msgId] = vmDeleteMatch;
|
const [, boxId, msgId] = vmDeleteMatch;
|
||||||
return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) });
|
return sendJson(res, { ok: await voiceboxManager.deleteMessage(boxId, msgId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static files.
|
// Static files.
|
||||||
@@ -428,10 +372,11 @@ export function initWebUi(
|
|||||||
const {
|
const {
|
||||||
port,
|
port,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
getConfig,
|
||||||
|
updateConfig,
|
||||||
log,
|
log,
|
||||||
onStartCall,
|
onStartCall,
|
||||||
onHangupCall,
|
onHangupCall,
|
||||||
onConfigSaved,
|
|
||||||
faxBoxManager,
|
faxBoxManager,
|
||||||
faxJobManager,
|
faxJobManager,
|
||||||
voiceboxManager,
|
voiceboxManager,
|
||||||
@@ -453,12 +398,12 @@ export function initWebUi(
|
|||||||
const cert = fs.readFileSync(certPath, 'utf8');
|
const cert = fs.readFileSync(certPath, 'utf8');
|
||||||
const key = fs.readFileSync(keyPath, 'utf8');
|
const key = fs.readFileSync(keyPath, 'utf8');
|
||||||
server = https.createServer({ cert, key }, (req, res) =>
|
server = https.createServer({ cert, key }, (req, res) =>
|
||||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||||
);
|
);
|
||||||
useTls = true;
|
useTls = true;
|
||||||
} catch {
|
} catch {
|
||||||
server = http.createServer((req, res) =>
|
server = http.createServer((req, res) =>
|
||||||
handleRequest(req, res, { getStatus, log, onStartCall, onHangupCall, onConfigSaved, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import * as smartbucket from '@push.rocks/smartbucket';
|
||||||
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
|
|
||||||
|
export { smartbucket, smartdata };
|
||||||
@@ -102,7 +102,8 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
statusStore.noteOutboundCallStarted(data);
|
statusStore.noteOutboundCallStarted(data);
|
||||||
|
|
||||||
if (data.ring_browsers === false) {
|
if (data.ring_browsers === false) {
|
||||||
faxJobManager.noteDialing(data.call_id, data.number, data.provider_id);
|
void faxJobManager.noteDialing(data.call_id, data.number, data.provider_id)
|
||||||
|
.catch((error) => log(`[fax] persist dialing failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.ring_browsers === false) {
|
if (data.ring_browsers === false) {
|
||||||
@@ -218,12 +219,12 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
onProxyEvent('recording_done', (data) => {
|
onProxyEvent('recording_done', (data) => {
|
||||||
const boxId = data.voicebox_id || 'default';
|
const boxId = data.voicebox_id || 'default';
|
||||||
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) box=${boxId} caller=${data.caller_number}`);
|
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) box=${boxId} caller=${data.caller_number}`);
|
||||||
voiceboxManager.addMessage(boxId, {
|
void voiceboxManager.addMessage(boxId, {
|
||||||
callerNumber: data.caller_number || 'Unknown',
|
callerNumber: data.caller_number || 'Unknown',
|
||||||
callerName: null,
|
callerName: null,
|
||||||
fileName: data.file_path,
|
fileName: data.file_path,
|
||||||
durationMs: data.duration_ms,
|
durationMs: data.duration_ms,
|
||||||
});
|
}).catch((error) => log(`[voicemail] persist failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
onProxyEvent('voicemail_error', (data) => {
|
onProxyEvent('voicemail_error', (data) => {
|
||||||
@@ -231,24 +232,24 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
});
|
});
|
||||||
|
|
||||||
onProxyEvent('fax_started', (data) => {
|
onProxyEvent('fax_started', (data) => {
|
||||||
faxJobManager.noteStarted(data);
|
void faxJobManager.noteStarted(data).catch((error) => log(`[fax] persist start failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`);
|
log(`[fax] started: call=${data.call_id} leg=${data.leg_id} ${data.direction}/${data.transport} codec=${data.codec || '?'} file=${data.file_path}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
onProxyEvent('fax_completed', (data) => {
|
onProxyEvent('fax_completed', (data) => {
|
||||||
faxJobManager.noteCompleted(data);
|
void faxJobManager.noteCompleted(data).catch((error) => log(`[fax] persist completion failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
log(
|
log(
|
||||||
`[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`,
|
`[fax] completed: call=${data.call_id} leg=${data.leg_id} success=${data.success} pagesTx=${data.stats.pages_tx} bitrate=${data.stats.bit_rate} completion=${data.completion_label || data.completion_code || 'unknown'}`,
|
||||||
);
|
);
|
||||||
if (data.direction === 'inbound' && data.success && data.fax_box_id) {
|
if (data.direction === 'inbound' && data.success && data.fax_box_id) {
|
||||||
faxBoxManager.addMessage(data.fax_box_id, {
|
void faxBoxManager.addMessage(data.fax_box_id, {
|
||||||
callerNumber: data.caller_number,
|
callerNumber: data.caller_number,
|
||||||
fileName: data.file_path,
|
fileName: data.file_path,
|
||||||
completionCode: data.completion_code,
|
completionCode: data.completion_code,
|
||||||
completionLabel: data.completion_label,
|
completionLabel: data.completion_label,
|
||||||
pageCount: data.stats.pages_rx || data.stats.pages_tx,
|
pageCount: data.stats.pages_rx || data.stats.pages_tx,
|
||||||
bitRate: data.stats.bit_rate,
|
bitRate: data.stats.bit_rate,
|
||||||
});
|
}).catch((error) => log(`[fax] persist inbox failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
}
|
}
|
||||||
if (data.direction === 'outbound' || data.fax_box_id) {
|
if (data.direction === 'outbound' || data.fax_box_id) {
|
||||||
void hangupCall(data.call_id);
|
void hangupCall(data.call_id);
|
||||||
@@ -256,7 +257,7 @@ export function registerProxyEventHandlers(options: IRegisterProxyEventHandlersO
|
|||||||
});
|
});
|
||||||
|
|
||||||
onProxyEvent('fax_failed', (data) => {
|
onProxyEvent('fax_failed', (data) => {
|
||||||
faxJobManager.noteFailed(data);
|
void faxJobManager.noteFailed(data).catch((error) => log(`[fax] persist failure failed: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`);
|
log(`[fax] failed: call=${data.call_id} leg=${data.leg_id} error=${data.error}`);
|
||||||
if (data.direction === 'outbound' || data.fax_box_id) {
|
if (data.direction === 'outbound' || data.fax_box_id) {
|
||||||
void hangupCall(data.call_id);
|
void hangupCall(data.call_id);
|
||||||
|
|||||||
+97
-71
@@ -8,7 +8,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { loadConfig, type IAppConfig } from './config.ts';
|
import { applyConfigUpdates, type IAppConfig } from './config.ts';
|
||||||
import { FaxBoxManager } from './faxbox.ts';
|
import { FaxBoxManager } from './faxbox.ts';
|
||||||
import { FaxJobManager } from './faxjobs.ts';
|
import { FaxJobManager } from './faxjobs.ts';
|
||||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||||
@@ -27,24 +27,21 @@ import {
|
|||||||
} from './proxybridge.ts';
|
} from './proxybridge.ts';
|
||||||
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
|
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
|
||||||
import { StatusStore } from './runtime/status-store.ts';
|
import { StatusStore } from './runtime/status-store.ts';
|
||||||
|
import { SiprouterStorage } from './storage.ts';
|
||||||
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
|
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
|
||||||
|
|
||||||
let appConfig: IAppConfig = loadConfig();
|
let appConfig: IAppConfig;
|
||||||
|
|
||||||
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
|
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
const statusStore = new StatusStore(appConfig);
|
const storage = new SiprouterStorage(log);
|
||||||
const webRtcLinks = new WebRtcLinkManager();
|
let statusStore: StatusStore;
|
||||||
const faxBoxManager = new FaxBoxManager(log);
|
let webRtcLinks: WebRtcLinkManager;
|
||||||
const faxJobManager = new FaxJobManager(log);
|
let faxBoxManager: FaxBoxManager;
|
||||||
const voiceboxManager = new VoiceboxManager(log);
|
let faxJobManager: FaxJobManager;
|
||||||
|
let voiceboxManager: VoiceboxManager;
|
||||||
faxBoxManager.init(appConfig.faxboxes ?? []);
|
|
||||||
faxJobManager.init();
|
|
||||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
|
||||||
initWebRtcSignaling({ log });
|
|
||||||
|
|
||||||
function now(): string {
|
function now(): string {
|
||||||
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
@@ -96,12 +93,12 @@ async function configureRuntime(config: IAppConfig): Promise<boolean> {
|
|||||||
async function reloadConfig(): Promise<void> {
|
async function reloadConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const previousConfig = appConfig;
|
const previousConfig = appConfig;
|
||||||
const nextConfig = loadConfig();
|
const nextConfig = await storage.getAppConfig();
|
||||||
|
|
||||||
appConfig = nextConfig;
|
appConfig = nextConfig;
|
||||||
statusStore.updateConfig(nextConfig);
|
statusStore.updateConfig(nextConfig);
|
||||||
faxBoxManager.init(nextConfig.faxboxes ?? []);
|
await faxBoxManager.init(nextConfig.faxboxes ?? []);
|
||||||
voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
await voiceboxManager.init(nextConfig.voiceboxes ?? []);
|
||||||
|
|
||||||
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
|
||||||
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
|
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
|
||||||
@@ -121,6 +118,13 @@ async function reloadConfig(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateConfig(updatesArg: any): Promise<IAppConfig> {
|
||||||
|
const nextConfig = applyConfigUpdates(appConfig, updatesArg);
|
||||||
|
await storage.writeAppConfig(nextConfig);
|
||||||
|
await reloadConfig();
|
||||||
|
return appConfig;
|
||||||
|
}
|
||||||
|
|
||||||
async function startProxyEngine(): Promise<void> {
|
async function startProxyEngine(): Promise<void> {
|
||||||
const started = await initProxyEngine(log);
|
const started = await initProxyEngine(log);
|
||||||
if (!started) {
|
if (!started) {
|
||||||
@@ -155,79 +159,101 @@ async function startProxyEngine(): Promise<void> {
|
|||||||
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
|
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
initWebUi({
|
async function main(): Promise<void> {
|
||||||
port: appConfig.proxy.webUiPort,
|
await storage.init();
|
||||||
getStatus,
|
appConfig = await storage.getAppConfig();
|
||||||
log,
|
|
||||||
onStartCall: (number, deviceId, providerId) => {
|
statusStore = new StatusStore(appConfig);
|
||||||
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
|
webRtcLinks = new WebRtcLinkManager();
|
||||||
void makeCall(number, deviceId, providerId).then((callId) => {
|
faxBoxManager = new FaxBoxManager(log, storage);
|
||||||
if (callId) {
|
faxJobManager = new FaxJobManager(log, storage);
|
||||||
log(`[dashboard] call started: ${callId}`);
|
voiceboxManager = new VoiceboxManager(log, storage);
|
||||||
statusStore.noteDashboardCallStarted(callId, number, providerId);
|
|
||||||
} else {
|
await faxBoxManager.init(appConfig.faxboxes ?? []);
|
||||||
|
await faxJobManager.init();
|
||||||
|
await voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||||
|
initWebRtcSignaling({ log });
|
||||||
|
|
||||||
|
initWebUi({
|
||||||
|
port: appConfig.proxy.webUiPort,
|
||||||
|
getStatus,
|
||||||
|
getConfig: () => appConfig,
|
||||||
|
updateConfig,
|
||||||
|
log,
|
||||||
|
onStartCall: async (number, deviceId, providerId) => {
|
||||||
|
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
|
||||||
|
const callId = await makeCall(number, deviceId, providerId);
|
||||||
|
if (!callId) {
|
||||||
log(`[dashboard] call failed for ${number}`);
|
log(`[dashboard] call failed for ${number}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log(`[dashboard] call started: ${callId}`);
|
||||||
|
statusStore.noteDashboardCallStarted(callId, number, providerId);
|
||||||
|
return { id: callId };
|
||||||
|
},
|
||||||
|
onHangupCall: (callId) => {
|
||||||
|
void hangupCall(callId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
faxBoxManager,
|
||||||
|
faxJobManager,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return { id: `pending-${Date.now()}` };
|
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
|
||||||
},
|
const result = await webrtcOffer(sessionId, sdp);
|
||||||
onHangupCall: (callId) => {
|
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
|
||||||
void hangupCall(callId);
|
if (result?.sdp) {
|
||||||
return true;
|
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
|
||||||
},
|
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
|
||||||
onConfigSaved: reloadConfig,
|
return;
|
||||||
faxBoxManager,
|
}
|
||||||
faxJobManager,
|
|
||||||
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)...`);
|
log('[webrtc] ERROR: no answer SDP from Rust');
|
||||||
const result = await webrtcOffer(sessionId, sdp);
|
},
|
||||||
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
|
onWebRtcIce: async (sessionId, candidate) => {
|
||||||
if (result?.sdp) {
|
await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
|
||||||
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
|
},
|
||||||
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
|
onWebRtcClose: async (sessionId) => {
|
||||||
return;
|
webRtcLinks.removeSession(sessionId);
|
||||||
}
|
await webrtcClose(sessionId);
|
||||||
|
},
|
||||||
|
onWebRtcAccept: (callId, sessionId) => {
|
||||||
|
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
|
||||||
|
|
||||||
log('[webrtc] ERROR: no answer SDP from Rust');
|
const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
|
||||||
},
|
if (pendingMedia) {
|
||||||
onWebRtcIce: async (sessionId, candidate) => {
|
requestWebRtcLink(callId, sessionId, pendingMedia);
|
||||||
await webrtcIce(sessionId, candidate as Parameters<typeof webrtcIce>[1]);
|
return;
|
||||||
},
|
}
|
||||||
onWebRtcClose: async (sessionId) => {
|
|
||||||
webRtcLinks.removeSession(sessionId);
|
|
||||||
await webrtcClose(sessionId);
|
|
||||||
},
|
|
||||||
onWebRtcAccept: (callId, sessionId) => {
|
|
||||||
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
|
|
||||||
|
|
||||||
const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
|
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
||||||
if (pendingMedia) {
|
},
|
||||||
requestWebRtcLink(callId, sessionId, pendingMedia);
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
|
await startProxyEngine();
|
||||||
},
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
log(`[FATAL] ${errorMessage(error)}`);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
void startProxyEngine();
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
log('SIGINT, exiting');
|
log('SIGINT, exiting');
|
||||||
shutdownProxyEngine();
|
shutdownProxyEngine();
|
||||||
|
void storage.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
log('SIGTERM, exiting');
|
log('SIGTERM, exiting');
|
||||||
shutdownProxyEngine();
|
shutdownProxyEngine();
|
||||||
|
void storage.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import * as fsPromises from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import * as plugins from './plugins.ts';
|
||||||
|
import {
|
||||||
|
createInitialConfigFromEnv,
|
||||||
|
normalizeConfig,
|
||||||
|
type IAppConfig,
|
||||||
|
} from './config.ts';
|
||||||
|
import type { IFaxMessage } from './faxbox.ts';
|
||||||
|
import type { IFaxJob } from './faxjobs.ts';
|
||||||
|
import type { IVoicemailMessage } from './voicebox.ts';
|
||||||
|
|
||||||
|
interface ISiprouterDataStore {
|
||||||
|
appConfig: IAppConfig;
|
||||||
|
faxJobs: IFaxJob[];
|
||||||
|
faxMessagesByBox: Record<string, IFaxMessage[]>;
|
||||||
|
voicemailMessagesByBox: Record<string, IVoicemailMessage[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLogFunction = (messageArg: string) => void;
|
||||||
|
|
||||||
|
const legacyConfigPath = path.join(process.cwd(), '.nogit', 'config.json');
|
||||||
|
|
||||||
|
function requiredEnv(keysArg: string[]): string {
|
||||||
|
for (const key of keysArg) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
throw new Error(`Missing required environment variable: ${keysArg.join(' or ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(valueArg: string | undefined, fallbackArg?: number): number | undefined {
|
||||||
|
if (!valueArg) return fallbackArg;
|
||||||
|
const parsed = Number(valueArg);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallbackArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBoolean(valueArg: string | undefined, fallbackArg?: boolean): boolean | undefined {
|
||||||
|
if (valueArg === undefined) return fallbackArg;
|
||||||
|
return !['0', 'false', 'no', 'off'].includes(valueArg.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObjectKey(keyArg: string): string {
|
||||||
|
const normalizedKey = keyArg.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
||||||
|
if (normalizedKey.split('/').includes('..')) {
|
||||||
|
throw new Error(`Invalid object key: ${keyArg}`);
|
||||||
|
}
|
||||||
|
return normalizedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SiprouterStorage {
|
||||||
|
private db!: InstanceType<typeof plugins.smartdata.SmartdataDb>;
|
||||||
|
private store!: any;
|
||||||
|
private bucket!: any;
|
||||||
|
private readonly cacheDir = path.join(process.cwd(), '.nogit', 'cache');
|
||||||
|
private readonly log: TLogFunction;
|
||||||
|
|
||||||
|
constructor(logArg: TLogFunction) {
|
||||||
|
this.log = logArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init(): Promise<void> {
|
||||||
|
this.db = new plugins.smartdata.SmartdataDb(this.getMongoDescriptor() as any);
|
||||||
|
await this.db.init();
|
||||||
|
this.store = await this.db.createEasyStore('siprouter-data');
|
||||||
|
|
||||||
|
const smartBucket = new plugins.smartbucket.SmartBucket(this.getS3Descriptor() as any);
|
||||||
|
const bucketName = requiredEnv(['SIPROUTER_S3_BUCKET', 'S3_BUCKET']);
|
||||||
|
this.bucket = await smartBucket.bucketExists(bucketName)
|
||||||
|
? await smartBucket.getBucketByName(bucketName)
|
||||||
|
: await smartBucket.createBucket(bucketName);
|
||||||
|
|
||||||
|
await fsPromises.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
this.log('[storage] smartdata and smartbucket initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
if (this.db) {
|
||||||
|
await this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAppConfig(): Promise<IAppConfig> {
|
||||||
|
const storedConfig = await this.readKey('appConfig');
|
||||||
|
if (storedConfig) {
|
||||||
|
return normalizeConfig(storedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyConfig = await this.readLegacyConfig();
|
||||||
|
const initialConfig = legacyConfig || createInitialConfigFromEnv();
|
||||||
|
await this.writeAppConfig(initialConfig);
|
||||||
|
this.log(legacyConfig ? '[storage] imported legacy .nogit/config.json into smartdata' : '[storage] created initial smartdata config');
|
||||||
|
return initialConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeAppConfig(configArg: IAppConfig): Promise<void> {
|
||||||
|
await this.writeKey('appConfig', normalizeConfig(configArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFaxJobs(): Promise<IFaxJob[]> {
|
||||||
|
return (await this.readKey('faxJobs')) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeFaxJobs(jobsArg: IFaxJob[]): Promise<void> {
|
||||||
|
await this.writeKey('faxJobs', jobsArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getVoicemailMessages(boxIdArg: string): Promise<IVoicemailMessage[]> {
|
||||||
|
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
|
||||||
|
return allMessages[boxIdArg] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeVoicemailMessages(boxIdArg: string, messagesArg: IVoicemailMessage[]): Promise<void> {
|
||||||
|
const allMessages = (await this.readKey('voicemailMessagesByBox')) || {};
|
||||||
|
allMessages[boxIdArg] = messagesArg;
|
||||||
|
await this.writeKey('voicemailMessagesByBox', allMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFaxMessages(boxIdArg: string): Promise<IFaxMessage[]> {
|
||||||
|
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
|
||||||
|
return allMessages[boxIdArg] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async writeFaxMessages(boxIdArg: string, messagesArg: IFaxMessage[]): Promise<void> {
|
||||||
|
const allMessages = (await this.readKey('faxMessagesByBox')) || {};
|
||||||
|
allMessages[boxIdArg] = messagesArg;
|
||||||
|
await this.writeKey('faxMessagesByBox', allMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async putFileObject(objectKeyArg: string, filePathArg: string): Promise<string> {
|
||||||
|
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||||
|
const contents = await fsPromises.readFile(filePathArg);
|
||||||
|
await this.bucket.fastPut({ path: objectKey, contents, overwrite: true });
|
||||||
|
await this.removeCachedObject(objectKey);
|
||||||
|
return objectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async putBufferObject(objectKeyArg: string, bufferArg: Buffer): Promise<string> {
|
||||||
|
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||||
|
await this.bucket.fastPut({ path: objectKey, contents: bufferArg, overwrite: true });
|
||||||
|
await this.removeCachedObject(objectKey);
|
||||||
|
return objectKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getObjectAsCachedFile(objectKeyArg: string, fileNameArg?: string): Promise<string | null> {
|
||||||
|
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||||
|
const cachePath = this.getCachePath(objectKey);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cachePath)) {
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
const contents = await this.bucket.fastGet({ path: objectKey });
|
||||||
|
await fsPromises.mkdir(path.dirname(cachePath), { recursive: true });
|
||||||
|
await fsPromises.writeFile(cachePath, contents);
|
||||||
|
return cachePath;
|
||||||
|
} catch {
|
||||||
|
if (fileNameArg) {
|
||||||
|
const fallbackPath = path.join(this.cacheDir, path.basename(fileNameArg));
|
||||||
|
return fs.existsSync(fallbackPath) ? fallbackPath : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeObject(objectKeyArg: string | undefined): Promise<void> {
|
||||||
|
if (!objectKeyArg) return;
|
||||||
|
const objectKey = normalizeObjectKey(objectKeyArg);
|
||||||
|
try {
|
||||||
|
await this.bucket.fastRemove({ path: objectKey });
|
||||||
|
} catch {
|
||||||
|
// Missing objects are harmless during metadata cleanup.
|
||||||
|
}
|
||||||
|
await this.removeCachedObject(objectKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachePath(objectKeyArg: string): string {
|
||||||
|
return path.join(this.cacheDir, normalizeObjectKey(objectKeyArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeCachedObject(objectKeyArg: string): Promise<void> {
|
||||||
|
await fsPromises.rm(this.getCachePath(objectKeyArg), { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readLegacyConfig(): Promise<IAppConfig | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fsPromises.readFile(legacyConfigPath, 'utf8');
|
||||||
|
return normalizeConfig(JSON.parse(raw) as IAppConfig);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readKey<TKey extends keyof ISiprouterDataStore>(keyArg: TKey): Promise<ISiprouterDataStore[TKey] | undefined> {
|
||||||
|
try {
|
||||||
|
return await this.store.readKey(keyArg) as ISiprouterDataStore[TKey] | undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeKey<TKey extends keyof ISiprouterDataStore>(
|
||||||
|
keyArg: TKey,
|
||||||
|
valueArg: ISiprouterDataStore[TKey],
|
||||||
|
): Promise<void> {
|
||||||
|
await this.store.writeKey(keyArg, valueArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMongoDescriptor(): Record<string, string> {
|
||||||
|
const mongoDbUrl = requiredEnv([
|
||||||
|
'SIPROUTER_MONGODB_URL',
|
||||||
|
'MONGODB_URI',
|
||||||
|
'MONGODB_URL',
|
||||||
|
]);
|
||||||
|
const descriptor: Record<string, string> = {
|
||||||
|
mongoDbUrl,
|
||||||
|
mongoDbName: process.env.SIPROUTER_MONGODB_NAME || process.env.MONGODB_DATABASE || process.env.MONGODB_NAME || 'siprouter',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mongoDbUser = process.env.SIPROUTER_MONGODB_USER || process.env.MONGODB_USERNAME || process.env.MONGODB_USER;
|
||||||
|
const mongoDbPass = process.env.SIPROUTER_MONGODB_PASS || process.env.MONGODB_PASSWORD || process.env.MONGODB_PASS;
|
||||||
|
if (mongoDbUser) descriptor.mongoDbUser = mongoDbUser;
|
||||||
|
if (mongoDbPass) descriptor.mongoDbPass = mongoDbPass;
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getS3Descriptor(): Record<string, string | number | boolean> {
|
||||||
|
const rawEndpoint = requiredEnv(['SIPROUTER_S3_ENDPOINT', 'S3_ENDPOINT', 'AWS_ENDPOINT_URL']);
|
||||||
|
let endpoint = rawEndpoint;
|
||||||
|
let port = optionalNumber(process.env.SIPROUTER_S3_PORT || process.env.S3_PORT);
|
||||||
|
let useSsl = optionalBoolean(process.env.SIPROUTER_S3_USESSL || process.env.S3_USESSL || process.env.S3_USE_SSL);
|
||||||
|
|
||||||
|
if (/^https?:\/\//.test(rawEndpoint)) {
|
||||||
|
const url = new URL(rawEndpoint);
|
||||||
|
endpoint = url.hostname;
|
||||||
|
port = url.port ? Number(url.port) : port;
|
||||||
|
useSsl = url.protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
accessKey: requiredEnv(['SIPROUTER_S3_ACCESS_KEY', 'S3_ACCESS_KEY', 'AWS_ACCESS_KEY_ID']),
|
||||||
|
accessSecret: requiredEnv(['SIPROUTER_S3_SECRET_KEY', 'S3_SECRET_KEY', 'AWS_SECRET_ACCESS_KEY']),
|
||||||
|
region: process.env.SIPROUTER_S3_REGION || process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1',
|
||||||
|
...(port ? { port } : {}),
|
||||||
|
...(useSsl !== undefined ? { useSsl } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-166
@@ -1,22 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* VoiceboxManager — manages voicemail boxes, message storage, and MWI.
|
* VoiceboxManager — manages voicemail boxes, message metadata, and audio objects.
|
||||||
*
|
|
||||||
* Each voicebox corresponds to a device/extension. Messages are stored
|
|
||||||
* as WAV files with JSON metadata in .nogit/voicemail/{boxId}/.
|
|
||||||
*
|
|
||||||
* Supports:
|
|
||||||
* - Per-box configurable TTS greetings (text + voice) or uploaded WAV
|
|
||||||
* - Message CRUD: save, list, mark heard, delete
|
|
||||||
* - Unheard count for MWI (Message Waiting Indicator)
|
|
||||||
* - Storage limit (max messages per box)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
import type { SiprouterStorage } from './storage.ts';
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface IVoiceboxConfig {
|
export interface IVoiceboxConfig {
|
||||||
/** Unique ID — typically matches device ID or extension. */
|
/** Unique ID — typically matches device ID or extension. */
|
||||||
@@ -27,11 +17,9 @@ export interface IVoiceboxConfig {
|
|||||||
greetingText?: string;
|
greetingText?: string;
|
||||||
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
|
/** Kokoro TTS voice ID for the greeting (default 'af_bella'). */
|
||||||
greetingVoice?: string;
|
greetingVoice?: string;
|
||||||
/** Path to uploaded WAV greeting (overrides TTS). */
|
/** Path to cached uploaded WAV greeting (overrides TTS). */
|
||||||
greetingWavPath?: string;
|
greetingWavPath?: string;
|
||||||
/** Seconds to wait before routing to voicemail. Defaults to 25 when
|
/** Seconds to wait before routing to voicemail. */
|
||||||
* absent — both the config loader and `VoiceboxManager.init` apply
|
|
||||||
* the default via `??=`. */
|
|
||||||
noAnswerTimeoutSec?: number;
|
noAnswerTimeoutSec?: number;
|
||||||
/** Maximum recording duration in seconds. Defaults to 120. */
|
/** Maximum recording duration in seconds. Defaults to 120. */
|
||||||
maxRecordingSec?: number;
|
maxRecordingSec?: number;
|
||||||
@@ -52,112 +40,80 @@ export interface IVoicemailMessage {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
/** Duration in milliseconds. */
|
/** Duration in milliseconds. */
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
/** Relative path to the WAV file (within the box directory). */
|
/** Display file name. */
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
/** SmartBucket object key for the WAV payload. */
|
||||||
|
objectKey?: string;
|
||||||
/** Whether the message has been listened to. */
|
/** Whether the message has been listened to. */
|
||||||
heard: boolean;
|
heard: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default greeting text when no custom text is configured.
|
|
||||||
const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
const DEFAULT_GREETING = 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// VoiceboxManager
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class VoiceboxManager {
|
export class VoiceboxManager {
|
||||||
private boxes = new Map<string, IVoiceboxConfig>();
|
private boxes = new Map<string, IVoiceboxConfig>();
|
||||||
private basePath: string;
|
private messagesByBox = new Map<string, IVoicemailMessage[]>();
|
||||||
private log: (msg: string) => void;
|
private readonly basePath: string;
|
||||||
|
private readonly log: (msg: string) => void;
|
||||||
|
private readonly storage: SiprouterStorage;
|
||||||
|
|
||||||
constructor(log: (msg: string) => void) {
|
constructor(log: (msg: string) => void, storageArg: SiprouterStorage) {
|
||||||
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
|
this.basePath = path.join(process.cwd(), '.nogit', 'voicemail');
|
||||||
this.log = log;
|
this.log = log;
|
||||||
|
this.storage = storageArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
async init(voiceboxConfigs: IVoiceboxConfig[]): Promise<void> {
|
||||||
// Initialization
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load voicebox configurations from the app config.
|
|
||||||
*/
|
|
||||||
init(voiceboxConfigs: IVoiceboxConfig[]): void {
|
|
||||||
this.boxes.clear();
|
this.boxes.clear();
|
||||||
|
|
||||||
for (const cfg of voiceboxConfigs) {
|
for (const cfg of voiceboxConfigs) {
|
||||||
// Apply defaults.
|
|
||||||
cfg.noAnswerTimeoutSec ??= 25;
|
cfg.noAnswerTimeoutSec ??= 25;
|
||||||
cfg.maxRecordingSec ??= 120;
|
cfg.maxRecordingSec ??= 120;
|
||||||
cfg.maxMessages ??= 50;
|
cfg.maxMessages ??= 50;
|
||||||
cfg.greetingVoice ??= 'af_bella';
|
cfg.greetingVoice ??= 'af_bella';
|
||||||
|
|
||||||
this.boxes.set(cfg.id, cfg);
|
this.boxes.set(cfg.id, cfg);
|
||||||
|
this.messagesByBox.set(cfg.id, await this.loadMessages(cfg.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure base directory exists.
|
await fsPromises.mkdir(this.basePath, { recursive: true });
|
||||||
fs.mkdirSync(this.basePath, { recursive: true });
|
|
||||||
|
|
||||||
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
|
this.log(`[voicebox] initialized ${this.boxes.size} voicebox(es)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Box management
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Get config for a specific voicebox. */
|
|
||||||
getBox(boxId: string): IVoiceboxConfig | null {
|
getBox(boxId: string): IVoiceboxConfig | null {
|
||||||
return this.boxes.get(boxId) ?? null;
|
return this.boxes.get(boxId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get all configured voicebox IDs. */
|
|
||||||
getBoxIds(): string[] {
|
getBoxIds(): string[] {
|
||||||
return [...this.boxes.keys()];
|
return [...this.boxes.keys()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the greeting text for a voicebox. */
|
|
||||||
getGreetingText(boxId: string): string {
|
getGreetingText(boxId: string): string {
|
||||||
const box = this.boxes.get(boxId);
|
const box = this.boxes.get(boxId);
|
||||||
return box?.greetingText || DEFAULT_GREETING;
|
return box?.greetingText || DEFAULT_GREETING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the greeting voice for a voicebox. */
|
|
||||||
getGreetingVoice(boxId: string): string {
|
getGreetingVoice(boxId: string): string {
|
||||||
const box = this.boxes.get(boxId);
|
const box = this.boxes.get(boxId);
|
||||||
return box?.greetingVoice || 'af_bella';
|
return box?.greetingVoice || 'af_bella';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a voicebox has a custom WAV greeting. */
|
|
||||||
hasCustomGreetingWav(boxId: string): boolean {
|
hasCustomGreetingWav(boxId: string): boolean {
|
||||||
const box = this.boxes.get(boxId);
|
const box = this.boxes.get(boxId);
|
||||||
if (!box?.greetingWavPath) return false;
|
if (!box?.greetingWavPath) return false;
|
||||||
return fs.existsSync(box.greetingWavPath);
|
return fs.existsSync(box.greetingWavPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the greeting WAV path (custom or null). */
|
|
||||||
getCustomGreetingWavPath(boxId: string): string | null {
|
getCustomGreetingWavPath(boxId: string): string | null {
|
||||||
const box = this.boxes.get(boxId);
|
const box = this.boxes.get(boxId);
|
||||||
if (!box?.greetingWavPath) return null;
|
if (!box?.greetingWavPath) return null;
|
||||||
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
|
return fs.existsSync(box.greetingWavPath) ? box.greetingWavPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the directory path for a voicebox. */
|
|
||||||
getBoxDir(boxId: string): string {
|
getBoxDir(boxId: string): string {
|
||||||
return path.join(this.basePath, boxId);
|
return path.join(this.basePath, boxId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
async addMessage(
|
||||||
// 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,
|
boxId: string,
|
||||||
info: {
|
info: {
|
||||||
callerNumber: string;
|
callerNumber: string;
|
||||||
@@ -165,124 +121,87 @@ export class VoiceboxManager {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
},
|
},
|
||||||
): void {
|
): Promise<void> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const localPath = path.isAbsolute(info.fileName) ? info.fileName : path.join(process.cwd(), info.fileName);
|
||||||
|
const objectKey = await this.storage.putFileObject(`voicemail/${boxId}/${id}.wav`, localPath);
|
||||||
|
|
||||||
const msg: IVoicemailMessage = {
|
const msg: IVoicemailMessage = {
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
boxId,
|
boxId,
|
||||||
callerNumber: info.callerNumber,
|
callerNumber: info.callerNumber,
|
||||||
callerName: info.callerName ?? undefined,
|
callerName: info.callerName ?? undefined,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
durationMs: info.durationMs,
|
durationMs: info.durationMs,
|
||||||
fileName: path.basename(info.fileName),
|
fileName: path.basename(localPath),
|
||||||
|
objectKey,
|
||||||
heard: false,
|
heard: false,
|
||||||
};
|
};
|
||||||
this.saveMessage(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const messages = this.getMessages(boxId);
|
||||||
* Save a new voicemail message.
|
messages.unshift(msg);
|
||||||
* The WAV file should already exist at the expected path.
|
await this.enforceLimit(boxId, messages);
|
||||||
*/
|
await this.writeMessages(boxId, messages);
|
||||||
saveMessage(msg: IVoicemailMessage): void {
|
|
||||||
const boxDir = this.getBoxDir(msg.boxId);
|
|
||||||
fs.mkdirSync(boxDir, { recursive: true });
|
|
||||||
|
|
||||||
const messages = this.loadMessages(msg.boxId);
|
await fsPromises.rm(localPath, { force: true }).catch(() => {});
|
||||||
messages.unshift(msg); // newest first
|
|
||||||
|
|
||||||
// Enforce max messages — delete oldest.
|
|
||||||
const box = this.boxes.get(msg.boxId);
|
|
||||||
const maxMessages = box?.maxMessages ?? 50;
|
|
||||||
while (messages.length > maxMessages) {
|
|
||||||
const old = messages.pop()!;
|
|
||||||
const oldPath = path.join(boxDir, old.fileName);
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
||||||
} catch { /* best effort */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
this.writeMessages(msg.boxId, messages);
|
|
||||||
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
|
this.log(`[voicebox] saved message ${msg.id} in box "${msg.boxId}" (${msg.durationMs}ms from ${msg.callerNumber})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List messages for a voicebox (newest first).
|
|
||||||
*/
|
|
||||||
getMessages(boxId: string): IVoicemailMessage[] {
|
getMessages(boxId: string): IVoicemailMessage[] {
|
||||||
return this.loadMessages(boxId);
|
return [...(this.messagesByBox.get(boxId) || [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single message by ID.
|
|
||||||
*/
|
|
||||||
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
|
getMessage(boxId: string, messageId: string): IVoicemailMessage | null {
|
||||||
const messages = this.loadMessages(boxId);
|
const messages = this.messagesByBox.get(boxId) || [];
|
||||||
return messages.find((m) => m.id === messageId) ?? null;
|
return messages.find((m) => m.id === messageId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async markHeard(boxId: string, messageId: string): Promise<boolean> {
|
||||||
* Mark a message as heard.
|
const messages = this.messagesByBox.get(boxId) || [];
|
||||||
*/
|
|
||||||
markHeard(boxId: string, messageId: string): boolean {
|
|
||||||
const messages = this.loadMessages(boxId);
|
|
||||||
const msg = messages.find((m) => m.id === messageId);
|
const msg = messages.find((m) => m.id === messageId);
|
||||||
if (!msg) return false;
|
if (!msg) return false;
|
||||||
|
|
||||||
msg.heard = true;
|
msg.heard = true;
|
||||||
this.writeMessages(boxId, messages);
|
await this.writeMessages(boxId, messages);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async deleteMessage(boxId: string, messageId: string): Promise<boolean> {
|
||||||
* Delete a message (both metadata and WAV file).
|
const messages = this.messagesByBox.get(boxId) || [];
|
||||||
*/
|
|
||||||
deleteMessage(boxId: string, messageId: string): boolean {
|
|
||||||
const messages = this.loadMessages(boxId);
|
|
||||||
const idx = messages.findIndex((m) => m.id === messageId);
|
const idx = messages.findIndex((m) => m.id === messageId);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
|
|
||||||
const msg = messages[idx];
|
const msg = messages[idx];
|
||||||
const boxDir = this.getBoxDir(boxId);
|
await this.storage.removeObject(msg.objectKey);
|
||||||
const wavPath = path.join(boxDir, msg.fileName);
|
if (!msg.objectKey) {
|
||||||
|
await fsPromises.rm(path.join(this.getBoxDir(boxId), msg.fileName), { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Delete WAV file.
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
|
||||||
} catch { /* best effort */ }
|
|
||||||
|
|
||||||
// Remove from list and save.
|
|
||||||
messages.splice(idx, 1);
|
messages.splice(idx, 1);
|
||||||
this.writeMessages(boxId, messages);
|
await this.writeMessages(boxId, messages);
|
||||||
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
this.log(`[voicebox] deleted message ${messageId} from box "${boxId}"`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getMessageAudioPath(boxId: string, messageId: string): Promise<string | null> {
|
||||||
* Get the full file path for a message's WAV file.
|
|
||||||
*/
|
|
||||||
getMessageAudioPath(boxId: string, messageId: string): string | null {
|
|
||||||
const msg = this.getMessage(boxId, messageId);
|
const msg = this.getMessage(boxId, messageId);
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
|
if (msg.objectKey) {
|
||||||
|
return await this.storage.getObjectAsCachedFile(msg.objectKey, msg.fileName);
|
||||||
|
}
|
||||||
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
const filePath = path.join(this.getBoxDir(boxId), msg.fileName);
|
||||||
return fs.existsSync(filePath) ? filePath : null;
|
return fs.existsSync(filePath) ? filePath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Counts
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Get count of unheard messages for a voicebox. */
|
|
||||||
getUnheardCount(boxId: string): number {
|
getUnheardCount(boxId: string): number {
|
||||||
const messages = this.loadMessages(boxId);
|
const messages = this.messagesByBox.get(boxId) || [];
|
||||||
return messages.filter((m) => !m.heard).length;
|
return messages.filter((m) => !m.heard).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get total message count for a voicebox. */
|
|
||||||
getTotalCount(boxId: string): number {
|
getTotalCount(boxId: string): number {
|
||||||
return this.loadMessages(boxId).length;
|
return (this.messagesByBox.get(boxId) || []).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get unheard counts for all voiceboxes. */
|
|
||||||
getAllUnheardCounts(): Record<string, number> {
|
getAllUnheardCounts(): Record<string, number> {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const boxId of this.boxes.keys()) {
|
for (const boxId of this.boxes.keys()) {
|
||||||
@@ -291,55 +210,74 @@ export class VoiceboxManager {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
async saveCustomGreeting(boxId: string, wavData: Buffer): Promise<string> {
|
||||||
// Greeting management
|
const objectKey = await this.storage.putBufferObject(`voicemail/${boxId}/greeting.wav`, wavData);
|
||||||
// -------------------------------------------------------------------------
|
const greetingPath = await this.storage.getObjectAsCachedFile(objectKey, `voicemail-${boxId}-greeting.wav`);
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a custom greeting WAV file for a voicebox.
|
|
||||||
*/
|
|
||||||
saveCustomGreeting(boxId: string, wavData: Buffer): string {
|
|
||||||
const boxDir = this.getBoxDir(boxId);
|
|
||||||
fs.mkdirSync(boxDir, { recursive: true });
|
|
||||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
|
||||||
fs.writeFileSync(greetingPath, wavData);
|
|
||||||
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
this.log(`[voicebox] saved custom greeting for box "${boxId}"`);
|
||||||
return greetingPath;
|
return greetingPath || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async deleteCustomGreeting(boxId: string): Promise<void> {
|
||||||
* Delete the custom greeting for a voicebox (falls back to TTS).
|
await this.storage.removeObject(`voicemail/${boxId}/greeting.wav`);
|
||||||
*/
|
|
||||||
deleteCustomGreeting(boxId: string): void {
|
|
||||||
const boxDir = this.getBoxDir(boxId);
|
|
||||||
const greetingPath = path.join(boxDir, 'greeting.wav');
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(greetingPath)) fs.unlinkSync(greetingPath);
|
|
||||||
} catch { /* best effort */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
private async enforceLimit(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
|
||||||
// Internal: JSON persistence
|
const box = this.boxes.get(boxId);
|
||||||
// -------------------------------------------------------------------------
|
const maxMessages = box?.maxMessages ?? 50;
|
||||||
|
while (messages.length > maxMessages) {
|
||||||
private messagesPath(boxId: string): string {
|
const old = messages.pop()!;
|
||||||
return path.join(this.getBoxDir(boxId), 'messages.json');
|
await this.storage.removeObject(old.objectKey);
|
||||||
|
if (!old.objectKey) {
|
||||||
|
await fsPromises.rm(path.join(this.getBoxDir(boxId), old.fileName), { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadMessages(boxId: string): IVoicemailMessage[] {
|
private async loadMessages(boxId: string): Promise<IVoicemailMessage[]> {
|
||||||
const filePath = this.messagesPath(boxId);
|
const storedMessages = await this.storage.getVoicemailMessages(boxId);
|
||||||
|
if (storedMessages.length) return await this.ensureMessageObjects(boxId, storedMessages);
|
||||||
|
|
||||||
|
const filePath = path.join(this.getBoxDir(boxId), 'messages.json');
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(filePath)) return [];
|
if (!fs.existsSync(filePath)) return [];
|
||||||
const raw = fs.readFileSync(filePath, 'utf8');
|
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||||
return JSON.parse(raw) as IVoicemailMessage[];
|
const legacyMessages = await this.ensureMessageObjects(boxId, JSON.parse(raw) as IVoicemailMessage[]);
|
||||||
|
await this.storage.writeVoicemailMessages(boxId, legacyMessages);
|
||||||
|
return legacyMessages;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeMessages(boxId: string, messages: IVoicemailMessage[]): void {
|
private async ensureMessageObjects(boxId: string, messages: IVoicemailMessage[]): Promise<IVoicemailMessage[]> {
|
||||||
const boxDir = this.getBoxDir(boxId);
|
let changed = false;
|
||||||
fs.mkdirSync(boxDir, { recursive: true });
|
|
||||||
fs.writeFileSync(this.messagesPath(boxId), JSON.stringify(messages, null, 2), 'utf8');
|
for (const msg of messages) {
|
||||||
|
if (!msg.id) {
|
||||||
|
msg.id = crypto.randomUUID();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (msg.objectKey) continue;
|
||||||
|
|
||||||
|
const localPath = path.isAbsolute(msg.fileName) ? msg.fileName : path.join(this.getBoxDir(boxId), msg.fileName);
|
||||||
|
if (!fs.existsSync(localPath)) continue;
|
||||||
|
|
||||||
|
const extension = path.extname(localPath) || '.wav';
|
||||||
|
msg.objectKey = await this.storage.putFileObject(`voicemail/${boxId}/${msg.id}${extension}`, localPath);
|
||||||
|
msg.fileName = path.basename(localPath);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await this.storage.writeVoicemailMessages(boxId, messages);
|
||||||
|
this.log(`[voicebox] migrated legacy messages for box "${boxId}" to smartbucket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeMessages(boxId: string, messages: IVoicemailMessage[]): Promise<void> {
|
||||||
|
this.messagesByBox.set(boxId, [...messages]);
|
||||||
|
await this.storage.writeVoicemailMessages(boxId, messages);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: 'siprouter',
|
name: 'siprouter',
|
||||||
version: '1.26.0',
|
version: '1.27.1',
|
||||||
description: 'undefined'
|
description: 'undefined'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user