diff --git a/README.md b/README.md index 8d7895f..badaa99 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ You give it a phone number and an intent ("dispute a charge on my December state │ │ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │ │ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │ -│ │ /api/* │ │ /ws/* │ │ (HTTP) │ │ /dashboard │ │ +│ │ /api/v1/*│ │ /ws/* │ │ (HTTP) │ │ / │ │ │ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │ │ │ │ │ │ │ ┌────┴──────────────┴──────────────┴────┐ │ @@ -79,7 +79,7 @@ You give it a phone number and an intent ("dispute a charge on my December state - **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** — 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 `/dashboard` with live monitor, call history with transcript playback, and a routing-rules editor +- **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 @@ -189,7 +189,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 @@ -211,7 +211,7 @@ pytest tests/ -v **Launch Hold Slayer on a number:** ```bash -curl -X POST http://localhost:8000/api/calls/hold-slayer \ +curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ @@ -225,21 +225,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 +256,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}' ``` @@ -355,7 +355,7 @@ All configuration is via environment variables (see `.env.example`): - **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 diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 0cb2e41..342ca0a 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -24,15 +24,15 @@ export async function fetchHealth(): Promise { } export async function fetchActiveCalls(): Promise { - return get('/api/calls/active'); + return get('/api/v1/calls/active'); } export async function fetchDevices(): Promise { - return get('/api/devices'); + return get('/api/v1/devices'); } export async function hangupCall(callId: string): Promise { - const res = await fetch(`/api/calls/${callId}/hangup`, { method: 'POST' }); + const res = await fetch(`/api/v1/calls/${callId}/hangup`, { method: 'POST' }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); } @@ -41,27 +41,27 @@ export async function fetchCallHistory( offset = 0, ): Promise { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - return get(`/api/calls/history?${params}`); + return get(`/api/v1/calls/history?${params}`); } export async function fetchCallRecord(callId: string): Promise { - return get(`/api/calls/${callId}/record`); + return get(`/api/v1/calls/${callId}/record`); } export async function fetchTranscript(callId: string): Promise { - return get(`/api/calls/${callId}/transcript`); + return get(`/api/v1/calls/${callId}/transcript`); } export function recordingUrl(callId: string): string { - return `/api/calls/${callId}/recording`; + return `/api/v1/calls/${callId}/recording`; } export async function fetchRoutingRules(): Promise { - return get('/api/routing/rules'); + return get('/api/v1/routing/rules'); } export async function createRoutingRule(rule: Partial): Promise { - const res = await fetch('/api/routing/rules', { + const res = await fetch('/api/v1/routing/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rule), @@ -74,7 +74,7 @@ export async function updateRoutingRule( ruleId: string, patch: Partial, ): Promise { - const res = await fetch(`/api/routing/rules/${ruleId}`, { + const res = await fetch(`/api/v1/routing/rules/${ruleId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), @@ -84,12 +84,12 @@ export async function updateRoutingRule( } export async function deleteRoutingRule(ruleId: string): Promise { - const res = await fetch(`/api/routing/rules/${ruleId}`, { method: 'DELETE' }); + const res = await fetch(`/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 { - const res = await fetch(`/api/routing/devices/${deviceId}/dnd`, { + const res = await fetch(`/api/v1/routing/devices/${deviceId}/dnd`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), diff --git a/docs/api-reference.md b/docs/api-reference.md index 806aa70..672ce84 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index ce6a144..f8ef511 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac │ │ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │ │ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │ -│ │ /api/* │ │ /ws/* │ │ (HTTP) │ │ /dashboard │ │ +│ │ /api/v1/*│ │ /ws/* │ │ (HTTP) │ │ / │ │ │ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └──────────────┘ │ │ │ │ │ │ │ ┌────┴──────────────┴──────────────┴────┐ │ @@ -81,7 +81,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac ``` 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 diff --git a/docs/call-flows.md b/docs/call-flows.md index b5a7391..25d4bdb 100644 --- a/docs/call-flows.md +++ b/docs/call-flows.md @@ -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 { diff --git a/docs/dial-plan.md b/docs/dial-plan.md index 54a4ca4..0f07655 100644 --- a/docs/dial-plan.md +++ b/docs/dial-plan.md @@ -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 | |-----------|---------------------------------|--------------------------------| diff --git a/main.py b/main.py index d9a95fd..efac496 100644 --- a/main.py +++ b/main.py @@ -248,62 +248,30 @@ app = FastAPI( "🗡️ 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 +# 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) +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 token 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) -# === 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", - ) - - -# === Root Endpoint === -@app.get("/", tags=["System"]) -async def root(): - """Gateway root — health check and quick status.""" - gateway = getattr(app.state, "gateway", None) - if gateway: - status = await gateway.status() - return { - "name": "Hold Slayer Gateway", - "version": "0.1.0", - "status": "running", - "uptime": status["uptime"], - "active_calls": status["active_calls"], - "trunk": status["trunk"], - } - return { - "name": "Hold Slayer Gateway", - "version": "0.1.0", - "status": "starting", - } - @app.get("/health", tags=["System"]) async def health(): @@ -370,6 +338,20 @@ def _availability(service) -> str: return "ok" if available else "unreachable" +# === Dashboard (built SvelteKit static, served at the root) === +# Registered last: a "/" mount matches every path, so the API, WS, +# health, and MCP routes above must come first. +import os as _os # noqa: E402 + +_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build") +if _os.path.isdir(_dashboard_build): + app.mount( + "/", + StaticFiles(directory=_dashboard_build, html=True), + name="dashboard", + ) + + if __name__ == "__main__": import uvicorn diff --git a/tests/test_api_security.py b/tests/test_api_security.py index 0c3b42d..07983a7 100644 --- a/tests/test_api_security.py +++ b/tests/test_api_security.py @@ -30,31 +30,31 @@ async def client(): class TestBearerToken: async def test_missing_token_rejected(self, token_enabled, client): - resp = await client.get("/api/calls/active") + 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"} + "/api/v1/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}"} + "/api/v1/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") + resp = await client.get("/api/v1/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"): + for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/", + "/api/v1/routing/rules", "/api/v1/calls/history"): resp = await client.get(path) assert resp.status_code == 401, path @@ -76,12 +76,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" diff --git a/tests/test_structure.py b/tests/test_structure.py index beb9d5b..84c9ace 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -163,38 +163,38 @@ FLOW_PAYLOAD = { class TestCallFlowRoutes: async def test_crud_round_trip(self, client): - resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD) + resp = await client.post("/api/v1/call-flows/", json=FLOW_PAYLOAD) assert resp.status_code == 200, resp.text flow_id = resp.json()["id"] assert flow_id == "acme-main-line" - resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD) + resp = await client.post("/api/v1/call-flows/", json=FLOW_PAYLOAD) assert resp.status_code == 409 - resp = await client.get("/api/call-flows/") + resp = await client.get("/api/v1/call-flows/") assert [f["id"] for f in resp.json()] == [flow_id] - resp = await client.get(f"/api/call-flows/{flow_id}") + resp = await client.get(f"/api/v1/call-flows/{flow_id}") assert resp.json()["steps"][0]["action_value"] == "2" - resp = await client.get("/api/call-flows/by-number/+18005551234") + resp = await client.get("/api/v1/call-flows/by-number/+18005551234") assert resp.json()["id"] == flow_id resp = await client.put( - f"/api/call-flows/{flow_id}", json={"notes": "updated"} + f"/api/v1/call-flows/{flow_id}", json={"notes": "updated"} ) assert resp.json()["notes"] == "updated" - resp = await client.delete(f"/api/call-flows/{flow_id}") + resp = await client.delete(f"/api/v1/call-flows/{flow_id}") assert resp.json()["status"] == "deleted" - resp = await client.get(f"/api/call-flows/{flow_id}") + resp = await client.get(f"/api/v1/call-flows/{flow_id}") assert resp.status_code == 404 class TestCallHistoryRoutes: async def test_history_and_record(self, client): - resp = await client.get("/api/calls/history") + resp = await client.get("/api/v1/calls/history") assert resp.status_code == 200 assert resp.json() == [] @@ -211,17 +211,17 @@ class TestCallHistoryRoutes: )) await session.commit() - resp = await client.get("/api/calls/history") + resp = await client.get("/api/v1/calls/history") assert [r["id"] for r in resp.json()] == ["call_hist1"] - resp = await client.get("/api/calls/history?number=%2B18005551234") + resp = await client.get("/api/v1/calls/history?number=%2B18005551234") assert len(resp.json()) == 1 - resp = await client.get("/api/calls/call_hist1/record") + resp = await client.get("/api/v1/calls/call_hist1/record") assert resp.json()["intent"] == "dispute charge" - resp = await client.get("/api/calls/call_missing/record") + resp = await client.get("/api/v1/calls/call_missing/record") assert resp.status_code == 404 - resp = await client.get("/api/calls/call_hist1/transcript") + resp = await client.get("/api/v1/calls/call_hist1/transcript") assert resp.json() == []