first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
435
backend/app/routes_voice.py
Normal file
435
backend/app/routes_voice.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import COOKIE_NAME, decode_token
|
||||
from .config import settings
|
||||
from .database import SessionLocal, get_db
|
||||
from .models import UserAccount, VoiceCall
|
||||
from .voice.bonvoice_client import BonvoiceError
|
||||
from .voice.call_service import (
|
||||
create_inbound_call_record,
|
||||
create_outbound_call,
|
||||
finalize_call_async,
|
||||
refresh_call_lifecycle,
|
||||
serialize_call,
|
||||
)
|
||||
from .voice.bonvoice_media import extract_start_metadata, parse_event, run_console_raw_relay
|
||||
from .voice.call_resolver import find_inbound_by_did, find_pending_outbound_call
|
||||
from .voice.presence import console_presence
|
||||
from .voice.public_url import unified_voicebot_url
|
||||
from .endpoint_security import signed_headers
|
||||
from .voice.relay_config import resolve_voice_socket
|
||||
from .voice.session_store import update_session
|
||||
from .voice.tunnel import relay_provider_to_upstream
|
||||
from .voice.validators import normalize_phone_digits, require_owned_did
|
||||
from .voice_auth import current_voice_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/voice", tags=["voice"])
|
||||
|
||||
VOICE_API_DOCS = {
|
||||
"title": "Initiate outbound call",
|
||||
"summary": "Place an outbound call from your Urmi number. Live audio is relayed to the voice socket linked to that number.",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/voice/calls",
|
||||
"authentication": {
|
||||
"header": "Authorization",
|
||||
"format": "Bearer urmi_live_<key>",
|
||||
"alternate_header": "X-Api-Key",
|
||||
},
|
||||
"request_body": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"example": "9149154337",
|
||||
"description": "Destination number with country code",
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"example": "7946350706",
|
||||
"description": "Your Urmi number (must be assigned to your account)",
|
||||
},
|
||||
"bridge_mode": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"example": "upstream",
|
||||
"description": "Use upstream for production. Console mode is for internal testing only.",
|
||||
},
|
||||
},
|
||||
"response_example": {
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"status": "initiated",
|
||||
"direction": "outbound",
|
||||
"from": "7946350706",
|
||||
"to": "9149154337",
|
||||
},
|
||||
"relay": {
|
||||
"principle": "Urmi is a transparent relay and authentication gateway. We validate your API key, place the call, and forward raw WebSocket frames. We do not decode, transcode, buffer, or inject audio.",
|
||||
"latency": "Frames are forwarded as they arrive to keep latency as low as possible. All speech processing happens on your server.",
|
||||
},
|
||||
"outbound_flow": [
|
||||
"Link your Urmi number to a voice socket under Webhooks & Sockets.",
|
||||
"POST /api/v1/voice/calls with from, to, and bridge_mode upstream.",
|
||||
"Urmi validates the number belongs to your account and places the call.",
|
||||
"When the callee answers, Urmi opens your voice socket and relays connected, start, media, and end frames unchanged.",
|
||||
"Poll GET /api/v1/voice/calls/{call_id} for call status.",
|
||||
],
|
||||
"endpoints": [
|
||||
{"method": "POST", "path": "/api/v1/voice/calls", "description": "Place an outbound call"},
|
||||
{"method": "GET", "path": "/api/v1/voice/calls/{call_id}", "description": "Get call status"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CreateCallRequest(BaseModel):
|
||||
to: str = Field(min_length=10, max_length=15)
|
||||
from_number: str = Field(alias="from", min_length=10, max_length=15)
|
||||
bridge_mode: str = Field(default="upstream", pattern="^(upstream|console)$")
|
||||
metadata: dict | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
def _provider_ip_allowed(client_host: str | None) -> bool:
|
||||
if settings.environment == "development":
|
||||
return True
|
||||
allowlist = [item.strip() for item in settings.bonvoice_media_allowlist.split(",") if item.strip()]
|
||||
if not allowlist:
|
||||
return False
|
||||
if not client_host:
|
||||
return False
|
||||
return client_host in allowlist
|
||||
|
||||
|
||||
def _websocket_user(db: Session, websocket: WebSocket) -> UserAccount | None:
|
||||
token = websocket.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
|
||||
if user and user.status == "active":
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/docs")
|
||||
def voice_docs() -> dict:
|
||||
return VOICE_API_DOCS
|
||||
|
||||
|
||||
@router.post("/calls")
|
||||
async def initiate_call(
|
||||
payload: CreateCallRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
bridge_mode = payload.bridge_mode
|
||||
if bridge_mode == "console":
|
||||
if not await console_presence.is_present(user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Open /console/voice with an active connection before placing a console-bridged call",
|
||||
)
|
||||
upstream = None
|
||||
else:
|
||||
number = require_owned_did(db, user, payload.from_number)
|
||||
endpoint = resolve_voice_socket(db, phone_number=number)
|
||||
if not endpoint:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Link this number to an enabled voice socket under Webhooks & Sockets",
|
||||
)
|
||||
upstream = endpoint.url
|
||||
|
||||
try:
|
||||
call = await create_outbound_call(
|
||||
db,
|
||||
user=user,
|
||||
from_value=payload.from_number,
|
||||
to_value=payload.to,
|
||||
upstream=upstream,
|
||||
bridge_mode=bridge_mode,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
except BonvoiceError as exc:
|
||||
raise HTTPException(status_code=exc.status_code or 502, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
body = serialize_call(call)
|
||||
return body
|
||||
|
||||
|
||||
@router.get("/calls/{call_id}")
|
||||
async def get_call(
|
||||
call_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
call = db.scalar(
|
||||
select(VoiceCall).where(VoiceCall.id == call_id, VoiceCall.user_id == user.id)
|
||||
)
|
||||
if not call:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Call not found")
|
||||
call = await refresh_call_lifecycle(db, call)
|
||||
body = serialize_call(call)
|
||||
return body
|
||||
|
||||
|
||||
@router.websocket("/console")
|
||||
async def voice_console(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
user = _websocket_user(db, websocket)
|
||||
if not user:
|
||||
await websocket.close(code=4401, reason="Authentication required")
|
||||
db.close()
|
||||
return
|
||||
|
||||
await console_presence.register(user.id, websocket)
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "console.ready",
|
||||
"message": "Console audio bridge active for outbound calls.",
|
||||
"media_url": unified_voicebot_url(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
if message["type"] != "websocket.receive":
|
||||
continue
|
||||
text = message.get("text")
|
||||
if text:
|
||||
await console_presence.forward_from_console(user.id, text)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await console_presence.unregister(user.id, websocket)
|
||||
db.close()
|
||||
|
||||
|
||||
async def _run_bonvoice_media_stream(websocket: WebSocket) -> None:
|
||||
"""Match Bonvoice media to outbound or inbound, then raw-relay to upstream or console."""
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
call: VoiceCall | None = None
|
||||
console_ws: WebSocket | None = None
|
||||
buffered: list[str] = []
|
||||
|
||||
async def on_end() -> None:
|
||||
if call:
|
||||
await update_session(call.session_token, status="ended")
|
||||
await finalize_call_async(db, call)
|
||||
if console_ws:
|
||||
try:
|
||||
await console_ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "call.ended",
|
||||
"call_id": str(call.id),
|
||||
"direction": call.direction,
|
||||
}
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
buffered.append(raw)
|
||||
message = parse_event(raw)
|
||||
if not message or message.get("event") != "start":
|
||||
continue
|
||||
|
||||
meta = extract_start_metadata(message)
|
||||
logger.info(
|
||||
"Bonvoice media start stream_id=%s call_id=%s from=%s to=%s",
|
||||
meta["stream_id"],
|
||||
meta["call_id"],
|
||||
meta["from"],
|
||||
meta["to"],
|
||||
)
|
||||
|
||||
call = find_pending_outbound_call(db, from_value=meta["from"], to_value=meta["to"])
|
||||
if call:
|
||||
call.status = "active"
|
||||
call.answered_at = datetime.now(timezone.utc)
|
||||
call.stream_id = meta["stream_id_str"] or None
|
||||
call.call_id = meta["call_id"] or None
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await update_session(call.session_token, status="active", stream_id=meta["stream_id_str"])
|
||||
|
||||
if call.bridge_mode == "upstream":
|
||||
endpoint = resolve_voice_socket(db, phone_number=call.phone_number)
|
||||
if not endpoint:
|
||||
await websocket.close(code=4403, reason="No upstream URL")
|
||||
break
|
||||
try:
|
||||
await relay_provider_to_upstream(
|
||||
websocket,
|
||||
endpoint.url,
|
||||
initial_frames=buffered,
|
||||
additional_headers=signed_headers("socket", endpoint.id),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Upstream tunnel failed for call %s: %s", call.id, exc)
|
||||
call.status = "failed"
|
||||
call.error_message = f"Upstream connection failed: {exc}"
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
break
|
||||
|
||||
if call.bridge_mode == "console":
|
||||
console_ws = await console_presence.get_connection(call.user_id)
|
||||
if not console_ws:
|
||||
logger.warning("Console not connected for outbound call %s", call.id)
|
||||
await websocket.close(code=4403, reason="Console not active")
|
||||
break
|
||||
await console_ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "call.outbound",
|
||||
"call_id": str(call.id),
|
||||
"from": call.from_did,
|
||||
"to": call.to_number,
|
||||
"stream_id": meta["stream_id"],
|
||||
}
|
||||
)
|
||||
)
|
||||
await run_console_raw_relay(
|
||||
websocket,
|
||||
call.user_id,
|
||||
console_ws,
|
||||
initial_frames=buffered,
|
||||
on_end=on_end,
|
||||
)
|
||||
call = None
|
||||
break
|
||||
|
||||
await websocket.close(code=4403, reason="Unsupported bridge mode")
|
||||
break
|
||||
|
||||
phone_number = find_inbound_by_did(db, meta["to"])
|
||||
if not phone_number:
|
||||
logger.warning(
|
||||
"No outbound or inbound match for from=%s to=%s",
|
||||
meta["from"],
|
||||
meta["to"],
|
||||
)
|
||||
did = normalize_phone_digits(meta["to"])
|
||||
if len(did) == 10:
|
||||
stale = db.scalar(
|
||||
select(VoiceCall)
|
||||
.where(
|
||||
VoiceCall.direction == "outbound",
|
||||
VoiceCall.status == "initiated",
|
||||
VoiceCall.from_did == did,
|
||||
)
|
||||
.order_by(VoiceCall.created_at.desc())
|
||||
)
|
||||
if stale:
|
||||
stale.status = "failed"
|
||||
stale.error_message = "Media session could not be matched to pending call"
|
||||
stale.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
await websocket.close(code=4403, reason="No matching call or DID")
|
||||
break
|
||||
|
||||
endpoint = resolve_voice_socket(db, phone_number=phone_number)
|
||||
if not endpoint:
|
||||
logger.warning("No upstream configured for inbound DID %s", phone_number.phone_number)
|
||||
await websocket.close(code=4403, reason="No upstream configured")
|
||||
break
|
||||
|
||||
caller = normalize_phone_digits(meta["from"])
|
||||
call = create_inbound_call_record(
|
||||
db,
|
||||
phone_number=phone_number,
|
||||
caller=caller,
|
||||
upstream=endpoint.url,
|
||||
stream_id=meta["stream_id_str"] or None,
|
||||
provider_call_id=meta["call_id"] or None,
|
||||
)
|
||||
|
||||
try:
|
||||
await relay_provider_to_upstream(
|
||||
websocket,
|
||||
endpoint.url,
|
||||
initial_frames=buffered,
|
||||
additional_headers=signed_headers("socket", endpoint.id),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Inbound upstream tunnel failed for call %s: %s", call.id, exc)
|
||||
call.status = "failed"
|
||||
call.error_message = f"Upstream connection failed: {exc}"
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Bonvoice disconnected call=%s", call.id if call else None)
|
||||
except RuntimeError as exc:
|
||||
if "WebSocket is not connected" not in str(exc):
|
||||
raise
|
||||
finally:
|
||||
if call:
|
||||
await on_end()
|
||||
db.close()
|
||||
|
||||
|
||||
async def _run_outbound_media_stream(websocket: WebSocket) -> None:
|
||||
await _run_bonvoice_media_stream(websocket)
|
||||
|
||||
|
||||
@router.websocket("/ws/media")
|
||||
async def bonvoice_ws_media(websocket: WebSocket) -> None:
|
||||
client_host = websocket.client.host if websocket.client else None
|
||||
if not _provider_ip_allowed(client_host):
|
||||
await websocket.close(code=4403, reason="Provider IP not allowed")
|
||||
return
|
||||
logger.info("Bonvoice WebSocket connect /ws/media from %s", client_host)
|
||||
await _run_outbound_media_stream(websocket)
|
||||
|
||||
|
||||
@router.websocket("/media/{session_token}")
|
||||
async def provider_outbound_media(websocket: WebSocket, session_token: str) -> None:
|
||||
client_host = websocket.client.host if websocket.client else None
|
||||
if not _provider_ip_allowed(client_host):
|
||||
await websocket.close(code=4403, reason="Provider IP not allowed")
|
||||
return
|
||||
logger.info("Bonvoice WebSocket connect /media/%s from %s", session_token[:8], client_host)
|
||||
await _run_outbound_media_stream(websocket)
|
||||
|
||||
|
||||
@router.post("/ws/media")
|
||||
async def ws_media_post_debug(request: Request) -> dict:
|
||||
body = await request.body()
|
||||
text = body.decode("utf-8", errors="replace")
|
||||
logger.warning("POST /ws/media (not WebSocket). Body: %s", text[:500])
|
||||
return {
|
||||
"error": "WebSocket required",
|
||||
"hint": f"Use wss://<host>/api/v1/voice/ws/media (not https://)",
|
||||
"received": text[:200] if text else None,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue