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_", "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:///api/v1/voice/ws/media (not https://)", "received": text[:200] if text else None, }