""" 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 and return with correct content-type.""" return Response( content=f'{body}', 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 for DTMF — commas are 0.5s pauses in DTMF notation ivr_twiml = f'' 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 = "" # 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"{bridge_url}" 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("") # with bridges both legs into a named room. # waitUrl="" suppresses hold music on our side (FTB's hold music is already playing). conference_twiml = ( "" f" " f" {BRIDGE_NUMBER}" " " "" ) return _xml(conference_twiml)