104 lines
3.8 KiB
Python
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)
|