first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
1
backend/app/voice/__init__.py
Normal file
1
backend/app/voice/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
40
backend/app/voice/audio_utils.py
Normal file
40
backend/app/voice/audio_utils.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""PCM helpers for Bonvoice media (from test_bonvoice/audio_utils.py)."""
|
||||
|
||||
import audioop
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
TARGET_RATE = 8000
|
||||
CHUNK_SIZE = 320
|
||||
|
||||
|
||||
def wav_to_pcm8000_mono(path: str | Path) -> bytes:
|
||||
path = Path(path)
|
||||
with wave.open(str(path), "rb") as wf:
|
||||
channels = wf.getnchannels()
|
||||
sample_width = wf.getsampwidth()
|
||||
frame_rate = wf.getframerate()
|
||||
pcm = wf.readframes(wf.getnframes())
|
||||
|
||||
if sample_width != 2:
|
||||
raise ValueError(f"Expected 16-bit audio, got {sample_width * 8}-bit")
|
||||
|
||||
if channels == 2:
|
||||
pcm = audioop.tomono(pcm, 2, 0.5, 0.5)
|
||||
elif channels != 1:
|
||||
raise ValueError(f"Unsupported channel count: {channels}")
|
||||
|
||||
if frame_rate != TARGET_RATE:
|
||||
pcm, _ = audioop.ratecv(pcm, 2, 1, frame_rate, TARGET_RATE, None)
|
||||
|
||||
return pcm
|
||||
|
||||
|
||||
def chunk_pcm(pcm: bytes, chunk_size: int = CHUNK_SIZE) -> list[bytes]:
|
||||
chunks: list[bytes] = []
|
||||
for i in range(0, len(pcm), chunk_size):
|
||||
chunk = pcm[i : i + chunk_size]
|
||||
if len(chunk) < chunk_size:
|
||||
chunk = chunk + b"\x00" * (chunk_size - len(chunk))
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
51
backend/app/voice/bonvoice_client.py
Normal file
51
backend/app/voice/bonvoice_client.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
|
||||
|
||||
class BonvoiceError(Exception):
|
||||
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
async def initiate_outbound_call(
|
||||
*,
|
||||
destination: str,
|
||||
leg_a_caller_id: str,
|
||||
voicebot_url: str,
|
||||
event_id: str | None = None,
|
||||
) -> dict:
|
||||
if not settings.bonvoice_token:
|
||||
raise BonvoiceError("Bonvoice credentials are not configured", status_code=503)
|
||||
|
||||
payload = {
|
||||
"autocallType": "5",
|
||||
"destination": destination,
|
||||
"legACallerID": leg_a_caller_id,
|
||||
"eventID": event_id or uuid.uuid4().hex.upper(),
|
||||
"voicebotProvider": "BONVOICE",
|
||||
"voicebotURL": voicebot_url,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Token {settings.bonvoice_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(settings.bonvoice_api_url, headers=headers, json=payload)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise BonvoiceError(
|
||||
f"Bonvoice API rejected the call ({response.status_code})",
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
if body.get("responseCode") not in (200, "200"):
|
||||
raise BonvoiceError(
|
||||
body.get("responseDescription") or "Bonvoice API returned an error",
|
||||
status_code=502,
|
||||
)
|
||||
return body
|
||||
76
backend/app/voice/bonvoice_media.py
Normal file
76
backend/app/voice/bonvoice_media.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Bonvoice media — raw frame relay only (no PCM processing on the API)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from .presence import console_presence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OnEndCallback = Callable[[], Awaitable[None]]
|
||||
|
||||
|
||||
def parse_event(raw: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def extract_start_metadata(message: dict[str, Any]) -> dict[str, Any]:
|
||||
data = message.get("data") or message.get("start") or {}
|
||||
stream_id = data.get("stream_id") if data.get("stream_id") is not None else message.get("stream_id")
|
||||
return {
|
||||
"stream_id": stream_id,
|
||||
"stream_id_str": str(stream_id) if stream_id is not None else "",
|
||||
"call_id": str(data.get("call_id") or message.get("call_id") or ""),
|
||||
"from": str(data.get("from") or message.get("from") or ""),
|
||||
"to": str(data.get("to") or message.get("to") or ""),
|
||||
}
|
||||
|
||||
|
||||
async def run_console_raw_relay(
|
||||
provider: WebSocket,
|
||||
user_id: UUID,
|
||||
console_ws: WebSocket,
|
||||
*,
|
||||
initial_frames: list[str] | None = None,
|
||||
on_end: OnEndCallback | None = None,
|
||||
) -> None:
|
||||
"""Console test bridge — forward raw JSON frames both ways (no server audio processing)."""
|
||||
stop = asyncio.Event()
|
||||
relay_queue = await console_presence.start_relay(user_id)
|
||||
|
||||
for raw in initial_frames or []:
|
||||
await console_ws.send_text(raw)
|
||||
|
||||
async def console_to_provider() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
raw = await relay_queue.get()
|
||||
await provider.send_text(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
stop.set()
|
||||
|
||||
async def provider_to_console() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
raw = await provider.receive_text()
|
||||
await console_ws.send_text(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
stop.set()
|
||||
|
||||
try:
|
||||
await asyncio.gather(console_to_provider(), provider_to_console())
|
||||
finally:
|
||||
stop.set()
|
||||
await console_presence.stop_relay(user_id)
|
||||
if on_end:
|
||||
await on_end()
|
||||
65
backend/app/voice/call_resolver.py
Normal file
65
backend/app/voice/call_resolver.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import PhoneNumber, VoiceCall
|
||||
from .validators import format_bonvoice_destination, normalize_phone_digits
|
||||
|
||||
|
||||
def find_inbound_by_did(db: Session, did_value: str) -> PhoneNumber | None:
|
||||
did = normalize_phone_digits(did_value)
|
||||
if len(did) != 10:
|
||||
return None
|
||||
return db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.phone_number == did,
|
||||
PhoneNumber.status == "assigned",
|
||||
PhoneNumber.user_id.is_not(None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _phone_variants(value: str) -> set[str]:
|
||||
raw = value.strip()
|
||||
normalized = normalize_phone_digits(raw)
|
||||
variants = {raw, normalized, format_bonvoice_destination(raw)}
|
||||
if len(normalized) == 10:
|
||||
variants.add(f"91{normalized}")
|
||||
return {item for item in variants if item}
|
||||
|
||||
|
||||
def find_pending_outbound_call(db: Session, *, from_value: str, to_value: str) -> VoiceCall | None:
|
||||
"""
|
||||
Match a pending outbound call to Bonvoice media `start`.
|
||||
|
||||
Outbound Bonvoice streams report:
|
||||
- `to` = leg-A caller ID (purchased DID, e.g. 7946350706)
|
||||
- `from` = destination/callee (e.g. 919149154337)
|
||||
"""
|
||||
did_candidates = _phone_variants(to_value)
|
||||
owned_dids = {
|
||||
normalize_phone_digits(value)
|
||||
for value in did_candidates
|
||||
if len(normalize_phone_digits(value)) == 10
|
||||
}
|
||||
if not owned_dids:
|
||||
return None
|
||||
|
||||
candidates = db.scalars(
|
||||
select(VoiceCall)
|
||||
.where(
|
||||
VoiceCall.direction == "outbound",
|
||||
VoiceCall.status == "initiated",
|
||||
VoiceCall.from_did.in_(owned_dids),
|
||||
)
|
||||
.order_by(VoiceCall.created_at.desc())
|
||||
.limit(5)
|
||||
).all()
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
callee_variants = _phone_variants(from_value)
|
||||
for call in candidates:
|
||||
if _phone_variants(call.to_number) & callee_variants:
|
||||
return call
|
||||
|
||||
return None
|
||||
199
backend/app/voice/call_service.py
Normal file
199
backend/app/voice/call_service.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..models import PhoneNumber, UserAccount, VoiceCall
|
||||
from .bonvoice_client import BonvoiceError, initiate_outbound_call
|
||||
from .public_url import unified_voicebot_url
|
||||
from .session_store import delete_session, get_session, save_session, update_session
|
||||
from .validators import format_bonvoice_destination, require_owned_did
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"ended", "failed", "no_answer"})
|
||||
|
||||
|
||||
def media_url_for_session(session_token: str) -> str:
|
||||
return unified_voicebot_url()
|
||||
|
||||
|
||||
def voice_media_ws_url() -> str:
|
||||
return unified_voicebot_url()
|
||||
|
||||
|
||||
def serialize_call(call: VoiceCall) -> dict:
|
||||
return {
|
||||
"id": str(call.id),
|
||||
"direction": call.direction,
|
||||
"from": call.from_did,
|
||||
"to": call.to_number,
|
||||
"status": call.status,
|
||||
"stream_id": call.stream_id,
|
||||
"call_id": call.call_id,
|
||||
"bridge_mode": call.bridge_mode,
|
||||
"upstream": call.upstream_ws,
|
||||
"error_message": call.error_message,
|
||||
"answered_at": call.answered_at,
|
||||
"ended_at": call.ended_at,
|
||||
"created_at": call.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def create_outbound_call(
|
||||
db: Session,
|
||||
*,
|
||||
user: UserAccount,
|
||||
from_value: str,
|
||||
to_value: str,
|
||||
upstream: str | None,
|
||||
bridge_mode: str,
|
||||
metadata: dict | None = None,
|
||||
) -> VoiceCall:
|
||||
owned_number = require_owned_did(db, user, from_value)
|
||||
destination = format_bonvoice_destination(to_value)
|
||||
if len(destination) < 10:
|
||||
raise ValueError("to must be a valid phone number")
|
||||
|
||||
session_token = secrets.token_urlsafe(24)
|
||||
provider_event_id = uuid.uuid4().hex.upper()
|
||||
call = VoiceCall(
|
||||
user_id=user.id,
|
||||
phone_number_id=owned_number.id,
|
||||
direction="outbound",
|
||||
from_did=owned_number.phone_number,
|
||||
to_number=destination,
|
||||
status="initiated",
|
||||
provider_event_id=provider_event_id,
|
||||
session_token=session_token,
|
||||
bridge_mode=bridge_mode,
|
||||
upstream_ws=upstream,
|
||||
)
|
||||
db.add(call)
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
|
||||
await save_session(
|
||||
session_token,
|
||||
{
|
||||
"call_id": str(call.id),
|
||||
"user_id": str(user.id),
|
||||
"direction": "outbound",
|
||||
"from": owned_number.phone_number,
|
||||
"to": destination,
|
||||
"bridge_mode": bridge_mode,
|
||||
"upstream_ws": upstream,
|
||||
"status": "pending",
|
||||
"metadata": metadata or {},
|
||||
},
|
||||
)
|
||||
|
||||
voicebot_url = unified_voicebot_url()
|
||||
try:
|
||||
await initiate_outbound_call(
|
||||
destination=destination,
|
||||
leg_a_caller_id=owned_number.phone_number,
|
||||
voicebot_url=voicebot_url,
|
||||
event_id=provider_event_id,
|
||||
)
|
||||
except BonvoiceError as exc:
|
||||
call.status = "failed"
|
||||
call.error_message = str(exc)
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
raise
|
||||
|
||||
return call
|
||||
|
||||
|
||||
def create_inbound_call_record(
|
||||
db: Session,
|
||||
*,
|
||||
phone_number: PhoneNumber,
|
||||
caller: str,
|
||||
upstream: str,
|
||||
stream_id: str | None,
|
||||
provider_call_id: str | None,
|
||||
) -> VoiceCall:
|
||||
session_token = secrets.token_urlsafe(24)
|
||||
call = VoiceCall(
|
||||
user_id=phone_number.user_id,
|
||||
phone_number_id=phone_number.id,
|
||||
direction="inbound",
|
||||
from_did=phone_number.phone_number,
|
||||
to_number=caller,
|
||||
status="active",
|
||||
session_token=session_token,
|
||||
stream_id=stream_id,
|
||||
call_id=provider_call_id,
|
||||
bridge_mode="upstream",
|
||||
upstream_ws=upstream,
|
||||
answered_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(call)
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
return call
|
||||
|
||||
|
||||
def finalize_call(db: Session, call: VoiceCall | None, *, status: str = "ended") -> None:
|
||||
if not call:
|
||||
return
|
||||
db.refresh(call)
|
||||
if call.status in TERMINAL_STATUSES:
|
||||
return
|
||||
call.status = status
|
||||
if not call.ended_at:
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def finalize_call_async(db: Session, call: VoiceCall | None, *, status: str = "ended") -> None:
|
||||
if not call:
|
||||
return
|
||||
finalize_call(db, call, status=status)
|
||||
if call.session_token:
|
||||
await delete_session(call.session_token)
|
||||
|
||||
|
||||
async def refresh_call_lifecycle(db: Session, call: VoiceCall) -> VoiceCall:
|
||||
if call.status in TERMINAL_STATUSES:
|
||||
return call
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
age_seconds = (now - call.created_at).total_seconds()
|
||||
|
||||
if call.status == "initiated":
|
||||
if age_seconds >= 45:
|
||||
call.status = "no_answer"
|
||||
call.ended_at = now
|
||||
if not call.error_message:
|
||||
call.error_message = "Call ended before media session connected"
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await delete_session(call.session_token)
|
||||
|
||||
if call.status == "active":
|
||||
session = await get_session(call.session_token)
|
||||
if not session:
|
||||
call.status = "ended"
|
||||
call.ended_at = now
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
elif session.get("status") == "ended":
|
||||
call.status = "ended"
|
||||
if not call.ended_at:
|
||||
call.ended_at = now
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await delete_session(call.session_token)
|
||||
elif age_seconds >= 600:
|
||||
call.status = "ended"
|
||||
call.ended_at = now
|
||||
if not call.error_message:
|
||||
call.error_message = "Call exceeded maximum duration"
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await delete_session(call.session_token)
|
||||
|
||||
return call
|
||||
50
backend/app/voice/presence.py
Normal file
50
backend/app/voice/presence.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConsolePresenceManager:
|
||||
def __init__(self) -> None:
|
||||
self._connections: dict[UUID, WebSocket] = {}
|
||||
self._relay_queues: dict[UUID, asyncio.Queue[str]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, user_id: UUID, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._connections[user_id] = websocket
|
||||
|
||||
async def unregister(self, user_id: UUID, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
if self._connections.get(user_id) is websocket:
|
||||
self._connections.pop(user_id, None)
|
||||
self._relay_queues.pop(user_id, None)
|
||||
|
||||
async def is_present(self, user_id: UUID) -> bool:
|
||||
async with self._lock:
|
||||
return user_id in self._connections
|
||||
|
||||
async def get_connection(self, user_id: UUID) -> WebSocket | None:
|
||||
async with self._lock:
|
||||
return self._connections.get(user_id)
|
||||
|
||||
async def start_relay(self, user_id: UUID) -> asyncio.Queue[str]:
|
||||
async with self._lock:
|
||||
queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
self._relay_queues[user_id] = queue
|
||||
return queue
|
||||
|
||||
async def stop_relay(self, user_id: UUID) -> None:
|
||||
async with self._lock:
|
||||
self._relay_queues.pop(user_id, None)
|
||||
|
||||
async def forward_from_console(self, user_id: UUID, text: str) -> bool:
|
||||
async with self._lock:
|
||||
queue = self._relay_queues.get(user_id)
|
||||
if not queue:
|
||||
return False
|
||||
await queue.put(text)
|
||||
return True
|
||||
|
||||
|
||||
console_presence = ConsolePresenceManager()
|
||||
40
backend/app/voice/public_url.py
Normal file
40
backend/app/voice/public_url.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ngrok_wss_base() -> str | None:
|
||||
"""Detect ngrok public WSS base (test_bonvoice/outbound_call.py pattern)."""
|
||||
tunnel_hosts = ("127.0.0.1:4040", "host.docker.internal:4040")
|
||||
for host in tunnel_hosts:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{host}/api/tunnels", timeout=2) as resp:
|
||||
data = json.load(resp)
|
||||
for tunnel in data.get("tunnels", []):
|
||||
public = tunnel.get("public_url", "")
|
||||
if public.startswith("https://"):
|
||||
ngrok_host = public.removeprefix("https://")
|
||||
return f"wss://{ngrok_host}/api/v1/voice"
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def resolve_voice_relay_public_base() -> str:
|
||||
configured = settings.voice_relay_public_url.rstrip("/")
|
||||
if "localhost" in configured or "127.0.0.1" in configured:
|
||||
ngrok_base = get_ngrok_wss_base()
|
||||
if ngrok_base:
|
||||
logger.info("Using ngrok voice relay base: %s", ngrok_base)
|
||||
return ngrok_base
|
||||
return configured
|
||||
|
||||
|
||||
def unified_voicebot_url() -> str:
|
||||
"""Bonvoice outbound media URL (voicebotURL)."""
|
||||
return f"{resolve_voice_relay_public_base()}/ws/media"
|
||||
18
backend/app/voice/relay_config.py
Normal file
18
backend/app/voice/relay_config.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import PhoneNumber, VoiceSocketEndpoint
|
||||
|
||||
|
||||
def resolve_voice_socket(
|
||||
db: Session,
|
||||
*,
|
||||
phone_number: PhoneNumber,
|
||||
) -> VoiceSocketEndpoint | None:
|
||||
return db.scalar(
|
||||
select(VoiceSocketEndpoint).where(
|
||||
VoiceSocketEndpoint.phone_number_id == phone_number.id,
|
||||
VoiceSocketEndpoint.user_id == phone_number.user_id,
|
||||
VoiceSocketEndpoint.enabled.is_(True),
|
||||
)
|
||||
)
|
||||
50
backend/app/voice/session_store.py
Normal file
50
backend/app/voice/session_store.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from ..config import settings
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
def _session_key(session_token: str) -> str:
|
||||
return f"voice:session:{session_token}"
|
||||
|
||||
|
||||
async def get_redis() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def save_session(session_token: str, data: dict[str, Any]) -> None:
|
||||
client = await get_redis()
|
||||
payload = dict(data)
|
||||
payload["session_token"] = session_token
|
||||
payload.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
||||
await client.set(_session_key(session_token), json.dumps(payload), ex=settings.voice_session_ttl_seconds)
|
||||
|
||||
|
||||
async def get_session(session_token: str) -> dict[str, Any] | None:
|
||||
client = await get_redis()
|
||||
raw = await client.get(_session_key(session_token))
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def update_session(session_token: str, **fields: Any) -> dict[str, Any] | None:
|
||||
session = await get_session(session_token)
|
||||
if not session:
|
||||
return None
|
||||
session.update(fields)
|
||||
await save_session(session_token, session)
|
||||
return session
|
||||
|
||||
|
||||
async def delete_session(session_token: str) -> None:
|
||||
client = await get_redis()
|
||||
await client.delete(_session_key(session_token))
|
||||
66
backend/app/voice/tunnel.py
Normal file
66
backend/app/voice/tunnel.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Transparent WebSocket frame relay — no parse, no audio processing."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import websockets
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def relay_text_websockets(left: WebSocket, right: WebSocket) -> None:
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def forward(source: WebSocket, destination: WebSocket) -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
message = await source.receive_text()
|
||||
await destination.send_text(message)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
stop.set()
|
||||
|
||||
await asyncio.gather(forward(left, right), forward(right, left))
|
||||
|
||||
|
||||
async def relay_provider_to_upstream(
|
||||
provider: WebSocket,
|
||||
upstream_url: str,
|
||||
*,
|
||||
initial_frames: list[str] | None = None,
|
||||
additional_headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Raw carrier ↔ customer upstream tunnel."""
|
||||
logger.info("Opening upstream tunnel to %s", upstream_url)
|
||||
async with websockets.connect(
|
||||
upstream_url,
|
||||
open_timeout=10,
|
||||
close_timeout=5,
|
||||
additional_headers=additional_headers,
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
max_queue=16,
|
||||
) as upstream:
|
||||
for frame in initial_frames or []:
|
||||
await upstream.send(frame)
|
||||
|
||||
async def provider_to_upstream() -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await provider.receive_text()
|
||||
await upstream.send(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
|
||||
async def upstream_to_provider() -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await upstream.recv()
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
await provider.send_text(raw)
|
||||
except (ConnectionClosed, WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
|
||||
await asyncio.gather(provider_to_upstream(), upstream_to_provider())
|
||||
52
backend/app/voice/validators.py
Normal file
52
backend/app/voice/validators.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import PhoneNumber, UserAccount
|
||||
|
||||
|
||||
def normalize_phone_digits(value: str) -> str:
|
||||
digits = re.sub(r"\D", "", value.strip())
|
||||
if len(digits) > 10 and digits.startswith("91"):
|
||||
digits = digits[2:]
|
||||
if len(digits) > 10:
|
||||
digits = digits[-10:]
|
||||
return digits
|
||||
|
||||
|
||||
def format_bonvoice_destination(value: str) -> str:
|
||||
digits = re.sub(r"\D", "", value.strip())
|
||||
if len(digits) == 10:
|
||||
return f"91{digits}"
|
||||
return digits
|
||||
|
||||
|
||||
def require_owned_did(db: Session, user: UserAccount, from_value: str) -> PhoneNumber:
|
||||
normalized = normalize_phone_digits(from_value)
|
||||
if len(normalized) != 10:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="from must be a valid 10-digit purchased number",
|
||||
)
|
||||
number = db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.user_id == user.id,
|
||||
PhoneNumber.status == "assigned",
|
||||
PhoneNumber.phone_number == normalized,
|
||||
)
|
||||
)
|
||||
if not number:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="from must be a purchased number assigned to your account",
|
||||
)
|
||||
return number
|
||||
|
||||
def parse_user_id(value: str) -> UUID:
|
||||
try:
|
||||
return UUID(value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
||||
Loading…
Add table
Add a link
Reference in a new issue