Remove Twilio endpoints and dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,6 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
|
openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||||
inworld_api_key: str = os.getenv("INWORLD_API_KEY", "")
|
inworld_api_key: str = os.getenv("INWORLD_API_KEY", "")
|
||||||
|
|
||||||
# Twilio Settings
|
|
||||||
twilio_account_sid: str = os.getenv("TWILIO_ACCOUNT_SID", "")
|
|
||||||
twilio_auth_token: str = os.getenv("TWILIO_AUTH_TOKEN", "")
|
|
||||||
twilio_phone_number: str = os.getenv("TWILIO_PHONE_NUMBER", "")
|
|
||||||
twilio_webhook_base_url: str = os.getenv("TWILIO_WEBHOOK_BASE_URL", "")
|
|
||||||
|
|
||||||
# LLM Settings
|
# LLM Settings
|
||||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||||
openrouter_model: str = "anthropic/claude-3-haiku"
|
openrouter_model: str = "anthropic/claude-3-haiku"
|
||||||
|
|||||||
169
backend/main.py
169
backend/main.py
@@ -4,13 +4,10 @@ import uuid
|
|||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse
|
||||||
from twilio.twiml.voice_response import VoiceResponse
|
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
import audioop
|
|
||||||
import time
|
import time
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -670,9 +667,9 @@ async def text_to_speech(request: TTSRequest):
|
|||||||
|
|
||||||
# Also send to active real callers so they hear the AI
|
# Also send to active real callers so they hear the AI
|
||||||
if session.active_real_caller:
|
if session.active_real_caller:
|
||||||
call_sid = session.active_real_caller["call_sid"]
|
caller_id = session.active_real_caller["caller_id"]
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
caller_service.send_audio_to_caller(call_sid, audio_bytes, 24000)
|
caller_service.send_audio_to_caller(caller_id, audio_bytes, 24000)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "playing", "duration": len(audio_bytes) / 2 / 24000}
|
return {"status": "playing", "duration": len(audio_bytes) / 2 / 24000}
|
||||||
@@ -780,34 +777,7 @@ async def update_settings(data: dict):
|
|||||||
return llm_service.get_settings()
|
return llm_service.get_settings()
|
||||||
|
|
||||||
|
|
||||||
# --- Twilio Webhook & Queue Endpoints ---
|
# --- 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"""
|
|
||||||
caller_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")
|
@app.get("/api/queue")
|
||||||
async def get_call_queue():
|
async def get_call_queue():
|
||||||
@@ -815,32 +785,22 @@ async def get_call_queue():
|
|||||||
return {"queue": caller_service.get_queue()}
|
return {"queue": caller_service.get_queue()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/queue/take/{call_sid}")
|
@app.post("/api/queue/take/{caller_id}")
|
||||||
async def take_call_from_queue(call_sid: str):
|
async def take_call_from_queue(caller_id: str):
|
||||||
"""Take a caller off hold and put them on air"""
|
"""Take a caller off hold and put them on air"""
|
||||||
try:
|
try:
|
||||||
call_info = caller_service.take_call(call_sid)
|
call_info = caller_service.take_call(caller_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(404, str(e))
|
raise HTTPException(404, str(e))
|
||||||
|
|
||||||
session.active_real_caller = {
|
session.active_real_caller = {
|
||||||
"call_sid": call_info["call_sid"],
|
"caller_id": call_info["caller_id"],
|
||||||
"phone": call_info["phone"],
|
|
||||||
"channel": call_info["channel"],
|
"channel": call_info["channel"],
|
||||||
"name": call_info["name"],
|
"name": call_info["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Connect Twilio media stream by updating the call
|
# Notify caller they're on air via WebSocket
|
||||||
from twilio.rest import Client as TwilioClient
|
await caller_service.notify_caller(caller_id, {"status": "on_air", "channel": call_info["channel"]})
|
||||||
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 {
|
return {
|
||||||
"status": "on_air",
|
"status": "on_air",
|
||||||
@@ -848,97 +808,17 @@ async def take_call_from_queue(call_sid: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/queue/drop/{call_sid}")
|
@app.post("/api/queue/drop/{caller_id}")
|
||||||
async def drop_from_queue(call_sid: str):
|
async def drop_from_queue(caller_id: str):
|
||||||
"""Drop a caller from the queue"""
|
"""Drop a caller from the queue"""
|
||||||
caller_service.remove_from_queue(call_sid)
|
caller_service.remove_from_queue(caller_id)
|
||||||
|
await caller_service.disconnect_caller(caller_id)
|
||||||
# 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"}
|
return {"status": "dropped"}
|
||||||
|
|
||||||
|
|
||||||
# --- Twilio WebSocket Media Stream ---
|
async def _handle_real_caller_transcription(caller_id: str, pcm_data: bytes, sample_rate: int):
|
||||||
|
|
||||||
@app.websocket("/api/twilio/stream")
|
|
||||||
async def twilio_media_stream(websocket: WebSocket):
|
|
||||||
"""Handle Twilio Media Streams WebSocket — bidirectional audio"""
|
|
||||||
await websocket.accept()
|
|
||||||
print("[Twilio WS] Media stream connected")
|
|
||||||
|
|
||||||
call_sid = None
|
|
||||||
stream_sid = None
|
|
||||||
audio_buffer = bytearray()
|
|
||||||
CHUNK_DURATION_S = 3 # Transcribe every 3 seconds of audio
|
|
||||||
MULAW_SAMPLE_RATE = 8000
|
|
||||||
chunk_samples = CHUNK_DURATION_S * MULAW_SAMPLE_RATE
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await websocket.receive_text()
|
|
||||||
msg = json.loads(data)
|
|
||||||
event = msg.get("event")
|
|
||||||
|
|
||||||
if event == "start":
|
|
||||||
stream_sid = msg["start"]["streamSid"]
|
|
||||||
call_sid = msg["start"]["callSid"]
|
|
||||||
caller_service.register_websocket(call_sid, websocket)
|
|
||||||
if call_sid in caller_service.active_calls:
|
|
||||||
caller_service.active_calls[call_sid]["stream_sid"] = stream_sid
|
|
||||||
print(f"[Twilio WS] Stream started: {stream_sid} for call {call_sid}")
|
|
||||||
|
|
||||||
elif event == "media":
|
|
||||||
# Decode mulaw audio from base64
|
|
||||||
payload = base64.b64decode(msg["media"]["payload"])
|
|
||||||
# Convert mulaw to 16-bit PCM
|
|
||||||
pcm_data = audioop.ulaw2lin(payload, 2)
|
|
||||||
audio_buffer.extend(pcm_data)
|
|
||||||
|
|
||||||
# Get channel for this caller
|
|
||||||
call_info = caller_service.active_calls.get(call_sid)
|
|
||||||
if call_info:
|
|
||||||
channel = call_info["channel"]
|
|
||||||
# Route PCM to the caller's dedicated Loopback channel
|
|
||||||
audio_service.route_real_caller_audio(pcm_data, channel, MULAW_SAMPLE_RATE)
|
|
||||||
|
|
||||||
# When we have enough audio, transcribe
|
|
||||||
if len(audio_buffer) >= chunk_samples * 2: # 2 bytes per sample
|
|
||||||
pcm_chunk = bytes(audio_buffer[:chunk_samples * 2])
|
|
||||||
audio_buffer = audio_buffer[chunk_samples * 2:]
|
|
||||||
|
|
||||||
# Transcribe in background
|
|
||||||
asyncio.create_task(
|
|
||||||
_handle_real_caller_transcription(call_sid, pcm_chunk, MULAW_SAMPLE_RATE)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif event == "stop":
|
|
||||||
print(f"[Twilio WS] Stream stopped: {stream_sid}")
|
|
||||||
break
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
print(f"[Twilio WS] Disconnected: {call_sid}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Twilio WS] Error: {e}")
|
|
||||||
finally:
|
|
||||||
if call_sid:
|
|
||||||
caller_service.unregister_websocket(call_sid)
|
|
||||||
# Transcribe any remaining audio
|
|
||||||
if audio_buffer and call_sid:
|
|
||||||
asyncio.create_task(
|
|
||||||
_handle_real_caller_transcription(call_sid, bytes(audio_buffer), MULAW_SAMPLE_RATE)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_real_caller_transcription(call_sid: str, pcm_data: bytes, sample_rate: int):
|
|
||||||
"""Transcribe a chunk of real caller audio and add to conversation"""
|
"""Transcribe a chunk of real caller audio and add to conversation"""
|
||||||
call_info = caller_service.active_calls.get(call_sid)
|
call_info = caller_service.active_calls.get(caller_id)
|
||||||
if not call_info:
|
if not call_info:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1017,7 +897,7 @@ async def hangup_real_caller():
|
|||||||
if not session.active_real_caller:
|
if not session.active_real_caller:
|
||||||
raise HTTPException(400, "No active real caller")
|
raise HTTPException(400, "No active real caller")
|
||||||
|
|
||||||
call_sid = session.active_real_caller["call_sid"]
|
caller_id = session.active_real_caller["caller_id"]
|
||||||
caller_name = session.active_real_caller["name"]
|
caller_name = session.active_real_caller["name"]
|
||||||
|
|
||||||
# Summarize the conversation
|
# Summarize the conversation
|
||||||
@@ -1039,16 +919,9 @@ async def hangup_real_caller():
|
|||||||
transcript=list(session.conversation),
|
transcript=list(session.conversation),
|
||||||
))
|
))
|
||||||
|
|
||||||
# End the Twilio call
|
# Disconnect the caller
|
||||||
caller_service.hangup(call_sid)
|
caller_service.hangup(caller_id)
|
||||||
|
await caller_service.disconnect_caller(caller_id)
|
||||||
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: {e}")
|
|
||||||
|
|
||||||
session.active_real_caller = None
|
session.active_real_caller = None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user