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

View file

@ -0,0 +1 @@

View 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

View 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

View 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()

View 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

View 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

View 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()

View 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"

View 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),
)
)

View 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))

View 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())

View 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