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