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>
This commit is contained in:
22
README.md
22
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 │ │
|
│ │ 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
|
- **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
|
- **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/`
|
- **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
|
### Data Models
|
||||||
- **Call** — Active call state with classification history, transcript chunks, hold time tracking
|
- **Call** — Active call state with classification history, transcript chunks, hold time tracking
|
||||||
@@ -189,7 +189,7 @@ npm run build
|
|||||||
cd ..
|
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.
|
`dashboard/build/` exists. Skip this step if you only need the REST/WS API.
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
@@ -211,7 +211,7 @@ pytest tests/ -v
|
|||||||
**Launch Hold Slayer on a number:**
|
**Launch Hold Slayer on a number:**
|
||||||
|
|
||||||
```bash
|
```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 "Authorization: Bearer $API_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
@@ -225,21 +225,21 @@ curl -X POST http://localhost:8000/api/calls/hold-slayer \
|
|||||||
**Check call status:**
|
**Check call status:**
|
||||||
|
|
||||||
```bash
|
```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):**
|
**Browse call history (persisted in the database):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/api/calls/history?limit=50
|
curl http://localhost:8000/api/v1/calls/history?limit=50
|
||||||
curl http://localhost:8000/api/calls/call_abc123/transcript
|
curl http://localhost:8000/api/v1/calls/call_abc123/transcript
|
||||||
curl -O http://localhost:8000/api/calls/call_abc123/recording # WAV
|
curl -O http://localhost:8000/api/v1/calls/call_abc123/recording # WAV
|
||||||
```
|
```
|
||||||
|
|
||||||
**Create a smart-routing rule:**
|
**Create a smart-routing rule:**
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "Block tollfree at night",
|
"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:**
|
**Toggle Do Not Disturb on a device:**
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"enabled": true}'
|
-d '{"enabled": true}'
|
||||||
```
|
```
|
||||||
@@ -355,7 +355,7 @@ All configuration is via environment variables (see `.env.example`):
|
|||||||
|
|
||||||
- **Python 3.12+** + **asyncio** — Single-process async architecture
|
- **Python 3.12+** + **asyncio** — Single-process async architecture
|
||||||
- **FastAPI** — REST API + WebSocket server
|
- **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
|
- **Sippy B2BUA** — SIP call control and DTMF
|
||||||
- **PJSUA2** — Media pipeline, conference bridge, recording, WAV playback
|
- **PJSUA2** — Media pipeline, conference bridge, recording, WAV playback
|
||||||
- **Speaches** (Whisper) — Speech-to-text
|
- **Speaches** (Whisper) — Speech-to-text
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ export async function fetchHealth(): Promise<HealthStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchActiveCalls(): Promise<CallSummary[]> {
|
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[]> {
|
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> {
|
export async function hangupCall(callId: string): Promise<void> {
|
||||||
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}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,27 +41,27 @@ export async function fetchCallHistory(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
): Promise<CallHistoryRow[]> {
|
): Promise<CallHistoryRow[]> {
|
||||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
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> {
|
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[]> {
|
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 {
|
export function recordingUrl(callId: string): string {
|
||||||
return `/api/calls/${callId}/recording`;
|
return `/api/v1/calls/${callId}/recording`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
|
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> {
|
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
|
||||||
const res = await fetch('/api/routing/rules', {
|
const res = await fetch('/api/v1/routing/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(rule),
|
body: JSON.stringify(rule),
|
||||||
@@ -74,7 +74,7 @@ export async function updateRoutingRule(
|
|||||||
ruleId: string,
|
ruleId: string,
|
||||||
patch: Partial<RoutingRule>,
|
patch: Partial<RoutingRule>,
|
||||||
): Promise<RoutingRule> {
|
): Promise<RoutingRule> {
|
||||||
const res = await fetch(`/api/routing/rules/${ruleId}`, {
|
const res = await fetch(`/api/v1/routing/rules/${ruleId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
@@ -84,12 +84,12 @@ export async function updateRoutingRule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRoutingRule(ruleId: string): Promise<void> {
|
export async function deleteRoutingRule(ruleId: string): Promise<void> {
|
||||||
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}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
|
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
|
||||||
const res = await fetch(`/api/routing/devices/${deviceId}/dnd`, {
|
const res = await fetch(`/api/v1/routing/devices/${deviceId}/dnd`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Base URL: `http://localhost:8000/api`
|
|||||||
#### Place an Outbound Call
|
#### Place an Outbound Call
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/calls/outbound
|
POST /api/v1/calls/outbound
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@@ -53,7 +53,7 @@ POST /api/calls/outbound
|
|||||||
#### Launch Hold Slayer
|
#### Launch Hold Slayer
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/calls/hold-slayer
|
POST /api/v1/calls/hold-slayer
|
||||||
```
|
```
|
||||||
|
|
||||||
Convenience endpoint — equivalent to `POST /outbound` with `mode=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 Call Status
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/calls/{call_id}
|
GET /api/v1/calls/{call_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -99,7 +99,7 @@ GET /api/calls/{call_id}
|
|||||||
#### List Active Calls
|
#### List Active Calls
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/calls
|
GET /api/v1/calls
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -117,13 +117,13 @@ GET /api/calls
|
|||||||
#### End a Call
|
#### End a Call
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/calls/{call_id}/hangup
|
POST /api/v1/calls/{call_id}/hangup
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Transfer a Call
|
#### Transfer a Call
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/calls/{call_id}/transfer
|
POST /api/v1/calls/{call_id}/transfer
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@@ -139,9 +139,9 @@ POST /api/calls/{call_id}/transfer
|
|||||||
#### List Call Flows
|
#### List Call Flows
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/call-flows
|
GET /api/v1/call-flows
|
||||||
GET /api/call-flows?company=Chase+Bank
|
GET /api/v1/call-flows?company=Chase+Bank
|
||||||
GET /api/call-flows?tag=banking
|
GET /api/v1/call-flows?tag=banking
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -166,7 +166,7 @@ GET /api/call-flows?tag=banking
|
|||||||
#### Get Call Flow
|
#### Get Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/call-flows/{flow_id}
|
GET /api/v1/call-flows/{flow_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the full call flow with all steps.
|
Returns the full call flow with all steps.
|
||||||
@@ -174,7 +174,7 @@ Returns the full call flow with all steps.
|
|||||||
#### Create Call Flow
|
#### Create Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/call-flows
|
POST /api/v1/call-flows
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@@ -197,13 +197,13 @@ POST /api/call-flows
|
|||||||
#### Update Call Flow
|
#### Update Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
PUT /api/call-flows/{flow_id}
|
PUT /api/v1/call-flows/{flow_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Delete Call Flow
|
#### Delete Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/call-flows/{flow_id}
|
DELETE /api/v1/call-flows/{flow_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Devices
|
### Devices
|
||||||
@@ -211,7 +211,7 @@ DELETE /api/call-flows/{flow_id}
|
|||||||
#### List Registered Devices
|
#### List Registered Devices
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/devices
|
GET /api/v1/devices
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
@@ -234,7 +234,7 @@ GET /api/devices
|
|||||||
#### Register a Device
|
#### Register a Device
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/devices
|
POST /api/v1/devices
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@@ -252,13 +252,13 @@ POST /api/devices
|
|||||||
#### Update Device
|
#### Update Device
|
||||||
|
|
||||||
```
|
```
|
||||||
PUT /api/devices/{device_id}
|
PUT /api/v1/devices/{device_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Remove Device
|
#### Remove Device
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/devices/{device_id}
|
DELETE /api/v1/devices/{device_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Responses
|
### Error Responses
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
|||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
|
||||||
│ │ REST API │ │WebSocket │ │MCP Server │ │ Dashboard │ │
|
│ │ 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
|
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()
|
2. Gateway.make_call()
|
||||||
├── CallManager.create_call() → track state
|
├── CallManager.create_call() → track state
|
||||||
|
|||||||
@@ -177,21 +177,21 @@ This handles:
|
|||||||
### List Call Flows
|
### List Call Flows
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/call-flows
|
GET /api/v1/call-flows
|
||||||
GET /api/call-flows?company=Chase+Bank
|
GET /api/v1/call-flows?company=Chase+Bank
|
||||||
GET /api/call-flows?tag=banking
|
GET /api/v1/call-flows?tag=banking
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Call Flow
|
### Get Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/call-flows/{flow_id}
|
GET /api/v1/call-flows/{flow_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create Call Flow
|
### Create Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/call-flows
|
POST /api/v1/call-flows
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -205,7 +205,7 @@ Content-Type: application/json
|
|||||||
### Update Call Flow
|
### Update Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
PUT /api/call-flows/{flow_id}
|
PUT /api/v1/call-flows/{flow_id}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{ ... updated flow ... }
|
{ ... updated flow ... }
|
||||||
@@ -214,13 +214,13 @@ Content-Type: application/json
|
|||||||
### Delete Call Flow
|
### Delete Call Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
DELETE /api/call-flows/{flow_id}
|
DELETE /api/v1/call-flows/{flow_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Learn Flow from Exploration
|
### Learn Flow from Exploration
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/call-flows/learn
|
POST /api/v1/call-flows/learn
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ All routing is pattern-matched in order; the first match wins.
|
|||||||
## 2XX — Endpoint Extensions
|
## 2XX — Endpoint Extensions
|
||||||
|
|
||||||
Extensions are auto-assigned from **221** upward when a SIP device
|
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 |
|
| Extension | Format | Example |
|
||||||
|-----------|---------------------------------|--------------------------------|
|
|-----------|---------------------------------|--------------------------------|
|
||||||
|
|||||||
64
main.py
64
main.py
@@ -248,62 +248,30 @@ app = FastAPI(
|
|||||||
"🗡️ AI PSTN Gateway — Navigate IVRs, wait on hold, "
|
"🗡️ AI PSTN Gateway — Navigate IVRs, wait on hold, "
|
||||||
"and connect you when a human answers.\n\n"
|
"and connect you when a human answers.\n\n"
|
||||||
"## Quick Start\n"
|
"## Quick Start\n"
|
||||||
"1. **POST /api/calls/hold-slayer** — Launch the Hold Slayer\n"
|
"1. **POST /api/v1/calls/hold-slayer** — Launch the Hold Slayer\n"
|
||||||
"2. **GET /api/calls/{call_id}** — Check call status\n"
|
"2. **GET /api/v1/calls/{call_id}** — Check call status\n"
|
||||||
"3. **WS /ws/events** — Real-time event stream\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",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# === API Routes ===
|
# === 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".
|
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||||
_auth = [Depends(require_token)]
|
_auth = [Depends(require_token)]
|
||||||
app.include_router(call_history.router, prefix="/api/calls", tags=["Call History"], dependencies=_auth)
|
app.include_router(call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth)
|
||||||
app.include_router(calls.router, prefix="/api/calls", tags=["Calls"], dependencies=_auth)
|
app.include_router(calls.router, prefix="/api/v1/calls", tags=["Calls"], dependencies=_auth)
|
||||||
app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"], 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/devices", tags=["Devices"], dependencies=_auth)
|
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
||||||
app.include_router(routing.router, prefix="/api/routing", tags=["Routing"], 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)
|
# WebSocket endpoints check the token themselves (query param or header)
|
||||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||||
|
|
||||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
||||||
app.mount("/mcp", mcp_http_app)
|
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"])
|
@app.get("/health", tags=["System"])
|
||||||
async def health():
|
async def health():
|
||||||
@@ -370,6 +338,20 @@ def _availability(service) -> str:
|
|||||||
return "ok" if available else "unreachable"
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
|||||||
@@ -30,31 +30,31 @@ async def client():
|
|||||||
|
|
||||||
class TestBearerToken:
|
class TestBearerToken:
|
||||||
async def test_missing_token_rejected(self, token_enabled, client):
|
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.status_code == 401
|
||||||
assert resp.headers["www-authenticate"] == "Bearer"
|
assert resp.headers["www-authenticate"] == "Bearer"
|
||||||
|
|
||||||
async def test_wrong_token_rejected(self, token_enabled, client):
|
async def test_wrong_token_rejected(self, token_enabled, client):
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/api/calls/active", headers={"Authorization": "Bearer wrong"}
|
"/api/v1/calls/active", headers={"Authorization": "Bearer wrong"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
||||||
resp = await client.get(
|
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
|
# No lifespan ran, so the handler itself 503s — auth was accepted
|
||||||
assert resp.status_code == 503
|
assert resp.status_code == 503
|
||||||
|
|
||||||
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
||||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
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
|
assert resp.status_code == 503
|
||||||
|
|
||||||
async def test_all_api_routers_protected(self, token_enabled, client):
|
async def test_all_api_routers_protected(self, token_enabled, client):
|
||||||
for path in ("/api/calls/active", "/api/call-flows/", "/api/devices/",
|
for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/",
|
||||||
"/api/routing/rules", "/api/calls/history"):
|
"/api/v1/routing/rules", "/api/v1/calls/history"):
|
||||||
resp = await client.get(path)
|
resp = await client.get(path)
|
||||||
assert resp.status_code == 401, path
|
assert resp.status_code == 401, path
|
||||||
|
|
||||||
@@ -76,12 +76,12 @@ class TestRouteOrder:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def test_history_not_shadowed_by_call_id(self):
|
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 is not None
|
||||||
assert route.endpoint.__name__ == "list_history"
|
assert route.endpoint.__name__ == "list_history"
|
||||||
|
|
||||||
def test_call_id_still_matches(self):
|
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 is not None
|
||||||
assert route.endpoint.__name__ == "get_call"
|
assert route.endpoint.__name__ == "get_call"
|
||||||
|
|
||||||
|
|||||||
@@ -163,38 +163,38 @@ FLOW_PAYLOAD = {
|
|||||||
|
|
||||||
class TestCallFlowRoutes:
|
class TestCallFlowRoutes:
|
||||||
async def test_crud_round_trip(self, client):
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
flow_id = resp.json()["id"]
|
flow_id = resp.json()["id"]
|
||||||
assert flow_id == "acme-main-line"
|
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
|
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]
|
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"
|
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
|
assert resp.json()["id"] == flow_id
|
||||||
|
|
||||||
resp = await client.put(
|
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"
|
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"
|
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
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestCallHistoryRoutes:
|
class TestCallHistoryRoutes:
|
||||||
async def test_history_and_record(self, client):
|
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.status_code == 200
|
||||||
assert resp.json() == []
|
assert resp.json() == []
|
||||||
|
|
||||||
@@ -211,17 +211,17 @@ class TestCallHistoryRoutes:
|
|||||||
))
|
))
|
||||||
await session.commit()
|
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"]
|
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
|
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"
|
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
|
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() == []
|
assert resp.json() == []
|
||||||
|
|||||||
Reference in New Issue
Block a user