199 lines
5.7 KiB
Python
199 lines
5.7 KiB
Python
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
|