Files
hold-slayer/main.py
Robert Helewka 4a3c14d4af
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering
authentication surfaces, outbound-call safety invariants, and other project
conventions to guide Claude's understanding of critical system behaviors.
2026-07-28 19:01:38 -04:00

626 lines
23 KiB
Python

"""
Hold Slayer Gateway — FastAPI Application Entry Point.
Your personal AI-powered telephony platform.
Navigates IVRs, waits on hold, and connects you when a human answers.
Usage:
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# Or directly:
python main.py
"""
import logging
import secrets
import sys
import time
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from api import auth as auth_router
from api import call_flows, call_history, calls, devices, routing, tokens, websocket
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
from config import Settings, get_settings
from core.gateway import AIPSTNGateway, build_sip_engine
from db.database import close_db, init_db, session_scope
from mcp_server.server import create_mcp_server
from models.call import CallMode
from services.audio_classifier import AudioClassifier
from services.call_persistence import persist_call_on_create, persist_call_on_end
from services.hold_slayer import HoldSlayerService
from services.notification import NotificationService
from services.receptionist import ReceptionistService
from services.recording import RecordingService
from services.routing import RoutingService
from services.transcription import TranscriptionService
from services.tts import TTSService
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
def _handle_db_error(exc: Exception) -> None:
"""Log a clear, human-readable database error and exit cleanly."""
# Walk the exception chain to find the root asyncpg/psycopg cause
cause = getattr(exc, "__cause__", None) or getattr(exc, "__context__", None)
root = cause or exc
root_type = type(root).__name__
root_msg = str(root)
if "InvalidPasswordError" in root_type or "password authentication failed" in root_msg:
logger.critical(
"\n"
"❌ Database authentication failed — wrong password.\n"
" The password in DATABASE_URL does not match the PostgreSQL user.\n"
" Fix DATABASE_URL in your .env file and restart.\n"
" Default: DATABASE_URL=postgresql+asyncpg://holdslayer:changeme@localhost:5432/holdslayer"
)
elif "InvalidCatalogNameError" in root_type or "does not exist" in root_msg:
logger.critical(
"\n"
"❌ Database does not exist.\n"
" Create it first: createdb holdslayer\n"
" Or update DATABASE_URL in your .env file."
)
elif (
"Connection refused" in root_msg
or "could not connect" in root_msg.lower()
):
logger.critical(
"\n"
"\u274c Cannot reach PostgreSQL \u2014 connection refused.\n"
" Is PostgreSQL running? Check DATABASE_URL in your .env file."
)
elif (
"nodename nor servname" in root_msg
or "Name or service not known" in root_msg
):
logger.critical(
"\n"
f"❌ Cannot resolve the database hostname.\n"
f" Check the host in DATABASE_URL in your .env file. (detail: {root_msg})"
)
else:
logger.critical(
f"\n❌ Database initialisation failed: {root_msg}\n"
f" Check DATABASE_URL in your .env file."
)
sys.exit(1)
def _check_startup_config(settings: Settings) -> None:
"""Refuse insecure or incomplete configurations before booting anything."""
if not settings.database_url:
logger.critical(
"\n"
"❌ DATABASE_URL is not set.\n"
" Add it to your .env file, e.g.:\n"
" DATABASE_URL=postgresql+asyncpg://holdslayer:<password>@localhost:5432/holdslayer"
)
sys.exit(1)
loopback = settings.host in ("127.0.0.1", "localhost", "::1")
if settings.casdoor.enabled:
c = settings.casdoor
missing = [
name
for name, val in (
("CASDOOR_ENDPOINT", c.endpoint),
("CASDOOR_CLIENT_ID", c.client_id),
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
("OWNER_NAME", settings.owner_name),
)
if not val
]
if missing:
logger.critical(
"\n"
"❌ CASDOOR_ENABLED=true but required settings are missing:\n"
f" {', '.join(missing)}\n"
" Set them in .env (Casdoor app credentials + the owner's "
"Casdoor username), or set CASDOOR_ENABLED=false with HOST=127.0.0.1 "
"for tokenless local development."
)
sys.exit(1)
elif not loopback:
logger.critical(
"\n"
"❌ CASDOOR_ENABLED=false but HOST binds beyond loopback "
f"({settings.host}).\n"
" Every surface (REST, WebSocket, MCP make_call) would resolve "
"to the dev owner — open to the network.\n"
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
"or set HOST=127.0.0.1 for tokenless local development."
)
sys.exit(1)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: Initialize database, SIP engine, and services."""
settings = get_settings()
_check_startup_config(settings)
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
# the network round-trip (no-op when SSO is disabled).
init_jwks_client()
# The MCP session manager lives in the mounted sub-app's lifespan;
# without entering it, every /mcp request 500s.
async with mcp_http_app.lifespan(app):
# Initialize database
logger.info("Initializing database...")
try:
await init_db()
except Exception as e:
_handle_db_error(e)
# === Composition root ===
# Build the gateway and every service here, wiring them by
# constructor/registration — nothing constructs its own deps.
gateway = AIPSTNGateway(
settings=settings,
on_call_created=persist_call_on_create,
on_call_ended=persist_call_on_end,
)
classifier = AudioClassifier(settings.classifier)
transcription = TranscriptionService(settings.speaches)
tts = TTSService(settings.tts)
routing_svc = RoutingService(gateway)
recording_svc = RecordingService()
receptionist = ReceptionistService(
gateway,
tts=tts,
transcription=transcription,
recording=recording_svc,
routing=routing_svc,
)
gateway.attach_services(tts=tts)
def launch_hold_slayer(call, sip_leg_id, call_flow_id):
svc = HoldSlayerService(
gateway=gateway,
call_manager=gateway.call_manager,
sip_engine=gateway.sip_engine,
classifier=classifier,
transcription=transcription,
settings=settings,
tts=tts,
)
gateway.spawn(
svc.run(call, sip_leg_id, call_flow_id),
name=f"holdslayer_{call.id}",
)
gateway.register_mode_handler(CallMode.HOLD_SLAYER, launch_hold_slayer)
try:
gateway.sip_engine = build_sip_engine(
settings,
gateway.media_pipeline,
on_leg_state_change=gateway._on_sip_leg_state,
on_device_registered=gateway._on_sip_device_registered,
on_incoming_call=receptionist.on_inbound_call,
)
except Exception as e:
logger.critical(f"\n❌ SIP engine failed to initialize:\n {e}")
sys.exit(1)
await routing_svc.start()
await gateway.start()
app.state.gateway = gateway
app.state.routing_service = routing_svc
app.state.transcription_service = transcription
notification_svc = NotificationService(gateway.event_bus, settings)
await notification_svc.start()
app.state.notification_service = notification_svc
await recording_svc.start()
app.state.recording_service = recording_svc
logger.info("=" * 60)
logger.info("🔥 Hold Slayer Gateway is LIVE")
# Show a usable URL — 0.0.0.0 is the bind address, not a browser URL
display_host = "localhost" if settings.host in ("0.0.0.0", "::") else settings.host
# When launched via `uvicorn main:app --port XXXX`, the CLI --port arg
# takes precedence over settings.port (which comes from .env).
display_port = settings.port
for i, arg in enumerate(sys.argv):
if arg in ("--port", "-p") and i + 1 < len(sys.argv):
try:
display_port = int(sys.argv[i + 1])
except ValueError:
pass
auth_state = (
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
if settings.casdoor.enabled
else "dev-owner (loopback, no auth)"
)
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
logger.info(f" MCP: http://{display_host}:{display_port}/mcp/ (streamable HTTP)")
logger.info("=" * 60)
yield
# Shutdown
logger.info("Shutting down Hold Slayer Gateway...")
await notification_svc.stop()
await gateway.stop()
await close_db()
logger.info("Gateway shut down cleanly. 👋")
def _get_gateway_instance() -> AIPSTNGateway | None:
"""Lazy gateway resolver for MCP tools (set on app.state by the lifespan)."""
return getattr(app.state, "gateway", None)
mcp = create_mcp_server(_get_gateway_instance)
mcp_http_app = mcp.http_app(path="/")
def _public_base_url(scope_or_request) -> str:
"""Resolve this service's public base URL (scheme + host), no trailing slash.
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
discovery endpoints share one implementation.
"""
settings = get_settings()
if settings.public_base_url:
return settings.public_base_url.rstrip("/")
if hasattr(scope_or_request, "headers"):
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
else:
headers = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope_or_request.get("headers", [])
}
default_scheme = scope_or_request.get("scheme", "http")
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
host = host.split(",", 1)[0].strip()
return f"{proto}://{host}"
def _owner_only_mcp(inner_app):
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
against a fresh DB session, and short-circuit non-owner requests with
401/403. In dev mode this resolves to the dev owner, so local development
keeps working without a token.
"""
async def _send_status(send, scope, status: int, body: bytes) -> None:
base = _public_base_url(scope)
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(
b"www-authenticate",
f'Bearer realm="hold-slayer-mcp", '
f'resource_metadata="{resource_metadata_url}"'.encode(),
),
],
}
)
await send({"type": "http.response.body", "body": body})
async def app(scope, receive, send):
if scope["type"] != "http":
await inner_app(scope, receive, send)
return
authorization = None
for name, value in scope.get("headers", []):
if name == b"authorization":
authorization = value.decode("latin-1")
break
async with session_scope() as session:
user = await resolve_from_header_or_query(session, authorization, None)
if user is None:
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
return
if not is_owner(user):
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
return
await inner_app(scope, receive, send)
return app
app = FastAPI(
title="Hold Slayer Gateway",
description=(
"🗡️ AI PSTN Gateway — Navigate IVRs, wait on hold, "
"and connect you when a human answers.\n\n"
"## Quick Start\n"
"1. **POST /api/v1/calls/hold-slayer** — Launch the Hold Slayer\n"
"2. **GET /api/v1/calls/{call_id}** — Check call status\n"
"3. **WS /ws/events** — Real-time event stream\n"
"4. **GET /api/v1/call-flows** — Manage stored IVR trees\n"
),
version="0.1.0",
lifespan=lifespan,
)
# === API Routes ===
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
# call_history must register before calls: both live under /api/v1/calls and
# calls' GET /{call_id} would otherwise capture the literal path "history".
_auth = [Depends(get_current_owner)]
app.include_router(auth_router.router)
app.include_router(tokens.router, dependencies=_auth)
app.include_router(
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
)
app.include_router(calls.router, prefix="/api/v1/calls", tags=["Calls"], dependencies=_auth)
app.include_router(
call_flows.router, prefix="/api/v1/call-flows", tags=["Call Flows"], dependencies=_auth
)
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
# WebSocket endpoints check the owner themselves (query param or header)
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
# The ASGI guard resolves the bearer to the owner before the inner app runs.
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
# clients re-register each session; the real gate is the bearer token.
_registered_clients: dict[str, dict] = {}
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
async def oauth_protected_resource_metadata(request: Request):
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
``mcp-remote`` versions verify it matches the URL they connected to.
"""
base = _public_base_url(request)
return JSONResponse(
{
"resource": f"{base}/mcp",
"authorization_servers": [base],
"bearer_methods_supported": ["header"],
"resource_documentation": f"{base}/docs",
}
)
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
async def oauth_authorization_server_metadata(request: Request):
"""RFC 8414 Authorization Server Metadata.
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
directly in their MCP configuration.
"""
base = _public_base_url(request)
settings = get_settings()
if settings.casdoor.enabled:
casdoor_base = settings.casdoor.endpoint.rstrip("/")
return JSONResponse(
{
"issuer": casdoor_base,
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"scopes_supported": ["openid", "profile", "email"],
}
)
return JSONResponse(
{
"issuer": base,
"authorization_endpoint": f"{base}/auth/login",
"token_endpoint": f"{base}/auth/callback",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
}
)
@app.post("/register", include_in_schema=False)
async def oauth_dynamic_registration(request: Request):
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
Registered clients are held in memory (ephemeral); the real security gate
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
"""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "invalid_client_metadata",
"error_description": "Request body must be valid JSON.",
},
)
redirect_uris = body.get("redirect_uris")
if not redirect_uris or not isinstance(redirect_uris, list):
return JSONResponse(
status_code=400,
content={
"error": "invalid_redirect_uri",
"error_description": "redirect_uris is required and must be a non-empty list.",
},
)
client_id = secrets.token_hex(16)
now = int(time.time())
_registered_clients[client_id] = {
"client_id": client_id,
"client_id_issued_at": now,
"redirect_uris": redirect_uris,
"grant_types": body.get("grant_types", ["authorization_code"]),
"response_types": body.get("response_types", ["code"]),
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
"client_name": body.get("client_name"),
"scope": body.get("scope"),
}
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
return JSONResponse(
status_code=201,
content={
"client_id": client_id,
"client_id_issued_at": now,
"redirect_uris": redirect_uris,
"grant_types": _registered_clients[client_id]["grant_types"],
"response_types": _registered_clients[client_id]["response_types"],
"token_endpoint_auth_method": _registered_clients[client_id][
"token_endpoint_auth_method"
],
},
)
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
async def api_status():
"""Gateway status summary for the dashboard header."""
gateway = getattr(app.state, "gateway", None)
if gateway:
status = await gateway.status()
return {
"name": "Hold Slayer Gateway",
"version": "0.1.0",
"status": "running",
"uptime": status["uptime"],
"active_calls": status["active_calls"],
"trunk": status["trunk"],
}
return {
"name": "Hold Slayer Gateway",
"version": "0.1.0",
"status": "starting",
}
@app.get("/health", tags=["System"])
async def health():
"""
Health check. "healthy" means the gateway can actually do its job:
real engine, registered trunk, reachable database. A mock engine or
a failing dependency reports "degraded" with the reason visible.
"""
from core.sip_engine import MockSIPEngine
from db.database import session_scope
gateway = getattr(app.state, "gateway", None)
ready = gateway is not None and await gateway.sip_engine.is_ready()
trunk_status = await gateway.sip_engine.get_trunk_status() if gateway else {"registered": False}
engine_mode = (
"mock" if gateway is None or isinstance(gateway.sip_engine, MockSIPEngine)
else "sippy"
)
db_ok = False
db_error = None
try:
from sqlalchemy import text
async with session_scope() as session:
await session.execute(text("SELECT 1"))
db_ok = True
except Exception as e:
db_error = str(e)[:200]
healthy = (
ready
and db_ok
and engine_mode == "sippy"
and trunk_status.get("registered", False)
)
checks = {
"gateway": "ready" if gateway else "not initialized",
"engine": engine_mode,
"sip_engine": "ready" if ready else "not ready",
"database": "ok" if db_ok else f"error: {db_error}",
"sip_trunk": {
"registered": trunk_status.get("registered", False),
"host": trunk_status.get("host"),
"reason": trunk_status.get("reason"),
},
}
if gateway is not None:
tts = getattr(gateway, "_tts", None)
checks["tts"] = _availability(tts)
transcription = getattr(app.state, "transcription_service", None)
checks["stt"] = _availability(transcription)
return {"status": "healthy" if healthy else "degraded", **checks}
def _availability(service) -> str:
"""Last-known reachability of an HTTP leaf service."""
if service is None:
return "not attached"
available = getattr(service, "available", None)
if available is None:
return "unknown (no requests yet)"
return "ok" if available else "unreachable"
# === Dashboard (built SvelteKit static, served at the root) ===
# Registered last: a "/" mount matches every path, so the API, WS,
# health, and MCP routes above must come first.
import os as _os # noqa: E402
_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build")
if _os.path.isdir(_dashboard_build):
app.mount(
"/",
StaticFiles(directory=_dashboard_build, html=True),
name="dashboard",
)
if __name__ == "__main__":
import uvicorn
settings = get_settings()
uvicorn.run(
"main:app",
host=settings.host,
port=settings.port,
reload=settings.debug,
log_level=settings.log_level,
)