Add SignalWire endpoints, update queue/hangup for phone callers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
169
backend/main.py
169
backend/main.py
@@ -4,11 +4,12 @@ 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, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import httpx
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -416,9 +417,48 @@ async def index():
|
|||||||
return FileResponse(frontend_dir / "index.html")
|
return FileResponse(frontend_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/call-in")
|
# --- SignalWire Endpoints ---
|
||||||
async def call_in_page():
|
|
||||||
return FileResponse(frontend_dir / "call-in.html")
|
@app.post("/api/signalwire/voice")
|
||||||
|
async def signalwire_voice_webhook(request: Request):
|
||||||
|
"""Handle inbound call from SignalWire — return XML to start bidirectional stream"""
|
||||||
|
form = await request.form()
|
||||||
|
caller_phone = form.get("From", "Unknown")
|
||||||
|
call_sid = form.get("CallSid", "")
|
||||||
|
print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})")
|
||||||
|
|
||||||
|
ws_scheme = "wss"
|
||||||
|
host = request.headers.get("host", "radioshow.macneilmediagroup.com")
|
||||||
|
stream_url = f"{ws_scheme}://{host}/api/signalwire/stream"
|
||||||
|
|
||||||
|
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Response>
|
||||||
|
<Connect>
|
||||||
|
<Stream url="{stream_url}" codec="L16@16000h">
|
||||||
|
<Parameter name="caller_phone" value="{caller_phone}"/>
|
||||||
|
<Parameter name="call_sid" value="{call_sid}"/>
|
||||||
|
</Stream>
|
||||||
|
</Connect>
|
||||||
|
</Response>"""
|
||||||
|
|
||||||
|
return Response(content=xml, media_type="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
async def _signalwire_end_call(call_sid: str):
|
||||||
|
"""End a phone call via SignalWire REST API"""
|
||||||
|
if not call_sid or not settings.signalwire_space:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
url = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}/Calls/{call_sid}"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
data={"Status": "completed"},
|
||||||
|
auth=(settings.signalwire_project_id, settings.signalwire_token),
|
||||||
|
)
|
||||||
|
print(f"[SignalWire] End call {call_sid}: {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SignalWire] Failed to end call {call_sid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
# --- Request Models ---
|
# --- Request Models ---
|
||||||
@@ -802,60 +842,54 @@ async def update_settings(data: dict):
|
|||||||
return llm_service.get_settings()
|
return llm_service.get_settings()
|
||||||
|
|
||||||
|
|
||||||
# --- Browser Caller WebSocket ---
|
@app.websocket("/api/signalwire/stream")
|
||||||
|
async def signalwire_audio_stream(websocket: WebSocket):
|
||||||
@app.websocket("/api/caller/stream")
|
"""Handle SignalWire bidirectional audio stream"""
|
||||||
async def caller_audio_stream(websocket: WebSocket):
|
|
||||||
"""Handle browser caller WebSocket — bidirectional audio"""
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
caller_id = str(uuid.uuid4())[:8]
|
caller_id = str(uuid.uuid4())[:8]
|
||||||
caller_name = "Anonymous"
|
caller_phone = "Unknown"
|
||||||
|
call_sid = ""
|
||||||
audio_buffer = bytearray()
|
audio_buffer = bytearray()
|
||||||
CHUNK_DURATION_S = 3
|
CHUNK_DURATION_S = 3
|
||||||
SAMPLE_RATE = 16000
|
SAMPLE_RATE = 16000
|
||||||
chunk_samples = CHUNK_DURATION_S * SAMPLE_RATE
|
chunk_samples = CHUNK_DURATION_S * SAMPLE_RATE
|
||||||
|
stream_started = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Wait for join message
|
|
||||||
join_data = await websocket.receive_text()
|
|
||||||
join_msg = json.loads(join_data)
|
|
||||||
if join_msg.get("type") == "join":
|
|
||||||
caller_name = join_msg.get("name", "Anonymous").strip() or "Anonymous"
|
|
||||||
|
|
||||||
# Add to queue
|
|
||||||
caller_service.add_to_queue(caller_id, caller_name)
|
|
||||||
caller_service.register_websocket(caller_id, websocket)
|
|
||||||
|
|
||||||
# Notify caller they're queued
|
|
||||||
queue = caller_service.get_queue()
|
|
||||||
position = next((i+1 for i, c in enumerate(queue) if c["caller_id"] == caller_id), 0)
|
|
||||||
await websocket.send_text(json.dumps({
|
|
||||||
"status": "queued",
|
|
||||||
"caller_id": caller_id,
|
|
||||||
"position": position,
|
|
||||||
}))
|
|
||||||
|
|
||||||
# Main loop — handle both text and binary messages
|
|
||||||
while True:
|
while True:
|
||||||
message = await websocket.receive()
|
raw = await websocket.receive_text()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
event = msg.get("event")
|
||||||
|
|
||||||
if message.get("type") == "websocket.disconnect":
|
if event == "start":
|
||||||
break
|
custom = msg.get("start", {}).get("customParameters", {})
|
||||||
|
caller_phone = custom.get("caller_phone", "Unknown")
|
||||||
|
call_sid = custom.get("call_sid", "")
|
||||||
|
|
||||||
|
stream_started = True
|
||||||
|
print(f"[SignalWire WS] Stream started: {caller_phone} (CallSid: {call_sid})")
|
||||||
|
|
||||||
|
caller_service.add_to_queue(caller_id, caller_phone)
|
||||||
|
caller_service.register_websocket(caller_id, websocket)
|
||||||
|
if call_sid:
|
||||||
|
caller_service.register_call_sid(caller_id, call_sid)
|
||||||
|
|
||||||
|
elif event == "media" and stream_started:
|
||||||
|
import base64
|
||||||
|
payload = msg.get("media", {}).get("payload", "")
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pcm_data = base64.b64decode(payload)
|
||||||
|
|
||||||
if "bytes" in message and message["bytes"]:
|
|
||||||
# Binary audio data — only process if caller is on air
|
|
||||||
call_info = caller_service.active_calls.get(caller_id)
|
call_info = caller_service.active_calls.get(caller_id)
|
||||||
if not call_info:
|
if not call_info:
|
||||||
continue # Still in queue, ignore audio
|
continue
|
||||||
|
|
||||||
pcm_data = message["bytes"]
|
|
||||||
audio_buffer.extend(pcm_data)
|
audio_buffer.extend(pcm_data)
|
||||||
|
|
||||||
# Route to configured live caller Loopback channel
|
|
||||||
audio_service.route_real_caller_audio(pcm_data, SAMPLE_RATE)
|
audio_service.route_real_caller_audio(pcm_data, SAMPLE_RATE)
|
||||||
|
|
||||||
# Transcribe when we have enough audio
|
|
||||||
if len(audio_buffer) >= chunk_samples * 2:
|
if len(audio_buffer) >= chunk_samples * 2:
|
||||||
pcm_chunk = bytes(audio_buffer[:chunk_samples * 2])
|
pcm_chunk = bytes(audio_buffer[:chunk_samples * 2])
|
||||||
audio_buffer = audio_buffer[chunk_samples * 2:]
|
audio_buffer = audio_buffer[chunk_samples * 2:]
|
||||||
@@ -863,24 +897,24 @@ async def caller_audio_stream(websocket: WebSocket):
|
|||||||
_handle_real_caller_transcription(caller_id, pcm_chunk, SAMPLE_RATE)
|
_handle_real_caller_transcription(caller_id, pcm_chunk, SAMPLE_RATE)
|
||||||
)
|
)
|
||||||
|
|
||||||
elif "text" in message and message["text"]:
|
elif event == "stop":
|
||||||
# Control messages (future use)
|
print(f"[SignalWire WS] Stream stopped: {caller_phone}")
|
||||||
pass
|
break
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
print(f"[Caller WS] Disconnected: {caller_id} ({caller_name})")
|
print(f"[SignalWire WS] Disconnected: {caller_id} ({caller_phone})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Caller WS] Error: {e}")
|
print(f"[SignalWire WS] Error: {e}")
|
||||||
finally:
|
finally:
|
||||||
caller_service.unregister_websocket(caller_id)
|
caller_service.unregister_websocket(caller_id)
|
||||||
# If still in queue, remove
|
caller_service.unregister_call_sid(caller_id)
|
||||||
caller_service.remove_from_queue(caller_id)
|
caller_service.remove_from_queue(caller_id)
|
||||||
# If on air, clean up
|
|
||||||
if caller_id in caller_service.active_calls:
|
if caller_id in caller_service.active_calls:
|
||||||
caller_service.hangup(caller_id)
|
caller_service.hangup(caller_id)
|
||||||
if session.active_real_caller and session.active_real_caller.get("caller_id") == caller_id:
|
if session.active_real_caller and session.active_real_caller.get("caller_id") == caller_id:
|
||||||
session.active_real_caller = None
|
session.active_real_caller = None
|
||||||
# Transcribe remaining audio
|
if len(caller_service.active_calls) == 0:
|
||||||
|
audio_service.stop_host_stream()
|
||||||
if audio_buffer:
|
if audio_buffer:
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_handle_real_caller_transcription(caller_id, bytes(audio_buffer), SAMPLE_RATE)
|
_handle_real_caller_transcription(caller_id, bytes(audio_buffer), SAMPLE_RATE)
|
||||||
@@ -945,12 +979,9 @@ async def take_call_from_queue(caller_id: str):
|
|||||||
session.active_real_caller = {
|
session.active_real_caller = {
|
||||||
"caller_id": call_info["caller_id"],
|
"caller_id": call_info["caller_id"],
|
||||||
"channel": call_info["channel"],
|
"channel": call_info["channel"],
|
||||||
"name": call_info["name"],
|
"phone": call_info["phone"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Notify caller they're on air via WebSocket
|
|
||||||
await caller_service.notify_caller(caller_id, {"status": "on_air", "channel": call_info["channel"]})
|
|
||||||
|
|
||||||
# Start host mic streaming if this is the first real caller
|
# Start host mic streaming if this is the first real caller
|
||||||
if len(caller_service.active_calls) == 1:
|
if len(caller_service.active_calls) == 1:
|
||||||
_start_host_audio_sender()
|
_start_host_audio_sender()
|
||||||
@@ -965,8 +996,10 @@ async def take_call_from_queue(caller_id: str):
|
|||||||
@app.post("/api/queue/drop/{caller_id}")
|
@app.post("/api/queue/drop/{caller_id}")
|
||||||
async def drop_from_queue(caller_id: str):
|
async def drop_from_queue(caller_id: str):
|
||||||
"""Drop a caller from the queue"""
|
"""Drop a caller from the queue"""
|
||||||
|
call_sid = caller_service.get_call_sid(caller_id)
|
||||||
caller_service.remove_from_queue(caller_id)
|
caller_service.remove_from_queue(caller_id)
|
||||||
await caller_service.disconnect_caller(caller_id)
|
if call_sid:
|
||||||
|
await _signalwire_end_call(call_sid)
|
||||||
return {"status": "dropped"}
|
return {"status": "dropped"}
|
||||||
|
|
||||||
|
|
||||||
@@ -980,18 +1013,18 @@ async def _handle_real_caller_transcription(caller_id: str, pcm_data: bytes, sam
|
|||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
caller_name = call_info["name"]
|
caller_phone = call_info["phone"]
|
||||||
print(f"[Real Caller] {caller_name}: {text}")
|
print(f"[Real Caller] {caller_phone}: {text}")
|
||||||
|
|
||||||
# Add to conversation with real_caller role
|
# Add to conversation with real_caller role
|
||||||
session.add_message(f"real_caller:{caller_name}", text)
|
session.add_message(f"real_caller:{caller_phone}", text)
|
||||||
|
|
||||||
# If AI auto-respond mode is on and an AI caller is active, check if AI should respond
|
# If AI auto-respond mode is on and an AI caller is active, check if AI should respond
|
||||||
if session.ai_respond_mode == "auto" and session.current_caller_key:
|
if session.ai_respond_mode == "auto" and session.current_caller_key:
|
||||||
asyncio.create_task(_check_ai_auto_respond(text, caller_name))
|
asyncio.create_task(_check_ai_auto_respond(text, caller_phone))
|
||||||
|
|
||||||
|
|
||||||
async def _check_ai_auto_respond(real_caller_text: str, real_caller_name: str):
|
async def _check_ai_auto_respond(real_caller_text: str, real_caller_phone: str):
|
||||||
"""Check if AI caller should jump in, and generate response if so"""
|
"""Check if AI caller should jump in, and generate response if so"""
|
||||||
if not session.caller:
|
if not session.caller:
|
||||||
return
|
return
|
||||||
@@ -1060,13 +1093,15 @@ async def hangup_real_caller():
|
|||||||
raise HTTPException(400, "No active real caller")
|
raise HTTPException(400, "No active real caller")
|
||||||
|
|
||||||
caller_id = session.active_real_caller["caller_id"]
|
caller_id = session.active_real_caller["caller_id"]
|
||||||
caller_name = session.active_real_caller["name"]
|
caller_phone = session.active_real_caller["phone"]
|
||||||
conversation_snapshot = list(session.conversation)
|
conversation_snapshot = list(session.conversation)
|
||||||
auto_followup_enabled = session.auto_followup
|
auto_followup_enabled = session.auto_followup
|
||||||
|
|
||||||
# Disconnect the caller immediately
|
# End the phone call via SignalWire
|
||||||
|
call_sid = caller_service.get_call_sid(caller_id)
|
||||||
caller_service.hangup(caller_id)
|
caller_service.hangup(caller_id)
|
||||||
await caller_service.disconnect_caller(caller_id)
|
if call_sid:
|
||||||
|
asyncio.create_task(_signalwire_end_call(call_sid))
|
||||||
|
|
||||||
# Stop host streaming if no more active callers
|
# Stop host streaming if no more active callers
|
||||||
if len(caller_service.active_calls) == 0:
|
if len(caller_service.active_calls) == 0:
|
||||||
@@ -1074,24 +1109,22 @@ async def hangup_real_caller():
|
|||||||
|
|
||||||
session.active_real_caller = None
|
session.active_real_caller = None
|
||||||
|
|
||||||
# Play hangup sound in background
|
|
||||||
import threading
|
import threading
|
||||||
hangup_sound = settings.sounds_dir / "hangup.wav"
|
hangup_sound = settings.sounds_dir / "hangup.wav"
|
||||||
if hangup_sound.exists():
|
if hangup_sound.exists():
|
||||||
threading.Thread(target=audio_service.play_sfx, args=(str(hangup_sound),), daemon=True).start()
|
threading.Thread(target=audio_service.play_sfx, args=(str(hangup_sound),), daemon=True).start()
|
||||||
|
|
||||||
# Summarize and store history in background
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_summarize_real_call(caller_name, conversation_snapshot, auto_followup_enabled)
|
_summarize_real_call(caller_phone, conversation_snapshot, auto_followup_enabled)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "disconnected",
|
"status": "disconnected",
|
||||||
"caller": caller_name,
|
"caller": caller_phone,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _summarize_real_call(caller_name: str, conversation: list, auto_followup_enabled: bool):
|
async def _summarize_real_call(caller_phone: str, conversation: list, auto_followup_enabled: bool):
|
||||||
"""Background task: summarize call and store in history"""
|
"""Background task: summarize call and store in history"""
|
||||||
summary = ""
|
summary = ""
|
||||||
if conversation:
|
if conversation:
|
||||||
@@ -1105,11 +1138,11 @@ async def _summarize_real_call(caller_name: str, conversation: list, auto_follow
|
|||||||
|
|
||||||
session.call_history.append(CallRecord(
|
session.call_history.append(CallRecord(
|
||||||
caller_type="real",
|
caller_type="real",
|
||||||
caller_name=caller_name,
|
caller_name=caller_phone,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
transcript=conversation,
|
transcript=conversation,
|
||||||
))
|
))
|
||||||
print(f"[Real Caller] {caller_name} call summarized: {summary[:80]}...")
|
print(f"[Real Caller] {caller_phone} call summarized: {summary[:80]}...")
|
||||||
|
|
||||||
if auto_followup_enabled:
|
if auto_followup_enabled:
|
||||||
await _auto_followup(summary)
|
await _auto_followup(summary)
|
||||||
|
|||||||
Reference in New Issue
Block a user