""" 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:@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, )