Compare commits

...

22 Commits

Author SHA1 Message Date
9dc766d631 fix(lab): silence Asterisk noise at the source, keep notice logging
Follow-up to 3150f78, which over-corrected. Dropping `notice` from logger.conf
made the log quiet by making the gateway undebuggable: Asterisk reports
rejected SIP requests at notice level via log_failed_request ("No matching
endpoint found", "Failed to authenticate"), and on a box whose entire job is
answering SIP those are the most useful lines it produces. A call was refused
and nothing said so.

`notice` is restored. The noise is dealt with where it originates instead:

- modules.conf, new. The image ships `autoload=yes` and loads every module it
  was built with, including chan_alsa on a container with no sound card:
  ~74 ALSA config errors per restart, plus module-load ERRORs for CDR/CEL
  backends, LDAP/ODBC realtime config, and format_ogg_vorbis — none of which
  can work here. Explicit noload for those; autoload stays on, because an
  allow-list would break quietly the first time a scenario needs a module
  nobody remembered to add.

- The healthcheck reduction from 3150f78 (one CLI connection on a 60s
  interval, rather than the image's ~7 every 30s) does the rest.

Verified on galatea: ALSA lines 74 → 0, all startup ERRORs gone (only one-off
benign WARNINGs remain), steady-state 4 lines per 2 minutes against ~840 per
30 minutes originally — and a refused call still logs 6 diagnostic lines.
Transport bound, both endpoints and dialplan loaded, container healthy.

`verbose` stays excluded: dialplan execution is worth having when tracing a
specific call, not worth shipping to Loki continuously. Raise it at runtime
with `asterisk -rx "core set verbose 3"`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:16:30 -04:00
3150f78552 fix(lab): make Asterisk logs usable — drop ANSI codes and healthcheck noise
Logging was configured correctly and shipping to Loki, but the stream was
useless: measured at 100% healthcheck chatter, with every line wrapped in ANSI
escape codes. The real SIP events were there and completely buried.

Three causes, each masking the next:

- asterisk.conf was written in the first lab commit with `nocolor = yes` and
  never mounted, so the setting had no effect. Now mounted. It was also
  overriding [directories] and runuser/rungroup, which the image sets up
  correctly itself — removed, since overriding them risks breaking the
  container for no gain.

- The image's command is `-vvvdddf`: verbosity 3 and debug 3 forced on the
  command line, which overrides both asterisk.conf and logger.conf. Overridden
  in compose to drop -v and -d; warnings and errors still log, and verbosity
  is raisable at runtime when tracing a call.

- The actual source: the image's healthcheck makes ~7 separate `asterisk -rx`
  connections every 30s, and Asterisk logs a connect/disconnect pair for each.
  Replaced with a single check on a 60s interval, and the check now runs
  `pjsip show transports` rather than `core show version` — that fails when
  Asterisk is up but unconfigured, which is exactly the state that produced a
  "healthy" container with no SIP stack on first deploy.

logger.conf drops both `notice` and `verbose`, which is where those pairs
arrive.

Verified on galatea: noise down from ~48 to 8 lines per two minutes (-83%),
zero ANSI codes in Loki, 90% of the stream now signal, container still
healthy, transport and dialplan intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:26:03 -04:00
c516f659cc feat(lab): add softphone endpoint so device registration can be tested
Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs
to the lab and the gateway transfers a live call to it by dialling extension
2001. This is 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 exercise or depend on that path, and the device is
authenticated.

The pjsua CLI built alongside the Python bindings is the test device — same
library stack as the gateway, so no new dependency. Verified end to end: a
gateway call to 2001 produces two channels Up under one bridge id.

Three things that cost time and are now written down:

- `--realm=asterisk`, not `--realm='*'`: the wildcard fails against Asterisk's
  digest challenge with PJSIP_EFAILEDCREDENTIAL.

- pjsua is an interactive console app and exits ~8s after start if stdin is
  closed or /dev/null. `script -qfc` and `setsid </dev/null` both appear to
  work — registration succeeds — and then the process dies, leaving a stale
  contact in Asterisk that routes INVITEs to a port nobody is listening on.
  Hold a fifo open on stdin instead, and verify the port is actually bound
  rather than trusting `pjsip show contacts`.

- Qualify is off for this AOR: 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 it re-enabled.

The identify block now matches source address *and port*. A host-only match
claims every packet from that address, so a co-located softphone's REGISTER
was attributed to the gateway endpoint and checked against the gateway's
password — surfacing as "Failed to authenticate" on a correct password.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:57:17 -04:00
92c45e9c4d fix(lab): make the speech fixture actually classify as speech
The speech fixture classified as LIVE_HUMAN on its first 3s window and drifted
to MUSIC for every window after. I had validated only the first window and
reported the fixture as verified, which overstated it: any lab result resting
on that fixture — the hold-slayer scenarios above all — was proving less than
it appeared to.

The cause was one modelling error, not a tuning problem. `_detect_tonality`
looks for an autocorrelation peak above 0.5 in the 50-1000 Hz lag range, and
each syllable used a *constant* f0, which is perfectly periodic there. That
scored is_tonal=True, handing the music score a free 0.3 that speech could not
outrun — and the decision requires speech_score to strictly exceed
music_score, so ties went to music.

Real voices glide and jitter, so the periodicity never locks. The fundamental
now follows a per-syllable pitch contour (rise or fall, plus ~2% cycle-to-cycle
jitter), with the frequency integrated to phase rather than multiplied by t —
`2*pi*f*t` is only a chirp when f is the instantaneous rate, which it is not
once f0 itself moves. is_tonal is now False in every window.

Two smaller fixes fell out of that:

- Aspiration noise is high-passed rather than broadband. 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 each syllable reads
  as a keypress. A first-difference filter leaves the 697-1633 Hz bands
  comparatively empty. The level is set for margin — spectral flatness lands
  at ~0.46, mid-way through the 0.1-0.5 band, not on an edge.

- The music fixture gained two more harmonics and a recording-style noise
  floor. Windows straddling a chord change had a momentarily sparse spectrum
  and fell *below* the music score's 0.05 flatness floor, scoring as speech.

All three fixtures now classify correctly in 100% of windows (music 27/27,
speech 5/5, silence 2/2), and remain correct when the window is stepped by
half a window — a fixture that only works on aligned boundaries would still
be a trap in a live call, where the analysis window has no relationship to
where the audio began.

Confirmed on a real call through the lab: scenario 1003 now shows the whole
hold-slayer arc, speech -> sustained music -> speech, matching the dialplan.

tests/test_lab_fixtures.py guards this: it sweeps every window rather than
sampling the first, which is exactly what the original validation missed, and
checks the generator is byte-for-byte deterministic. It skips when the
fixtures have not been generated, since they are gitignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:56:58 -04:00
2a05be27bf feat(sip): add PJSUA2 engine — audio finally reaches the classifier
The gateway can now hear. Verified end to end against the Asterisk lab:
speech classifies as live_human, hold music as music, the speech→music
transition tracks the dialplan, and DTMF reaches a real IVR (Asterisk logged
"caller pressed 1 -> accounts" and branched). RTP stats show 0% packet loss.

PJSUAEngine implements the existing SIPEngine interface, so the gateway,
call manager and hold-slayer service are unchanged. It is selected with
SIP_ENGINE=pjsua2; the default stays "sippy" while this is proven, and the
mock remains opt-in as before.

Why a new engine rather than fixing the old path: PJSUA2 exposes no
standalone RTP media object, so it will not surface media for a dialog it
does not own. Owning the dialog is the price of owning the media. Sippy keeps
the SBC roles it is good at — device registration, routing, leg bridging —
and SippyEngine remains fully functional for signalling; it simply cannot
carry media, which its media branch now says plainly instead of calling a
method that could never work.

The safety invariants are untouched. This engine is reachable only through
gateway.make_call, which refuses emergency numbers and enforces the
concurrency cap before any SIP action. No new dial path was introduced.

MediaPipeline.add_remote_stream(host, port) is replaced by
attach_call_media(stream_id, audio_media), called from onCallMediaState —
the one place PJSUA2 hands out RTP-backed media. Taps requested before media
comes up are attached when it does, so the classifier never misses the start
of a call.

Three crash/lifetime bugs found by running against the real bindings, none of
which any unit test would have caught:

- pj.Call and pj.Account objects finalised after libDestroy() abort the
  process on a native assertion, exactly as media ports do. Both are now
  dropped and collected before the pipeline destroys the endpoint.
- PJSUA2 keeps delivering callbacks during interpreter teardown, when module
  globals may already be cleared. The callbacks alias what they need locally
  and swallow everything: a raise there escapes into C++ and takes the worker
  thread with it.
- hangup() only queues the BYE, so shutdown deleted the account with a call
  still active and left the far end on an unclosed dialog. stop() now waits
  briefly for the teardown to complete.

Threading follows the established rule: PJSUA2 worker threads reach the loop
only through _post_from_pj → run_coroutine_threadsafe, and any thread PJSUA2
did not create registers itself before touching a PJSUA2 object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:06:56 -04:00
7979e70705 feat(media): implement PJSUA2 audio capture port; document the media-plane refactor
Implements the tap half of the media path and records why the other half
requires moving call placement into PJSUA2.

MediaPipeline.create_tap was a stub: it logged "🎤 Audio tap created" and
returned a tap that nothing ever fed, so the classifier received no audio on
a live call. It now builds a real pj.AudioMediaPort subclass whose
onFrameReceived converts the SWIG ByteVector to PCM bytes and fans it out to
every tap on the stream.

One capture port per stream, shared by all taps: a second port on the same
stream would be mixed back into the conference bridge and the call would echo.

Thread safety is the constraint here. onFrameReceived runs on a PJSUA2 worker
thread — a third execution context beside the asyncio loop and the Sippy ED
thread — and touches nothing but AudioTap.feed, which hops to the owning loop
via call_soon_threadsafe. An exception escaping into PJSUA2's C++ callback
would tear down the worker thread and silently kill media for every call, so
the handler catches and logs once per port rather than on every 20ms frame.

Also fixes a hard crash found while testing this against real PJSUA2: a media
port finalised after Endpoint.libDestroy() calls pjmedia_conf_remove_port
against a freed conference bridge and aborts the process on a native
assertion. Ports are now released in remove_stream while the bridge still
exists, and stop() forces a collection before libDestroy — dropping the last
Python reference is not sufficient on its own.

Verified against the real bindings: frames fan out to multiple taps, cross the
thread boundary intact, and shutdown is clean.

add_remote_stream remains a stub, and deliberately so. 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 via pj.Call.getAudioMedia() on a dialog PJSUA2 itself owns.
A design where Sippy owns the dialog can never obtain media from PJSUA2, so
that function cannot be written against this API. docs/architecture.md now
explains this and records the resolution: PJSUA2 places the trunk call while
Sippy keeps the SBC roles (device registration, routing, leg bridging), with
the emergency guard and concurrency cap staying first in gateway.make_call
regardless of which library dials.

The architecture doc also had drift unrelated to media: it described the
thread boundary as asyncio.run_in_executor() when the real mechanism is
run_coroutine_threadsafe / ED2.callFromThread, claimed two execution contexts
where there are three, and cited MediaPipeline.add_stream() and
SippyEngine.bridge() — neither of which exists. Corrected, with the data flow
now showing the emergency guard and concurrency cap in their real positions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 06:56:50 -04:00
c00cf02676 test(lab): add Asterisk lab — a fake PSTN for media validation
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m12s
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: no charges, no strangers, no E911 exposure.

Asterisk rather than Kamailio because the unproven risks are media risks.
Kamailio is a proxy — it routes signalling and answers nothing, so it would
forward the INVITE and find nobody home. Asterisk is a B2BUA: it answers,
plays prompts and collects DTMF, which is the hold-slayer scenario itself.
Kamailio remains the better model for trunk registration/digest auth later.

No application changes are needed to use it. SIP_TRUNK_HOST is just an
address, so the production code path runs unmodified — there is no test-only
branch anywhere in the gateway. It also means safety is structural: while the
trunk points at the lab there is no route to the PSTN at all, an absence of
route rather than a policy that could be misconfigured.

Nine scenarios (1001-1008 plus an echo test) cover the baseline call, the
IVR/DTMF path, hold-then-human, long hold, busy, no-answer, remote hangup and
silence.

The image ships no sound files, so sounds/generate.py synthesises three
fixtures from fixed seeds — byte-identical on every run, which is what makes
a classifier regression distinguishable from noise. Verified against
AudioClassifier: music→MUSIC 0.85, speech→LIVE_HUMAN 0.75,
silence→SILENCE 1.00. The speech formants deliberately avoid the DTMF bands;
the first version landed on a valid pair and classified as a keypress.

Anonymous inbound calls are refused, and endpoint matching is by source
address — 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).

Generated audio and the rendered per-host configs are gitignored: the former
is reproducible from a fixed seed, the latter carry a host-specific IP and
the lab password.

Known limit, documented in the README: MediaPipeline.create_tap is a stub, so
the classifier receives no audio on a live call. RTP flows and Asterisk plays
audio, but the tap is never fed — the fixture results above were measured by
feeding the classifier directly. This blocks scenarios 1002/1003/1004.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 06:06:14 -04:00
204203e3b0 fix(sip): correct five Sippy API mismatches that broke every real call
SippyEngine had never successfully placed a call or registered a trunk.
Every failure was masked by broad exception handlers that logged and marked
the leg terminated, so the gateway reported "ringing" and then ended the
call rather than surfacing the fault. None of it was visible to the test
suite, which runs exclusively on MockSIPEngine.

Found by pointing the gateway at a local Asterisk instance (tests/lab) —
each fix uncovered the next.

1. Trunk registration used kwargs the installed sippy (2.3.0) does not
   accept (auth_name/auth_password → user/passw), called register()
   instead of doregister(), and passed aor/contact as strings where
   SipRegistrationAgent calls .getCopy() and mutates .username/.port,
   so SipURL objects are required.

   It also posted registered=True at *send* time. Registration is
   asynchronous, so a rejected REGISTER would still have reported success
   — and /health treats a registered trunk as a condition for "healthy".
   Now wired to sippy's rok_cb/rfail_cb, so the rejection status line
   (typically a bad trunk password) reaches the operator.

   The Contact also fell back to loopback when the SIP bind is 0.0.0.0;
   a wildcard address is not somewhere a trunk can send an INVITE.

2. The INVITE passed SDP as a `body` kwarg. CCEventTry takes no such
   argument: UacStateIdle unpacks exactly six fields from the data tuple
   and expects the SDP as a MsgBody in position four. callingID/calledID
   are bare usernames — sippy builds the URIs itself from nh_address.

3. _sip_logger was absent from the global config. SipTransactionManager
   dereferences it on every message, so the first SIP packet in either
   direction raised KeyError inside the ED thread.

4. SippyCallController was not callable. Sippy invokes event_cb(event, ua)
   with CCEvent objects; the class only exposed on_* methods that nothing
   called. Added __call__ to dispatch CCEventRing/Connect/Disconnect/Fail
   to the existing handlers, guarding the body because an exception
   escaping into the ED dispatcher would hang the leg silently.

5. The UA was constructed without credentials, so sippy could not answer
   the 401/407 challenge that any authenticating trunk sends. Every
   outbound call died on the challenge.

Verified end to end against Asterisk 22.10.1: 180 Ringing → Connected →
23s of audio → clean teardown, with the dialplan executing and audio
playing in real time.

Not fixed here, and still blocking media: MediaPipeline.create_tap is a
stub that logs success and returns a tap nothing ever feeds, so the
classifier receives no audio on a live call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 06:04:01 -04:00
e9219f2d4a docs: mark PJSUA2 build complete and add build procedure
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 58s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m42s
Update deployment validation plan to reflect Phase 1b completion — pjsua2
built from pjproject 2.17 on caliban without sudo. Document the non-obvious
RPATH/patchelf step, the status() method name correction, and two remaining
caveats (not captured by pip install, Docker still runs stub media).

Update README to point at the new docs/pjsua2-build.md and clarify stub-mode
behavior.
2026-07-28 22:05:26 -04:00
394e3fc920 docs: add deployment validation plan and expand env config
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 52s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m49s
Add comprehensive deployment and validation plan documenting a staged
bring-up approach that gates each layer on its predecessor and defers
PSTN testing until everything else is proven.

Update .env.example to reflect current configuration:
- Replace static API_TOKEN with Casdoor SSO + owner-minted PAT auth
- Add Rhema TTS settings with port-collision warning
- Add AI Receptionist settings for inbound calls
- Reconcile GATEWAY_SIP_PORT to 5060 and default SIP domain
2026-07-28 21:46:16 -04:00
4a3c14d4af docs: add Claude AI assistant rules and configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
Add comprehensive rule documentation for AI-assisted development covering
authentication surfaces, outbound-call safety invariants, and other project
conventions to guide Claude's understanding of critical system behaviors.
2026-07-28 19:01:38 -04:00
016d8be71d Merge pull request 'Add Docker image + Gitea CI for Virgo Dev deploy' (#7) from deploy/docker-virgo-dev into main
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 44s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m1s
2026-07-19 17:12:19 +00:00
bd078c058e Add Docker image + Gitea CI for Virgo Dev deploy
Containerize Hold Slayer as a single image (one FastAPI process serving
REST/WS/MCP and its built SvelteKit dashboard) for deployment to Virgo Dev
on triton.

- Dockerfile: 3-stage (node builds the dashboard → python wheels → runtime).
  Runs from source via `pip install -e .` so db/database.py resolves
  alembic.ini (via __file__.parent.parent) and migrations run on boot. The
  loose top-level modules (main.py, config.py) also require the source layout.
  pjsua2 is left unbuilt (documented stub media) — fine for a mock-SIP deploy.
- .dockerignore: keep .env and gitignored dashboard build artifacts out of
  the context; the node stage builds a fresh dashboard.
- CI (.gitea/workflows): single-image Trivy scan + build + push to
  git.helu.ca/r/hold-slayer (sha / latest-on-main / semver tags), mirroring
  the Demeter workflow. Requires a PACKAGE_TOKEN Actions secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:10:54 -04:00
e7c84885d9 Dashboard authenticates: token prompt + bearer on REST/WS/recordings
The UI never sent the bearer token, so with API_TOKEN set every data
call 401'd and /ws/events was rejected pre-accept (the 403s in the
uvicorn log). The API client now keeps the token in localStorage,
attaches Authorization to every request, prompts once on a 401 and
retries, and appends ?token= to the WebSocket connect — the page's
reconnect loop picks the token up after the first prompt.

require_token also accepts a ?token= query parameter (same convention
as the WebSocket) because <audio> elements fetching recordings can't
set headers; recordingUrl() rides the token there.

The dashboard header's status call moved to a new authenticated
GET /api/v1/status — its old source was the JSON root endpoint that
the dashboard itself replaced at /.

Two new auth tests (query-param accepted / wrong query-param 401);
dashboard rebuilt. Verified live: WS rejected without token and
connected with ?token=, status 200, ?token=wrong 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 06:21:55 -04:00
dff21f7d5c Serve dashboard at /, version the REST API under /api/v1
The SvelteKit build was always made for the root (no base path); it
now mounts at / — registered last so /api/v1, /ws, /health, and /mcp
match first — and the JSON root endpoint is gone (its info lives in
/health and gateway_status). REST routers move from /api/* to
/api/v1/*; /ws and /health stay put; /mcp/ unchanged. Dashboard API
client, tests, README, and docs updated; dashboard rebuilt (build/ is
gitignored).

Verified live: / serves the UI, /api/v1 answers 200/401, the old
/api paths 404, MCP still lists 15 tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:57:45 -04:00
a8f06462e6 Merge stages 2-6 into main (completes the stacked PR chain #2-#6)
PRs #2-#6 each merged into their stacked base branch rather than main;
feature/stage5-data-layer ended up holding the full accepted stack.
This merge lands that exact tree on main — no new content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:52:28 -04:00
90620fbf80 Merge pull request 'Stage 6: learn_call_flow rebuilt on the learner, docs truth sweep' (#6) from feature/stage6-learner-and-truth into feature/stage5-data-layer
Reviewed-on: #6
2026-07-10 17:49:22 +00:00
ff7ea8623a Stage 6: learn_call_flow rebuilt on the learner, docs truth sweep
The call-flow learner finally gets fed: exploration mode records its
IVR discoveries on the call (ActiveCall.exploration_steps) instead of
throwing them away, persistence stores them in the call record's
metadata, and the rebuilt learn_call_flow MCP tool turns a completed
exploration call into a stored flow via CallFlowLearner — correct
constructor (llm_client from get_llm, heuristic labels when the LLM
is unavailable), build for a new number, merge/refine when a flow
already exists. save_learned_flow/update_flow_from_model keep the
CallFlow↔row mapping in call_persistence.

Test gaps closed: tests/test_learner.py (discoveries→linked steps,
exploration persistence, learn-then-refine through the in-memory MCP
client, no-data and unknown-call answers) and tests/test_websocket.py
(4401 without token, trunk-status-then-replay on connect, per-call
stream filtering).

Docs aligned to code: README (15 tools incl. learn_call_flow, HTTP
not SSE, Python 3.12+, PostgreSQL+Alembic — no SQLite fallback, media
pipeline marked stub-mode until pjsua2 installed, Alembic and honest
/health checked off); docs/mcp-server.md rewritten against the actual
tool surface (hangup not end_call, real params, 3 real resources,
/mcp/ streamable HTTP + bearer auth); architecture/development/
configuration drift fixed.

pyproject: pruned never-imported deps (websockets, librosa,
soundfile, python-multipart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:45:35 -04:00
f7a11f2f20 Stage 5: Alembic migrations, durable call rows, one transcript truth
Alembic replaces create_all as the schema authority: async env.py
against Base.metadata (CLI and in-app entry paths share it via
config.attributes["connection"]), an autogenerated baseline of the
create_all-era schema, and init_db now runs upgrade head — stamping
the baseline first on a pre-Alembic database so existing deployments
adopt cleanly. create_all remains for tests only.

Calls are durable from the start: CallManager gains an
on_call_created hook (wired to persist_call_on_create) that inserts
an in_progress CallRecord the moment a call is created;
persist_call_on_end finalizes that same row. A SIGKILL mid-call now
leaves an in_progress row instead of erasing the call from history
(verified live against the dev database).

One transcript representation: ActiveCall.transcript_chunks holds
TranscriptEntry (t_offset_ms, speaker, text) — add_transcript stamps
real offsets from connect time, receptionist passes speaker instead
of encoding it into "caller: ..." strings, persisted chunks carry
real seek offsets, and the dead CallRecord.transcript Text column is
dropped by migration. Device.is_online migrates String → Boolean
(with a USING cast for existing rows).

Model de-triplication: CallResponse/CallStatusResponse build via
from_call classmethods (one ActiveCall→response mapping);
DeviceStatus deleted — can_receive_call is a computed field on
Device and the list endpoint returns the domain model; all row↔dict
and row↔domain mapping now lives in call_persistence.py
(record_summary/record_detail/chunk_to_dict + device row functions).

New tests/test_data_layer.py: upgrade-head-matches-models,
pre-Alembic adoption, durable in_progress rows, end-without-create
fallback, transcript offsets, consolidated response models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:42:52 -04:00
4048ce1db6 Stage 4: honest health, explicit error policy, event-bus integrity
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.

/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.

Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.

Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).

Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.

Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:01:45 -04:00
67a00defc3 refactor: composition root in lifespan, break core↔services cycle, shared data layer
The gateway was the composition root, device registry, inbound-call
policy, and call-operations service in one class, with core↔services
circular imports papered over by function-local imports, wiring done
by assigning private attributes, and MCP tools duplicating REST query
logic against their own sessions.

Composition:
- main.py's lifespan now builds every service and wires them by
  constructor/registration. gateway.from_config() is gone; core/ no
  longer imports services/ anywhere — the cycle is dead.
- Inbound-call policy moved to ReceptionistService.on_inbound_call
  (routing evaluation, reject/answer, screening dispatch); wired as
  the engine's on_incoming_call by the lifespan. Receptionist deps
  (tts/transcription/recording/routing) are constructor-injected —
  no more gateway._tts reach-through or importing hold_slayer's
  private _get_llm (now services.llm_client.get_llm, shared).
- Hold-slayer launch goes through a mode-handler registry
  (register_mode_handler); the gateway no longer knows the service's
  type. CallManager takes on_call_ended in its constructor.
- build_sip_engine() is a pure function taking explicit callbacks.
- api/routing.py uses the routing service from app.state via a
  proper dependency instead of gateway._routing.

Shared data layer:
- db.session_scope() is the one session convention (get_db wraps it).
- services/call_persistence.py gains the query/write functions and
  the single StoredCallFlow→CallFlow mapper; api/call_flows.py,
  api/call_history.py, and the six DB-touching MCP tools are thin
  wrappers over them — the two surfaces can't drift.
- legs_for_call() replaces the three private _call_legs scans
  (gateway transfer/hangup, REST dtmf, MCP dtmf).

7 new tests (mode-handler launch, on_call_ended hook, receptionist
inbound answer/reject, call-flow CRUD round-trip and history routes
against real SQLite through the shared layer). aiosqlite added to dev
deps for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:29:01 -04:00
5880b59872 fix: enforce thread ownership at the Sippy/PJSUA2 boundary
Three thread domains were mutating shared dicts with no locks: Sippy's
ED thread wrote _legs/_registered_devices directly from SIP handlers,
the asyncio loop wrote them from make_call/hangup, and
run_in_executor(None, ...) had default-pool threads driving sippy UA
objects. AudioTap.feed() pushed into an asyncio.Queue (not
thread-safe) from the PJSUA2 thread.

New ownership rule, enforced structurally:
- The asyncio loop owns all app-visible state; the only mutator is the
  new _on_engine_event funnel. Sippy handlers extract plain strings on
  the ED thread and post via run_coroutine_threadsafe.
- The ED thread owns sippy objects plus _ed_ua_to_leg/_ed_leg_to_ua;
  loop-side commands (INVITE/BYE/DTMF/trunk register) hop over via
  ED2.callFromThread. UA references no longer live on SipCallLeg.
- AudioTap captures its loop and feed() hops via call_soon_threadsafe.
- Fix ED import: installed sippy 2.x exposes ED2, not ED — the old
  import could never start the event loop.

Also:
- Wire the never-connected on_leg_state_change callback: outbound
  ringing/connected/terminated now reaches CallManager; a call ends
  when its last leg terminates (transfers keep it alive). Adds
  CallManager.unmap_leg/legs_for_call.
- AudioClassifier.classify(): async entry that runs the FFT work in
  asyncio.to_thread and updates history on the loop — all four
  hold_slayer call sites now route through it, fixing both the
  loop-blocking and the 2-of-4 history gap. DTMF Goertzel loop
  replaced by the equivalent vectorized DFT-bin power.
- Task hygiene: gateway.spawn() tracks hold-slayer/receptionist tasks
  and stop() cancels them; recording safety-timeout task is retained
  and cancelled on stop_recording; engine tracks incoming-call
  dispatch tasks.

10 new tests: funnel events from a foreign thread, auto-answer
fallback, AudioTap cross-thread feed, classifier history, leg-state →
call status (including no stomping of ON_HOLD), stop() cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:53:36 -04:00
100 changed files with 8696 additions and 1900 deletions

View 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.

View 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.

View 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.

View 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.

View 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.

View 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
View 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
View 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

View File

@@ -1,17 +1,37 @@
# ============================================================
# 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
# without USE_MOCK_SIP=true refuses to start.
USE_MOCK_SIP=false
SIP_TRUNK_HOST=sip.yourprovider.com
SIP_TRUNK_PORT=5060
SIP_TRUNK_USERNAME=your_sip_username
@@ -23,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
@@ -47,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

View 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}"

240
CLAUDE.md Normal file
View File

@@ -0,0 +1,240 @@
# 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` (146 tests across 16 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.** Logging is plain `logging.basicConfig` in
[main.py](main.py); there's no `LOG_FORMAT`/JSON path (README Phase 4 has this
unchecked). If Heluca observability wants JSON logs shipped to a collector,
that's a deliberate piece of work, not a drive-by.
- **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 (README Phase 4, unchecked).
- **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.

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# 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
# 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}"]

109
README.md
View File

@@ -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) │ │ / │ │
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
│ │ │ │ │
│ ┌────┴──────────────┴──────────────┴────┐ │
@@ -53,7 +53,7 @@ You give it a phone number and an intent ("dispute a charge on my December state
### 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
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, recording 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 +78,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
@@ -130,7 +130,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 +139,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 +165,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 +198,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 +213,27 @@ uvicorn main:app --host 0.0.0.0 --port 8000
pytest tests/ -v
```
## 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 +241,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 +255,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 +286,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 +294,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 +306,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 +333,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,7 +364,14 @@ 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` |
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
| `SIP_TRUNK_USERNAME` | SIP auth username | — |
@@ -352,15 +391,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 async architecture
- **FastAPI** — REST API + WebSocket server
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/dashboard`)
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/`)
- **Sippy B2BUA** — SIP call control and DTMF
- **PJSUA2** — Media pipeline, conference bridge, recording, WAV playback
- **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
@@ -384,7 +423,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 +439,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 🚧
- [ ] 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
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
- [ ] Graceful degradation (classifier works without STT, etc.)
- [ ] Docker Compose (Hold Slayer + PostgreSQL)
- [x] Docker Compose (Hold Slayer + PostgreSQL)
### Phase 5: Additional Services 🚧

42
alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
# Alembic configuration. The database URL is not set here — env.py
# reads it from config.Settings (environment / .env), so CLI runs and
# app startup migrate the same database the app uses.
[alembic]
script_location = db/migrations
prepend_sys_path = .
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

191
api/auth.py Normal file
View File

@@ -0,0 +1,191 @@
"""
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, 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 db.database import session_scope
router = APIRouter(prefix="/auth", tags=["auth"])
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")
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")
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")
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")
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")
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)

View File

@@ -2,26 +2,21 @@
Call Flows API — Store and manage IVR navigation trees.
The system gets smarter every time you call somewhere.
Thin HTTP layer over the shared data functions in call_persistence.
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from slugify import slugify
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway
from core.gateway import AIPSTNGateway
from db.database import StoredCallFlow, get_db
from db.database import get_db
from models.call_flow import (
CallFlow,
CallFlowCreate,
CallFlowStep,
CallFlowSummary,
CallFlowUpdate,
)
from services import call_persistence as store
router = APIRouter()
@@ -34,39 +29,23 @@ async def create_call_flow(
"""Store a new call flow for a phone number."""
flow_id = slugify(flow.name)
# Check if ID already exists
existing = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
if existing.scalar_one_or_none():
if await store.get_flow(db, flow_id):
raise HTTPException(
status_code=409,
detail=f"Call flow '{flow_id}' already exists. Use PUT to update.",
)
db_flow = StoredCallFlow(
id=flow_id,
row = await store.create_flow(
db,
flow_id=flow_id,
name=flow.name,
phone_number=flow.phone_number,
description=flow.description,
steps=[s.model_dump() for s in flow.steps],
tags=flow.tags,
notes=flow.notes,
last_verified=datetime.now(),
)
db.add(db_flow)
await db.flush()
return CallFlow(
id=flow_id,
name=flow.name,
phone_number=flow.phone_number,
description=flow.description,
steps=flow.steps,
tags=flow.tags,
notes=flow.notes,
last_verified=datetime.now(),
)
return store.flow_to_model(row)
@router.get("/", response_model=list[CallFlowSummary])
@@ -74,9 +53,7 @@ async def list_call_flows(
db: AsyncSession = Depends(get_db),
):
"""List all stored call flows."""
result = await db.execute(select(StoredCallFlow))
rows = result.scalars().all()
rows = await store.list_flows(db)
return [
CallFlowSummary(
id=row.id,
@@ -100,26 +77,10 @@ async def get_call_flow(
db: AsyncSession = Depends(get_db),
):
"""Get a stored call flow by ID."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
row = await store.get_flow(db, flow_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in row.steps],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
return store.flow_to_model(row)
@router.get("/by-number/{phone_number}", response_model=CallFlow)
@@ -128,29 +89,13 @@ async def get_flow_for_number(
db: AsyncSession = Depends(get_db),
):
"""Look up stored call flow by phone number."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
)
row = result.scalar_one_or_none()
row = await store.get_flow_by_number(db, phone_number)
if not row:
raise HTTPException(
status_code=404,
detail=f"No call flow found for {phone_number}",
)
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in row.steps],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
return store.flow_to_model(row)
@router.put("/{flow_id}", response_model=CallFlow)
@@ -160,10 +105,7 @@ async def update_call_flow(
db: AsyncSession = Depends(get_db),
):
"""Update an existing call flow."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
row = await store.get_flow(db, flow_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
@@ -181,20 +123,7 @@ async def update_call_flow(
row.last_verified = update.last_verified
await db.flush()
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in row.steps],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
return store.flow_to_model(row)
@router.delete("/{flow_id}")
@@ -203,10 +132,7 @@ async def delete_call_flow(
db: AsyncSession = Depends(get_db),
):
"""Delete a stored call flow."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
row = await store.get_flow(db, flow_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")

View File

@@ -1,22 +1,18 @@
"""
Call History API — Read-only access to persisted call records,
transcript chunks, and recording files for the dashboard.
Thin HTTP layer over the shared data functions in call_persistence.
"""
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.database import (
CallRecord,
RecordingRecord,
TranscriptChunk,
get_db,
)
from db.database import get_db
from services import call_persistence as store
router = APIRouter()
@@ -25,102 +21,47 @@ router = APIRouter()
async def list_history(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
number: Optional[str] = None,
status: Optional[str] = None,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
number: str | None = None,
status: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
db: AsyncSession = Depends(get_db),
):
"""Paged list of past calls, newest first."""
stmt = select(CallRecord).order_by(desc(CallRecord.started_at))
if number:
stmt = stmt.where(CallRecord.remote_number == number)
if status:
stmt = stmt.where(CallRecord.status == status)
if since:
stmt = stmt.where(CallRecord.started_at >= since)
if until:
stmt = stmt.where(CallRecord.started_at <= until)
rows = (await db.execute(stmt.offset(offset).limit(limit))).scalars().all()
return [
{
"id": r.id,
"direction": r.direction,
"remote_number": r.remote_number,
"status": r.status,
"mode": r.mode,
"intent": r.intent,
"started_at": r.started_at.isoformat() if r.started_at else None,
"ended_at": r.ended_at.isoformat() if r.ended_at else None,
"duration": r.duration,
"hold_time": r.hold_time,
"device_used": r.device_used,
"summary": r.summary,
}
for r in rows
]
rows = await store.search_history(
db,
number=number,
status=status,
since=since,
until=until,
limit=limit,
offset=offset,
)
return [store.record_summary(r) for r in rows]
@router.get("/{call_id}/record")
async def get_record(call_id: str, db: AsyncSession = Depends(get_db)):
"""Full CallRecord with classification_timeline."""
row = (await db.execute(
select(CallRecord).where(CallRecord.id == call_id)
)).scalar_one_or_none()
row = await store.get_record(db, call_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return {
"id": row.id,
"direction": row.direction,
"remote_number": row.remote_number,
"status": row.status,
"mode": row.mode,
"intent": row.intent,
"started_at": row.started_at.isoformat() if row.started_at else None,
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
"duration": row.duration,
"hold_time": row.hold_time,
"device_used": row.device_used,
"summary": row.summary,
"action_items": row.action_items,
"sentiment": row.sentiment,
"call_flow_id": row.call_flow_id,
"classification_timeline": row.classification_timeline,
}
return store.record_detail(row)
@router.get("/{call_id}/transcript")
async def get_transcript(call_id: str, db: AsyncSession = Depends(get_db)):
"""Ordered transcript chunks for a call."""
rows = (await db.execute(
select(TranscriptChunk)
.where(TranscriptChunk.call_id == call_id)
.order_by(TranscriptChunk.seq)
)).scalars().all()
return [
{
"seq": c.seq,
"t_offset_ms": c.t_offset_ms,
"speaker": c.speaker,
"text": c.text,
"confidence": c.confidence,
}
for c in rows
]
rows = await store.get_transcript_chunks(db, call_id)
return [store.chunk_to_dict(c) for c in rows]
@router.get("/{call_id}/recording")
async def get_recording(call_id: str, db: AsyncSession = Depends(get_db)):
"""Stream the WAV recording for a call."""
row = (await db.execute(
select(RecordingRecord)
.where(RecordingRecord.call_id == call_id)
.order_by(desc(RecordingRecord.started_at))
)).scalar_one_or_none()
row = await store.latest_recording(db, call_id)
if not row or not row.path:
raise HTTPException(status_code=404, detail="Recording not found")
import os
if not os.path.exists(row.path):
raise HTTPException(status_code=404, detail="Recording file missing on disk")
return FileResponse(row.path, media_type="audio/wav", filename=os.path.basename(row.path))

View File

@@ -40,12 +40,7 @@ async def make_call(
call_flow_id=request.call_flow_id,
services=request.services,
)
return CallResponse(
call_id=call.id,
status=call.status.value,
number=request.number,
mode=request.mode.value,
)
return CallResponse.from_call(call)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -81,11 +76,8 @@ async def hold_slayer(
call_flow_id=request.call_flow_id,
device=request.transfer_to,
)
return CallResponse(
call_id=call.id,
status="navigating_ivr",
number=request.number,
mode="hold_slayer",
return CallResponse.from_call(
call,
message="Hold Slayer activated. I'll ring you when a human picks up. ☕",
)
except ValueError as e:
@@ -113,21 +105,7 @@ async def get_call(
if not call:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return CallStatusResponse(
call_id=call.id,
status=call.status.value,
direction=call.direction,
remote_number=call.remote_number,
mode=call.mode.value,
duration=call.duration,
hold_time=call.hold_time,
audio_type=call.current_classification.value,
intent=call.intent,
transcript_excerpt=call.transcript[-500:] if call.transcript else None,
classification_history=call.classification_history[-50:],
current_step=call.current_step_id,
services=call.services,
)
return CallStatusResponse.from_call(call)
@router.post("/{call_id}/transfer")
@@ -172,10 +150,9 @@ async def send_dtmf(
if not call:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
# Find the PSTN leg for this call
for leg_id, cid in gateway.call_manager._call_legs.items():
if cid == call_id:
await gateway.sip_engine.send_dtmf(leg_id, digits)
return {"status": "sent", "digits": digits}
legs = gateway.call_manager.legs_for_call(call_id)
if not legs:
raise HTTPException(status_code=409, detail="No active SIP leg found for this call")
raise HTTPException(status_code=500, detail="No active SIP leg found for this call")
await gateway.sip_engine.send_dtmf(legs[0], digits)
return {"status": "sent", "digits": digits}

View File

@@ -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
@@ -18,22 +18,9 @@ def get_gateway(request: Request) -> AIPSTNGateway:
return gateway
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"},
)
def get_routing_service(request: Request):
"""Get the routing service from app state."""
routing = getattr(request.app.state, "routing_service", None)
if routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return routing

View File

@@ -1,19 +1,19 @@
"""
Device Management API — Register and manage phones/softphones.
Row mapping lives in call_persistence; this layer works with the
Device domain model only.
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway
from core.gateway import AIPSTNGateway
from db.database import Device as DeviceDB
from db.database import get_db
from models.device import Device, DeviceCreate, DeviceStatus, DeviceUpdate
from models.device import Device, DeviceCreate, DeviceUpdate
from services import call_persistence as store
router = APIRouter()
@@ -25,45 +25,18 @@ async def register_device(
db: AsyncSession = Depends(get_db),
):
"""Register a new device with the gateway."""
device_id = f"dev_{uuid.uuid4().hex[:8]}"
# Save to DB
db_device = DeviceDB(
id=device_id,
name=device.name,
type=device.type.value,
sip_uri=device.sip_uri,
phone_number=device.phone_number,
priority=device.priority,
capabilities=device.capabilities,
is_online="false",
)
db.add(db_device)
await db.flush()
# Register with gateway
dev = Device(id=device_id, **device.model_dump())
dev = Device(id=f"dev_{uuid.uuid4().hex[:8]}", **device.model_dump())
await store.create_device_row(db, dev)
gateway.register_device(dev)
return dev
@router.get("/", response_model=list[DeviceStatus])
@router.get("/", response_model=list[Device])
async def list_devices(
gateway: AIPSTNGateway = Depends(get_gateway),
):
"""List all registered devices and their status."""
return [
DeviceStatus(
id=d.id,
name=d.name,
type=d.type,
is_online=d.is_online,
last_seen=d.last_seen,
can_receive_call=d.can_receive_call,
)
for d in gateway.devices.values()
]
return list(gateway.devices.values())
@router.get("/{device_id}", response_model=Device)
@@ -90,22 +63,11 @@ async def update_device(
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Update in-memory
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(device, key, value)
# Update in DB
result = await db.execute(
select(DeviceDB).where(DeviceDB.id == device_id)
)
db_device = result.scalar_one_or_none()
if db_device:
for key, value in update_data.items():
if key == "type" and value is not None:
value = value.value if hasattr(value, "value") else value
setattr(db_device, key, value)
await store.update_device_row(db, device_id, update_data)
return device
@@ -120,12 +82,5 @@ async def unregister_device(
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
gateway.unregister_device(device_id)
result = await db.execute(
select(DeviceDB).where(DeviceDB.id == device_id)
)
db_device = result.scalar_one_or_none()
if db_device:
await db.delete(db_device)
await store.delete_device_row(db, device_id)
return {"status": "unregistered", "device_id": device_id}

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway
from api.deps import get_gateway, get_routing_service
from core.gateway import AIPSTNGateway
from db.database import Device as DeviceDB
from db.database import get_db
@@ -15,36 +15,31 @@ from models.routing import (
RoutingRuleCreate,
RoutingRuleUpdate,
)
from services.routing import RoutingService
router = APIRouter()
@router.get("/rules", response_model=list[RoutingRule])
async def list_rules(gateway: AIPSTNGateway = Depends(get_gateway)):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return sorted(gateway._routing.rules, key=lambda r: (r.priority, r.id))
async def list_rules(routing: RoutingService = Depends(get_routing_service)):
return sorted(routing.rules, key=lambda r: (r.priority, r.id))
@router.post("/rules", response_model=RoutingRule, status_code=201)
async def create_rule(
payload: RoutingRuleCreate,
gateway: AIPSTNGateway = Depends(get_gateway),
routing: RoutingService = Depends(get_routing_service),
):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return await gateway._routing.create_rule(payload)
return await routing.create_rule(payload)
@router.put("/rules/{rule_id}", response_model=RoutingRule)
async def update_rule(
rule_id: str,
payload: RoutingRuleUpdate,
gateway: AIPSTNGateway = Depends(get_gateway),
routing: RoutingService = Depends(get_routing_service),
):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
rule = await gateway._routing.update_rule(rule_id, payload)
rule = await routing.update_rule(rule_id, payload)
if rule is None:
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
return rule
@@ -53,11 +48,9 @@ async def update_rule(
@router.delete("/rules/{rule_id}")
async def delete_rule(
rule_id: str,
gateway: AIPSTNGateway = Depends(get_gateway),
routing: RoutingService = Depends(get_routing_service),
):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
ok = await gateway._routing.delete_rule(rule_id)
ok = await routing.delete_rule(rule_id)
if not ok:
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
return {"status": "deleted", "rule_id": rule_id}

107
api/tokens.py Normal file
View 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()

View File

@@ -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
@@ -94,7 +92,7 @@ async def event_stream(websocket: WebSocket):
# Immediately push current trunk status so the dashboard doesn't start blank
await _send_trunk_status(websocket, gateway)
subscription = gateway.event_bus.subscribe()
subscription = gateway.event_bus.subscribe(replay_last=25)
try:
async for event in subscription:

385
auth.py Normal file
View 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)]

View File

@@ -11,7 +11,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class SIPTrunkSettings(BaseSettings):
"""SIP trunk provider configuration."""
model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_")
model_config = SettingsConfigDict(env_prefix="SIP_TRUNK_", env_file=".env", extra="ignore")
host: str = "sip.provider.com"
port: int = 5060
@@ -24,7 +24,7 @@ class SIPTrunkSettings(BaseSettings):
class GatewaySIPSettings(BaseSettings):
"""Gateway SIP listener for device registration."""
model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_")
model_config = SettingsConfigDict(env_prefix="GATEWAY_SIP_", env_file=".env", extra="ignore")
host: str = "0.0.0.0"
port: int = 5060
@@ -34,7 +34,7 @@ class GatewaySIPSettings(BaseSettings):
class SpeachesSettings(BaseSettings):
"""Speaches STT service configuration."""
model_config = SettingsConfigDict(env_prefix="SPEACHES_")
model_config = SettingsConfigDict(env_prefix="SPEACHES_", env_file=".env", extra="ignore")
url: str = "http://localhost:22070"
model: str = "whisper-large-v3"
@@ -43,7 +43,7 @@ class SpeachesSettings(BaseSettings):
class ClassifierSettings(BaseSettings):
"""Audio classifier thresholds."""
model_config = SettingsConfigDict(env_prefix="CLASSIFIER_")
model_config = SettingsConfigDict(env_prefix="CLASSIFIER_", env_file=".env", extra="ignore")
music_threshold: float = 0.7
speech_threshold: float = 0.6
@@ -54,7 +54,7 @@ class ClassifierSettings(BaseSettings):
class LLMSettings(BaseSettings):
"""LLM service configuration (OpenAI-compatible API)."""
model_config = SettingsConfigDict(env_prefix="LLM_")
model_config = SettingsConfigDict(env_prefix="LLM_", env_file=".env", extra="ignore")
base_url: str = "http://localhost:11434/v1"
model: str = "llama3"
@@ -67,7 +67,7 @@ class LLMSettings(BaseSettings):
class HoldSlayerSettings(BaseSettings):
"""Hold Slayer behavior settings."""
model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True)
model_config = SettingsConfigDict(env_prefix="HOLD_SLAYER_", env_prefix_allow_empty=True, env_file=".env", extra="ignore")
default_transfer_device: str = Field(
default="sip_phone", validation_alias="DEFAULT_TRANSFER_DEVICE"
@@ -79,7 +79,7 @@ class HoldSlayerSettings(BaseSettings):
class TTSSettings(BaseSettings):
"""Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech)."""
model_config = SettingsConfigDict(env_prefix="TTS_")
model_config = SettingsConfigDict(env_prefix="TTS_", env_file=".env", extra="ignore")
base_url: str = "http://localhost:8000"
model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
@@ -89,10 +89,29 @@ 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."""
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_")
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_", env_file=".env", extra="ignore")
enabled: bool = True
greeting_template: str = (
@@ -126,13 +145,29 @@ 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("")
# 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
# Explicit engine mode — the mock engine must be asked for. An
# unconfigured trunk without this flag fails startup instead of
# 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 = ""
@@ -145,6 +180,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

View File

@@ -5,15 +5,19 @@ Central nervous system of the gateway. Tracks all active calls,
publishes events, and coordinates between SIP engine and services.
"""
import asyncio
import logging
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Optional
from core.event_bus import EventBus, EventSubscription
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus, ClassificationResult
from core.event_bus import EventBus
from models.call import (
ActiveCall,
CallMode,
CallStatus,
ClassificationResult,
TranscriptEntry,
)
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
@@ -26,11 +30,12 @@ class CallManager:
The single source of truth for what's happening on the gateway.
"""
def __init__(self, event_bus: EventBus):
def __init__(self, event_bus: EventBus, on_call_created=None, on_call_ended=None):
self.event_bus = event_bus
self._active_calls: dict[str, ActiveCall] = {}
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
self._on_call_ended = None # async callback(call: ActiveCall, final_status)
self._on_call_created = on_call_created # async callback(call)
self._on_call_ended = on_call_ended # async callback(call, final_status)
# ================================================================
# Call Lifecycle
@@ -67,6 +72,14 @@ class CallManager:
message=f"📞 Calling {remote_number} ({mode.value})",
))
# Durable in_progress row — a crash mid-call must not erase the
# call from history. The hook does its own retrying/logging.
if self._on_call_created is not None:
try:
await self._on_call_created(call)
except Exception as e:
logger.warning(f"on_call_created hook failed for {call_id}: {e}")
return call
async def update_status(self, call_id: str, status: CallStatus) -> None:
@@ -135,18 +148,26 @@ class CallManager:
message=f"🎵 Audio: {result.audio_type.value} ({result.confidence:.0%})",
))
async def add_transcript(self, call_id: str, text: str) -> None:
"""Add a transcript chunk to a call."""
async def add_transcript(
self, call_id: str, text: str, speaker: str = "unknown"
) -> None:
"""Add a transcript entry to a call, stamped with its offset."""
call = self._active_calls.get(call_id)
if not call:
return
call.transcript_chunks.append(text)
anchor = call.connected_at or call.started_at
entry = TranscriptEntry(
t_offset_ms=int((datetime.now() - anchor).total_seconds() * 1000),
speaker=speaker,
text=text,
)
call.transcript_chunks.append(entry)
await self.event_bus.publish(GatewayEvent(
type=EventType.TRANSCRIPT_CHUNK,
call_id=call_id,
data={"text": text},
data={"text": text, "speaker": speaker, "t_offset_ms": entry.t_offset_ms},
message=f"📝 '{text[:80]}...' " if len(text) > 80 else f"📝 '{text}'",
))
@@ -180,6 +201,14 @@ class CallManager:
"""Map a SIP leg ID to a call ID."""
self._call_legs[sip_leg_id] = call_id
def unmap_leg(self, sip_leg_id: str) -> None:
"""Remove a SIP leg mapping (leg terminated)."""
self._call_legs.pop(sip_leg_id, None)
def legs_for_call(self, call_id: str) -> list[str]:
"""All SIP leg IDs currently mapped to a call."""
return [leg for leg, cid in self._call_legs.items() if cid == call_id]
def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
"""Look up which call a SIP leg belongs to."""
call_id = self._call_legs.get(sip_leg_id)

View File

@@ -20,63 +20,70 @@ class EventBus:
Features:
- Non-blocking publish (put_nowait)
- Automatic dead-subscriber cleanup (full queues are removed)
- Event history (last N events for late joiners)
- Slow subscribers lose their oldest event, never their subscription
- Event history (last N events, replayable to late joiners)
- Typed event filtering on subscriptions
- Async iteration via EventSubscription
"""
def __init__(self, max_history: int = 1000):
self._subscribers: list[tuple[asyncio.Queue[GatewayEvent], Optional[set[EventType]]]] = []
self._subscribers: list[EventSubscription] = []
self._history: list[GatewayEvent] = []
self._max_history = max_history
async def publish(self, event: GatewayEvent) -> None:
"""Publish an event to all subscribers."""
"""Publish an event to all subscribers.
A full subscriber queue drops its oldest event (counted on the
subscription) — a slow dashboard must never be silently
unsubscribed while its socket stays open.
"""
self._history.append(event)
if len(self._history) > self._max_history:
self._history = self._history[-self._max_history :]
logger.info(f"📡 Event: {event.type.value} | {event.message or ''}")
dead_queues = []
for queue, type_filter in self._subscribers:
# Skip if subscriber has a type filter and this event doesn't match
if type_filter and event.type not in type_filter:
for sub in self._subscribers:
if sub.type_filter and event.type not in sub.type_filter:
continue
try:
queue.put_nowait(event)
except asyncio.QueueFull:
dead_queues.append((queue, type_filter))
for entry in dead_queues:
self._subscribers.remove(entry)
sub.deliver(event)
def subscribe(
self,
max_size: int = 100,
event_types: Optional[set[EventType]] = None,
replay_last: int = 0,
) -> "EventSubscription":
"""
Create a new subscription.
Args:
max_size: Queue depth before subscriber is considered dead.
max_size: Queue depth; overflow drops the oldest event.
event_types: Optional filter — only receive these event types.
None means receive everything.
replay_last: Seed the queue with up to N most recent
history events (post-filter) before live ones.
Returns:
An async iterator of GatewayEvents.
"""
queue: asyncio.Queue[GatewayEvent] = asyncio.Queue(maxsize=max_size)
entry = (queue, event_types)
self._subscribers.append(entry)
return EventSubscription(queue, self, entry)
sub = EventSubscription(queue, self, event_types)
if replay_last > 0:
replayable = [
e for e in self._history
if not event_types or e.type in event_types
]
for event in replayable[-replay_last:]:
sub.deliver(event)
self._subscribers.append(sub)
return sub
def unsubscribe(self, entry: tuple) -> None:
def unsubscribe(self, sub: "EventSubscription") -> None:
"""Remove a subscriber."""
if entry in self._subscribers:
self._subscribers.remove(entry)
if sub in self._subscribers:
self._subscribers.remove(sub)
@property
def recent_events(self) -> list[GatewayEvent]:
@@ -95,11 +102,28 @@ class EventSubscription:
self,
queue: asyncio.Queue[GatewayEvent],
bus: EventBus,
entry: tuple,
type_filter: Optional[set[EventType]] = None,
):
self._queue = queue
self._bus = bus
self._entry = entry
self.type_filter = type_filter
self.dropped = 0 # events lost to queue overflow
def deliver(self, event: GatewayEvent) -> None:
"""Enqueue an event, dropping the oldest on overflow."""
try:
self._queue.put_nowait(event)
except asyncio.QueueFull:
try:
self._queue.get_nowait()
self._queue.put_nowait(event)
except (asyncio.QueueEmpty, asyncio.QueueFull):
pass
self.dropped += 1
if self.dropped in (1, 10, 100) or self.dropped % 1000 == 0:
logger.warning(
f"📡 Slow subscriber: {self.dropped} events dropped"
)
def __aiter__(self):
return self
@@ -108,7 +132,7 @@ class EventSubscription:
try:
return await self._queue.get()
except asyncio.CancelledError:
self._bus.unsubscribe(self._entry)
self._bus.unsubscribe(self)
raise
async def get(self, timeout: Optional[float] = None) -> GatewayEvent:
@@ -117,4 +141,4 @@ class EventSubscription:
def close(self):
"""Unsubscribe from the event bus."""
self._bus.unsubscribe(self._entry)
self._bus.unsubscribe(self)

View File

@@ -1,15 +1,18 @@
"""
AI PSTN Gateway — The main orchestrator.
AI PSTN Gateway — call operations and device registry.
Ties together SIP engine, call manager, event bus, and all services.
This is the top-level object that FastAPI and MCP talk to.
The application service that FastAPI and MCP talk to for live-call
work. Composition happens in main.py's lifespan: services are built
there and attached; this module never imports from services/.
"""
import asyncio
import logging
from collections.abc import Callable
from datetime import datetime
from typing import Optional
from config import Settings, get_settings
from config import Settings
from core.call_manager import CallManager
from core.dial_plan import is_emergency_number, next_extension
from core.event_bus import EventBus
@@ -17,110 +20,142 @@ from core.media_pipeline import MediaPipeline
from core.sip_engine import MockSIPEngine, SIPEngine
from core.sippy_engine import SippyEngine
from models.call import ActiveCall, CallMode, CallStatus
from models.call_flow import CallFlow
from models.device import Device, DeviceType
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
def _extract_number(sip_uri: str) -> str:
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212)."""
if not sip_uri:
return ""
s = sip_uri.strip()
if s.startswith("<") and ">" in s:
s = s[1:s.index(">")]
if s.startswith("sip:"):
s = s[4:]
if "@" in s:
s = s.split("@", 1)[0]
return s
def build_sip_engine(
settings: Settings,
media_pipeline: MediaPipeline,
on_leg_state_change: Callable,
on_device_registered: Callable,
on_incoming_call: Callable,
) -> SIPEngine:
"""
Build the SIP engine from config.
The mock engine must be requested explicitly (USE_MOCK_SIP=true).
An unconfigured trunk or a failed SippyEngine construction raises —
the caller fails startup rather than running a gateway that can't
place real calls while reporting healthy.
"""
if settings.use_mock_sip:
logger.warning("🧪 USE_MOCK_SIP=true — SIP engine is a mock, no real calls")
return MockSIPEngine()
def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine:
"""Build the appropriate SIP engine from config."""
trunk = settings.sip_trunk
gw_sip = settings.gateway_sip
if trunk.host and trunk.host != "sip.provider.com":
# Real trunk configured — use Sippy B2BUA
try:
return SippyEngine(
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=gateway.media_pipeline,
on_device_registered=gateway._on_sip_device_registered,
on_incoming_call=gateway._on_sip_incoming_call,
)
except Exception as e:
logger.warning(f"Could not create SippyEngine: {e} — using mock")
if not trunk.host or trunk.host in ("sip.provider.com", "sip.yourprovider.com"):
raise RuntimeError(
"SIP trunk is not configured (SIP_TRUNK_HOST is unset or a "
"placeholder). Set SIP_TRUNK_* in .env, or set USE_MOCK_SIP=true "
"for development without a trunk."
)
return MockSIPEngine()
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,
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,
)
class AIPSTNGateway:
"""
The AI PSTN Gateway.
Central coordination point for:
- SIP engine (signaling + media)
- Call manager (state + events)
- Hold Slayer service
- Audio classifier
- Transcription service
- Device management
Owns live-call operations (make/transfer/hangup), the device
registry, and per-call background tasks. Services are attached by
the composition root; mode handlers launch per-call services
(hold slayer) without the gateway knowing their types.
"""
def __init__(
self,
settings: Settings,
sip_engine: Optional[SIPEngine] = None,
on_call_created=None,
on_call_ended=None,
):
self.settings = settings
self.event_bus = EventBus()
self.call_manager = CallManager(self.event_bus)
self.call_manager = CallManager(
self.event_bus,
on_call_created=on_call_created,
on_call_ended=on_call_ended,
)
self.media_pipeline = MediaPipeline(sample_rate=16000)
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
# Services (initialized in start())
self._hold_slayer = None
self._audio_classifier = None
self._transcription = None
# Attached by the composition root (attach_services)
self._tts = None
self._routing = None
self._receptionist = None
# Device registry (loaded from DB on start)
# Per-call-mode launchers registered by the composition root
self._mode_handlers: dict[CallMode, Callable] = {}
# Device registry
self._devices: dict[str, Device] = {}
# Background tasks (per-call services, receptionist sessions) —
# tracked so shutdown can cancel them and GC can't drop them
self._tasks: set[asyncio.Task] = set()
# Startup time
self._started_at: Optional[datetime] = None
@classmethod
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway":
"""Create gateway from environment config."""
settings = get_settings()
gw = cls(settings=settings)
if sip_engine is not None:
gw.sip_engine = sip_engine
else:
gw.sip_engine = _build_sip_engine(settings, gw)
return gw
def spawn(self, coro, name: str) -> asyncio.Task:
"""Launch a tracked background task."""
task = asyncio.get_running_loop().create_task(coro, name=name)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task
def attach_services(self, tts=None) -> None:
"""Attach shared services the gateway must manage on shutdown."""
self._tts = tts
def register_mode_handler(self, mode: CallMode, handler: Callable) -> None:
"""Register a launcher called as handler(call, sip_leg_id, call_flow_id)."""
self._mode_handlers[mode] = handler
# ================================================================
# Lifecycle
# ================================================================
async def start(self) -> None:
"""Boot the gateway — start SIP engine and services."""
"""Boot the gateway — start media pipeline and SIP engine."""
logger.info("🔥 Starting AI PSTN Gateway...")
# Start media pipeline first so SIP engine can hand it RTP streams
@@ -128,26 +163,7 @@ class AIPSTNGateway:
# Start SIP engine
await self.sip_engine.start()
logger.info(f" SIP Engine: ready")
# Import services here to avoid circular imports
from services.audio_classifier import AudioClassifier
from services.transcription import TranscriptionService
from services.tts import TTSService
from services.routing import RoutingService
from services.receptionist import ReceptionistService
self._audio_classifier = AudioClassifier(self.settings.classifier)
self._transcription = TranscriptionService(self.settings.speaches)
self._tts = TTSService(self.settings.tts)
self._routing = RoutingService(self)
await self._routing.start()
self._receptionist = ReceptionistService(self)
# Persist completed calls to the database for history/playback.
from services.call_persistence import persist_call_on_end
self.call_manager._on_call_ended = persist_call_on_end
logger.info(" SIP Engine: ready")
self._started_at = datetime.now()
@@ -176,6 +192,12 @@ class AIPSTNGateway:
"""Gracefully shut down."""
logger.info("Shutting down AI PSTN Gateway...")
# Cancel per-call background tasks before tearing down their deps
for task in list(self._tasks):
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
# End all active calls
for call_id in list(self.call_manager.active_calls.keys()):
call = self.call_manager.get_call(call_id)
@@ -264,25 +286,10 @@ class AIPSTNGateway:
await self.call_manager.update_status(call.id, CallStatus.FAILED)
raise
# If hold_slayer mode, launch the Hold Slayer service
if mode == CallMode.HOLD_SLAYER:
from services.hold_slayer import HoldSlayerService
hold_slayer = HoldSlayerService(
gateway=self,
call_manager=self.call_manager,
sip_engine=self.sip_engine,
classifier=self._audio_classifier,
transcription=self._transcription,
settings=self.settings,
tts=self._tts,
)
# Launch as background task — don't block
import asyncio
asyncio.create_task(
hold_slayer.run(call, sip_leg_id, call_flow_id),
name=f"holdslayer_{call.id}",
)
# Hand off to the registered per-mode launcher (e.g. hold slayer)
handler = self._mode_handlers.get(mode)
if handler is not None:
handler(call, sip_leg_id, call_flow_id)
return call
@@ -303,11 +310,14 @@ class AIPSTNGateway:
self.call_manager.map_leg(device_leg_id, call_id)
# Get the original PSTN leg
pstn_leg_id = None
for leg_id, cid in self.call_manager._call_legs.items():
if cid == call_id and leg_id != device_leg_id:
pstn_leg_id = leg_id
break
pstn_leg_id = next(
(
leg_id
for leg_id in self.call_manager.legs_for_call(call_id)
if leg_id != device_leg_id
),
None,
)
if pstn_leg_id:
# Bridge the PSTN leg and device leg
@@ -324,9 +334,8 @@ class AIPSTNGateway:
raise ValueError(f"Call {call_id} not found")
# Hang up all legs associated with this call
for leg_id, cid in list(self.call_manager._call_legs.items()):
if cid == call_id:
await self.sip_engine.hangup(leg_id)
for leg_id in self.call_manager.legs_for_call(call_id):
await self.sip_engine.hangup(leg_id)
await self.call_manager.end_call(call_id)
@@ -366,6 +375,33 @@ class AIPSTNGateway:
if device:
logger.info(f"📱 Device unregistered: {device.name}")
async def _on_sip_leg_state(self, leg_id: str, state: str) -> None:
"""
SIP leg state change from the engine (already on the loop).
Maps leg transitions onto call status. Status only moves
forward from the dialing phase — hold-slayer/receptionist
states (ON_HOLD, NAVIGATING_IVR, …) are never stomped by a
late ringing/connected signal from a second leg.
"""
call = self.call_manager.get_call_for_leg(leg_id)
if call is None:
return
if state == "ringing" and call.status == CallStatus.INITIATING:
await self.call_manager.update_status(call.id, CallStatus.RINGING)
elif state == "connected" and call.status in (
CallStatus.INITIATING,
CallStatus.RINGING,
):
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
elif state == "terminated":
self.call_manager.unmap_leg(leg_id)
# End the call only when its last leg is gone (a transfer
# keeps the call alive on the device leg)
if not self.call_manager.legs_for_call(call.id):
await self.call_manager.end_call(call.id)
async def _on_sip_device_registered(
self, aor: str, contact: str, expires: int
) -> None:
@@ -425,74 +461,6 @@ class AIPSTNGateway:
},
))
async def _on_sip_incoming_call(
self, from_uri: str, to_uri: str, leg_id: str
) -> None:
"""
Called by SippyEngine when an inbound INVITE arrives.
Evaluates routing rules, then either:
- Rejects (rule says reject/DND)
- Answers + hands off to the AI Receptionist
"""
import uuid as _uuid
from models.call import CallMode, CallStatus
from models.routing import RoutingActionType
caller_number = _extract_number(from_uri)
dnis = _extract_number(to_uri)
# Create a call record so the dashboard sees the ringing call.
call = await self.call_manager.create_call(
remote_number=caller_number,
mode=CallMode.RECEPTIONIST,
intent=None,
call_flow_id=None,
device=None,
)
# Mark inbound
call.direction = "inbound"
self.call_manager.map_leg(leg_id, call.id)
await self.call_manager.update_status(call.id, CallStatus.RINGING)
decision = (
await self._routing.evaluate(caller_number, dnis)
if self._routing is not None
else None
)
if decision is not None:
await self.event_bus.publish(GatewayEvent(
type=EventType.ROUTING_RULE_MATCHED,
call_id=call.id,
data={
"matched_rule_id": decision.matched_rule_id,
"matched_rule_name": decision.matched_rule_name,
"action": decision.action.type.value,
"reason": decision.reason,
},
message=decision.reason,
))
if decision.action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
if hasattr(self.sip_engine, "reject_inbound"):
await self.sip_engine.reject_inbound(leg_id)
await self.call_manager.end_call(call.id, CallStatus.COMPLETED)
return
# Answer the leg
if hasattr(self.sip_engine, "accept_inbound"):
await self.sip_engine.accept_inbound(leg_id)
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
# Hand off to the AI Receptionist
if self._receptionist is not None and self.settings.receptionist.enabled:
import asyncio as _asyncio
_asyncio.create_task(
self._receptionist.handle(call, leg_id, decision),
name=f"receptionist_{call.id}",
)
def preferred_device(self) -> Optional[Device]:
"""Get the highest-priority online device."""
online_devices = [

View File

@@ -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
@@ -52,11 +53,23 @@ class AudioTap:
self._buffer: asyncio.Queue[bytes] = asyncio.Queue(maxsize=500)
self._active = True
self._pjsua2_port = None # PJSUA2 AudioMediaPort for tapping
# asyncio.Queue is not thread-safe; feed() hops onto this loop
try:
self._loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
def feed(self, pcm_data: bytes) -> None:
"""Feed PCM audio data into the tap (called from PJSUA2 thread)."""
"""Feed PCM audio data into the tap (called from the PJSUA2 thread)."""
if not self._active:
return
if self._loop is not None:
self._loop.call_soon_threadsafe(self._enqueue, pcm_data)
else:
self._enqueue(pcm_data)
def _enqueue(self, pcm_data: bytes) -> None:
"""Queue a frame on the owning loop, dropping oldest on overflow."""
try:
self._buffer.put_nowait(pcm_data)
except asyncio.QueueFull:
@@ -86,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
# ================================================================
@@ -100,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
@@ -131,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)
@@ -161,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
@@ -243,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()
@@ -258,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
@@ -279,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:
@@ -338,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:
@@ -414,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
View 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()

View File

@@ -9,11 +9,22 @@ Architecture:
Sippy B2BUA → SIP signaling (call control, registration, DTMF)
PJSUA2 → Media anchor (conference bridge, audio tapping, recording)
Sippy B2BUA runs in its own thread (it has its own event loop).
We bridge async/sync via run_in_executor.
Thread-ownership rule:
- The asyncio loop owns all application-visible state: `_legs`,
`_bridges`, `_registered_devices`, `_trunk_registered`, and the
media pipeline. The ONLY place that state is mutated is
`_on_engine_event`, which runs on the loop.
- The Sippy ED thread owns every sippy object (UAs, transactions)
plus the `_ed_*` maps. Sippy objects are only touched by code
scheduled onto that thread via `_run_on_sippy`.
- `leg_id` strings are the only tokens that cross the boundary,
carried by `_post_from_ed` (Sippy → loop, via
run_coroutine_threadsafe) and `_run_on_sippy` (loop → Sippy, via
ED2.callFromThread).
"""
import asyncio
import inspect
import logging
import threading
import uuid
@@ -30,16 +41,15 @@ logger = logging.getLogger(__name__)
# ================================================================
class SipCallLeg:
"""Tracks a single SIP call leg managed by Sippy."""
"""Tracks a single SIP call leg. Owned by the asyncio loop."""
def __init__(self, leg_id: str, direction: str, remote_uri: str):
self.leg_id = leg_id
self.direction = direction # "outbound" or "inbound"
self.remote_uri = remote_uri
self.state = "init" # init, trying, ringing, connected, terminated
self.sippy_ua = None # Sippy UA object reference
self.media_port: Optional[int] = None # PJSUA2 conf bridge port
self.dtmf_buffer: list[str] = []
self.pending_sdp: Optional[str] = None # inbound INVITE SDP, until answered
def __repr__(self):
return f"<SipCallLeg {self.leg_id} {self.direction} {self.state}{self.remote_uri}>"
@@ -65,75 +75,86 @@ class SippyCallController:
"""
Handles Sippy B2BUA callbacks for a single call leg.
Sippy B2BUA uses a callback model — when SIP events happen
(180 Ringing, 200 OK, BYE, etc.), the corresponding method
is called on this controller.
Runs entirely on the Sippy ED thread. It holds only the leg_id
token and forwards every state change to the asyncio loop via
the engine's event funnel — it never touches loop-owned state.
"""
def __init__(self, leg: SipCallLeg, engine: "SippyEngine"):
self.leg = leg
def __init__(self, leg_id: str, engine: "SippyEngine"):
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."""
self.leg.state = "trying"
logger.debug(f" {self.leg.leg_id}: 100 Trying")
logger.debug(f" {self.leg_id}: 100 Trying")
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "trying"})
def on_ringing(self, ringing_code: int = 180):
"""180 Ringing / 183 Session Progress received."""
self.leg.state = "ringing"
logger.info(f" {self.leg.leg_id}: {ringing_code} Ringing")
if self.engine._on_leg_state_change:
self.engine._loop.call_soon_threadsafe(
self.engine._on_leg_state_change, self.leg.leg_id, "ringing"
)
logger.info(f" {self.leg_id}: {ringing_code} Ringing")
self.engine._post_from_ed("leg_state", {"leg_id": self.leg_id, "state": "ringing"})
def on_connected(self, sdp_body: Optional[str] = None):
"""200 OK — call connected, media negotiated."""
self.leg.state = "connected"
logger.info(f" {self.leg.leg_id}: Connected")
# Extract remote RTP endpoint from SDP for PJSUA2 media bridge
if sdp_body and self.engine.media_pipeline:
try:
remote_rtp = self.engine._parse_sdp_rtp_endpoint(sdp_body)
if remote_rtp:
port = self.engine.media_pipeline.add_remote_stream(
self.leg.leg_id,
remote_rtp["host"],
remote_rtp["port"],
remote_rtp["codec"],
)
self.leg.media_port = port
except Exception as e:
logger.error(f" Failed to set up media for {self.leg.leg_id}: {e}")
if self.engine._on_leg_state_change:
self.engine._loop.call_soon_threadsafe(
self.engine._on_leg_state_change, self.leg.leg_id, "connected"
)
logger.info(f" {self.leg_id}: Connected")
self.engine._post_from_ed(
"leg_state", {"leg_id": self.leg_id, "state": "connected", "sdp": sdp_body}
)
def on_disconnected(self, reason: str = ""):
"""BYE received or call terminated."""
self.leg.state = "terminated"
logger.info(f" {self.leg.leg_id}: Disconnected ({reason})")
# Clean up media
if self.engine.media_pipeline and self.leg.media_port is not None:
try:
self.engine.media_pipeline.remove_stream(self.leg.leg_id)
except Exception as e:
logger.error(f" Failed to clean up media for {self.leg.leg_id}: {e}")
if self.engine._on_leg_state_change:
self.engine._loop.call_soon_threadsafe(
self.engine._on_leg_state_change, self.leg.leg_id, "terminated"
)
logger.info(f" {self.leg_id}: Disconnected ({reason})")
self.engine._ed_forget_leg(self.leg_id)
self.engine._post_from_ed(
"leg_state", {"leg_id": self.leg_id, "state": "terminated", "reason": reason}
)
def on_dtmf(self, digit: str):
"""DTMF digit received (RFC 2833 or SIP INFO)."""
self.leg.dtmf_buffer.append(digit)
logger.debug(f" {self.leg.leg_id}: DTMF '{digit}'")
logger.debug(f" {self.leg_id}: DTMF '{digit}'")
self.engine._post_from_ed("dtmf", {"leg_id": self.leg_id, "digit": digit})
# ================================================================
@@ -190,17 +211,144 @@ class SippyEngine(SIPEngine):
self._on_incoming_call = on_incoming_call
self._loop: Optional[asyncio.AbstractEventLoop] = None
# State
# Loop-owned state (mutated only in _on_engine_event and the
# async methods below, all of which run on the loop)
self._ready = False
self._trunk_registered = False
self._legs: dict[str, SipCallLeg] = {}
self._bridges: dict[str, SipBridge] = {}
self._registered_devices: list[dict] = []
self._tasks: set[asyncio.Task] = set()
# ED-thread-owned state: sippy UA objects, only touched from
# the Sippy thread (handlers and _run_on_sippy closures)
self._ed_ua_to_leg: dict[Any, str] = {}
self._ed_leg_to_ua: dict[str, Any] = {}
# Sippy B2BUA internals (set during start)
self._sippy_global_config: dict[str, Any] = {}
self._sippy_thread: Optional[threading.Thread] = None
# ================================================================
# Thread-boundary crossing primitives
# ================================================================
def _post_from_ed(self, kind: str, data: dict) -> None:
"""Sippy thread → loop: schedule the single state-mutation funnel."""
if self._loop is None:
return
asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)
def _run_on_sippy(self, fn: Callable[[], None]) -> None:
"""Loop → Sippy thread: run fn where the sippy objects live."""
try:
from sippy.Core.EventDispatcher import ED2
except ImportError:
# Simulation mode — no sippy, no ED thread; run inline.
fn()
return
ED2.callFromThread(fn)
def _ed_forget_leg(self, leg_id: str) -> None:
"""Drop the ED-side UA maps for a leg (Sippy thread only)."""
ua = self._ed_leg_to_ua.pop(leg_id, None)
if ua is not None:
self._ed_ua_to_leg.pop(ua, None)
def _spawn(self, coro, name: str) -> None:
"""Track a background task so shutdown can cancel it."""
task = asyncio.get_running_loop().create_task(coro, name=name)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _on_engine_event(self, kind: str, data: dict) -> None:
"""
The single funnel where Sippy-thread events mutate loop-owned
state. Everything here runs on the asyncio loop.
"""
if kind == "leg_state":
leg = self._legs.get(data["leg_id"])
if leg is None:
return
state = data["state"]
leg.state = state
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:
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 parse SDP for {leg.leg_id}: {e}")
elif state == "terminated":
if self.media_pipeline and leg.media_port is not None:
try:
self.media_pipeline.remove_stream(leg.leg_id)
except Exception as e:
logger.error(f" Failed to clean up media for {leg.leg_id}: {e}")
leg.media_port = None
if self._on_leg_state_change:
result = self._on_leg_state_change(leg.leg_id, state)
if inspect.isawaitable(result):
await result
elif kind == "incoming_invite":
leg = SipCallLeg(data["leg_id"], "inbound", data["from_uri"])
leg.pending_sdp = data.get("sdp")
self._legs[leg.leg_id] = leg
if self._on_incoming_call:
self._spawn(
self._on_incoming_call(data["from_uri"], data["to_uri"], leg.leg_id),
name=f"incoming_{leg.leg_id}",
)
else:
# No routing wired — preserve the historical auto-answer
await self.accept_inbound(leg.leg_id)
elif kind == "register":
existing = next(
(d for d in self._registered_devices if d.get("aor") == data["aor"]),
None,
)
if existing:
existing["contact"] = data["contact"]
existing["expires"] = data["expires"]
else:
self._registered_devices.append({
"aor": data["aor"],
"contact": data["contact"],
"expires": data["expires"],
})
if self._on_device_registered:
await self._on_device_registered(
data["aor"], data["contact"], data["expires"]
)
elif kind == "deregister":
self._registered_devices = [
d for d in self._registered_devices if d.get("aor") != data["aor"]
]
elif kind == "dtmf":
# Received DTMF has no consumer yet; log until one exists
logger.info(f" DTMF '{data['digit']}' received on {data['leg_id']}")
elif kind == "trunk_registered":
self._trunk_registered = data["registered"]
# ================================================================
# Lifecycle
# ================================================================
@@ -212,21 +360,27 @@ class SippyEngine(SIPEngine):
try:
from sippy.SipConf import SipConf
from sippy.SipTransactionManager import SipTransactionManager
# Configure Sippy
SipConf.my_address = self._sip_address
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
# Sippy uses its own event loop (Twisted reactor or custom loop)
# Sippy uses its own event loop (the ED2 event dispatcher)
self._sippy_thread = threading.Thread(
target=self._run_sippy_loop,
name="sippy-b2bua",
@@ -254,8 +408,8 @@ class SippyEngine(SIPEngine):
def _run_sippy_loop(self):
"""Run Sippy B2BUA's event loop in a dedicated thread."""
try:
from sippy.Core.EventDispatcher import ED2
from sippy.SipTransactionManager import SipTransactionManager
from sippy.Timeout import Timeout
# Initialize Sippy's transaction manager
stm = SipTransactionManager(self._sippy_global_config, self._handle_sippy_request)
@@ -263,11 +417,9 @@ class SippyEngine(SIPEngine):
logger.info(" Sippy transaction manager started")
# Sippy will block here in its event loop
# For the Twisted-based version, this runs the reactor
# For the asyncore version, this runs asyncore.loop()
from sippy.Core.EventDispatcher import ED
ED.loop()
# Sippy blocks here dispatching its event loop; callbacks
# injected via ED2.callFromThread run inside this loop.
ED2.loop()
except Exception as e:
logger.error(f" Sippy event loop crashed: {e}")
@@ -294,10 +446,9 @@ class SippyEngine(SIPEngine):
"""
Handle an incoming SIP REGISTER from a phone or softphone.
Extracts the AOR (address of record) from the To header, records
the contact and expiry, and sends a 200 OK. The gateway's
register_device() is called asynchronously via the event loop so
the phone gets an extension and SIP URI assigned automatically.
Runs on the Sippy thread: parses the request, replies 200 OK,
and posts the registration to the loop funnel, which owns the
device list and notifies the gateway.
"""
try:
to_uri = str(req.getHFBody("to").getUri())
@@ -309,34 +460,13 @@ class SippyEngine(SIPEngine):
logger.info(f" SIP REGISTER: {to_uri} contact={contact_uri} expires={expires}")
if expires == 0:
# De-registration
self._registered_devices = [
d for d in self._registered_devices
if d.get("aor") != to_uri
]
logger.info(f" De-registered: {to_uri}")
self._post_from_ed("deregister", {"aor": to_uri})
else:
# Update or add registration record
existing = next(
(d for d in self._registered_devices if d.get("aor") == to_uri),
None,
)
if existing:
existing["contact"] = contact_uri
existing["expires"] = expires
else:
self._registered_devices.append({
"aor": to_uri,
"contact": contact_uri,
"expires": expires,
})
# Notify the gateway (async) so it can assign an extension
if self._loop:
self._loop.call_soon_threadsafe(
self._loop.create_task,
self._notify_registration(to_uri, contact_uri, expires),
)
self._post_from_ed("register", {
"aor": to_uri,
"contact": contact_uri,
"expires": expires,
})
# Reply 200 OK
req.sendResponse(200, "OK")
@@ -348,52 +478,41 @@ class SippyEngine(SIPEngine):
except Exception:
pass
async def _notify_registration(self, aor: str, contact: str, expires: int):
"""
Async callback: tell the gateway about the newly registered device
so it can assign an extension if needed.
"""
if self._on_device_registered:
await self._on_device_registered(aor, contact, expires)
def _handle_incoming_invite(self, req, sip_t):
"""Handle an incoming INVITE — create inbound call leg.
"""Handle an incoming INVITE — surface an inbound call leg.
The gateway is notified via `on_incoming_call`; it decides
whether to answer (via `accept_inbound`) or reject the leg
based on routing rules.
Runs on the Sippy thread: extracts everything the loop needs
(URIs, SDP body) as plain strings and posts them. The gateway
decides whether to answer (via `accept_inbound`) or reject.
"""
from_uri = str(req.getHFBody("from").getUri())
to_uri = str(req.getHFBody("to").getUri())
sdp = str(req.getBody()) if req.getBody() else None
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
leg = SipCallLeg(leg_id, "inbound", from_uri)
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None
leg.pending_invite = req
self._legs[leg_id] = leg
ua = sip_t.ua if hasattr(sip_t, "ua") else None
if ua is not None:
self._ed_ua_to_leg[ua] = leg_id
self._ed_leg_to_ua[leg_id] = ua
logger.info(f" Incoming call: {from_uri}{to_uri} (leg: {leg_id})")
# Surface to the gateway. If no callback is wired, fall back to
# auto-answer so we don't regress the previous behavior.
if self._on_incoming_call and self._loop:
asyncio.run_coroutine_threadsafe(
self._on_incoming_call(from_uri, to_uri, leg_id),
self._loop,
)
else:
controller = SippyCallController(leg, self)
controller.on_connected(str(req.getBody()) if req.getBody() else None)
self._post_from_ed("incoming_invite", {
"leg_id": leg_id,
"from_uri": from_uri,
"to_uri": to_uri,
"sdp": sdp,
})
async def accept_inbound(self, leg_id: str) -> bool:
"""Answer a previously-surfaced inbound INVITE."""
leg = self._legs.get(leg_id)
if not leg or leg.direction != "inbound":
return False
req = getattr(leg, "pending_invite", None)
controller = SippyCallController(leg, self)
body = str(req.getBody()) if req and req.getBody() else None
controller.on_connected(body)
sdp, leg.pending_sdp = leg.pending_sdp, None
await self._on_engine_event(
"leg_state", {"leg_id": leg_id, "state": "connected", "sdp": sdp}
)
return True
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
@@ -404,66 +523,96 @@ class SippyEngine(SIPEngine):
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
# Real SIP rejection would go through Sippy here; we just drop the leg
# in stub mode so callers see the call terminate.
self._run_on_sippy(lambda: self._ed_forget_leg(leg_id))
return True
def _handle_incoming_bye(self, req, sip_t):
"""Handle incoming BYE — tear down call leg."""
# Find the leg by Sippy's UA object
for leg in self._legs.values():
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua:
controller = SippyCallController(leg, self)
controller.on_disconnected("BYE received")
break
"""Handle incoming BYE — tear down call leg (Sippy thread)."""
ua = sip_t.ua if hasattr(sip_t, "ua") else None
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
if leg_id:
SippyCallController(leg_id, self).on_disconnected("BYE received")
def _handle_incoming_info(self, req, sip_t):
"""Handle SIP INFO (DTMF via SIP INFO method)."""
"""Handle SIP INFO (DTMF via SIP INFO method) on the Sippy thread."""
body = str(req.getBody()) if req.getBody() else ""
if "dtmf" in body.lower() or "Signal=" in body:
# Extract DTMF digit from SIP INFO body
ua = sip_t.ua if hasattr(sip_t, "ua") else None
leg_id = self._ed_ua_to_leg.get(ua) if ua is not None else None
if not leg_id:
return
for line in body.split("\n"):
if line.startswith("Signal="):
digit = line.split("=")[1].strip()
for leg in self._legs.values():
if leg.sippy_ua and hasattr(sip_t, "ua") and leg.sippy_ua == sip_t.ua:
controller = SippyCallController(leg, self)
controller.on_dtmf(digit)
break
SippyCallController(leg_id, self).on_dtmf(digit)
async def _register_trunk(self) -> None:
"""Register with the SIP trunk provider."""
try:
from sippy.UA import UA
from sippy.SipRegistrationAgent import SipRegistrationAgent
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
def do_register():
try:
from sippy.SipRegistrationAgent import SipRegistrationAgent
from sippy.SipURL import SipURL
# Run registration in Sippy's thread
def do_register():
try:
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,
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)},
)
reg_agent.register()
self._trunk_registered = True
logger.info(" ✅ Trunk registration sent")
except Exception as e:
logger.error(f" ❌ Trunk registration failed: {e}")
self._trunk_registered = False
await asyncio.get_event_loop().run_in_executor(None, do_register)
# 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"
)
except ImportError:
logger.warning(" Sippy registration agent not available")
self._trunk_registered = False
# aor/contact must be SipURL objects: the agent calls
# .getCopy() and mutates .username/.port on them.
reg_agent = SipRegistrationAgent(
self._sippy_global_config,
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,
)
# 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}", exc_info=True)
self._post_from_ed(
"trunk_registered", {"registered": False, "reason": str(e)}
)
self._run_on_sippy(do_register)
async def stop(self) -> None:
"""Gracefully shut down the SIP engine."""
logger.info("🔌 Stopping Sippy B2BUA...")
# Cancel in-flight incoming-call dispatch tasks
for task in list(self._tasks):
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
# Hang up all active legs
for leg_id in list(self._legs.keys()):
try:
@@ -473,8 +622,8 @@ class SippyEngine(SIPEngine):
# Stop Sippy's event loop
try:
from sippy.Core.EventDispatcher import ED
ED.breakLoop()
from sippy.Core.EventDispatcher import ED2
ED2.breakLoop()
except Exception:
pass
@@ -505,53 +654,71 @@ 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
logger.info(f"📞 Placing call: {from_uri}{remote_uri} (leg: {leg_id})")
# Place the call via Sippy
# Generate SDP on the loop (allocate_rtp_port is lock-protected)
sdp_body = self._generate_sdp(leg_id)
def do_invite():
try:
from sippy.UA import UA
from sippy.SipCallId import SipCallId
from sippy.CCEvents import CCEventTry
from sippy.MsgBody import MsgBody
from sippy.SipCallId import SipCallId
from sippy.UA import UA
controller = SippyCallController(leg, self)
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),
)
leg.sippy_ua = ua
self._ed_leg_to_ua[leg_id] = ua
self._ed_ua_to_leg[ua] = leg_id
# Generate SDP for the call
sdp_body = self._generate_sdp(leg_id)
# 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
# Send INVITE
# 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)
leg.state = "trying"
logger.info(f" INVITE sent for {leg_id}")
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
except ImportError:
# Sippy not installed — simulate for development
logger.warning(f" Sippy not installed, simulating call for {leg_id}")
leg.state = "ringing"
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}")
leg.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)},
)
await asyncio.get_event_loop().run_in_executor(None, do_invite)
self._run_on_sippy(do_invite)
return leg_id
async def hangup(self, call_leg_id: str) -> None:
@@ -563,15 +730,18 @@ class SippyEngine(SIPEngine):
def do_bye():
try:
if leg.sippy_ua:
ua = self._ed_leg_to_ua.get(call_leg_id)
if ua is not None:
from sippy.CCEvents import CCEventDisconnect
leg.sippy_ua.recvEvent(CCEventDisconnect())
ua.recvEvent(CCEventDisconnect())
except Exception as e:
logger.error(f" Error sending BYE for {call_leg_id}: {e}")
finally:
leg.state = "terminated"
self._ed_forget_leg(call_leg_id)
await asyncio.get_event_loop().run_in_executor(None, do_bye)
self._run_on_sippy(do_bye)
leg.state = "terminated"
# Clean up media
if self.media_pipeline and leg.media_port is not None:
@@ -595,13 +765,13 @@ class SippyEngine(SIPEngine):
def do_dtmf():
try:
if leg.sippy_ua:
# Send via RFC 2833 (in-band RTP event)
# Sippy handles this through the UA's DTMF sender
ua = self._ed_leg_to_ua.get(call_leg_id)
if ua is not None:
# Send via SIP INFO through the UA
from sippy.CCEvents import CCEventInfo
for digit in digits:
from sippy.CCEvents import CCEventInfo
body = f"Signal={digit}\r\nDuration=160\r\n"
leg.sippy_ua.recvEvent(CCEventInfo(body=body))
ua.recvEvent(CCEventInfo(body=body))
else:
logger.warning(f" No UA for {call_leg_id}, DTMF not sent")
except ImportError:
@@ -609,7 +779,7 @@ class SippyEngine(SIPEngine):
except Exception as e:
logger.error(f" DTMF send error: {e}")
await asyncio.get_event_loop().run_in_executor(None, do_dtmf)
self._run_on_sippy(do_dtmf)
# ================================================================
# Device Calls (for transfer)
@@ -638,13 +808,15 @@ class SippyEngine(SIPEngine):
logger.info(f"📱 Calling device: {device.name} ({device.sip_uri}) (leg: {leg_id})")
sdp_body = self._generate_sdp(leg_id)
def do_invite_device():
try:
from sippy.UA import UA
from sippy.CCEvents import CCEventTry
from sippy.SipCallId import SipCallId
from sippy.UA import UA
controller = SippyCallController(leg, self)
controller = SippyCallController(leg_id, self)
# Parse device SIP URI for routing
# sip:robert@192.168.1.100:5060
@@ -662,25 +834,24 @@ class SippyEngine(SIPEngine):
event_cb=controller,
nh_address=(host, port),
)
leg.sippy_ua = ua
sdp_body = self._generate_sdp(leg_id)
self._ed_leg_to_ua[leg_id] = ua
self._ed_ua_to_leg[ua] = leg_id
event = CCEventTry(
(SipCallId(), f"sip:gateway@{self._domain}", device.sip_uri),
body=sdp_body,
)
ua.recvEvent(event)
leg.state = "trying"
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "trying"})
except ImportError:
logger.warning(f" Sippy not installed, simulating device call for {leg_id}")
leg.state = "ringing"
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
except Exception as e:
logger.error(f" Failed to call device {device.name}: {e}")
leg.state = "terminated"
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
await asyncio.get_event_loop().run_in_executor(None, do_invite_device)
self._run_on_sippy(do_invite_device)
return leg_id
# ================================================================

View File

@@ -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",

View File

@@ -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",

View File

@@ -1 +1,4 @@
@import 'tailwindcss';
@plugin 'daisyui' {
themes: light --default, dark --prefersdark;
}

View 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();

View File

@@ -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 {

View 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();

View 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>

View 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>

View 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}

View File

@@ -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;

View File

@@ -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}

View File

@@ -4,7 +4,9 @@ Database connection and session management.
PostgreSQL via asyncpg + SQLAlchemy async.
"""
from datetime import datetime
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from sqlalchemy import (
JSON,
@@ -12,6 +14,7 @@ from sqlalchemy import (
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
@@ -49,7 +52,6 @@ class CallRecord(Base):
hold_time = Column(Integer, default=0) # seconds spent on hold
device_used = Column(String)
recording_path = Column(String, nullable=True)
transcript = Column(Text, nullable=True)
summary = Column(Text, nullable=True)
action_items = Column(JSON, nullable=True)
sentiment = Column(String, nullable=True)
@@ -83,24 +85,6 @@ class StoredCallFlow(Base):
return f"<StoredCallFlow {self.id} {self.phone_number}>"
class Contact(Base):
__tablename__ = "contacts"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
phone_numbers = Column(JSON, nullable=False) # [{number, label, primary}, ...]
category = Column(String) # personal / business / service
routing_preference = Column(String, nullable=True) # how to handle their calls
notes = Column(Text, nullable=True)
call_count = Column(Integer, default=0)
last_call = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<Contact {self.id} {self.name}>"
class Device(Base):
__tablename__ = "devices"
@@ -110,7 +94,7 @@ class Device(Base):
sip_uri = Column(String, nullable=True) # sip:robert@gateway.helu.ca
phone_number = Column(String, nullable=True) # For PSTN devices
priority = Column(Integer, default=10) # Routing priority (lower = higher priority)
is_online = Column(String, default="false")
is_online = Column(Boolean, default=False, nullable=False)
capabilities = Column(JSON, default=list) # ["voice", "video", "sms"]
dnd = Column(Boolean, default=False, nullable=False)
last_seen = Column(DateTime, nullable=True)
@@ -170,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
# ============================================================
@@ -204,8 +230,13 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
return _session_factory
async def get_db() -> AsyncSession:
"""Dependency: yield an async database session."""
@asynccontextmanager
async def session_scope() -> AsyncIterator[AsyncSession]:
"""A commit-on-success session — the one session-lifecycle convention.
REST handlers get it via the get_db dependency; services and MCP
tools use it directly.
"""
factory = get_session_factory()
async with factory() as session:
try:
@@ -216,11 +247,37 @@ async def get_db() -> AsyncSession:
raise
async def get_db() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency: yield an async database session."""
async with session_scope() as session:
yield session
# The autogenerated baseline revision — a schema created by the old
# create_all path is identical to it, so such databases are stamped
# here and then migrated forward like any other.
_BASELINE_REVISION = "1173a71329ed"
def _upgrade_to_head(connection) -> None:
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
cfg = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini"))
cfg.attributes["connection"] = connection
inspector = inspect(connection)
if not inspector.has_table("alembic_version") and inspector.has_table("call_records"):
command.stamp(cfg, _BASELINE_REVISION)
command.upgrade(cfg, "head")
async def init_db():
"""Create all tables. For development; use Alembic migrations in production."""
"""Bring the schema to Alembic head (tests create tables directly)."""
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(_upgrade_to_head)
async def close_db():

68
db/migrations/env.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Alembic environment — async engine against Base.metadata.
Two entry paths:
- CLI (``alembic upgrade head``): builds an async engine from
Settings.database_url and runs migrations on it.
- App startup (db.database.init_db): passes an already-open
connection via ``config.attributes["connection"]`` so migrations
run inside the app's engine instead of opening a second one.
"""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from config import get_settings
from db.database import Base
config = context.config
# Only configure logging on standalone CLI runs — inside the app this
# would clobber uvicorn's logger setup.
if config.config_file_name is not None and config.attributes.get("connection") is None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Emit SQL to stdout without a live connection (--sql mode)."""
context.configure(
url=get_settings().database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
engine = create_async_engine(get_settings().database_url, poolclass=pool.NullPool)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()
def run_migrations_online() -> None:
connection = config.attributes.get("connection")
if connection is not None:
do_run_migrations(connection)
else:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,129 @@
"""baseline schema
Revision ID: 1173a71329ed
Revises:
Create Date: 2026-07-10 07:19:08.321778
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1173a71329ed'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('call_flows',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('phone_number', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('steps', sa.JSON(), nullable=False),
sa.Column('last_verified', sa.DateTime(), nullable=True),
sa.Column('avg_hold_time', sa.Integer(), nullable=True),
sa.Column('success_rate', sa.Float(), nullable=True),
sa.Column('times_used', sa.Integer(), nullable=True),
sa.Column('last_used', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_call_flows_phone_number'), 'call_flows', ['phone_number'], unique=False)
op.create_table('call_records',
sa.Column('id', sa.String(), nullable=False),
sa.Column('direction', sa.String(), nullable=False),
sa.Column('remote_number', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('mode', sa.String(), nullable=False),
sa.Column('intent', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('hold_time', sa.Integer(), nullable=True),
sa.Column('device_used', sa.String(), nullable=True),
sa.Column('recording_path', sa.String(), nullable=True),
sa.Column('transcript', sa.Text(), nullable=True),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('action_items', sa.JSON(), nullable=True),
sa.Column('sentiment', sa.String(), nullable=True),
sa.Column('call_flow_id', sa.String(), nullable=True),
sa.Column('classification_timeline', sa.JSON(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_call_records_remote_number'), 'call_records', ['remote_number'], unique=False)
op.create_table('devices',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('sip_uri', sa.String(), nullable=True),
sa.Column('phone_number', sa.String(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True),
sa.Column('is_online', sa.String(), nullable=True),
sa.Column('capabilities', sa.JSON(), nullable=True),
sa.Column('dnd', sa.Boolean(), nullable=False),
sa.Column('last_seen', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('recordings',
sa.Column('id', sa.String(), nullable=False),
sa.Column('call_id', sa.String(), nullable=False),
sa.Column('path', sa.String(), nullable=False),
sa.Column('format', sa.String(), nullable=True),
sa.Column('duration_s', sa.Float(), nullable=True),
sa.Column('size_bytes', sa.Integer(), nullable=True),
sa.Column('channels', sa.Integer(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_recordings_call_id'), 'recordings', ['call_id'], unique=False)
op.create_table('routing_rules',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('match', sa.JSON(), nullable=False),
sa.Column('action', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transcript_chunks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('call_id', sa.String(), nullable=False),
sa.Column('seq', sa.Integer(), nullable=False),
sa.Column('t_offset_ms', sa.Integer(), nullable=True),
sa.Column('speaker', sa.String(), nullable=True),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transcript_chunks_call_id'), 'transcript_chunks', ['call_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_transcript_chunks_call_id'), table_name='transcript_chunks')
op.drop_table('transcript_chunks')
op.drop_table('routing_rules')
op.drop_index(op.f('ix_recordings_call_id'), table_name='recordings')
op.drop_table('recordings')
op.drop_table('devices')
op.drop_index(op.f('ix_call_records_remote_number'), table_name='call_records')
op.drop_table('call_records')
op.drop_index(op.f('ix_call_flows_phone_number'), table_name='call_flows')
op.drop_table('call_flows')
# ### end Alembic commands ###

View File

@@ -0,0 +1,45 @@
"""drop dead transcript column, boolean is_online
Revision ID: 5187577efc23
Revises: 1173a71329ed
Create Date: 2026-07-10 07:19:40.741327
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '5187577efc23'
down_revision: Union[str, None] = '1173a71329ed'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Transcript text lives solely in transcript_chunks rows now.
op.drop_column('call_records', 'transcript')
# String "true"/"false" (or NULL) -> real boolean; NULLs become false.
# batch mode so the table-recreate path works on SQLite too.
with op.batch_alter_table('devices') as batch_op:
batch_op.alter_column(
'is_online',
existing_type=sa.VARCHAR(),
type_=sa.Boolean(),
nullable=False,
postgresql_using="coalesce(lower(is_online) in ('true', 't', '1'), false)",
)
def downgrade() -> None:
with op.batch_alter_table('devices') as batch_op:
batch_op.alter_column(
'is_online',
existing_type=sa.Boolean(),
type_=sa.VARCHAR(),
nullable=True,
postgresql_using="case when is_online then 'true' else 'false' end",
)
op.add_column('call_records', sa.Column('transcript', sa.TEXT(), nullable=True))

View 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
View 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:

View File

@@ -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

View File

@@ -1,6 +1,15 @@
# 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.
> **Media plane in transition.** The gateway currently signals with Sippy and
> intends PJSUA2 to carry media, but PJSUA2 will not surface an RTP stream for
> a dialog it does not own — so no audio ever reaches the classifier. The fix
> moves *call placement* into PJSUA2 while Sippy keeps the SBC roles. See
> [Media plane: why PJSUA2 places the call](#media-plane-why-pjsua2-places-the-call)
> before changing anything in `core/`.
## System Diagram
@@ -10,7 +19,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 +36,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 +60,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 +84,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 +114,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 +132,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 +230,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 +244,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
View 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 18 across the LAN
6. **Only then** consider Kamailio (4a) or the PSTN (4b)
Steps 24 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 24
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 24 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 24.
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.)

View File

@@ -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
{

View File

@@ -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 |
@@ -126,7 +145,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

View 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.

View File

@@ -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)

View File

@@ -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 |
|-----------|---------------------------------|--------------------------------|

View File

@@ -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
View 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.

453
main.py
View File

@@ -12,18 +12,32 @@ 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
from db.database import close_db, init_db
from core.gateway import AIPSTNGateway, build_sip_engine
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
from services.call_persistence import persist_call_on_create, persist_call_on_end
from services.hold_slayer import HoldSlayerService
from services.notification import NotificationService
from services.receptionist import ReceptionistService
from services.recording import RecordingService
from services.routing import RoutingService
from services.transcription import TranscriptionService
from services.tts import TTSService
# Configure logging
logging.basicConfig(
@@ -96,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)
@@ -116,6 +152,10 @@ async def lifespan(app: FastAPI):
settings = get_settings()
_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):
@@ -126,23 +166,70 @@ async def lifespan(app: FastAPI):
except Exception as e:
_handle_db_error(e)
# Boot the telephony engine
gateway = AIPSTNGateway.from_config()
# === Composition root ===
# Build the gateway and every service here, wiring them by
# constructor/registration — nothing constructs its own deps.
gateway = AIPSTNGateway(
settings=settings,
on_call_created=persist_call_on_create,
on_call_ended=persist_call_on_end,
)
classifier = AudioClassifier(settings.classifier)
transcription = TranscriptionService(settings.speaches)
tts = TTSService(settings.tts)
routing_svc = RoutingService(gateway)
recording_svc = RecordingService()
receptionist = ReceptionistService(
gateway,
tts=tts,
transcription=transcription,
recording=recording_svc,
routing=routing_svc,
)
gateway.attach_services(tts=tts)
def launch_hold_slayer(call, sip_leg_id, call_flow_id):
svc = HoldSlayerService(
gateway=gateway,
call_manager=gateway.call_manager,
sip_engine=gateway.sip_engine,
classifier=classifier,
transcription=transcription,
settings=settings,
tts=tts,
)
gateway.spawn(
svc.run(call, sip_leg_id, call_flow_id),
name=f"holdslayer_{call.id}",
)
gateway.register_mode_handler(CallMode.HOLD_SLAYER, launch_hold_slayer)
try:
gateway.sip_engine = build_sip_engine(
settings,
gateway.media_pipeline,
on_leg_state_change=gateway._on_sip_leg_state,
on_device_registered=gateway._on_sip_device_registered,
on_incoming_call=receptionist.on_inbound_call,
)
except Exception as e:
logger.critical(f"\n❌ SIP engine failed to initialize:\n {e}")
sys.exit(1)
await routing_svc.start()
await gateway.start()
app.state.gateway = gateway
# Start auxiliary services
from services.notification import NotificationService
from services.recording import RecordingService
app.state.routing_service = routing_svc
app.state.transcription_service = transcription
notification_svc = NotificationService(gateway.event_bus, settings)
await notification_svc.start()
app.state.notification_service = notification_svc
recording_svc = RecordingService()
await recording_svc.start()
app.state.recording_service = recording_svc
gateway._recording_service = recording_svc
logger.info("=" * 60)
logger.info("🔥 Hold Slayer Gateway is LIVE")
@@ -157,7 +244,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")
@@ -179,57 +270,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()
@@ -250,21 +535,81 @@ async def root():
@app.get("/health", tags=["System"])
async def health():
"""Health check endpoint."""
"""
Health check. "healthy" means the gateway can actually do its job:
real engine, registered trunk, reachable database. A mock engine or
a failing dependency reports "degraded" with the reason visible.
"""
from core.sip_engine import MockSIPEngine
from db.database import session_scope
gateway = getattr(app.state, "gateway", None)
ready = gateway is not None and await gateway.sip_engine.is_ready()
trunk_status = await gateway.sip_engine.get_trunk_status() if gateway else {"registered": False}
return {
"status": "healthy" if ready else "degraded",
engine_mode = (
"mock" if gateway is None or isinstance(gateway.sip_engine, MockSIPEngine)
else "sippy"
)
db_ok = False
db_error = None
try:
from sqlalchemy import text
async with session_scope() as session:
await session.execute(text("SELECT 1"))
db_ok = True
except Exception as e:
db_error = str(e)[:200]
healthy = (
ready
and db_ok
and engine_mode == "sippy"
and trunk_status.get("registered", False)
)
checks = {
"gateway": "ready" if gateway else "not initialized",
"engine": engine_mode,
"sip_engine": "ready" if ready else "not ready",
"database": "ok" if db_ok else f"error: {db_error}",
"sip_trunk": {
"registered": trunk_status.get("registered", False),
"host": trunk_status.get("host"),
"mock": trunk_status.get("mock", False),
"reason": trunk_status.get("reason"),
},
}
if gateway is not None:
tts = getattr(gateway, "_tts", None)
checks["tts"] = _availability(tts)
transcription = getattr(app.state, "transcription_service", None)
checks["stt"] = _availability(transcription)
return {"status": "healthy" if healthy else "degraded", **checks}
def _availability(service) -> str:
"""Last-known reachability of an HTTP leaf service."""
if service is None:
return "not attached"
available = getattr(service, "available", None)
if available is None:
return "unknown (no requests yet)"
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__":

View File

@@ -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()
@@ -184,18 +182,12 @@ def create_mcp_server(
Returns the IVR navigation tree if one exists.
"""
from db.database import StoredCallFlow, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(StoredCallFlow).where(
StoredCallFlow.phone_number == phone_number
)
)
row = result.scalar_one_or_none()
async with session_scope() as session:
row = await store.get_flow_by_number(session, phone_number)
if not row:
return f"No stored call flow for {phone_number}."
@@ -243,25 +235,26 @@ def create_mcp_server(
"""
from slugify import slugify as do_slugify
from db.database import StoredCallFlow, get_session_factory
from db.database import session_scope
from services import call_persistence as store
try:
steps = json.loads(steps_json)
flow_id = do_slugify(name)
factory = get_session_factory()
async with factory() as session:
db_flow = StoredCallFlow(
id=flow_id,
async with session_scope() as session:
if await store.get_flow(session, flow_id):
return f"Call flow '{flow_id}' already exists."
await store.create_flow(
session,
flow_id=flow_id,
name=name,
phone_number=phone_number,
description="Created by AI assistant",
steps=steps,
notes=notes or None,
tags=["ai-created"],
notes=notes or None,
)
session.add(db_flow)
await session.commit()
return f"Call flow '{name}' saved for {phone_number} (ID: {flow_id})"
except json.JSONDecodeError:
@@ -269,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:
"""
@@ -283,12 +340,12 @@ def create_mcp_server(
if not call:
return f"Call {call_id} not found."
for leg_id, cid in gateway.call_manager._call_legs.items():
if cid == call_id:
await gateway.sip_engine.send_dtmf(leg_id, digits)
return f"Sent DTMF '{digits}' on call {call_id}."
legs = gateway.call_manager.legs_for_call(call_id)
if not legs:
return f"No active SIP leg found for call {call_id}."
return f"No active SIP leg found for call {call_id}."
await gateway.sip_engine.send_dtmf(legs[0], digits)
return f"Sent DTMF '{digits}' on call {call_id}."
@mcp.tool()
async def get_call_transcript(call_id: str) -> str:
@@ -318,16 +375,12 @@ def create_mcp_server(
Returns the recording file path and status.
"""
from db.database import CallRecord, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
record = result.scalar_one_or_none()
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}."
if not record.recording_path:
@@ -348,16 +401,12 @@ def create_mcp_server(
Returns the summary, action items, and sentiment analysis.
"""
from db.database import CallRecord, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
record = result.scalar_one_or_none()
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}."
@@ -398,27 +447,17 @@ def create_mcp_server(
intent: Filter by intent text (partial match)
limit: Max results to return (default 10)
"""
from db.database import CallRecord, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
query = select(CallRecord).order_by(
CallRecord.started_at.desc()
).limit(limit)
if phone_number:
query = query.where(
CallRecord.remote_number.contains(phone_number)
)
if intent:
query = query.where(
CallRecord.intent.icontains(intent)
)
result = await session.execute(query)
records = result.scalars().all()
async with session_scope() as session:
records = await store.search_history(
session,
number_contains=phone_number or None,
intent_contains=intent or None,
limit=limit,
)
if not records:
return "No matching call records found."
@@ -482,14 +521,12 @@ def create_mcp_server(
@mcp.resource("gateway://call-flows")
async def resource_call_flows() -> str:
"""List all stored call flows."""
from db.database import StoredCallFlow, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(select(StoredCallFlow))
rows = result.scalars().all()
async with session_scope() as session:
rows = await store.list_flows(session)
flows = [
{
"id": r.id,

View File

@@ -55,6 +55,14 @@ class ClassificationResult(BaseModel):
details: Optional[dict] = None # Extra analysis data
class TranscriptEntry(BaseModel):
"""One transcribed utterance, offset from call start for seek."""
t_offset_ms: int
speaker: str = "unknown" # caller / agent / receptionist / unknown
text: str
class ActiveCall(BaseModel):
"""In-memory state for an active call."""
@@ -71,9 +79,12 @@ class ActiveCall(BaseModel):
hold_started_at: Optional[datetime] = None
current_classification: AudioClassification = AudioClassification.UNKNOWN
classification_history: list[ClassificationResult] = Field(default_factory=list)
transcript_chunks: list[str] = Field(default_factory=list)
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:
@@ -92,7 +103,7 @@ class ActiveCall(BaseModel):
@property
def transcript(self) -> str:
"""Full transcript so far."""
return "\n".join(self.transcript_chunks)
return "\n".join(e.text for e in self.transcript_chunks)
def summary(self) -> dict:
"""Compact summary for list views."""
@@ -145,6 +156,16 @@ class CallResponse(BaseModel):
mode: str
message: Optional[str] = None
@classmethod
def from_call(cls, call: "ActiveCall", message: Optional[str] = None) -> "CallResponse":
return cls(
call_id=call.id,
status=call.status.value,
number=call.remote_number,
mode=call.mode.value,
message=message,
)
class CallStatusResponse(BaseModel):
"""Full status of an active or completed call."""
@@ -163,6 +184,25 @@ class CallStatusResponse(BaseModel):
current_step: Optional[str] = None
services: list[str] = Field(default_factory=list)
@classmethod
def from_call(cls, call: "ActiveCall") -> "CallStatusResponse":
"""The one ActiveCall → status-response mapping."""
return cls(
call_id=call.id,
status=call.status.value,
direction=call.direction,
remote_number=call.remote_number,
mode=call.mode.value,
duration=call.duration,
hold_time=call.hold_time,
audio_type=call.current_classification.value,
intent=call.intent,
transcript_excerpt=call.transcript[-500:] if call.transcript else None,
classification_history=call.classification_history[-20:],
current_step=call.current_step_id,
services=call.services,
)
class TransferRequest(BaseModel):
"""Request to transfer a call to a device."""

View File

@@ -1,60 +0,0 @@
"""
Contact models — People and organizations you call.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class PhoneNumber(BaseModel):
"""A phone number associated with a contact."""
number: str # E.164 format
label: str = "main" # main, mobile, work, home, fax, etc.
primary: bool = False
class ContactBase(BaseModel):
"""Shared contact fields."""
name: str
phone_numbers: list[PhoneNumber]
category: Optional[str] = None # personal / business / service
routing_preference: Optional[str] = None # how to handle their calls
notes: Optional[str] = None
class Contact(ContactBase):
"""Full contact model."""
id: str
call_count: int = 0
last_call: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@property
def primary_number(self) -> Optional[str]:
"""Get the primary phone number."""
for pn in self.phone_numbers:
if pn.primary:
return pn.number
return self.phone_numbers[0].number if self.phone_numbers else None
class ContactCreate(ContactBase):
"""Request model for creating a contact."""
pass
class ContactUpdate(BaseModel):
"""Request model for updating a contact."""
name: Optional[str] = None
phone_numbers: Optional[list[PhoneNumber]] = None
category: Optional[str] = None
routing_preference: Optional[str] = None
notes: Optional[str] = None

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, computed_field
class DeviceType(str, Enum):
@@ -43,6 +43,7 @@ class Device(DeviceBase):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@computed_field # serialized so API consumers see routability directly
@property
def can_receive_call(self) -> bool:
"""Can this device receive a call right now?"""
@@ -71,14 +72,3 @@ class DeviceUpdate(BaseModel):
phone_number: Optional[str] = None
priority: Optional[int] = None
capabilities: Optional[list[str]] = None
class DeviceStatus(BaseModel):
"""Lightweight device status for list views."""
id: str
name: str
type: DeviceType
is_online: bool
last_seen: Optional[datetime] = None
can_receive_call: bool

View File

@@ -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]
@@ -50,6 +50,9 @@ dev = [
"pytest-cov>=6.0.0",
"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]
@@ -69,3 +72,7 @@ line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
[tool.ruff.lint.per-file-ignores]
# Alembic-generated migrations keep the standard template style.
"db/migrations/versions/*" = ["E501", "UP007", "UP035", "W291"]

View File

@@ -12,6 +12,7 @@ Uses spectral analysis (librosa/numpy) to classify audio without needing
a trained ML model — just signal processing and heuristics.
"""
import asyncio
import logging
import time
from typing import Optional
@@ -47,9 +48,23 @@ class AudioClassifier:
self._window_samples = int(settings.window_seconds * SAMPLE_RATE)
self._classification_history: list[AudioClassification] = []
async def classify(self, audio_data: bytes) -> ClassificationResult:
"""
Classify a chunk off the event loop and record it in the history.
The FFT/autocorrelation work is CPU-bound, so the pure
`classify_chunk` runs in a worker thread; the history update
happens back on the loop, keeping it single-threaded. This is
the call sites' entry point — routing every classification
through here is what keeps the history complete.
"""
result = await asyncio.to_thread(self.classify_chunk, audio_data)
self.update_history(result.audio_type)
return result
def classify_chunk(self, audio_data: bytes) -> ClassificationResult:
"""
Classify a chunk of audio data.
Classify a chunk of audio data (pure, synchronous).
Args:
audio_data: Raw PCM audio (16-bit signed, 16kHz, mono)
@@ -129,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
@@ -285,17 +304,15 @@ class AudioClassifier:
(941, 1209): "*", (941, 1336): "0", (941, 1477): "#", (941, 1633): "D",
}
# Compute power at each DTMF frequency
# Power at each DTMF frequency via the DFT bin (numerically equal
# to the Goertzel result s1² + s2² coeff·s1·s2, but vectorized —
# the per-sample Python loop blocked for ~50ms per chunk)
n = np.arange(len(samples))
def goertzel_power(freq: int) -> float:
k = int(0.5 + len(samples) * freq / SAMPLE_RATE)
w = 2 * np.pi * k / len(samples)
coeff = 2 * np.cos(w)
s0, s1, s2 = 0.0, 0.0, 0.0
for sample in samples:
s0 = sample + coeff * s1 - s2
s2 = s1
s1 = s0
return float(s1 * s1 + s2 * s2 - coeff * s1 * s2)
bin_value = np.dot(samples, np.exp(-2j * np.pi * k * n / len(samples)))
return float(np.abs(bin_value) ** 2)
# Find strongest low and high frequencies
low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low]

View File

@@ -1,324 +0,0 @@
"""
Call Analytics Service — Tracks call metrics and generates insights.
Monitors call patterns, hold times, success rates, and IVR navigation
efficiency. Provides data for the dashboard and API.
"""
import logging
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Optional
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus
logger = logging.getLogger(__name__)
class CallAnalytics:
"""
In-memory call analytics engine.
Tracks:
- Call success/failure rates
- Hold time statistics (avg, min, max, p95)
- IVR navigation efficiency
- Human detection accuracy
- Per-number/company patterns
- Time-of-day patterns
In production, this would be backed by TimescaleDB or similar.
For now, we keep rolling windows in memory.
"""
def __init__(self, max_history: int = 10000):
self._max_history = max_history
self._call_records: list[CallRecord] = []
self._company_stats: dict[str, CompanyStats] = defaultdict(CompanyStats)
# ================================================================
# Record Calls
# ================================================================
def record_call(self, call: ActiveCall) -> None:
"""
Record a completed call for analytics.
Called when a call ends (from CallManager).
"""
record = CallRecord(
call_id=call.id,
remote_number=call.remote_number,
mode=call.mode,
status=call.status,
intent=call.intent,
started_at=call.created_at,
duration_seconds=call.duration,
hold_time_seconds=call.hold_time,
classification_history=[
r.audio_type.value for r in call.classification_history
],
transcript_chunks=list(call.transcript_chunks),
services=list(call.services),
)
self._call_records.append(record)
# Trim history
if len(self._call_records) > self._max_history:
self._call_records = self._call_records[-self._max_history :]
# Update company stats
company_key = self._normalize_number(call.remote_number)
self._company_stats[company_key].update(record)
logger.debug(
f"📊 Recorded call {call.id}: "
f"{call.status.value}, {call.duration}s, hold={call.hold_time}s"
)
# ================================================================
# Aggregate Stats
# ================================================================
def get_summary(self, hours: int = 24) -> dict[str, Any]:
"""Get summary statistics for the last N hours."""
cutoff = datetime.now() - timedelta(hours=hours)
recent = [r for r in self._call_records if r.started_at >= cutoff]
if not recent:
return {
"period_hours": hours,
"total_calls": 0,
"success_rate": 0.0,
"avg_hold_time": 0.0,
"avg_duration": 0.0,
}
total = len(recent)
successful = sum(1 for r in recent if r.status in (
CallStatus.COMPLETED, CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED
))
failed = sum(1 for r in recent if r.status == CallStatus.FAILED)
hold_times = [r.hold_time_seconds for r in recent if r.hold_time_seconds > 0]
durations = [r.duration_seconds for r in recent if r.duration_seconds > 0]
hold_slayer_calls = [r for r in recent if r.mode == CallMode.HOLD_SLAYER]
hold_slayer_success = sum(
1 for r in hold_slayer_calls
if r.status in (CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED)
)
return {
"period_hours": hours,
"total_calls": total,
"successful": successful,
"failed": failed,
"success_rate": round(successful / total, 3) if total else 0.0,
"avg_duration": round(sum(durations) / len(durations), 1) if durations else 0.0,
"max_duration": max(durations) if durations else 0,
"hold_time": {
"avg": round(sum(hold_times) / len(hold_times), 1) if hold_times else 0.0,
"min": min(hold_times) if hold_times else 0,
"max": max(hold_times) if hold_times else 0,
"p95": self._percentile(hold_times, 95) if hold_times else 0,
"total": sum(hold_times),
},
"hold_slayer": {
"total": len(hold_slayer_calls),
"success": hold_slayer_success,
"success_rate": round(
hold_slayer_success / len(hold_slayer_calls), 3
) if hold_slayer_calls else 0.0,
},
"by_mode": self._group_by_mode(recent),
"by_hour": self._group_by_hour(recent),
}
def get_company_stats(self, number: str) -> dict[str, Any]:
"""Get stats for a specific company/number."""
key = self._normalize_number(number)
stats = self._company_stats.get(key)
if not stats:
return {"number": number, "total_calls": 0}
return stats.to_dict(number)
def get_top_numbers(self, limit: int = 10) -> list[dict[str, Any]]:
"""Get the most-called numbers with their stats."""
sorted_stats = sorted(
self._company_stats.items(),
key=lambda x: x[1].total_calls,
reverse=True,
)[:limit]
return [stats.to_dict(number) for number, stats in sorted_stats]
# ================================================================
# Hold Time Trends
# ================================================================
def get_hold_time_trend(
self,
number: Optional[str] = None,
days: int = 7,
) -> list[dict]:
"""
Get hold time trend data for graphing.
Returns daily average hold times for the last N days.
"""
cutoff = datetime.now() - timedelta(days=days)
records = [r for r in self._call_records if r.started_at >= cutoff]
if number:
key = self._normalize_number(number)
records = [r for r in records if self._normalize_number(r.remote_number) == key]
# Group by day
by_day: dict[str, list[int]] = defaultdict(list)
for r in records:
day = r.started_at.strftime("%Y-%m-%d")
if r.hold_time_seconds > 0:
by_day[day].append(r.hold_time_seconds)
trend = []
for i in range(days):
date = (datetime.now() - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
times = by_day.get(date, [])
trend.append({
"date": date,
"avg_hold_time": round(sum(times) / len(times), 1) if times else 0,
"call_count": len(times),
"max_hold_time": max(times) if times else 0,
})
return trend
# ================================================================
# Helpers
# ================================================================
@staticmethod
def _normalize_number(number: str) -> str:
"""Normalize phone number for grouping."""
# Strip formatting, keep last 10 digits
digits = "".join(c for c in number if c.isdigit())
return digits[-10:] if len(digits) >= 10 else digits
@staticmethod
def _percentile(values: list, pct: int) -> float:
"""Calculate percentile value."""
if not values:
return 0.0
sorted_vals = sorted(values)
idx = int(len(sorted_vals) * pct / 100)
idx = min(idx, len(sorted_vals) - 1)
return float(sorted_vals[idx])
@staticmethod
def _group_by_mode(records: list["CallRecord"]) -> dict[str, int]:
"""Group call counts by mode."""
by_mode: dict[str, int] = defaultdict(int)
for r in records:
by_mode[r.mode.value] += 1
return dict(by_mode)
@staticmethod
def _group_by_hour(records: list["CallRecord"]) -> dict[int, int]:
"""Group call counts by hour of day."""
by_hour: dict[int, int] = defaultdict(int)
for r in records:
by_hour[r.started_at.hour] += 1
return dict(sorted(by_hour.items()))
@property
def total_calls_recorded(self) -> int:
return len(self._call_records)
# ================================================================
# Data Models
# ================================================================
class CallRecord:
"""A completed call record for analytics."""
def __init__(
self,
call_id: str,
remote_number: str,
mode: CallMode,
status: CallStatus,
intent: Optional[str] = None,
started_at: Optional[datetime] = None,
duration_seconds: int = 0,
hold_time_seconds: int = 0,
classification_history: Optional[list[str]] = None,
transcript_chunks: Optional[list[str]] = None,
services: Optional[list[str]] = None,
):
self.call_id = call_id
self.remote_number = remote_number
self.mode = mode
self.status = status
self.intent = intent
self.started_at = started_at or datetime.now()
self.duration_seconds = duration_seconds
self.hold_time_seconds = hold_time_seconds
self.classification_history = classification_history or []
self.transcript_chunks = transcript_chunks or []
self.services = services or []
class CompanyStats:
"""Aggregated stats for a specific company/phone number."""
def __init__(self):
self.total_calls = 0
self.successful_calls = 0
self.failed_calls = 0
self.total_hold_time = 0
self.hold_times: list[int] = []
self.total_duration = 0
self.last_called: Optional[datetime] = None
self.intents: dict[str, int] = defaultdict(int)
def update(self, record: CallRecord) -> None:
"""Update stats with a new call record."""
self.total_calls += 1
self.total_duration += record.duration_seconds
self.last_called = record.started_at
if record.status in (CallStatus.COMPLETED, CallStatus.BRIDGED, CallStatus.HUMAN_DETECTED):
self.successful_calls += 1
elif record.status == CallStatus.FAILED:
self.failed_calls += 1
if record.hold_time_seconds > 0:
self.total_hold_time += record.hold_time_seconds
self.hold_times.append(record.hold_time_seconds)
if record.intent:
self.intents[record.intent] += 1
def to_dict(self, number: str) -> dict[str, Any]:
return {
"number": number,
"total_calls": self.total_calls,
"successful_calls": self.successful_calls,
"failed_calls": self.failed_calls,
"success_rate": round(
self.successful_calls / self.total_calls, 3
) if self.total_calls else 0.0,
"avg_hold_time": round(
self.total_hold_time / len(self.hold_times), 1
) if self.hold_times else 0.0,
"max_hold_time": max(self.hold_times) if self.hold_times else 0,
"avg_duration": round(
self.total_duration / self.total_calls, 1
) if self.total_calls else 0.0,
"last_called": self.last_called.isoformat() if self.last_called else None,
"top_intents": dict(
sorted(self.intents.items(), key=lambda x: x[1], reverse=True)[:5]
),
}

View File

@@ -1,70 +1,368 @@
"""
Call Persistence — Writes completed calls and their transcript chunks
to the database when CallManager.end_call() fires.
Call Persistence — the data-access layer for calls and call flows.
Holds the on-hangup persistence hook plus the query/write functions
that both the REST handlers and the MCP tools call, so the two
surfaces can't drift. Every function takes an AsyncSession; callers
own the transaction (get_db for REST, session_scope for MCP/services).
"""
import asyncio
import logging
import uuid
from datetime import datetime
from db.database import CallRecord, TranscriptChunk, get_session_factory
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.database import (
CallRecord,
RecordingRecord,
StoredCallFlow,
TranscriptChunk,
session_scope,
)
from db.database import Device as DeviceRow
from models.call import ActiveCall, CallStatus
from models.call_flow import CallFlow, CallFlowStep
from models.device import Device
logger = logging.getLogger(__name__)
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
"""Insert a CallRecord and any transcript chunks for `call`.
def flow_to_model(row: StoredCallFlow) -> CallFlow:
"""The one StoredCallFlow-row → CallFlow-model mapping."""
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in (row.steps or [])],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
Wired into CallManager via _on_call_ended in gateway.start().
def record_summary(row: CallRecord) -> dict:
"""The one CallRecord-row → list-item mapping."""
return {
"id": row.id,
"direction": row.direction,
"remote_number": row.remote_number,
"status": row.status,
"mode": row.mode,
"intent": row.intent,
"started_at": row.started_at.isoformat() if row.started_at else None,
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
"duration": row.duration,
"hold_time": row.hold_time,
"device_used": row.device_used,
"summary": row.summary,
}
def record_detail(row: CallRecord) -> dict:
"""Full CallRecord-row mapping, superset of record_summary."""
return record_summary(row) | {
"action_items": row.action_items,
"sentiment": row.sentiment,
"call_flow_id": row.call_flow_id,
"classification_timeline": row.classification_timeline,
}
def chunk_to_dict(row: TranscriptChunk) -> dict:
"""The one TranscriptChunk-row → dict mapping."""
return {
"seq": row.seq,
"t_offset_ms": row.t_offset_ms,
"speaker": row.speaker,
"text": row.text,
"confidence": row.confidence,
}
# ================================================================
# Call flows
# ================================================================
async def get_flow(session: AsyncSession, flow_id: str) -> StoredCallFlow | None:
result = await session.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
return result.scalar_one_or_none()
async def get_flow_by_number(
session: AsyncSession, phone_number: str
) -> StoredCallFlow | None:
result = await session.execute(
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
)
return result.scalar_one_or_none()
async def list_flows(session: AsyncSession) -> list[StoredCallFlow]:
result = await session.execute(select(StoredCallFlow))
return list(result.scalars().all())
async def create_flow(
session: AsyncSession,
flow_id: str,
name: str,
phone_number: str,
steps: list[dict],
description: str | None = None,
tags: list[str] | None = None,
notes: str | None = None,
) -> StoredCallFlow:
row = StoredCallFlow(
id=flow_id,
name=name,
phone_number=phone_number,
description=description,
steps=steps,
tags=tags,
notes=notes,
last_verified=datetime.now(),
)
session.add(row)
await session.flush()
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
# ================================================================
async def create_device_row(session: AsyncSession, device: Device) -> None:
"""The one Device-model → row mapping."""
session.add(DeviceRow(
id=device.id,
name=device.name,
type=device.type.value,
sip_uri=device.sip_uri,
phone_number=device.phone_number,
priority=device.priority,
capabilities=device.capabilities,
is_online=device.is_online,
))
await session.flush()
async def update_device_row(
session: AsyncSession, device_id: str, values: dict
) -> None:
result = await session.execute(
select(DeviceRow).where(DeviceRow.id == device_id)
)
row = result.scalar_one_or_none()
if row is None:
return
for key, value in values.items():
if key == "type" and value is not None:
value = value.value if hasattr(value, "value") else value
setattr(row, key, value)
async def delete_device_row(session: AsyncSession, device_id: str) -> None:
result = await session.execute(
select(DeviceRow).where(DeviceRow.id == device_id)
)
row = result.scalar_one_or_none()
if row is not None:
await session.delete(row)
# ================================================================
# Call history / records
# ================================================================
async def get_record(session: AsyncSession, call_id: str) -> CallRecord | None:
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
return result.scalar_one_or_none()
async def search_history(
session: AsyncSession,
number: str | None = None,
number_contains: str | None = None,
intent_contains: str | None = None,
status: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> list[CallRecord]:
stmt = select(CallRecord).order_by(desc(CallRecord.started_at))
if number:
stmt = stmt.where(CallRecord.remote_number == number)
if number_contains:
stmt = stmt.where(CallRecord.remote_number.contains(number_contains))
if intent_contains:
stmt = stmt.where(CallRecord.intent.icontains(intent_contains))
if status:
stmt = stmt.where(CallRecord.status == status)
if since:
stmt = stmt.where(CallRecord.started_at >= since)
if until:
stmt = stmt.where(CallRecord.started_at <= until)
result = await session.execute(stmt.offset(offset).limit(limit))
return list(result.scalars().all())
async def get_transcript_chunks(
session: AsyncSession, call_id: str
) -> list[TranscriptChunk]:
result = await session.execute(
select(TranscriptChunk)
.where(TranscriptChunk.call_id == call_id)
.order_by(TranscriptChunk.seq)
)
return list(result.scalars().all())
async def latest_recording(
session: AsyncSession, call_id: str
) -> RecordingRecord | None:
result = await session.execute(
select(RecordingRecord)
.where(RecordingRecord.call_id == call_id)
.order_by(desc(RecordingRecord.started_at))
)
return result.scalars().first()
async def persist_call_on_create(call: ActiveCall) -> None:
"""Insert an in_progress CallRecord the moment a call starts.
Wired into CallManager as its on_call_created hook — a crash
mid-call leaves this row behind instead of erasing the call from
history. persist_call_on_end updates it to the terminal state.
"""
try:
async with get_session_factory()() as session:
record = CallRecord(
id=call.id,
direction=call.direction,
remote_number=call.remote_number,
status=final_status.value,
mode=call.mode.value,
intent=call.intent,
started_at=call.started_at,
ended_at=datetime.now(),
duration=int(call.duration),
hold_time=int(call.hold_time),
device_used=call.device,
call_flow_id=call.call_flow_id,
classification_timeline=[
{
"timestamp": c.timestamp,
"audio_type": c.audio_type.value,
"confidence": c.confidence,
}
for c in call.classification_history
],
metadata_={"services": list(call.services)},
)
await _with_retry(_insert_in_progress_record, call)
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
"""Finalize the CallRecord and write transcript chunks for `call`.
Wired into CallManager as its on_call_ended hook by the
composition root in main.py.
"""
await _with_retry(_finalize_call_record, call, final_status)
async def _with_retry(write, call: ActiveCall, *args) -> None:
"""Losing the row means the call never happened as far as history
is concerned, so the final failure logs at ERROR with identifiers."""
for attempt in range(3):
try:
await write(call, *args)
return
except Exception as e:
if attempt == 2:
logger.error(
f"Call record lost ({write.__name__}): id={call.id} "
f"number={call.remote_number}: {e}"
)
return
await asyncio.sleep(2**attempt)
async def _insert_in_progress_record(call: ActiveCall) -> None:
async with session_scope() as session:
session.add(CallRecord(
id=call.id,
direction=call.direction,
remote_number=call.remote_number,
status="in_progress",
mode=call.mode.value,
intent=call.intent,
started_at=call.started_at,
device_used=call.device,
call_flow_id=call.call_flow_id,
metadata_={"services": list(call.services)},
))
async def _finalize_call_record(call: ActiveCall, final_status: CallStatus) -> None:
async with session_scope() as session:
record = await get_record(session, call.id)
if record is None:
# The create-time insert failed (or predates the hook);
# write the whole row now instead.
record = CallRecord(id=call.id)
session.add(record)
# Each transcript chunk gets its own row with a sequence number
# so the dashboard can render them in order with click-to-seek.
for seq, text in enumerate(call.transcript_chunks):
speaker = "unknown"
payload = text
if ":" in text:
head, rest = text.split(":", 1)
head = head.strip().lower()
if head in {"caller", "agent", "receptionist", "caller_message"}:
speaker = head if head != "caller_message" else "caller"
payload = rest.strip()
session.add(TranscriptChunk(
id=f"tc_{uuid.uuid4().hex[:10]}",
call_id=call.id,
seq=seq,
t_offset_ms=0,
speaker=speaker,
text=payload,
))
record.direction = call.direction
record.remote_number = call.remote_number
record.status = final_status.value
record.mode = call.mode.value
record.intent = call.intent
record.started_at = call.started_at
record.ended_at = datetime.now()
record.duration = int(call.duration)
record.hold_time = int(call.hold_time)
record.device_used = call.device
record.call_flow_id = call.call_flow_id
record.classification_timeline = [
{
"timestamp": c.timestamp,
"audio_type": c.audio_type.value,
"confidence": c.confidence,
}
for c in call.classification_history
]
metadata = {"services": list(call.services)}
if call.exploration_steps:
metadata["exploration_steps"] = call.exploration_steps
record.metadata_ = metadata
await session.commit()
except Exception as e:
logger.warning(f"Could not persist call {call.id}: {e}")
# Each transcript entry gets its own row with a sequence number
# and real offset so the dashboard can render click-to-seek.
for seq, entry in enumerate(call.transcript_chunks):
session.add(TranscriptChunk(
id=f"tc_{uuid.uuid4().hex[:10]}",
call_id=call.id,
seq=seq,
t_offset_ms=entry.t_offset_ms,
speaker=entry.speaker,
text=entry.text,
))

View File

@@ -23,35 +23,12 @@ from models.call import ActiveCall, AudioClassification, CallStatus, Classificat
from models.call_flow import ActionType, CallFlow, CallFlowStep
from models.events import EventType, GatewayEvent
from services.audio_classifier import AudioClassifier
from services.llm_client import get_llm
from services.transcription import TranscriptionService
from services.tts import TTSService
logger = logging.getLogger(__name__)
# LLM client is optional — imported at use time
_llm_client = None
def _get_llm():
"""Lazy-load LLM client (optional dependency)."""
global _llm_client
if _llm_client is None:
try:
from config import get_settings
from services.llm_client import LLMClient
settings = get_settings()
_llm_client = LLMClient(
base_url=settings.llm.base_url,
model=settings.llm.model,
api_key=settings.llm.api_key.get_secret_value(),
timeout=settings.llm.timeout,
)
except Exception as e:
logger.debug(f"LLM client not available: {e}")
_llm_client = False # Sentinel: don't retry
return _llm_client if _llm_client is not False else None
class HoldSlayerService:
"""
@@ -79,6 +56,29 @@ class HoldSlayerService:
self.settings = settings
self.tts = tts
async def _service_error(self, call_id: str, service: str, error: Exception) -> None:
"""Surface a failed dependency as a typed event, not silence."""
logger.error(f"⚠️ {service} failed for {call_id}: {error}")
try:
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.ERROR,
call_id=call_id,
data={"service": service, "error": str(error)},
message=f"⚠️ {service} failed: {error}",
))
except Exception:
pass
async def _transcribe(
self, call_id: str, audio: bytes, prompt: Optional[str] = None
) -> str:
"""Transcribe with an explicit empty-string fallback on failure."""
try:
return await self.transcription.transcribe(audio, prompt=prompt)
except Exception as e:
await self._service_error(call_id, "transcription", e)
return ""
async def run(
self,
call: ActiveCall,
@@ -228,7 +228,7 @@ class HoldSlayerService:
# Phase 2: LLM fallback if regex couldn't decide
if not decision and transcript:
llm = _get_llm()
llm = get_llm()
if llm:
try:
logger.info("🤖 Regex inconclusive, asking LLM...")
@@ -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()
@@ -323,8 +323,7 @@ class HoldSlayerService:
continue
# Classify the audio
classification = self.classifier.classify_chunk(audio_chunk)
self.classifier.update_history(classification.audio_type)
classification = await self.classifier.classify(audio_chunk)
await self.call_manager.add_classification(call.id, classification)
# Transcribe if it sounds like speech
@@ -333,9 +332,10 @@ class HoldSlayerService:
AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN,
):
transcript = await self.transcription.transcribe(
transcript = await self._transcribe(
call.id,
audio_chunk,
prompt="Phone IVR menu, customer service, press 1 for..."
prompt="Phone IVR menu, customer service, press 1 for...",
)
if transcript:
await self.call_manager.add_transcript(call.id, transcript)
@@ -447,14 +447,13 @@ class HoldSlayerService:
continue
# Classify
result = self.classifier.classify_chunk(audio_chunk)
self.classifier.update_history(result.audio_type)
result = await self.classifier.classify(audio_chunk)
await self.call_manager.add_classification(call.id, result)
# Check for human
if result.audio_type == AudioClassification.LIVE_HUMAN:
# Verify with transcription
transcript = await self.transcription.transcribe(audio_chunk)
transcript = await self._transcribe(call.id, audio_chunk)
if transcript:
await self.call_manager.add_transcript(call.id, transcript)
# If we got meaningful speech, it's probably a real person
@@ -508,7 +507,7 @@ class HoldSlayerService:
continue
# Classify first
result = self.classifier.classify_chunk(audio_chunk)
result = await self.classifier.classify(audio_chunk)
if result.audio_type not in (
AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN,
@@ -516,7 +515,7 @@ class HoldSlayerService:
continue
# Transcribe
transcript = await self.transcription.transcribe(audio_chunk)
transcript = await self._transcribe(call.id, audio_chunk)
if not transcript:
continue
@@ -560,7 +559,7 @@ class HoldSlayerService:
if not audio_chunk:
break
result = self.classifier.classify_chunk(audio_chunk)
result = await self.classifier.classify(audio_chunk)
# If we're getting silence after speech, the menu prompt is done
if result.audio_type == AudioClassification.SILENCE and transcript_parts:
@@ -570,7 +569,7 @@ class HoldSlayerService:
AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN,
):
text = await self.transcription.transcribe(audio_chunk)
text = await self._transcribe(call.id, audio_chunk)
if text:
transcript_parts.append(text)
@@ -738,7 +737,11 @@ class HoldSlayerService:
os.close(fd)
try:
ok = await self.tts.synthesize_to_file(text, tmp_path)
try:
ok = await self.tts.synthesize_to_file(text, tmp_path)
except Exception as e:
await self._service_error(call.id, "tts", e)
return False
if not ok:
logger.warning(f"🗣️ TTS synthesis returned no audio for: '{text[:60]}'")
return False

View File

@@ -327,15 +327,14 @@ class LLMClient:
except httpx.HTTPStatusError as e:
self._total_errors += 1
logger.error(f"LLM API error: {e.response.status_code} {e.response.text[:200]}")
return ""
raise
except httpx.TimeoutException:
self._total_errors += 1
logger.error(f"LLM API timeout after {self.timeout}s")
return ""
except Exception as e:
raise
except Exception:
self._total_errors += 1
logger.error(f"LLM client error: {e}")
return ""
raise
@staticmethod
def _parse_json_response(text: str) -> dict[str, Any]:
@@ -389,3 +388,31 @@ class LLMClient:
"model": self.model,
"base_url": self.base_url,
}
# ================================================================
# Shared lazy client
# ================================================================
_shared_client: Optional["LLMClient"] = None
_shared_failed = False
def get_llm() -> Optional["LLMClient"]:
"""Lazily build the shared LLMClient from settings (None if unavailable)."""
global _shared_client, _shared_failed
if _shared_client is None and not _shared_failed:
try:
from config import get_settings
settings = get_settings()
_shared_client = LLMClient(
base_url=settings.llm.base_url,
model=settings.llm.model,
api_key=settings.llm.api_key.get_secret_value(),
timeout=settings.llm.timeout,
)
except Exception as e:
logger.debug(f"LLM client not available: {e}")
_shared_failed = True # don't retry
return _shared_client

View File

@@ -67,7 +67,6 @@ class NotificationService:
self._event_bus = event_bus
self._settings = settings
self._task: Optional[asyncio.Task] = None
self._sms_sender: Optional[Any] = None
# Track what we've already notified (avoid spam)
self._notified: dict[str, set[str]] = {} # call_id -> set of event types
@@ -214,43 +213,3 @@ class NotificationService:
# WebSocket notifications go through the event bus
# (the WebSocket handler in the API reads from EventBus directly)
# SMS for critical notifications
if (
notification.priority == NotificationPriority.CRITICAL
and self._settings.notify_sms_number
):
await self._send_sms(notification)
async def _send_sms(self, notification: Notification) -> None:
"""
Send an SMS notification.
Uses a simple HTTP-based SMS gateway. In production,
this would use Twilio, AWS SNS, or similar.
"""
phone = self._settings.notify_sms_number
if not phone:
return
try:
import httpx
# Generic webhook-based SMS (configure your provider)
# This is a placeholder — wire up your preferred SMS provider
logger.info(f"📱 SMS → {phone}: {notification.title}")
# Example: Twilio-style API
# async with httpx.AsyncClient() as client:
# await client.post(
# "https://api.twilio.com/2010-04-01/Accounts/.../Messages.json",
# data={
# "To": phone,
# "From": self._settings.sip_trunk.did,
# "Body": f"{notification.title}\n{notification.message}",
# },
# auth=(account_sid, auth_token),
# )
except Exception as e:
logger.error(f"SMS send failed: {e}")

View File

@@ -27,12 +27,100 @@ from models.routing import RoutingAction, RoutingActionType, RoutingDecision
logger = logging.getLogger(__name__)
class ReceptionistService:
"""Drives the receptionist state machine for a single inbound call."""
def _extract_number(sip_uri: str) -> str:
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212)."""
if not sip_uri:
return ""
s = sip_uri.strip()
if s.startswith("<") and ">" in s:
s = s[1 : s.index(">")]
if s.startswith("sip:"):
s = s[4:]
if "@" in s:
s = s.split("@", 1)[0]
return s
def __init__(self, gateway):
class ReceptionistService:
"""Owns inbound-call policy: routing evaluation, screening, voicemail."""
def __init__(
self,
gateway,
tts=None,
transcription=None,
recording=None,
routing=None,
):
self.gateway = gateway
self.settings = gateway.settings.receptionist
self.tts = tts
self.transcription = transcription
self.recording = recording
self.routing = routing
async def on_inbound_call(self, from_uri: str, to_uri: str, leg_id: str) -> None:
"""
Entry point for an inbound INVITE (wired as the SIP engine's
on_incoming_call by the composition root).
Evaluates routing rules, then either rejects (rule says
reject/DND) or answers and runs the screening flow.
"""
from models.call import CallMode
gateway = self.gateway
caller_number = _extract_number(from_uri)
dnis = _extract_number(to_uri)
# Create a call record so the dashboard sees the ringing call.
call = await gateway.call_manager.create_call(
remote_number=caller_number,
mode=CallMode.RECEPTIONIST,
intent=None,
call_flow_id=None,
device=None,
)
call.direction = "inbound"
gateway.call_manager.map_leg(leg_id, call.id)
await gateway.call_manager.update_status(call.id, CallStatus.RINGING)
decision = (
await self.routing.evaluate(caller_number, dnis)
if self.routing is not None
else None
)
if decision is not None:
await gateway.event_bus.publish(GatewayEvent(
type=EventType.ROUTING_RULE_MATCHED,
call_id=call.id,
data={
"matched_rule_id": decision.matched_rule_id,
"matched_rule_name": decision.matched_rule_name,
"action": decision.action.type.value,
"reason": decision.reason,
},
message=decision.reason,
))
if decision.action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
if hasattr(gateway.sip_engine, "reject_inbound"):
await gateway.sip_engine.reject_inbound(leg_id)
await gateway.call_manager.end_call(call.id, CallStatus.COMPLETED)
return
# Answer the leg
if hasattr(gateway.sip_engine, "accept_inbound"):
await gateway.sip_engine.accept_inbound(leg_id)
await gateway.call_manager.update_status(call.id, CallStatus.CONNECTED)
# Screen the caller (unless the receptionist is disabled)
if self.settings.enabled:
gateway.spawn(
self.handle(call, leg_id, decision),
name=f"receptionist_{call.id}",
)
async def handle(
self,
@@ -46,13 +134,9 @@ class ReceptionistService:
transcript = await self._listen(call, sip_leg_id)
if transcript:
call.transcript_chunks.append(f"caller: {transcript}")
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.TRANSCRIPT_CHUNK,
call_id=call.id,
data={"text": transcript, "speaker": "caller"},
message=f"📝 caller: {transcript[:80]}",
))
await self.gateway.call_manager.add_transcript(
call.id, transcript, speaker="caller"
)
classification = await self._classify(call, transcript, routing_decision)
call.intent = classification.get("intent")
@@ -89,7 +173,10 @@ class ReceptionistService:
await self._speak(
call, sip_leg_id, "One moment, I'll connect you now."
)
answered = await self.gateway._routing.ring_chain(
if self.routing is None:
await self._take_message(call, sip_leg_id)
return
answered = await self.routing.ring_chain(
call.id, devices, action.ring_timeout
)
if answered:
@@ -112,6 +199,19 @@ class ReceptionistService:
# State machine steps
# ----------------------------------------------------------------
async def _service_error(self, call_id: str, service: str, error: Exception) -> None:
"""Surface a failed dependency as a typed event, not silence."""
logger.error(f"⚠️ {service} failed for {call_id}: {error}")
try:
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.ERROR,
call_id=call_id,
data={"service": service, "error": str(error)},
message=f"⚠️ {service} failed: {error}",
))
except Exception:
pass
async def _greet(self, call: ActiveCall, sip_leg_id: str) -> None:
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_GREETING,
@@ -156,10 +256,14 @@ class ReceptionistService:
finally:
tap.close()
if not audio:
if not audio or self.transcription is None:
return ""
return await self.gateway._transcription.transcribe(bytes(audio))
try:
return await self.transcription.transcribe(bytes(audio))
except Exception as e:
await self._service_error(call.id, "transcription", e)
return ""
async def _classify(
self,
@@ -168,9 +272,9 @@ class ReceptionistService:
routing_decision: Optional[RoutingDecision],
) -> dict:
"""Ask the LLM to interpret the caller's utterance."""
from services.hold_slayer import _get_llm
from services.llm_client import get_llm
llm = _get_llm()
llm = get_llm()
if llm is None or not transcript.strip():
return {
"intent": transcript or "unknown",
@@ -200,7 +304,7 @@ class ReceptionistService:
system=self.settings.llm_persona,
)
except Exception as e:
logger.warning(f"Receptionist LLM classify failed: {e}")
await self._service_error(call.id, "llm", e)
return {
"intent": transcript,
"urgency": "normal",
@@ -213,10 +317,14 @@ class ReceptionistService:
routing_decision: Optional[RoutingDecision],
classification: dict,
) -> RoutingAction:
"""Rules win on conflict; otherwise use the LLM's recommendation."""
if routing_decision and routing_decision.action.type not in (
RoutingActionType.TAKE_MESSAGE,
):
"""Rules win on conflict; otherwise use the LLM's recommendation.
A decision counts as a rule only when one actually matched
(matched_rule_id set) — the no-rule default is take_message and
must stay overridable by the LLM. A matched TAKE_MESSAGE rule
wins like any other rule.
"""
if routing_decision and routing_decision.matched_rule_id:
return routing_decision.action
recommended = (classification.get("recommended_action") or "ring").lower()
@@ -245,7 +353,7 @@ class ReceptionistService:
await self._speak(call, sip_leg_id, self.settings.message_prompt)
media = self.gateway.media_pipeline
recording_svc = getattr(self.gateway, "_recording_service", None)
recording_svc = self.recording
if recording_svc is None or media is None:
logger.warning("Receptionist: recording unavailable, ending call")
await self._hangup(call, sip_leg_id)
@@ -256,43 +364,51 @@ class ReceptionistService:
call.id, media_pipeline=media, leg_ids=[sip_leg_id]
)
try:
await asyncio.sleep(self.settings.message_max_seconds)
# Record up to the cap, but stop early once the caller hangs
# up (leg termination ends the call via the leg-state wiring).
deadline = _time.monotonic() + self.settings.message_max_seconds
while _time.monotonic() < deadline:
await asyncio.sleep(1.0)
if self.gateway.call_manager.get_call(call.id) is None:
break
finally:
session = await recording_svc.stop_recording(
call.id, media_pipeline=media
)
message_text = ""
rec_path = session.filepath_mixed if session else None
if rec_path and Path(rec_path).exists():
try:
audio_bytes = Path(rec_path).read_bytes()
message_text = await self.gateway._transcription.transcribe(audio_bytes)
except Exception as e:
logger.warning(f"Receptionist transcribe failed: {e}")
message_text = ""
rec_path = session.filepath_mixed if session else None
if rec_path and Path(rec_path).exists() and self.transcription is not None:
try:
audio_bytes = Path(rec_path).read_bytes()
message_text = await self.transcription.transcribe(audio_bytes)
except Exception as e:
await self._service_error(call.id, "transcription", e)
if message_text:
call.transcript_chunks.append(f"caller_message: {message_text}")
if message_text:
await self.gateway.call_manager.add_transcript(
call.id, message_text, speaker="caller"
)
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
call_id=call.id,
data={
"path": rec_path,
"transcript": message_text,
"caller": call.remote_number,
},
message=f"📥 Message saved from {call.remote_number}",
))
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
call_id=call.id,
data={
"path": rec_path,
"transcript": message_text,
"caller": call.remote_number,
},
message=f"📥 Message saved from {call.remote_number}",
))
await self._hangup(call, sip_leg_id)
await self._hangup(call, sip_leg_id)
# ----------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> None:
tts = self.gateway._tts
tts = self.tts
media = self.gateway.media_pipeline
if tts is None or media is None or not text.strip():
return
@@ -303,7 +419,11 @@ class ReceptionistService:
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_")
os.close(fd)
try:
ok = await tts.synthesize_to_file(text, tmp_path)
try:
ok = await tts.synthesize_to_file(text, tmp_path)
except Exception as e:
await self._service_error(call.id, "tts", e)
return
if not ok:
return
await media.play_wav(sip_leg_id, tmp_path)

View File

@@ -39,6 +39,7 @@ class RecordingService:
self._max_recording_seconds = max_recording_seconds
self._sample_rate = sample_rate
self._active_recordings: dict[str, RecordingSession] = {}
self._timeout_tasks: dict[str, asyncio.Task] = {}
self._metadata: list[dict] = []
async def start(self) -> None:
@@ -90,6 +91,7 @@ class RecordingService:
filepath_agent=filepath_agent,
started_at=datetime.now(),
sample_rate=self._sample_rate,
leg_ids=leg_ids,
)
# Start PJSUA2 recording if media pipeline is available
@@ -101,8 +103,8 @@ class RecordingService:
self._active_recordings[call_id] = session
logger.info(f"🔴 Recording started: {call_id}{filepath_mixed}")
# Safety timeout
asyncio.create_task(
# Safety timeout — tracked so it can be cancelled and isn't GC'd
self._timeout_tasks[call_id] = asyncio.create_task(
self._recording_timeout(call_id),
name=f"rec_timeout_{call_id}",
)
@@ -115,6 +117,14 @@ class RecordingService:
media_pipeline=None,
) -> Optional["RecordingSession"]:
"""Stop recording a call and finalize the WAV file."""
timeout_task = self._timeout_tasks.pop(call_id, None)
if (
timeout_task is not None
and timeout_task is not asyncio.current_task()
and not timeout_task.done()
):
timeout_task.cancel()
session = self._active_recordings.pop(call_id, None)
if not session:
logger.warning(f" No active recording for {call_id}")
@@ -150,26 +160,38 @@ class RecordingService:
@staticmethod
async def _persist_recording(session: "RecordingSession") -> None:
"""Write a recordings row for this session. Failures are non-fatal."""
try:
import uuid as _uuid
from db.database import RecordingRecord, get_session_factory
"""Write a recordings row for this session, with bounded retry.
async with get_session_factory()() as db:
db.add(RecordingRecord(
id=f"rec_{_uuid.uuid4().hex[:10]}",
call_id=session.call_id,
path=session.filepath_mixed or "",
format="wav",
duration_s=float(session.duration_seconds or 0),
size_bytes=int(session.file_size_bytes or 0),
channels=1,
started_at=session.started_at,
ended_at=session.stopped_at,
))
await db.commit()
except Exception as e:
logger.warning(f"Recording persistence failed: {e}")
Non-fatal for the call, but a lost row means the dashboard can
never find the WAV — so failures log at ERROR, not warning.
"""
import uuid as _uuid
from db.database import RecordingRecord, session_scope
for attempt in range(3):
try:
async with session_scope() as db:
db.add(RecordingRecord(
id=f"rec_{_uuid.uuid4().hex[:10]}",
call_id=session.call_id,
path=session.filepath_mixed or "",
format="wav",
duration_s=float(session.duration_seconds or 0),
size_bytes=int(session.file_size_bytes or 0),
channels=1,
started_at=session.started_at,
ended_at=session.stopped_at,
))
return
except Exception as e:
if attempt == 2:
logger.error(
f"Recording row lost for {session.call_id} "
f"(path={session.filepath_mixed}): {e}"
)
return
await asyncio.sleep(2 ** attempt)
async def _recording_timeout(self, call_id: str) -> None:
"""Auto-stop recording after max duration."""
@@ -230,6 +252,7 @@ class RecordingSession:
filepath_agent: Optional[str] = None,
started_at: Optional[datetime] = None,
sample_rate: int = 16000,
leg_ids: Optional[list[str]] = None,
):
self.call_id = call_id
self.filepath_mixed = filepath_mixed
@@ -240,7 +263,7 @@ class RecordingSession:
self.duration_seconds: Optional[int] = None
self.file_size_bytes: Optional[int] = None
self.sample_rate = sample_rate
self._leg_ids: list[str] = []
self._leg_ids: list[str] = list(leg_ids or [])
def to_dict(self) -> dict:
return {

View File

@@ -27,6 +27,8 @@ class TranscriptionService:
def __init__(self, settings: SpeachesSettings):
self.settings = settings
self._client: Optional[httpx.AsyncClient] = None
# Last-known reachability, surfaced by /health (None = no requests yet)
self.available: Optional[bool] = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client."""
@@ -60,6 +62,9 @@ class TranscriptionService:
# Convert raw PCM to WAV format for the API
wav_data = self._pcm_to_wav(audio_data)
# Raises on failure — callers decide the per-call fallback and
# publish a service-error event; swallowing here made a down
# Speaches look like "the AI is deciding badly".
try:
response = await client.post(
"/v1/audio/transcriptions",
@@ -72,44 +77,13 @@ class TranscriptionService:
},
)
response.raise_for_status()
text = response.text.strip()
logger.debug(f"Transcription: '{text}'")
return text
except httpx.HTTPStatusError as e:
logger.error(f"Speaches API error: {e.response.status_code} {e.response.text}")
return ""
except httpx.ConnectError:
logger.error(f"Cannot connect to Speaches at {self.settings.url}")
return ""
except Exception as e:
logger.error(f"Transcription failed: {e}")
return ""
async def transcribe_stream(
self,
audio_data: bytes,
language: str = "en",
):
"""
Stream transcription — for real-time results.
Uses Speaches streaming endpoint if available,
falls back to chunked transcription.
Yields:
str: Partial transcription chunks
"""
# For now, do chunked transcription
# TODO: Implement WebSocket streaming when Speaches supports it
chunk_size = 16000 * 2 * 3 # 3 seconds of 16kHz 16-bit mono
for i in range(0, len(audio_data), chunk_size):
chunk = audio_data[i:i + chunk_size]
if len(chunk) > 0:
text = await self.transcribe(chunk, language)
if text:
yield text
except Exception:
self.available = False
raise
self.available = True
text = response.text.strip()
logger.debug(f"Transcription: '{text}'")
return text
async def close(self) -> None:
"""Close the HTTP client."""

View File

@@ -22,6 +22,8 @@ class TTSService:
def __init__(self, settings: TTSSettings):
self.settings = settings
self._client: Optional[httpx.AsyncClient] = None
# Last-known reachability, surfaced by /health (None = no requests yet)
self.available: Optional[bool] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
@@ -54,19 +56,17 @@ class TTSService:
"sample_rate": self.settings.sample_rate,
}
# Raises on failure — callers decide the per-call fallback and
# publish a service-error event; swallowing here made a down
# Rhema look like "the AI went quiet".
try:
response = await client.post("/v1/audio/speech", json=body)
response.raise_for_status()
return response.content
except httpx.HTTPStatusError as e:
logger.error(f"Rhema TTS error: {e.response.status_code} {e.response.text}")
return b""
except httpx.ConnectError:
logger.error(f"Cannot connect to Rhema at {self.settings.base_url}")
return b""
except Exception as e:
logger.error(f"TTS synthesis failed: {e}")
return b""
except Exception:
self.available = False
raise
self.available = True
return response.content
async def synthesize_to_file(
self,

213
tests/lab/README.md Normal file
View 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
> 697941 Hz, columns 12091633 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
View 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

View 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

View 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()

View File

@@ -0,0 +1,30 @@
; 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]
; `notice` is KEPT, deliberately. Asterisk logs rejected SIP requests at
; notice level via log_failed_request ("No matching endpoint found",
; "Failed to authenticate"), and on a box whose whole job is answering SIP
; those are the single most useful diagnostic. Dropping notice made the log
; quieter and the gateway undebuggable — a call was refused and nothing said
; so.
;
; `verbose` is excluded: dialplan execution ("Executing [1001@...]") is
; useful when tracing a specific call, but it is not worth shipping to Loki
; continuously. Raise it at runtime instead:
; asterisk -rx "core set verbose 3"
;
; The healthcheck's "Remote UNIX connection" pairs also arrive on notice.
; They are dealt with at the source rather than by silencing the channel:
; docker-compose replaces the image's ~7-connections-per-30s healthcheck with
; a single check on a 60s interval, and modules.conf stops the container
; loading hardware and database modules it has no use for (~74 ALSA lines per
; restart). Those two together cut the noise without costing visibility.
console => notice,warning,error

View File

@@ -0,0 +1,46 @@
; Module loading for the lab.
;
; The image ships `autoload=yes`, which loads every module Asterisk was built
; with. On a headless container that produces a lot of startup noise for
; hardware and backends that do not exist here — measured at ~74 ALSA lines
; plus a dozen module-load ERRORs per restart, all of it in Loki. None of it
; was harmful; all of it made the log harder to read.
;
; autoload stays on: this is a lab, and an explicit allow-list would break
; quietly every time a scenario needs a module nobody remembered to add. The
; noload lines below are only for modules that *cannot* work in this container.
[modules]
autoload=yes
; --- Audio hardware -------------------------------------------------------
; No sound card in a container. chan_alsa/chan_oss probe for one and emit
; ~74 lines of ALSA config errors on every start. The media path is RTP via
; PJSIP, never a local device.
noload => chan_alsa.so
noload => chan_oss.so
noload => chan_console.so
; --- CDR/CEL backends for databases we do not run -------------------------
; Each logs "declined to load" or a config error at startup. Call records live
; in Hold Slayer's own Postgres, written by the gateway, not by Asterisk.
noload => cdr_pgsql.so
noload => cdr_sqlite3_custom.so
noload => cdr_custom.so
noload => cdr_csv.so
noload => cel_pgsql.so
noload => cel_sqlite3_custom.so
noload => cel_custom.so
; --- Realtime config backends we do not use -------------------------------
; The lab's configuration is the mounted .conf files. LDAP/ODBC/PgSQL realtime
; each complain about missing connection details on every start.
noload => res_config_ldap.so
noload => res_config_odbc.so
noload => res_config_pgsql.so
noload => res_config_sqlite3.so
; --- Codecs and formats that fail to initialise ----------------------------
; format_ogg_vorbis errors on load. The lab plays .sln (signed linear) and
; negotiates ulaw/alaw, so nothing here needs Vorbis.
noload => format_ogg_vorbis.so
noload => format_ogg_speex.so

View 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

View 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 }}

View File

@@ -0,0 +1,45 @@
# 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
# noload for hardware/DB modules this container cannot use — the image
# autoloads everything, which costs ~74 ALSA lines per restart.
- ./dialplan/modules.conf:/etc/asterisk/modules.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
View 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

View 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()

View File

@@ -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
View 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

168
tests/test_concurrency.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Thread-ownership and task-hygiene tests.
Covers the Sippy→loop event funnel (events posted from a foreign
thread mutate loop-owned state), the AudioTap thread-safe feed, the
off-loop classifier entry point, leg-state propagation into the
CallManager, and background-task cancellation on gateway stop.
"""
import asyncio
import threading
import numpy as np
from config import ClassifierSettings, Settings
from core.gateway import AIPSTNGateway
from core.media_pipeline import AudioTap
from core.sippy_engine import SippyEngine
from models.call import CallStatus
from services.audio_classifier import AudioClassifier
def _engine_on_loop() -> SippyEngine:
"""Engine wired to the running loop without starting the SIP stack."""
engine = SippyEngine()
engine._loop = asyncio.get_running_loop()
return engine
def _post_from_thread(engine: SippyEngine, kind: str, data: dict) -> None:
"""Post a funnel event from a foreign thread, like the Sippy ED thread."""
t = threading.Thread(target=engine._post_from_ed, args=(kind, data))
t.start()
t.join()
class TestEngineEventFunnel:
async def test_register_and_deregister_from_foreign_thread(self):
engine = _engine_on_loop()
_post_from_thread(engine, "register", {
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600,
})
await asyncio.sleep(0.05)
assert engine._registered_devices == [
{"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600}
]
# Re-register updates in place instead of duplicating
_post_from_thread(engine, "register", {
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.9", "expires": 60,
})
await asyncio.sleep(0.05)
assert len(engine._registered_devices) == 1
assert engine._registered_devices[0]["contact"] == "sip:alice@10.0.0.9"
_post_from_thread(engine, "deregister", {"aor": "sip:alice@gw"})
await asyncio.sleep(0.05)
assert engine._registered_devices == []
async def test_incoming_invite_auto_answers_without_callback(self):
engine = _engine_on_loop()
_post_from_thread(engine, "incoming_invite", {
"leg_id": "leg_test1",
"from_uri": "sip:caller@pstn",
"to_uri": "sip:+15551234567@gw",
"sdp": None,
})
await asyncio.sleep(0.05)
leg = engine._legs["leg_test1"]
assert leg.direction == "inbound"
assert leg.state == "connected"
async def test_incoming_call_callback_runs_as_tracked_task(self):
engine = _engine_on_loop()
seen = asyncio.Event()
async def on_incoming(from_uri, to_uri, leg_id):
seen.set()
engine._on_incoming_call = on_incoming
_post_from_thread(engine, "incoming_invite", {
"leg_id": "leg_test2", "from_uri": "a", "to_uri": "b", "sdp": None,
})
await asyncio.wait_for(seen.wait(), timeout=1.0)
assert engine._legs["leg_test2"].state == "init" # not auto-answered
async def test_bye_terminates_leg_and_notifies(self):
engine = _engine_on_loop()
states: list[tuple[str, str]] = []
engine._on_leg_state_change = lambda leg_id, state: states.append((leg_id, state))
_post_from_thread(engine, "incoming_invite", {
"leg_id": "leg_test3", "from_uri": "a", "to_uri": "b", "sdp": None,
})
await asyncio.sleep(0.05)
_post_from_thread(engine, "leg_state", {"leg_id": "leg_test3", "state": "terminated"})
await asyncio.sleep(0.05)
assert engine._legs["leg_test3"].state == "terminated"
assert ("leg_test3", "terminated") in states
async def test_dtmf_and_trunk_events(self):
engine = _engine_on_loop()
_post_from_thread(engine, "dtmf", {"leg_id": "leg_test4", "digit": "5"})
_post_from_thread(engine, "trunk_registered", {"registered": True})
await asyncio.sleep(0.05)
assert engine._trunk_registered is True
class TestAudioTapThreadSafety:
async def test_feed_from_foreign_thread_reaches_reader(self):
tap = AudioTap("leg_x")
frame = b"\x01\x02" * 320
t = threading.Thread(target=tap.feed, args=(frame,))
t.start()
t.join()
received = await tap.read_frame(timeout=1.0)
assert received == frame
class TestClassifierOffLoop:
async def test_classify_runs_and_records_history(self):
classifier = AudioClassifier(ClassifierSettings())
silence = np.zeros(16000, dtype=np.int16).tobytes()
result = await classifier.classify(silence)
assert result.audio_type.value == "silence"
assert classifier._classification_history == [result.audio_type]
class TestLegStatePropagation:
async def test_leg_lifecycle_drives_call_status(self):
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
call = await gateway.make_call("+15551234567")
assert call.status == CallStatus.RINGING
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
await gateway._on_sip_leg_state(leg_id, "connected")
assert gateway.get_call(call.id).status == CallStatus.CONNECTED
await gateway._on_sip_leg_state(leg_id, "terminated")
assert gateway.get_call(call.id) is None
assert gateway.call_manager.legs_for_call(call.id) == []
async def test_late_signals_do_not_stomp_service_states(self):
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
call = await gateway.make_call("+15551234567")
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
await gateway.call_manager.update_status(call.id, CallStatus.ON_HOLD)
await gateway._on_sip_leg_state(leg_id, "connected")
assert gateway.get_call(call.id).status == CallStatus.ON_HOLD
class TestTaskHygiene:
async def test_gateway_stop_cancels_spawned_tasks(self):
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
task = gateway.spawn(asyncio.sleep(60), name="test_sleeper")
await gateway.stop()
assert task.cancelled()
assert gateway._tasks == set()

187
tests/test_data_layer.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Data-layer tests.
Alembic migrations produce the schema the ORM models declare (and
adopt a pre-Alembic database); a call gets a durable in_progress row
the moment it starts; transcript entries carry real offsets; the
consolidated response models map from the domain in one place.
"""
import asyncio
import pytest
from sqlalchemy import Boolean, inspect, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import db.database as dbmod
from core.call_manager import CallManager
from core.event_bus import EventBus
from db.database import Base
from models.call import ActiveCall, CallStatus, CallStatusResponse
from models.device import Device, DeviceType
from services import call_persistence as store
# ================================================================
# Alembic migrations
# ================================================================
def _run_alembic(connection, revision: str) -> None:
from alembic import command
from alembic.config import Config
cfg = Config("alembic.ini")
cfg.attributes["connection"] = connection
command.upgrade(cfg, revision)
def _schema_info(sync_conn) -> dict:
inspector = inspect(sync_conn)
return {
"tables": set(inspector.get_table_names()) - {"alembic_version"},
"call_record_cols": {c["name"] for c in inspector.get_columns("call_records")},
"is_online_type": next(
c["type"] for c in inspector.get_columns("devices")
if c["name"] == "is_online"
),
}
class TestMigrations:
async def test_upgrade_head_matches_models(self, tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/mig.db")
async with engine.begin() as conn:
await conn.run_sync(dbmod._upgrade_to_head)
async with engine.connect() as conn:
info = await conn.run_sync(_schema_info)
await engine.dispose()
assert info["tables"] == set(Base.metadata.tables)
assert "transcript" not in info["call_record_cols"]
assert isinstance(info["is_online_type"], Boolean)
async def test_adopts_pre_alembic_schema(self, tmp_path):
"""A create_all-era database (baseline schema, no alembic_version)
is stamped and migrated forward instead of failing."""
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/legacy.db")
async with engine.begin() as conn:
await conn.run_sync(
lambda c: _run_alembic(c, dbmod._BASELINE_REVISION)
)
await conn.execute(text("DROP TABLE alembic_version"))
async with engine.begin() as conn:
await conn.run_sync(dbmod._upgrade_to_head)
async with engine.connect() as conn:
info = await conn.run_sync(_schema_info)
await engine.dispose()
assert "transcript" not in info["call_record_cols"]
assert isinstance(info["is_online_type"], Boolean)
# ================================================================
# Durable call rows
# ================================================================
@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()
class TestDurableCallRows:
async def test_in_progress_row_from_the_start(self, mem_db):
cm = CallManager(
EventBus(),
on_call_created=store.persist_call_on_create,
on_call_ended=store.persist_call_on_end,
)
call = await cm.create_call("+15551230000", intent="dispute a charge")
async with mem_db() as session:
row = await store.get_record(session, call.id)
assert row is not None
assert row.status == "in_progress"
assert row.ended_at is None
assert row.intent == "dispute a charge"
await cm.add_transcript(call.id, "hello, billing please", speaker="caller")
await cm.end_call(call.id, CallStatus.COMPLETED)
async with mem_db() as session:
row = await store.get_record(session, call.id)
chunks = await store.get_transcript_chunks(session, call.id)
assert row.status == "completed"
assert row.ended_at is not None
assert [(c.seq, c.speaker, c.text) for c in chunks] == [
(0, "caller", "hello, billing please")
]
async def test_end_without_create_still_writes_row(self, mem_db):
"""If the create-time insert never happened, finalize inserts."""
cm = CallManager(EventBus(), on_call_ended=store.persist_call_on_end)
call = await cm.create_call("+15551230001")
await cm.end_call(call.id, CallStatus.FAILED)
async with mem_db() as session:
row = await store.get_record(session, call.id)
assert row is not None
assert row.status == "failed"
# ================================================================
# Transcript offsets
# ================================================================
class TestTranscriptOffsets:
async def test_entries_carry_offset_and_speaker(self):
cm = CallManager(EventBus())
call = await cm.create_call("+15551230002")
await cm.add_transcript(call.id, "one")
await asyncio.sleep(0.02)
await cm.add_transcript(call.id, "two", speaker="agent")
first, second = call.transcript_chunks
assert first.t_offset_ms >= 0
assert second.t_offset_ms > first.t_offset_ms
assert first.speaker == "unknown"
assert second.speaker == "agent"
assert call.transcript == "one\ntwo"
# ================================================================
# Consolidated response models
# ================================================================
class TestResponseModels:
def test_status_response_from_call(self):
call = ActiveCall(id="call_x", remote_number="+15550000000", intent="pay bill")
resp = CallStatusResponse.from_call(call)
assert resp.call_id == "call_x"
assert resp.status == "initiating"
assert resp.remote_number == "+15550000000"
assert resp.intent == "pay bill"
def test_device_serializes_routability(self):
device = Device(
id="dev_1",
name="Desk Phone",
type=DeviceType.SIP_PHONE,
sip_uri="sip:desk@gw",
is_online=True,
)
assert device.model_dump()["can_receive_call"] is True
device.dnd = True
assert device.model_dump()["can_receive_call"] is False

View File

@@ -0,0 +1,89 @@
"""
Engine-mode and event-bus-integrity tests.
The mock engine must be requested explicitly; a full subscriber queue
drops its oldest event but never loses the subscription; history is
replayable to late joiners.
"""
import asyncio
import pytest
from config import Settings
from core.event_bus import EventBus
from core.gateway import build_sip_engine
from core.media_pipeline import MediaPipeline
from core.sip_engine import MockSIPEngine
from models.events import EventType, GatewayEvent
def _noop(*args, **kwargs):
pass
class TestEngineMode:
def _build(self, settings: Settings):
return build_sip_engine(
settings,
MediaPipeline(sample_rate=16000),
on_leg_state_change=_noop,
on_device_registered=_noop,
on_incoming_call=_noop,
)
def test_mock_engine_only_when_asked(self):
settings = Settings(use_mock_sip=True)
assert isinstance(self._build(settings), MockSIPEngine)
def test_unconfigured_trunk_refuses_to_build(self):
settings = Settings(use_mock_sip=False)
settings.sip_trunk.host = "sip.yourprovider.com"
with pytest.raises(RuntimeError, match="not configured"):
self._build(settings)
def _event(i: int) -> GatewayEvent:
return GatewayEvent(
type=EventType.CALL_INITIATED,
call_id=f"call_{i}",
data={},
message=f"event {i}",
)
class TestEventBusIntegrity:
async def test_overflow_drops_oldest_keeps_subscription(self):
bus = EventBus()
sub = bus.subscribe(max_size=3)
for i in range(5):
await bus.publish(_event(i))
assert bus.subscriber_count == 1 # never evicted
assert sub.dropped == 2
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
assert [e.call_id for e in received] == ["call_2", "call_3", "call_4"]
async def test_replay_last_seeds_history(self):
bus = EventBus()
for i in range(10):
await bus.publish(_event(i))
sub = bus.subscribe(replay_last=3)
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
assert [e.call_id for e in received] == ["call_7", "call_8", "call_9"]
async def test_replay_respects_type_filter(self):
bus = EventBus()
await bus.publish(_event(1))
await bus.publish(GatewayEvent(
type=EventType.HUMAN_DETECTED, call_id="call_h", data={}, message="x"
))
sub = bus.subscribe(
event_types={EventType.HUMAN_DETECTED}, replay_last=5
)
event = await asyncio.wait_for(sub.get(), 1.0)
assert event.call_id == "call_h"
assert sub._queue.empty()

View File

@@ -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
View 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
View 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

View File

@@ -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)

View 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

View File

@@ -24,10 +24,27 @@ class TestReceptionistDecide:
gw = _make_gateway()
svc = ReceptionistService(gw)
rule_action = RoutingAction(type=RoutingActionType.REJECT, message="nope")
decision = RoutingDecision(action=rule_action, reason="rule said so")
decision = RoutingDecision(
action=rule_action,
matched_rule_id="rule_1",
matched_rule_name="block",
reason="rule said so",
)
chosen = svc._decide(decision, {"recommended_action": "ring"})
assert chosen.type == RoutingActionType.REJECT
def test_matched_take_message_rule_beats_llm(self):
gw = _make_gateway()
svc = ReceptionistService(gw)
decision = RoutingDecision(
action=RoutingAction(type=RoutingActionType.TAKE_MESSAGE),
matched_rule_id="rule_2",
matched_rule_name="voicemail-hours",
reason="matched rule 'voicemail-hours'",
)
chosen = svc._decide(decision, {"recommended_action": "ring"})
assert chosen.type == RoutingActionType.TAKE_MESSAGE
def test_falls_back_to_llm_when_rule_is_default_take_message(self):
gw = _make_gateway()
svc = ReceptionistService(gw)

View File

@@ -123,14 +123,14 @@ class TestLLMClient:
assert result["key"] == "value"
@pytest.mark.asyncio
async def test_chat_http_error_returns_empty(self):
"""Verify HTTP errors return empty string gracefully."""
async def test_chat_error_raises(self):
"""Failures propagate to the caller (which owns the fallback)."""
client = self._make_client()
with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.side_effect = Exception("Connection refused")
result = await client.chat("test", system="test")
assert result == ""
with pytest.raises(Exception, match="Connection refused"):
await client.chat("test", system="test")
assert client._total_errors == 1
@pytest.mark.asyncio

228
tests/test_structure.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Composition and API-surface tests.
Covers the gateway composed the way main.py's lifespan composes it
(mode handlers, on_call_ended hook, receptionist-owned inbound
policy) and the REST routes running against a real (SQLite) database
through the shared data layer in services/call_persistence.py.
"""
import httpx
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import main
from config import ReceptionistSettings, Settings, get_settings
from core.gateway import AIPSTNGateway
from db.database import Base, CallRecord, get_db
from models.call import CallMode, CallStatus
from models.routing import RoutingAction, RoutingActionType, RoutingDecision
from services.receptionist import ReceptionistService
# ================================================================
# Gateway composition
# ================================================================
class TestGatewayComposition:
async def test_mode_handler_launches_per_call(self):
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
launched: list[tuple] = []
gateway.register_mode_handler(
CallMode.HOLD_SLAYER,
lambda call, leg_id, flow_id: launched.append((call.id, leg_id, flow_id)),
)
call = await gateway.make_call("+15551234567", mode=CallMode.HOLD_SLAYER,
call_flow_id="acme-main")
assert launched == [(call.id, gateway.call_manager.legs_for_call(call.id)[0], "acme-main")]
async def test_direct_mode_needs_no_handler(self):
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
call = await gateway.make_call("+15551234567")
assert call.status == CallStatus.RINGING
async def test_on_call_ended_hook_from_constructor(self):
ended: list[tuple] = []
async def hook(call, status):
ended.append((call.id, status))
gateway = AIPSTNGateway(
settings=Settings(max_concurrent_calls=4), on_call_ended=hook
)
call = await gateway.make_call("+15551234567")
await gateway.hangup_call(call.id)
assert ended == [(call.id, CallStatus.COMPLETED)]
# ================================================================
# Receptionist-owned inbound policy
# ================================================================
class _StubRouting:
def __init__(self, decision):
self._decision = decision
async def evaluate(self, caller_number, dnis):
return self._decision
class TestInboundPolicy:
def _gateway(self) -> AIPSTNGateway:
settings = Settings(max_concurrent_calls=4)
settings.receptionist = ReceptionistSettings(enabled=False)
return AIPSTNGateway(settings=settings)
async def test_inbound_call_answered_and_tracked(self):
gateway = self._gateway()
receptionist = ReceptionistService(gateway)
await receptionist.on_inbound_call(
"sip:+16135550100@pstn", "sip:+15551234567@gw", "leg_in1"
)
calls = list(gateway.call_manager.active_calls.values())
assert len(calls) == 1
call = calls[0]
assert call.direction == "inbound"
assert call.remote_number == "+16135550100"
assert call.status == CallStatus.CONNECTED
assert gateway.call_manager.legs_for_call(call.id) == ["leg_in1"]
async def test_reject_rule_declines_before_answer(self):
gateway = self._gateway()
decision = RoutingDecision(
action=RoutingAction(type=RoutingActionType.REJECT),
matched_rule_id="rule_x",
matched_rule_name="block",
reason="matched rule 'block'",
)
receptionist = ReceptionistService(gateway, routing=_StubRouting(decision))
await receptionist.on_inbound_call(
"sip:+18005550100@pstn", "sip:+15551234567@gw", "leg_in2"
)
assert gateway.call_manager.active_calls == {}
# ================================================================
# REST routes on the shared data layer (real SQLite)
# ================================================================
@pytest.fixture
async def client(monkeypatch):
# 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:",
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)
async def _get_db():
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
main.app.dependency_overrides[get_db] = _get_db
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
c.db_factory = factory
yield c
main.app.dependency_overrides.pop(get_db, None)
await engine.dispose()
FLOW_PAYLOAD = {
"name": "Acme Main Line",
"phone_number": "+18005551234",
"description": "Main IVR",
"steps": [
{
"id": "step1",
"description": "Press 2 for billing",
"action": "dtmf",
"action_value": "2",
}
],
"tags": ["test"],
}
class TestCallFlowRoutes:
async def test_crud_round_trip(self, client):
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/v1/call-flows/", json=FLOW_PAYLOAD)
assert resp.status_code == 409
resp = await client.get("/api/v1/call-flows/")
assert [f["id"] for f in resp.json()] == [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/v1/call-flows/by-number/+18005551234")
assert resp.json()["id"] == flow_id
resp = await client.put(
f"/api/v1/call-flows/{flow_id}", json={"notes": "updated"}
)
assert resp.json()["notes"] == "updated"
resp = await client.delete(f"/api/v1/call-flows/{flow_id}")
assert resp.json()["status"] == "deleted"
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/v1/calls/history")
assert resp.status_code == 200
assert resp.json() == []
async with client.db_factory() as session:
session.add(CallRecord(
id="call_hist1",
direction="outbound",
remote_number="+18005551234",
status="completed",
mode="hold_slayer",
intent="dispute charge",
duration=120,
hold_time=90,
))
await session.commit()
resp = await client.get("/api/v1/calls/history")
assert [r["id"] for r in resp.json()] == ["call_hist1"]
resp = await client.get("/api/v1/calls/history?number=%2B18005551234")
assert len(resp.json()) == 1
resp = await client.get("/api/v1/calls/call_hist1/record")
assert resp.json()["intent"] == "dispute charge"
resp = await client.get("/api/v1/calls/call_missing/record")
assert resp.status_code == 404
resp = await client.get("/api/v1/calls/call_hist1/transcript")
assert resp.json() == []

99
tests/test_websocket.py Normal file
View 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"