Files
gogo-telefon/gogo/api/internal.py

160 lines
5.0 KiB
Python
Raw Normal View History

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