osprey/api/webhooks/twilio.py

104 lines
3.8 KiB
Python

"""
Twilio webhook handlers — TwiML responses for outbound government hold-line calls.
Twilio calls these endpoints during a live call to get instructions (TwiML XML).
The initiating script (app/dialers/ftb.py) starts the call; Twilio fetches
/webhooks/twilio/twiml to know what to do once the call connects.
"""
from __future__ import annotations
import os
from fastapi import APIRouter, Form, Response
from fastapi.responses import PlainTextResponse
router = APIRouter(prefix="/webhooks/twilio")
# Phone number to bridge the user into the call once we're in queue.
# Set via OSPREY_BRIDGE_NUMBER env var (E.164 format, e.g. +15105551234).
BRIDGE_NUMBER = os.environ.get("OSPREY_BRIDGE_NUMBER", "")
def _xml(body: str) -> Response:
"""Wrap TwiML body in <Response> and return with correct content-type."""
return Response(
content=f'<?xml version="1.0" encoding="UTF-8"?><Response>{body}</Response>',
media_type="application/xml",
)
@router.post("/twiml")
async def twiml_navigate():
"""
Called by Twilio when the outbound call to the agency connects.
Returns TwiML that navigates the IVR and then holds.
IVR sequence is loaded from OSPREY_IVR_SEQUENCE env var —
a comma-separated list of DTMF digits/pauses, e.g. "1,,,2,,,0"
where commas are 0.5s pauses between digits.
After navigation, we pause for 45s. If the call is still alive
at the /status callback, the status handler initiates the bridge call.
"""
sequence = os.environ.get("OSPREY_IVR_SEQUENCE", "")
digits = sequence.replace(" ", "") # strip accidental spaces
# Build <Play digits="..."> for DTMF — commas are 0.5s pauses in DTMF notation
ivr_twiml = f'<Play digits="{digits}"/>' if digits else ""
# Hold for long enough to confirm we're in queue before bridging.
# 45s covers the IVR tree + any initial hold announcement.
hold_twiml = "<Pause length='45'/>"
# After holding, redirect to the bridge endpoint to connect the user.
bridge_url = os.environ.get("OSPREY_BASE_URL", "").rstrip("/") + "/webhooks/twilio/bridge"
redirect_twiml = f"<Redirect>{bridge_url}</Redirect>"
return _xml(ivr_twiml + hold_twiml + redirect_twiml)
@router.post("/status")
async def call_status(
CallStatus: str = Form(...),
CallDuration: str = Form("0"),
CallSid: str = Form(""),
):
"""
Twilio posts here on every call status change.
We log rejections (short duration + completed/busy/failed) for retry logic.
The dialer script polls a shared state file; this endpoint writes to it.
"""
duration = int(CallDuration or 0)
rejected = CallStatus in ("completed", "busy", "failed", "no-answer") and duration < 30
state_file = os.environ.get("OSPREY_STATE_FILE", "/tmp/osprey_call_state.txt")
with open(state_file, "w") as f:
f.write(f"{CallStatus}:{duration}:{CallSid}")
if rejected:
print(f"[Osprey] Call {CallSid} rejected after {duration}s — will retry")
else:
print(f"[Osprey] Call {CallSid} status={CallStatus} duration={duration}s")
return PlainTextResponse("OK")
@router.post("/bridge")
async def bridge_call():
"""
Called after the hold period — we're confident we're in the queue.
Dials OSPREY_BRIDGE_NUMBER and connects both legs into a conference.
"""
if not BRIDGE_NUMBER:
# No bridge number configured — just keep holding silently.
return _xml("<Pause length='3600'/>")
# <Dial> with <Conference> bridges both legs into a named room.
# waitUrl="" suppresses hold music on our side (FTB's hold music is already playing).
conference_twiml = (
"<Dial>"
f" <Number statusCallbackEvent='answered' statusCallback='/webhooks/twilio/status'>"
f" {BRIDGE_NUMBER}"
" </Number>"
"</Dial>"
)
return _xml(conference_twiml)