435 lines
16 KiB
Python
435 lines
16 KiB
Python
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,
|
|
}
|