first commit

This commit is contained in:
debu-2 2026-10-02 11:22:40 +05:30
commit efa5080897
127 changed files with 18610 additions and 0 deletions

435
backend/app/routes_voice.py Normal file
View 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,
}