Compare commits
23 Commits
f7a11f2f20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c178bb7bd | |||
| 59f0136370 | |||
| e2051f7486 | |||
| 1644999bcb | |||
| 98c80e3a56 | |||
| 3150f78552 | |||
| c516f659cc | |||
| 92c45e9c4d | |||
| 2a05be27bf | |||
| 7979e70705 | |||
| c00cf02676 | |||
| 204203e3b0 | |||
| e9219f2d4a | |||
| 394e3fc920 | |||
| 4a3c14d4af | |||
| 016d8be71d | |||
| bd078c058e | |||
| e7c84885d9 | |||
| dff21f7d5c | |||
| a8f06462e6 | |||
| 90620fbf80 | |||
| ff7ea8623a | |||
| fd88543fb1 |
69
.claude/rules/auth-surfaces.md
Normal file
69
.claude/rules/auth-surfaces.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
description: Casdoor SSO for the browser + owner-minted PATs for MCP/CLI; owner-only on every surface; one resolver; ?token= fallback; dev-owner on loopback
|
||||
paths:
|
||||
- "auth.py"
|
||||
- "api/auth.py"
|
||||
- "api/tokens.py"
|
||||
- "api/deps.py"
|
||||
- "api/websocket.py"
|
||||
- "mcp_server/server.py"
|
||||
- "main.py"
|
||||
---
|
||||
|
||||
# Authentication across the four surfaces
|
||||
|
||||
Auth is **Casdoor SSO for the browser + owner-minted Personal Access Tokens for
|
||||
MCP/CLI**, and the gateway is **owner-only**: exactly one operator (the Casdoor
|
||||
user whose name matches `OWNER_NAME`) may use any surface; every other identity
|
||||
gets 403. There is one resolver behind all of it — don't add a second auth
|
||||
mechanism, a per-surface token, or a bypass.
|
||||
|
||||
- **`resolve_bearer(session, raw_token)` in [auth.py](../../auth.py) is the single
|
||||
resolver.** It turns a bearer string into a `User` (or `None`), classifying it
|
||||
as a PAT (`hs_pat_` prefix → hash lookup) or a Casdoor JWT (RS256, validated
|
||||
against the endpoint's JWKS). `resolve_from_header_or_query` wraps it to accept
|
||||
the token from the `Authorization` header *or* a `?token=` query param. Every
|
||||
surface funnels through these — REST, WebSocket, MCP.
|
||||
|
||||
- **Owner gating is `is_owner(user)` + `get_current_owner`.** `is_owner` matches
|
||||
`user.name == OWNER_NAME` (SSO) or the dev-owner sub (dev mode). REST routers
|
||||
carry `dependencies=[Depends(get_current_owner)]` (aliased `_auth` in
|
||||
[main.py](../../main.py)) → 401 if unauthenticated, 403 if not owner. New
|
||||
protected routers get the same dependency. `/auth/me` is the one exception: it
|
||||
resolves the user *without* the owner gate so a signed-in non-owner sees
|
||||
`is_owner:false` (the dashboard's "not authorized" screen) instead of a bare
|
||||
401.
|
||||
|
||||
- **The `?token=` query-param fallback is intentional and narrow.** Browsers
|
||||
can't set headers on a WebSocket connect or on an `<audio src>`/`<a href>`
|
||||
recording download, so the current token (Casdoor JWT, or a PAT) rides as
|
||||
`?token=`. It's validated by the same resolver as the header. Don't widen it or
|
||||
remove it without accounting for those two consumers.
|
||||
|
||||
- **WebSocket checks ownership itself** ([api/websocket.py](../../api/websocket.py)
|
||||
`_authorize`) rather than via a router dependency, because WS handshakes don't
|
||||
run FastAPI dependencies the same way. It opens a `session_scope`, resolves the
|
||||
bearer (header or `?token=`), and closes with **4401** unless the caller is the
|
||||
owner. Keep the check **before** `websocket.accept()`.
|
||||
|
||||
- **MCP is gated by the ASGI `_owner_only_mcp` wrapper in main.py**, not by
|
||||
FastMCP auth. `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper reads
|
||||
the ASGI scope's `Authorization` header, resolves it via the same
|
||||
`resolve_from_header_or_query` + `is_owner`, and short-circuits non-owner
|
||||
requests with 401/403 (plus an RFC 9728 `WWW-Authenticate` header pointing at
|
||||
`/.well-known/oauth-protected-resource/mcp`). This is why PATs *and* JWTs both
|
||||
work on `/mcp` with one code path. The nested `mcp_http_app.lifespan` still runs
|
||||
— the wrapper is pure middleware around the inner app.
|
||||
|
||||
- **Dev mode is the loopback bypass, not a token.** `CASDOOR_ENABLED=false` makes
|
||||
every request resolve to the dev owner — permitted **only** on a loopback bind.
|
||||
`_check_startup_config` in [main.py](../../main.py) exits if SSO is disabled and
|
||||
`HOST` is off-loopback (the network would see a dev-owner-open gateway), and
|
||||
exits if SSO is enabled but `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/
|
||||
`OWNER_NAME` is missing. **Never weaken these to "warn and continue" — a
|
||||
startup misconfiguration must stop the service.**
|
||||
|
||||
- **Never log a token.** `client_secret` is a `SecretStr`; read it via
|
||||
`.get_secret_value()` only where you hand it to the Casdoor SDK. PAT plaintext
|
||||
is shown once at creation and only its SHA-256 hash is stored — never log the
|
||||
plaintext, a JWT, or a hash.
|
||||
45
.claude/rules/call-safety.md
Normal file
45
.claude/rules/call-safety.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: emergency-number guard, concurrent-call cap, dial-path discipline — the outbound safety invariants
|
||||
paths:
|
||||
- "core/dial_plan.py"
|
||||
- "core/gateway.py"
|
||||
- "mcp_server/server.py"
|
||||
- "api/calls.py"
|
||||
---
|
||||
|
||||
# Outbound-call safety
|
||||
|
||||
This is the safety core. A defect here means an unwanted real phone call, a
|
||||
runaway telephony bill, or — the one that matters most — an AI-initiated
|
||||
emergency call that should have been impossible.
|
||||
|
||||
- **`is_emergency_number()` is the single source of truth for refusal.** It
|
||||
lives in `core/dial_plan.py`, blocks `911`/`9911`/`112` and their E.164
|
||||
mappings (`_BLOCKED` = keys ∪ values), and normalises the input (strips
|
||||
spaces, dashes, dots) before comparing. If you learn of another dialled form
|
||||
that reaches emergency services, add it to `EMERGENCY_NUMBERS` — never work
|
||||
around the guard.
|
||||
|
||||
- **Every outbound path goes through `gateway.make_call`, and the guard is its
|
||||
first check** — before the concurrency cap, before `create_call`, before any
|
||||
SIP action. REST `make_call`, MCP `make_call`, receptionist ring-back, and any
|
||||
transfer to an external number must funnel through it. **Do not introduce a
|
||||
dial path that reaches `sip_engine.make_call` without passing the guard
|
||||
first.** If a new feature needs to place a call, it calls `gateway.make_call`.
|
||||
|
||||
- **The concurrency cap is spend control, not decoration.** `max_concurrent_calls`
|
||||
(default 4) is checked in `make_call` after the emergency guard and before
|
||||
call creation, using `len(call_manager.active_calls)`. Keep the ordering:
|
||||
refuse-emergency, then cap, then create. Don't move the count to after
|
||||
creation (it would off-by-one) and don't remove it.
|
||||
|
||||
- **Refusals raise `ValueError` at the gateway; surfaces translate it.**
|
||||
`make_call` raises `ValueError` for both refusals; the MCP tool converts it to
|
||||
`ToolError`, and the REST layer maps it to a 4xx. Keep refusals as exceptions
|
||||
from the gateway — a refused call must never look like a placed one.
|
||||
|
||||
- **The README's `[!CAUTION]` block is a contract, not decoration.** If you
|
||||
change refusal behaviour, the README caution and this rule must stay true. A
|
||||
system that quietly stops refusing emergency numbers is a serious regression
|
||||
even if every test still passes — add/keep a test that asserts each blocked
|
||||
form is refused.
|
||||
55
.claude/rules/concurrency-threads.md
Normal file
55
.claude/rules/concurrency-threads.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly
|
||||
paths:
|
||||
- "core/sippy_engine.py"
|
||||
- "core/sip_engine.py"
|
||||
- "core/media_pipeline.py"
|
||||
- "core/call_manager.py"
|
||||
- "core/gateway.py"
|
||||
---
|
||||
|
||||
# The thread boundary (the invariant that keeps this app sane)
|
||||
|
||||
The README says "single-process async." That's true at the surface, but under
|
||||
the SIP engine there are **two execution contexts**: the asyncio event loop, and
|
||||
a dedicated **Sippy/PJSUA2 OS thread** running the `ED2` event dispatcher. Almost
|
||||
every hard-to-debug class of bug in a telephony gateway comes from touching one
|
||||
context's state from the other. This app avoids that with exactly one funnel each
|
||||
way. Preserve them.
|
||||
|
||||
- **Who owns what:**
|
||||
- *Sippy thread* owns the Sippy UA objects and the `ED2` dispatcher. State:
|
||||
`_ed_ua_to_leg`, `_ed_leg_to_ua` (the "ED-thread-owned state" maps). Only
|
||||
touch these from a Sippy handler or a `_run_on_sippy` closure.
|
||||
- *asyncio loop* owns everything else: `_legs`, `_bridges`,
|
||||
`_registered_devices`, the `EventBus`, the `CallManager`, the media pipeline
|
||||
wiring.
|
||||
|
||||
- **Cross thread → loop only via `_post_from_ed`.** It calls
|
||||
`asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)`.
|
||||
`_on_engine_event` is **the single funnel** where Sippy-thread events mutate
|
||||
loop-owned state, and it runs on the loop. New Sippy-side events post through
|
||||
here with a new `kind`; they do **not** reach into `_legs`/`EventBus` directly
|
||||
from the handler.
|
||||
|
||||
- **Cross loop → thread only via `_run_on_sippy`.** It uses `ED2.callFromThread(fn)`
|
||||
so `fn` runs where the Sippy objects live. In simulation mode (no `sippy`
|
||||
import) it runs `fn` inline — keep that fallback so tests and stub mode work
|
||||
without the native library. Anything that manipulates a UA object goes through
|
||||
here.
|
||||
|
||||
- **Never:** read/write a Sippy UA object from the loop; never mutate `_legs`,
|
||||
publish an event, or touch the `CallManager` from inside a raw Sippy callback
|
||||
without going through `_post_from_ed`. If you find yourself wanting to, you're
|
||||
about to introduce a data race — add a `kind` to the funnel instead.
|
||||
|
||||
- **Background tasks are tracked, both sides.** The gateway's `spawn()` and the
|
||||
engine's `_spawn()` add tasks to a `_tasks` set with a done-callback that
|
||||
discards them, so shutdown can cancel them and the GC can't drop a live
|
||||
coroutine. Launch per-call/background work through these, not a bare
|
||||
`asyncio.create_task` you forget to hold a reference to.
|
||||
|
||||
- **`MockSIPEngine` has no thread.** Tests run against it; it satisfies the same
|
||||
`SIPEngine` interface synchronously/async-inline. When you extend the real
|
||||
engine's behaviour, extend the mock to match, or tests will pass against a
|
||||
fiction.
|
||||
63
.claude/rules/config-startup.md
Normal file
63
.claude/rules/config-startup.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
description: pydantic-settings nested sub-configs + get_settings() singleton, SecretStr discipline, startup refusals, .env hygiene
|
||||
paths:
|
||||
- "config.py"
|
||||
- "main.py"
|
||||
- ".env*"
|
||||
---
|
||||
|
||||
# Config & startup
|
||||
|
||||
Config is `Settings` in [config.py](../../config.py): a root `BaseSettings` with
|
||||
**nested sub-config models**, each carrying its own `env_prefix`. Read it through
|
||||
the `get_settings()` cached singleton.
|
||||
|
||||
- **`get_settings()` is the only accessor.** It memoises a single `Settings()`.
|
||||
Don't construct `Settings()` elsewhere, and don't reach for `os.environ.get`
|
||||
for Hold Slayer config — the whole point of the sub-config layout is that every
|
||||
knob has one typed home.
|
||||
|
||||
- **Sub-configs own their prefixes.** `SIP_TRUNK_*` → `SIPTrunkSettings`, `LLM_*`
|
||||
→ `LLMSettings`, `TTS_*` → `TTSSettings`, `RECEPTIONIST_*` →
|
||||
`ReceptionistSettings`, `CASDOOR_*` → `CasdoorSettings`, `CLASSIFIER_*`,
|
||||
`SPEACHES_*`, `GATEWAY_SIP_*`. Root vars (`DATABASE_URL`, `HOST`, `PORT`,
|
||||
`MAX_CONCURRENT_CALLS`, `USE_MOCK_SIP`, `NOTIFY_SMS_NUMBER`, `DEBUG`,
|
||||
`LOG_LEVEL`, and the auth cross-cutters `OWNER_NAME` + `PUBLIC_BASE_URL`) are
|
||||
unprefixed on the root model. A new knob goes in the sub-config it belongs to;
|
||||
a genuinely new subsystem gets its own sub-config + prefix, not flat root vars.
|
||||
- `OWNER_NAME` (the owner's Casdoor username) and `PUBLIC_BASE_URL` (OAuth
|
||||
discovery base) live on the root, not under `CASDOOR_`, because they cross-cut
|
||||
every surface — like `DATABASE_URL`. The Casdoor *connection* knobs
|
||||
(`enabled`/`endpoint`/`client_id`/`client_secret`/`org_name`/`app_name`) live
|
||||
under `CASDOOR_`.
|
||||
- `HoldSlayerSettings` uses `env_prefix_allow_empty=True` with explicit
|
||||
`validation_alias`es (`DEFAULT_TRANSFER_DEVICE`, `MAX_HOLD_TIME`,
|
||||
`HOLD_CHECK_INTERVAL`) — i.e. those three are read *unprefixed* by design.
|
||||
Follow that pattern only if you deliberately want an unprefixed name.
|
||||
|
||||
- **Secrets are `SecretStr`.** `casdoor.client_secret`, `sip_trunk.password`,
|
||||
`llm.api_key`, `tts.api_key`. Keep new secrets as `SecretStr`; call
|
||||
`.get_secret_value()` only at the point of use (outbound header, SDK
|
||||
construction) — never store the bare string, never log it.
|
||||
|
||||
- **Startup refuses bad configs loudly, then exits.** In [main.py](../../main.py):
|
||||
- `_check_startup_config` exits if `DATABASE_URL` is unset; if `CASDOOR_ENABLED`
|
||||
is true but any of `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/`OWNER_NAME`
|
||||
is missing; or if `CASDOOR_ENABLED` is false while `HOST` is off-loopback
|
||||
(dev-owner mode would be open to the network). See the
|
||||
[auth-surfaces rule](auth-surfaces.md).
|
||||
- `_handle_db_error` turns raw asyncpg failures into human-readable guidance
|
||||
(wrong password, missing DB, connection refused, bad hostname) and `sys.exit(1)`.
|
||||
- SIP engine build failure and mock-vs-real are surfaced, not swallowed.
|
||||
**Keep the pattern: a misconfiguration stops the service with a message a human
|
||||
can act on — never a silent degrade or a stack trace with no guidance.**
|
||||
|
||||
- **`use_mock_sip` is opt-in for a reason.** An unconfigured trunk without
|
||||
`USE_MOCK_SIP=true` must fail startup rather than boot a gateway that silently
|
||||
can't place real calls. Don't default it to `True`.
|
||||
|
||||
- **`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||
it, never treat the checked-out `.env` as a template. Only `.env.example`
|
||||
(placeholders) is committed, and it must stay in sync with the models here and
|
||||
the README config table. Every new var lands in all three: model,
|
||||
`.env.example`, README.
|
||||
56
.claude/rules/lifespan-wiring.md
Normal file
56
.claude/rules/lifespan-wiring.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: composition-root lifespan, nested MCP http_app lifespan, app.state wiring, route/mount ordering, honest /health
|
||||
paths:
|
||||
- "main.py"
|
||||
- "api/deps.py"
|
||||
- "core/gateway.py"
|
||||
---
|
||||
|
||||
# Lifespan, composition root & route ordering
|
||||
|
||||
[main.py](../../main.py)'s `lifespan` is the **composition root**: it builds the
|
||||
gateway and every service, wires them by constructor/registration, and hangs the
|
||||
long-lived ones on `app.state`. This is the one place dependencies are
|
||||
assembled.
|
||||
|
||||
- **Build services here, inject them — nothing self-constructs its deps.** The
|
||||
gateway, classifier, transcription, TTS, routing, recording, receptionist, and
|
||||
notification services are all constructed in the lifespan and wired together
|
||||
(e.g. the receptionist receives tts/transcription/recording/routing;
|
||||
`launch_hold_slayer` is registered as the `HOLD_SLAYER` mode handler). A new
|
||||
service is built here and passed in, not instantiated deep in a call path.
|
||||
|
||||
- **The MCP sub-app's lifespan MUST be nested.** The lifespan opens
|
||||
`async with mcp_http_app.lifespan(app):` around all startup. FastMCP's
|
||||
streamable-HTTP session manager is initialised inside *its* lifespan; mount the
|
||||
app without entering that context and every `/mcp` request 500s
|
||||
("session manager not initialised" / "Task group is not initialized"). **Keep
|
||||
the nesting.** This is the same landmine across the estate's mounted-MCP
|
||||
services.
|
||||
|
||||
- **`app.state` is the handoff to request handlers.** The lifespan sets
|
||||
`app.state.gateway`, `.routing_service`, `.transcription_service`,
|
||||
`.notification_service`, `.recording_service`. Dependencies in
|
||||
[api/deps.py](../../api/deps.py) read these and raise `503` if not yet set. MCP
|
||||
tools reach the gateway via the lazy `_get_gateway_instance` resolver. Don't
|
||||
reach for module-level globals; go through `app.state`.
|
||||
|
||||
- **Route/mount registration order is load-bearing:**
|
||||
1. `call_history` router registers **before** `calls` — both live under
|
||||
`/api/v1/calls`, and `calls`' `GET /{call_id}` would otherwise swallow the
|
||||
literal path `history`. Keep history first.
|
||||
2. The `"/mcp"` mount and all API/WS/health routes register **before** the
|
||||
`"/"` static dashboard mount — a root mount matches every path, so anything
|
||||
after it is unreachable. The dashboard mount stays last, and only when
|
||||
`dashboard/build/` exists.
|
||||
|
||||
- **`/health` is honest by construction.** `healthy` = real (non-`MockSIPEngine`)
|
||||
engine **and** registered trunk **and** reachable DB; it also reports STT/TTS
|
||||
last-known reachability via `_availability`. Don't relax any of these to make a
|
||||
probe pass — a degraded gateway must read as `degraded`, with the reason
|
||||
visible.
|
||||
|
||||
- **Shutdown reverses startup.** Stop notifications, stop the gateway (which
|
||||
cancels tracked tasks, ends active calls, stops SIP then media), close the DB.
|
||||
New long-lived resources get a matching teardown here — don't leak a task or a
|
||||
client across restarts.
|
||||
56
.claude/rules/mcp-tools.md
Normal file
56
.claude/rules/mcp-tools.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: MCP tools return formatted strings; ToolError-vs-return-string convention; lazy gateway resolution; resources are JSON
|
||||
paths:
|
||||
- "mcp_server/server.py"
|
||||
---
|
||||
|
||||
# MCP tools & resources
|
||||
|
||||
The MCP server ([mcp_server/server.py](../../mcp_server/server.py)) is the
|
||||
AI-assistant control surface. It's built by `create_mcp_server(get_gateway)` and
|
||||
mounted at `/mcp/` before the lifespan runs, so everything is resolved lazily.
|
||||
|
||||
- **Auth is the ASGI `_owner_only_mcp` wrapper in [main.py](../../main.py), not
|
||||
FastMCP.** `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper resolves
|
||||
the `Authorization` bearer (Casdoor JWT or owner-minted PAT) via the shared
|
||||
`resolve_from_header_or_query` + `is_owner` and returns 401/403 before the inner
|
||||
app runs. Don't reintroduce a FastMCP verifier here — one resolver gates all
|
||||
four surfaces (see the [auth-surfaces rule](auth-surfaces.md)). MCP clients use
|
||||
a PAT (`hs_pat_…`) minted from the dashboard's Tokens modal.
|
||||
|
||||
- **Tools return plain formatted strings; resources return JSON strings.** Tools
|
||||
produce human-readable text an assistant reads back to a user (`"Call abc123
|
||||
initiated. …"`). Resources (`gateway://status`, `gateway://call-flows`,
|
||||
`gateway://active-calls`) return `json.dumps(...)`. Don't blur these — a tool
|
||||
that returns raw JSON, or a resource that returns prose, breaks the contract.
|
||||
|
||||
- **Error convention — match the two existing patterns:**
|
||||
- **Raise `ToolError`** when the request is invalid or unsafe: emergency
|
||||
number, bad mode, concurrency cap hit, or "gateway still starting"
|
||||
(`require_gateway`). The assistant should treat these as errors.
|
||||
- **Return an error string** for a lookup that simply found nothing or hit a
|
||||
recoverable snag: `"Call {id} not found."`, `"No stored call flow for …"`,
|
||||
`"Error looking up …: {e}"`. The assistant reads these as content.
|
||||
- Rule of thumb: *"you asked for something invalid/unsafe" → raise; "I looked,
|
||||
here's the (maybe empty/failed) answer" → return.*
|
||||
|
||||
- **`require_gateway()` gates every tool that needs the live gateway.** It raises
|
||||
`ToolError("Gateway is still starting up …")` when `get_gateway()` returns
|
||||
`None`. This is why the MCP app can mount before the lifespan builds the
|
||||
gateway. Call it at the top of any tool that touches the gateway; never assume
|
||||
the gateway exists.
|
||||
|
||||
- **`make_call` is the one tool that dials.** It maps the string `mode` to
|
||||
`CallMode`, defaults unknown modes to `DIRECT`, and lets `gateway.make_call`'s
|
||||
refusals (`ValueError`) surface as `ToolError`. The emergency guard and
|
||||
concurrency cap live in the gateway, **not** here — don't reimplement or skip
|
||||
them at the tool layer (see the call-safety rule).
|
||||
|
||||
- **DB-backed tools use `session_scope()`** and read from `call_persistence`.
|
||||
Completed-call history, summaries, recordings, and stored flows come from the
|
||||
database, not from `active_calls` (those are live only). Keep the
|
||||
`async with session_scope() as session:` pattern; don't open ad-hoc sessions.
|
||||
|
||||
- **Keep the tool count and README table in sync.** There are 15 tools + 3
|
||||
resources. If you add/remove one, update the README's MCP table and the
|
||||
`docs/mcp-server.md` reference — a drifting tool list is a documented lie.
|
||||
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
||||
# Secrets — never bake into the image (injected at runtime via compose env).
|
||||
.env
|
||||
|
||||
# The dashboard is rebuilt in the node stage and COPY'd in fresh; keep the
|
||||
# gitignored working-tree copies out of the build context.
|
||||
dashboard/build/
|
||||
dashboard/node_modules/
|
||||
dashboard/.svelte-kit/
|
||||
|
||||
# Python build/cache cruft.
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local runtime artifacts.
|
||||
recordings/
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# VCS / editor / OS.
|
||||
.git/
|
||||
.gitea/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Not needed at runtime.
|
||||
tests/
|
||||
docs/
|
||||
34
.env.compose.example
Normal file
34
.env.compose.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Compose environment for `docker compose up` — copy to `.env` and fill in.
|
||||
#
|
||||
# cp .env.compose.example .env
|
||||
#
|
||||
# These values are substituted into docker-compose.yaml (${VAR}); they are NOT
|
||||
# baked into the image (.env is gitignored and in .dockerignore). Distinct from
|
||||
# the app's own .env used for a bare `uvicorn` run.
|
||||
|
||||
# --- Database (the bundled postgres:17 service) ---
|
||||
HS_DB_USER=holdslayer
|
||||
HS_DB_PASSWORD=change-me
|
||||
HS_DB_NAME=holdslayer
|
||||
|
||||
# --- Published port on the host ---
|
||||
HS_APP_PORT=21081
|
||||
|
||||
# --- SIP: mock by default (dev/local). Set false + fill SIP_TRUNK_* for a real trunk. ---
|
||||
USE_MOCK_SIP=true
|
||||
|
||||
# --- Auth: Casdoor SSO (owner-only) ---
|
||||
# Required: this stack publishes the port on 0.0.0.0, so dev-owner mode
|
||||
# (CASDOOR_ENABLED=false) is refused at startup — it's loopback-only. Register a
|
||||
# `hold-slayer` app in Casdoor (org heluca, redirect URI <PUBLIC_BASE_URL>/auth/callback).
|
||||
CASDOOR_ENABLED=true
|
||||
CASDOOR_ENDPOINT=https://id.ouranos.helu.ca
|
||||
CASDOOR_CLIENT_ID=
|
||||
CASDOOR_CLIENT_SECRET=
|
||||
CASDOOR_ORG_NAME=heluca
|
||||
CASDOOR_APP_NAME=hold-slayer
|
||||
# The owner's Casdoor username — the only identity allowed on any surface.
|
||||
OWNER_NAME=
|
||||
# Public base URL the browser reaches (drives OAuth discovery + the Casdoor
|
||||
# redirect_uri). E.g. http://localhost:21081 for a local run.
|
||||
PUBLIC_BASE_URL=http://localhost:21081
|
||||
50
.env.example
50
.env.example
@@ -1,15 +1,32 @@
|
||||
# ============================================================
|
||||
# Hold Slayer Gateway Configuration
|
||||
# ============================================================
|
||||
# Copy to .env and fill in your values
|
||||
# Copy to .env and fill in your values. This is the app's own .env for a bare
|
||||
# `uvicorn main:app` run — see .env.compose.example for the Docker stack.
|
||||
|
||||
# --- Database (required) ---
|
||||
DATABASE_URL=postgresql+asyncpg://holdslayer:<db-password>@localhost:5432/holdslayer
|
||||
|
||||
# --- API auth (required unless HOST=127.0.0.1) ---
|
||||
# One static bearer token shared by REST, WebSocket (?token=...), and MCP.
|
||||
# Generate with: openssl rand -hex 32
|
||||
API_TOKEN=
|
||||
# --- Auth: Casdoor SSO + owner-minted PATs (owner-only) ---
|
||||
# The browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use
|
||||
# owner-minted PATs (hs_pat_…). Both resolve to a User gated to OWNER_NAME.
|
||||
#
|
||||
# Two supported configurations, enforced at startup:
|
||||
# 1. CASDOOR_ENABLED=true + endpoint/client_id/client_secret/OWNER_NAME set
|
||||
# 2. CASDOOR_ENABLED=false + HOST=127.0.0.1 (dev-owner mode, loopback ONLY)
|
||||
# SSO-off with an off-loopback HOST is refused — it would resolve every request
|
||||
# to the dev owner, open to the network.
|
||||
CASDOOR_ENABLED=true
|
||||
CASDOOR_ENDPOINT=https://id.example.com
|
||||
CASDOOR_CLIENT_ID=
|
||||
CASDOOR_CLIENT_SECRET=
|
||||
CASDOOR_ORG_NAME=
|
||||
CASDOOR_APP_NAME=hold-slayer
|
||||
# The owner's Casdoor username — the only identity allowed on any surface.
|
||||
OWNER_NAME=
|
||||
# Public base URL the browser reaches (drives OAuth discovery + the Casdoor
|
||||
# redirect_uri). Blank derives it from the request headers.
|
||||
PUBLIC_BASE_URL=
|
||||
|
||||
# --- SIP Trunk ---
|
||||
# The mock engine must be requested explicitly; an unconfigured trunk
|
||||
@@ -26,13 +43,23 @@ SIP_TRUNK_DID=+15551234567
|
||||
# --- Gateway SIP Listener ---
|
||||
# Port for devices (softphones/hardphones) to register to
|
||||
GATEWAY_SIP_HOST=0.0.0.0
|
||||
GATEWAY_SIP_PORT=5080
|
||||
GATEWAY_SIP_DOMAIN=gateway.helu.ca
|
||||
GATEWAY_SIP_PORT=5060
|
||||
GATEWAY_SIP_DOMAIN=gateway.local
|
||||
|
||||
# --- Speaches STT ---
|
||||
SPEACHES_URL=http://localhost:22070
|
||||
SPEACHES_MODEL=whisper-large-v3
|
||||
|
||||
# --- Rhema TTS (OpenAI-compatible /v1/audio/speech) ---
|
||||
# Must NOT point at this app's own port (default PORT=8000) — set a real
|
||||
# endpoint or TTS requests loop back into the gateway.
|
||||
TTS_BASE_URL=http://localhost:8001
|
||||
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
TTS_VOICE=af_heart
|
||||
TTS_API_KEY=
|
||||
TTS_TIMEOUT=30.0
|
||||
TTS_SAMPLE_RATE=16000
|
||||
|
||||
# --- Audio Classifier ---
|
||||
# Thresholds for hold music detection (0.0 - 1.0)
|
||||
CLASSIFIER_MUSIC_THRESHOLD=0.7
|
||||
@@ -50,6 +77,12 @@ LLM_TIMEOUT=30.0
|
||||
LLM_MAX_TOKENS=1024
|
||||
LLM_TEMPERATURE=0.3
|
||||
|
||||
# --- AI Receptionist (inbound calls) ---
|
||||
RECEPTIONIST_ENABLED=true
|
||||
RECEPTIONIST_LISTEN_TIMEOUT_S=15.0
|
||||
RECEPTIONIST_END_OF_UTTERANCE_SILENCE_S=1.2
|
||||
RECEPTIONIST_MESSAGE_MAX_SECONDS=90
|
||||
|
||||
# --- Hold Slayer ---
|
||||
# Default device to transfer to when human detected
|
||||
DEFAULT_TRANSFER_DEVICE=sip_phone
|
||||
@@ -67,6 +100,9 @@ HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
LOG_LEVEL=info
|
||||
# Log rendering: "text" (human-readable) or "json" (one object per line, for
|
||||
# Loki/Alloy). The Docker image sets json; text is the default for local dev.
|
||||
LOG_FORMAT=text
|
||||
|
||||
# --- Safety ---
|
||||
# Max simultaneous calls the gateway will place (REST + MCP)
|
||||
|
||||
105
.gitea/workflows/cve-scan-docker-build.yml
Normal file
105
.gitea/workflows/cve-scan-docker-build.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: CVE Scan & Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A pushed version tag (e.g. 0.2.0 or v0.2.0) cuts a release: the build
|
||||
# below stamps the image with the matching semver tag (e.g. :0.2.0) so the
|
||||
# deploy can pin an immutable release instead of a moving :latest/:sha.
|
||||
tags: ['*']
|
||||
|
||||
env:
|
||||
REGISTRY: git.helu.ca
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin
|
||||
trivy --version
|
||||
|
||||
- name: Install pip-tools and resolve Python dependencies
|
||||
run: |
|
||||
python3 -m venv /tmp/scanenv
|
||||
/tmp/scanenv/bin/pip install --quiet pip-tools
|
||||
/tmp/scanenv/bin/pip-compile pyproject.toml \
|
||||
-o /tmp/requirements.txt \
|
||||
--no-header --quiet --allow-unsafe --strip-extras \
|
||||
--resolver=backtracking || {
|
||||
/tmp/scanenv/bin/pip install --quiet . && \
|
||||
/tmp/scanenv/bin/pip freeze > /tmp/requirements.txt
|
||||
}
|
||||
cat /tmp/requirements.txt
|
||||
|
||||
- name: Scan Python dependencies for CVEs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
trivy fs --scanners vuln --severity HIGH,CRITICAL --format table /tmp/requirements.txt
|
||||
|
||||
- name: Audit dashboard npm dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd dashboard
|
||||
npm ci --ignore-scripts
|
||||
npm audit --audit-level=high || true
|
||||
|
||||
- name: Scan repository for secrets
|
||||
continue-on-error: true
|
||||
run: |
|
||||
trivy fs --scanners secret --severity HIGH,CRITICAL --format table .
|
||||
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: security-scan
|
||||
if: always()
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
# Single image: Hold Slayer is one FastAPI process exposing REST/WS/MCP
|
||||
# and serving its own built SvelteKit dashboard at "/" — no separate
|
||||
# web/nginx image. The Dockerfile's node stage builds the dashboard.
|
||||
- name: Extract metadata for image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan image for CVEs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin
|
||||
IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n1)
|
||||
echo "Scanning image: ${IMAGE_TAG}"
|
||||
trivy image --severity HIGH,CRITICAL --format table "${IMAGE_TAG}"
|
||||
249
CLAUDE.md
Normal file
249
CLAUDE.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# CLAUDE.md — Hold Slayer 🔥🐾
|
||||
|
||||
Red Panda Standards for the Hold Slayer telephony gateway. This is an
|
||||
**AI-powered PSTN gateway**: it places real phone calls, navigates IVR menus,
|
||||
waits on hold, and rings a human's desk phone when a live person answers. It
|
||||
also answers inbound calls with an AI receptionist and smart routing.
|
||||
|
||||
**It dials real numbers on a real SIP trunk and may incur telephony charges,
|
||||
and an AI agent drives it.** Treat every change through that lens: a bug here
|
||||
isn't a 500, it's an unwanted phone call — or a *refused emergency call that
|
||||
should never have been attempted in the first place*. Read this file before
|
||||
touching call placement, auth, the SIP thread boundary, or the emergency guard.
|
||||
|
||||
Lead with a paw print in this repo.
|
||||
|
||||
---
|
||||
|
||||
## The shape of this thing (read once, then it's obvious)
|
||||
|
||||
One FastAPI process exposes four surfaces over the same port: **REST** (`/api/v1/*`),
|
||||
**WebSocket** (`/ws/*`), an **MCP server** (streamable HTTP at `/mcp/`), and the
|
||||
built **SvelteKit dashboard** at `/`. All four are **owner-only**, gated by Casdoor
|
||||
SSO (browser JWT) or an owner-minted PAT through one shared resolver.
|
||||
|
||||
Under them sits a **composition root** in [main.py](main.py)'s `lifespan`: the
|
||||
gateway and every service are constructed and wired there, then hung on
|
||||
`app.state`. Nothing constructs its own dependencies — if you need a new
|
||||
service, build it in the lifespan and pass it in.
|
||||
|
||||
Below the services is the part that makes this app unusual: a **`SippyB2BUAEngine`
|
||||
that runs the SIP/PJSUA2 event loop on its own OS thread**, not the asyncio loop.
|
||||
This is the highest-leverage invariant in the codebase. See
|
||||
[the concurrency rule](.claude/rules/concurrency-threads.md) — the README's
|
||||
"single-process async" line is a simplification; there are two execution
|
||||
contexts and exactly one funnel between them.
|
||||
|
||||
```
|
||||
REST / WS / MCP / Dashboard (asyncio, FastAPI)
|
||||
│
|
||||
composition root (lifespan) → services → gateway
|
||||
│
|
||||
SippyB2BUAEngine ──┬── asyncio side (legs, bridges, event bus)
|
||||
└── Sippy thread (UA objects, ED2 dispatcher)
|
||||
↑ crossed only via _post_from_ed / _run_on_sippy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Red Panda Approval™ — what it means here
|
||||
|
||||
1. **Fresh Environment Test** — `cp .env.example .env`, set `DATABASE_URL` and
|
||||
either `CASDOOR_*` + `OWNER_NAME` (SSO) or `CASDOOR_ENABLED=false` with
|
||||
`HOST=127.0.0.1` (dev-owner, loopback only), `pip install -e ".[dev]"`,
|
||||
`uvicorn main:app`. It must boot to a clear log banner or **exit with a
|
||||
human-readable reason** (see `_check_startup_config` / `_handle_db_error` in
|
||||
[main.py](main.py)). It must *never* boot into a state where it silently can't
|
||||
place calls — that's why `use_mock_sip` is opt-in and an unconfigured trunk
|
||||
fails startup.
|
||||
2. **Elegant Simplicity** — the composition root wires; services do one job;
|
||||
the thread boundary has exactly one funnel each way. Don't add a second path
|
||||
across the thread line, a second auth mechanism, or a service that reaches
|
||||
into another service's internals.
|
||||
3. **Observable & Debuggable** — `/health` is *honest*: it reports `degraded`
|
||||
with the reason (mock engine, unregistered trunk, DB down, STT/TTS
|
||||
unreachable) rather than a green light that lies. Keep it honest. Events flow
|
||||
through the typed `EventBus`; new call-lifecycle facts become typed events,
|
||||
not `print`s.
|
||||
4. **Consistent Patterns** — config via pydantic-settings sub-configs; MCP tools
|
||||
return formatted strings; REST returns Pydantic models; DB access via
|
||||
`session_scope()`. Match the neighbours.
|
||||
5. **Actually Works** — `pytest tests/ -v` (189 tests across 19 files). A change
|
||||
to call placement, routing, the classifier, or auth needs a test. The suite
|
||||
runs against SQLite (`aiosqlite`) and the mock SIP engine — no trunk, no
|
||||
Postgres required to test.
|
||||
|
||||
---
|
||||
|
||||
## Invariants that must not be weakened
|
||||
|
||||
These are safety- and correctness-critical. Loosening one is never a casual
|
||||
refactor — it needs an explicit rationale and, where it deviates from a stated
|
||||
standard, a note (there is no `docs/EXCEPTIONS.md` yet; if you start
|
||||
accumulating documented deviations, create one rather than letting them go
|
||||
unrecorded).
|
||||
|
||||
- **The emergency-number guard is absolute.** `is_emergency_number()` in
|
||||
[core/dial_plan.py](core/dial_plan.py) blocks `911`/`9911`/`112` (and their
|
||||
E.164 forms, whitespace/dashes stripped) at `gateway.make_call`, **before**
|
||||
the concurrency check and before any SIP action. Every dialling path — REST
|
||||
`make_call`, MCP `make_call`, receptionist call-back, transfer to an external
|
||||
number — must pass through a guard that refuses these. An AI agent must never
|
||||
place an emergency call, and API calls carry no E911 location. **Do not add a
|
||||
dial path that bypasses `make_call`'s guard.** If you add a new outbound path,
|
||||
it calls the guard first. See [the safety rule](.claude/rules/call-safety.md).
|
||||
|
||||
- **The concurrency cap is real spend control.** `max_concurrent_calls` (default
|
||||
4) caps simultaneous outbound calls in `gateway.make_call`. It's checked after
|
||||
the emergency guard, before creating the call. Don't remove it or move it
|
||||
below call creation.
|
||||
|
||||
- **The Sippy thread boundary is crossed only through the two funnels.** Sippy
|
||||
UA objects and the `ED2` dispatcher live on the Sippy thread; legs, bridges,
|
||||
and the event bus live on the asyncio loop. Cross **thread→loop** only via
|
||||
`_post_from_ed` (→ `run_coroutine_threadsafe` → the single `_on_engine_event`
|
||||
funnel) and **loop→thread** only via `_run_on_sippy` (→ `ED2.callFromThread`).
|
||||
Never touch a Sippy UA object from the loop; never mutate loop-owned state from
|
||||
a Sippy handler. This is the whole reason the app is thread-safe.
|
||||
|
||||
- **Auth is Casdoor SSO + owner-minted PATs, owner-only, one resolver.** The
|
||||
browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use owner-minted
|
||||
PATs (`hs_pat_…`). `resolve_bearer` in [auth.py](auth.py) turns either into a
|
||||
`User`, and `is_owner`/`get_current_owner` gate every surface to the single
|
||||
operator (`OWNER_NAME`) — non-owners get 403. Dev mode (`CASDOOR_ENABLED=false`)
|
||||
resolves the dev owner and is **only** permitted on a loopback bind;
|
||||
`_check_startup_config` refuses SSO-off-loopback and SSO-on-with-missing-config.
|
||||
Keep those refusals — a silent dev-owner-open-on-0.0.0.0 is the failure mode
|
||||
they exist to prevent. See [the auth rule](.claude/rules/auth-surfaces.md).
|
||||
|
||||
- **`/health` tells the truth.** `healthy` requires a real (non-mock) engine, a
|
||||
registered trunk, and a reachable DB. Don't relax it to make a probe go green;
|
||||
a gateway that can't place calls is not healthy, and the dashboard/operator
|
||||
needs to see that.
|
||||
|
||||
- **The database is the source of truth for history; live state is in memory.**
|
||||
Active calls live in the `CallManager`; completed calls + transcripts are
|
||||
persisted on hangup via `call_persistence`. MCP/REST history and summaries read
|
||||
from the DB through `session_scope()`. Don't confuse the two — a call that
|
||||
ended is gone from `active_calls` and only exists in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Config — pydantic-settings, nested, singleton
|
||||
|
||||
Config is `Settings` in [config.py](config.py): a root `BaseSettings` with
|
||||
**nested sub-config models** (`SIPTrunkSettings`, `LLMSettings`, `TTSSettings`,
|
||||
`ReceptionistSettings`, …), each with its own `env_prefix`
|
||||
(`SIP_TRUNK_`, `LLM_`, `TTS_`, `RECEPTIONIST_`, …). Read config through
|
||||
`get_settings()` (a cached singleton) — don't call `os.environ.get` for
|
||||
Hold Slayer settings, and don't construct `Settings()` yourself outside that
|
||||
accessor.
|
||||
|
||||
> **Estate note (not a defect):** unlike the `KERNOS_`/`ARGOS_`/`NIKE_`-style
|
||||
> single-prefix services, Hold Slayer has **no one umbrella prefix** — root vars
|
||||
> (`DATABASE_URL`, `HOST`, `MAX_CONCURRENT_CALLS`, `OWNER_NAME`) are unprefixed
|
||||
> and each subsystem carries its own (`CASDOOR_` for the SSO connection). That's a
|
||||
> deliberate readability choice for
|
||||
> a config with this many subsystems; keep new vars consistent with the
|
||||
> sub-config they belong to, and if you add a new subsystem, give it its own
|
||||
> sub-config + prefix rather than piling flat vars onto the root.
|
||||
|
||||
Secrets (`casdoor.client_secret`, `sip_trunk.password`, `llm.api_key`,
|
||||
`tts.api_key`) are
|
||||
`SecretStr` — keep them so, and read via `.get_secret_value()` only at the point
|
||||
of use. Every new var also needs a row in [.env.example](.env.example) and the
|
||||
README's config table. See [the config rule](.claude/rules/config-startup.md).
|
||||
|
||||
**`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||
it, never treat the local `.env` as a template. Only `.env.example`
|
||||
(placeholders) is committed.
|
||||
|
||||
---
|
||||
|
||||
## MCP tools — formatted strings, and the error convention to know
|
||||
|
||||
MCP tools ([mcp_server/server.py](mcp_server/server.py)) return **plain
|
||||
human-readable strings** (not JSON, not Pydantic models — that's the REST
|
||||
layer's job). Resources (`gateway://status`, `gateway://call-flows`,
|
||||
`gateway://active-calls`) return JSON strings.
|
||||
|
||||
There's a **deliberate but uneven error convention** worth understanding before
|
||||
you add a tool:
|
||||
|
||||
- `make_call` raises `ToolError` on a bad request (emergency number, bad mode,
|
||||
cap hit) — a hard failure the assistant should treat as an error.
|
||||
- Most read/lookup tools **return** an error *string* (`"Call … not found."`,
|
||||
`"Error looking up …: {e}"`) instead of raising — a soft "here's what
|
||||
happened" the assistant reads as content.
|
||||
|
||||
When you add a tool: raise `ToolError` for "you asked for something invalid or
|
||||
unsafe"; return a plain string for "I looked and here's the (possibly empty)
|
||||
answer." The gateway is resolved lazily per call via `require_gateway()` (it
|
||||
raises `ToolError` while the gateway is still starting) — keep that, because the
|
||||
MCP app is mounted before the lifespan builds the gateway. See
|
||||
[the MCP rule](.claude/rules/mcp-tools.md).
|
||||
|
||||
---
|
||||
|
||||
## What's real vs. stubbed (don't mistake one for the other)
|
||||
|
||||
- **PJSUA2 media pipeline runs in stub mode** unless the `pjsua2` bindings are
|
||||
built from pjproject (not pip-installable). Signaling works; audio
|
||||
routing/recording is a no-op stub without them. Code that assumes real audio
|
||||
must degrade honestly, and `/health`/engine-mode must reflect stub vs real.
|
||||
- **The mock SIP engine (`MockSIPEngine`) must be asked for** (`USE_MOCK_SIP=true`).
|
||||
It exists for tests and local dev. Production must not silently run on it —
|
||||
`/health` reports `engine: mock` and refuses `healthy`.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps (flag, don't fold — these are not your task unless asked)
|
||||
|
||||
Surfaced honestly so you don't rediscover them as surprises. The README's
|
||||
Phase 4/5/6 checklists track most of these; **don't fold fixes into unrelated
|
||||
work** — raise them.
|
||||
|
||||
- ~~**No structured JSON logging.**~~ Done: `LOG_FORMAT=json` in
|
||||
[core/logging_config.py](core/logging_config.py), applied at import and again
|
||||
in `lifespan` because uvicorn installs its own handlers (`propagate=False`)
|
||||
after importing the app. The access log is included, with `status_code` as a
|
||||
number so Loki can range-filter it. Text remains the default; the Docker image
|
||||
sets json.
|
||||
- **No `/metrics` endpoint and no Prometheus.** Unlike the metrics-bearing
|
||||
estate services, there's no exposition endpoint here yet.
|
||||
- **No health-probe access-log filter.** Every `/health` poll hits the access
|
||||
log. Other estate services suppress probe noise; this one doesn't.
|
||||
- ~~**No rate limiting** on API endpoints.~~ Done, but *narrowly*: only the
|
||||
unauthenticated `/auth/*` routes are limited
|
||||
([core/rate_limit.py](core/rate_limit.py)), because every other surface is
|
||||
already owner-gated and a limit there would throttle the sole operator. The
|
||||
limiter keys on the **socket peer, not `X-Forwarded-For`** — behind the
|
||||
estate's reverse proxy that means per-proxy, not per-caller. Per-caller limits
|
||||
need an explicit trusted-proxy config; don't silently start trusting the
|
||||
header.
|
||||
- **Docker: single-image `Dockerfile` + `docker-compose.yaml`** (app +
|
||||
`postgres:17`) ship in-repo; the Gitea CI (`cve-scan-docker-build.yml`) builds
|
||||
the image on push to `main`. The compose stack requires SSO enabled
|
||||
(published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo.
|
||||
- **A committed `.DS_Store` is *not* present** (good), but do check `git status`
|
||||
stays clean of OS cruft; `.gitignore` already lists it.
|
||||
|
||||
---
|
||||
|
||||
## Working here
|
||||
|
||||
- **Run:** `uvicorn main:app --host 0.0.0.0 --port 8000` (or `python main.py`).
|
||||
The CLI `--port` wins over `settings.port` in the startup banner logic — a real
|
||||
gotcha the banner code already accounts for; don't "fix" it into disagreement.
|
||||
- **Test:** `pytest tests/ -v`. Fast, no external services (SQLite + mock SIP).
|
||||
- **Lint:** `ruff check .` (line length 100, `py312` target).
|
||||
- **Dashboard:** `cd dashboard && npm install && npm run build` → served at `/`
|
||||
when `dashboard/build/` exists. The API/WS/health/MCP routes are registered
|
||||
**before** the `"/"` static mount because a root mount matches everything —
|
||||
keep that ordering.
|
||||
- **DB migrations:** Alembic (`db/migrations/`), upgrade-on-boot via `init_db`.
|
||||
Schema changes are migrations, never ad-hoc `CREATE`.
|
||||
|
||||
Path-scoped rules in [.claude/rules/](.claude/rules/) load automatically when you
|
||||
edit the files they cover. They carry the fine-grained "don't break this"
|
||||
detail; this file is the map.
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# Hold Slayer — single image: FastAPI process that also serves the built
|
||||
# SvelteKit dashboard at "/". One container, four surfaces (REST/WS/MCP/dash).
|
||||
#
|
||||
# pjsua2 is deliberately NOT built here — it is not pip-installable (compiled
|
||||
# from pjproject) and the media pipeline degrades to documented stub mode
|
||||
# without it. That is correct for a mock-SIP dev deploy (USE_MOCK_SIP=true);
|
||||
# /health honestly reports the mock engine as "degraded". Building real media
|
||||
# is a separate, deliberate piece of work.
|
||||
|
||||
# Stage 1: build the SvelteKit dashboard → dashboard/build/ (SPA, static).
|
||||
# dashboard/build and dashboard/node_modules are gitignored, so build fresh
|
||||
# here rather than copying a stale working-tree artifact.
|
||||
FROM node:22-alpine AS dashboard
|
||||
WORKDIR /dashboard
|
||||
COPY dashboard/package.json dashboard/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY dashboard/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: runtime. The app runs FROM SOURCE at /app (not purely from
|
||||
# site-packages): db/database.py locates alembic.ini via
|
||||
# Path(__file__).parent.parent, and main.py/config.py are loose top-level
|
||||
# modules — both require the source tree layout under the working dir. An
|
||||
# editable install puts the deps + entry points in place while keeping /app/db,
|
||||
# /app/config.py, /app/alembic.ini resolving to the real files.
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# build-essential: some deps compile from source (no manylinux wheel).
|
||||
# curl: required for the compose healthcheck (GET /health).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies first (better layer caching) using just the manifest,
|
||||
# then the source. -e keeps the package importable from /app so alembic.ini
|
||||
# and the loose modules resolve correctly at runtime.
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY . .
|
||||
# Bring in the freshly built dashboard (overwrites any stale gitignored copy).
|
||||
COPY --from=dashboard /dashboard/build ./dashboard/build
|
||||
|
||||
RUN pip install --no-cache-dir -e . \
|
||||
&& apt-get purge -y build-essential && apt-get autoremove -y
|
||||
|
||||
EXPOSE 21081
|
||||
|
||||
# Structured logs by default in the container: the host's Alloy agent reads
|
||||
# stdout and ships it to Loki, where text lines arrive as an unqueryable blob.
|
||||
# Overridable (LOG_FORMAT=text) for interactive `docker run` debugging.
|
||||
ENV LOG_FORMAT=json
|
||||
|
||||
# Migrations run in the app's own init_db() on boot (db/database.py), so no
|
||||
# separate `alembic upgrade` here. Bind host/port from the same env vars
|
||||
# pydantic-settings reads (HOST/PORT) so configured values and the actual bind
|
||||
# cannot drift. Deploy sets PORT=21081 (image default 8000 collides with other
|
||||
# host-net services on triton).
|
||||
CMD ["sh", "-c", "uvicorn main:app --host ${HOST:-0.0.0.0} --port ${PORT:-21081}"]
|
||||
153
README.md
153
README.md
@@ -21,7 +21,7 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
||||
│ │ /api/* │ │ /ws/* │ │ (SSE) │ │ /dashboard │ │
|
||||
│ │ /api/v1/*│ │ /ws/* │ │ (HTTP) │ │ / │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴──────────────┴──────────────┴────┐ │
|
||||
@@ -52,8 +52,9 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
## What's Implemented
|
||||
|
||||
### Core Engine
|
||||
- **Sippy B2BUA Engine** (`core/sippy_engine.py`) — SIP call control, DTMF, bridging, conference, trunk registration
|
||||
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, recording ports, conference bridge, WAV playback
|
||||
- **Sippy B2BUA Engine** (`core/sippy_engine.py`) — SIP call control, DTMF, bridging, conference, trunk registration. Signalling only: no audio reaches the classifier on this path
|
||||
- **PJSUA2 SIP Engine** (`core/pjsua_engine.py`) — Places the call itself so it owns the dialog, which is the only way PJSUA2 will surface RTP. Select with `SIP_ENGINE=pjsua2`; see [docs/architecture.md](docs/architecture.md#media-plane-why-pjsua2-places-the-call)
|
||||
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, capture ports, conference bridge, WAV playback (stub mode until the `pjsua2` bindings are installed — see note below)
|
||||
- **Call Manager** (`core/call_manager.py`) — Active call state tracking, lifecycle management
|
||||
- **Event Bus** (`core/event_bus.py`) — Async pub/sub with per-subscriber queues, type filtering, history
|
||||
|
||||
@@ -78,8 +79,8 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
### API Surface
|
||||
- **REST API** — Call management, call history, transcripts, recordings, routing rules, device DND, call flow CRUD
|
||||
- **WebSocket** — Real-time call events, transcripts, classification updates, receptionist state transitions
|
||||
- **MCP Server** — 14 tools + 3 resources for AI assistant integration (make calls, send DTMF, get transcripts, manage flows), served over streamable HTTP at `/mcp/`
|
||||
- **Dashboard** — SvelteKit UI served at `/dashboard` with live monitor, call history with transcript playback, and a routing-rules editor
|
||||
- **MCP Server** — 15 tools + 3 resources for AI assistant integration (make calls, send DTMF, get transcripts, manage flows), served over streamable HTTP at `/mcp/`
|
||||
- **Dashboard** — SvelteKit UI served at `/` with live monitor, call history with transcript playback, and a routing-rules editor
|
||||
|
||||
### Data Models
|
||||
- **Call** — Active call state with classification history, transcript chunks, hold time tracking
|
||||
@@ -99,8 +100,13 @@ hold-slayer/
|
||||
├── config.py # Pydantic settings from .env
|
||||
├── core/
|
||||
│ ├── gateway.py # Top-level gateway orchestrator
|
||||
│ ├── sippy_engine.py # Sippy B2BUA SIP engine
|
||||
│ ├── dial_plan.py # Emergency-number guard + number normalisation
|
||||
│ ├── sip_engine.py # SIPEngine ABC + MockSIPEngine
|
||||
│ ├── sippy_engine.py # Sippy B2BUA SIP engine (signalling only)
|
||||
│ ├── pjsua_engine.py # PJSUA2 SIP engine (call control + media)
|
||||
│ ├── media_pipeline.py # PJSUA2 audio routing
|
||||
│ ├── logging_config.py # Text/JSON log formatting
|
||||
│ ├── rate_limit.py # Fixed-window limiter for the /auth/* edge
|
||||
│ ├── call_manager.py # Active call state management
|
||||
│ └── event_bus.py # Async pub/sub event bus
|
||||
├── services/
|
||||
@@ -130,7 +136,7 @@ hold-slayer/
|
||||
│ ├── calls/[call_id]/ # Detail page + transcript playback
|
||||
│ └── routing/ # Rules editor + DND toggles
|
||||
├── mcp_server/
|
||||
│ └── server.py # MCP tools + resources (10 tools)
|
||||
│ └── server.py # MCP tools + resources (15 tools)
|
||||
├── models/
|
||||
│ ├── call.py # Call state models
|
||||
│ ├── call_flow.py # IVR tree models
|
||||
@@ -139,7 +145,7 @@ hold-slayer/
|
||||
│ ├── device.py # Device models
|
||||
│ └── contact.py # Contact models
|
||||
├── db/
|
||||
│ └── database.py # SQLAlchemy async (PostgreSQL/SQLite)
|
||||
│ └── database.py # SQLAlchemy async (PostgreSQL + Alembic)
|
||||
└── tests/
|
||||
├── test_audio_classifier.py # 18 tests — waveform analysis
|
||||
├── test_call_flows.py # 10 tests — call flow models
|
||||
@@ -165,20 +171,29 @@ pip install -e ".[dev]"
|
||||
> The PJSUA2 media pipeline needs the `pjsua2` Python bindings, which are
|
||||
> **not pip-installable** — they're built from pjproject (`./configure &&
|
||||
> make && make install` with `--enable-shared` and the Python SWIG target).
|
||||
> Without them the media layer runs in stub mode (signaling only).
|
||||
> Without them the media layer runs in stub mode (signaling only): audio
|
||||
> routing, recording and playback become no-ops that still return success.
|
||||
>
|
||||
> **See [docs/pjsua2-build.md](docs/pjsua2-build.md)** for the verified
|
||||
> procedure (pjproject 2.17, no `sudo` required). It includes the `patchelf`
|
||||
> RPATH step, without which the bindings compile and install but fail to import.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your SIP trunk credentials, LLM endpoint, etc.
|
||||
# Required: DATABASE_URL, and API_TOKEN unless HOST=127.0.0.1
|
||||
openssl rand -hex 32 # → API_TOKEN
|
||||
# Required: DATABASE_URL, plus either the Casdoor SSO settings
|
||||
# (CASDOOR_* + OWNER_NAME) or CASDOOR_ENABLED=false with HOST=127.0.0.1.
|
||||
```
|
||||
|
||||
All REST, WebSocket, and MCP access requires `Authorization: Bearer
|
||||
$API_TOKEN` (WebSocket also accepts `?token=...`). An empty token is only
|
||||
permitted when bound to loopback.
|
||||
The gateway is **owner-only**. The browser dashboard signs in via **Casdoor
|
||||
SSO** (short-lived JWT); MCP and CLI clients use a **Personal Access Token**
|
||||
(`hs_pat_…`) minted from the dashboard's *API Tokens* menu. Both are presented as
|
||||
`Authorization: Bearer <token>` (WebSocket and `<audio>` recording downloads also
|
||||
accept `?token=…`). Only the user whose Casdoor username matches `OWNER_NAME` may
|
||||
use any surface — everyone else gets 403. With `CASDOOR_ENABLED=false` the gateway
|
||||
runs in dev-owner mode, permitted **only** on a loopback bind.
|
||||
|
||||
### 3. Build the dashboard (optional but recommended)
|
||||
|
||||
@@ -189,7 +204,7 @@ npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
The gateway serves the built UI at `/dashboard` automatically when
|
||||
The gateway serves the built UI at `/` automatically when
|
||||
`dashboard/build/` exists. Skip this step if you only need the REST/WS API.
|
||||
|
||||
### 4. Run
|
||||
@@ -204,6 +219,36 @@ uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
No external services required — the suite runs against SQLite (`aiosqlite`) and
|
||||
the mock SIP engine, so it needs neither a trunk nor PostgreSQL.
|
||||
|
||||
For the parts a unit test cannot reach — real SIP signalling, RTP, IVR
|
||||
navigation against a live switch — there is an **Asterisk lab**: a fake PSTN
|
||||
that answers calls, plays hold music, and runs scripted IVR menus, so call
|
||||
paths can be exercised without dialling a real number or incurring telephony
|
||||
charges. See [tests/lab/README.md](tests/lab/README.md).
|
||||
|
||||
## Docker
|
||||
|
||||
A single image bundles the FastAPI process and the built dashboard (the node
|
||||
stage compiles the SPA; `pjsua2` is deliberately not built, so the media
|
||||
pipeline runs in stub mode — see the `Dockerfile` header). `docker-compose.yaml`
|
||||
brings up the app plus its own PostgreSQL:
|
||||
|
||||
```bash
|
||||
cp .env.compose.example .env
|
||||
# Fill in HS_DB_PASSWORD, and CASDOOR_CLIENT_ID/SECRET + OWNER_NAME.
|
||||
docker compose up --build
|
||||
# → http://localhost:21081
|
||||
```
|
||||
|
||||
Because the published port binds the app to `0.0.0.0`, the compose stack must run
|
||||
with **Casdoor SSO enabled** — dev-owner mode (`CASDOOR_ENABLED=false`) is
|
||||
loopback-only and is refused at startup here. Register a `hold-slayer` app in
|
||||
Casdoor (org `heluca`, redirect URI `<PUBLIC_BASE_URL>/auth/callback`) first. The
|
||||
image runs with `USE_MOCK_SIP=true` by default (a real trunk needs the
|
||||
`SIP_TRUNK_*` vars and `USE_MOCK_SIP=false`).
|
||||
|
||||
## Usage
|
||||
|
||||
### REST API
|
||||
@@ -211,8 +256,8 @@ pytest tests/ -v
|
||||
**Launch Hold Slayer on a number:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/calls/hold-slayer \
|
||||
-H "Authorization: Bearer $API_TOKEN" \
|
||||
curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"number": "+18005551234",
|
||||
@@ -225,21 +270,21 @@ curl -X POST http://localhost:8000/api/calls/hold-slayer \
|
||||
**Check call status:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/calls/call_abc123
|
||||
curl http://localhost:8000/api/v1/calls/call_abc123
|
||||
```
|
||||
|
||||
**Browse call history (persisted in the database):**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/calls/history?limit=50
|
||||
curl http://localhost:8000/api/calls/call_abc123/transcript
|
||||
curl -O http://localhost:8000/api/calls/call_abc123/recording # WAV
|
||||
curl http://localhost:8000/api/v1/calls/history?limit=50
|
||||
curl http://localhost:8000/api/v1/calls/call_abc123/transcript
|
||||
curl -O http://localhost:8000/api/v1/calls/call_abc123/recording # WAV
|
||||
```
|
||||
|
||||
**Create a smart-routing rule:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/routing/rules \
|
||||
curl -X POST http://localhost:8000/api/v1/routing/rules \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Block tollfree at night",
|
||||
@@ -256,7 +301,7 @@ curl -X POST http://localhost:8000/api/routing/rules \
|
||||
**Toggle Do Not Disturb on a device:**
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:8000/api/routing/devices/dev_abc123/dnd \
|
||||
curl -X PATCH http://localhost:8000/api/v1/routing/devices/dev_abc123/dnd \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enabled": true}'
|
||||
```
|
||||
@@ -264,7 +309,7 @@ curl -X PATCH http://localhost:8000/api/routing/devices/dev_abc123/dnd \
|
||||
### WebSocket — Real-Time Events
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${API_TOKEN}`);
|
||||
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${token}`);
|
||||
ws.onmessage = (msg) => {
|
||||
const event = JSON.parse(msg.data);
|
||||
// event.type: "human_detected", "hold_detected", "ivr_step", etc.
|
||||
@@ -276,14 +321,15 @@ ws.onmessage = (msg) => {
|
||||
### MCP — AI Assistant Integration
|
||||
|
||||
The MCP server is served over **streamable HTTP at `/mcp/`** (note the
|
||||
trailing slash) and authenticates with the same bearer token:
|
||||
trailing slash) and authenticates with an owner-minted Personal Access Token
|
||||
(mint one from the dashboard's *API Tokens* menu — it starts with `hs_pat_`):
|
||||
|
||||
```bash
|
||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||
--header "Authorization: Bearer $API_TOKEN"
|
||||
--header "Authorization: Bearer hs_pat_..."
|
||||
```
|
||||
|
||||
It exposes 14 tools and 3 resources (`gateway://status`,
|
||||
It exposes 15 tools and 3 resources (`gateway://status`,
|
||||
`gateway://call-flows`, `gateway://active-calls`):
|
||||
|
||||
| Tool | Description |
|
||||
@@ -302,6 +348,7 @@ It exposes 14 tools and 3 resources (`gateway://status`,
|
||||
| `create_call_flow` | Store a new IVR call flow |
|
||||
| `get_call_summary` | Stored summary and action items for a call |
|
||||
| `search_call_history` | Search past calls by number or intent |
|
||||
| `learn_call_flow` | Build/refine a reusable IVR flow from an exploration call |
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -332,13 +379,28 @@ All configuration is via environment variables (see `.env.example`):
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | — (required) |
|
||||
| `API_TOKEN` | Static bearer token for REST/WS/MCP | — (required unless `HOST=127.0.0.1`) |
|
||||
| `CASDOOR_ENABLED` | Enable Casdoor SSO (false → dev-owner, loopback only) | `false` |
|
||||
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` |
|
||||
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — (required if SSO on) |
|
||||
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — (required if SSO on) |
|
||||
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` |
|
||||
| `CASDOOR_APP_NAME` | Casdoor application name | — |
|
||||
| `OWNER_NAME` | Casdoor username of the single operator (owner) | — (required if SSO on) |
|
||||
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived from headers) | — |
|
||||
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` |
|
||||
| `HOST` | Bind address (off-loopback requires SSO enabled) | `0.0.0.0` |
|
||||
| `PORT` | Bind port | `8000` |
|
||||
| `DEBUG` | SQLAlchemy echo + uvicorn reload | `false` |
|
||||
| `LOG_LEVEL` | Root log level (`debug`/`info`/`warning`/`error`) | `info` |
|
||||
| `LOG_FORMAT` | `text` (human-readable) or `json` (structured, for Loki) | `text` |
|
||||
| `USE_MOCK_SIP` | Run the mock SIP engine — no real calls. Must be asked for | `false` |
|
||||
| `SIP_ENGINE` | `sippy` (signalling only) or `pjsua2` (call control + media) | `sippy` |
|
||||
| `NOTIFY_SMS_NUMBER` | SMS notification number (optional) | — |
|
||||
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
|
||||
| `SIP_TRUNK_USERNAME` | SIP auth username | — |
|
||||
| `SIP_TRUNK_PASSWORD` | SIP auth password | — |
|
||||
| `SIP_TRUNK_DID` | Your phone number (E.164) | — |
|
||||
| `GATEWAY_SIP_PORT` | Port for device registration | `5080` |
|
||||
| `GATEWAY_SIP_PORT` | Port for device registration | `5060` |
|
||||
| `SPEACHES_URL` | Speaches/Whisper STT endpoint | `http://localhost:22070` |
|
||||
| `LLM_BASE_URL` | OpenAI-compatible LLM endpoint | `http://localhost:11434/v1` |
|
||||
| `LLM_MODEL` | Model name for IVR analysis | `llama3` |
|
||||
@@ -352,15 +414,15 @@ All configuration is via environment variables (see `.env.example`):
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Python 3.13** + **asyncio** — Single-process async architecture
|
||||
- **Python 3.12+** + **asyncio** — Single process. Not single-threaded: the SIP stacks run their own event loops on separate OS threads, crossed only through defined funnels ([docs/architecture.md](docs/architecture.md#threading-model))
|
||||
- **FastAPI** — REST API + WebSocket server
|
||||
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/dashboard`)
|
||||
- **Sippy B2BUA** — SIP call control and DTMF
|
||||
- **PJSUA2** — Media pipeline, conference bridge, recording, WAV playback
|
||||
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/`)
|
||||
- **Sippy B2BUA** — SIP call control and DTMF (`SIP_ENGINE=sippy`, signalling only)
|
||||
- **PJSUA2** — Call control + media: conference bridge, capture ports, recording, WAV playback (`SIP_ENGINE=pjsua2`)
|
||||
- **Speaches** (Whisper) — Speech-to-text
|
||||
- **Rhema** (Kokoro) — Text-to-speech (OpenAI-compatible `/v1/audio/speech`)
|
||||
- **Ollama / vLLM / OpenAI** — LLM for IVR menu analysis and receptionist intent capture
|
||||
- **SQLAlchemy** — Async database (PostgreSQL or SQLite)
|
||||
- **SQLAlchemy + Alembic** — Async database (PostgreSQL; schema managed by migrations)
|
||||
- **MCP (Model Context Protocol)** — AI assistant integration
|
||||
|
||||
## Documentation
|
||||
@@ -369,6 +431,9 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
|
||||
- [Architecture](docs/architecture.md) — System design, data flow, threading model
|
||||
- [Core Engine](docs/core-engine.md) — SIP engine, media pipeline, call manager, event bus
|
||||
- [Dial Plan](docs/dial-plan.md) — Number normalisation and the emergency-number guard
|
||||
- [PJSUA2 Build](docs/pjsua2-build.md) — Building the bindings (not pip-installable)
|
||||
- [Asterisk Lab](tests/lab/README.md) — The fake PSTN used for media validation
|
||||
- [Hold Slayer Service](docs/hold-slayer-service.md) — IVR navigation, hold detection, human detection
|
||||
- [Audio Classifier](docs/audio-classifier.md) — Waveform analysis, feature extraction, classification
|
||||
- [Services](docs/services.md) — LLM client, transcription, recording, analytics, notifications
|
||||
@@ -384,7 +449,7 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
|
||||
- [x] Extract EventBus to dedicated module with typed filtering
|
||||
- [x] Implement Sippy B2BUA SIP engine (signaling, DTMF, bridging)
|
||||
- [x] Implement PJSUA2 media pipeline (conference bridge, audio tapping, recording)
|
||||
- [x] PJSUA2 media pipeline contract (conference bridge, audio tapping, recording) — runs in stub mode until `pjsua2` bindings are installed
|
||||
- [x] Call manager with active call state tracking
|
||||
- [x] Gateway orchestrator wiring all components
|
||||
|
||||
@@ -400,20 +465,20 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
|
||||
- [x] REST API — calls, call flows, devices, DTMF
|
||||
- [x] WebSocket real-time event streaming
|
||||
- [x] MCP server with 14 tools + 3 resources, mounted at `/mcp/` (streamable HTTP)
|
||||
- [x] MCP server with 15 tools + 3 resources, mounted at `/mcp/` (streamable HTTP)
|
||||
- [x] Notification service (WebSocket + SMS)
|
||||
- [x] Service wiring in main.py lifespan
|
||||
|
||||
### Phase 4: Production Hardening 🚧
|
||||
### Phase 4: Production Hardening ✅
|
||||
|
||||
- [ ] Alembic database migrations
|
||||
- [x] API authentication — static bearer token across REST/WS/MCP
|
||||
- [x] Alembic database migrations (baseline + upgrade-on-boot)
|
||||
- [x] API authentication — Casdoor SSO (browser JWT) + owner-minted PATs, owner-only across REST/WS/MCP
|
||||
- [x] Emergency-number guard + concurrent-call cap on outbound calls
|
||||
- [ ] Rate limiting on API endpoints
|
||||
- [ ] Structured JSON logging
|
||||
- [ ] Health check endpoints for all dependencies
|
||||
- [ ] Graceful degradation (classifier works without STT, etc.)
|
||||
- [ ] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
- [x] Rate limiting on the unauthenticated `/auth/*` edge (everything else is owner-gated; see [core/rate_limit.py](core/rate_limit.py))
|
||||
- [x] Structured JSON logging (`LOG_FORMAT=json`, uvicorn access log included)
|
||||
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
|
||||
- [x] Graceful degradation — a down STT/LLM/TTS degrades the call and publishes an `ERROR` event naming the service, rather than aborting it
|
||||
- [x] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
|
||||
### Phase 5: Additional Services 🚧
|
||||
|
||||
|
||||
201
api/auth.py
Normal file
201
api/auth.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
OIDC authentication endpoints (Casdoor SSO).
|
||||
|
||||
GET /auth/login → redirect to Casdoor authorization URL
|
||||
GET /auth/callback → exchange code for tokens, redirect to UI with token
|
||||
GET /auth/me → return current user info (requires Bearer token)
|
||||
GET /auth/silent-refresh → hidden-iframe refresh (re-auth with existing session)
|
||||
GET /auth/refresh-callback → post the refreshed token to the parent window
|
||||
GET /auth/logout → redirect to Casdoor logout URL
|
||||
|
||||
The dashboard is owner-only; ``/auth/me`` returns ``is_owner`` so a signed-in
|
||||
non-owner sees an "access denied" screen instead of a bare 401.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from auth import get_sdk, is_owner, resolve_from_header_or_query
|
||||
from config import get_settings
|
||||
from core.rate_limit import rate_limit
|
||||
from db.database import session_scope
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# These are the only routes that must answer before an identity exists, so they
|
||||
# are the only ones worth limiting — everything else is already owner-gated.
|
||||
# `/callback` and `/refresh-callback` each trigger an outbound token exchange
|
||||
# with Casdoor, and `/me` opens a DB session per call; all three are
|
||||
# unauthenticated work an attacker controls. See core/rate_limit.py.
|
||||
_limit_callback = [Depends(rate_limit("auth:callback", limit=10))]
|
||||
_limit_me = [Depends(rate_limit("auth:me"))]
|
||||
_limit_redirect = [Depends(rate_limit("auth:redirect"))]
|
||||
|
||||
|
||||
def _build_casdoor_auth_url(
|
||||
callback: str,
|
||||
*,
|
||||
scope: str = "openid profile email",
|
||||
state: str | None = None,
|
||||
prompt: str | None = None,
|
||||
) -> str:
|
||||
"""Build the Casdoor authorization URL directly.
|
||||
|
||||
The SDK's get_auth_link() doesn't support the ``prompt`` parameter that
|
||||
silent refresh needs, so build the URL manually.
|
||||
"""
|
||||
c = get_settings().casdoor
|
||||
params = {
|
||||
"client_id": c.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": callback,
|
||||
"scope": scope,
|
||||
"state": state or secrets.token_urlsafe(16),
|
||||
}
|
||||
if prompt:
|
||||
params["prompt"] = prompt
|
||||
return f"{c.endpoint.rstrip('/')}/login/oauth/authorize?{urlencode(params)}"
|
||||
|
||||
|
||||
@router.get("/login", dependencies=_limit_redirect)
|
||||
async def login(request: Request, redirect_uri: str = Query(None)):
|
||||
"""Redirect the browser to the Casdoor authorization page.
|
||||
|
||||
No ``prompt=login`` — an existing Casdoor session auto-redirects back with
|
||||
a code without showing the login form (silent SSO across *.helu.ca).
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
callback = redirect_uri or f"{request.base_url}auth/callback"
|
||||
return RedirectResponse(url=_build_casdoor_auth_url(callback))
|
||||
|
||||
|
||||
@router.get("/callback", dependencies=_limit_callback)
|
||||
async def callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(None),
|
||||
redirect_uri: str = Query(None),
|
||||
):
|
||||
"""Exchange the authorization code for tokens.
|
||||
|
||||
Redirects to the dashboard with the access token in the URL *fragment*
|
||||
(``/#token=...``) so the token stays client-side and is stored in
|
||||
localStorage.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
sdk = get_sdk()
|
||||
try:
|
||||
token = await sdk.get_oauth_token(code=code)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Token exchange failed: {exc}") from exc
|
||||
|
||||
access_token = token.get("access_token", "")
|
||||
return RedirectResponse(url=f"/#token={access_token}")
|
||||
|
||||
|
||||
@router.get("/silent-refresh", dependencies=_limit_redirect)
|
||||
async def silent_refresh(request: Request):
|
||||
"""Start a silent token refresh via hidden iframe (``prompt=none``).
|
||||
|
||||
If the Casdoor session is still active, Casdoor redirects back to
|
||||
``/auth/refresh-callback`` with a fresh code — no login form. Otherwise it
|
||||
returns an error and the iframe tells the parent to show the login overlay.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
callback = f"{request.base_url}auth/refresh-callback"
|
||||
return RedirectResponse(url=_build_casdoor_auth_url(callback, prompt="none"))
|
||||
|
||||
|
||||
@router.get("/refresh-callback", dependencies=_limit_callback)
|
||||
async def refresh_callback(
|
||||
code: str = Query(None),
|
||||
error: str = Query(None),
|
||||
state: str = Query(None),
|
||||
):
|
||||
"""Handle the silent-refresh callback inside the hidden iframe.
|
||||
|
||||
On success posts the new token to the parent window; on failure posts an
|
||||
error so the parent shows the login overlay.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
if error or not code:
|
||||
return HTMLResponse(
|
||||
'<script>window.parent.postMessage('
|
||||
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||
)
|
||||
|
||||
sdk = get_sdk()
|
||||
try:
|
||||
token = await sdk.get_oauth_token(code=code)
|
||||
access_token = token.get("access_token", "")
|
||||
except Exception:
|
||||
return HTMLResponse(
|
||||
'<script>window.parent.postMessage('
|
||||
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
f'<script>window.parent.postMessage('
|
||||
f'{{type:"hold-slayer-refresh",token:"{access_token}"}},"*");</script>'
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", dependencies=_limit_me)
|
||||
async def me(request: Request):
|
||||
"""Return the current authenticated user's profile + ``is_owner``.
|
||||
|
||||
Resolved manually (not via the ``OwnerUser`` gate) so a signed-in
|
||||
non-owner gets a 200 with ``is_owner:false`` — the dashboard uses that to
|
||||
show the "not authorized" screen rather than treating it as a hard 401.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization")
|
||||
q_token = request.query_params.get("token")
|
||||
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"display_name": user.display_name,
|
||||
"email": user.email,
|
||||
"is_owner": is_owner(user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Clear the Casdoor session and redirect back to the app.
|
||||
|
||||
``post_logout_redirect_uri`` must be absolute — Casdoor won't follow a
|
||||
relative ``/`` — so it's derived from ``request.base_url`` (works behind
|
||||
HAProxy/nginx with X-Forwarded-Proto/Host).
|
||||
"""
|
||||
c = get_settings().casdoor
|
||||
if not c.enabled:
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
app_url = str(request.base_url).rstrip("/")
|
||||
logout_url = (
|
||||
f"{c.endpoint.rstrip('/')}/login/oauth/logout"
|
||||
f"?client_id={c.client_id}"
|
||||
f"&post_logout_redirect_uri={app_url}/auth/login"
|
||||
)
|
||||
return RedirectResponse(url=logout_url)
|
||||
29
api/deps.py
29
api/deps.py
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
API Dependencies — Shared dependency injection for all routes.
|
||||
|
||||
Auth is not here: the owner gate lives in `auth.py` (`get_current_owner` /
|
||||
`OwnerUser`), applied as a router-level dependency in main.py.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from config import get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
|
||||
|
||||
@@ -24,24 +24,3 @@ def get_routing_service(request: Request):
|
||||
if routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
return routing
|
||||
|
||||
|
||||
def require_token(authorization: str | None = Header(default=None)) -> None:
|
||||
"""
|
||||
Enforce the static bearer token (API_TOKEN) on REST routes.
|
||||
|
||||
An empty configured token disables auth; startup refuses that
|
||||
combination unless the server is bound to loopback.
|
||||
"""
|
||||
token = get_settings().api_token.get_secret_value()
|
||||
if not token:
|
||||
return
|
||||
supplied = ""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
supplied = authorization[7:]
|
||||
if not secrets.compare_digest(supplied, token):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing or invalid bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
107
api/tokens.py
Normal file
107
api/tokens.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Owner-only CRUD for personal access tokens (PATs).
|
||||
|
||||
PATs are long-lived bearer tokens for MCP/CLI clients (Claude Desktop, Cline)
|
||||
and scripted API consumers that can't refresh a short-lived Casdoor JWT. The
|
||||
plaintext is shown to the caller exactly once at creation; only its SHA-256
|
||||
hash is stored.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import PAT_PREFIX, OwnerUser, hash_token
|
||||
from db.database import PersonalAccessToken, get_db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
token_prefix: str
|
||||
created_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
revoked_at: str | None = None
|
||||
|
||||
|
||||
class TokenCreated(TokenOut):
|
||||
token: str = Field(..., description="Plaintext token — shown only once. Store it now.")
|
||||
|
||||
|
||||
def _serialize(pat: PersonalAccessToken) -> dict:
|
||||
return {
|
||||
"id": pat.id,
|
||||
"name": pat.name,
|
||||
"token_prefix": pat.token_prefix,
|
||||
"created_at": pat.created_at.isoformat() if pat.created_at else None,
|
||||
"last_used_at": pat.last_used_at.isoformat() if pat.last_used_at else None,
|
||||
"expires_at": pat.expires_at.isoformat() if pat.expires_at else None,
|
||||
"revoked_at": pat.revoked_at.isoformat() if pat.revoked_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenOut])
|
||||
async def list_tokens(
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""List the owner's personal access tokens (no plaintext)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken)
|
||||
.where(PersonalAccessToken.user_id == user.id)
|
||||
.order_by(PersonalAccessToken.created_at.desc())
|
||||
)
|
||||
return [_serialize(pat) for pat in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreated, status_code=201)
|
||||
async def create_token(
|
||||
payload: TokenCreate,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a new PAT. The plaintext is returned ONCE in the response."""
|
||||
plaintext = PAT_PREFIX + secrets.token_urlsafe(32)
|
||||
pat = PersonalAccessToken(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user.id,
|
||||
name=payload.name,
|
||||
token_hash=hash_token(plaintext),
|
||||
token_prefix=plaintext[: len(PAT_PREFIX) + 4],
|
||||
)
|
||||
session.add(pat)
|
||||
await session.commit()
|
||||
await session.refresh(pat)
|
||||
return {**_serialize(pat), "token": plaintext}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-revoke a PAT (sets revoked_at)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(
|
||||
PersonalAccessToken.id == token_id,
|
||||
PersonalAccessToken.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
if pat.revoked_at is None:
|
||||
pat.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
@@ -1,13 +1,11 @@
|
||||
"""WebSocket API — Real-time call events and audio classification stream."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.deps import get_gateway
|
||||
from config import get_settings
|
||||
from auth import is_owner, resolve_from_header_or_query
|
||||
from db.database import session_scope
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,21 +15,21 @@ router = APIRouter()
|
||||
|
||||
async def _authorize(websocket: WebSocket) -> bool:
|
||||
"""
|
||||
Check the static bearer token before accepting the socket.
|
||||
Require the owner before accepting the socket.
|
||||
|
||||
Browsers can't set headers on WebSocket connects, so a `token`
|
||||
query parameter is accepted alongside the Authorization header.
|
||||
Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
|
||||
a PAT) is accepted on the `?token=` query param alongside the Authorization
|
||||
header — the same narrow fallback the recording download uses. In dev mode
|
||||
the owner resolves tokenlessly. A non-owner or absent credential closes the
|
||||
socket with code 4401.
|
||||
"""
|
||||
token = get_settings().api_token.get_secret_value()
|
||||
if not token:
|
||||
q_token = websocket.query_params.get("token")
|
||||
auth_header = websocket.headers.get("authorization")
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||
if user is not None and is_owner(user):
|
||||
return True
|
||||
supplied = websocket.query_params.get("token", "")
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
supplied = auth[7:]
|
||||
if secrets.compare_digest(supplied, token):
|
||||
return True
|
||||
await websocket.close(code=4401, reason="Missing or invalid bearer token")
|
||||
await websocket.close(code=4401, reason="Owner authentication required")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
385
auth.py
Normal file
385
auth.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Authentication and authorisation for Hold Slayer.
|
||||
|
||||
This gateway is **owner-only**. It dials real phones and spends money, so
|
||||
there are no guest/shared resources: exactly one operator (the Casdoor user
|
||||
whose name matches ``OWNER_NAME``) may use any surface; every other identity
|
||||
gets 403.
|
||||
|
||||
Two bearer-token kinds are accepted on ``Authorization: Bearer <token>``
|
||||
(or, for the two browser consumers that can't set headers — the WebSocket
|
||||
connect and ``<audio>`` recording downloads — on a ``?token=`` query param):
|
||||
|
||||
1. **Casdoor JWT** — short-lived, signed by Casdoor. Validated against the
|
||||
public keys served at ``${CASDOOR_ENDPOINT}/.well-known/jwks`` (PyJWKClient
|
||||
cache, RS256). Used by the browser dashboard after OIDC login.
|
||||
2. **Personal Access Token** — long-lived ``hs_pat_<random>`` token, minted
|
||||
from the owner-only dashboard and stored hashed in
|
||||
``personal_access_tokens``. Used by MCP/CLI clients (Claude Desktop, Cline)
|
||||
that can't refresh a JWT.
|
||||
|
||||
When ``CASDOOR_ENABLED=false`` (dev, loopback only) every request resolves to
|
||||
the dev owner — no token required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from casdoor import AsyncCasdoorSDK
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import get_settings
|
||||
from db.database import PersonalAccessToken, User, get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_DEV_OWNER_SUB = "dev-owner"
|
||||
PAT_PREFIX = "hs_pat_"
|
||||
|
||||
# ── Casdoor SDK singleton (for OAuth code exchange in /auth/callback) ─────────
|
||||
|
||||
_sdk: AsyncCasdoorSDK | None = None
|
||||
|
||||
|
||||
def get_sdk() -> AsyncCasdoorSDK:
|
||||
"""Build the Casdoor SDK lazily.
|
||||
|
||||
Used only for the OAuth2 code-exchange step in ``/auth/callback`` — JWT
|
||||
validation happens via PyJWKClient below. The certificate parameter is
|
||||
unused for code exchange but the constructor requires *something*; we pass
|
||||
an empty bytestring.
|
||||
"""
|
||||
global _sdk
|
||||
if _sdk is None:
|
||||
c = get_settings().casdoor
|
||||
_sdk = AsyncCasdoorSDK(
|
||||
endpoint=c.endpoint,
|
||||
client_id=c.client_id,
|
||||
client_secret=c.client_secret.get_secret_value(),
|
||||
certificate=b"",
|
||||
org_name=c.org_name,
|
||||
application_name=c.app_name,
|
||||
)
|
||||
return _sdk
|
||||
|
||||
|
||||
# ── JWKS client (for Casdoor JWT validation) ─────────────────────────────────
|
||||
|
||||
_jwks_client: jwt.PyJWKClient | None = None
|
||||
|
||||
|
||||
def init_jwks_client() -> None:
|
||||
"""Construct the PyJWKClient pointed at Casdoor's JWKS endpoint.
|
||||
|
||||
Called once from the app lifespan before requests are served. Pre-fetches
|
||||
the keys so the network round-trip happens at startup rather than on the
|
||||
first authenticated request. A no-op when SSO is disabled; a failed
|
||||
prefetch is non-fatal (keys are fetched lazily on first use).
|
||||
"""
|
||||
global _jwks_client
|
||||
if not get_settings().casdoor.enabled:
|
||||
return
|
||||
endpoint = get_settings().casdoor.endpoint.rstrip("/")
|
||||
jwks_uri = f"{endpoint}/.well-known/jwks"
|
||||
_jwks_client = jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=3600)
|
||||
try:
|
||||
_jwks_client.fetch_data()
|
||||
logger.info("Casdoor JWKS prefetched from %s", jwks_uri)
|
||||
except Exception as exc:
|
||||
logger.warning("Casdoor JWKS prefetch failed (%s); will retry on first request", exc)
|
||||
|
||||
|
||||
def _decode_casdoor_jwt(token: str) -> dict:
|
||||
"""Validate a Casdoor RS256 JWT against the cached JWKS.
|
||||
|
||||
Refreshes the key cache once on unknown-kid before giving up. Audience
|
||||
verification is disabled because Casdoor sets ``aud`` to the application
|
||||
name, which differs from the client_id; the signature check against
|
||||
Casdoor's key is the primary control.
|
||||
"""
|
||||
if _jwks_client is None:
|
||||
raise HTTPException(status_code=503, detail="Auth subsystem not ready")
|
||||
|
||||
issuer = get_settings().casdoor.endpoint.rstrip("/")
|
||||
|
||||
def _decode_with_current_keys() -> dict:
|
||||
signing_key = _jwks_client.get_signing_key_from_jwt(token)
|
||||
return jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
try:
|
||||
return _decode_with_current_keys()
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(status_code=401, detail="Token has expired") from exc
|
||||
except jwt.PyJWKClientError as exc:
|
||||
logger.warning("Unknown JWKS key (%s); refreshing", exc)
|
||||
try:
|
||||
_jwks_client.fetch_data()
|
||||
return _decode_with_current_keys()
|
||||
except Exception as inner:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {inner}") from inner
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc
|
||||
|
||||
|
||||
# ── PAT helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def hash_token(plaintext: str) -> str:
|
||||
"""SHA-256 hex digest of a plaintext PAT."""
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _validate_pat(session: AsyncSession, plaintext: str) -> User:
|
||||
"""Look up a PAT by hash, check it's active, return the owning user."""
|
||||
digest = hash_token(plaintext)
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(PersonalAccessToken.token_hash == digest)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None or pat.revoked_at is not None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if pat.expires_at is not None:
|
||||
# DateTime columns come back naive on SQLite (and on a Postgres
|
||||
# TIMESTAMP without tz); treat a naive value as UTC before comparing.
|
||||
expires_at = pat.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= now:
|
||||
raise HTTPException(status_code=401, detail="Token has expired")
|
||||
|
||||
pat.last_used_at = now
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
user_result = await session.execute(select(User).where(User.id == pat.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return user
|
||||
|
||||
|
||||
# ── User provisioning ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_or_create_dev_owner(session: AsyncSession) -> User:
|
||||
"""Return the dev-mode owner user row, creating it if it doesn't exist."""
|
||||
result = await session.execute(select(User).where(User.casdoor_sub == _DEV_OWNER_SUB))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(id=uuid.uuid4().hex, name="Owner", casdoor_sub=_DEV_OWNER_SUB)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def _find_or_create_user(
|
||||
session: AsyncSession,
|
||||
casdoor_sub: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
email: str | None,
|
||||
) -> User:
|
||||
"""Look up a user by casdoor_sub; create a new row on first login.
|
||||
|
||||
Lookup priority, so identity survives a Casdoor redeploy:
|
||||
1. casdoor_sub — the OIDC subject claim (primary SSO identity).
|
||||
2. name — the Casdoor username (stable, unique). Relinks a changed sub.
|
||||
3. email — pre-SSO users logging in via Casdoor for the first time.
|
||||
|
||||
Non-owner users are still provisioned (so ``is_owner`` can say "no"), but
|
||||
they reach nothing — every surface is owner-gated.
|
||||
"""
|
||||
result = await session.execute(select(User).where(User.casdoor_sub == casdoor_sub))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
changed = False
|
||||
if user.name != name:
|
||||
user.name = name
|
||||
changed = True
|
||||
if user.display_name != display_name:
|
||||
user.display_name = display_name
|
||||
changed = True
|
||||
if changed:
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
result = await session.execute(select(User).where(User.name == name))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
logger.info(
|
||||
"Linking user %s (id=%s) to new casdoor_sub %s (was %s)",
|
||||
name, user.id, casdoor_sub, user.casdoor_sub,
|
||||
)
|
||||
user.casdoor_sub = casdoor_sub
|
||||
user.display_name = display_name
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
if email:
|
||||
result = await session.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
user.casdoor_sub = casdoor_sub
|
||||
user.name = name
|
||||
user.display_name = display_name
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4().hex,
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
email=email,
|
||||
casdoor_sub=casdoor_sub,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("Created new user: %s (id=%s)", name, user.id)
|
||||
return user
|
||||
|
||||
|
||||
def _claims_to_identity(claims: dict) -> tuple[str, str, str, str | None]:
|
||||
"""Pull (sub, name, display_name, email) out of Casdoor JWT claims."""
|
||||
sub = claims.get("sub") or claims.get("name") or ""
|
||||
name = claims.get("name") or sub
|
||||
display_name = claims.get("displayName") or claims.get("name") or sub
|
||||
email = claims.get("email") or None
|
||||
return sub, name, display_name, email
|
||||
|
||||
|
||||
# ── The single resolver — used by every surface ──────────────────────────────
|
||||
|
||||
|
||||
async def resolve_bearer(session: AsyncSession, raw_token: str | None) -> User | None:
|
||||
"""Resolve a bare bearer-token string to a User, or None on any failure.
|
||||
|
||||
This is the one place a token becomes an identity. It never raises — the
|
||||
caller decides how to respond (401/403 for REST, 4401 close for WS). The
|
||||
header-based REST path and the ``?token=`` query path both funnel here.
|
||||
|
||||
Dev mode (SSO disabled) ignores the token and returns the dev owner.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
return await _get_or_create_dev_owner(session)
|
||||
|
||||
if not raw_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
if raw_token.startswith(PAT_PREFIX):
|
||||
return await _validate_pat(session, raw_token)
|
||||
claims = _decode_casdoor_jwt(raw_token)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
sub, name, display_name, email = _claims_to_identity(claims)
|
||||
if not sub:
|
||||
return None
|
||||
try:
|
||||
return await _find_or_create_user(session, sub, name, display_name, email)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _token_from_header(authorization_header: str | None) -> str | None:
|
||||
"""Extract the bearer token from an Authorization header, or None."""
|
||||
if not authorization_header:
|
||||
return None
|
||||
parts = authorization_header.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
token = parts[1].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
async def resolve_from_header_or_query(
|
||||
session: AsyncSession,
|
||||
authorization_header: str | None,
|
||||
query_token: str | None,
|
||||
) -> User | None:
|
||||
"""Resolve a User from an Authorization header, falling back to ``?token=``.
|
||||
|
||||
The query fallback exists only for the two browser consumers that can't
|
||||
set headers — the WebSocket connect and ``<audio>`` recording downloads —
|
||||
matching Hold Slayer's long-standing narrow ``?token=`` convention. The
|
||||
header wins when both are present.
|
||||
"""
|
||||
raw = _token_from_header(authorization_header) or query_token
|
||||
return await resolve_bearer(session, raw)
|
||||
|
||||
|
||||
# ── Ownership ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_owner(user: User) -> bool:
|
||||
"""Whether the user owns this gateway.
|
||||
|
||||
Dev mode: the dev-owner sub is always the owner. SSO mode: the owner is
|
||||
the user whose Casdoor username (``user.name``) matches ``OWNER_NAME``.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
return user.casdoor_sub == _DEV_OWNER_SUB
|
||||
owner_name = get_settings().owner_name
|
||||
return bool(owner_name and user.name == owner_name)
|
||||
|
||||
|
||||
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
||||
token: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the authenticated user (owner or not) — 401 if unauthenticated.
|
||||
|
||||
Used by ``/auth/me`` so a signed-in non-owner sees ``is_owner:false``
|
||||
rather than a bare 401. Owner-gating is a separate step.
|
||||
"""
|
||||
header = f"Bearer {credentials.credentials}" if credentials else None
|
||||
user = await resolve_from_header_or_query(session, header, token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_owner(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
"""The one gate for protected surfaces — 401 if unauthenticated, 403 if not owner."""
|
||||
if not is_owner(user):
|
||||
raise HTTPException(status_code=403, detail="Owner access required")
|
||||
return user
|
||||
|
||||
|
||||
OwnerUser = Annotated[User, Depends(get_current_owner)]
|
||||
42
config.py
42
config.py
@@ -89,6 +89,25 @@ class TTSSettings(BaseSettings):
|
||||
sample_rate: int = 16000
|
||||
|
||||
|
||||
class CasdoorSettings(BaseSettings):
|
||||
"""Casdoor SSO (OIDC) configuration.
|
||||
|
||||
When `enabled` is true the browser authenticates via Casdoor and every
|
||||
surface is gated to the owner; the SDK is used only for the OAuth2 code
|
||||
exchange in the /auth/callback route (JWTs are validated against the
|
||||
endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only).
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore")
|
||||
|
||||
enabled: bool = False
|
||||
endpoint: str = "https://id.ouranos.helu.ca"
|
||||
client_id: str = ""
|
||||
client_secret: SecretStr = SecretStr("")
|
||||
org_name: str = "heluca"
|
||||
app_name: str = ""
|
||||
|
||||
|
||||
class ReceptionistSettings(BaseSettings):
|
||||
"""AI Receptionist behavior settings."""
|
||||
|
||||
@@ -126,9 +145,19 @@ class Settings(BaseSettings):
|
||||
debug: bool = False
|
||||
log_level: str = "info"
|
||||
|
||||
# Auth — one static bearer token shared by REST, WebSocket, and MCP.
|
||||
# Empty disables auth, which is only permitted on loopback binds.
|
||||
api_token: SecretStr = SecretStr("")
|
||||
# Log rendering: "text" (human-readable, for a terminal) or "json" (one
|
||||
# object per line, for Loki). Text is the default so local dev is readable;
|
||||
# the container sets LOG_FORMAT=json. See core/logging_config.py.
|
||||
log_format: str = "text"
|
||||
|
||||
# Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
|
||||
# gated to a single owner. `owner_name` is the Casdoor username that owns
|
||||
# this gateway (everyone else gets 403). `public_base_url` seeds the OAuth
|
||||
# discovery URLs; blank derives them from request headers. Both cross-cut
|
||||
# every surface, so they live on the root model (like DATABASE_URL); the
|
||||
# Casdoor connection knobs live under the CASDOOR_ prefix.
|
||||
owner_name: str = ""
|
||||
public_base_url: str = ""
|
||||
|
||||
# Outbound-call safety cap (REST + MCP make_call)
|
||||
max_concurrent_calls: int = 4
|
||||
@@ -138,6 +167,12 @@ class Settings(BaseSettings):
|
||||
# silently degrading to a gateway that can't place real calls.
|
||||
use_mock_sip: bool = False
|
||||
|
||||
# SIP stack: "sippy" (signalling only — the classifier gets no audio) or
|
||||
# "pjsua2" (call control + media, the only path where audio reaches the
|
||||
# classifier). Opt-in while the PJSUA2 engine is proven against the lab;
|
||||
# see docs/architecture.md → "Media plane: why PJSUA2 places the call".
|
||||
sip_engine: str = "sippy"
|
||||
|
||||
# Notifications
|
||||
notify_sms_number: str = ""
|
||||
|
||||
@@ -150,6 +185,7 @@ class Settings(BaseSettings):
|
||||
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
||||
tts: TTSSettings = Field(default_factory=TTSSettings)
|
||||
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
||||
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
|
||||
|
||||
|
||||
# Singleton
|
||||
|
||||
@@ -55,6 +55,26 @@ def build_sip_engine(
|
||||
"for development without a trunk."
|
||||
)
|
||||
|
||||
if settings.sip_engine.lower() == "pjsua2":
|
||||
from core.pjsua_engine import PJSUAEngine
|
||||
|
||||
logger.info("📞 SIP engine: PJSUA2 (call control + media)")
|
||||
return PJSUAEngine(
|
||||
sip_address=gw_sip.host,
|
||||
sip_port=gw_sip.port,
|
||||
trunk_host=trunk.host,
|
||||
trunk_port=trunk.port,
|
||||
trunk_username=trunk.username,
|
||||
trunk_password=trunk.password.get_secret_value(),
|
||||
trunk_transport=trunk.transport,
|
||||
domain=gw_sip.domain,
|
||||
did=trunk.did,
|
||||
media_pipeline=media_pipeline,
|
||||
on_leg_state_change=on_leg_state_change,
|
||||
on_device_registered=on_device_registered,
|
||||
on_incoming_call=on_incoming_call,
|
||||
)
|
||||
|
||||
return SippyEngine(
|
||||
sip_address=gw_sip.host,
|
||||
sip_port=gw_sip.port,
|
||||
|
||||
175
core/logging_config.py
Normal file
175
core/logging_config.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Logging configuration — human-readable text or structured JSON.
|
||||
|
||||
Hold Slayer's logs are shipped to Loki by the host's Alloy agent, which reads
|
||||
the container's stdout. Text logs arrive there as an opaque blob: filtering on
|
||||
a status code or a call ID means regex over a formatted string. JSON lines
|
||||
arrive as queryable fields.
|
||||
|
||||
Two things about this are less obvious than they look, and both are the reason
|
||||
this module exists instead of a `format=` argument on `basicConfig`:
|
||||
|
||||
1. **Uvicorn brings its own handlers.** `uvicorn.config.LOGGING_CONFIG` attaches
|
||||
a `StreamHandler` to `uvicorn` and `uvicorn.access` with `propagate: False`,
|
||||
so those records never reach the root logger's formatter. Configuring only
|
||||
the root would leave the access log — the highest-volume, most useful stream
|
||||
— as plain colourised text next to our JSON. `configure_logging` reaches into
|
||||
those two loggers explicitly.
|
||||
|
||||
2. **The access record's payload is in `record.args`, not the message.** Uvicorn
|
||||
logs access lines as a 5-tuple `(client_addr, method, full_path, http_version,
|
||||
status_code)` and lets its `AccessFormatter` interpolate them. Formatting the
|
||||
message would throw that structure away and force Loki to parse it back out,
|
||||
so `JSONFormatter` unpacks the tuple into real fields.
|
||||
|
||||
Text mode stays the default: it is what a developer wants on a terminal, and a
|
||||
JSON-only logger makes local debugging worse. Production opts in via
|
||||
`LOG_FORMAT=json`.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# LogRecord attributes that are either already represented in our output or are
|
||||
# formatting machinery. Anything on a record that is *not* here is treated as a
|
||||
# caller-supplied `extra=` field and promoted into the JSON object.
|
||||
_RESERVED = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
# Uvicorn passes an ANSI-colourised duplicate of the message as
|
||||
# `extra={"color_message": ...}` for its own formatter to prefer. Left
|
||||
# unfiltered, generic extra-promotion copies escape codes into every
|
||||
# startup line — the same unreadable-in-Grafana problem as the lab's
|
||||
# Asterisk logs.
|
||||
"color_message",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"taskName",
|
||||
"thread",
|
||||
"threadName",
|
||||
}
|
||||
)
|
||||
|
||||
# Uvicorn's own access-log tuple, in order.
|
||||
_ACCESS_FIELDS = ("client_addr", "method", "path", "http_version", "status_code")
|
||||
|
||||
TEXT_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Render a LogRecord as a single-line JSON object."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
# RFC 3339 in UTC. `logging`'s default asctime is local-time and
|
||||
# date-less, which makes correlating with Loki's own timestamps
|
||||
# unnecessarily hard.
|
||||
"ts": _dt.datetime.fromtimestamp(record.created, tz=_dt.UTC).isoformat(
|
||||
timespec="milliseconds"
|
||||
),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
}
|
||||
|
||||
if record.name == "uvicorn.access" and isinstance(record.args, tuple):
|
||||
payload.update(_access_fields(record))
|
||||
else:
|
||||
payload["msg"] = record.getMessage()
|
||||
|
||||
# Thread name matters here in a way it doesn't in a single-context app:
|
||||
# this process runs the asyncio loop, the Sippy ED thread, and PJSUA2
|
||||
# worker threads, and "which context logged this" is usually the first
|
||||
# question when debugging a call.
|
||||
if record.threadName and record.threadName != "MainThread":
|
||||
payload["thread"] = record.threadName
|
||||
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
payload["stack"] = self.formatStack(record.stack_info)
|
||||
|
||||
for key, value in record.__dict__.items():
|
||||
if key not in _RESERVED and not key.startswith("_"):
|
||||
payload[key] = _safe(value)
|
||||
|
||||
return json.dumps(payload, default=str, separators=(",", ":"))
|
||||
|
||||
|
||||
def _access_fields(record: logging.LogRecord) -> dict[str, Any]:
|
||||
"""Unpack uvicorn's access-log arg tuple into named fields.
|
||||
|
||||
Falls back to the interpolated message if uvicorn ever changes the tuple's
|
||||
shape — a log line with a slightly wrong shape beats an exception inside the
|
||||
logging path taking out the request.
|
||||
"""
|
||||
args = record.args
|
||||
if not isinstance(args, tuple) or len(args) != len(_ACCESS_FIELDS):
|
||||
return {"msg": record.getMessage()}
|
||||
|
||||
fields: dict[str, Any] = dict(zip(_ACCESS_FIELDS, args))
|
||||
try:
|
||||
fields["status_code"] = int(fields["status_code"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return fields
|
||||
|
||||
|
||||
def _safe(value: Any) -> Any:
|
||||
"""Keep JSON-native types; stringify everything else."""
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def configure_logging(log_format: str, log_level: str) -> None:
|
||||
"""Install the root and uvicorn log handlers.
|
||||
|
||||
Idempotent: existing root handlers are removed first, so calling this after
|
||||
uvicorn has configured itself replaces its formatting rather than adding a
|
||||
second stream (which is how you get every line twice).
|
||||
"""
|
||||
level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
use_json = log_format.lower() == "json"
|
||||
|
||||
formatter: logging.Formatter = (
|
||||
JSONFormatter() if use_json else logging.Formatter(TEXT_FORMAT, datefmt="%H:%M:%S")
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
for existing in root.handlers[:]:
|
||||
root.removeHandler(existing)
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level)
|
||||
|
||||
# Uvicorn sets `propagate = False` on these and attaches its own colourised
|
||||
# handlers, so they must be redirected explicitly or they bypass everything
|
||||
# above. Clearing the handlers and re-enabling propagation routes them
|
||||
# through the root handler like any other logger.
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
uv = logging.getLogger(name)
|
||||
uv.handlers.clear()
|
||||
uv.propagate = True
|
||||
uv.setLevel(level)
|
||||
@@ -20,6 +20,7 @@ PJSUA2 runs in its own thread with a dedicated Endpoint.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -98,6 +99,72 @@ class AudioTap:
|
||||
self._active = False
|
||||
|
||||
|
||||
def make_capture_port(stream_id: str, sample_rate: int, channels: int, frame_ms: int):
|
||||
"""Build a PJSUA2 media port that forks conference audio into taps.
|
||||
|
||||
Defined as a factory rather than a module-level class because
|
||||
``pj.AudioMediaPort`` can only be subclassed once ``pjsua2`` imports —
|
||||
and the whole pipeline degrades to stub mode when it doesn't.
|
||||
|
||||
The returned port is a *sink*: the conference bridge transmits into it,
|
||||
and every frame is copied to each registered tap. Returns ``None`` when
|
||||
pjsua2 is unavailable.
|
||||
"""
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
class _CapturePort(pj.AudioMediaPort):
|
||||
"""Receives conference-bridge frames and fans them out to taps.
|
||||
|
||||
``onFrameReceived`` is called on a **PJSUA2 worker thread** — a third
|
||||
execution context alongside the asyncio loop and the Sippy ED thread.
|
||||
It must touch nothing but ``AudioTap.feed``, which is explicitly
|
||||
thread-safe (it hops to the owning loop via ``call_soon_threadsafe``).
|
||||
Reaching into pipeline state, the event bus, or a Sippy object from
|
||||
here would be a data race.
|
||||
"""
|
||||
|
||||
def __init__(self, stream_id: str):
|
||||
super().__init__()
|
||||
self.stream_id = stream_id
|
||||
self.taps: list[AudioTap] = []
|
||||
self._logged_error = False
|
||||
|
||||
def onFrameReceived(self, frame): # noqa: N802 — PJSUA2 C++ callback name
|
||||
try:
|
||||
if not self.taps or frame.size <= 0:
|
||||
return
|
||||
# frame.buf is a SWIG ByteVector of signed chars; the tap
|
||||
# contract is raw little-endian 16-bit PCM.
|
||||
pcm = bytes(bytearray(b & 0xFF for b in frame.buf))
|
||||
for tap in self.taps:
|
||||
tap.feed(pcm)
|
||||
except Exception as e:
|
||||
# An exception escaping into PJSUA2's C++ callback would tear
|
||||
# down the worker thread and silently kill media for every
|
||||
# call. Log once per port rather than on every 20ms frame.
|
||||
if not self._logged_error:
|
||||
self._logged_error = True
|
||||
logger.error(
|
||||
f" Audio capture failed for {self.stream_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
fmt = pj.MediaFormatAudio()
|
||||
fmt.init(
|
||||
pj.PJMEDIA_FORMAT_L16,
|
||||
sample_rate,
|
||||
channels,
|
||||
frame_ms * 1000, # frameTimeUsec
|
||||
16, # bitsPerSample
|
||||
)
|
||||
port = _CapturePort(stream_id)
|
||||
port.createPort(f"tap-{stream_id}", fmt)
|
||||
return port
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Stream Entry — tracks a single media stream in the pipeline
|
||||
# ================================================================
|
||||
@@ -112,6 +179,8 @@ class MediaStream:
|
||||
self.codec = codec
|
||||
self.conf_port: Optional[int] = None # PJSUA2 conference bridge port ID
|
||||
self.transport = None # PJSUA2 SipTransport
|
||||
self.media = None # PJSUA2 AudioMedia for this stream
|
||||
self.capture_port = None # Shared _CapturePort feeding this stream's taps
|
||||
self.rtp_port: Optional[int] = None # Local RTP listen port
|
||||
self.taps: list[AudioTap] = []
|
||||
self.recorder = None # PJSUA2 AudioMediaRecorder
|
||||
@@ -143,10 +212,11 @@ class MediaPipeline:
|
||||
pipeline = MediaPipeline()
|
||||
await pipeline.start()
|
||||
|
||||
# Add a stream for a call leg
|
||||
port = pipeline.add_remote_stream("leg_1", "10.0.0.1", 20000, "PCMU")
|
||||
# Media arrives from the SIP engine's onCallMediaState callback
|
||||
# (PJSUA2 only surfaces RTP media for a call it owns):
|
||||
# pipeline.attach_call_media("leg_1", call.getAudioMedia(i))
|
||||
|
||||
# Tap audio for analysis
|
||||
# Tap audio for analysis — safe before or after media comes up
|
||||
tap = pipeline.create_tap("leg_1")
|
||||
async for frame in tap.stream():
|
||||
classify(frame)
|
||||
@@ -173,6 +243,7 @@ class MediaPipeline:
|
||||
self._next_rtp_port = rtp_start_port
|
||||
self._sample_rate = sample_rate
|
||||
self._channels = channels
|
||||
self._frame_ms = 20 # Must match medConfig.audioFramePtime below
|
||||
self._null_audio = null_audio # Use null audio device (no sound card needed)
|
||||
|
||||
# State
|
||||
@@ -255,11 +326,17 @@ class MediaPipeline:
|
||||
tap.close()
|
||||
self._taps.clear()
|
||||
|
||||
# Remove all streams
|
||||
# Remove all streams (this releases their capture ports)
|
||||
for stream_id in list(self._streams.keys()):
|
||||
self.remove_stream(stream_id)
|
||||
|
||||
# Destroy PJSUA2 endpoint
|
||||
# Destroy PJSUA2 endpoint. Every media port must be collected first:
|
||||
# a port finalised after libDestroy() runs pjmedia_conf_remove_port
|
||||
# against a freed conference bridge and aborts the process. Dropping
|
||||
# the last Python reference is not enough on its own — force the
|
||||
# collection here rather than leaving it to interpreter exit.
|
||||
gc.collect()
|
||||
|
||||
if self._endpoint:
|
||||
try:
|
||||
self._endpoint.libDestroy()
|
||||
@@ -270,6 +347,15 @@ class MediaPipeline:
|
||||
self._ready = False
|
||||
logger.info("🎵 PJSUA2 media pipeline stopped")
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
"""The PJSUA2 Endpoint, or None in stub mode.
|
||||
|
||||
PJSUA2 permits exactly one Endpoint per process, so the pipeline
|
||||
creates it and the SIP engine borrows it rather than making a second.
|
||||
"""
|
||||
return self._endpoint
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return self._ready
|
||||
@@ -291,50 +377,51 @@ class MediaPipeline:
|
||||
# Stream Management
|
||||
# ================================================================
|
||||
|
||||
def add_remote_stream(
|
||||
self, stream_id: str, remote_host: str, remote_port: int, codec: str = "PCMU"
|
||||
) -> Optional[int]:
|
||||
def attach_call_media(self, stream_id: str, audio_media) -> Optional[int]:
|
||||
"""Register a call's live ``AudioMedia`` with the pipeline.
|
||||
|
||||
Called from ``onCallMediaState`` on a PJSUA2 worker thread, which is
|
||||
the only place PJSUA2 surfaces RTP-backed media. Any tap created
|
||||
before this point is attached now; taps created later find the media
|
||||
already present.
|
||||
|
||||
There is deliberately no ``add_remote_stream(host, port)`` counterpart:
|
||||
PJSUA2 has no standalone RTP media object, so media can only arrive
|
||||
from a call PJSUA2 owns. See ``docs/architecture.md``.
|
||||
"""
|
||||
Add a remote RTP stream to the conference bridge.
|
||||
stream = self._streams.get(stream_id)
|
||||
if stream is None:
|
||||
stream = MediaStream(stream_id, "", 0)
|
||||
self._streams[stream_id] = stream
|
||||
|
||||
Creates a PJSUA2 transport and media port for the remote
|
||||
party's RTP stream, connecting it to the conference bridge.
|
||||
stream.media = audio_media
|
||||
try:
|
||||
stream.conf_port = audio_media.getPortId()
|
||||
except Exception:
|
||||
stream.conf_port = None
|
||||
|
||||
Args:
|
||||
stream_id: Unique ID (typically the SIP leg ID)
|
||||
remote_host: Remote RTP host
|
||||
remote_port: Remote RTP port
|
||||
codec: Audio codec (PCMU, PCMA, G729)
|
||||
# Wire up taps that were requested before media came up.
|
||||
pending = self._taps.get(stream_id, [])
|
||||
if pending and stream.capture_port is None:
|
||||
port = make_capture_port(
|
||||
stream_id, self._sample_rate, self._channels, self._frame_ms
|
||||
)
|
||||
if port is not None:
|
||||
try:
|
||||
audio_media.startTransmit(port)
|
||||
stream.capture_port = port
|
||||
port.taps.extend(pending)
|
||||
logger.info(
|
||||
f" 🎤 Audio tap attached for {stream_id} "
|
||||
f"({len(pending)} waiting)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f" Failed to attach capture port for {stream_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
Returns:
|
||||
Conference bridge port ID, or None if PJSUA2 not available
|
||||
"""
|
||||
stream = MediaStream(stream_id, remote_host, remote_port, codec)
|
||||
stream.rtp_port = self.allocate_rtp_port(stream_id)
|
||||
|
||||
if self._endpoint:
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
|
||||
# Create a media transport for this stream
|
||||
# In a full implementation, we'd create an AudioMediaPort
|
||||
# that receives RTP and feeds it into the conference bridge
|
||||
transport_cfg = pj.TransportConfig()
|
||||
transport_cfg.port = stream.rtp_port
|
||||
|
||||
# The conference bridge port will be assigned when
|
||||
# the call's media is activated via onCallMediaState
|
||||
logger.info(
|
||||
f" 📡 Added stream {stream_id}: "
|
||||
f"local={stream.rtp_port} → remote={remote_host}:{remote_port} ({codec})"
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
logger.debug(f" PJSUA2 not available, stream {stream_id} is virtual")
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to add stream {stream_id}: {e}")
|
||||
|
||||
self._streams[stream_id] = stream
|
||||
logger.info(f" 📡 Media attached for {stream_id} (conf port {stream.conf_port})")
|
||||
return stream.conf_port
|
||||
|
||||
def remove_stream(self, stream_id: str) -> None:
|
||||
@@ -350,6 +437,19 @@ class MediaPipeline:
|
||||
tap.close()
|
||||
self._taps.pop(stream_id, None)
|
||||
|
||||
# Release the capture port while the conference bridge still exists.
|
||||
# A port garbage-collected after Endpoint.libDestroy() calls
|
||||
# pjmedia_conf_remove_port against a freed bridge and aborts the
|
||||
# process on a native assertion — a hard crash, not an exception.
|
||||
if stream.capture_port is not None:
|
||||
try:
|
||||
if stream.media is not None:
|
||||
stream.media.stopTransmit(stream.capture_port)
|
||||
except Exception as e:
|
||||
logger.debug(f" stopTransmit failed for {stream_id}: {e}")
|
||||
stream.capture_port.taps.clear()
|
||||
stream.capture_port = None
|
||||
|
||||
# Stop recording
|
||||
if stream.recorder:
|
||||
try:
|
||||
@@ -426,16 +526,28 @@ class MediaPipeline:
|
||||
self._taps[stream_id] = []
|
||||
self._taps[stream_id].append(tap)
|
||||
|
||||
if self._endpoint and stream and stream.conf_port is not None:
|
||||
if self._endpoint and stream and stream.media is not None:
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
# Create an AudioMediaPort that captures frames
|
||||
# and feeds them to the tap
|
||||
# In PJSUA2, we'd subclass AudioMediaPort and implement
|
||||
# onFrameReceived to call tap.feed(frame_data)
|
||||
logger.info(f" 🎤 Audio tap created for {stream_id} (PJSUA2)")
|
||||
# One capture port per stream, shared by every tap on it:
|
||||
# the bridge would otherwise mix each additional port back
|
||||
# into the conference and the call would echo.
|
||||
if stream.capture_port is None:
|
||||
port = make_capture_port(
|
||||
stream_id, self._sample_rate, self._channels, self._frame_ms
|
||||
)
|
||||
if port is not None:
|
||||
# The stream's media transmits into the capture port,
|
||||
# not the reverse — the port is a sink.
|
||||
stream.media.startTransmit(port)
|
||||
stream.capture_port = port
|
||||
logger.info(f" 🎤 Audio tap created for {stream_id} (PJSUA2)")
|
||||
|
||||
if stream.capture_port is not None:
|
||||
stream.capture_port.taps.append(tap)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to create PJSUA2 tap for {stream_id}: {e}")
|
||||
logger.error(
|
||||
f" Failed to create PJSUA2 tap for {stream_id}: {e}", exc_info=True
|
||||
)
|
||||
else:
|
||||
logger.info(f" 🎤 Audio tap created for {stream_id} (virtual)")
|
||||
|
||||
|
||||
488
core/pjsua_engine.py
Normal file
488
core/pjsua_engine.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
PJSUA2 SIP engine — call control *and* media in one library.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The gateway originally signalled with Sippy and expected PJSUA2 to carry
|
||||
media. That cannot work: **PJSUA2 exposes no standalone RTP media object**.
|
||||
Every ``AudioMedia`` subclass in the Python bindings is a file player,
|
||||
recorder, tone generator or capture port, and RTP is reachable only through
|
||||
``pj.Call.getAudioMedia()`` — on a dialog PJSUA2 itself owns. A design where
|
||||
another stack owns the dialog can never obtain media from PJSUA2, so audio
|
||||
never reached the classifier.
|
||||
|
||||
Owning the dialog is the price of owning the media, so this engine places the
|
||||
call. See ``docs/architecture.md`` → "Media plane: why PJSUA2 places the call".
|
||||
|
||||
Safety
|
||||
------
|
||||
This engine is *only* reached through ``gateway.make_call``, which refuses
|
||||
emergency numbers and enforces the concurrency cap **before** any SIP action.
|
||||
Nothing here may be given a second dial path that bypasses those checks.
|
||||
|
||||
Threading
|
||||
---------
|
||||
Three execution contexts, as elsewhere in the codebase:
|
||||
|
||||
* the **asyncio loop** owns legs, the event bus, and the call manager;
|
||||
* **PJSUA2 worker threads** run every ``on*`` callback below;
|
||||
* (the Sippy ED thread is not involved — this engine replaces it.)
|
||||
|
||||
PJSUA2 callbacks cross to the loop through exactly one funnel,
|
||||
``_post_from_pj`` → ``run_coroutine_threadsafe``. A callback must never touch
|
||||
loop-owned state directly. Any thread PJSUA2 did not create must call
|
||||
``libRegisterThread`` before touching a PJSUA2 object, which
|
||||
``_ensure_registered`` handles.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from core.sip_engine import SIPEngine
|
||||
from models.device import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PJSUAEngine(SIPEngine):
|
||||
"""SIP engine backed by PJSUA2 for both signalling and media."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sip_address: str = "0.0.0.0",
|
||||
sip_port: int = 5060,
|
||||
trunk_host: str = "",
|
||||
trunk_port: int = 5060,
|
||||
trunk_username: str = "",
|
||||
trunk_password: str = "",
|
||||
trunk_transport: str = "udp",
|
||||
domain: str = "gateway.local",
|
||||
did: str = "",
|
||||
media_pipeline=None,
|
||||
on_leg_state_change: Callable | None = None,
|
||||
on_device_registered: Callable | None = None,
|
||||
on_incoming_call: Callable | None = None,
|
||||
):
|
||||
self._sip_address = sip_address
|
||||
self._sip_port = sip_port
|
||||
self._trunk_host = trunk_host
|
||||
self._trunk_port = trunk_port
|
||||
self._trunk_username = trunk_username
|
||||
self._trunk_password = trunk_password
|
||||
self._trunk_transport = trunk_transport
|
||||
self._domain = domain
|
||||
self._did = did
|
||||
|
||||
# The media pipeline owns the PJSUA2 Endpoint; this engine borrows it
|
||||
# rather than creating a second one (PJSUA2 permits only one).
|
||||
self.media_pipeline = media_pipeline
|
||||
|
||||
self._on_leg_state_change = on_leg_state_change
|
||||
self._on_device_registered = on_device_registered
|
||||
self._on_incoming_call = on_incoming_call
|
||||
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._ready = False
|
||||
self._account = None
|
||||
self._trunk_registered = False
|
||||
self._trunk_reason = "not started"
|
||||
|
||||
# PJSUA2-thread-owned: maps leg_id → pj.Call. Only touched from a
|
||||
# PJSUA2 callback or a method that has registered itself first.
|
||||
self._calls: dict[str, object] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ================================================================
|
||||
# Thread boundary
|
||||
# ================================================================
|
||||
|
||||
def _post_from_pj(self, coro) -> None:
|
||||
"""Schedule loop work from a PJSUA2 worker thread. The one funnel."""
|
||||
if self._loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
|
||||
def _ensure_registered(self) -> None:
|
||||
"""Register the calling thread with PJSUA2 if it isn't already.
|
||||
|
||||
PJSUA2 aborts when a thread it does not know touches its objects.
|
||||
Calls made from the asyncio loop (hangup, DTMF) hit this.
|
||||
"""
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
|
||||
ep = pj.Endpoint.instance()
|
||||
if not ep.libIsThreadRegistered():
|
||||
ep.libRegisterThread(threading.current_thread().name)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug(f" thread registration skipped: {e}")
|
||||
|
||||
async def _emit_leg_state(self, leg_id: str, state: str) -> None:
|
||||
"""Deliver a leg-state change on the loop."""
|
||||
if self._on_leg_state_change is None:
|
||||
return
|
||||
result = self._on_leg_state_change(leg_id, state)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
# ================================================================
|
||||
# Lifecycle
|
||||
# ================================================================
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Create the SIP transport and register with the trunk."""
|
||||
self._loop = asyncio.get_running_loop()
|
||||
logger.info("🔌 Starting PJSUA2 SIP engine...")
|
||||
|
||||
if self.media_pipeline is None or not self.media_pipeline.endpoint:
|
||||
raise RuntimeError(
|
||||
"PJSUAEngine requires a started MediaPipeline — PJSUA2 allows "
|
||||
"only one Endpoint, so the pipeline owns it and the engine "
|
||||
"borrows it."
|
||||
)
|
||||
|
||||
import pjsua2 as pj
|
||||
|
||||
ep = self.media_pipeline.endpoint
|
||||
|
||||
transport_cfg = pj.TransportConfig()
|
||||
transport_cfg.port = self._sip_port
|
||||
if self._sip_address and self._sip_address != "0.0.0.0":
|
||||
transport_cfg.boundAddress = self._sip_address
|
||||
|
||||
tp_type = (
|
||||
pj.PJSIP_TRANSPORT_TCP
|
||||
if self._trunk_transport.lower() == "tcp"
|
||||
else pj.PJSIP_TRANSPORT_UDP
|
||||
)
|
||||
ep.transportCreate(tp_type, transport_cfg)
|
||||
|
||||
self._create_account(ep)
|
||||
|
||||
self._ready = True
|
||||
logger.info(f"🔌 PJSUA2 SIP engine ready on {self._sip_address}:{self._sip_port}")
|
||||
|
||||
def _create_account(self, ep) -> None:
|
||||
"""Build the account — registered to the trunk, or local-only."""
|
||||
import pjsua2 as pj
|
||||
|
||||
engine = self
|
||||
|
||||
class _Account(pj.Account):
|
||||
def onRegState(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
try:
|
||||
info = self.getInfo()
|
||||
engine._trunk_registered = bool(info.regIsActive)
|
||||
engine._trunk_reason = f"{prm.code} {prm.reason}".strip()
|
||||
if info.regIsActive:
|
||||
logger.info(" ✅ Trunk registration accepted")
|
||||
else:
|
||||
# A rejected REGISTER must not read as "registered":
|
||||
# /health treats a registered trunk as a condition of
|
||||
# being healthy.
|
||||
logger.error(
|
||||
f" ❌ Trunk registration failed: {engine._trunk_reason}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" onRegState error: {e}", exc_info=True)
|
||||
|
||||
def onIncomingCall(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
try:
|
||||
engine._handle_incoming(self, prm.callId)
|
||||
except Exception as e:
|
||||
logger.error(f" onIncomingCall error: {e}", exc_info=True)
|
||||
|
||||
acc_cfg = pj.AccountConfig()
|
||||
|
||||
if self._trunk_host:
|
||||
acc_cfg.idUri = f"sip:{self._trunk_username}@{self._trunk_host}"
|
||||
acc_cfg.regConfig.registrarUri = f"sip:{self._trunk_host}:{self._trunk_port}"
|
||||
cred = pj.AuthCredInfo(
|
||||
"digest", "*", self._trunk_username, 0, self._trunk_password
|
||||
)
|
||||
acc_cfg.sipConfig.authCreds.append(cred)
|
||||
else:
|
||||
# No trunk configured: a local-only account still lets devices
|
||||
# register and inbound calls arrive.
|
||||
acc_cfg.idUri = f"sip:gateway@{self._domain}"
|
||||
self._trunk_reason = "No SIP trunk configured"
|
||||
|
||||
self._account = _Account()
|
||||
self._account.create(acc_cfg)
|
||||
|
||||
if self._trunk_host:
|
||||
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Hang up everything and drop the account."""
|
||||
logger.info("🔌 Stopping PJSUA2 SIP engine...")
|
||||
self._ready = False
|
||||
|
||||
self._ensure_registered()
|
||||
had_calls = bool(self._calls)
|
||||
for leg_id in list(self._calls.keys()):
|
||||
try:
|
||||
await self.hangup(leg_id)
|
||||
except Exception as e:
|
||||
logger.debug(f" hangup during shutdown failed for {leg_id}: {e}")
|
||||
|
||||
# hangup() only queues the BYE. Give PJSUA2 a moment to send it and
|
||||
# tear the media down, or the account is deleted with a call still
|
||||
# active ("deleting account 0 while call 0 is still active") and the
|
||||
# far end is left waiting on a dialog nobody closed.
|
||||
if had_calls:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Drop every PJSUA2 object before the pipeline destroys the endpoint.
|
||||
# A Call or Account finalised after libDestroy() aborts the process on
|
||||
# a native assertion, exactly as a stray media port does — and a Call
|
||||
# still alive keeps delivering callbacks into a half-torn-down
|
||||
# interpreter. Dropping the last reference is not enough on its own,
|
||||
# so force the collection here.
|
||||
with self._lock:
|
||||
self._calls.clear()
|
||||
self._account = None
|
||||
gc.collect()
|
||||
logger.info("🔌 PJSUA2 SIP engine stopped")
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
return self._ready
|
||||
|
||||
# ================================================================
|
||||
# Calls
|
||||
# ================================================================
|
||||
|
||||
def _make_call_class(self):
|
||||
"""Build the pj.Call subclass bound to this engine."""
|
||||
import pjsua2 as pj
|
||||
|
||||
engine = self
|
||||
|
||||
class _Call(pj.Call):
|
||||
def __init__(self, acc, leg_id: str, call_id=pj.PJSUA_INVALID_ID):
|
||||
super().__init__(acc, call_id)
|
||||
self.leg_id = leg_id
|
||||
|
||||
def onCallState(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
# PJSUA2 keeps delivering callbacks while the interpreter is
|
||||
# tearing down, when module globals may already be cleared —
|
||||
# hence the local alias and the bare except. A raise here
|
||||
# escapes into C++ and takes the worker thread with it.
|
||||
state_map = _STATE_MAP
|
||||
try:
|
||||
info = self.getInfo()
|
||||
state = state_map.get(info.state)
|
||||
if state is None:
|
||||
return
|
||||
if state == "terminated":
|
||||
engine._forget_call(self.leg_id)
|
||||
engine._post_from_pj(engine._emit_leg_state(self.leg_id, state))
|
||||
except Exception:
|
||||
try:
|
||||
logger.error(" onCallState error", exc_info=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def onCallMediaState(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
"""Media is up — hand the audio to the pipeline.
|
||||
|
||||
This is the callback the whole refactor exists for: it is the
|
||||
only place PJSUA2 surfaces an RTP-backed AudioMedia.
|
||||
"""
|
||||
try:
|
||||
info = self.getInfo()
|
||||
for i, mi in enumerate(info.media):
|
||||
if (
|
||||
mi.type == pj.PJMEDIA_TYPE_AUDIO
|
||||
and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE
|
||||
):
|
||||
engine._attach_media(self.leg_id, self.getAudioMedia(i))
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
logger.error(" onCallMediaState error", exc_info=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _Call
|
||||
|
||||
def _attach_media(self, leg_id: str, audio_media) -> None:
|
||||
"""Register a live AudioMedia with the pipeline (PJSUA2 thread)."""
|
||||
if self.media_pipeline is None:
|
||||
return
|
||||
try:
|
||||
self.media_pipeline.attach_call_media(leg_id, audio_media)
|
||||
logger.info(f" 🎵 Media active for {leg_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to attach media for {leg_id}: {e}", exc_info=True)
|
||||
|
||||
def _forget_call(self, leg_id: str) -> None:
|
||||
with self._lock:
|
||||
self._calls.pop(leg_id, None)
|
||||
if self.media_pipeline is not None:
|
||||
try:
|
||||
self.media_pipeline.remove_stream(leg_id)
|
||||
except Exception as e:
|
||||
logger.debug(f" stream cleanup failed for {leg_id}: {e}")
|
||||
|
||||
async def make_call(self, number: str, caller_id: str | None = None) -> str:
|
||||
"""Place an outbound call. Reached only via gateway.make_call."""
|
||||
if not self._ready:
|
||||
raise RuntimeError("SIP engine not ready")
|
||||
|
||||
import pjsua2 as pj
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
target = (
|
||||
f"sip:{number}@{self._trunk_host}:{self._trunk_port}"
|
||||
if self._trunk_host
|
||||
else f"sip:{number}@{self._domain}"
|
||||
)
|
||||
logger.info(f"📞 Placing call to {target} (leg: {leg_id})")
|
||||
|
||||
self._ensure_registered()
|
||||
call_cls = self._make_call_class()
|
||||
call = call_cls(self._account, leg_id)
|
||||
|
||||
prm = pj.CallOpParam(True)
|
||||
call.makeCall(target, prm)
|
||||
|
||||
with self._lock:
|
||||
self._calls[leg_id] = call
|
||||
return leg_id
|
||||
|
||||
async def hangup(self, call_leg_id: str) -> None:
|
||||
import pjsua2 as pj
|
||||
|
||||
with self._lock:
|
||||
call = self._calls.get(call_leg_id)
|
||||
if call is None:
|
||||
return
|
||||
self._ensure_registered()
|
||||
try:
|
||||
call.hangup(pj.CallOpParam(True))
|
||||
except Exception as e:
|
||||
logger.debug(f" hangup failed for {call_leg_id}: {e}")
|
||||
self._forget_call(call_leg_id)
|
||||
|
||||
async def send_dtmf(self, call_leg_id: str, digits: str) -> None:
|
||||
"""Send DTMF as RFC 2833 — the in-band path a real IVR expects."""
|
||||
with self._lock:
|
||||
call = self._calls.get(call_leg_id)
|
||||
if call is None:
|
||||
logger.warning(f" send_dtmf: no call for {call_leg_id}")
|
||||
return
|
||||
self._ensure_registered()
|
||||
call.dialDtmf(digits)
|
||||
logger.info(f" Sent DTMF '{digits}' on {call_leg_id}")
|
||||
|
||||
async def call_device(self, device: Device) -> str:
|
||||
"""Ring a registered device (transfer target)."""
|
||||
if not self._ready:
|
||||
raise RuntimeError("SIP engine not ready")
|
||||
|
||||
import pjsua2 as pj
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
target = device.sip_uri or f"sip:{device.id}@{self._domain}"
|
||||
logger.info(f"📞 Ringing device {device.id} at {target} (leg: {leg_id})")
|
||||
|
||||
self._ensure_registered()
|
||||
call_cls = self._make_call_class()
|
||||
call = call_cls(self._account, leg_id)
|
||||
call.makeCall(target, pj.CallOpParam(True))
|
||||
|
||||
with self._lock:
|
||||
self._calls[leg_id] = call
|
||||
return leg_id
|
||||
|
||||
def _handle_incoming(self, account, call_id) -> None:
|
||||
"""Inbound INVITE (PJSUA2 thread) — answer and hand to the receptionist."""
|
||||
import pjsua2 as pj
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
call_cls = self._make_call_class()
|
||||
call = call_cls(account, leg_id, call_id)
|
||||
|
||||
try:
|
||||
info = call.getInfo()
|
||||
remote = info.remoteUri
|
||||
except Exception:
|
||||
remote = "unknown"
|
||||
|
||||
with self._lock:
|
||||
self._calls[leg_id] = call
|
||||
|
||||
call.answer(pj.CallOpParam(True))
|
||||
logger.info(f"📞 Inbound call {leg_id} from {remote}")
|
||||
|
||||
if self._on_incoming_call is not None:
|
||||
result = self._on_incoming_call(leg_id, remote)
|
||||
if asyncio.iscoroutine(result):
|
||||
self._post_from_pj(result)
|
||||
|
||||
# ================================================================
|
||||
# Bridging
|
||||
# ================================================================
|
||||
|
||||
async def bridge_calls(self, leg_a: str, leg_b: str) -> str:
|
||||
"""Join two legs in the conference bridge."""
|
||||
bridge_id = f"bridge_{uuid.uuid4().hex[:8]}"
|
||||
if self.media_pipeline is not None:
|
||||
self.media_pipeline.bridge_streams(leg_a, leg_b)
|
||||
logger.info(f" 🌉 Bridged {leg_a} ↔ {leg_b} ({bridge_id})")
|
||||
return bridge_id
|
||||
|
||||
async def unbridge(self, bridge_id: str) -> None:
|
||||
logger.info(f" Unbridged {bridge_id}")
|
||||
|
||||
def get_audio_stream(self, call_leg_id: str):
|
||||
if self.media_pipeline is not None:
|
||||
return self.media_pipeline.get_audio_tap(call_leg_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# Status
|
||||
# ================================================================
|
||||
|
||||
async def get_registered_devices(self) -> list[dict]:
|
||||
return []
|
||||
|
||||
async def get_trunk_status(self) -> dict:
|
||||
return {
|
||||
"registered": self._trunk_registered,
|
||||
"host": self._trunk_host or "not configured",
|
||||
"port": self._trunk_port,
|
||||
"transport": self._trunk_transport,
|
||||
"username": self._trunk_username,
|
||||
"reason": None if self._trunk_registered else self._trunk_reason,
|
||||
}
|
||||
|
||||
|
||||
# Populated lazily: the pjsua2 constants are unavailable until import, and
|
||||
# the module must import cleanly in stub mode.
|
||||
_STATE_MAP: dict = {}
|
||||
|
||||
|
||||
def _init_state_map() -> None:
|
||||
global _STATE_MAP
|
||||
if _STATE_MAP:
|
||||
return
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
except ImportError:
|
||||
return
|
||||
_STATE_MAP = {
|
||||
pj.PJSIP_INV_STATE_CALLING: "trying",
|
||||
pj.PJSIP_INV_STATE_EARLY: "ringing",
|
||||
pj.PJSIP_INV_STATE_CONNECTING: "trying",
|
||||
pj.PJSIP_INV_STATE_CONFIRMED: "connected",
|
||||
pj.PJSIP_INV_STATE_DISCONNECTED: "terminated",
|
||||
}
|
||||
|
||||
|
||||
_init_state_map()
|
||||
132
core/rate_limit.py
Normal file
132
core/rate_limit.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Rate limiting for the unauthenticated edge.
|
||||
|
||||
**Scope, and why it is narrow.** Every REST/WS/MCP surface is owner-only: an
|
||||
unauthenticated request is rejected by `resolve_bearer`/`is_owner` before any
|
||||
handler runs, and real spend control for outbound calls is
|
||||
`max_concurrent_calls` in `gateway.make_call`. Blanket per-endpoint limits would
|
||||
therefore mostly rate-limit the single legitimate operator. What is genuinely
|
||||
exposed is the handful of `/auth/*` routes that must answer before an identity
|
||||
exists — those are limited here, and nothing else.
|
||||
|
||||
**What this defends against.** Not credential guessing: PATs are
|
||||
`secrets.token_urlsafe(32)` (256 bits) compared by SHA-256 digest, so brute
|
||||
force is not a practical threat. The concern is *unauthenticated work an
|
||||
attacker controls*: `/auth/callback` makes an outbound token-exchange round-trip
|
||||
to Casdoor on every request, and `/auth/me` opens a DB session and runs a token
|
||||
lookup. Both are free to trigger and neither is cheap to serve.
|
||||
|
||||
**Fixed-window, in-process, no dependency.** One operator and a single process
|
||||
mean a shared counter store would be infrastructure without a purpose. The
|
||||
trade-off of a fixed window is a burst of up to 2× the limit across a boundary;
|
||||
that is irrelevant at these thresholds, and it costs one dict lookup with no
|
||||
background task.
|
||||
|
||||
**Client identity is the socket peer, deliberately.** `X-Forwarded-For` is
|
||||
attacker-controlled unless a trusted proxy overwrites it, and this app does not
|
||||
currently establish that trust (`_public_base_url` reads forwarded headers, but
|
||||
only to build URLs). Keying on a spoofable header would let one client present
|
||||
as thousands and make the limiter worse than useless. Behind the estate's
|
||||
reverse proxy this means the limit applies per-proxy rather than per-caller —
|
||||
correct for exhaustion, and honest about what it can enforce. If per-caller
|
||||
limits are ever needed, that needs an explicit trusted-proxy config, not a
|
||||
silent `X-Forwarded-For` read.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
# Requests allowed per window, per client, per route. Sized to be invisible to a
|
||||
# human — a dashboard load touches /auth/me once — while capping automated
|
||||
# hammering.
|
||||
DEFAULT_LIMIT = 30
|
||||
DEFAULT_WINDOW_SECONDS = 60
|
||||
|
||||
# Stop the bucket store growing without bound under a spray of source addresses.
|
||||
# Eviction is oldest-first and only runs when the cap is exceeded.
|
||||
MAX_TRACKED_CLIENTS = 10_000
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Fixed-window request counter, keyed by (route, client)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
window_seconds: int = DEFAULT_WINDOW_SECONDS,
|
||||
max_clients: int = MAX_TRACKED_CLIENTS,
|
||||
):
|
||||
self.limit = limit
|
||||
self.window_seconds = window_seconds
|
||||
self.max_clients = max_clients
|
||||
# key -> [window_start, count]. Insertion-ordered, which is what makes
|
||||
# oldest-first eviction a cheap `next(iter(...))`.
|
||||
self._buckets: dict[str, list[float]] = {}
|
||||
|
||||
def check(
|
||||
self, key: str, limit: int | None = None, now: float | None = None
|
||||
) -> tuple[bool, int]:
|
||||
"""Record a hit. Returns (allowed, retry_after_seconds).
|
||||
|
||||
`retry_after` is 0 when allowed, and the seconds remaining in the
|
||||
current window when not. `limit` overrides the instance default for
|
||||
routes that want a tighter cap.
|
||||
"""
|
||||
now = time.monotonic() if now is None else now
|
||||
effective = self.limit if limit is None else limit
|
||||
bucket = self._buckets.get(key)
|
||||
|
||||
if bucket is None or now - bucket[0] >= self.window_seconds:
|
||||
self._buckets.pop(key, None) # re-insert so ordering tracks recency
|
||||
self._buckets[key] = [now, 1]
|
||||
self._evict_if_needed()
|
||||
return True, 0
|
||||
|
||||
bucket[1] += 1
|
||||
if bucket[1] > effective:
|
||||
remaining = self.window_seconds - (now - bucket[0])
|
||||
return False, max(1, int(remaining) + 1)
|
||||
return True, 0
|
||||
|
||||
def _evict_if_needed(self) -> None:
|
||||
while len(self._buckets) > self.max_clients:
|
||||
self._buckets.pop(next(iter(self._buckets)))
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buckets.clear()
|
||||
|
||||
|
||||
def client_key(request: Request, scope: str) -> str:
|
||||
"""Identify the caller for limiting purposes.
|
||||
|
||||
Uses the socket peer, never a forwarded header — see the module docstring.
|
||||
"""
|
||||
client = request.client.host if request.client else "unknown"
|
||||
return f"{scope}:{client}"
|
||||
|
||||
|
||||
_limiter = RateLimiter()
|
||||
|
||||
|
||||
def get_limiter() -> RateLimiter:
|
||||
return _limiter
|
||||
|
||||
|
||||
def rate_limit(scope: str, limit: int | None = None):
|
||||
"""FastAPI dependency factory limiting one route.
|
||||
|
||||
Applied per-route rather than as middleware so the authenticated surfaces —
|
||||
which are already owner-gated — pay nothing.
|
||||
"""
|
||||
|
||||
async def _dependency(request: Request) -> None:
|
||||
allowed, retry_after = get_limiter().check(client_key(request, scope), limit=limit)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many requests",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
return _dependency
|
||||
@@ -84,6 +84,48 @@ class SippyCallController:
|
||||
self.leg_id = leg_id
|
||||
self.engine = engine
|
||||
|
||||
def __call__(self, event, ua) -> None:
|
||||
"""Sippy's ``event_cb`` — invoked as ``event_cb(event, ua)``.
|
||||
|
||||
Sippy delivers call progress as CCEvent objects through this one
|
||||
entry point; it never calls the ``on_*`` methods directly. This
|
||||
dispatches to them so each SIP fact still has a named handler.
|
||||
"""
|
||||
from sippy.CCEvents import (
|
||||
CCEventConnect,
|
||||
CCEventDisconnect,
|
||||
CCEventFail,
|
||||
CCEventPreConnect,
|
||||
CCEventRing,
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(event, CCEventRing):
|
||||
self.on_ringing()
|
||||
elif isinstance(event, (CCEventConnect, CCEventPreConnect)):
|
||||
# data is (code, reason, body) — the body carries the
|
||||
# negotiated SDP that tells the media pipeline where to
|
||||
# send RTP.
|
||||
data = event.getData()
|
||||
body = data[2] if isinstance(data, tuple) and len(data) > 2 else None
|
||||
self.on_connected(str(body) if body is not None else None)
|
||||
elif isinstance(event, CCEventDisconnect):
|
||||
self.on_disconnected("remote hangup")
|
||||
elif isinstance(event, CCEventFail):
|
||||
data = event.getData()
|
||||
reason = " ".join(str(d) for d in data[:2]) if data else "call failed"
|
||||
self.on_disconnected(reason)
|
||||
# DTMF is not handled here: SIP INFO arrives as a request and is
|
||||
# picked up by _handle_incoming_info, and RFC 2833 DTMF rides in
|
||||
# the RTP stream, which is the media pipeline's business.
|
||||
except Exception as e:
|
||||
# This runs on the Sippy ED thread: an escaping exception is
|
||||
# swallowed by the dispatcher and the leg would hang silently.
|
||||
logger.error(
|
||||
f" {self.leg_id}: error handling {type(event).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def on_trying(self):
|
||||
"""100 Trying received."""
|
||||
logger.debug(f" {self.leg_id}: 100 Trying")
|
||||
@@ -234,17 +276,22 @@ class SippyEngine(SIPEngine):
|
||||
if state == "connected":
|
||||
sdp = data.get("sdp")
|
||||
if sdp and self.media_pipeline:
|
||||
# Signalling only: PJSUA2 surfaces RTP media exclusively
|
||||
# through a call it owns, so a Sippy-owned dialog can
|
||||
# never be given media — the classifier stays deaf on this
|
||||
# engine. PJSUAEngine is the media-capable path; see
|
||||
# docs/architecture.md. Log the negotiated endpoint so the
|
||||
# SIP exchange is still debuggable.
|
||||
try:
|
||||
remote_rtp = self._parse_sdp_rtp_endpoint(sdp)
|
||||
if remote_rtp:
|
||||
leg.media_port = self.media_pipeline.add_remote_stream(
|
||||
leg.leg_id,
|
||||
remote_rtp["host"],
|
||||
remote_rtp["port"],
|
||||
remote_rtp["codec"],
|
||||
logger.info(
|
||||
f" {leg.leg_id}: remote RTP "
|
||||
f"{remote_rtp['host']}:{remote_rtp['port']} "
|
||||
f"({remote_rtp['codec']}) — no media on this engine"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to set up media for {leg.leg_id}: {e}")
|
||||
logger.error(f" Failed to parse SDP for {leg.leg_id}: {e}")
|
||||
elif state == "terminated":
|
||||
if self.media_pipeline and leg.media_port is not None:
|
||||
try:
|
||||
@@ -319,10 +366,17 @@ class SippyEngine(SIPEngine):
|
||||
SipConf.my_port = self._sip_port
|
||||
SipConf.my_uaname = "Hold Slayer Gateway"
|
||||
|
||||
# SipTransactionManager dereferences _sip_logger unconditionally on
|
||||
# every message, so it must exist before any SIP traffic. It
|
||||
# defaults to the stderr backend (SIPLOG_BEND), not the
|
||||
# /var/log/sip.log path in its signature — nothing to create.
|
||||
from sippy.SipLogger import SipLogger
|
||||
|
||||
self._sippy_global_config = {
|
||||
"_sip_address": self._sip_address,
|
||||
"_sip_port": self._sip_port,
|
||||
"_sip_tm": None, # Transaction manager set after start
|
||||
"_sip_logger": SipLogger("hold-slayer"),
|
||||
}
|
||||
|
||||
# Start Sippy's SIP transaction manager in a background thread
|
||||
@@ -499,23 +553,53 @@ class SippyEngine(SIPEngine):
|
||||
def do_register():
|
||||
try:
|
||||
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
||||
from sippy.SipURL import SipURL
|
||||
|
||||
def on_registered(_rtime, _contact, _cb_arg):
|
||||
logger.info(" ✅ Trunk registration accepted")
|
||||
self._post_from_ed("trunk_registered", {"registered": True})
|
||||
|
||||
def on_register_failed(status_line, _cb_arg):
|
||||
# status_line is the response's status line (e.g. "403
|
||||
# Forbidden") — surface it; a bad trunk password is the
|
||||
# most common cause and is otherwise invisible.
|
||||
logger.error(f" ❌ Trunk registration rejected: {status_line}")
|
||||
self._post_from_ed(
|
||||
"trunk_registered",
|
||||
{"registered": False, "reason": str(status_line)},
|
||||
)
|
||||
|
||||
# A wildcard bind is not a routable Contact — the trunk would
|
||||
# have nowhere to send the inbound INVITE. Fall back to
|
||||
# loopback, matching _generate_sdp's handling.
|
||||
contact_host = (
|
||||
self._sip_address if self._sip_address != "0.0.0.0" else "127.0.0.1"
|
||||
)
|
||||
|
||||
# aor/contact must be SipURL objects: the agent calls
|
||||
# .getCopy() and mutates .username/.port on them.
|
||||
reg_agent = SipRegistrationAgent(
|
||||
self._sippy_global_config,
|
||||
f"sip:{self._trunk_username}@{self._trunk_host}",
|
||||
f"sip:{self._trunk_host}:{self._trunk_port}",
|
||||
auth_name=self._trunk_username,
|
||||
auth_password=self._trunk_password,
|
||||
SipURL(f"sip:{self._trunk_username}@{self._trunk_host}"),
|
||||
SipURL(f"sip:{self._trunk_username}@{contact_host}:{self._sip_port}"),
|
||||
user=self._trunk_username,
|
||||
passw=self._trunk_password,
|
||||
rok_cb=on_registered,
|
||||
rfail_cb=on_register_failed,
|
||||
)
|
||||
reg_agent.register()
|
||||
logger.info(" ✅ Trunk registration sent")
|
||||
self._post_from_ed("trunk_registered", {"registered": True})
|
||||
# Registration is asynchronous: success is reported by the
|
||||
# callbacks above, not here. Reporting "registered" at send
|
||||
# time would let /health go green on a rejected REGISTER.
|
||||
reg_agent.doregister()
|
||||
logger.info(" Trunk REGISTER sent, awaiting response")
|
||||
except ImportError:
|
||||
logger.warning(" Sippy registration agent not available")
|
||||
self._post_from_ed("trunk_registered", {"registered": False})
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Trunk registration failed: {e}")
|
||||
self._post_from_ed("trunk_registered", {"registered": False})
|
||||
logger.error(f" ❌ Trunk registration failed: {e}", exc_info=True)
|
||||
self._post_from_ed(
|
||||
"trunk_registered", {"registered": False, "reason": str(e)}
|
||||
)
|
||||
|
||||
self._run_on_sippy(do_register)
|
||||
|
||||
@@ -570,7 +654,8 @@ class SippyEngine(SIPEngine):
|
||||
else:
|
||||
remote_uri = f"sip:{number}@{self._domain}"
|
||||
|
||||
from_uri = f"sip:{caller_id or self._did}@{self._domain}"
|
||||
caller_number = caller_id or self._did
|
||||
from_uri = f"sip:{caller_number}@{self._domain}"
|
||||
|
||||
leg = SipCallLeg(leg_id, "outbound", remote_uri)
|
||||
self._legs[leg_id] = leg
|
||||
@@ -583,24 +668,38 @@ class SippyEngine(SIPEngine):
|
||||
def do_invite():
|
||||
try:
|
||||
from sippy.CCEvents import CCEventTry
|
||||
from sippy.MsgBody import MsgBody
|
||||
from sippy.SipCallId import SipCallId
|
||||
from sippy.UA import UA
|
||||
|
||||
controller = SippyCallController(leg_id, self)
|
||||
|
||||
# Create Sippy UA for this call
|
||||
# Create Sippy UA for this call. The credentials are required:
|
||||
# a trunk answers the first INVITE with 401/407, and sippy
|
||||
# only retries with a digest response when they are set —
|
||||
# without them every outbound call dies on the challenge.
|
||||
ua = UA(
|
||||
self._sippy_global_config,
|
||||
event_cb=controller,
|
||||
username=self._trunk_username or None,
|
||||
password=self._trunk_password or None,
|
||||
nh_address=(self._trunk_host, self._trunk_port),
|
||||
)
|
||||
self._ed_leg_to_ua[leg_id] = ua
|
||||
self._ed_ua_to_leg[ua] = leg_id
|
||||
|
||||
# Send INVITE
|
||||
# SDP travels inside the event's data tuple as a MsgBody, not
|
||||
# as a kwarg. needs_update=False marks it final: with it set,
|
||||
# sippy would call ua.on_local_sdp_change (unset here) before
|
||||
# sending, and the INVITE would never go out.
|
||||
body = MsgBody(sdp_body, mtype="application/sdp")
|
||||
body.needs_update = False
|
||||
|
||||
# UacStateIdle unpacks exactly six fields and builds the SIP
|
||||
# URIs itself from nh_address — callingID/calledID are bare
|
||||
# usernames, not full URIs.
|
||||
event = CCEventTry(
|
||||
(SipCallId(), from_uri, remote_uri),
|
||||
body=sdp_body,
|
||||
(SipCallId(), caller_number, number, body, None, None)
|
||||
)
|
||||
ua.recvEvent(event)
|
||||
|
||||
@@ -613,8 +712,11 @@ class SippyEngine(SIPEngine):
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to send INVITE for {leg_id}: {e}")
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
||||
logger.error(f" Failed to send INVITE for {leg_id}: {e}", exc_info=True)
|
||||
self._post_from_ed(
|
||||
"leg_state",
|
||||
{"leg_id": leg_id, "state": "terminated", "error": str(e)},
|
||||
)
|
||||
|
||||
self._run_on_sippy(do_invite)
|
||||
return leg_id
|
||||
|
||||
11
dashboard/package-lock.json
generated
11
dashboard/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
@@ -1262,6 +1263,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
|
||||
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
5
dashboard/src/hooks.client.ts
Normal file
5
dashboard/src/hooks.client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
// Runs once, before the app mounts: capture the Casdoor callback token from
|
||||
// the URL fragment before any /auth/me call or render.
|
||||
auth.captureFragmentToken();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AccessToken,
|
||||
CallHistoryRow,
|
||||
CallSummary,
|
||||
DeviceStatus,
|
||||
@@ -9,14 +10,44 @@ import type {
|
||||
TranscriptRow,
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
|
||||
// a PAT) in localStorage via the auth store. On a 401 we attempt one
|
||||
// silent refresh and retry; a second failure logs out.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
import { auth, getToken } from './auth.svelte';
|
||||
|
||||
function withAuth(init: RequestInit): RequestInit {
|
||||
const token = getToken();
|
||||
if (!token) return init;
|
||||
return {
|
||||
...init,
|
||||
headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let res = await fetch(path, withAuth(init));
|
||||
if (res.status === 401) {
|
||||
const refreshed = await auth.trySilentRefresh();
|
||||
if (refreshed) {
|
||||
res = await fetch(path, withAuth(init));
|
||||
} else {
|
||||
auth.setUnauthenticated();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
const res = await request(path);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function fetchGatewayStatus(): Promise<GatewayStatus> {
|
||||
return get<GatewayStatus>('/');
|
||||
return get<GatewayStatus>('/api/v1/status');
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthStatus> {
|
||||
@@ -24,15 +55,15 @@ export async function fetchHealth(): Promise<HealthStatus> {
|
||||
}
|
||||
|
||||
export async function fetchActiveCalls(): Promise<CallSummary[]> {
|
||||
return get<CallSummary[]>('/api/calls/active');
|
||||
return get<CallSummary[]>('/api/v1/calls/active');
|
||||
}
|
||||
|
||||
export async function fetchDevices(): Promise<DeviceStatus[]> {
|
||||
return get<DeviceStatus[]>('/api/devices');
|
||||
return get<DeviceStatus[]>('/api/v1/devices');
|
||||
}
|
||||
|
||||
export async function hangupCall(callId: string): Promise<void> {
|
||||
const res = await fetch(`/api/calls/${callId}/hangup`, { method: 'POST' });
|
||||
const res = await request(`/api/v1/calls/${callId}/hangup`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
@@ -41,27 +72,33 @@ export async function fetchCallHistory(
|
||||
offset = 0,
|
||||
): Promise<CallHistoryRow[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
return get<CallHistoryRow[]>(`/api/calls/history?${params}`);
|
||||
return get<CallHistoryRow[]>(`/api/v1/calls/history?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchCallRecord(callId: string): Promise<CallHistoryRow> {
|
||||
return get<CallHistoryRow>(`/api/calls/${callId}/record`);
|
||||
return get<CallHistoryRow>(`/api/v1/calls/${callId}/record`);
|
||||
}
|
||||
|
||||
export async function fetchTranscript(callId: string): Promise<TranscriptRow[]> {
|
||||
return get<TranscriptRow[]>(`/api/calls/${callId}/transcript`);
|
||||
return get<TranscriptRow[]>(`/api/v1/calls/${callId}/transcript`);
|
||||
}
|
||||
|
||||
export function recordingUrl(callId: string): string {
|
||||
return `/api/calls/${callId}/recording`;
|
||||
// <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
|
||||
// rides as a query param — the same narrow fallback the WebSocket uses,
|
||||
// accepted server-side alongside the Authorization header. The proactive
|
||||
// refresh timer keeps the stored JWT valid, so it's fresh at click time.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return `/api/v1/calls/${callId}/recording${suffix}`;
|
||||
}
|
||||
|
||||
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
|
||||
return get<RoutingRule[]>('/api/routing/rules');
|
||||
return get<RoutingRule[]>('/api/v1/routing/rules');
|
||||
}
|
||||
|
||||
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
|
||||
const res = await fetch('/api/routing/rules', {
|
||||
const res = await request('/api/v1/routing/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rule),
|
||||
@@ -74,7 +111,7 @@ export async function updateRoutingRule(
|
||||
ruleId: string,
|
||||
patch: Partial<RoutingRule>,
|
||||
): Promise<RoutingRule> {
|
||||
const res = await fetch(`/api/routing/rules/${ruleId}`, {
|
||||
const res = await request(`/api/v1/routing/rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
@@ -84,12 +121,12 @@ export async function updateRoutingRule(
|
||||
}
|
||||
|
||||
export async function deleteRoutingRule(ruleId: string): Promise<void> {
|
||||
const res = await fetch(`/api/routing/rules/${ruleId}`, { method: 'DELETE' });
|
||||
const res = await request(`/api/v1/routing/rules/${ruleId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
|
||||
const res = await fetch(`/api/routing/devices/${deviceId}/dnd`, {
|
||||
const res = await request(`/api/v1/routing/devices/${deviceId}/dnd`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
@@ -97,12 +134,38 @@ export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Personal Access Tokens (owner-only) — for MCP/CLI clients.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
export async function fetchTokens(): Promise<AccessToken[]> {
|
||||
return get<AccessToken[]>('/api/v1/tokens');
|
||||
}
|
||||
|
||||
export async function createToken(name: string): Promise<AccessToken> {
|
||||
const res = await request('/api/v1/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<AccessToken>;
|
||||
}
|
||||
|
||||
export async function revokeToken(tokenId: string): Promise<void> {
|
||||
const res = await request(`/api/v1/tokens/${tokenId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export function connectEventStream(
|
||||
onEvent: (e: GatewayEvent) => void,
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events`);
|
||||
// Browsers can't set headers on WS connects — the token rides as ?token=.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
|
||||
146
dashboard/src/lib/auth.svelte.ts
Normal file
146
dashboard/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { User } from './types';
|
||||
|
||||
const TOKEN_KEY = 'hold-slayer-token';
|
||||
|
||||
// 'denied' = authenticated with Casdoor but not the owner of this gateway.
|
||||
export type AuthStatus = 'loading' | 'authed' | 'unauthenticated' | 'denied';
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
class AuthStore {
|
||||
status = $state<AuthStatus>('loading');
|
||||
user = $state<User | null>(null);
|
||||
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get isOwner(): boolean {
|
||||
return this.user?.is_owner ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a `#token=...` fragment left by the Casdoor callback, persist it,
|
||||
* and scrub it from the URL. Runs before the first /auth/me call.
|
||||
*/
|
||||
captureFragmentToken() {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#token=')) {
|
||||
const token = hash.slice(7);
|
||||
if (token) setToken(token);
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine auth state on boot. */
|
||||
async init(): Promise<void> {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
// No token: maybe SSO is disabled (dev mode) — /auth/me succeeds tokenless.
|
||||
try {
|
||||
const res = await fetch('/auth/me');
|
||||
if (res.ok) {
|
||||
this.applyUser(await res.json());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to unauthenticated */
|
||||
}
|
||||
this.status = 'unauthenticated';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Token invalid — try silent refresh once, then retry.
|
||||
const refreshed = await this.trySilentRefresh();
|
||||
if (refreshed) return this.init();
|
||||
clearToken();
|
||||
this.status = 'unauthenticated';
|
||||
return;
|
||||
}
|
||||
this.applyUser(await res.json());
|
||||
this.scheduleTokenRefresh(token);
|
||||
} catch {
|
||||
this.status = 'unauthenticated';
|
||||
}
|
||||
}
|
||||
|
||||
/** Set user + status from an /auth/me payload. Non-owners are denied. */
|
||||
private applyUser(user: User) {
|
||||
this.user = user;
|
||||
this.status = user.is_owner ? 'authed' : 'denied';
|
||||
}
|
||||
|
||||
setUnauthenticated() {
|
||||
clearToken();
|
||||
this.user = null;
|
||||
this.status = 'unauthenticated';
|
||||
}
|
||||
|
||||
/** Silent token refresh via a hidden iframe + postMessage. */
|
||||
trySilentRefresh(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = '/auth/silent-refresh';
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
iframe.remove();
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!event.data || event.data.type !== 'hold-slayer-refresh') return;
|
||||
cleanup();
|
||||
if (event.data.token) {
|
||||
setToken(event.data.token);
|
||||
this.scheduleTokenRefresh(event.data.token);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Proactively refresh 5 minutes before JWT expiry. PATs are skipped. */
|
||||
scheduleTokenRefresh(token: string) {
|
||||
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp * 1000;
|
||||
const refreshIn = Math.max(exp - Date.now() - 5 * 60 * 1000, 30 * 1000);
|
||||
this.refreshTimer = setTimeout(async () => {
|
||||
const ok = await this.trySilentRefresh();
|
||||
if (!ok) this.setUnauthenticated();
|
||||
}, refreshIn);
|
||||
} catch {
|
||||
// Not a JWT (e.g. a PAT) — no refresh needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-200 w-96 shadow-xl">
|
||||
<div class="card-body items-center gap-4 text-center">
|
||||
<h1 class="text-2xl font-bold">Not authorized</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
Hold Slayer is a single-operator gateway. You're signed in as
|
||||
<span class="font-medium">{auth.user?.display_name ?? auth.user?.name}</span>,
|
||||
but this gateway is reserved for its owner.
|
||||
</p>
|
||||
<a href="/auth/logout" class="btn btn-outline w-full">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
// A full-screen sign-in gate. The anchor is a real navigation to the
|
||||
// server-side /auth/login route (which 302s to Casdoor), not a fetch.
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-200 w-80 shadow-xl">
|
||||
<div class="card-body items-center gap-4 text-center">
|
||||
<h1 class="flex items-center justify-center gap-2 text-3xl font-bold">
|
||||
<span class="text-orange-500">🔥</span>
|
||||
Hold Slayer
|
||||
</h1>
|
||||
<p class="text-sm opacity-60">Sign in to the gateway</p>
|
||||
<a href="/auth/login" class="btn btn-primary w-full">Sign in with SSO</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { AccessToken } from '$lib/types';
|
||||
import { createToken, fetchTokens, revokeToken } from '$lib/api';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let tokens = $state<AccessToken[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
// The plaintext of a just-created token — shown once, never re-fetchable.
|
||||
let created = $state<AccessToken | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tokens = await fetchTokens();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
created = null;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
async function create() {
|
||||
if (!newName.trim()) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
try {
|
||||
created = await createToken(newName.trim());
|
||||
newName = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await revokeToken(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function mcpConfig(plaintext: string): string {
|
||||
const url = `${location.origin}/mcp`;
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
'hold-slayer': {
|
||||
type: 'streamable-http',
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${plaintext}` }
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
void navigator.clipboard.writeText(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">API Tokens</h3>
|
||||
<p class="py-1 text-sm opacity-60">
|
||||
Personal access tokens for MCP/CLI clients (Claude Desktop, Cline). The
|
||||
plaintext is shown once — store it now.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error my-2 text-sm">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if created?.token}
|
||||
<div class="alert alert-success my-3 flex-col items-start gap-2">
|
||||
<span class="font-medium">Token created — copy it now, it won't be shown again.</span>
|
||||
<code class="bg-base-300 w-full break-all rounded p-2 text-xs">{created.token}</code>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs" onclick={() => copy(created!.token!)}>Copy token</button>
|
||||
<button class="btn btn-xs" onclick={() => copy(mcpConfig(created!.token!))}
|
||||
>Copy MCP config</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-3 flex gap-2">
|
||||
<input
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Token name (e.g. Claude Desktop)"
|
||||
bind:value={newName}
|
||||
onkeydown={(e) => e.key === 'Enter' && create()}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={creating || !newName.trim()} onclick={create}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="py-4 text-center opacity-60">Loading…</div>
|
||||
{:else if tokens.length === 0}
|
||||
<div class="py-4 text-center opacity-60">No tokens yet.</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Prefix</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tokens as t (t.id)}
|
||||
<tr class:opacity-50={t.revoked_at}>
|
||||
<td>{t.name}</td>
|
||||
<td><code class="text-xs">{t.token_prefix}…</code></td>
|
||||
<td class="text-xs">{t.last_used_at?.slice(0, 10) ?? '—'}</td>
|
||||
<td class="text-right">
|
||||
{#if t.revoked_at}
|
||||
<span class="badge badge-ghost badge-sm">revoked</span>
|
||||
{:else}
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => revoke(t.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={() => (open = false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-backdrop" onclick={() => (open = false)} aria-label="Close"></button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,3 +1,23 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
// Present only in the create response — the plaintext, shown once.
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
name: string;
|
||||
version: string;
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import '../app.css';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import LoginScreen from '$lib/components/LoginScreen.svelte';
|
||||
import DeniedScreen from '$lib/components/DeniedScreen.svelte';
|
||||
import TokensModal from '$lib/components/TokensModal.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let tokensOpen = $state(false);
|
||||
|
||||
type ThemeOverride = 'dark' | 'light' | null;
|
||||
let override = $state<ThemeOverride>(null);
|
||||
let systemDark = $state(true);
|
||||
@@ -12,7 +18,10 @@
|
||||
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
||||
|
||||
$effect(() => {
|
||||
// Keep both theming systems in sync: Tailwind `dark:` variant (.dark class)
|
||||
// for the existing pages, and DaisyUI `data-theme` for the SSO components.
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
@@ -27,6 +36,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void auth.init();
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemDark = mq.matches;
|
||||
|
||||
@@ -49,40 +60,63 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
<header
|
||||
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-orange-500 text-lg leading-none">🔥</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
|
||||
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
|
||||
</div>
|
||||
<nav class="flex gap-1 ml-2">
|
||||
{#each nav as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
|
||||
item.href
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
{#if auth.status === 'loading'}
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-slate-50 dark:bg-gray-950">
|
||||
<span class="loading loading-spinner loading-lg text-orange-500"></span>
|
||||
</div>
|
||||
{:else if auth.status === 'unauthenticated'}
|
||||
<LoginScreen />
|
||||
{:else if auth.status === 'denied'}
|
||||
<DeniedScreen />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
<header
|
||||
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-orange-500 text-lg leading-none">🔥</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
|
||||
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
|
||||
</div>
|
||||
<nav class="flex gap-1 ml-2">
|
||||
{#each nav as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
|
||||
item.href
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm">
|
||||
{auth.user?.display_name ?? auth.user?.name ?? 'Owner'}
|
||||
</button>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-20 w-48 p-2 shadow">
|
||||
<li><button onclick={() => (tokensOpen = true)}>API Tokens</button></li>
|
||||
<li><a href="/auth/logout">Sign out</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<TokensModal bind:open={tokensOpen} />
|
||||
{/if}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -153,6 +154,48 @@ class RecordingRecord(Base):
|
||||
return f"<Recording {self.id} call={self.call_id} {self.path}>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""An SSO-provisioned identity. The gateway is owner-only: the single
|
||||
owner is the user whose `name` matches settings.owner_name; everyone else
|
||||
is created on first login but reaches nothing (403 on every surface)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True) # uuid4().hex, set in Python
|
||||
name = Column(String, nullable=False) # Casdoor username — owner-match key
|
||||
display_name = Column(String, nullable=True) # Casdoor display name (UI only)
|
||||
email = Column(String, nullable=True, unique=True)
|
||||
casdoor_sub = Column(String, nullable=True, unique=True) # OIDC subject claim
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.id} {self.name}>"
|
||||
|
||||
|
||||
class PersonalAccessToken(Base):
|
||||
"""Long-lived bearer token for API/MCP clients (Claude Desktop, Cline)
|
||||
that can't refresh a JWT. Plaintext is shown once at creation; only the
|
||||
SHA-256 hash is persisted. Soft-revoked by setting revoked_at."""
|
||||
|
||||
__tablename__ = "personal_access_tokens"
|
||||
|
||||
id = Column(String, primary_key=True) # uuid4().hex
|
||||
user_id = Column(
|
||||
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name = Column(String, nullable=False)
|
||||
token_hash = Column(String, nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String, nullable=False) # for display, not a secret
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PersonalAccessToken {self.id} user={self.user_id}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Engine & Session
|
||||
# ============================================================
|
||||
|
||||
55
db/migrations/versions/a1b2c3d4e5f6_users_and_pats.py
Normal file
55
db/migrations/versions/a1b2c3d4e5f6_users_and_pats.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""users and personal access tokens
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 5187577efc23
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '5187577efc23'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('display_name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('casdoor_sub', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('casdoor_sub')
|
||||
)
|
||||
op.create_table('personal_access_tokens',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('token_hash', sa.String(), nullable=False),
|
||||
sa.Column('token_prefix', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token_hash')
|
||||
)
|
||||
op.create_index(op.f('ix_personal_access_tokens_token_hash'), 'personal_access_tokens', ['token_hash'], unique=True)
|
||||
op.create_index(op.f('ix_personal_access_tokens_user_id'), 'personal_access_tokens', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_personal_access_tokens_user_id'), table_name='personal_access_tokens')
|
||||
op.drop_index(op.f('ix_personal_access_tokens_token_hash'), table_name='personal_access_tokens')
|
||||
op.drop_table('personal_access_tokens')
|
||||
op.drop_table('users')
|
||||
84
docker-compose.yaml
Normal file
84
docker-compose.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
# Local Hold Slayer stack: the single app image + its own PostgreSQL.
|
||||
#
|
||||
# Hold Slayer is ONE FastAPI process exposing REST/WS/MCP and serving its built
|
||||
# SvelteKit dashboard at "/" — no separate web/nginx service (the Dockerfile's
|
||||
# node stage builds the dashboard into the image).
|
||||
#
|
||||
# Auth is Casdoor SSO (owner-only). Because the published port binds the app to
|
||||
# 0.0.0.0, dev-owner mode (CASDOOR_ENABLED=false) is intentionally REFUSED at
|
||||
# startup here — that mode is loopback-only. So the stack expects the CASDOOR_*
|
||||
# + OWNER_NAME vars set (see .env.compose.example). MCP/CLI clients then use an
|
||||
# owner-minted PAT.
|
||||
#
|
||||
# cp .env.compose.example .env
|
||||
# # fill in CASDOOR_* + OWNER_NAME (+ HS_DB_PASSWORD)
|
||||
# docker compose up --build
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: ${HS_DB_USER:-holdslayer}
|
||||
POSTGRES_PASSWORD: ${HS_DB_PASSWORD:?set HS_DB_PASSWORD in .env}
|
||||
POSTGRES_DB: ${HS_DB_NAME:-holdslayer}
|
||||
volumes:
|
||||
- hs_pgdata:/var/lib/postgresql/data
|
||||
# json-file + Alloy docker-socket discovery is the estate pattern; no
|
||||
# syslog driver / 514xx listener (which would block container creation when
|
||||
# the listener is absent). See ouranos Rosalind/Virgo logging convention.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${HS_DB_USER:-holdslayer} -d ${HS_DB_NAME:-holdslayer}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Migrations run in the app's own init_db() on boot; it just needs to
|
||||
# reach the db service. asyncpg URL points at the compose service name.
|
||||
DATABASE_URL: postgresql+asyncpg://${HS_DB_USER:-holdslayer}:${HS_DB_PASSWORD}@db:5432/${HS_DB_NAME:-holdslayer}
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "21081"
|
||||
# Mock SIP: this stack is a dev/local deploy, not a real trunk. /health
|
||||
# honestly reports engine=mock as "degraded". Flip to false + fill the
|
||||
# SIP_TRUNK_* vars for a real-trunk deploy.
|
||||
USE_MOCK_SIP: ${USE_MOCK_SIP:-true}
|
||||
# --- Auth: Casdoor SSO (owner-only) ---
|
||||
CASDOOR_ENABLED: ${CASDOOR_ENABLED:-true}
|
||||
CASDOOR_ENDPOINT: ${CASDOOR_ENDPOINT:-https://id.ouranos.helu.ca}
|
||||
CASDOOR_CLIENT_ID: ${CASDOOR_CLIENT_ID}
|
||||
CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET}
|
||||
CASDOOR_ORG_NAME: ${CASDOOR_ORG_NAME:-heluca}
|
||||
CASDOOR_APP_NAME: ${CASDOOR_APP_NAME:-hold-slayer}
|
||||
OWNER_NAME: ${OWNER_NAME:?set OWNER_NAME (the owner's Casdoor username)}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||
ports:
|
||||
- "${HS_APP_PORT:-21081}:21081"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
# /health returns 200 even when "degraded" (mock engine / unregistered
|
||||
# trunk) — so a 200 means the process is up and serving, which is the
|
||||
# right liveness signal for a mock-SIP dev stack.
|
||||
test: ["CMD", "curl", "-f", "http://localhost:21081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hs_pgdata:
|
||||
@@ -8,6 +8,9 @@ Comprehensive documentation for the Hold Slayer AI telephony gateway.
|
||||
|----------|-------------|
|
||||
| [Architecture](architecture.md) | System architecture, component diagram, data flow |
|
||||
| [Core Engine](core-engine.md) | SIP engine, media pipeline, call manager, event bus |
|
||||
| [Dial Plan](dial-plan.md) | Number normalisation and the emergency-number guard |
|
||||
| [PJSUA2 Build](pjsua2-build.md) | Building the `pjsua2` bindings (not pip-installable) |
|
||||
| [Asterisk Lab](../tests/lab/README.md) | The fake PSTN used for media validation |
|
||||
| [Hold Slayer Service](hold-slayer-service.md) | IVR navigation, hold detection, human detection, transfer |
|
||||
| [Audio Classifier](audio-classifier.md) | Waveform analysis, feature extraction, classification logic |
|
||||
| [Services](services.md) | LLM client, transcription, recording, analytics, notifications |
|
||||
|
||||
@@ -11,7 +11,7 @@ Base URL: `http://localhost:8000/api`
|
||||
#### Place an Outbound Call
|
||||
|
||||
```
|
||||
POST /api/calls/outbound
|
||||
POST /api/v1/calls/outbound
|
||||
```
|
||||
|
||||
**Request:**
|
||||
@@ -53,7 +53,7 @@ POST /api/calls/outbound
|
||||
#### Launch Hold Slayer
|
||||
|
||||
```
|
||||
POST /api/calls/hold-slayer
|
||||
POST /api/v1/calls/hold-slayer
|
||||
```
|
||||
|
||||
Convenience endpoint — equivalent to `POST /outbound` with `mode=hold_slayer`.
|
||||
@@ -72,7 +72,7 @@ Convenience endpoint — equivalent to `POST /outbound` with `mode=hold_slayer`.
|
||||
#### Get Call Status
|
||||
|
||||
```
|
||||
GET /api/calls/{call_id}
|
||||
GET /api/v1/calls/{call_id}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -99,7 +99,7 @@ GET /api/calls/{call_id}
|
||||
#### List Active Calls
|
||||
|
||||
```
|
||||
GET /api/calls
|
||||
GET /api/v1/calls
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -117,13 +117,13 @@ GET /api/calls
|
||||
#### End a Call
|
||||
|
||||
```
|
||||
POST /api/calls/{call_id}/hangup
|
||||
POST /api/v1/calls/{call_id}/hangup
|
||||
```
|
||||
|
||||
#### Transfer a Call
|
||||
|
||||
```
|
||||
POST /api/calls/{call_id}/transfer
|
||||
POST /api/v1/calls/{call_id}/transfer
|
||||
```
|
||||
|
||||
**Request:**
|
||||
@@ -139,9 +139,9 @@ POST /api/calls/{call_id}/transfer
|
||||
#### List Call Flows
|
||||
|
||||
```
|
||||
GET /api/call-flows
|
||||
GET /api/call-flows?company=Chase+Bank
|
||||
GET /api/call-flows?tag=banking
|
||||
GET /api/v1/call-flows
|
||||
GET /api/v1/call-flows?company=Chase+Bank
|
||||
GET /api/v1/call-flows?tag=banking
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -166,7 +166,7 @@ GET /api/call-flows?tag=banking
|
||||
#### Get Call Flow
|
||||
|
||||
```
|
||||
GET /api/call-flows/{flow_id}
|
||||
GET /api/v1/call-flows/{flow_id}
|
||||
```
|
||||
|
||||
Returns the full call flow with all steps.
|
||||
@@ -174,7 +174,7 @@ Returns the full call flow with all steps.
|
||||
#### Create Call Flow
|
||||
|
||||
```
|
||||
POST /api/call-flows
|
||||
POST /api/v1/call-flows
|
||||
```
|
||||
|
||||
**Request:**
|
||||
@@ -197,13 +197,13 @@ POST /api/call-flows
|
||||
#### Update Call Flow
|
||||
|
||||
```
|
||||
PUT /api/call-flows/{flow_id}
|
||||
PUT /api/v1/call-flows/{flow_id}
|
||||
```
|
||||
|
||||
#### Delete Call Flow
|
||||
|
||||
```
|
||||
DELETE /api/call-flows/{flow_id}
|
||||
DELETE /api/v1/call-flows/{flow_id}
|
||||
```
|
||||
|
||||
### Devices
|
||||
@@ -211,7 +211,7 @@ DELETE /api/call-flows/{flow_id}
|
||||
#### List Registered Devices
|
||||
|
||||
```
|
||||
GET /api/devices
|
||||
GET /api/v1/devices
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -234,7 +234,7 @@ GET /api/devices
|
||||
#### Register a Device
|
||||
|
||||
```
|
||||
POST /api/devices
|
||||
POST /api/v1/devices
|
||||
```
|
||||
|
||||
**Request:**
|
||||
@@ -252,13 +252,13 @@ POST /api/devices
|
||||
#### Update Device
|
||||
|
||||
```
|
||||
PUT /api/devices/{device_id}
|
||||
PUT /api/v1/devices/{device_id}
|
||||
```
|
||||
|
||||
#### Remove Device
|
||||
|
||||
```
|
||||
DELETE /api/devices/{device_id}
|
||||
DELETE /api/v1/devices/{device_id}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
@@ -274,10 +274,21 @@ All errors follow a consistent format:
|
||||
| Status Code | Meaning |
|
||||
|-------------|---------|
|
||||
| `400` | Bad request (invalid parameters) |
|
||||
| `401` | Not authenticated (missing or invalid bearer token) |
|
||||
| `403` | Authenticated but not the owner |
|
||||
| `404` | Resource not found (call, flow, device) |
|
||||
| `409` | Conflict (call already ended, device already registered) |
|
||||
| `429` | Rate limited — `/auth/*` routes only. Carries `Retry-After` (seconds) |
|
||||
| `500` | Internal server error |
|
||||
|
||||
`429` applies solely to the unauthenticated `/auth/*` edge: those routes must
|
||||
answer before an identity exists, and each does real work (`/auth/callback`
|
||||
makes an outbound token exchange with Casdoor, `/auth/me` opens a DB session).
|
||||
The owner-gated API is not rate limited — it is already restricted to a single
|
||||
operator. Limits are per client, per route, in a fixed 60-second window; the
|
||||
client is the **socket peer**, so behind a reverse proxy the limit applies
|
||||
per-proxy. See [core/rate_limit.py](../core/rate_limit.py).
|
||||
|
||||
## WebSocket
|
||||
|
||||
### Event Stream
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Architecture
|
||||
|
||||
Hold Slayer is a single-process async Python application built on FastAPI. It acts as an intelligent B2BUA (Back-to-Back User Agent) sitting between your SIP trunk (PSTN access) and your desk phone/softphone.
|
||||
Hold Slayer is a single-process async Python application built on FastAPI. It
|
||||
acts as an intelligent B2BUA (Back-to-Back User Agent) sitting between your SIP
|
||||
trunk (PSTN access) and your desk phone/softphone.
|
||||
|
||||
> **Two SIP engines, selected by `SIP_ENGINE`.** `sippy` (the default) signals
|
||||
> only — PJSUA2 will not surface an RTP stream for a dialog it does not own, so
|
||||
> **no audio reaches the classifier** on that path. `pjsua2`
|
||||
> (`core/pjsua_engine.py`) places the call itself and is the only mode where
|
||||
> audio reaches the classifier; it is opt-in while being proven against the lab.
|
||||
> Read [Media plane: why PJSUA2 places the call](#media-plane-why-pjsua2-places-the-call)
|
||||
> before changing anything in `core/`.
|
||||
|
||||
## System Diagram
|
||||
|
||||
@@ -10,7 +20,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
||||
│ │ /api/* │ │ /ws/* │ │ (SSE) │ │ /dashboard │ │
|
||||
│ │ /api/v1/*│ │ /ws/* │ │ (HTTP) │ │ / │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴──────────────┴──────────────┴────┐ │
|
||||
@@ -27,8 +37,13 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
│ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌────┴──────────────┴───────────────────┐ │
|
||||
│ │ Sippy B2BUA Engine │ │
|
||||
│ │ (SIP calls, DTMF, conference bridge) │ │
|
||||
│ │ SIP Engine │ │
|
||||
│ │ signalling + call control │ │
|
||||
│ └────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────┴──────────────────────────────────┐ │
|
||||
│ │ Media Pipeline (PJSUA2) │ │
|
||||
│ │ RTP, conference bridge, taps, record │ │
|
||||
│ └────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└───────┼─────────────────────────────────────────────────────────┘
|
||||
@@ -46,7 +61,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
|-----------|------|----------|---------|
|
||||
| REST API | `api/calls.py`, `api/call_flows.py`, `api/devices.py` | HTTP | Call management, CRUD, configuration |
|
||||
| WebSocket | `api/websocket.py` | WS | Real-time event streaming to clients |
|
||||
| MCP Server | `mcp_server/server.py` | SSE | AI assistant tool integration |
|
||||
| MCP Server | `mcp_server/server.py` | Streamable HTTP at `/mcp/` | AI assistant tool integration |
|
||||
|
||||
### Orchestration Layer
|
||||
|
||||
@@ -70,23 +85,24 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Sippy Engine | `core/sippy_engine.py` | SIP signaling (INVITE, BYE, REGISTER, DTMF) |
|
||||
| Media Pipeline | `core/media_pipeline.py` | PJSUA2 RTP media handling, conference bridge, recording |
|
||||
| Sippy Engine | `core/sippy_engine.py` | SIP signalling (INVITE, BYE, REGISTER, DTMF) |
|
||||
| Media Pipeline | `core/media_pipeline.py` | PJSUA2 RTP media, conference bridge, taps, recording |
|
||||
| Recording | `services/recording.py` | WAV file management and storage |
|
||||
| Analytics | `services/call_analytics.py` | Call metrics, hold time stats, trends |
|
||||
| Notifications | `services/notification.py` | WebSocket + SMS alerts |
|
||||
| Database | `db/database.py` | SQLAlchemy async (PostgreSQL or SQLite) |
|
||||
| Database | `db/database.py` | SQLAlchemy async (PostgreSQL, Alembic migrations) |
|
||||
|
||||
## Data Flow — Hold Slayer Call
|
||||
|
||||
```
|
||||
1. User Request
|
||||
POST /api/calls/hold-slayer { number, intent, call_flow_id }
|
||||
POST /api/v1/calls/hold-slayer { number, intent, call_flow_id }
|
||||
│
|
||||
2. Gateway.make_call()
|
||||
├── CallManager.create_call() → track state
|
||||
├── SippyEngine.make_call() → SIP INVITE to trunk
|
||||
└── MediaPipeline.add_stream() → RTP media setup
|
||||
├── is_emergency_number() → REFUSE 911/112 (before anything else)
|
||||
├── concurrency cap check → refuse past max_concurrent_calls
|
||||
├── CallManager.create_call() → track state
|
||||
└── sip_engine.make_call() → place the call, media follows
|
||||
│
|
||||
3. HoldSlayer.run_with_flow() or run_exploration()
|
||||
├── AudioClassifier.classify() → analyze 3s audio windows
|
||||
@@ -99,14 +115,14 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
├── TranscriptionService.transcribe() → STT on speech audio
|
||||
│
|
||||
├── LLMClient.analyze_ivr_menu() → pick menu option (fallback)
|
||||
│ └── SippyEngine.send_dtmf() → press the button
|
||||
│ └── sip_engine.send_dtmf() → press the button
|
||||
│
|
||||
└── detect_hold_to_human_transition()
|
||||
└── HUMAN_DETECTED! → transfer
|
||||
│
|
||||
4. Transfer
|
||||
├── SippyEngine.bridge() → connect call legs
|
||||
├── MediaPipeline.bridge_streams() → bridge RTP
|
||||
├── SippyEngine.bridge_calls() → join the two call legs
|
||||
├── MediaPipeline.bridge_streams() → bridge RTP in the conf bridge
|
||||
├── EventBus.publish(TRANSFER_STARTED)
|
||||
└── NotificationService → "Pick up your phone!"
|
||||
│
|
||||
@@ -117,44 +133,95 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
→ Analytics tracking
|
||||
```
|
||||
|
||||
The emergency guard and the concurrency cap are the first two steps of
|
||||
`make_call` for a reason, and their order is load-bearing — see
|
||||
[.claude/rules/call-safety.md](../.claude/rules/call-safety.md).
|
||||
|
||||
## Threading Model
|
||||
|
||||
Hold Slayer is primarily single-threaded async (asyncio), with one exception:
|
||||
|
||||
- **Main thread**: FastAPI + all async services (event bus, hold slayer, classifier, etc.)
|
||||
- **Sippy thread**: Sippy B2BUA runs its own event loop in a dedicated daemon thread. The `SippyEngine` bridges async↔sync via `asyncio.run_in_executor()`.
|
||||
- **PJSUA2**: Runs in the main thread using null audio device (no sound card needed — headless server mode).
|
||||
The README's "single-process async" is a simplification. There are **three**
|
||||
execution contexts, and the boundaries between them are the highest-leverage
|
||||
invariant in the codebase.
|
||||
|
||||
```
|
||||
Main Thread (asyncio)
|
||||
├── FastAPI (uvicorn)
|
||||
├── EventBus
|
||||
├── CallManager
|
||||
├── HoldSlayer
|
||||
asyncio loop (main thread) Sippy ED thread PJSUA2 worker threads
|
||||
├── FastAPI (uvicorn) └── ED2 dispatcher └── media / RTP
|
||||
├── EventBus ├── SIP signalling └── onFrameReceived
|
||||
├── CallManager ├── UA objects
|
||||
├── HoldSlayer └── DTMF relay
|
||||
├── AudioClassifier
|
||||
├── TranscriptionService
|
||||
├── LLMClient
|
||||
├── MediaPipeline (PJSUA2)
|
||||
├── NotificationService
|
||||
└── RecordingService
|
||||
|
||||
Sippy Thread (daemon)
|
||||
└── Sippy B2BUA event loop
|
||||
├── SIP signaling
|
||||
├── DTMF relay
|
||||
└── Call leg management
|
||||
```
|
||||
|
||||
**Crossing the boundaries — one funnel each way:**
|
||||
|
||||
| Direction | Mechanism | Notes |
|
||||
|---|---|---|
|
||||
| Sippy ED → loop | `_post_from_ed` → `asyncio.run_coroutine_threadsafe` → `_on_engine_event` | The single funnel where Sippy-thread events mutate loop state |
|
||||
| loop → Sippy ED | `_run_on_sippy` → `ED2.callFromThread` | Anything touching a Sippy UA object |
|
||||
| PJSUA2 worker → loop | `AudioTap.feed` → `loop.call_soon_threadsafe` | The **only** thing a PJSUA2 callback may touch |
|
||||
|
||||
`onFrameReceived` runs on a PJSUA2 worker thread every 20 ms. It must call
|
||||
nothing but `AudioTap.feed`; reaching into pipeline state, the event bus, or a
|
||||
Sippy object from there is a data race. An exception escaping into PJSUA2's C++
|
||||
callback tears down the worker thread and silently kills media for every call,
|
||||
which is why the capture port catches and logs once rather than per frame.
|
||||
|
||||
Full detail: [.claude/rules/concurrency-threads.md](../.claude/rules/concurrency-threads.md).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why Sippy B2BUA + PJSUA2?
|
||||
### Media plane: why PJSUA2 places the call
|
||||
|
||||
We split SIP signaling and media handling into two separate libraries:
|
||||
The original split was *Sippy signals, PJSUA2 carries media*. It does not work,
|
||||
for a reason that is not obvious until you try it:
|
||||
|
||||
- **Sippy B2BUA** handles SIP signaling (INVITE, BYE, REGISTER, re-INVITE, DTMF relay). It's battle-tested for telephony and handles the complex SIP state machine.
|
||||
- **PJSUA2** handles RTP media (audio streams, conference bridge, recording, tone generation). It provides a clean C++/Python API for media manipulation without needing to deal with raw RTP.
|
||||
**PJSUA2 exposes no standalone RTP media object.** Every `AudioMedia` subclass
|
||||
in the Python bindings is a file player, recorder, tone generator, or capture
|
||||
port. RTP is reachable only through `pj.Call.getAudioMedia()`, after
|
||||
`onCallMediaState` fires on a dialog **PJSUA2 itself owns**. There is no
|
||||
"give me an AudioMedia for this remote host:port" API to call.
|
||||
|
||||
This split lets us tap into the audio stream (for classification and STT) without interfering with SIP signaling, and bridge calls through a conference bridge for clean transfer.
|
||||
So a design where Sippy owns the dialog can never obtain a media stream from
|
||||
PJSUA2. `MediaPipeline.add_remote_stream()` is not unfinished work — it is a
|
||||
function that cannot be written against this API. The consequence is that audio
|
||||
never reaches the classifier: `create_tap` builds a valid capture port with
|
||||
nothing to attach it to.
|
||||
|
||||
**The resolution: PJSUA2 places the call; Sippy keeps every other role.**
|
||||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Emergency guard, concurrency cap | `gateway.make_call` — unchanged, still first |
|
||||
| Trunk registration | PJSUA2 `Account` |
|
||||
| Outbound INVITE / answer / hangup | PJSUA2 `Call` |
|
||||
| RTP, conference bridge, taps, recording | PJSUA2 media |
|
||||
| DTMF | PJSUA2 `Call.dialDtmf` (RFC 2833) |
|
||||
| Device registration, routing, leg bridging | Sippy / gateway |
|
||||
| Inbound call dispatch | PJSUA2 `Account.onIncomingCall` |
|
||||
|
||||
Sippy remains the SBC-shaped layer — it is where device registrations, routing
|
||||
decisions and B2BUA leg-joining live. What moves is the raw dialog for a trunk
|
||||
call, because owning the dialog is the price of owning the media.
|
||||
|
||||
Alternatives considered and rejected:
|
||||
|
||||
- **Terminate RTP ourselves** (aiortc or raw sockets) and feed PCM into
|
||||
`AudioTap` directly, keeping Sippy on the wire. Preserves the split, but
|
||||
means owning jitter buffering, packet loss concealment and ulaw/alaw
|
||||
transcoding — precisely the work PJSUA2 exists to do.
|
||||
- **A loopback `pj.Call` mirroring each real leg**, so PJSUA2 has a dialog it
|
||||
owns. Avoids touching call placement, but adds a phantom call per real call
|
||||
and the SDP juggling is fragile.
|
||||
|
||||
> **Safety note for this refactor:** `is_emergency_number()` stays the first
|
||||
> check in `gateway.make_call`, above the concurrency cap and above any SIP
|
||||
> action, regardless of which library dials. A new outbound path that reaches
|
||||
> the SIP layer without passing that guard is a serious regression even if
|
||||
> every test passes.
|
||||
|
||||
### Why asyncio Queue-based EventBus?
|
||||
|
||||
@@ -164,11 +231,13 @@ This split lets us tap into the audio stream (for classification and STT) withou
|
||||
- **Dead subscriber cleanup** — full queues are automatically removed
|
||||
- **Event history** — late joiners can catch up on recent events
|
||||
|
||||
If scaling to multiple gateway processes becomes necessary, the EventBus interface can be backed by Redis pub/sub without changing consumers.
|
||||
If scaling to multiple gateway processes becomes necessary, the EventBus
|
||||
interface can be backed by Redis pub/sub without changing consumers.
|
||||
|
||||
### Why OpenAI-compatible LLM API?
|
||||
|
||||
The LLM client uses raw HTTP (httpx) against any OpenAI-compatible endpoint. This means:
|
||||
The LLM client uses raw HTTP (httpx) against any OpenAI-compatible endpoint.
|
||||
This means:
|
||||
|
||||
- **Ollama** (local, free) — `http://localhost:11434/v1`
|
||||
- **LM Studio** (local, free) — `http://localhost:1234/v1`
|
||||
@@ -176,3 +245,11 @@ The LLM client uses raw HTTP (httpx) against any OpenAI-compatible endpoint. Thi
|
||||
- **OpenAI** (cloud) — `https://api.openai.com/v1`
|
||||
|
||||
No SDK dependency. No vendor lock-in. Switch models by changing one env var.
|
||||
|
||||
## Testing against a fake PSTN
|
||||
|
||||
`tests/lab/` runs an Asterisk instance that answers calls, plays an IVR, holds
|
||||
with music and connects a "human" — so the gateway has something real to dial
|
||||
that is not the PSTN. `SIP_TRUNK_HOST` is just an address, so the production
|
||||
code path runs unmodified; while it points at the lab there is no route to the
|
||||
PSTN at all. See [tests/lab/README.md](../tests/lab/README.md).
|
||||
|
||||
293
docs/asterisk-lab-design.md
Normal file
293
docs/asterisk-lab-design.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Asterisk Lab — design
|
||||
|
||||
A **fake PSTN** for Hold Slayer: an Asterisk instance in Virgo Dev that answers
|
||||
calls, plays an IVR, holds you in a queue with music, and eventually connects a
|
||||
"human". It gives the gateway something real to dial that is not the PSTN — no
|
||||
charges, no strangers, no E911 exposure, and a *deterministic* script that makes
|
||||
classifier regressions reproducible.
|
||||
|
||||
Status: **design, not built.** Nothing here has been deployed.
|
||||
|
||||
---
|
||||
|
||||
## Why Asterisk and not Kamailio
|
||||
|
||||
Kamailio is a SIP **proxy** — it routes signaling and does not answer calls or
|
||||
handle media. The risks that remain unproven in Hold Slayer are mostly *media*
|
||||
risks: `send_dtmf` has only ever run against `MockSIPEngine` (a no-op), the
|
||||
audio classifier has never seen real RTP, and the PJSUA2 pipeline built in Phase
|
||||
1b has never carried a packet. A proxy forwards the INVITE and finds nobody
|
||||
home, so it exercises none of that.
|
||||
|
||||
Asterisk is a B2BUA: it answers, plays prompts, collects DTMF, and can hold a
|
||||
call in a queue with music. That is precisely the hold-slayer scenario, so it is
|
||||
the right primary target.
|
||||
|
||||
Kamailio still has a place — **later, and narrowly**. It models a real ITSP's
|
||||
registration/digest-auth behaviour better than Asterisk does, so it is the right
|
||||
tool for exercising `_register_trunk()` ([core/sippy_engine.py:495](../core/sippy_engine.py#L495))
|
||||
in isolation. It is deliberately *not* in scope for this lab.
|
||||
|
||||
```
|
||||
Phase 2/3 (this lab) Phase 4a (optional) Phase 4b
|
||||
Asterisk IVR + media → Kamailio registration → real PSTN, one call
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The one thing that makes this work without code changes
|
||||
|
||||
`SIP_TRUNK_HOST` is just an address. `SippyEngine.make_call()` builds
|
||||
`sip:{number}@{trunk_host}:{trunk_port}`
|
||||
([core/sippy_engine.py:568](../core/sippy_engine.py#L568)) and registers against
|
||||
whatever host it is given. Point it at Asterisk and Hold Slayer dials it exactly
|
||||
as it would dial a real provider.
|
||||
|
||||
**There is no test-only branch, no mock, no `if lab:` anywhere.** The code path
|
||||
under test is the production code path. That is the entire value of this
|
||||
approach — a lab that requires special-casing the application proves less than
|
||||
it costs.
|
||||
|
||||
It also means the safety story is structural: while `SIP_TRUNK_HOST` points at
|
||||
Asterisk on the Dev LAN, there is **no route to the PSTN at all**. Not a policy
|
||||
that could be misconfigured — an absence of route.
|
||||
|
||||
---
|
||||
|
||||
## Placement in Virgo
|
||||
|
||||
| Decision | Value | Why |
|
||||
|---|---|---|
|
||||
| Host | **nereid** (`10.0.1.214`) | Terraform describes it as "Experimental Apps (POC, testing new technologies)" — [terraform/incus/containers.tf](../../virgo/terraform/incus/containers.tf). An unauthenticated SIP endpoint is exactly experimental. |
|
||||
| Deploy | Docker Compose via Ansible | Matches every other Virgo service. `nereid` already has `docker = true`. |
|
||||
| Hostname | `asterisk.helu.ca` (internal) | LAN only. **No `*.d.helu.ca` HAProxy entry** — HAProxy is HTTP; SIP/RTP would not traverse it, and this must not be publicly reachable. |
|
||||
| Database | none | Asterisk needs no DB, so the "no databases in Docker" rule is not engaged. |
|
||||
|
||||
Hold Slayer itself stays on **triton** (`10.0.1.213`), where it is already
|
||||
deployed at port 21081. Splitting the two hosts is deliberate: SIP then crosses
|
||||
a real network with real latency, jitter and MTU, rather than a loopback that
|
||||
hides every transport problem.
|
||||
|
||||
### Ports
|
||||
|
||||
Project **210** is Hold Slayer's (existing: `hold_slayer_web_port: 21081`).
|
||||
Verified free: only `21081` and `29181` are allocated in that space today.
|
||||
|
||||
| Var | Port | Purpose |
|
||||
|---|---|---|
|
||||
| `asterisk_sip_port` | **21061** | SIP signalling (UDP). `2-10-6-1`: project 210, service 6 (SIP), instance 1 |
|
||||
| `asterisk_rtp_start` | **21100** | RTP media range start (UDP) |
|
||||
| `asterisk_rtp_end` | **21149** | RTP range end — 50 ports ≈ 25 concurrent calls, well above `max_concurrent_calls: 4` |
|
||||
| `asterisk_ari_port` | **21071** | ARI/HTTP management (service 7 = management) |
|
||||
| `asterisk_syslog_port` | **51462** | Docker syslog, `514YZ` convention (daedalus uses 51461) |
|
||||
|
||||
Service digit `6` for SIP is a **new allocation** — the existing scheme
|
||||
([docs/virgo.md](../../virgo/docs/virgo.md) §Port Numbering) defines 1/2/5/7/8/9
|
||||
and has no telephony digit. Worth confirming before it becomes precedent.
|
||||
|
||||
> **Note:** 5060 is *not* used. The convention forbids random ports, and using
|
||||
> the well-known SIP port invites scanner traffic. Nothing requires 5060 — both
|
||||
> ends are configured.
|
||||
|
||||
---
|
||||
|
||||
## Call scenarios
|
||||
|
||||
Each maps to a Hold Slayer behaviour that is currently unproven. Extensions are
|
||||
what Hold Slayer dials as `number`.
|
||||
|
||||
| Ext | Scenario | Proves |
|
||||
|---|---|---|
|
||||
| `1001` | **Immediate answer**, plays speech, hangs up after 30s | Baseline: INVITE→200→ACK→RTP→BYE, audio flows both ways, classifier reports `LIVE_HUMAN` |
|
||||
| `1002` | **IVR menu** — "press 1 for accounts, 2 for cards", branches on DTMF | `send_dtmf` genuinely emits RFC 2833 and Asterisk receives it. This is the big one — currently a no-op in the mock |
|
||||
| `1003` | **Hold music, then human** — 60s MoH, then answers | The whole hold-slayer loop: classify music → stay on hold → detect human → ring the owner |
|
||||
| `1004` | **Long hold** — 10 min MoH | `MAX_HOLD_TIME` and the hold-check interval |
|
||||
| `1005` | **Immediate busy** (`BUSY()`) | Failure path: call marked `FAILED`, no stuck leg |
|
||||
| `1006` | **Ring, never answer** | Timeout path |
|
||||
| `1007` | **Answer then hang up after 5s** | Remote-BYE handling, DB persistence on hangup |
|
||||
| `1008` | **Silence after answer** | Classifier `SILENCE` vs the 30s no-audio case |
|
||||
|
||||
`1002` and `1003` are the two that matter most; the rest are cheap to add once
|
||||
the dialplan exists.
|
||||
|
||||
### Classifier determinism
|
||||
|
||||
The reason for a scripted IVR rather than a real call: real hold music varies
|
||||
per call, so a classifier regression on the PSTN is indistinguishable from
|
||||
noise. Against a fixed prompt the answer is binary. Recommend a **fixed MoH
|
||||
file committed to the repo** rather than Asterisk's stock music, so the
|
||||
classifier's input is byte-identical on every run and across hosts.
|
||||
|
||||
This is what makes the Phase 0 music-vs-speech precedence fix
|
||||
([services/audio_classifier.py](../services/audio_classifier.py)) testable
|
||||
against real audio for the first time.
|
||||
|
||||
---
|
||||
|
||||
## Security — the part that needs a decision
|
||||
|
||||
Two unauthenticated SIP endpoints would exist on the Dev LAN.
|
||||
|
||||
**1. Asterisk.** Default `pjsip.conf` examples accept anonymous calls. This lab
|
||||
must not: `allow_anonymous_inbound = no`, an explicit endpoint for Hold Slayer
|
||||
with a password, and `permit=` limited to triton's address. Asterisk's default
|
||||
config is a well-known toll-fraud target and must not be shipped as-is.
|
||||
|
||||
**2. Hold Slayer's own SIP listener — pre-existing, flagged earlier.**
|
||||
`_handle_incoming_register()` replies `200 OK` to any REGISTER with **no digest
|
||||
challenge**. On loopback that was tolerable. The moment the gateway binds a LAN
|
||||
interface to talk to Asterisk, any host on the Dev LAN can register as a device
|
||||
and receive transferred calls.
|
||||
|
||||
That is not caused by this lab, but this lab is what makes it reachable. Options,
|
||||
in order of preference:
|
||||
|
||||
1. **Implement digest auth** on inbound REGISTER — the real fix
|
||||
2. **Bind the SIP listener to a specific interface** and firewall 5060 to triton
|
||||
↔ nereid only — mitigation, not a fix
|
||||
3. Accept it explicitly, in writing, as Dev-only
|
||||
|
||||
I would not deploy this without at least (2), and (1) is required before
|
||||
anything resembling production. **This needs your decision before build.**
|
||||
|
||||
Also note: `pjsua2` runs `--enable-shared` with TLS available, so SIP-TLS +
|
||||
SRTP is possible later. Not proposed for the lab — plain UDP keeps `sngrep`
|
||||
readable, which matters enormously when debugging signalling.
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
Match the estate rather than inventing:
|
||||
|
||||
- **Logs** — default `json-file` driver, discovered by the host Alloy Docker
|
||||
socket source and labelled `job=asterisk`. **No syslog listener and no Loki
|
||||
URL env** — that would double-ship, the same note already carried in
|
||||
[hold-slayer's compose template](../../virgo/ansible/hold-slayer/docker-compose.yml.j2).
|
||||
- **Health** — Asterisk has no HTTP health endpoint by default. Enable ARI on
|
||||
`21071` and probe `/ari/asterisk/info`, which is a genuine liveness signal
|
||||
(the SIP stack answers), unlike a bare TCP check.
|
||||
- **`sngrep`** on nereid for live SIP ladder inspection. Not currently installed
|
||||
anywhere; it is the single most useful tool when signalling misbehaves.
|
||||
|
||||
---
|
||||
|
||||
## What this does *not* prove
|
||||
|
||||
Stated plainly so the lab is not over-trusted:
|
||||
|
||||
- **Not real PSTN audio.** No G.711 transcoding artefacts, no packet loss, no
|
||||
jitter, no carrier-side DTMF mangling. Asterisk is clean; the PSTN is not.
|
||||
- **Not real IVR behaviour.** Our dialplan is what we imagine a bank sounds
|
||||
like. Real trees are longer, noisier, and interrupt.
|
||||
- **Not trunk registration/auth** — that is Kamailio's job (Phase 4a), or the
|
||||
real trunk's.
|
||||
- **Not carrier-specific quirks** — each ITSP has its own.
|
||||
|
||||
It proves the gateway's *own* logic end to end. That is the majority of the
|
||||
risk, and it is the part that is currently entirely untested against real media.
|
||||
|
||||
---
|
||||
|
||||
## Blocking prerequisite — the container runs stub media
|
||||
|
||||
**The Hold Slayer Docker image deliberately does not build PJSUA2**
|
||||
([docs/pjsua2-build.md](pjsua2-build.md)), so the deployed container on triton
|
||||
runs the media pipeline in **stub mode**. Stub mode's audio calls *return
|
||||
successfully while doing nothing*.
|
||||
|
||||
If the lab runs against the current image, every media test passes while proving
|
||||
nothing. This is the single most dangerous failure mode in the plan, because it
|
||||
looks like success.
|
||||
|
||||
Two options:
|
||||
|
||||
| Option | Effort | Trade-off |
|
||||
|---|---|---|
|
||||
| **A. Add a pjproject build stage to the Dockerfile** | Higher — multi-stage build, ~10 min build, larger image | The deployed artefact gains real media. Needed eventually regardless |
|
||||
| **B. Run Hold Slayer from a venv on triton for the lab** | Lower — the Phase 1b build already exists on caliban | Tests the binaries actually built, but diverges from the deployed artefact |
|
||||
|
||||
**Recommendation: A.** B tests something that is not what ships, and the
|
||||
Dockerfile needs this anyway before Hold Slayer can place a real call from a
|
||||
container. Doing it now means the lab validates the real artefact. B is a
|
||||
reasonable short-cut only if you want a fast first signal.
|
||||
|
||||
The existing deploy also has a **stale-config finding** — see below — that
|
||||
touches the same file, so both are worth doing in one pass.
|
||||
|
||||
---
|
||||
|
||||
## Finding: the deployed compose template is stale
|
||||
|
||||
Independent of this lab, [the deployed template](../../virgo/ansible/hold-slayer/docker-compose.yml.j2)
|
||||
sets `API_TOKEN`, which **no longer exists** — auth is now Casdoor SSO + PATs
|
||||
via one resolver. The comment "the app's single static bearer across REST/WS/MCP
|
||||
… required on 0.0.0.0" describes an auth model that was removed.
|
||||
|
||||
Live state confirms the service is up and `degraded`/`engine: mock` (correct and
|
||||
honest). Given `_check_startup_config` refuses SSO-off on a non-loopback bind, it
|
||||
is worth establishing how it is currently booting — most likely `CASDOOR_ENABLED`
|
||||
defaults such that the unknown `API_TOKEN` is simply ignored.
|
||||
|
||||
Flagging, not fixing — it is outside this design, but it lives in the file the
|
||||
lab will modify, and `hold_slayer_api_token` is still being pulled from the OCI
|
||||
vault for a variable the app no longer reads.
|
||||
|
||||
---
|
||||
|
||||
## Build order
|
||||
|
||||
Each step is independently verifiable; none commits you to the next.
|
||||
|
||||
1. **Decide** the two open questions: media (A or B), and SIP-listener security
|
||||
(digest / firewall / accept)
|
||||
2. **Dialplan + compose**, developed on caliban against a local Asterisk
|
||||
container — no Virgo changes yet, fastest iteration
|
||||
3. **Prove `1001`** locally: Hold Slayer places a call, audio flows, classifier
|
||||
sees `LIVE_HUMAN`. This is the real Phase 2 gate
|
||||
4. **Prove `1002`/`1003`** locally: DTMF lands, hold→human transition fires
|
||||
5. **Promote to Virgo** — Ansible role on nereid, `SIP_TRUNK_HOST=nereid.helu.ca`
|
||||
on triton, re-run 1–8 across the LAN
|
||||
6. **Only then** consider Kamailio (4a) or the PSTN (4b)
|
||||
|
||||
Steps 2–4 need no Virgo changes at all, which is worth exploiting: the dialplan
|
||||
is where the fiddly work is, and iterating locally is far faster than through
|
||||
Ansible.
|
||||
|
||||
---
|
||||
|
||||
## Files this would add
|
||||
|
||||
```
|
||||
hold-slayer/
|
||||
tests/lab/
|
||||
dialplan/extensions.conf # the 8 scenarios
|
||||
dialplan/pjsip.conf # endpoint for Hold Slayer, anonymous denied
|
||||
sounds/hold-music.wav # fixed MoH — deterministic classifier input
|
||||
docker-compose.lab.yml # local Asterisk for steps 2–4
|
||||
README.md # how to run the lab locally
|
||||
|
||||
virgo/
|
||||
ansible/asterisk/
|
||||
deploy.yml # mirrors ansible/hold-slayer/deploy.yml
|
||||
docker-compose.yml.j2
|
||||
extensions.conf.j2
|
||||
pjsip.conf.j2
|
||||
ansible/inventory/host_vars/nereid.helu.ca.yml # + asterisk_* vars, + service
|
||||
```
|
||||
|
||||
Dialplan lives in **hold-slayer**, not virgo: it is test fixture data that
|
||||
belongs with the code it tests, and step 2–4 iteration needs it locally.
|
||||
Ansible templates it out to nereid for step 5.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Media: A or B?** Determines whether step 5 tests the real artefact.
|
||||
2. **SIP listener security** — digest auth, firewall, or documented acceptance?
|
||||
Blocking for step 5, not for steps 2–4.
|
||||
3. **Is service digit `6` acceptable for SIP** in the 22XYZ scheme, or should
|
||||
telephony get a different digit? Sets estate precedent.
|
||||
4. **`asterisk.helu.ca` DNS** — needs an entry, or is `nereid.helu.ca` on the
|
||||
allocated port sufficient? (Simpler, and one less thing to maintain.)
|
||||
@@ -177,21 +177,21 @@ This handles:
|
||||
### List Call Flows
|
||||
|
||||
```
|
||||
GET /api/call-flows
|
||||
GET /api/call-flows?company=Chase+Bank
|
||||
GET /api/call-flows?tag=banking
|
||||
GET /api/v1/call-flows
|
||||
GET /api/v1/call-flows?company=Chase+Bank
|
||||
GET /api/v1/call-flows?tag=banking
|
||||
```
|
||||
|
||||
### Get Call Flow
|
||||
|
||||
```
|
||||
GET /api/call-flows/{flow_id}
|
||||
GET /api/v1/call-flows/{flow_id}
|
||||
```
|
||||
|
||||
### Create Call Flow
|
||||
|
||||
```
|
||||
POST /api/call-flows
|
||||
POST /api/v1/call-flows
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -205,7 +205,7 @@ Content-Type: application/json
|
||||
### Update Call Flow
|
||||
|
||||
```
|
||||
PUT /api/call-flows/{flow_id}
|
||||
PUT /api/v1/call-flows/{flow_id}
|
||||
Content-Type: application/json
|
||||
|
||||
{ ... updated flow ... }
|
||||
@@ -214,13 +214,13 @@ Content-Type: application/json
|
||||
### Delete Call Flow
|
||||
|
||||
```
|
||||
DELETE /api/call-flows/{flow_id}
|
||||
DELETE /api/v1/call-flows/{flow_id}
|
||||
```
|
||||
|
||||
### Learn Flow from Exploration
|
||||
|
||||
```
|
||||
POST /api/call-flows/learn
|
||||
POST /api/v1/call-flows/learn
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
|
||||
@@ -4,6 +4,25 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Auth (Casdoor SSO + owner)
|
||||
|
||||
The gateway is **owner-only**: the browser signs in via Casdoor (JWT), MCP/CLI
|
||||
clients use owner-minted PATs, and only `OWNER_NAME` may use any surface. With
|
||||
`CASDOOR_ENABLED=false` the gateway runs in dev-owner mode — permitted **only** on
|
||||
a loopback `HOST`. Startup refuses SSO-enabled-with-missing-config and
|
||||
SSO-disabled-off-loopback.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CASDOOR_ENABLED` | Enable Casdoor SSO | `false` | No |
|
||||
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` | If SSO on |
|
||||
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — | If SSO on |
|
||||
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — | If SSO on |
|
||||
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` | No |
|
||||
| `CASDOOR_APP_NAME` | Casdoor application name | — | No |
|
||||
| `OWNER_NAME` | Casdoor username of the single operator | — | If SSO on |
|
||||
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived) | — | No |
|
||||
|
||||
### SIP Trunk
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
@@ -15,14 +34,36 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
| `SIP_TRUNK_DID` | Your phone number (E.164) | — | Yes |
|
||||
| `SIP_TRUNK_TRANSPORT` | Transport protocol (`udp`, `tcp`, `tls`) | `udp` | No |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `HOST` | Bind address. Off-loopback requires `CASDOOR_ENABLED=true` | `0.0.0.0` | No |
|
||||
| `PORT` | Bind port | `8000` | No |
|
||||
| `DEBUG` | SQLAlchemy echo + uvicorn reload | `false` | No |
|
||||
| `LOG_LEVEL` | Root log level (`debug`/`info`/`warning`/`error`) | `info` | No |
|
||||
| `LOG_FORMAT` | `text` (human-readable) or `json` (structured, for Loki) | `text` | No |
|
||||
|
||||
`LOG_FORMAT=json` renders one JSON object per line, including uvicorn's access
|
||||
log — `method`, `path`, `status_code` (numeric, so it can be range-filtered) and
|
||||
`client_addr` arrive as queryable fields rather than a formatted string. The
|
||||
Docker image sets it; `text` is the default so local development stays readable.
|
||||
|
||||
### Safety
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` | No |
|
||||
| `USE_MOCK_SIP` | Run the mock SIP engine — no real calls. Must be asked for explicitly; an unconfigured trunk without it fails startup | `false` | No |
|
||||
| `SIP_ENGINE` | `sippy` (signalling only — no audio reaches the classifier) or `pjsua2` (call control + media) | `sippy` | No |
|
||||
|
||||
### Gateway
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `GATEWAY_SIP_PORT` | Port for device SIP registration | `5080` | No |
|
||||
| `GATEWAY_RTP_PORT_MIN` | Minimum RTP port | `10000` | No |
|
||||
| `GATEWAY_RTP_PORT_MAX` | Maximum RTP port | `20000` | No |
|
||||
| `GATEWAY_HOST` | Bind address | `0.0.0.0` | No |
|
||||
| `GATEWAY_SIP_HOST` | Bind address for the device-registration listener | `0.0.0.0` | No |
|
||||
| `GATEWAY_SIP_PORT` | Port for device SIP registration | `5060` | No |
|
||||
| `GATEWAY_SIP_DOMAIN` | SIP domain devices register against | `gateway.local` | No |
|
||||
|
||||
### LLM
|
||||
|
||||
@@ -46,7 +87,7 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `DATABASE_URL` | PostgreSQL or SQLite connection string | `sqlite+aiosqlite:///./hold_slayer.db` | No |
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Startup exits with a readable error if unset | — | Yes |
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -54,6 +95,18 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
|----------|-------------|---------|----------|
|
||||
| `NOTIFY_SMS_NUMBER` | Phone number for SMS alerts (E.164) | — | No |
|
||||
|
||||
### Receptionist
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `RECEPTIONIST_ENABLED` | Answer inbound calls with the AI receptionist | `true` | No |
|
||||
| `RECEPTIONIST_GREETING_TEMPLATE` | Spoken greeting | `"Hi, you've reached Robert's line. Who's calling, and what's this about?"` | No |
|
||||
| `RECEPTIONIST_MESSAGE_PROMPT` | Spoken prompt before recording a message | `"Please leave your message after the tone."` | No |
|
||||
| `RECEPTIONIST_LLM_PERSONA` | System prompt shaping the receptionist's decisions | See `config.py` | No |
|
||||
| `RECEPTIONIST_LISTEN_TIMEOUT_S` | Seconds to wait for the caller to speak | `15.0` | No |
|
||||
| `RECEPTIONIST_END_OF_UTTERANCE_SILENCE_S` | Silence marking the end of a turn | `1.2` | No |
|
||||
| `RECEPTIONIST_MESSAGE_MAX_SECONDS` | Voicemail cap | `90` | No |
|
||||
|
||||
### Audio Classifier
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
@@ -126,7 +179,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Use PostgreSQL instead of SQLite
|
||||
# PostgreSQL is required (no SQLite fallback)
|
||||
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/hold_slayer
|
||||
|
||||
# Use vLLM for faster inference
|
||||
|
||||
343
docs/deployment-validation-plan.md
Normal file
343
docs/deployment-validation-plan.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Hold Slayer — Deployment & Validation Plan
|
||||
|
||||
Bring the gateway up in stages, proving each layer before the one above it can
|
||||
lie about working. The ordering principle: **nothing touches the PSTN until
|
||||
everything that can be validated without it has been.** A bug found on the trunk
|
||||
costs money and rings a stranger's phone; the same bug found internally costs
|
||||
nothing.
|
||||
|
||||
Phases gate on each other. Do not start a phase until its predecessor's exit
|
||||
criteria are all green.
|
||||
|
||||
---
|
||||
|
||||
## Environment as found (2026-07-28)
|
||||
|
||||
Verified on `caliban`, not assumed:
|
||||
|
||||
| Fact | State | Consequence |
|
||||
|---|---|---|
|
||||
| `sippy` 2.3.0 | installed | SIP signaling is real |
|
||||
| `pjsua2` | ✅ **built 2026-07-28** (pjproject 2.17) | real media pipeline; see [pjsua2-build.md](pjsua2-build.md) |
|
||||
| `USE_MOCK_SIP` | `true` | engine is `MockSIPEngine`; `/health` can never be `healthy` |
|
||||
| `SIP_TRUNK_*` | placeholders (`sip.yourprovider.com`) | no trunk configured |
|
||||
| `SIP_TRUNK_DID` | `+16472474242` | real DID already allocated |
|
||||
| `GATEWAY_SIP_PORT` | `5060` | `.env.example` says 5080 — reconcile |
|
||||
| `DATABASE_URL` | `portia.incus:5432/hold_slayer` | remote Postgres, reachable? unverified |
|
||||
| Casdoor | enabled, `hold-slayer` app, `OWNER_NAME=r@helu.ca` | SSO path is configured |
|
||||
| `SPEACHES_URL` | `pan.helu.ca:22070` | not reachable from this host as tested |
|
||||
| `LLM_BASE_URL` | `nyx.helu.ca:29000` | not reachable from this host as tested |
|
||||
| `TTS_*` | unset → defaults to `localhost:8000` | **collides with the app's own port** — must be set |
|
||||
| `pytest` | 159 pass, 6 fail | 4 are `.env` bleed, 2 are real (see Phase 0) |
|
||||
|
||||
Two config landmines worth fixing before anything else: `TTS_BASE_URL` is unset
|
||||
and defaults to `http://localhost:8000`, which is Hold Slayer's own port — TTS
|
||||
calls would loop back into the gateway. And `API_TOKEN` still sits in `.env`
|
||||
though the code now uses Casdoor + PATs; it is dead weight that suggests the old
|
||||
auth path still exists.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Baseline: make the test suite an honest gate ✅ COMPLETE
|
||||
|
||||
**Done 2026-07-28.** Suite is 165 passed / 0 failed from the repo root with the
|
||||
real `.env` present. Outcomes differed from the plan's guesses in one important
|
||||
way: the classifier failure was a **real bug in `classify_chunk`**, not a
|
||||
threshold or fixture problem — see item 3. One item is deferred with rationale
|
||||
(`ruff`, below).
|
||||
|
||||
You cannot use a suite with known failures as a regression gate; every later
|
||||
phase's "did I break something" check depends on a clean baseline.
|
||||
|
||||
**Do:**
|
||||
1. Fix the `.env` bleed in `tests/test_oauth_metadata.py`. The 4 failures are
|
||||
entirely because pydantic reads the real `.env` and `PUBLIC_BASE_URL=http://localhost:21081`
|
||||
overrides the test's expected `http://test`. Confirmed: the same tests pass
|
||||
when run from a directory without `.env`. Fix by monkeypatching
|
||||
`get_settings().public_base_url = ""` in the `client` fixture, so tests derive
|
||||
the base URL from request headers as intended.
|
||||
2. Resolve `tests/test_hold_slayer.py::TestMockSIPEngine::test_trunk_status`.
|
||||
**The test is wrong, not the code.** It asserts the mock trunk reports
|
||||
`registered is True` after `start()`, but `MockSIPEngine.get_trunk_status()`
|
||||
deliberately returns `registered: False` with
|
||||
`reason: "No SIP trunk configured (mock mode)"` — which is exactly what the
|
||||
`/health`-tells-the-truth invariant requires. Update the test to assert
|
||||
`False` + the reason.
|
||||
3. Triage `test_audio_classifier.py::test_complex_tone_as_music`. **Outcome: the
|
||||
test was right and the code had a real bug.** Measured feature values for the
|
||||
C-major chord: `music_score` = **0.850**, comfortably above the 0.7
|
||||
`music_threshold` — music was detected confidently. But step 5's
|
||||
`if speech_score > music_score` ran first and short-circuited, because
|
||||
`speech_score` = **1.000**: the four speech bands are wide and overlapping
|
||||
(flatness 0.223 ∈ (0.1,0.5), centroid 852 ∈ (500,4000), ZCR 0.039 ∈
|
||||
(0.02,0.2), RMS 0.183 ∈ (0.01,0.5)) and a sustained musical chord satisfies
|
||||
all four trivially. Verified structural, not a one-off — C major, A minor and
|
||||
a bright chord *all* classified `live_human` despite scoring 0.85/0.65 music.
|
||||
|
||||
Fixed in `services/audio_classifier.py` by making the speech branch yield to a
|
||||
confident music score:
|
||||
`if speech_score > music_score and music_score < self.settings.music_threshold:`
|
||||
One line, both thresholds stay meaningful, scorers untouched. Chords now
|
||||
classify `music` (0.85) and all 18 classifier tests pass, so speech-like audio
|
||||
still classifies as speech.
|
||||
|
||||
**This was the plan's highest-value find:** the bug is exactly the operational
|
||||
failure that rings your desk for a hold queue. Worth re-validating against
|
||||
*real* hold music in Phase 2 — the synthetic "bright chord" case scores 0.65,
|
||||
below threshold, so it still reads as speech.
|
||||
|
||||
**Exit criteria:** `pytest tests/ -v` green from the repo root with the real
|
||||
`.env` present ✅ (165 passed). `ruff check .` clean — **deferred, see below**.
|
||||
|
||||
**Deferred: `ruff check .` reports 217 errors — all pre-existing.** Verified
|
||||
identical (217) with my changes stashed, so nothing here was introduced by
|
||||
Phase 0, and the three files I touched add none. The backlog is stylistic and
|
||||
repo-wide: 157 × `UP045` (`Optional[X]` → `X | None`), 18 × `F401` unused
|
||||
imports, 14 × `E501`, spread across `services/` (77), `models/` (75) and
|
||||
`core/` (49). Auto-fixing would rewrite annotations throughout `sippy_engine.py`
|
||||
and `media_pipeline.py` — the thread-boundary and media code that currently
|
||||
**cannot be exercised** (no PJSUA2, no trunk). That is a large unverifiable diff
|
||||
in the highest-risk files, unrelated to making the suite an honest gate. Recommend
|
||||
its own commit, ideally after Phase 1b restores the ability to actually run that
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1a — Internal signaling with a registered softphone (no audio)
|
||||
|
||||
Prove the SIP stack, registration, dial plan, and call lifecycle with zero
|
||||
media and zero PSTN. This is the phase that de-risks the most for the least.
|
||||
|
||||
**Config:**
|
||||
```
|
||||
USE_MOCK_SIP=false # real Sippy engine
|
||||
SIP_TRUNK_HOST= # blank — engine skips _register_trunk()
|
||||
GATEWAY_SIP_HOST=0.0.0.0
|
||||
GATEWAY_SIP_PORT=5060
|
||||
GATEWAY_SIP_DOMAIN=voip.helu.ca
|
||||
```
|
||||
Leaving `SIP_TRUNK_HOST` blank is deliberate and load-bearing: `SippyEngine.start()`
|
||||
only calls `_register_trunk()` `if self._trunk_host`, so the gateway comes up
|
||||
listening for devices with **no possible path to the PSTN**. That is the
|
||||
strongest safety guarantee available in this phase — not policy, but absence of
|
||||
a route.
|
||||
|
||||
**Setup:**
|
||||
1. Register a softphone (Linphone or Zoiper on a laptop; Groundwire on mobile)
|
||||
to `<caliban-ip>:5060`, any username, domain `voip.helu.ca`.
|
||||
**Expect registration to succeed with any credentials** — see the security
|
||||
finding below.
|
||||
2. Confirm the device appears: `GET /api/v1/devices` and the `list_devices` MCP
|
||||
tool, plus the `📱 SIP REGISTER` line in the log.
|
||||
3. Create a `Device` row for it (`type: sip_phone`, `sip_uri` matching the
|
||||
registered contact) so `call_device()` can target it.
|
||||
|
||||
**Validate:**
|
||||
- REGISTER lands, appears in `_registered_devices`, refreshes on re-REGISTER,
|
||||
and disappears on `expires=0` (hang up / unregister in the softphone).
|
||||
- Inbound INVITE from the softphone to the gateway surfaces an `incoming_invite`
|
||||
event and routes through the receptionist path.
|
||||
- `gateway.call_device()` toward the softphone makes it **ring** — this
|
||||
exercises `_call_sip_device`, SDP generation, and the loop→Sippy funnel.
|
||||
- Answer, then hang up from each end in turn; confirm `terminated` propagates
|
||||
and the leg is cleaned from `_legs`.
|
||||
- DTMF from the softphone appears in the log (note: received DTMF currently has
|
||||
no consumer — it logs and stops).
|
||||
- `/health` reports `degraded` with `sip_trunk.registered: false`. **This is
|
||||
correct** — do not chase a green light here.
|
||||
|
||||
**Security finding to address in this phase:** `_handle_incoming_register()`
|
||||
replies `200 OK` to any REGISTER with no authentication challenge — no 401 with
|
||||
a nonce, no digest verification. Any host that can reach port 5060 can register
|
||||
as any AOR and become a transfer target. On a LAN-only bind for this phase
|
||||
that's tolerable; **before Phase 3 exposes the gateway to a trunk it is not.**
|
||||
Either add digest auth to the REGISTER path or firewall 5060 to known device
|
||||
IPs. Flagging rather than folding in — it's a real change to the SIP path and
|
||||
your call how to scope it.
|
||||
|
||||
**Exit criteria:** softphone registers, rings on `call_device`, both-direction
|
||||
teardown is clean, no leaked legs after 10 call cycles, suite still green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1b — Gate: build PJSUA2 ✅ COMPLETE
|
||||
|
||||
**Done 2026-07-28.** Built out of order (ahead of Phase 1a) because nothing
|
||||
useful works without it. pjproject **2.17** — newer than expected, with Python
|
||||
3.13 and modern-gcc support already upstream, so no patching was needed.
|
||||
|
||||
`MediaPipeline.status()` now reports **`pjsua2_available: True`**; the stub-mode
|
||||
warning is gone and the pipeline starts and stops cleanly at 16 kHz. Full
|
||||
procedure recorded in **[pjsua2-build.md](pjsua2-build.md)**; README note now
|
||||
points at it.
|
||||
|
||||
Notes worth carrying forward:
|
||||
|
||||
- **No `sudo` was needed.** Both missing tools (`swig`, `patchelf`) have PyPI
|
||||
wheels and installed into the venv, so nothing on the host changed outside
|
||||
`~/src` and `~/.local`.
|
||||
- **The RPATH step is the non-obvious part.** The bindings compile and install
|
||||
cleanly and then fail at import with
|
||||
`ImportError: libpjsua2.so.2: cannot open shared object file`, because
|
||||
`~/.local/lib` isn't on the loader path. Fixed with `patchelf --set-rpath` on
|
||||
both the extension **and** all 12 `libpj*.so.2` libraries — patching only the
|
||||
extension just surfaces the transitive deps one layer down. Chosen over
|
||||
`LD_LIBRARY_PATH` because that would have to be set for uvicorn, systemd, cron
|
||||
and every subprocess, and it fails at call time rather than startup. Verified
|
||||
under `env -i` from `/`, so it depends on no inherited environment.
|
||||
- **Configure found OpenSSL, ALSA and Opus** — the full codec/TLS surface.
|
||||
- **165 tests still pass.** All `pjsua2` imports in the codebase are lazy
|
||||
(inside functions), so the suite still runs without touching real media.
|
||||
|
||||
**Exit criteria:** `import pjsua2` succeeds in the venv ✅; `MediaPipeline`
|
||||
reports `pjsua2_available: true` ✅ (note the method is `status()`, not
|
||||
`get_status()` as this plan originally said).
|
||||
|
||||
**Two caveats this build does not solve:**
|
||||
|
||||
1. **Not captured by `pip install -e ".[dev]"`.** It lives outside Python
|
||||
packaging metadata — a fresh venv, new host or rebuilt container needs it
|
||||
repeated. Host provisioning, not a dependency.
|
||||
2. **The Docker image still runs stub media** — the Dockerfile deliberately
|
||||
skips this build. So the Phase 2 audio validation must run **outside the
|
||||
container**, or the Dockerfile needs a pjproject build stage. Worth deciding
|
||||
before Phase 2, since it determines where you test.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Audio, TTS, STT, and the classifier on internal calls
|
||||
|
||||
Now the softphone is a full test instrument: you can speak into the gateway and
|
||||
hear it speak back, with no telephony charges.
|
||||
|
||||
**Config to fix first:**
|
||||
```
|
||||
TTS_BASE_URL=<real Rhema endpoint> # NOT localhost:8000 — that's this app
|
||||
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
SPEACHES_URL=http://pan.helu.ca:22070
|
||||
```
|
||||
|
||||
**Validate leaf services standalone before wiring them into a call** — a failure
|
||||
here is much easier to read outside the call path:
|
||||
- `TTSService.synthesize()` returns PCM directly (a short script against the
|
||||
service). Confirm `service.available` flips to `True` and `/health` reports
|
||||
`tts: ok`.
|
||||
- `TranscriptionService.transcribe()` against a known WAV returns expected text;
|
||||
`/health` reports `stt: ok`.
|
||||
- Both are reachable **from wherever the gateway actually runs** — neither
|
||||
`pan.helu.ca:22070` nor `nyx.helu.ca:29000` answered from this host during
|
||||
assessment. Resolve that before blaming the call path.
|
||||
- LLM: `nyx.helu.ca:29000` with `Qwen3.6-35B-A3B-UD-Q4_K_XL`. Note the URL has
|
||||
no `/v1` suffix while the default does — verify which the client expects.
|
||||
|
||||
**Then in-call, softphone ↔ gateway only:**
|
||||
- Receptionist answers an inbound call from the softphone: you hear the TTS
|
||||
greeting, speak, and your speech is transcribed. This single test exercises
|
||||
TTS + STT + LLM + media in the real path.
|
||||
- Recording writes a real file to `recordings/` with actual audio in it (check
|
||||
the file plays, not just that it exists — the stub path created files too).
|
||||
- Classifier: play hold music from a phone/laptop into the call and confirm
|
||||
`MUSIC`; speak and confirm `LIVE_HUMAN`. This is the ground truth that
|
||||
Phase 0's failing classifier test was gesturing at.
|
||||
- Transcript persists to the DB on hangup and is readable via
|
||||
`get_call_transcript`.
|
||||
|
||||
**Exit criteria:** a full receptionist conversation over the softphone with
|
||||
audible TTS, accurate STT, correct classification, a playable recording, and a
|
||||
persisted transcript.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — MCP integration
|
||||
|
||||
Independent of audio, so it can run in parallel with 1b/2 if convenient.
|
||||
|
||||
- Mint a PAT via `api/tokens.py`, confirm `hs_pat_…` format.
|
||||
- Connect an MCP client to `https://<host>/mcp/` and confirm OAuth discovery
|
||||
(`/.well-known/oauth-protected-resource`) resolves against the real
|
||||
`PUBLIC_BASE_URL`.
|
||||
- Read-only tools first: `gateway_status`, `list_active_calls`, `list_devices`,
|
||||
`search_call_history`, and the three resources.
|
||||
- **Verify the auth boundary negatively**: a non-owner identity and a bogus PAT
|
||||
both get 403/401 on `/mcp/`, REST, and WS. Owner-only is an invariant; prove
|
||||
it rather than assuming it.
|
||||
- `make_call` against the softphone (internal, no trunk) — confirms the MCP
|
||||
path reaches the gateway.
|
||||
- **Test the emergency guard through MCP specifically**: `make_call("911")`,
|
||||
`"9911"`, `"112"`, `"+1911"`, and with whitespace/dashes must every one raise
|
||||
`ToolError` before any SIP action. Do this while `SIP_TRUNK_HOST` is still
|
||||
blank, so a guard failure cannot become an actual emergency call. **This is
|
||||
the single most important test in the plan** — run it before Phase 4, never
|
||||
after.
|
||||
|
||||
**Exit criteria:** all read tools return sane data, `make_call` rings the
|
||||
softphone, every emergency variant is refused, non-owner is refused.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — SIP trunk (first PSTN contact)
|
||||
|
||||
Only now does real money and a real network get involved.
|
||||
|
||||
1. Set `SIP_TRUNK_HOST/PORT/USERNAME/PASSWORD/TRANSPORT` for the provider; keep
|
||||
`SIP_TRUNK_DID=+16472474242`.
|
||||
2. Restart and watch registration: `_register_trunk()` posts `trunk_registered`,
|
||||
`/health` should flip to **`healthy`** — real engine + registered trunk +
|
||||
reachable DB. This is the first moment a green `/health` is meaningful.
|
||||
3. **Re-run the entire emergency-guard suite from Phase 3** now that a real
|
||||
route exists. The guard is what stands between an AI agent and a 911
|
||||
dispatcher with no E911 location attached.
|
||||
4. First outbound call: dial **your own mobile**, nothing else. Confirm ring,
|
||||
answer, two-way audio, clean teardown, DB persistence.
|
||||
5. First inbound: call the DID from your mobile, receptionist answers, routing
|
||||
and transfer-to-softphone work.
|
||||
6. Then a real hold scenario against a known IVR with a long queue.
|
||||
|
||||
**Safety rails while validating:**
|
||||
- Drop `MAX_CONCURRENT_CALLS` to `1` for first calls; restore to 4 after.
|
||||
- Keep `MAX_HOLD_TIME` low initially — 7200s is two hours of trunk time if
|
||||
something wedges.
|
||||
- Watch the provider's billing/CDR page live during the first calls.
|
||||
- Have the provider portal open to kill calls out-of-band.
|
||||
|
||||
**Exit criteria:** `/health` genuinely `healthy`, one successful outbound to a
|
||||
known number, one inbound handled, CDRs match expectations, no orphaned legs.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting: `.env.example` drift ✅ FIXED (Phase 0)
|
||||
|
||||
`.env.example` had documented the removed `API_TOKEN` as the auth mechanism and
|
||||
omitted every `CASDOOR_*` var, `OWNER_NAME`, `PUBLIC_BASE_URL`, and the whole
|
||||
`TTS_*` block. Rewritten to match `config.py`: dropped `API_TOKEN`, added the
|
||||
Casdoor/auth block (documenting both supported configurations and why SSO-off
|
||||
off-loopback is refused), the full `TTS_*` and `RECEPTIONIST_*` blocks, and
|
||||
reconciled `GATEWAY_SIP_PORT` to 5060 to match `.env`.
|
||||
|
||||
`TTS_BASE_URL` now defaults to `localhost:8001` in the template with a comment
|
||||
warning that it must not equal the app's own `PORT` — this was blocker #3, and
|
||||
the template previously would have reproduced it for any fresh checkout.
|
||||
|
||||
Validated by copying the template to a clean directory and parsing it through
|
||||
`Settings()`: every sub-config loads, and `tts.base_url != localhost:{port}` is
|
||||
asserted. `grep` confirms no `API_TOKEN` references remain in code, templates or
|
||||
docs. The README config table was already current — no drift there.
|
||||
|
||||
---
|
||||
|
||||
## Summary of blockers found
|
||||
|
||||
| # | Blocker | Blocks | Severity |
|
||||
|---|---|---|---|
|
||||
| 1 | PJSUA2 not installed | all audio: TTS/STT/recording/classifier in-call | ✅ **fixed** — pjproject 2.17 built, `pjsua2_available: True` |
|
||||
| 2 | REGISTER accepts any credentials, no digest auth | safe exposure of port 5060 | **security** |
|
||||
| 3 | `TTS_BASE_URL` unset → defaults to app's own port | TTS entirely | config — ✅ fixed in template; **still set it in your real `.env`** |
|
||||
| 4 | STT/LLM endpoints unreachable as tested | Phase 2 | environment |
|
||||
| 5 | 6 failing tests (2 real, 4 `.env` bleed) | honest regression gate | ✅ **fixed** — 165 pass |
|
||||
| 6 | `.env.example` documents removed `API_TOKEN` auth | fresh-environment test | ✅ **fixed** |
|
||||
| 7 | Hold music classified as `LIVE_HUMAN` (found during Phase 0) | correct hold detection | ✅ **fixed** — real bug, see Phase 0 item 3 |
|
||||
|
||||
**Note on #3:** the fix landed in `.env.example` (the template). Your live
|
||||
`.env` still has no `TTS_*` block at all, so TTS resolves to the
|
||||
`localhost:8000` default and collides with the app. Set `TTS_BASE_URL` before
|
||||
Phase 2.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.13+
|
||||
- Python 3.12+
|
||||
- Ollama (or any OpenAI-compatible LLM) — for IVR menu analysis
|
||||
- Speaches or Whisper API — for speech-to-text (optional for dev)
|
||||
- A SIP trunk account — for making real calls (optional for dev)
|
||||
|
||||
@@ -34,7 +34,7 @@ All routing is pattern-matched in order; the first match wins.
|
||||
## 2XX — Endpoint Extensions
|
||||
|
||||
Extensions are auto-assigned from **221** upward when a SIP device
|
||||
registers (`SIP REGISTER`) with the gateway or via `POST /api/devices`.
|
||||
registers (`SIP REGISTER`) with the gateway or via `POST /api/v1/devices`.
|
||||
|
||||
| Extension | Format | Example |
|
||||
|-----------|---------------------------------|--------------------------------|
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
# MCP Server
|
||||
|
||||
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp), it exposes tools and resources over SSE.
|
||||
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant
|
||||
control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp),
|
||||
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
|
||||
**streamable HTTP** and authenticates with an owner-minted Personal Access Token
|
||||
(`hs_pat_…`) — the same owner-only auth as the REST API and WebSocket. Auth is
|
||||
enforced by an ASGI guard (`_owner_only_mcp` in `main.py`) that resolves the
|
||||
bearer to the owner; a Casdoor JWT also works, but MCP clients can't refresh one,
|
||||
so a PAT is the intended credential.
|
||||
|
||||
## Overview
|
||||
|
||||
An AI assistant connects via SSE to the MCP server and gains access to tools for placing calls, checking status, sending DTMF, getting transcripts, and managing call flows. The assistant can orchestrate an entire call through natural language.
|
||||
An AI assistant connects to the MCP endpoint and gains access to 15 tools and
|
||||
3 resources for placing calls, checking status, sending DTMF, getting
|
||||
transcripts, and managing call flows. The assistant can orchestrate an entire
|
||||
call through natural language.
|
||||
|
||||
`make_call` places a **real PSTN call** that may incur charges; emergency
|
||||
numbers are always refused, and the concurrent-call cap applies.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -15,13 +28,32 @@ Place an outbound call through the SIP trunk.
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `number` | string | Yes | Phone number to call (E.164 format) |
|
||||
| `mode` | string | No | Call mode: `direct`, `hold_slayer`, `ai_assisted` (default: `hold_slayer`) |
|
||||
| `mode` | string | No | `direct`, `hold_slayer`, or `ai_assisted` (default: `direct`) |
|
||||
| `intent` | string | No | What you want to accomplish on the call |
|
||||
| `call_flow_id` | string | No | ID of a stored call flow to follow |
|
||||
| `device` | string | No | Device to transfer to when a human is detected |
|
||||
|
||||
Returns: Call ID and initial status.
|
||||
Returns: call ID and initial status.
|
||||
|
||||
### end_call
|
||||
### get_call_status
|
||||
|
||||
Check the current state of a call — status, duration, hold time, current
|
||||
audio classification, recent transcript.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to check |
|
||||
|
||||
### transfer_call
|
||||
|
||||
Transfer an active call to a registered device.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to transfer |
|
||||
| `device` | string | Yes | Device ID or type to ring |
|
||||
|
||||
### hangup
|
||||
|
||||
Hang up an active call.
|
||||
|
||||
@@ -29,102 +61,117 @@ Hang up an active call.
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to hang up |
|
||||
|
||||
### send_dtmf
|
||||
|
||||
Send touch-tone digits to an active call (for manual IVR navigation).
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to send digits to |
|
||||
| `digits` | string | Yes | DTMF digits to send (e.g., "1", "3#", "1234") |
|
||||
|
||||
### get_call_status
|
||||
|
||||
Check the current state of a call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to check |
|
||||
|
||||
Returns: Status, duration, hold time, audio classification, transcript excerpt.
|
||||
|
||||
### get_call_transcript
|
||||
|
||||
Get the live transcript of a call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to get transcript for |
|
||||
|
||||
Returns: Array of transcript chunks with timestamps and speaker labels.
|
||||
|
||||
### get_call_recording
|
||||
|
||||
Get recording metadata and file path for a call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to get recording for |
|
||||
|
||||
Returns: Recording path, duration, file size.
|
||||
|
||||
### list_active_calls
|
||||
|
||||
List all calls currently in progress. No parameters.
|
||||
|
||||
Returns: Array of active calls with status, number, duration.
|
||||
### send_dtmf
|
||||
|
||||
Send touch-tone digits on an active call (manual IVR navigation).
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to send digits on |
|
||||
| `digits` | string | Yes | DTMF digits (e.g., `"1"`, `"123#"`) |
|
||||
|
||||
### get_call_transcript
|
||||
|
||||
Get the full transcript of an active call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to get the transcript for |
|
||||
|
||||
### get_call_recording
|
||||
|
||||
Get recording metadata (path, duration) for a persisted call.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to look up |
|
||||
|
||||
### get_call_summary
|
||||
|
||||
Get analytics summary — hold times, success rates, call volume. No parameters.
|
||||
Stored summary, action items, and sentiment for a persisted call.
|
||||
|
||||
Returns: Aggregate statistics across all calls.
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The call to look up |
|
||||
|
||||
### search_call_history
|
||||
|
||||
Search past calls by number, company, or date range.
|
||||
Search past call records.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `query` | string | Yes | Search term (phone number, company name) |
|
||||
| `limit` | int | No | Max results (default: 20) |
|
||||
| `phone_number` | string | No | Filter by phone number (partial match) |
|
||||
| `intent` | string | No | Filter by intent text (partial match) |
|
||||
| `limit` | int | No | Max results (default: 10) |
|
||||
|
||||
### get_call_flow
|
||||
|
||||
Look up the stored IVR call flow for a phone number.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `phone_number` | string | Yes | Number to look up (E.164) |
|
||||
|
||||
### create_call_flow
|
||||
|
||||
Store a new IVR call flow by hand.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | Yes | Human-readable name |
|
||||
| `phone_number` | string | Yes | Phone number (E.164) |
|
||||
| `steps_json` | string | Yes | JSON array of call flow steps |
|
||||
| `notes` | string | No | General notes |
|
||||
|
||||
### learn_call_flow
|
||||
|
||||
Build a reusable call flow from a completed exploration call.
|
||||
Build (or refine) a reusable IVR call flow from a completed hold-slayer
|
||||
exploration call. Exploration calls record every IVR prompt heard and DTMF
|
||||
sent; this turns those discoveries into a stored flow so the next call
|
||||
navigates directly.
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `call_id` | string | Yes | The exploration call to learn from |
|
||||
| `company` | string | No | Company name for the flow |
|
||||
| `call_id` | string | Yes | A completed call that ran in exploration mode |
|
||||
| `company_name` | string | No | Company name for labeling a new flow |
|
||||
|
||||
Returns: The generated CallFlow object.
|
||||
### list_devices
|
||||
|
||||
List registered devices and their online/offline status. No parameters.
|
||||
|
||||
### gateway_status
|
||||
|
||||
Trunk registration, device count, active calls, engine mode. No parameters.
|
||||
|
||||
## Resources
|
||||
|
||||
MCP resources provide read-only data that assistants can reference:
|
||||
|
||||
| Resource URI | Description |
|
||||
|-------------|-------------|
|
||||
| `gateway://status` | Current gateway status — trunk registration, active calls, service health |
|
||||
| `gateway://calls` | List of all active calls with current status |
|
||||
| `gateway://calls/{call_id}` | Detailed status for a specific call |
|
||||
| `gateway://flows` | List of all stored call flows |
|
||||
| `gateway://analytics` | Call analytics summary |
|
||||
| `gateway://status` | Current gateway status — trunk registration, active calls |
|
||||
| `gateway://call-flows` | List of all stored call flows |
|
||||
| `gateway://active-calls` | All active calls with current status |
|
||||
|
||||
## Configuration
|
||||
## Connecting an AI Assistant
|
||||
|
||||
The MCP server is mounted on the FastAPI app at `/mcp`.
|
||||
Claude Code:
|
||||
|
||||
### Connecting an AI Assistant
|
||||
```bash
|
||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||
--header "Authorization: Bearer hs_pat_..."
|
||||
```
|
||||
|
||||
Add to your MCP client configuration (e.g., Claude Desktop, Cline):
|
||||
Generic MCP client configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hold-slayer": {
|
||||
"url": "http://localhost:8000/mcp/sse"
|
||||
"url": "http://localhost:8000/mcp/",
|
||||
"headers": {"Authorization": "Bearer hs_pat_..."}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +179,6 @@ Add to your MCP client configuration (e.g., Claude Desktop, Cline):
|
||||
|
||||
## Example Conversation
|
||||
|
||||
Here is how an AI assistant would use the MCP tools to handle a complete call:
|
||||
|
||||
**User:** "Call Chase Bank and dispute the Amazon charge from December 15th"
|
||||
|
||||
**Assistant actions:**
|
||||
@@ -141,15 +186,8 @@ Here is how an AI assistant would use the MCP tools to handle a complete call:
|
||||
1. Calls `make_call(number="+18005551234", mode="hold_slayer", intent="dispute Amazon charge Dec 15th", call_flow_id="chase-bank-main")`
|
||||
2. Receives `call_id: "call_abc123"`
|
||||
3. Polls `get_call_status("call_abc123")` periodically
|
||||
4. Status progression: `trying` → `ringing` → `connected` → `on_hold`
|
||||
4. Status progression: `initiating` → `ringing` → `connected` → `on_hold`
|
||||
5. Tells user: "I'm on hold with Chase Bank. Currently 4 minutes in. I'll let you know when someone picks up."
|
||||
6. Status changes to `transferring` — human detected!
|
||||
7. Tells user: "A live agent just picked up. I'm transferring the call to your desk phone now. Pick up!"
|
||||
8. After the call, calls `learn_call_flow("call_abc123", company="Chase Bank")` to save the IVR path for next time.
|
||||
|
||||
**User:** "How long was I on hold?"
|
||||
|
||||
**Assistant actions:**
|
||||
|
||||
1. Calls `get_call_summary()`
|
||||
2. Reports: "Your Chase Bank call lasted 12 minutes total, with 8 minutes on hold. The disputes department averages 6 minutes hold time on Tuesdays."
|
||||
8. After the call, calls `learn_call_flow("call_abc123", company_name="Chase Bank")` to save the IVR path for next time.
|
||||
|
||||
130
docs/pjsua2-build.md
Normal file
130
docs/pjsua2-build.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Building the PJSUA2 Python bindings
|
||||
|
||||
The media pipeline ([core/media_pipeline.py](../core/media_pipeline.py)) needs
|
||||
the `pjsua2` Python bindings. They are **not pip-installable** — they are SWIG
|
||||
bindings compiled from pjproject. Without them `MediaPipeline.start()` catches
|
||||
`ImportError` and runs in **stub mode**: signaling works, but audio routing,
|
||||
recording, tapping and playback are all no-ops that *return successfully*. That
|
||||
last part is the trap — a stub gateway looks healthy while being unable to speak
|
||||
or listen.
|
||||
|
||||
Verified on Ubuntu 25.10 / Python 3.13.7 / gcc 15.2, pjproject **2.17**,
|
||||
2026-07-28.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
`swig` and `patchelf` are both needed and both have PyPI wheels, so **no `sudo`
|
||||
is required** — install them into the venv:
|
||||
|
||||
```bash
|
||||
pip install swig patchelf
|
||||
```
|
||||
|
||||
System dev libraries (already present on caliban; `libsrtp2-dev` is *not*
|
||||
needed — pjproject bundles its own SRTP):
|
||||
|
||||
```
|
||||
libasound2-dev libssl-dev libopus-dev uuid-dev python3-dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
mkdir -p ~/src && cd ~/src
|
||||
git clone --depth 1 --branch 2.17 https://github.com/pjsip/pjproject.git
|
||||
cd pjproject
|
||||
|
||||
# Minimal config_site.h — enable TLS transport support
|
||||
echo '#define PJ_HAS_SSL_SOCK 1' > pjlib/include/pj/config_site.h
|
||||
|
||||
# -fPIC is REQUIRED: the Python extension links these into a shared object.
|
||||
# --enable-shared builds the .so files the bindings load at runtime.
|
||||
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" ./configure \
|
||||
--enable-shared \
|
||||
--disable-video --disable-libyuv --disable-libwebrtc \
|
||||
--prefix=$HOME/.local
|
||||
|
||||
make dep && make -j$(nproc) && make install
|
||||
```
|
||||
|
||||
Confirm configure found what the gateway needs (all should say yes/enabled):
|
||||
OpenSSL, ALSA (`alsa/version.h`), OPUS.
|
||||
|
||||
```bash
|
||||
# Python bindings
|
||||
cd pjsip-apps/src/swig
|
||||
make python
|
||||
cd python && python setup.py install
|
||||
```
|
||||
|
||||
## The RPATH step — do not skip this
|
||||
|
||||
The shared libraries install to `~/.local/lib`, which is **not** on the default
|
||||
loader path, and neither the extension nor pjproject's own libraries carry an
|
||||
RPATH. Straight after `setup.py install` the import fails with:
|
||||
|
||||
```
|
||||
ImportError: libpjsua2.so.2: cannot open shared object file
|
||||
```
|
||||
|
||||
Rather than requiring `LD_LIBRARY_PATH` everywhere (it would have to be set for
|
||||
`uvicorn`, systemd, cron and any subprocess — easy to miss, and it fails at call
|
||||
time, not startup), bake the path into the binaries:
|
||||
|
||||
```bash
|
||||
# The extension module …
|
||||
patchelf --set-rpath $HOME/.local/lib \
|
||||
$VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2.cpython-313-x86_64-linux-gnu.so
|
||||
|
||||
# … and pjproject's libraries, which must also find each other.
|
||||
cd ~/.local/lib && for f in libpj*.so.2; do
|
||||
patchelf --set-rpath $HOME/.local/lib "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Patching only the extension is not enough: it resolves `libpjsua2`, which then
|
||||
fails on its own transitive deps (`libpjsua`, `libpjsip`, `libpjmedia`, `libpj`,
|
||||
…). Patch the whole set.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# 1. Loads with a completely empty environment (proves RPATH, not inherited vars)
|
||||
cd / && env -i $VIRTUAL_ENV/bin/python -c \
|
||||
"import pjsua2 as pj; ep=pj.Endpoint(); ep.libCreate(); print(ep.libVersion().full); ep.libDestroy()"
|
||||
|
||||
# 2. No unresolved libraries
|
||||
ldd $VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2*.so | grep "not found"
|
||||
|
||||
# 3. The real gate — Hold Slayer's own pipeline reports it
|
||||
python -c "
|
||||
import asyncio
|
||||
from core.media_pipeline import MediaPipeline
|
||||
async def m():
|
||||
p = MediaPipeline(); await p.start()
|
||||
assert p.status()['pjsua2_available'] is True
|
||||
print('pjsua2_available: True'); await p.stop()
|
||||
asyncio.run(m())"
|
||||
```
|
||||
|
||||
`ImportError` in step 1 or `pjsua2_available: False` in step 3 means you are
|
||||
still in stub mode.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Not captured by `pip install -e ".[dev]"`.** This build lives outside the
|
||||
Python packaging metadata, so a fresh venv, a rebuilt container, or another
|
||||
host needs it repeated. Treat it as host provisioning.
|
||||
- **The Docker image deliberately does not build this** (see the comment at the
|
||||
top of the [Dockerfile](../Dockerfile)) — the container therefore runs stub
|
||||
media. Anything validating audio must run outside the image, or the Dockerfile
|
||||
needs a build stage adding.
|
||||
- **Threading:** PJSUA2 starts its own worker threads, in addition to the Sippy
|
||||
ED thread. Per the concurrency rule, PJSUA2 objects belong to the media
|
||||
pipeline and must not be touched from the Sippy thread or mutated directly
|
||||
from the asyncio loop.
|
||||
- The extension compiles against system headers (`/usr/include/python3.13`)
|
||||
rather than the venv's. Harmless while both are the same 3.13.7 with matching
|
||||
SOABI — worth re-checking if the venv's Python is ever upgraded independently.
|
||||
348
main.py
348
main.py
@@ -12,17 +12,22 @@ Usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
||||
from api.deps import require_token
|
||||
from api import auth as auth_router
|
||||
from api import call_flows, call_history, calls, devices, routing, tokens, websocket
|
||||
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway, build_sip_engine
|
||||
from db.database import close_db, init_db
|
||||
from core.logging_config import configure_logging
|
||||
from db.database import close_db, init_db, session_scope
|
||||
from mcp_server.server import create_mcp_server
|
||||
from models.call import CallMode
|
||||
from services.audio_classifier import AudioClassifier
|
||||
@@ -35,13 +40,12 @@ from services.routing import RoutingService
|
||||
from services.transcription import TranscriptionService
|
||||
from services.tts import TTSService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
# Configure logging at import so anything logged during module import and
|
||||
# startup config checks is formatted. Uvicorn installs its own handlers *after*
|
||||
# importing this module, so `lifespan` calls `configure_logging` again to take
|
||||
# them over — see core/logging_config.py.
|
||||
_startup_settings = get_settings()
|
||||
configure_logging(_startup_settings.log_format, _startup_settings.log_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -106,16 +110,38 @@ def _check_startup_config(settings: Settings) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
token = settings.api_token.get_secret_value()
|
||||
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
loopback = settings.host in ("127.0.0.1", "localhost", "::1")
|
||||
if settings.casdoor.enabled:
|
||||
c = settings.casdoor
|
||||
missing = [
|
||||
name
|
||||
for name, val in (
|
||||
("CASDOOR_ENDPOINT", c.endpoint),
|
||||
("CASDOOR_CLIENT_ID", c.client_id),
|
||||
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
|
||||
("OWNER_NAME", settings.owner_name),
|
||||
)
|
||||
if not val
|
||||
]
|
||||
if missing:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ CASDOOR_ENABLED=true but required settings are missing:\n"
|
||||
f" {', '.join(missing)}\n"
|
||||
" Set them in .env (Casdoor app credentials + the owner's "
|
||||
"Casdoor username), or set CASDOOR_ENABLED=false with HOST=127.0.0.1 "
|
||||
"for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
elif not loopback:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ API_TOKEN is not set but HOST binds beyond loopback "
|
||||
"❌ CASDOOR_ENABLED=false but HOST binds beyond loopback "
|
||||
f"({settings.host}).\n"
|
||||
" Every surface (REST, WebSocket, MCP make_call) would be open "
|
||||
"to the network.\n"
|
||||
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set "
|
||||
"HOST=127.0.0.1 for tokenless local development."
|
||||
" Every surface (REST, WebSocket, MCP make_call) would resolve "
|
||||
"to the dev owner — open to the network.\n"
|
||||
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
|
||||
"or set HOST=127.0.0.1 for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -124,8 +150,18 @@ def _check_startup_config(settings: Settings) -> None:
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: Initialize database, SIP engine, and services."""
|
||||
settings = get_settings()
|
||||
|
||||
# Re-apply: under `uvicorn main:app` the server installs its own handlers on
|
||||
# `uvicorn`/`uvicorn.access` after importing this module, which would emit
|
||||
# colourised text alongside our JSON. This takes them back over.
|
||||
configure_logging(settings.log_format, settings.log_level)
|
||||
|
||||
_check_startup_config(settings)
|
||||
|
||||
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
|
||||
# the network round-trip (no-op when SSO is disabled).
|
||||
init_jwks_client()
|
||||
|
||||
# The MCP session manager lives in the mounted sub-app's lifespan;
|
||||
# without entering it, every /mcp request 500s.
|
||||
async with mcp_http_app.lifespan(app):
|
||||
@@ -214,7 +250,11 @@ async def lifespan(app: FastAPI):
|
||||
display_port = int(sys.argv[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
auth_state = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)"
|
||||
auth_state = (
|
||||
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
|
||||
if settings.casdoor.enabled
|
||||
else "dev-owner (loopback, no auth)"
|
||||
)
|
||||
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
||||
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
||||
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
||||
@@ -236,57 +276,251 @@ def _get_gateway_instance() -> AIPSTNGateway | None:
|
||||
return getattr(app.state, "gateway", None)
|
||||
|
||||
|
||||
mcp = create_mcp_server(
|
||||
_get_gateway_instance,
|
||||
api_token=get_settings().api_token.get_secret_value(),
|
||||
)
|
||||
mcp = create_mcp_server(_get_gateway_instance)
|
||||
mcp_http_app = mcp.http_app(path="/")
|
||||
|
||||
|
||||
def _public_base_url(scope_or_request) -> str:
|
||||
"""Resolve this service's public base URL (scheme + host), no trailing slash.
|
||||
|
||||
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
|
||||
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
|
||||
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
|
||||
discovery endpoints share one implementation.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if settings.public_base_url:
|
||||
return settings.public_base_url.rstrip("/")
|
||||
|
||||
if hasattr(scope_or_request, "headers"):
|
||||
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
|
||||
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
|
||||
else:
|
||||
headers = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope_or_request.get("headers", [])
|
||||
}
|
||||
default_scheme = scope_or_request.get("scheme", "http")
|
||||
|
||||
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
|
||||
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
|
||||
host = host.split(",", 1)[0].strip()
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def _owner_only_mcp(inner_app):
|
||||
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
|
||||
|
||||
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
|
||||
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
|
||||
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
|
||||
against a fresh DB session, and short-circuit non-owner requests with
|
||||
401/403. In dev mode this resolves to the dev owner, so local development
|
||||
keeps working without a token.
|
||||
"""
|
||||
|
||||
async def _send_status(send, scope, status: int, body: bytes) -> None:
|
||||
base = _public_base_url(scope)
|
||||
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(
|
||||
b"www-authenticate",
|
||||
f'Bearer realm="hold-slayer-mcp", '
|
||||
f'resource_metadata="{resource_metadata_url}"'.encode(),
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
async def app(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await inner_app(scope, receive, send)
|
||||
return
|
||||
|
||||
authorization = None
|
||||
for name, value in scope.get("headers", []):
|
||||
if name == b"authorization":
|
||||
authorization = value.decode("latin-1")
|
||||
break
|
||||
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, authorization, None)
|
||||
if user is None:
|
||||
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
|
||||
return
|
||||
if not is_owner(user):
|
||||
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
|
||||
return
|
||||
|
||||
await inner_app(scope, receive, send)
|
||||
|
||||
return app
|
||||
|
||||
app = FastAPI(
|
||||
title="Hold Slayer Gateway",
|
||||
description=(
|
||||
"🗡️ AI PSTN Gateway — Navigate IVRs, wait on hold, "
|
||||
"and connect you when a human answers.\n\n"
|
||||
"## Quick Start\n"
|
||||
"1. **POST /api/calls/hold-slayer** — Launch the Hold Slayer\n"
|
||||
"2. **GET /api/calls/{call_id}** — Check call status\n"
|
||||
"1. **POST /api/v1/calls/hold-slayer** — Launch the Hold Slayer\n"
|
||||
"2. **GET /api/v1/calls/{call_id}** — Check call status\n"
|
||||
"3. **WS /ws/events** — Real-time event stream\n"
|
||||
"4. **GET /api/call-flows** — Manage stored IVR trees\n"
|
||||
"4. **GET /api/v1/call-flows** — Manage stored IVR trees\n"
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === API Routes ===
|
||||
# call_history must register before calls: both live under /api/calls and
|
||||
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
|
||||
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
|
||||
# call_history must register before calls: both live under /api/v1/calls and
|
||||
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||
_auth = [Depends(require_token)]
|
||||
app.include_router(call_history.router, prefix="/api/calls", tags=["Call History"], dependencies=_auth)
|
||||
app.include_router(calls.router, prefix="/api/calls", tags=["Calls"], dependencies=_auth)
|
||||
app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"], dependencies=_auth)
|
||||
app.include_router(devices.router, prefix="/api/devices", tags=["Devices"], dependencies=_auth)
|
||||
app.include_router(routing.router, prefix="/api/routing", tags=["Routing"], dependencies=_auth)
|
||||
# WebSocket endpoints check the token themselves (query param or header)
|
||||
_auth = [Depends(get_current_owner)]
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(tokens.router, dependencies=_auth)
|
||||
app.include_router(
|
||||
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
||||
)
|
||||
app.include_router(calls.router, prefix="/api/v1/calls", tags=["Calls"], dependencies=_auth)
|
||||
app.include_router(
|
||||
call_flows.router, prefix="/api/v1/call-flows", tags=["Call Flows"], dependencies=_auth
|
||||
)
|
||||
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
||||
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
||||
# WebSocket endpoints check the owner themselves (query param or header)
|
||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
|
||||
# The ASGI guard resolves the bearer to the owner before the inner app runs.
|
||||
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
|
||||
|
||||
# === Dashboard (built SvelteKit static) ===
|
||||
import os as _os
|
||||
_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build")
|
||||
if _os.path.isdir(_dashboard_build):
|
||||
app.mount(
|
||||
"/dashboard",
|
||||
StaticFiles(directory=_dashboard_build, html=True),
|
||||
name="dashboard",
|
||||
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
|
||||
# clients re-register each session; the real gate is the bearer token.
|
||||
_registered_clients: dict[str, dict] = {}
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
|
||||
async def oauth_protected_resource_metadata(request: Request):
|
||||
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
|
||||
|
||||
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
|
||||
``mcp-remote`` versions verify it matches the URL they connected to.
|
||||
"""
|
||||
base = _public_base_url(request)
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": f"{base}/mcp",
|
||||
"authorization_servers": [base],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{base}/docs",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === Root Endpoint ===
|
||||
@app.get("/", tags=["System"])
|
||||
async def root():
|
||||
"""Gateway root — health check and quick status."""
|
||||
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
|
||||
async def oauth_authorization_server_metadata(request: Request):
|
||||
"""RFC 8414 Authorization Server Metadata.
|
||||
|
||||
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
|
||||
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
|
||||
directly in their MCP configuration.
|
||||
"""
|
||||
base = _public_base_url(request)
|
||||
settings = get_settings()
|
||||
if settings.casdoor.enabled:
|
||||
casdoor_base = settings.casdoor.endpoint.rstrip("/")
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": casdoor_base,
|
||||
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
|
||||
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
|
||||
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
"scopes_supported": ["openid", "profile", "email"],
|
||||
}
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": f"{base}/auth/login",
|
||||
"token_endpoint": f"{base}/auth/callback",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/register", include_in_schema=False)
|
||||
async def oauth_dynamic_registration(request: Request):
|
||||
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
|
||||
|
||||
Registered clients are held in memory (ephemeral); the real security gate
|
||||
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_client_metadata",
|
||||
"error_description": "Request body must be valid JSON.",
|
||||
},
|
||||
)
|
||||
|
||||
redirect_uris = body.get("redirect_uris")
|
||||
if not redirect_uris or not isinstance(redirect_uris, list):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_redirect_uri",
|
||||
"error_description": "redirect_uris is required and must be a non-empty list.",
|
||||
},
|
||||
)
|
||||
|
||||
client_id = secrets.token_hex(16)
|
||||
now = int(time.time())
|
||||
_registered_clients[client_id] = {
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": now,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": body.get("grant_types", ["authorization_code"]),
|
||||
"response_types": body.get("response_types", ["code"]),
|
||||
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
|
||||
"client_name": body.get("client_name"),
|
||||
"scope": body.get("scope"),
|
||||
}
|
||||
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": now,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": _registered_clients[client_id]["grant_types"],
|
||||
"response_types": _registered_clients[client_id]["response_types"],
|
||||
"token_endpoint_auth_method": _registered_clients[client_id][
|
||||
"token_endpoint_auth_method"
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
||||
async def api_status():
|
||||
"""Gateway status summary for the dashboard header."""
|
||||
gateway = getattr(app.state, "gateway", None)
|
||||
if gateway:
|
||||
status = await gateway.status()
|
||||
@@ -370,6 +604,20 @@ def _availability(service) -> str:
|
||||
return "ok" if available else "unreachable"
|
||||
|
||||
|
||||
# === Dashboard (built SvelteKit static, served at the root) ===
|
||||
# Registered last: a "/" mount matches every path, so the API, WS,
|
||||
# health, and MCP routes above must come first.
|
||||
import os as _os # noqa: E402
|
||||
|
||||
_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build")
|
||||
if _os.path.isdir(_dashboard_build):
|
||||
app.mount(
|
||||
"/",
|
||||
StaticFiles(directory=_dashboard_build, html=True),
|
||||
name="dashboard",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -380,4 +628,8 @@ if __name__ == "__main__":
|
||||
port=settings.port,
|
||||
reload=settings.debug,
|
||||
log_level=settings.log_level,
|
||||
# Suppress uvicorn's own dictConfig: ours is installed at import and in
|
||||
# lifespan, and letting uvicorn apply its default would attach a second,
|
||||
# colourised handler — every line twice, one of them not JSON.
|
||||
log_config=None,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_mcp_server(
|
||||
get_gateway: Callable[[], Optional[AIPSTNGateway]],
|
||||
api_token: str = "",
|
||||
) -> FastMCP:
|
||||
"""
|
||||
Create and configure the MCP server with all tools and resources.
|
||||
@@ -35,14 +34,13 @@ def create_mcp_server(
|
||||
The gateway is resolved lazily per request via `get_gateway` so the
|
||||
server can be mounted at app construction, before the lifespan has
|
||||
started the gateway.
|
||||
|
||||
Auth is **not** configured on the FastMCP instance: the mounted `/mcp`
|
||||
ASGI app is gated by `_owner_only_mcp` in main.py, which resolves a
|
||||
Casdoor JWT or PAT to the owner (one resolver shared with REST/WS) —
|
||||
so PATs and JWTs both work here with a single code path.
|
||||
"""
|
||||
auth = None
|
||||
if api_token:
|
||||
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
||||
|
||||
auth = StaticTokenVerifier(tokens={api_token: {"client_id": "hold-slayer"}})
|
||||
|
||||
mcp = FastMCP("Hold Slayer Gateway", auth=auth)
|
||||
mcp = FastMCP("Hold Slayer Gateway", auth=None)
|
||||
|
||||
def require_gateway() -> AIPSTNGateway:
|
||||
gateway = get_gateway()
|
||||
@@ -264,6 +262,70 @@ def create_mcp_server(
|
||||
except Exception as e:
|
||||
return f"Error creating call flow: {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def learn_call_flow(call_id: str, company_name: str = "") -> str:
|
||||
"""
|
||||
Build (or refine) a reusable IVR call flow from a completed
|
||||
hold-slayer exploration call.
|
||||
|
||||
Exploration calls record every IVR prompt heard and DTMF sent;
|
||||
this turns those discoveries into a stored call flow so the next
|
||||
call to that number navigates directly instead of exploring.
|
||||
If a flow already exists for the number, the discoveries refine
|
||||
it (timeouts averaged, usage counters updated).
|
||||
|
||||
Args:
|
||||
call_id: A completed call that ran in exploration mode
|
||||
company_name: Optional company name for labeling a new flow
|
||||
"""
|
||||
from db.database import session_scope
|
||||
from services import call_persistence as store
|
||||
from services.call_flow_learner import CallFlowLearner
|
||||
from services.llm_client import get_llm
|
||||
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
record = await store.get_record(session, call_id)
|
||||
if not record:
|
||||
return f"No record found for call {call_id}."
|
||||
|
||||
steps = (record.metadata_ or {}).get("exploration_steps") or []
|
||||
if not steps:
|
||||
return (
|
||||
f"Call {call_id} has no exploration data to learn from. "
|
||||
"Only hold-slayer calls without a stored flow record "
|
||||
"IVR discoveries."
|
||||
)
|
||||
|
||||
learner = CallFlowLearner(llm_client=get_llm())
|
||||
existing = await store.get_flow_by_number(
|
||||
session, record.remote_number
|
||||
)
|
||||
if existing:
|
||||
flow = await learner.merge_discoveries(
|
||||
store.flow_to_model(existing), steps, intent=record.intent
|
||||
)
|
||||
await store.update_flow_from_model(session, existing, flow)
|
||||
return (
|
||||
f"Refined existing flow '{existing.name}' for "
|
||||
f"{record.remote_number} from {len(steps)} discoveries "
|
||||
f"({len(flow.steps)} steps, used {flow.times_used}x)."
|
||||
)
|
||||
|
||||
flow = await learner.build_flow(
|
||||
phone_number=record.remote_number,
|
||||
discovered_steps=steps,
|
||||
intent=record.intent,
|
||||
company_name=company_name or None,
|
||||
)
|
||||
await store.save_learned_flow(session, flow)
|
||||
return (
|
||||
f"Learned new flow '{flow.name}' with {len(flow.steps)} "
|
||||
f"steps from {len(steps)} discoveries (ID: {flow.id})."
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error learning call flow: {e}"
|
||||
|
||||
@mcp.tool()
|
||||
async def send_dtmf(call_id: str, digits: str) -> str:
|
||||
"""
|
||||
|
||||
@@ -82,6 +82,9 @@ class ActiveCall(BaseModel):
|
||||
transcript_chunks: list[TranscriptEntry] = Field(default_factory=list)
|
||||
current_step_id: Optional[str] = None # Current position in call flow
|
||||
services: list[str] = Field(default_factory=list) # Active services on this call
|
||||
# IVR discoveries from hold-slayer exploration mode; persisted with
|
||||
# the call record so learn_call_flow can build a flow afterwards
|
||||
exploration_steps: list[dict] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def duration(self) -> int:
|
||||
|
||||
@@ -13,34 +13,34 @@ dependencies = [
|
||||
# Web framework
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"websockets>=13.0",
|
||||
|
||||
|
||||
# Database
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
|
||||
|
||||
# Settings & validation
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
|
||||
|
||||
# SIP signaling
|
||||
"sippy>=1.2.0",
|
||||
|
||||
|
||||
# Audio analysis
|
||||
"numpy>=1.26.0",
|
||||
"librosa>=0.10.0",
|
||||
"soundfile>=0.12.0",
|
||||
|
||||
|
||||
# HTTP client (for Speaches STT)
|
||||
"httpx>=0.28.0",
|
||||
|
||||
# MCP server (3.x — http_app + StaticTokenVerifier)
|
||||
|
||||
# MCP server (3.x — http_app + ASGI owner guard)
|
||||
"fastmcp>=3.0.0",
|
||||
|
||||
|
||||
# Auth: Casdoor SSO (OAuth code exchange) + RS256 JWT validation
|
||||
"casdoor>=1.0",
|
||||
"pyjwt[crypto]>=2.8",
|
||||
|
||||
# Utilities
|
||||
"python-slugify>=8.0.0",
|
||||
"python-multipart>=0.0.12",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -51,6 +51,8 @@ dev = [
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
"aiosqlite>=0.22.0",
|
||||
# Mint RS256 JWTs in tests without a live Casdoor
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -144,7 +144,11 @@ class AudioClassifier:
|
||||
)
|
||||
|
||||
# 5. If it's speech-like, is it live or automated?
|
||||
if speech_score > music_score:
|
||||
# A confident music score wins outright: the speech bands are wide
|
||||
# enough that sustained hold music satisfies all four and scores 1.0,
|
||||
# which would otherwise mask music as LIVE_HUMAN and ring the owner
|
||||
# for a hold queue.
|
||||
if speech_score > music_score and music_score < self.settings.music_threshold:
|
||||
# Use history to distinguish live human from IVR
|
||||
# IVR: repetitive patterns, synthetic prosody
|
||||
# Human: natural variation, conversational rhythm
|
||||
|
||||
@@ -136,6 +136,36 @@ async def create_flow(
|
||||
return row
|
||||
|
||||
|
||||
async def save_learned_flow(session: AsyncSession, flow: CallFlow) -> StoredCallFlow:
|
||||
"""The one CallFlow-model → row mapping (auto-learned flows)."""
|
||||
row = StoredCallFlow(
|
||||
id=flow.id,
|
||||
name=flow.name,
|
||||
phone_number=flow.phone_number,
|
||||
description=flow.description,
|
||||
steps=[s.model_dump(mode="json") for s in flow.steps],
|
||||
tags=flow.tags,
|
||||
notes=flow.notes,
|
||||
times_used=flow.times_used,
|
||||
last_used=flow.last_used,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
async def update_flow_from_model(
|
||||
session: AsyncSession, row: StoredCallFlow, flow: CallFlow
|
||||
) -> None:
|
||||
"""Write a refined CallFlow back onto its existing row."""
|
||||
row.steps = [s.model_dump(mode="json") for s in flow.steps]
|
||||
row.times_used = flow.times_used
|
||||
row.last_used = flow.last_used
|
||||
row.notes = flow.notes
|
||||
await session.flush()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Devices
|
||||
# ================================================================
|
||||
@@ -320,7 +350,10 @@ async def _finalize_call_record(call: ActiveCall, final_status: CallStatus) -> N
|
||||
}
|
||||
for c in call.classification_history
|
||||
]
|
||||
record.metadata_ = {"services": list(call.services)}
|
||||
metadata = {"services": list(call.services)}
|
||||
if call.exploration_steps:
|
||||
metadata["exploration_steps"] = call.exploration_steps
|
||||
record.metadata_ = metadata
|
||||
|
||||
# Each transcript entry gets its own row with a sequence number
|
||||
# and real offset so the dashboard can render click-to-seek.
|
||||
|
||||
@@ -294,7 +294,7 @@ class HoldSlayerService:
|
||||
logger.info(f"🔍 Exploration mode: discovering IVR for {call.remote_number}")
|
||||
await self.call_manager.update_status(call.id, CallStatus.NAVIGATING_IVR)
|
||||
|
||||
discovered_steps: list[dict] = []
|
||||
discovered_steps = call.exploration_steps # persisted with the record
|
||||
max_time = self.settings.hold_slayer.max_hold_time
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
213
tests/lab/README.md
Normal file
213
tests/lab/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Asterisk lab — a fake PSTN
|
||||
|
||||
An Asterisk instance that answers calls, plays an IVR, holds you with music,
|
||||
and eventually connects a "human". It gives the gateway something real to dial
|
||||
that is **not** the PSTN: no charges, no strangers, no E911 exposure, and a
|
||||
deterministic script that makes classifier regressions reproducible.
|
||||
|
||||
Design rationale and the Virgo deployment plan:
|
||||
[docs/asterisk-lab-design.md](../../docs/asterisk-lab-design.md).
|
||||
|
||||
> This lab found five bugs in `SippyEngine` on its first call — the engine had
|
||||
> never successfully placed one. Everything below runs against the real
|
||||
> `SippyEngine`, never `MockSIPEngine`, which is the entire point.
|
||||
|
||||
---
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd tests/lab
|
||||
|
||||
# 1. Generate the audio fixtures (the image ships with NO sound files).
|
||||
python sounds/generate.py
|
||||
|
||||
# 2. Render the local configs. They carry a host-specific IP and the lab
|
||||
# password, so they are gitignored — regenerate them per machine.
|
||||
cd dialplan
|
||||
LOCALIP=$(ip route get 1.1.1.1 | grep -oP '(?<=src\s)\d+(\.\d+){3}')
|
||||
sed -e "s/{{ asterisk_sip_port }}/21061/" \
|
||||
-e "s/{{ asterisk_external_ip }}/$LOCALIP/" \
|
||||
-e "s#{{ asterisk_local_net }}#10.10.0.0/24#" \
|
||||
-e "s/{{ asterisk_match_host }}/127.0.0.1/" \
|
||||
-e "s/{{ asterisk_sip_username }}/holdslayer/" \
|
||||
-e "s/{{ asterisk_sip_password }}/labpassword/" \
|
||||
pjsip.conf > pjsip.local.conf
|
||||
sed -e "s/{{ asterisk_rtp_start }}/21100/" \
|
||||
-e "s/{{ asterisk_rtp_end }}/21149/" \
|
||||
rtp.conf > rtp.local.conf
|
||||
cd ..
|
||||
|
||||
# 3. Start it.
|
||||
docker compose -f docker-compose.lab.yml up -d
|
||||
```
|
||||
|
||||
Point Hold Slayer at it — no code changes, no test-only branch. `make_call`
|
||||
builds `sip:{number}@{trunk_host}:{trunk_port}`, so the lab is just an address:
|
||||
|
||||
```bash
|
||||
USE_MOCK_SIP=false
|
||||
SIP_TRUNK_HOST=127.0.0.1
|
||||
SIP_TRUNK_PORT=21061
|
||||
SIP_TRUNK_USERNAME=holdslayer
|
||||
SIP_TRUNK_PASSWORD=labpassword
|
||||
SIP_TRUNK_DID=+15550000000
|
||||
GATEWAY_SIP_PORT=21062 # must differ from the Asterisk port
|
||||
```
|
||||
|
||||
> **The repo's own `.env` sets `USE_MOCK_SIP=true`** and a placeholder trunk
|
||||
> host, and pydantic-settings lets `.env` win over the process environment. If
|
||||
> the engine reports `MockSIPEngine` despite the above, that is why.
|
||||
>
|
||||
> `AIPSTNGateway(settings=...)` also defaults to `MockSIPEngine` unless an
|
||||
> engine is assigned — `main.py`'s lifespan calls `build_sip_engine()` after
|
||||
> construction. A harness that skips that step silently tests the mock.
|
||||
|
||||
## The softphone (transfer target)
|
||||
|
||||
**Asterisk is the registrar for devices, not Hold Slayer.** The gateway reaches
|
||||
a desk phone by dialling extension `2001`, which Asterisk routes to whatever
|
||||
has registered as `softphone`. This deliberately avoids Hold Slayer's own SIP
|
||||
listener, which answers `200 OK` to any REGISTER with no digest challenge.
|
||||
|
||||
The `pjsua` CLI built alongside the Python bindings is the test device — same
|
||||
library stack as the gateway, no extra dependency. It needs an RPATH patch like
|
||||
the bindings did:
|
||||
|
||||
```bash
|
||||
cp ~/src/pjproject/pjsip-apps/bin/pjsua-x86_64-pc-linux-gnu ~/.local/bin/pjsua
|
||||
patchelf --set-rpath $HOME/.local/lib ~/.local/bin/pjsua
|
||||
```
|
||||
|
||||
Register it (config file avoids shell-quoting pain):
|
||||
|
||||
```bash
|
||||
cat > softphone.cfg <<'EOF'
|
||||
--null-audio
|
||||
--auto-answer=200
|
||||
--max-calls=4
|
||||
--local-port=21070
|
||||
--id=sip:softphone@127.0.0.1
|
||||
--registrar=sip:127.0.0.1:21061
|
||||
--realm=asterisk
|
||||
--username=softphone
|
||||
--password=labphone
|
||||
--log-level=3
|
||||
EOF
|
||||
|
||||
# pjsua is an interactive console app: it exits ~8s after start if stdin is
|
||||
# closed or /dev/null. Hold a fifo open on stdin — `script -qfc` and
|
||||
# `setsid </dev/null` both look like they work (registration succeeds) and
|
||||
# then the process dies, leaving a stale contact in Asterisk that routes
|
||||
# INVITEs to a port nobody is listening on.
|
||||
mkfifo sp.fifo
|
||||
setsid sh -c 'exec 3<>sp.fifo; pjsua --config-file softphone.cfg <&3 >softphone.log 2>&1' &
|
||||
```
|
||||
|
||||
Verify — **check the port is actually bound**, not just that Asterisk holds a
|
||||
contact, since a stale registration outlives the process:
|
||||
|
||||
```bash
|
||||
ss -lnup | grep 21070 # must be listening
|
||||
docker compose -f docker-compose.lab.yml exec asterisk \
|
||||
asterisk -rx "pjsip show contacts" # must show softphone
|
||||
```
|
||||
|
||||
Then place a call to `2001`. Both legs should show `Up` under one bridge id:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.lab.yml exec asterisk \
|
||||
asterisk -rx "core show channels concise"
|
||||
```
|
||||
|
||||
> **`--realm=asterisk`, not `--realm='*'`** — the wildcard fails with
|
||||
> `PJSIP_EFAILEDCREDENTIAL` against Asterisk's digest challenge.
|
||||
|
||||
> **Qualify is off** for this AOR (`qualify_frequency = 0`): the pjsua console
|
||||
> does not answer `OPTIONS`, so polling marks a working softphone `Unavail` and
|
||||
> the dialplan refuses to ring it. The `2001` guard therefore tests
|
||||
> `PJSIP_AOR(softphone,contact)` rather than `DEVICE_STATE`. A real hardphone
|
||||
> answers OPTIONS and can have qualify re-enabled.
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.lab.yml exec asterisk asterisk -rvvv # CLI
|
||||
docker compose -f docker-compose.lab.yml logs -f asterisk # logs
|
||||
docker compose -f docker-compose.lab.yml exec asterisk \
|
||||
asterisk -rx "pjsip set logger on" # SIP trace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
Hold Slayer dials these as `number`.
|
||||
|
||||
| Ext | Scenario | Proves |
|
||||
|---|---|---|
|
||||
| `1001` | Answers, speech, hangs up | Baseline: INVITE→200→ACK→RTP→BYE, audio both ways |
|
||||
| `1002` | IVR menu, branches on DTMF | `send_dtmf` really emits RFC 2833 and Asterisk receives it |
|
||||
| `1003` | Hold music, then a human | The hold-slayer loop: music → wait → human → ring owner |
|
||||
| `1004` | Long hold (~10 min) | `MAX_HOLD_TIME`, `HOLD_CHECK_INTERVAL` |
|
||||
| `1005` | Busy | Failure path: call marked `FAILED`, no stuck leg |
|
||||
| `1006` | Rings, never answers | Timeout path |
|
||||
| `1007` | Answers, hangs up after 5s | Remote BYE, DB persistence on hangup |
|
||||
| `1008` | Answers, then silence | Classifier `SILENCE` vs. no-audio |
|
||||
| `1099` | Echo test | Debugging aid — confirm bidirectional RTP by ear |
|
||||
|
||||
## Audio fixtures
|
||||
|
||||
The Asterisk image ships **no sound files**, and the design calls for
|
||||
deterministic audio: real hold music varies per call, so a classifier
|
||||
regression on the PSTN is indistinguishable from noise. `sounds/generate.py`
|
||||
synthesises three fixtures from fixed seeds — byte-identical every run.
|
||||
|
||||
Verified against `AudioClassifier` (16 kHz):
|
||||
|
||||
| Fixture | Classifies as | Confidence |
|
||||
|---|---|---|
|
||||
| `lab-music.sln` | `MUSIC` | 0.85 |
|
||||
| `lab-speech.sln` | `LIVE_HUMAN` | 0.75 |
|
||||
| `lab-silence.sln` | `SILENCE` | 1.00 |
|
||||
|
||||
Format is 8 kHz 16-bit mono signed-linear (`.sln`) — Asterisk's native
|
||||
telephony rate, played without transcoding.
|
||||
|
||||
> The speech fixture's formants deliberately avoid the DTMF bands (rows
|
||||
> 697–941 Hz, columns 1209–1633 Hz). The first version landed on a valid
|
||||
> DTMF pair and the whole utterance classified as a keypress.
|
||||
|
||||
---
|
||||
|
||||
## Known limits
|
||||
|
||||
- **The classifier receives nothing on a live call.**
|
||||
`MediaPipeline.create_tap` is a stub — it logs `🎤 Audio tap created` and
|
||||
returns a tap that is never fed (`core/media_pipeline.py`, and the same at
|
||||
stream creation). RTP flows and Asterisk plays audio, but nothing reaches
|
||||
the classifier. The table above was measured by feeding the fixtures
|
||||
directly. **This blocks the hold-slayer scenarios (1002/1003/1004).**
|
||||
- **Not real PSTN audio** — no transcoding artefacts, packet loss, jitter, or
|
||||
carrier-side DTMF mangling. Asterisk is clean; the PSTN is not.
|
||||
- **Not real IVR behaviour** — this dialplan is what we imagine a bank sounds
|
||||
like. Real trees are longer, noisier, and interrupt.
|
||||
- **Not trunk registration against a real ITSP** — `_register_trunk()` works
|
||||
against Asterisk, but carrier quirks are their own phase.
|
||||
|
||||
## Security
|
||||
|
||||
`pjsip.conf` refuses anonymous inbound calls: every call must authenticate as
|
||||
the `hold-slayer` endpoint. Asterisk's stock examples allow anonymous calls and
|
||||
are a well-known toll-fraud target — there is no PSTN behind this instance, so
|
||||
an unauthorised call reaches only the dialplan, but the lock-down keeps this
|
||||
config safe to copy.
|
||||
|
||||
Endpoint matching is by **source address** (`type=identify`). Asterisk's
|
||||
default matches the From-header domain, which Hold Slayer populates from its
|
||||
SIP bind address — `0.0.0.0` on a wildcard bind, which never matches.
|
||||
|
||||
> **Separate, pre-existing:** Hold Slayer's own SIP listener answers `200 OK`
|
||||
> to any REGISTER with no digest challenge. Fine on loopback; it must be
|
||||
> resolved before the gateway binds a LAN interface, or any host on the
|
||||
> network can register as a device and receive transferred calls.
|
||||
3
tests/lab/dialplan/.gitignore
vendored
Normal file
3
tests/lab/dialplan/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Rendered from the .conf templates by the local-lab instructions in
|
||||
# README.md; contains a host-specific IP and the lab password.
|
||||
*.local.conf
|
||||
17
tests/lab/dialplan/asterisk.conf
Normal file
17
tests/lab/dialplan/asterisk.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
; Minimal Asterisk core config for the lab.
|
||||
;
|
||||
; Deliberately does NOT set [directories] or runuser/rungroup: the image's
|
||||
; compiled-in defaults are correct, and it runs as the `asterisk` user via a
|
||||
; USER directive. Overriding either risks breaking the container for no gain
|
||||
; (an earlier version of this file did both).
|
||||
[options]
|
||||
; Log to stdout so Docker's json-file driver captures it and Alloy ships it
|
||||
; to Loki. A file-based log inside the container would be invisible.
|
||||
verbose = 3
|
||||
debug = 0
|
||||
; No ANSI colour. Asterisk colourises the console by default and the escape
|
||||
; codes travel through Docker into Loki, where every line arrives wrapped in
|
||||
; \x1b[0;30m — unreadable in Grafana and awkward to filter on. This setting
|
||||
; lives here, not in logger.conf, and only takes effect if this file is
|
||||
; actually mounted into the container.
|
||||
nocolor = yes
|
||||
161
tests/lab/dialplan/extensions.conf
Normal file
161
tests/lab/dialplan/extensions.conf
Normal file
@@ -0,0 +1,161 @@
|
||||
; ---------------------------------------------------------------------------
|
||||
; Hold Slayer lab dialplan — a fake bank phone tree
|
||||
; ---------------------------------------------------------------------------
|
||||
; Each extension is one scenario Hold Slayer must handle. Everything here is
|
||||
; deterministic on purpose: real hold music varies per call, so a classifier
|
||||
; regression on the PSTN is indistinguishable from noise. Against a fixed
|
||||
; prompt the answer is binary.
|
||||
;
|
||||
; Hold Slayer dials these as `number` with SIP_TRUNK_HOST pointing here.
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
[globals]
|
||||
; Lab-generated audio (tests/lab/sounds/, built by generate.py). The Asterisk
|
||||
; image ships with no sounds at all, and these are synthesised from a fixed
|
||||
; seed so the classifier sees byte-identical input on every run.
|
||||
; lab-speech -> must classify LIVE_HUMAN
|
||||
; lab-music -> must classify MUSIC
|
||||
; lab-silence -> must classify SILENCE
|
||||
GREETING=lab-speech
|
||||
INVALID=lab-speech
|
||||
|
||||
[hold-slayer-lab]
|
||||
|
||||
; --- 1001: immediate answer, speech, hangup -------------------------------
|
||||
; Baseline. Proves INVITE→200→ACK→RTP→BYE and that audio flows both ways.
|
||||
; The classifier should report LIVE_HUMAN throughout.
|
||||
exten => 1001,1,NoOp(LAB 1001: immediate answer)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(${GREETING})
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(20)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1002: IVR menu, branches on DTMF -------------------------------------
|
||||
; THE important one. Proves send_dtmf genuinely emits RFC 2833 and that
|
||||
; Asterisk receives the digits — currently a no-op in MockSIPEngine.
|
||||
; Press 1 → accounts (answers as human). Press 2 → cards (hold, then human).
|
||||
exten => 1002,1,NoOp(LAB 1002: IVR menu)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Set(TRIES=0)
|
||||
same => n(menu),Background(lab-speech)
|
||||
same => n,WaitExten(8)
|
||||
same => n,Set(TRIES=$[${TRIES} + 1])
|
||||
same => n,GotoIf($[${TRIES} < 3]?menu)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Hangup()
|
||||
|
||||
; Option 1 — straight to a "human"
|
||||
exten => 1,1,NoOp(LAB 1002: caller pressed 1 -> accounts)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(15)
|
||||
same => n,Hangup()
|
||||
|
||||
; Option 2 — hold queue, then a "human"
|
||||
exten => 2,1,NoOp(LAB 1002: caller pressed 2 -> cards, hold)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(15)
|
||||
same => n,Hangup()
|
||||
|
||||
exten => i,1,NoOp(LAB 1002: invalid entry)
|
||||
same => n,Playback(${INVALID})
|
||||
same => n,Goto(1002,menu)
|
||||
|
||||
exten => t,1,NoOp(LAB 1002: entry timeout)
|
||||
same => n,Goto(1002,menu)
|
||||
|
||||
; --- 1003: hold music, then a human ---------------------------------------
|
||||
; The whole hold-slayer loop in one call: classify music → stay on hold →
|
||||
; detect the human → ring the owner. 60s of MoH is long enough for several
|
||||
; classifier windows (CLASSIFIER_WINDOW_SECONDS defaults to 3.0).
|
||||
exten => 1003,1,NoOp(LAB 1003: hold then human)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(30)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1004: long hold ------------------------------------------------------
|
||||
; Exercises MAX_HOLD_TIME and HOLD_CHECK_INTERVAL. 10 minutes.
|
||||
exten => 1004,1,NoOp(LAB 1004: long hold)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(15)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1005: busy -----------------------------------------------------------
|
||||
; Failure path: the call must be marked FAILED with no stuck leg.
|
||||
exten => 1005,1,NoOp(LAB 1005: busy)
|
||||
same => n,Busy(20)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1006: ring, never answer ---------------------------------------------
|
||||
; Timeout path. Rings for 120s without answering.
|
||||
exten => 1006,1,NoOp(LAB 1006: ring no answer)
|
||||
same => n,Progress()
|
||||
same => n,Wait(120)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1007: answer, then remote hangup after 5s ----------------------------
|
||||
; Proves remote-BYE handling and that the call persists to the DB on hangup.
|
||||
exten => 1007,1,NoOp(LAB 1007: quick remote hangup)
|
||||
same => n,Answer()
|
||||
same => n,Playback(${GREETING})
|
||||
same => n,Wait(5)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1008: answer, then silence -------------------------------------------
|
||||
; Classifier SILENCE vs the no-audio case. 45s of nothing.
|
||||
exten => 1008,1,NoOp(LAB 1008: silence)
|
||||
same => n,Answer()
|
||||
same => n,Playback(lab-silence)
|
||||
same => n,Wait(40)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 2001: ring the registered softphone ----------------------------------
|
||||
; The transfer target. Asterisk is the registrar for devices, so the gateway
|
||||
; reaches a desk phone by dialling this rather than by registering it itself.
|
||||
; Fails fast when nothing is registered — a silent 30s ring would look like a
|
||||
; gateway bug rather than an absent softphone.
|
||||
exten => 2001,1,NoOp(LAB 2001: ring softphone)
|
||||
; Count registered contacts rather than DEVICE_STATE: device state follows
|
||||
; the OPTIONS qualify, which is off for this AOR (the pjsua CLI does not
|
||||
; answer OPTIONS), so a registered softphone would still read UNAVAILABLE.
|
||||
same => n,GotoIf($[${PJSIP_AOR(softphone,contact)} = ""]?nodevice)
|
||||
same => n,Dial(PJSIP/softphone,30)
|
||||
same => n,Hangup()
|
||||
same => n(nodevice),NoOp(LAB 2001: no softphone registered)
|
||||
same => n,Answer()
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- echo test ------------------------------------------------------------
|
||||
; Not a scenario — a debugging aid. Echoes audio back so you can confirm
|
||||
; bidirectional RTP by ear when something looks wrong.
|
||||
exten => 1099,1,NoOp(LAB 1099: echo test)
|
||||
same => n,Answer()
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Echo()
|
||||
same => n,Hangup()
|
||||
|
||||
; Anything else: reject explicitly rather than failing obscurely.
|
||||
exten => _X.,1,NoOp(LAB: unknown extension ${EXTEN})
|
||||
same => n,Answer()
|
||||
same => n,Playback(${INVALID})
|
||||
same => n,Hangup()
|
||||
24
tests/lab/dialplan/logger.conf
Normal file
24
tests/lab/dialplan/logger.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
; Log to stdout only — Docker's json-file driver captures it and the host
|
||||
; Alloy ships it to Loki as job=<compose project>. Writing to a file inside
|
||||
; the container would put the logs where nothing can see them.
|
||||
[general]
|
||||
dateformat = %F %T
|
||||
; Colour is disabled in asterisk.conf (`nocolor = yes`), not here — Asterisk
|
||||
; colourises the console by default and the escape codes travel through Docker
|
||||
; into Loki, where every line arrives wrapped in \x1b[0;30m. That file must be
|
||||
; mounted for the setting to take effect.
|
||||
|
||||
[logfiles]
|
||||
; Warnings and errors only. That covers what matters when a lab call
|
||||
; misbehaves: failed authentication, no-matching-endpoint, playback failures.
|
||||
;
|
||||
; `notice` and `verbose` are both excluded because the "Remote UNIX
|
||||
; connection" pairs the healthcheck generates arrive on those channels, and
|
||||
; they swamped everything else (the Loki stream measured 100% healthcheck
|
||||
; noise before this). The healthcheck itself is also reduced to one CLI call
|
||||
; on a 60s interval in docker-compose — the two changes work together.
|
||||
;
|
||||
; To trace a call's dialplan execution, raise verbosity at runtime rather than
|
||||
; leaving it on:
|
||||
; asterisk -rx "core set verbose 3"
|
||||
console => warning,error
|
||||
132
tests/lab/dialplan/pjsip.conf
Normal file
132
tests/lab/dialplan/pjsip.conf
Normal file
@@ -0,0 +1,132 @@
|
||||
; ---------------------------------------------------------------------------
|
||||
; Hold Slayer lab — PJSIP configuration
|
||||
; ---------------------------------------------------------------------------
|
||||
; SECURITY: this endpoint answers calls. Asterisk's stock examples allow
|
||||
; anonymous inbound, which is a well-known toll-fraud target. This config
|
||||
; refuses it: every call must authenticate as the `hold-slayer` endpoint.
|
||||
;
|
||||
; There is no PSTN behind this Asterisk — an unauthorised call reaches only
|
||||
; the lab dialplan and costs nothing. The lock-down is defence in depth and
|
||||
; so this config is never copied somewhere it would matter.
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
[global]
|
||||
type = global
|
||||
; Do not fall through to an `anonymous` endpoint for unmatched calls.
|
||||
; This is the single most important line in the file.
|
||||
unidentified_request_count = 5
|
||||
unidentified_request_period = 5
|
||||
unidentified_request_prune_interval = 30
|
||||
|
||||
[transport-udp]
|
||||
type = transport
|
||||
protocol = udp
|
||||
bind = 0.0.0.0:{{ asterisk_sip_port }}
|
||||
; The address Asterisk advertises in SDP. Without this, containers advertise
|
||||
; their internal bridge IP and RTP arrives at an unroutable address — the
|
||||
; classic "call connects but there is no audio" failure.
|
||||
external_media_address = {{ asterisk_external_ip }}
|
||||
external_signaling_address = {{ asterisk_external_ip }}
|
||||
local_net = {{ asterisk_local_net }}
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Hold Slayer endpoint
|
||||
; ---------------------------------------------------------------------------
|
||||
; Hold Slayer authenticates as this endpoint to place calls into the lab.
|
||||
|
||||
; Identify the endpoint by source address *and port*. Asterisk's default
|
||||
; matching uses the From-header domain, which Hold Slayer populates from its
|
||||
; SIP bind address (0.0.0.0 on a wildcard bind) — never a value Asterisk can
|
||||
; match. Matching on where the packet came from sidesteps that.
|
||||
;
|
||||
; The port is essential when the softphone runs on the same host: a
|
||||
; host-only match claims *every* packet from that address, so the
|
||||
; softphone's REGISTER would be attributed to this endpoint and checked
|
||||
; against the gateway's password ("Failed to authenticate", confusingly).
|
||||
; Endpoints that authenticate by username (the softphone) must not be
|
||||
; covered by an identify block.
|
||||
[hold-slayer]
|
||||
type = identify
|
||||
endpoint = hold-slayer
|
||||
match = {{ asterisk_match_host }}:{{ asterisk_gateway_port }}
|
||||
|
||||
[hold-slayer]
|
||||
type = endpoint
|
||||
context = hold-slayer-lab
|
||||
disallow = all
|
||||
; ulaw first: it is what the PSTN uses, so the lab exercises the same codec
|
||||
; path a real trunk would. alaw as fallback.
|
||||
allow = ulaw
|
||||
allow = alaw
|
||||
auth = hold-slayer-auth
|
||||
aors = hold-slayer
|
||||
; RFC 2833 out-of-band DTMF — what send_dtmf must produce. Setting this
|
||||
; explicitly (rather than `auto`) means a DTMF failure is a real failure and
|
||||
; not a negotiation fallback quietly rescuing it.
|
||||
dtmf_mode = rfc4733
|
||||
direct_media = no
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
rtp_symmetric = yes
|
||||
|
||||
[hold-slayer-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = {{ asterisk_sip_username }}
|
||||
password = {{ asterisk_sip_password }}
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Softphone endpoint — the transfer target
|
||||
; ---------------------------------------------------------------------------
|
||||
; Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs
|
||||
; here and the gateway transfers a live call to it by dialling extension 2001.
|
||||
;
|
||||
; Deliberate: Hold Slayer's own SIP listener answers 200 OK to any REGISTER
|
||||
; with no digest challenge, so anything on the network could register as a
|
||||
; device and receive transferred calls. Keeping registration in Asterisk means
|
||||
; the lab does not depend on that path, and the softphone is authenticated.
|
||||
;
|
||||
; Test with the pjsua CLI built alongside the Python bindings:
|
||||
; pjsua --null-audio --auto-answer=200 \
|
||||
; --id=sip:softphone@<asterisk-host> \
|
||||
; --registrar=sip:<asterisk-host>:21061 \
|
||||
; --realm='*' --username=softphone --password=<pw> \
|
||||
; --local-port=<free port>
|
||||
|
||||
[softphone]
|
||||
type = endpoint
|
||||
context = hold-slayer-lab
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
allow = alaw
|
||||
auth = softphone-auth
|
||||
aors = softphone
|
||||
dtmf_mode = rfc4733
|
||||
direct_media = no
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
rtp_symmetric = yes
|
||||
|
||||
[softphone-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = {{ asterisk_softphone_username }}
|
||||
password = {{ asterisk_softphone_password }}
|
||||
|
||||
[softphone]
|
||||
type = aor
|
||||
; The device's contact is learned from its REGISTER rather than configured —
|
||||
; a softphone's port is not known in advance.
|
||||
max_contacts = 1
|
||||
remove_existing = yes
|
||||
; No qualify: the pjsua CLI does not answer OPTIONS while sitting at its
|
||||
; console prompt, so polling marks a perfectly working softphone Unavail and
|
||||
; the dialplan refuses to ring it. Registration itself is the liveness signal
|
||||
; here. A real hardphone answers OPTIONS and can have qualify re-enabled.
|
||||
qualify_frequency = 0
|
||||
|
||||
[hold-slayer]
|
||||
type = aor
|
||||
max_contacts = 2
|
||||
remove_existing = yes
|
||||
qualify_frequency = 60
|
||||
9
tests/lab/dialplan/rtp.conf
Normal file
9
tests/lab/dialplan/rtp.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
; RTP media port range for the lab.
|
||||
;
|
||||
; 50 ports ≈ 25 concurrent calls — comfortably above Hold Slayer's
|
||||
; max_concurrent_calls (default 4). The range must match the ports published
|
||||
; in docker-compose, or media arrives at a port Docker isn't forwarding and
|
||||
; the call connects with no audio.
|
||||
[general]
|
||||
rtpstart = {{ asterisk_rtp_start }}
|
||||
rtpend = {{ asterisk_rtp_end }}
|
||||
42
tests/lab/docker-compose.lab.yml
Normal file
42
tests/lab/docker-compose.lab.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Local Asterisk lab — for iterating on caliban before promoting to Virgo.
|
||||
#
|
||||
# This is the LOCAL variant: ports and credentials are concrete, not Jinja.
|
||||
# Ansible templates the same dialplan out to galatea with the estate's
|
||||
# variables (see virgo/ansible/asterisk/).
|
||||
#
|
||||
# Run: docker compose -f docker-compose.lab.yml up -d
|
||||
# CLI: docker compose -f docker-compose.lab.yml exec asterisk asterisk -rvvv
|
||||
#
|
||||
# host networking: SIP/RTP carry IP addresses *inside* the payload, so a
|
||||
# bridged network needs external_media_address set correctly or the call
|
||||
# connects with no audio. Host networking sidesteps that entirely for local
|
||||
# work. The Virgo deploy uses the same approach for the same reason.
|
||||
services:
|
||||
asterisk:
|
||||
image: andrius/asterisk:22.10.1_debian-trixie
|
||||
container_name: asterisk-lab
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./dialplan/extensions.conf:/etc/asterisk/extensions.conf:ro
|
||||
- ./dialplan/pjsip.local.conf:/etc/asterisk/pjsip.conf:ro
|
||||
- ./dialplan/rtp.local.conf:/etc/asterisk/rtp.conf:ro
|
||||
- ./dialplan/logger.conf:/etc/asterisk/logger.conf:ro
|
||||
# Carries `nocolor = yes`: without it every log line reaches Loki
|
||||
# wrapped in ANSI escape codes.
|
||||
- ./dialplan/asterisk.conf:/etc/asterisk/asterisk.conf:ro
|
||||
# The image ships no sound files at all. These are generated by
|
||||
# sounds/generate.py; Asterisk resolves Playback(lab-music) to
|
||||
# lab-music.sln here (8kHz signed-linear, no transcoding).
|
||||
- ./sounds:/var/lib/asterisk/sounds/en:ro
|
||||
# The image's default command is `-vvvdddf` — verbosity 3 and debug 3
|
||||
# forced on the command line, which overrides both asterisk.conf and
|
||||
# logger.conf. That makes every healthcheck CLI connection log a
|
||||
# "Remote UNIX connection" pair: ~2900 lines/day of pure noise that
|
||||
# completely buried the real SIP events in Loki.
|
||||
#
|
||||
# -f foreground (required: Docker needs PID 1 to stay), -T timestamps,
|
||||
# -W colour off, -U run as asterisk, -p realtime priority. No -v, no -d:
|
||||
# warnings and errors still log, and verbosity can be raised at runtime
|
||||
# with `asterisk -rx "core set verbose 3"` when tracing a call.
|
||||
command: ["/usr/sbin/asterisk", "-f", "-T", "-W", "-U", "asterisk", "-p"]
|
||||
restart: unless-stopped
|
||||
4
tests/lab/sounds/.gitignore
vendored
Normal file
4
tests/lab/sounds/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by generate.py — deterministic from fixed seeds, so the bytes are
|
||||
# reproducible and there is no reason to carry ~680K of binary in the repo.
|
||||
# Run `python generate.py` before starting the lab.
|
||||
*.sln
|
||||
150
tests/lab/sounds/generate.py
Normal file
150
tests/lab/sounds/generate.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the lab's audio fixtures.
|
||||
|
||||
The Asterisk container ships with no sound files, and the design calls for
|
||||
*deterministic* audio: real hold music varies per call, so a classifier
|
||||
regression on the PSTN is indistinguishable from noise. These are synthesised
|
||||
from a fixed seed, so every run classifies identical input.
|
||||
|
||||
Output is 8 kHz 16-bit mono signed-linear (.sln), which Asterisk plays without
|
||||
transcoding — the format is implied by the extension, so `Playback(lab-music)`
|
||||
finds `lab-music.sln`.
|
||||
|
||||
python generate.py [outdir]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
RATE = 8000 # Asterisk's native rate for ulaw/alaw telephony
|
||||
|
||||
|
||||
def _write_sln(path: Path, samples: np.ndarray) -> None:
|
||||
"""Write float samples in [-1, 1] as 16-bit signed little-endian PCM."""
|
||||
clipped = np.clip(samples, -1.0, 1.0)
|
||||
pcm = (clipped * 32767).astype("<i2")
|
||||
path.write_bytes(pcm.tobytes())
|
||||
print(f" {path.name}: {len(pcm) / RATE:.1f}s ({path.stat().st_size} bytes)")
|
||||
|
||||
|
||||
def make_music(seconds: float = 30.0, seed: int = 7) -> np.ndarray:
|
||||
"""Sustained multi-harmonic tones — what the classifier must call MUSIC.
|
||||
|
||||
A chord progression with stable pitch and strong harmonic structure. The
|
||||
steady spectrum across a long window is what distinguishes music from
|
||||
speech; this deliberately has no pauses.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
t = np.linspace(0, seconds, int(RATE * seconds), endpoint=False)
|
||||
# A-minor-ish progression, one chord per 2s bar.
|
||||
chords = [(220.0, 261.6, 329.6), (196.0, 246.9, 293.7),
|
||||
(174.6, 220.0, 261.6), (196.0, 246.9, 329.6)]
|
||||
out = np.zeros_like(t)
|
||||
bar = 2.0
|
||||
for i, chord in enumerate(chords * int(np.ceil(seconds / (bar * len(chords))))):
|
||||
start, end = i * bar, (i + 1) * bar
|
||||
if start >= seconds:
|
||||
break
|
||||
mask = (t >= start) & (t < end)
|
||||
for j, freq in enumerate(chord):
|
||||
# Fundamental plus four harmonics, decaying — a plucked-string
|
||||
# feel. Enough harmonics to keep spectral flatness inside the
|
||||
# music score's 0.05-0.4 band: with only three, some windows fall
|
||||
# *below* 0.05 (too pure to read as music) and score as speech.
|
||||
for h, amp in ((1, 0.30), (2, 0.12), (3, 0.05), (4, 0.03), (5, 0.02)):
|
||||
out[mask] += amp / (j + 1) * np.sin(2 * np.pi * freq * h * t[mask])
|
||||
# Gentle per-bar envelope so bars are distinguishable but never silent.
|
||||
env = 0.8 + 0.2 * np.sin(2 * np.pi * (t[mask] - start) / bar)
|
||||
out[mask] *= env
|
||||
|
||||
# Recording-style noise floor. Windows straddling a chord change have a
|
||||
# momentarily sparse spectrum and land just *under* the music score's
|
||||
# 0.05 flatness floor, scoring as speech. This is well below the level
|
||||
# that would disturb tonality — every real recording has one.
|
||||
out += rng.normal(0, 0.004, len(out))
|
||||
return out * 0.45
|
||||
|
||||
|
||||
def make_speech(seconds: float = 8.0, seed: int = 1337) -> np.ndarray:
|
||||
"""Formant-like bursts with pauses — what the classifier must call SPEECH.
|
||||
|
||||
Not real speech, but it carries the features the classifier keys on: a
|
||||
fundamental in the human range, shifting formants, and syllable-rate
|
||||
amplitude modulation with genuine silence between utterances.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
t = np.linspace(0, seconds, int(RATE * seconds), endpoint=False)
|
||||
out = np.zeros_like(t)
|
||||
|
||||
pos = 0.3 # leading pause
|
||||
while pos < seconds - 0.4:
|
||||
syl = rng.uniform(0.12, 0.28) # syllable length
|
||||
mask = (t >= pos) & (t < pos + syl)
|
||||
if mask.any():
|
||||
local = t[mask] - pos
|
||||
frac = local / syl
|
||||
|
||||
# Pitch CONTOUR, not a constant. This is the single feature that
|
||||
# separates this fixture from music. `_detect_tonality` looks for
|
||||
# an autocorrelation peak > 0.5 in the 50-1000 Hz lag range; a
|
||||
# fixed f0 is perfectly periodic there, scores is_tonal=True, and
|
||||
# hands the music score a free 0.3 that speech cannot outrun.
|
||||
# Real voices glide and jitter, so the periodicity never locks.
|
||||
f0_start = rng.uniform(95, 165)
|
||||
f0_end = f0_start * rng.uniform(0.72, 1.38) # rise or fall
|
||||
f0 = f0_start + (f0_end - f0_start) * frac
|
||||
# Cycle-to-cycle jitter on top of the glide (~2% is human).
|
||||
f0 *= 1.0 + 0.02 * rng.standard_normal(len(local))
|
||||
# Integrate frequency to phase — with a varying f0, `2*pi*f*t`
|
||||
# would be wrong (that is a chirp only if f is the *instantaneous*
|
||||
# rate, which it is not once f0 itself moves).
|
||||
ph0 = 2 * np.pi * np.cumsum(f0) / RATE
|
||||
|
||||
# Two formants, swept across the syllable. The ranges deliberately
|
||||
# avoid the DTMF bands (rows 697-941, columns 1209-1633): a formant
|
||||
# pair landing on both trips the Goertzel detector and the whole
|
||||
# utterance is classified as a keypress.
|
||||
f1 = rng.uniform(300, 620) + rng.uniform(-40, 40) * frac
|
||||
f2 = rng.uniform(1750, 2600) + rng.uniform(-120, 120) * frac
|
||||
sig = (0.50 * np.sin(ph0)
|
||||
+ 0.30 * np.sin(2 * np.pi * f1 * local)
|
||||
+ 0.18 * np.sin(2 * np.pi * f2 * local))
|
||||
# Aspiration noise — HIGH-PASSED, not broadband. Real speech noise
|
||||
# sits above the formants; flat noise puts energy in every
|
||||
# Goertzel bin, so the strongest DTMF row and column both clear
|
||||
# the detector's `total_power * 0.1` threshold and every syllable
|
||||
# reads as a keypress. A first-difference filter (y[n]-y[n-1]) is
|
||||
# a cheap +6dB/octave tilt that leaves the 697-1633 Hz DTMF bands
|
||||
# comparatively empty. The 0.09 level is chosen for margin: it puts
|
||||
# spectral flatness at ~0.46, mid-way through the 0.1-0.5 band the
|
||||
# speech score rewards, rather than on either edge.
|
||||
noise = rng.standard_normal(len(local) + 1)
|
||||
sig += 0.09 * np.diff(noise)
|
||||
# Raised-cosine envelope: no clicks at syllable edges.
|
||||
sig *= np.sin(np.pi * local / syl) ** 0.6
|
||||
out[mask] += sig
|
||||
# Inter-syllable gap; occasionally a longer between-word pause.
|
||||
pos += syl + (rng.uniform(0.25, 0.5) if rng.random() < 0.25
|
||||
else rng.uniform(0.04, 0.12))
|
||||
return out * 0.55
|
||||
|
||||
|
||||
def make_silence(seconds: float = 5.0) -> np.ndarray:
|
||||
"""Near-silence with a trace of noise — real lines are never digitally flat."""
|
||||
rng = np.random.default_rng(4242)
|
||||
return rng.normal(0, 0.0006, int(RATE * seconds))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
outdir = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Generating lab audio into {outdir}/")
|
||||
_write_sln(outdir / "lab-music.sln", make_music())
|
||||
_write_sln(outdir / "lab-speech.sln", make_speech())
|
||||
_write_sln(outdir / "lab-silence.sln", make_silence())
|
||||
print("Done. Deterministic: same bytes on every run.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +1,53 @@
|
||||
"""
|
||||
API surface tests — bearer-token enforcement and route registration order.
|
||||
API surface tests — owner enforcement and route registration order.
|
||||
|
||||
The app is exercised without its lifespan: auth runs before any handler,
|
||||
so a 503 ("Gateway not initialized") proves the token was accepted.
|
||||
so a 503 ("Gateway not initialized") proves the caller was accepted as
|
||||
owner. Auth internals (JWT/PAT resolution) are covered in test_auth.py;
|
||||
here we assert the routers are gated and the routes register in the right
|
||||
order. In dev-owner mode (SSO disabled) a tokenless request is the owner.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from starlette.routing import Match
|
||||
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import get_settings
|
||||
|
||||
TOKEN = "test-token-for-suite"
|
||||
from db.database import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_enabled(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dev_owner(monkeypatch):
|
||||
"""SSO disabled — every request resolves to the dev owner."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sso_enabled(monkeypatch):
|
||||
"""SSO enabled with no credentials supplied → 401 on protected routes."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -28,36 +57,34 @@ async def client():
|
||||
yield c
|
||||
|
||||
|
||||
class TestBearerToken:
|
||||
async def test_missing_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get("/api/calls/active")
|
||||
class TestOwnerGate:
|
||||
async def test_dev_owner_reaches_handler(self, dev_owner, mem_db, client):
|
||||
resp = await client.get("/api/v1/calls/active")
|
||||
assert resp.status_code == 503 # dev-owner accepted; handler 503s (no lifespan)
|
||||
|
||||
async def test_sso_missing_credentials_rejected(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get("/api/v1/calls/active")
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
async def test_wrong_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/calls/active", headers={"Authorization": "Bearer wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/calls/active", headers={"Authorization": f"Bearer {TOKEN}"}
|
||||
)
|
||||
# No lifespan ran, so the handler itself 503s — auth was accepted
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||
resp = await client.get("/api/calls/active")
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_all_api_routers_protected(self, token_enabled, client):
|
||||
for path in ("/api/calls/active", "/api/call-flows/", "/api/devices/",
|
||||
"/api/routing/rules", "/api/calls/history"):
|
||||
async def test_all_api_routers_protected(self, sso_enabled, mem_db, client):
|
||||
for path in (
|
||||
"/api/v1/calls/active",
|
||||
"/api/v1/call-flows/",
|
||||
"/api/v1/devices/",
|
||||
"/api/v1/routing/rules",
|
||||
"/api/v1/calls/history",
|
||||
"/api/v1/tokens",
|
||||
):
|
||||
resp = await client.get(path)
|
||||
assert resp.status_code == 401, path
|
||||
|
||||
async def test_auth_routes_are_public(self, sso_enabled, mem_db, client):
|
||||
"""The OIDC endpoints must be reachable without a token."""
|
||||
# /auth/me with no token → 401 (not 403); /auth/login → redirect to Casdoor
|
||||
resp = await client.get("/auth/login", follow_redirects=False)
|
||||
assert resp.status_code in (302, 307)
|
||||
|
||||
|
||||
class TestRouteOrder:
|
||||
def _resolve(self, path: str):
|
||||
@@ -76,12 +103,12 @@ class TestRouteOrder:
|
||||
return None
|
||||
|
||||
def test_history_not_shadowed_by_call_id(self):
|
||||
route = self._resolve("/api/calls/history")
|
||||
route = self._resolve("/api/v1/calls/history")
|
||||
assert route is not None
|
||||
assert route.endpoint.__name__ == "list_history"
|
||||
|
||||
def test_call_id_still_matches(self):
|
||||
route = self._resolve("/api/calls/call_abc123")
|
||||
route = self._resolve("/api/v1/calls/call_abc123")
|
||||
assert route is not None
|
||||
assert route.endpoint.__name__ == "get_call"
|
||||
|
||||
|
||||
239
tests/test_auth.py
Normal file
239
tests/test_auth.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Auth tests — Casdoor JWT + PAT resolution, owner gating, dev-owner mode.
|
||||
|
||||
No live Casdoor: we generate an RSA keypair, stub the JWKS client so
|
||||
`_decode_casdoor_jwt` trusts our public key, and mint RS256 JWTs locally.
|
||||
The app is exercised without its lifespan, so a 503 ("Gateway not
|
||||
initialized") proves auth was accepted and the request reached a handler.
|
||||
|
||||
DB access (resolve_bearer → users/PATs) hits an in-memory SQLite database
|
||||
wired in via the `mem_db` fixture, mirroring tests/test_data_layer.py.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import auth as authmod
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import get_settings
|
||||
from db.database import Base, PersonalAccessToken, User
|
||||
|
||||
ENDPOINT = "https://id.example.test"
|
||||
OWNER = "owner@example.test"
|
||||
|
||||
|
||||
# ── RSA keypair + JWKS stub ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def keypair():
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
return private_pem, key.public_key()
|
||||
|
||||
|
||||
def _mint(private_pem, *, sub, name, email=None, exp_delta=3600):
|
||||
claims = {
|
||||
"iss": ENDPOINT,
|
||||
"sub": sub,
|
||||
"name": name,
|
||||
"displayName": name,
|
||||
"exp": int(time.time()) + exp_delta,
|
||||
"iat": int(time.time()),
|
||||
}
|
||||
if email:
|
||||
claims["email"] = email
|
||||
return jwt.encode(claims, private_pem, algorithm="RS256")
|
||||
|
||||
|
||||
class _StubJWKS:
|
||||
"""Stands in for jwt.PyJWKClient — returns our fixed public key."""
|
||||
|
||||
def __init__(self, public_key):
|
||||
self._key = public_key
|
||||
|
||||
def get_signing_key_from_jwt(self, token):
|
||||
class _K:
|
||||
key = self._key
|
||||
|
||||
return _K()
|
||||
|
||||
def fetch_data(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sso_enabled(monkeypatch, keypair):
|
||||
"""Enable Casdoor SSO with a known owner and a stubbed JWKS client."""
|
||||
_, public_key = keypair
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings.casdoor, "enabled", True)
|
||||
monkeypatch.setattr(settings.casdoor, "endpoint", ENDPOINT)
|
||||
monkeypatch.setattr(settings, "owner_name", OWNER)
|
||||
monkeypatch.setattr(authmod, "_jwks_client", _StubJWKS(public_key))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _seed_user(factory, *, name, casdoor_sub=None, email=None) -> str:
|
||||
uid = uuid.uuid4().hex
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
User(id=uid, name=name, display_name=name, email=email, casdoor_sub=casdoor_sub)
|
||||
)
|
||||
await session.commit()
|
||||
return uid
|
||||
|
||||
|
||||
async def _seed_pat(factory, user_id, *, revoked=False, expires_at=None) -> str:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
plaintext = authmod.PAT_PREFIX + uuid.uuid4().hex
|
||||
async with factory() as session:
|
||||
pat = PersonalAccessToken(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user_id,
|
||||
name="test",
|
||||
token_hash=authmod.hash_token(plaintext),
|
||||
token_prefix=plaintext[: len(authmod.PAT_PREFIX) + 4],
|
||||
revoked_at=(datetime.now(UTC) if revoked else None),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(pat)
|
||||
await session.commit()
|
||||
return plaintext
|
||||
|
||||
|
||||
PROTECTED = "/api/v1/calls/active"
|
||||
|
||||
|
||||
# ── JWT paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCasdoorJWT:
|
||||
async def test_owner_jwt_reaches_handler(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-owner", name=OWNER, email=OWNER)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 503 # auth accepted; no lifespan → handler 503s
|
||||
|
||||
async def test_non_owner_jwt_forbidden(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_expired_jwt_unauthenticated(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-owner", name=OWNER, exp_delta=-10)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_garbage_token_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": "Bearer not.a.jwt"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_no_credentials_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get(PROTECTED)
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
|
||||
# ── PAT paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPAT:
|
||||
async def test_owner_pat_reaches_handler(self, sso_enabled, mem_db, client):
|
||||
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||
pat = await _seed_pat(mem_db, uid)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_non_owner_pat_forbidden(self, sso_enabled, mem_db, client):
|
||||
uid = await _seed_user(mem_db, name="guest@example.test", casdoor_sub="s-guest")
|
||||
pat = await _seed_pat(mem_db, uid)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_revoked_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||
pat = await _seed_pat(mem_db, uid, revoked=True)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_expired_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||
pat = await _seed_pat(mem_db, uid, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_unknown_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get(
|
||||
PROTECTED, headers={"Authorization": f"Bearer {authmod.PAT_PREFIX}nope"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── Dev-owner mode (SSO disabled) ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDevOwnerMode:
|
||||
async def test_tokenless_request_is_owner(self, monkeypatch, mem_db, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
resp = await client.get(PROTECTED)
|
||||
assert resp.status_code == 503 # dev-owner resolved; handler 503s (no lifespan)
|
||||
|
||||
async def test_auth_me_reports_owner(self, monkeypatch, mem_db, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
resp = await client.get("/auth/me")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_owner"] is True
|
||||
|
||||
|
||||
# ── /auth/me for a non-owner (200 + is_owner:false, not a hard 401) ──────────
|
||||
|
||||
|
||||
class TestAuthMe:
|
||||
async def test_non_owner_gets_200_not_owner(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
|
||||
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_owner"] is False
|
||||
187
tests/test_graceful_degradation.py
Normal file
187
tests/test_graceful_degradation.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Graceful-degradation tests.
|
||||
|
||||
Every external dependency — STT, LLM, TTS — is reachable over the network and
|
||||
can be down. The gateway's rule is that a dead dependency degrades the call
|
||||
rather than killing it, and says so: each failure publishes an `ERROR` event
|
||||
naming the service, so a down Speaches reads as "transcription failed" rather
|
||||
than "the AI is making bad decisions".
|
||||
|
||||
The behaviour is already implemented across the services; these tests exist so a
|
||||
later refactor can't quietly remove it. The failure mode being guarded against is
|
||||
silent: an un-caught exception in one of these paths aborts a live phone call.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from core.event_bus import EventBus
|
||||
from models.events import EventType
|
||||
|
||||
|
||||
def _gateway():
|
||||
"""A gateway stand-in with a real event bus, so events can be asserted."""
|
||||
gw = MagicMock()
|
||||
gw.settings = Settings(database_url="sqlite+aiosqlite:///:memory:")
|
||||
gw.event_bus = EventBus()
|
||||
gw.call_manager = MagicMock()
|
||||
gw.call_manager.add_transcript = AsyncMock()
|
||||
return gw
|
||||
|
||||
|
||||
def _hold_slayer(gateway, transcription):
|
||||
from services.audio_classifier import AudioClassifier
|
||||
from services.hold_slayer import HoldSlayerService
|
||||
|
||||
return HoldSlayerService(
|
||||
gateway=gateway,
|
||||
call_manager=gateway.call_manager,
|
||||
sip_engine=MagicMock(),
|
||||
classifier=AudioClassifier(gateway.settings.classifier),
|
||||
transcription=transcription,
|
||||
settings=gateway.settings,
|
||||
)
|
||||
|
||||
|
||||
async def _errors_for(bus: EventBus, coro):
|
||||
"""Run `coro` while subscribed, returning the ERROR events it published."""
|
||||
import asyncio
|
||||
|
||||
sub = bus.subscribe(event_types={EventType.ERROR})
|
||||
try:
|
||||
result = await coro
|
||||
seen = []
|
||||
while True:
|
||||
try:
|
||||
seen.append(sub._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return result, seen
|
||||
finally:
|
||||
bus.unsubscribe(sub)
|
||||
|
||||
|
||||
class TestClassifierWithoutSTT:
|
||||
"""The classifier is spectral: it must not depend on STT at all."""
|
||||
|
||||
def _classifier(self):
|
||||
from services.audio_classifier import AudioClassifier
|
||||
|
||||
return AudioClassifier(Settings(database_url="sqlite+aiosqlite:///:memory:").classifier)
|
||||
|
||||
def test_classify_takes_only_audio(self):
|
||||
# A transcript parameter would make STT a hard dependency of hold
|
||||
# detection — the thing this checkbox is about.
|
||||
import inspect
|
||||
|
||||
params = set(inspect.signature(self._classifier().classify_chunk).parameters)
|
||||
assert params == {"audio_data"}
|
||||
|
||||
async def test_classifies_with_no_stt_service_anywhere(self):
|
||||
# Silence is the cheapest deterministic input; the point is that a
|
||||
# classification is produced at all with no STT in the picture.
|
||||
result = await self._classifier().classify(b"\x00\x00" * 16000)
|
||||
assert result.audio_type is not None
|
||||
|
||||
|
||||
class TestTranscriptionDegradation:
|
||||
async def test_hold_slayer_transcribe_returns_empty_on_failure(self):
|
||||
gw = _gateway()
|
||||
stt = MagicMock()
|
||||
stt.transcribe = AsyncMock(side_effect=RuntimeError("Connection refused"))
|
||||
svc = _hold_slayer(gw, stt)
|
||||
|
||||
text, errors = await _errors_for(gw.event_bus, svc._transcribe("call-1", b"\x00" * 320))
|
||||
|
||||
assert text == "" # empty transcript, not an exception
|
||||
assert len(errors) == 1
|
||||
assert errors[0].data["service"] == "transcription"
|
||||
|
||||
async def test_error_event_names_the_service(self):
|
||||
# "transcription failed" vs "the AI decided badly" — the whole reason
|
||||
# transcribe() raises instead of swallowing.
|
||||
gw = _gateway()
|
||||
stt = MagicMock()
|
||||
stt.transcribe = AsyncMock(side_effect=RuntimeError("Connection refused"))
|
||||
svc = _hold_slayer(gw, stt)
|
||||
|
||||
_, errors = await _errors_for(gw.event_bus, svc._transcribe("call-1", b"\x00" * 320))
|
||||
assert "Connection refused" in errors[0].data["error"]
|
||||
|
||||
async def test_service_error_survives_a_dead_event_bus(self):
|
||||
# Degradation reporting must not itself become a failure path.
|
||||
gw = _gateway()
|
||||
gw.event_bus.publish = AsyncMock(side_effect=RuntimeError("bus down"))
|
||||
stt = MagicMock()
|
||||
stt.transcribe = AsyncMock(side_effect=RuntimeError("stt down"))
|
||||
svc = _hold_slayer(gw, stt)
|
||||
|
||||
assert await svc._transcribe("call-1", b"\x00" * 320) == ""
|
||||
|
||||
async def test_transcription_marks_itself_unavailable(self):
|
||||
# /health reads this flag; a failure that doesn't record itself makes
|
||||
# the probe lie.
|
||||
import httpx
|
||||
|
||||
from services.transcription import TranscriptionService
|
||||
|
||||
svc = TranscriptionService(Settings(database_url="sqlite+aiosqlite:///:memory:").speaches)
|
||||
client = MagicMock()
|
||||
client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
svc._client = client
|
||||
svc._client.is_closed = False
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await svc.transcribe(b"\x00" * 320)
|
||||
assert svc.available is False
|
||||
|
||||
|
||||
class TestReceptionistDegradation:
|
||||
def _receptionist(self, gateway, **kw):
|
||||
from services.receptionist import ReceptionistService
|
||||
|
||||
return ReceptionistService(gateway=gateway, **kw)
|
||||
|
||||
async def test_llm_failure_falls_back_to_a_usable_decision(self):
|
||||
gw = _gateway()
|
||||
svc = self._receptionist(gw)
|
||||
call = MagicMock(id="call-1", remote_number="+15551234567")
|
||||
|
||||
llm = MagicMock()
|
||||
llm.chat_json = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
import services.llm_client as llm_mod
|
||||
|
||||
original = llm_mod.get_llm
|
||||
llm_mod.get_llm = lambda: llm
|
||||
try:
|
||||
result, errors = await _errors_for(
|
||||
gw.event_bus, svc._classify(call, "I need to speak to someone", None)
|
||||
)
|
||||
finally:
|
||||
llm_mod.get_llm = original
|
||||
|
||||
# A decision still comes back, so the call can proceed.
|
||||
assert result["recommended_action"] in {"ring", "message", "reject"}
|
||||
assert errors[0].data["service"] == "llm"
|
||||
|
||||
async def test_no_transcription_service_yields_empty_not_crash(self):
|
||||
# transcription=None is a valid wiring (STT not configured).
|
||||
gw = _gateway()
|
||||
svc = self._receptionist(gw, transcription=None)
|
||||
assert svc.transcription is None
|
||||
|
||||
|
||||
class TestHealthReportsDegradation:
|
||||
"""A degraded gateway must read as degraded — /health may not lie."""
|
||||
|
||||
def test_availability_helper_distinguishes_unknown_from_down(self):
|
||||
# Four distinct states, because "not wired up" and "wired up but the
|
||||
# remote is refusing connections" are different operator problems.
|
||||
import main
|
||||
|
||||
assert main._availability(None) == "not attached"
|
||||
assert main._availability(MagicMock(available=None)) == "unknown (no requests yet)"
|
||||
assert main._availability(MagicMock(available=True)) == "ok"
|
||||
assert main._availability(MagicMock(available=False)) == "unreachable"
|
||||
@@ -260,6 +260,11 @@ class TestMockSIPEngine:
|
||||
status = await engine.get_trunk_status()
|
||||
assert status["registered"] is False
|
||||
|
||||
# Starting the mock engine must NOT report a registered trunk: /health
|
||||
# requires a registered trunk to be "healthy", so a mock that claimed
|
||||
# registration would let a gateway that cannot place calls go green.
|
||||
await engine.start()
|
||||
status = await engine.get_trunk_status()
|
||||
assert status["registered"] is True
|
||||
assert status["registered"] is False
|
||||
assert status["mock"] is True
|
||||
assert status["reason"] == "No SIP trunk configured (mock mode)"
|
||||
|
||||
109
tests/test_lab_fixtures.py
Normal file
109
tests/test_lab_fixtures.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Lab audio fixtures — the classifier must agree with what each one claims to be.
|
||||
|
||||
These guard the *fixtures*, not the classifier. `tests/lab/sounds/generate.py`
|
||||
synthesises music/speech/silence that the Asterisk lab plays down a real call;
|
||||
if a fixture drifts into the wrong class, every lab result built on it is
|
||||
quietly meaningless — a hold-music scenario that never classifies as music
|
||||
proves nothing about the hold slayer.
|
||||
|
||||
The first version of these fixtures passed on the opening 3s window and drifted
|
||||
to MUSIC after, which a single-window check would not have caught. Hence the
|
||||
sweep across every window.
|
||||
|
||||
Skipped when the fixtures have not been generated: they are gitignored (~680K,
|
||||
reproducible from a fixed seed), so a fresh checkout has none until
|
||||
`python tests/lab/sounds/generate.py` runs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from models.call import AudioClassification
|
||||
from services.audio_classifier import SAMPLE_RATE, AudioClassifier
|
||||
|
||||
SOUNDS_DIR = Path(__file__).parent / "lab" / "sounds"
|
||||
GENERATOR = SOUNDS_DIR / "generate.py"
|
||||
|
||||
# The lab writes 8 kHz .sln; the classifier works at 16 kHz.
|
||||
LAB_RATE = 8000
|
||||
WINDOW_SAMPLES = SAMPLE_RATE * 3 # classifier's 3s analysis window
|
||||
|
||||
FIXTURES = [
|
||||
("lab-music.sln", AudioClassification.MUSIC),
|
||||
("lab-speech.sln", AudioClassification.LIVE_HUMAN),
|
||||
("lab-silence.sln", AudioClassification.SILENCE),
|
||||
]
|
||||
|
||||
|
||||
def _load_16k(path: Path) -> np.ndarray:
|
||||
"""Load an 8 kHz .sln and upsample to the classifier's 16 kHz."""
|
||||
return np.repeat(np.fromfile(path, dtype="<i2"), SAMPLE_RATE // LAB_RATE)
|
||||
|
||||
|
||||
def _windows(samples: np.ndarray, step: int):
|
||||
"""Yield successive analysis windows; at least one, even for short files."""
|
||||
end = max(1, len(samples) - WINDOW_SAMPLES)
|
||||
for offset in range(0, end, step):
|
||||
yield samples[offset : offset + WINDOW_SAMPLES].astype("<i2").tobytes()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def classifier():
|
||||
return AudioClassifier(settings=Settings().classifier)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,expected", FIXTURES)
|
||||
def test_fixture_classifies_correctly_in_every_window(filename, expected, classifier):
|
||||
"""Every window must classify correctly — not just the first.
|
||||
|
||||
Stepped at half the window length so windows overlap: a fixture that only
|
||||
works on aligned boundaries would still be a trap in a live call, where
|
||||
the window has no relationship to where the audio started.
|
||||
"""
|
||||
path = SOUNDS_DIR / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"{filename} not generated — run {GENERATOR}")
|
||||
|
||||
samples = _load_16k(path)
|
||||
results = [
|
||||
classifier.classify_chunk(w).audio_type
|
||||
for w in _windows(samples, step=WINDOW_SAMPLES // 2)
|
||||
]
|
||||
|
||||
wrong = [(i, r.value) for i, r in enumerate(results) if r is not expected]
|
||||
assert not wrong, (
|
||||
f"{filename} must classify as {expected.value} in all "
|
||||
f"{len(results)} windows; wrong: {wrong}"
|
||||
)
|
||||
|
||||
|
||||
def test_generator_is_deterministic(tmp_path):
|
||||
"""Same bytes on every run — the whole point of synthesising them.
|
||||
|
||||
Real hold music varies per call, so a classifier regression on the PSTN is
|
||||
indistinguishable from noise. Fixed-seed audio makes the answer binary.
|
||||
"""
|
||||
if not GENERATOR.exists():
|
||||
pytest.skip("generator not present")
|
||||
|
||||
def run(target: Path) -> dict[str, bytes]:
|
||||
subprocess.run(
|
||||
[sys.executable, str(GENERATOR), str(target)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return {p.name: p.read_bytes() for p in sorted(target.glob("*.sln"))}
|
||||
|
||||
first = run(tmp_path / "a")
|
||||
second = run(tmp_path / "b")
|
||||
|
||||
assert first, "generator produced no .sln files"
|
||||
assert first.keys() == second.keys()
|
||||
for name in first:
|
||||
assert first[name] == second[name], f"{name} differs between runs"
|
||||
137
tests/test_learner.py
Normal file
137
tests/test_learner.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Call-flow learner tests.
|
||||
|
||||
Exploration discoveries become a linked CallFlow, survive with the
|
||||
persisted call record, and the learn_call_flow MCP tool turns them
|
||||
into a stored flow (refining on subsequent calls).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import db.database as dbmod
|
||||
import services.llm_client as llm_mod
|
||||
from core.call_manager import CallManager
|
||||
from core.event_bus import EventBus
|
||||
from db.database import Base
|
||||
from mcp_server.server import create_mcp_server
|
||||
from models.call import CallStatus
|
||||
from models.call_flow import ActionType
|
||||
from services import call_persistence as store
|
||||
from services.call_flow_learner import CallFlowLearner
|
||||
|
||||
DISCOVERIES = [
|
||||
{"timestamp": 1.0, "audio_type": "ringing", "confidence": 0.9,
|
||||
"transcript": "", "action_taken": None},
|
||||
{"timestamp": 4.0, "audio_type": "ivr_prompt", "confidence": 0.8,
|
||||
"transcript": "press 1 for english press 2 for french",
|
||||
"action_taken": {"dtmf": "1"}},
|
||||
{"timestamp": 8.0, "audio_type": "ivr_prompt", "confidence": 0.8,
|
||||
"transcript": "press 1 for billing press 2 for support press 0 for an agent",
|
||||
"action_taken": {"dtmf": "0"}},
|
||||
{"timestamp": 12.0, "audio_type": "music", "confidence": 0.9,
|
||||
"transcript": "", "action_taken": None},
|
||||
{"timestamp": 200.0, "audio_type": "live_human", "confidence": 0.85,
|
||||
"transcript": "thank you for holding, how can I help",
|
||||
"action_taken": None},
|
||||
]
|
||||
|
||||
|
||||
class TestBuildFlow:
|
||||
async def test_discoveries_become_linked_steps(self):
|
||||
learner = CallFlowLearner(llm_client=None)
|
||||
flow = await learner.build_flow(
|
||||
phone_number="+18005551234",
|
||||
discovered_steps=DISCOVERIES,
|
||||
intent="dispute a charge",
|
||||
)
|
||||
|
||||
# ringing is skipped; menus/hold/human map to actions in order
|
||||
assert [s.action for s in flow.steps] == [
|
||||
ActionType.DTMF, ActionType.DTMF, ActionType.HOLD, ActionType.TRANSFER,
|
||||
]
|
||||
assert [s.action_value for s in flow.steps[:2]] == ["1", "0"]
|
||||
assert [s.next_step for s in flow.steps[:-1]] == [s.id for s in flow.steps[1:]]
|
||||
assert "auto-learned" in flow.tags
|
||||
assert flow.phone_number == "+18005551234"
|
||||
|
||||
|
||||
class TestExplorationPersistence:
|
||||
async def test_exploration_steps_survive_with_the_record(self, mem_db):
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call("+18005551234", intent="dispute a charge")
|
||||
call.exploration_steps.extend(DISCOVERIES)
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
|
||||
async with mem_db() as session:
|
||||
record = await store.get_record(session, call.id)
|
||||
assert record.metadata_["exploration_steps"] == DISCOVERIES
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_llm(monkeypatch):
|
||||
"""learn_call_flow must work without an LLM (labels stay heuristic)."""
|
||||
monkeypatch.setattr(llm_mod, "_shared_client", None)
|
||||
monkeypatch.setattr(llm_mod, "_shared_failed", True)
|
||||
|
||||
|
||||
class TestLearnCallFlowTool:
|
||||
async def _completed_exploration_call(self, number: str) -> str:
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call(number, intent="dispute a charge")
|
||||
call.exploration_steps.extend(DISCOVERIES)
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
return call.id
|
||||
|
||||
async def test_learns_then_refines(self, mem_db, no_llm):
|
||||
call_id = await self._completed_exploration_call("+18005551234")
|
||||
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
|
||||
assert "Learned new flow" in result.content[0].text
|
||||
|
||||
async with mem_db() as session:
|
||||
row = await store.get_flow_by_number(session, "+18005551234")
|
||||
assert row is not None
|
||||
assert len(row.steps) == 4
|
||||
assert "auto-learned" in row.tags
|
||||
|
||||
result = await client.call_tool("learn_call_flow", {"call_id": call_id})
|
||||
assert "Refined existing flow" in result.content[0].text
|
||||
|
||||
async def test_call_without_exploration_data(self, mem_db, no_llm):
|
||||
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
|
||||
call = await cm.create_call("+15550001111")
|
||||
await cm.end_call(call.id, CallStatus.COMPLETED)
|
||||
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("learn_call_flow", {"call_id": call.id})
|
||||
assert "no exploration data" in result.content[0].text
|
||||
|
||||
async def test_unknown_call(self, mem_db, no_llm):
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"learn_call_flow", {"call_id": "call_nope"}
|
||||
)
|
||||
assert "No record found" in result.content[0].text
|
||||
183
tests/test_logging_config.py
Normal file
183
tests/test_logging_config.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Structured-logging tests.
|
||||
|
||||
The two things worth guarding are the ones that are easy to break silently:
|
||||
uvicorn's access logger must actually route through our formatter (it sets
|
||||
`propagate = False` and brings its own handler), and its arg tuple must land as
|
||||
real JSON fields rather than a pre-formatted string.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from core.logging_config import JSONFormatter, configure_logging
|
||||
|
||||
|
||||
def _record(name="test.logger", level=logging.INFO, msg="hello", args=None, **extra):
|
||||
record = logging.LogRecord(
|
||||
name=name, level=level, pathname=__file__, lineno=1, msg=msg, args=args, exc_info=None
|
||||
)
|
||||
for key, value in extra.items():
|
||||
setattr(record, key, value)
|
||||
return record
|
||||
|
||||
|
||||
def _emit(record):
|
||||
return json.loads(JSONFormatter().format(record))
|
||||
|
||||
|
||||
class TestJSONFormatter:
|
||||
def test_emits_single_line_json_with_core_fields(self):
|
||||
out = JSONFormatter().format(_record())
|
||||
assert "\n" not in out
|
||||
payload = json.loads(out)
|
||||
assert payload["msg"] == "hello"
|
||||
assert payload["level"] == "INFO"
|
||||
assert payload["logger"] == "test.logger"
|
||||
|
||||
def test_timestamp_is_utc_rfc3339_with_date(self):
|
||||
# logging's default asctime is local-time and date-less, which is
|
||||
# exactly what makes text logs hard to correlate in Loki.
|
||||
ts = _emit(_record())["ts"]
|
||||
assert ts.endswith("+00:00")
|
||||
assert "T" in ts
|
||||
|
||||
def test_interpolates_message_args(self):
|
||||
assert _emit(_record(msg="call %s ended", args=("abc123",)))["msg"] == "call abc123 ended"
|
||||
|
||||
def test_extra_fields_are_promoted(self):
|
||||
payload = _emit(_record(call_id="c-1", duration=12.5))
|
||||
assert payload["call_id"] == "c-1"
|
||||
assert payload["duration"] == 12.5
|
||||
|
||||
def test_uvicorn_color_message_is_dropped(self):
|
||||
# Uvicorn ships an ANSI-coloured copy of the message via extra=; letting
|
||||
# it through puts escape codes in Loki.
|
||||
payload = _emit(
|
||||
_record(name="uvicorn.error", msg="Started", color_message="\x1b[36mStarted\x1b[0m")
|
||||
)
|
||||
assert "color_message" not in payload
|
||||
assert "\x1b" not in json.dumps(payload)
|
||||
|
||||
def test_non_json_native_extra_is_stringified(self):
|
||||
payload = _emit(_record(obj=object()))
|
||||
assert isinstance(payload["obj"], str)
|
||||
|
||||
def test_secretstr_extra_stays_masked(self):
|
||||
# Config secrets are SecretStr precisely so an accidental log can't leak
|
||||
# them; stringification must preserve that.
|
||||
from pydantic import SecretStr
|
||||
|
||||
payload = _emit(_record(secret=SecretStr("hs_pat_supersecret")))
|
||||
assert "supersecret" not in json.dumps(payload)
|
||||
|
||||
def test_exception_is_captured(self):
|
||||
try:
|
||||
raise ValueError("boom")
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
record = _record(level=logging.ERROR, msg="failed")
|
||||
record.exc_info = sys.exc_info()
|
||||
payload = _emit(record)
|
||||
assert "ValueError: boom" in payload["exc"]
|
||||
|
||||
def test_thread_name_included_only_off_main_thread(self):
|
||||
# Which execution context logged a line is the first question when
|
||||
# debugging a call across the asyncio/Sippy/PJSUA2 boundary.
|
||||
on_main = _record()
|
||||
on_main.threadName = "MainThread"
|
||||
assert "thread" not in _emit(on_main)
|
||||
|
||||
off_main = _record()
|
||||
off_main.threadName = "sippy-ed"
|
||||
assert _emit(off_main)["thread"] == "sippy-ed"
|
||||
|
||||
|
||||
class TestAccessLogFields:
|
||||
def _access(self, status=200):
|
||||
return _emit(
|
||||
_record(
|
||||
name="uvicorn.access",
|
||||
msg='%s - "%s %s HTTP/%s" %d',
|
||||
args=("127.0.0.1:5050", "GET", "/api/v1/calls", "1.1", status),
|
||||
)
|
||||
)
|
||||
|
||||
def test_arg_tuple_becomes_structured_fields(self):
|
||||
payload = self._access()
|
||||
assert payload["method"] == "GET"
|
||||
assert payload["path"] == "/api/v1/calls"
|
||||
assert payload["client_addr"] == "127.0.0.1:5050"
|
||||
assert payload["http_version"] == "1.1"
|
||||
|
||||
def test_status_code_is_a_number_not_a_string(self):
|
||||
# So Loki can range-filter on it (status_code >= 500).
|
||||
assert self._access(503)["status_code"] == 503
|
||||
|
||||
def test_access_line_is_not_pre_formatted(self):
|
||||
# The whole point: no interpolated request line to regex back apart.
|
||||
assert "msg" not in self._access()
|
||||
|
||||
def test_unexpected_arg_shape_falls_back_to_message(self):
|
||||
# A logging path that raises would take out the request; degrade instead.
|
||||
payload = _emit(_record(name="uvicorn.access", msg="just a string", args=None))
|
||||
assert payload["msg"] == "just a string"
|
||||
|
||||
|
||||
class TestConfigureLogging:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore(self):
|
||||
root = logging.getLogger()
|
||||
saved = root.handlers[:], root.level
|
||||
yield
|
||||
root.handlers[:] = saved[0]
|
||||
root.setLevel(saved[1])
|
||||
|
||||
def _uvicorn_defaults(self):
|
||||
"""Reproduce what uvicorn does to its loggers at startup."""
|
||||
from uvicorn.config import LOGGING_CONFIG
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
def test_takes_over_uvicorn_handlers(self):
|
||||
self._uvicorn_defaults()
|
||||
access = logging.getLogger("uvicorn.access")
|
||||
assert access.propagate is False # uvicorn's default, the problem
|
||||
|
||||
configure_logging("json", "info")
|
||||
assert access.propagate is True
|
||||
assert access.handlers == []
|
||||
|
||||
def test_json_mode_installs_json_formatter(self):
|
||||
configure_logging("json", "info")
|
||||
assert isinstance(logging.getLogger().handlers[0].formatter, JSONFormatter)
|
||||
|
||||
def test_text_mode_does_not(self):
|
||||
configure_logging("text", "info")
|
||||
assert not isinstance(logging.getLogger().handlers[0].formatter, JSONFormatter)
|
||||
|
||||
def test_is_idempotent(self):
|
||||
# Called at import and again in lifespan; a second call must replace the
|
||||
# handler, not add one, or every line is emitted twice.
|
||||
configure_logging("json", "info")
|
||||
configure_logging("json", "info")
|
||||
assert len(logging.getLogger().handlers) == 1
|
||||
|
||||
def test_respects_log_level(self):
|
||||
configure_logging("json", "warning")
|
||||
assert logging.getLogger().level == logging.WARNING
|
||||
|
||||
|
||||
class TestSettings:
|
||||
def test_defaults_to_text(self):
|
||||
# A JSON-only default would make local development worse.
|
||||
assert Settings(database_url="sqlite+aiosqlite:///:memory:").log_format == "text"
|
||||
|
||||
def test_reads_log_format_env(self, monkeypatch):
|
||||
monkeypatch.setenv("LOG_FORMAT", "json")
|
||||
assert Settings(database_url="sqlite+aiosqlite:///:memory:").log_format == "json"
|
||||
@@ -6,12 +6,32 @@ Uses the FastMCP in-memory client (no network, no mounted app).
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from config import Settings
|
||||
import db.database as dbmod
|
||||
from config import Settings, get_settings
|
||||
from core.dial_plan import is_emergency_number
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Base
|
||||
from mcp_server.server import create_mcp_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"make_call",
|
||||
"get_call_status",
|
||||
@@ -25,6 +45,7 @@ EXPECTED_TOOLS = {
|
||||
"get_call_recording",
|
||||
"get_call_summary",
|
||||
"search_call_history",
|
||||
"learn_call_flow",
|
||||
"list_devices",
|
||||
"gateway_status",
|
||||
}
|
||||
@@ -42,11 +63,64 @@ class TestToolSurface:
|
||||
tools = {t.name for t in await client.list_tools()}
|
||||
assert tools == EXPECTED_TOOLS
|
||||
|
||||
async def test_auth_configured_when_token_given(self):
|
||||
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None
|
||||
async def test_no_fastmcp_auth_configured(self):
|
||||
# Auth for /mcp is enforced by the ASGI _owner_only_mcp guard in
|
||||
# main.py, not on the FastMCP instance itself.
|
||||
assert create_mcp_server(lambda: None).auth is None
|
||||
|
||||
|
||||
class TestMcpOwnerGuard:
|
||||
"""The ASGI wrapper gates /mcp before the inner app runs."""
|
||||
|
||||
def _wrapped(self):
|
||||
import main
|
||||
|
||||
calls = {"inner": 0}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
calls["inner"] += 1
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
return main._owner_only_mcp(inner), calls
|
||||
|
||||
async def _run(self, app, headers):
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(msg):
|
||||
sent.append(msg)
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/",
|
||||
"headers": headers,
|
||||
"query_string": b"",
|
||||
}
|
||||
await app(scope, receive, send)
|
||||
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
|
||||
return status
|
||||
|
||||
async def test_missing_token_401(self, monkeypatch, mem_db):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||
app, calls = self._wrapped()
|
||||
status = await self._run(app, headers=[])
|
||||
assert status == 401
|
||||
assert calls["inner"] == 0
|
||||
|
||||
async def test_dev_owner_passes(self, monkeypatch, mem_db):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
app, calls = self._wrapped()
|
||||
status = await self._run(app, headers=[])
|
||||
assert status == 200
|
||||
assert calls["inner"] == 1
|
||||
|
||||
|
||||
class TestGatewayResolution:
|
||||
async def test_tool_errors_cleanly_before_gateway_ready(self):
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
|
||||
82
tests/test_oauth_metadata.py
Normal file
82
tests/test_oauth_metadata.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
OAuth discovery metadata tests (RFC 9728 / RFC 8414 / RFC 7591).
|
||||
|
||||
MCP clients that get a 401 from /mcp perform OAuth discovery. These
|
||||
endpoints are unauthenticated and served straight from main.app.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import main
|
||||
from config import get_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch):
|
||||
# These endpoints derive their URLs from PUBLIC_BASE_URL when it is set,
|
||||
# falling back to the request's Host header. Pin it empty so the assertions
|
||||
# below exercise the header path and can't be overridden by a developer's
|
||||
# real .env.
|
||||
monkeypatch.setattr(get_settings(), "public_base_url", "")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestProtectedResourceMetadata:
|
||||
async def test_resource_advertises_mcp_path(self, client):
|
||||
resp = await client.get("/.well-known/oauth-protected-resource")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# mcp-remote verifies this matches the URL it connected to.
|
||||
assert body["resource"] == "http://test/mcp"
|
||||
assert body["authorization_servers"] == ["http://test"]
|
||||
|
||||
async def test_mcp_suffixed_variant(self, client):
|
||||
resp = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["resource"] == "http://test/mcp"
|
||||
|
||||
|
||||
class TestAuthorizationServerMetadata:
|
||||
async def test_advertises_casdoor_when_enabled(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
resp = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["issuer"] == "https://id.example.test"
|
||||
assert body["jwks_uri"] == "https://id.example.test/.well-known/jwks"
|
||||
assert body["registration_endpoint"] == "http://test/register"
|
||||
|
||||
async def test_dev_mode_advertises_local(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
resp = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["issuer"] == "http://test"
|
||||
assert body["authorization_endpoint"] == "http://test/auth/login"
|
||||
|
||||
|
||||
class TestDynamicRegistration:
|
||||
async def test_registers_client(self, client):
|
||||
resp = await client.post(
|
||||
"/register",
|
||||
json={"redirect_uris": ["http://localhost/cb"], "client_name": "test"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert "client_id" in body
|
||||
assert body["redirect_uris"] == ["http://localhost/cb"]
|
||||
|
||||
async def test_rejects_missing_redirect_uris(self, client):
|
||||
resp = await client.post("/register", json={"client_name": "test"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "invalid_redirect_uri"
|
||||
|
||||
async def test_rejects_non_json(self, client):
|
||||
resp = await client.post(
|
||||
"/register", content=b"not json", headers={"content-type": "application/json"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
185
tests/test_rate_limit.py
Normal file
185
tests/test_rate_limit.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Rate-limiting tests.
|
||||
|
||||
The limiter guards the unauthenticated `/auth/*` edge — the only routes that
|
||||
must answer before an identity exists. Everything else is owner-gated, so a
|
||||
limit there would mostly throttle the single legitimate operator.
|
||||
|
||||
The properties worth pinning: the cap actually blocks, windows expire, clients
|
||||
and routes don't share a bucket, the store can't grow without bound, and
|
||||
identity comes from the socket peer rather than a spoofable header.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
import main
|
||||
from core.rate_limit import RateLimiter, client_key, get_limiter, rate_limit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_limiter():
|
||||
get_limiter().reset()
|
||||
yield
|
||||
get_limiter().reset()
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
def test_allows_up_to_the_limit(self):
|
||||
rl = RateLimiter(limit=3, window_seconds=60)
|
||||
assert [rl.check("k", now=100.0)[0] for _ in range(3)] == [True, True, True]
|
||||
|
||||
def test_blocks_past_the_limit(self):
|
||||
rl = RateLimiter(limit=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
rl.check("k", now=100.0)
|
||||
allowed, retry_after = rl.check("k", now=100.0)
|
||||
assert allowed is False
|
||||
assert retry_after > 0
|
||||
|
||||
def test_window_expiry_resets_the_count(self):
|
||||
rl = RateLimiter(limit=2, window_seconds=60)
|
||||
rl.check("k", now=100.0)
|
||||
rl.check("k", now=100.0)
|
||||
assert rl.check("k", now=100.0)[0] is False
|
||||
# A full window later, the caller is welcome again.
|
||||
assert rl.check("k", now=161.0)[0] is True
|
||||
|
||||
def test_retry_after_shrinks_as_the_window_drains(self):
|
||||
rl = RateLimiter(limit=1, window_seconds=60)
|
||||
rl.check("k", now=100.0)
|
||||
early = rl.check("k", now=110.0)[1]
|
||||
late = rl.check("k", now=150.0)[1]
|
||||
assert early > late >= 1
|
||||
|
||||
def test_clients_do_not_share_a_bucket(self):
|
||||
rl = RateLimiter(limit=1, window_seconds=60)
|
||||
assert rl.check("auth:me:1.1.1.1", now=100.0)[0] is True
|
||||
# A different client must be unaffected by the first one's usage.
|
||||
assert rl.check("auth:me:2.2.2.2", now=100.0)[0] is True
|
||||
|
||||
def test_routes_do_not_share_a_bucket(self):
|
||||
rl = RateLimiter(limit=1, window_seconds=60)
|
||||
assert rl.check("auth:me:1.1.1.1", now=100.0)[0] is True
|
||||
assert rl.check("auth:callback:1.1.1.1", now=100.0)[0] is True
|
||||
|
||||
def test_per_call_limit_overrides_the_default(self):
|
||||
rl = RateLimiter(limit=100, window_seconds=60)
|
||||
rl.check("k", limit=1, now=100.0)
|
||||
assert rl.check("k", limit=1, now=100.0)[0] is False
|
||||
|
||||
def test_bucket_store_is_bounded(self):
|
||||
# Otherwise a spray of source addresses is itself a memory exhaustion
|
||||
# vector — the thing the limiter exists to prevent.
|
||||
rl = RateLimiter(limit=5, window_seconds=60, max_clients=10)
|
||||
for i in range(50):
|
||||
rl.check(f"client-{i}", now=100.0 + i)
|
||||
assert len(rl._buckets) <= 10
|
||||
|
||||
def test_eviction_drops_oldest_first(self):
|
||||
rl = RateLimiter(limit=5, window_seconds=600, max_clients=3)
|
||||
for i in range(4):
|
||||
rl.check(f"client-{i}", now=100.0 + i)
|
||||
assert "client-0" not in rl._buckets
|
||||
assert "client-3" in rl._buckets
|
||||
|
||||
|
||||
class TestClientKey:
|
||||
def _request(self, peer: str | None, headers: dict | None = None):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/auth/me",
|
||||
"headers": [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
|
||||
"client": (peer, 12345) if peer else None,
|
||||
}
|
||||
from starlette.requests import Request
|
||||
|
||||
return Request(scope)
|
||||
|
||||
def test_uses_the_socket_peer(self):
|
||||
assert client_key(self._request("10.0.0.5"), "auth:me") == "auth:me:10.0.0.5"
|
||||
|
||||
def test_ignores_x_forwarded_for(self):
|
||||
# Trusting a spoofable header would let one client present as
|
||||
# thousands, making the limiter worse than useless.
|
||||
key = client_key(
|
||||
self._request("10.0.0.5", {"X-Forwarded-For": "1.2.3.4"}), "auth:me"
|
||||
)
|
||||
assert key == "auth:me:10.0.0.5"
|
||||
assert "1.2.3.4" not in key
|
||||
|
||||
def test_missing_peer_does_not_crash(self):
|
||||
assert client_key(self._request(None), "auth:me") == "auth:me:unknown"
|
||||
|
||||
|
||||
class TestDependency:
|
||||
"""The FastAPI integration: a 429 with a Retry-After header."""
|
||||
|
||||
def _app(self, limit=2):
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/limited", dependencies=[Depends(rate_limit("test", limit=limit))])
|
||||
async def limited():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/unlimited")
|
||||
async def unlimited():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
async def _get(self, app, path, n=1):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
return [await c.get(path) for _ in range(n)]
|
||||
|
||||
async def test_returns_429_past_the_limit(self):
|
||||
responses = await self._get(self._app(limit=2), "/limited", n=3)
|
||||
assert [r.status_code for r in responses] == [200, 200, 429]
|
||||
|
||||
async def test_429_carries_retry_after(self):
|
||||
responses = await self._get(self._app(limit=1), "/limited", n=2)
|
||||
blocked = responses[-1]
|
||||
assert blocked.status_code == 429
|
||||
assert int(blocked.headers["retry-after"]) >= 1
|
||||
|
||||
async def test_unlimited_routes_are_untouched(self):
|
||||
responses = await self._get(self._app(limit=1), "/unlimited", n=10)
|
||||
assert {r.status_code for r in responses} == {200}
|
||||
|
||||
|
||||
class TestAuthRoutesAreLimited:
|
||||
"""The wiring: the unauthenticated edge is covered, the rest is not."""
|
||||
|
||||
def _is_limited(self, path: str) -> bool:
|
||||
"""True if the route carries a dependency built by `rate_limit`.
|
||||
|
||||
Identified by the closure's qualname rather than a string search, so
|
||||
this can't pass on an unrelated dependency that happens to stringify
|
||||
similarly.
|
||||
"""
|
||||
for route in main.app.routes:
|
||||
if getattr(route, "path", None) != path:
|
||||
continue
|
||||
return any(
|
||||
getattr(d.dependency, "__qualname__", "").startswith("rate_limit")
|
||||
for d in getattr(route, "dependencies", [])
|
||||
)
|
||||
raise AssertionError(f"route {path} not found")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["/auth/login", "/auth/callback", "/auth/me", "/auth/refresh-callback"]
|
||||
)
|
||||
def test_unauthenticated_auth_routes_are_limited(self, path):
|
||||
assert self._is_limited(path)
|
||||
|
||||
def test_owner_gated_routes_are_not_limited(self):
|
||||
# They're already behind is_owner; limiting them would throttle the
|
||||
# single legitimate operator.
|
||||
assert not self._is_limited("/api/v1/calls/active")
|
||||
|
||||
def test_logout_is_not_limited(self):
|
||||
# Pure redirect builder — no I/O, nothing to exhaust.
|
||||
assert not self._is_limited("/auth/logout")
|
||||
@@ -9,7 +9,6 @@ through the shared data layer in services/call_persistence.py.
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -116,7 +115,9 @@ class TestInboundPolicy:
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||
# Dev-owner mode: tokenless requests resolve to the owner. auth's DB
|
||||
# session comes through the same get_db override below.
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
@@ -163,38 +164,38 @@ FLOW_PAYLOAD = {
|
||||
|
||||
class TestCallFlowRoutes:
|
||||
async def test_crud_round_trip(self, client):
|
||||
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
||||
resp = await client.post("/api/v1/call-flows/", json=FLOW_PAYLOAD)
|
||||
assert resp.status_code == 200, resp.text
|
||||
flow_id = resp.json()["id"]
|
||||
assert flow_id == "acme-main-line"
|
||||
|
||||
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
||||
resp = await client.post("/api/v1/call-flows/", json=FLOW_PAYLOAD)
|
||||
assert resp.status_code == 409
|
||||
|
||||
resp = await client.get("/api/call-flows/")
|
||||
resp = await client.get("/api/v1/call-flows/")
|
||||
assert [f["id"] for f in resp.json()] == [flow_id]
|
||||
|
||||
resp = await client.get(f"/api/call-flows/{flow_id}")
|
||||
resp = await client.get(f"/api/v1/call-flows/{flow_id}")
|
||||
assert resp.json()["steps"][0]["action_value"] == "2"
|
||||
|
||||
resp = await client.get("/api/call-flows/by-number/+18005551234")
|
||||
resp = await client.get("/api/v1/call-flows/by-number/+18005551234")
|
||||
assert resp.json()["id"] == flow_id
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/call-flows/{flow_id}", json={"notes": "updated"}
|
||||
f"/api/v1/call-flows/{flow_id}", json={"notes": "updated"}
|
||||
)
|
||||
assert resp.json()["notes"] == "updated"
|
||||
|
||||
resp = await client.delete(f"/api/call-flows/{flow_id}")
|
||||
resp = await client.delete(f"/api/v1/call-flows/{flow_id}")
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
resp = await client.get(f"/api/call-flows/{flow_id}")
|
||||
resp = await client.get(f"/api/v1/call-flows/{flow_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCallHistoryRoutes:
|
||||
async def test_history_and_record(self, client):
|
||||
resp = await client.get("/api/calls/history")
|
||||
resp = await client.get("/api/v1/calls/history")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
@@ -211,17 +212,17 @@ class TestCallHistoryRoutes:
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
resp = await client.get("/api/calls/history")
|
||||
resp = await client.get("/api/v1/calls/history")
|
||||
assert [r["id"] for r in resp.json()] == ["call_hist1"]
|
||||
|
||||
resp = await client.get("/api/calls/history?number=%2B18005551234")
|
||||
resp = await client.get("/api/v1/calls/history?number=%2B18005551234")
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
resp = await client.get("/api/calls/call_hist1/record")
|
||||
resp = await client.get("/api/v1/calls/call_hist1/record")
|
||||
assert resp.json()["intent"] == "dispute charge"
|
||||
|
||||
resp = await client.get("/api/calls/call_missing/record")
|
||||
resp = await client.get("/api/v1/calls/call_missing/record")
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = await client.get("/api/calls/call_hist1/transcript")
|
||||
resp = await client.get("/api/v1/calls/call_hist1/transcript")
|
||||
assert resp.json() == []
|
||||
|
||||
99
tests/test_websocket.py
Normal file
99
tests/test_websocket.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
WebSocket event-stream tests.
|
||||
|
||||
The socket is owner-gated: refused (4401) when SSO is enabled and no
|
||||
credential is supplied, and — in dev-owner mode (SSO disabled) — an
|
||||
authorized client immediately receives the synthetic trunk-status event
|
||||
followed by the replayed recent history.
|
||||
|
||||
The WS `_authorize` resolves the owner via a DB session, so an in-memory
|
||||
SQLite database is wired in (StaticPool, shared across the TestClient
|
||||
thread) mirroring tests/test_data_layer.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Base
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mem_db(monkeypatch):
|
||||
"""Synchronous setup of an in-memory SQLite DB shared with the app."""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
async def _create():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.run(_create())
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield
|
||||
asyncio.run(engine.dispose())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_app(monkeypatch, mem_db):
|
||||
"""Dev-owner mode: a tokenless WS connect resolves the owner."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
gateway = AIPSTNGateway(settings=Settings())
|
||||
main.app.state.gateway = gateway
|
||||
yield gateway
|
||||
del main.app.state.gateway
|
||||
|
||||
|
||||
def _publish(gateway, call_id: str) -> None:
|
||||
asyncio.run(gateway.event_bus.publish(GatewayEvent(
|
||||
type=EventType.CALL_INITIATED,
|
||||
call_id=call_id,
|
||||
data={},
|
||||
message=f"call {call_id}",
|
||||
)))
|
||||
|
||||
|
||||
class TestEventStream:
|
||||
def test_refused_without_credential(self, monkeypatch, mem_db):
|
||||
"""SSO enabled + no token → the socket is closed with 4401."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||
client = TestClient(main.app)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/events"):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
def test_trunk_status_then_replayed_history(self, ws_app):
|
||||
_publish(ws_app, "call_ws1")
|
||||
_publish(ws_app, "call_ws2")
|
||||
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect("/ws/events") as ws:
|
||||
first = ws.receive_json()
|
||||
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
||||
replayed = [ws.receive_json() for _ in range(2)]
|
||||
assert [m["call_id"] for m in replayed] == ["call_ws1", "call_ws2"]
|
||||
|
||||
def test_per_call_stream_filters(self, ws_app):
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect("/ws/calls/call_target/events") as ws:
|
||||
_publish(ws_app, "call_other")
|
||||
_publish(ws_app, "call_target")
|
||||
msg = ws.receive_json()
|
||||
assert msg["call_id"] == "call_target"
|
||||
Reference in New Issue
Block a user