""" 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 FastAPI from fastapi.staticfiles import StaticFiles from api import calls, call_flows, devices, websocket from config import 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) @asynccontextmanager async def lifespan(app: FastAPI): """Startup: Initialize database, SIP engine, and services.""" settings = get_settings() # 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 from services.call_analytics import CallAnalytics from services.call_flow_learner import CallFlowLearner 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 analytics_svc = CallAnalytics() app.state.analytics_service = analytics_svc flow_learner = CallFlowLearner() app.state.flow_learner = flow_learner # Create and mount MCP server mcp = create_mcp_server(gateway) app.state.mcp = mcp 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 logger.info(f" API: http://{display_host}:{display_port}") 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: Available via FastMCP") 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. 👋") 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 === app.include_router(calls.router, prefix="/api/calls", tags=["Calls"]) app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"]) app.include_router(devices.router, prefix="/api/devices", tags=["Devices"]) app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"]) # === 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, )