From 28ff8c2d1678f99f8762c081437fafd17f0d1695 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Thu, 5 Feb 2026 13:33:03 -0700 Subject: [PATCH] Add Twilio webhook and queue management endpoints Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 92 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index b233750..7eff550 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,14 +4,16 @@ import uuid import asyncio from dataclasses import dataclass, field from pathlib import Path -from fastapi import FastAPI, HTTPException, UploadFile, File +from fastapi import FastAPI, HTTPException, UploadFile, File, Form from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response +from twilio.twiml.voice_response import VoiceResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional from .config import settings +from .services.twilio_service import TwilioService from .services.transcription import transcribe_audio from .services.llm import llm_service from .services.tts import generate_speech @@ -395,6 +397,7 @@ class Session: session = Session() +twilio_service = TwilioService() # --- Static Files --- @@ -761,6 +764,91 @@ async def update_settings(data: dict): return llm_service.get_settings() +# --- Twilio Webhook & Queue Endpoints --- + +@app.post("/api/twilio/voice") +async def twilio_voice_webhook( + CallSid: str = Form(...), + From: str = Form(...), +): + """Handle incoming Twilio call — greet and enqueue""" + twilio_service.add_to_queue(CallSid, From) + + response = VoiceResponse() + response.say("You're calling Luke at the Roost. Hold tight, we'll get to you.", voice="alice") + response.enqueue( + "radio_show", + wait_url="/api/twilio/hold-music", + wait_url_method="POST", + ) + return Response(content=str(response), media_type="application/xml") + + +@app.post("/api/twilio/hold-music") +async def twilio_hold_music(): + """Serve hold music TwiML for queued callers""" + response = VoiceResponse() + response.say("Please hold, you'll be on air shortly.", voice="alice") + response.pause(length=30) + return Response(content=str(response), media_type="application/xml") + + +@app.get("/api/queue") +async def get_call_queue(): + """Get list of callers waiting in queue""" + return {"queue": twilio_service.get_queue()} + + +@app.post("/api/queue/take/{call_sid}") +async def take_call_from_queue(call_sid: str): + """Take a caller off hold and put them on air""" + try: + call_info = twilio_service.take_call(call_sid) + except ValueError as e: + raise HTTPException(404, str(e)) + + session.active_real_caller = { + "call_sid": call_info["call_sid"], + "phone": call_info["phone"], + "channel": call_info["channel"], + "name": call_info["name"], + } + + # Connect Twilio media stream by updating the call + from twilio.rest import Client as TwilioClient + if settings.twilio_account_sid and settings.twilio_auth_token: + client = TwilioClient(settings.twilio_account_sid, settings.twilio_auth_token) + twiml = VoiceResponse() + connect = twiml.connect() + connect.stream( + url=f"wss://{settings.twilio_webhook_base_url.replace('https://', '')}/api/twilio/stream", + name=call_sid, + ) + client.calls(call_sid).update(twiml=str(twiml)) + + return { + "status": "on_air", + "caller": call_info, + } + + +@app.post("/api/queue/drop/{call_sid}") +async def drop_from_queue(call_sid: str): + """Drop a caller from the queue""" + twilio_service.remove_from_queue(call_sid) + + # Hang up the Twilio call + from twilio.rest import Client as TwilioClient + if settings.twilio_account_sid and settings.twilio_auth_token: + try: + client = TwilioClient(settings.twilio_account_sid, settings.twilio_auth_token) + client.calls(call_sid).update(status="completed") + except Exception as e: + print(f"[Twilio] Failed to end call {call_sid}: {e}") + + return {"status": "dropped"} + + # --- Server Control Endpoints --- import subprocess