- AudioSocket server (asyncio TCP, Asterisk wire protocol) bridging calls into the same M2 agent used by chat - Call session engine: greeting → energy-VAD utterance collection → STT → agent turn → TTS playback with barge-in (120ms of caller speech stops playback); inactivity + max-duration guards; unintelligible audio reaches the agent as '[nerazumljivo]' so the two-attempt rule stays in the prompt - STTProvider (faster-whisper, lang hint 'sr', GPU/CPU via env) and TTSProvider (Azure Neural raw-8k PCM / ElevenLabs Flash) + test fakes - Recording (mixed caller+agent WAV), transcripts, per-turn latency trace, metering via record_agent_call (§12) - Internal dialplan API: /internal/calls/register (ring targets computed from working hours + ring settings §5.2), /answered (human outcome, no minutes), /hangup (abandoned → missed-call SMS §5.5); shared-token auth - Asterisk config generator (pjsip.conf + extensions.conf from DB): worker endpoints, per-tenant test-caller endpoint, register→Dial→AudioSocket dialplan with h-extension reporting — validated against a real Asterisk 20 container (modules, dialplan, endpoints all load) - docker-compose 'voice' profile (voice server + Asterisk), Dockerfile.voice, docs/VOICE_TESTING.md softphone runbook - 16 new tests incl. full fake-call e2e and a real-TCP AudioSocket wire test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
5.0 KiB
Python
160 lines
5.0 KiB
Python
"""Internal API for the Asterisk dialplan (M4) — plain-text responses because
|
|
the dialplan consumes them via CURL()/CUT().
|
|
|
|
Auth: shared token (GOGO_INTERNAL_TOKEN) as a query param; this API must only
|
|
be reachable on the internal network regardless.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import PlainTextResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from gogo.config import get_settings
|
|
from gogo.db import get_session
|
|
from gogo.domain import CallOutcome
|
|
from gogo.hours import DEFAULT_WORKING_HOURS, is_open_at
|
|
from gogo.models import Call, CallRegistration, Tenant, Worker, utcnow
|
|
from gogo.sms import send_sms
|
|
from gogo.sms.templates import render_sms
|
|
|
|
log = logging.getLogger("gogo.internal")
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _authorized(token: str) -> bool:
|
|
return token == get_settings().internal_token
|
|
|
|
|
|
@router.get("/internal/calls/register", response_class=PlainTextResponse)
|
|
async def register_call(
|
|
tenant: str,
|
|
caller: str = "",
|
|
token: str = "",
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Dialplan entry point. Returns 'uuid,ring_timeout,dial_targets'.
|
|
|
|
dial_targets is empty when the agent should answer immediately
|
|
(out of hours, ring-first disabled, or no active workers §5.2).
|
|
"""
|
|
if not _authorized(token):
|
|
return PlainTextResponse("forbidden", status_code=403)
|
|
t = (
|
|
await session.execute(select(Tenant).where(Tenant.slug == tenant))
|
|
).scalar_one_or_none()
|
|
if t is None or t.status != "active":
|
|
return PlainTextResponse("unknown-tenant", status_code=404)
|
|
|
|
reg = CallRegistration(tenant_id=t.id, caller_msisdn=caller.strip())
|
|
session.add(reg)
|
|
await session.commit()
|
|
|
|
targets = ""
|
|
from zoneinfo import ZoneInfo
|
|
|
|
open_now = is_open_at(
|
|
t.working_hours or DEFAULT_WORKING_HOURS, utcnow(), ZoneInfo(t.timezone)
|
|
)
|
|
if t.ring_first_enabled and open_now:
|
|
workers = (
|
|
(
|
|
await session.execute(
|
|
select(Worker)
|
|
.where(Worker.tenant_id == t.id, Worker.active.is_(True))
|
|
.order_by(Worker.sip_username)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
if workers:
|
|
# '&' rings all at once (default §5.2); sequential strategy is a Phase-2 tuning
|
|
targets = "&".join(f"PJSIP/{w.sip_username}" for w in workers)
|
|
|
|
return f"{reg.id},{t.ring_timeout_s},{targets}"
|
|
|
|
|
|
@router.get("/internal/calls/{reg_id}/answered", response_class=PlainTextResponse)
|
|
async def call_answered(
|
|
reg_id: str,
|
|
token: str = "",
|
|
duration: int = 0,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Ring group answered by a human — log the call, no agent minutes (§12)."""
|
|
if not _authorized(token):
|
|
return PlainTextResponse("forbidden", status_code=403)
|
|
reg = (
|
|
await session.execute(
|
|
select(CallRegistration).where(CallRegistration.id == uuid.UUID(reg_id))
|
|
)
|
|
).scalar_one_or_none()
|
|
if reg is None:
|
|
return PlainTextResponse("unknown", status_code=404)
|
|
reg.answered_by_human = True
|
|
if not reg.finalized:
|
|
reg.finalized = True
|
|
session.add(
|
|
Call(
|
|
tenant_id=reg.tenant_id,
|
|
caller_msisdn=reg.caller_msisdn,
|
|
outcome=CallOutcome.human_answered.value,
|
|
duration_s=max(0, duration),
|
|
)
|
|
)
|
|
await session.commit()
|
|
return "ok"
|
|
|
|
|
|
@router.get("/internal/calls/{reg_id}/hangup", response_class=PlainTextResponse)
|
|
async def call_hangup(
|
|
reg_id: str,
|
|
status: str = "",
|
|
token: str = "",
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Dialplan h-extension. If nobody (human or agent) handled the call, the
|
|
caller abandoned during ringing → log + missed-call SMS (§5.5)."""
|
|
if not _authorized(token):
|
|
return PlainTextResponse("forbidden", status_code=403)
|
|
reg = (
|
|
await session.execute(
|
|
select(CallRegistration).where(CallRegistration.id == uuid.UUID(reg_id))
|
|
)
|
|
).scalar_one_or_none()
|
|
if reg is None:
|
|
return PlainTextResponse("unknown", status_code=404)
|
|
if reg.finalized or reg.answered_by_human or reg.handled_by_agent:
|
|
return "ok"
|
|
|
|
reg.finalized = True
|
|
tenant = (
|
|
await session.execute(select(Tenant).where(Tenant.id == reg.tenant_id))
|
|
).scalar_one()
|
|
session.add(
|
|
Call(
|
|
tenant_id=reg.tenant_id,
|
|
caller_msisdn=reg.caller_msisdn,
|
|
outcome=CallOutcome.abandoned.value,
|
|
)
|
|
)
|
|
if reg.caller_msisdn:
|
|
link = tenant.website or get_settings().base_url
|
|
await send_sms(
|
|
session,
|
|
tenant.id,
|
|
reg.caller_msisdn,
|
|
render_sms(tenant, "missed_call", link=link),
|
|
kind="missed_call",
|
|
)
|
|
await session.commit()
|
|
log.info("abandoned call for %s from %s (status=%s)", tenant.slug, reg.caller_msisdn, status)
|
|
return "ok"
|