""" 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 sys from contextlib import asynccontextmanager from fastapi import Depends, FastAPI from fastapi.staticfiles import StaticFiles from api import call_flows, call_history, calls, devices, routing, websocket from api.deps import require_token from config import Settings, get_settings from core.gateway import AIPSTNGateway from db.database import close_db, init_db from mcp_server.server import create_mcp_server # 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) token = settings.api_token.get_secret_value() if not token and settings.host not in ("127.0.0.1", "localhost", "::1"): logger.critical( "\n" "❌ API_TOKEN is not set but HOST binds beyond loopback " f"({settings.host}).\n" " Every surface (REST, WebSocket, MCP make_call) would be open " "to the network.\n" " Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), 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) # 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) # Boot the telephony engine gateway = AIPSTNGateway.from_config() await gateway.start() app.state.gateway = gateway # Start auxiliary services from services.notification import NotificationService from services.recording import RecordingService notification_svc = NotificationService(gateway.event_bus, settings) await notification_svc.start() app.state.notification_service = notification_svc recording_svc = RecordingService() await recording_svc.start() app.state.recording_service = recording_svc gateway._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 = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)" 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, api_token=get_settings().api_token.get_secret_value(), ) mcp_http_app = mcp.http_app(path="/") 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/calls/hold-slayer** — Launch the Hold Slayer\n" "2. **GET /api/calls/{call_id}** — Check call status\n" "3. **WS /ws/events** — Real-time event stream\n" "4. **GET /api/call-flows** — Manage stored IVR trees\n" ), version="0.1.0", lifespan=lifespan, ) # === API Routes === # call_history must register before calls: both live under /api/calls and # calls' GET /{call_id} would otherwise capture the literal path "history". _auth = [Depends(require_token)] app.include_router(call_history.router, prefix="/api/calls", tags=["Call History"], dependencies=_auth) app.include_router(calls.router, prefix="/api/calls", tags=["Calls"], dependencies=_auth) app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"], dependencies=_auth) app.include_router(devices.router, prefix="/api/devices", tags=["Devices"], dependencies=_auth) app.include_router(routing.router, prefix="/api/routing", tags=["Routing"], dependencies=_auth) # WebSocket endpoints check the token themselves (query param or header) app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"]) # === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) === app.mount("/mcp", mcp_http_app) # === Dashboard (built SvelteKit static) === import os as _os _dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build") if _os.path.isdir(_dashboard_build): app.mount( "/dashboard", StaticFiles(directory=_dashboard_build, html=True), name="dashboard", ) # === Root Endpoint === @app.get("/", tags=["System"]) async def root(): """Gateway root — health check and quick status.""" 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 endpoint.""" 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} return { "status": "healthy" if ready else "degraded", "gateway": "ready" if gateway else "not initialized", "sip_engine": "ready" if ready else "not ready", "sip_trunk": { "registered": trunk_status.get("registered", False), "host": trunk_status.get("host"), "mock": trunk_status.get("mock", False), "reason": trunk_status.get("reason"), }, } 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, )