from __future__ import annotations import asyncio import logging import os from pathlib import Path from datetime import datetime, timezone from typing import Any import httpx from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from services.shared.models import HealthResponse app = FastAPI(title="api-gateway", version="1.0.0") logger = logging.getLogger("api-gateway.proxy") _ERROR_ALERT_SENT_AT: dict[str, float] = {} def _env_flag(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on"} def _env_int(name: str, default: int) -> int: value = os.getenv(name) if value is None: return default try: parsed = int(value) except ValueError: return default return max(1, parsed) def _error_alert_enabled() -> bool: return _env_flag("ERROR_TELEGRAM_ALERTS", False) def _error_alert_bot_token() -> str: return os.getenv("ERROR_TELEGRAM_ALERT_BOT_TOKEN", "").strip() def _normalize_telegram_chat_id(raw_id: str) -> str: value = raw_id.strip() if not value: return value lowered = value.lower() for prefix in ("https://t.me/", "http://t.me/", "t.me/"): if lowered.startswith(prefix): value = value[len(prefix):].strip() break if not value.startswith(("@", "-100")) and not value.isdigit(): value = f"@{value}" return value def _error_alert_chat_ids() -> list[str]: raw = os.getenv("ERROR_TELEGRAM_ALERT_CHAT_IDS", "").strip() if not raw: raw = os.getenv("ERROR_TELEGRAM_ALERT_CHANNEL_ID", "").strip() if not raw: raw = os.getenv("ERROR_TELEGRAM_ALERT_CHANNEL", "").strip() normalized = [_normalize_telegram_chat_id(item) for item in raw.split(",")] return [item for item in normalized if item] def _error_alert_min_status() -> int: return max(1, _env_int("ERROR_TELEGRAM_ALERT_MIN_STATUS", 500)) def _error_alert_cooldown_seconds() -> int: return _env_int("ERROR_TELEGRAM_ALERT_COOLDOWN_SECONDS", 30) def _error_alert_ignored_paths() -> tuple[str, ...]: raw = os.getenv("ERROR_TELEGRAM_ALERT_IGNORE_PATHS", "/health").strip() paths = tuple(item.strip() for item in raw.split(",") if item.strip()) return paths if paths else ("/health",) def _error_alert_service_name() -> str: return os.getenv("ERROR_TELEGRAM_ALERT_SERVICE", "api-gateway").strip() or "api-gateway" def _error_alert_bot_api_base() -> str: return os.getenv("ERROR_TELEGRAM_ALERT_BOT_API_BASE", "https://api.telegram.org").rstrip("/") def _error_alert_should_alert_path(path: str) -> bool: ignore = _error_alert_ignored_paths() return not any(path == item or path.startswith(f"{item}/") for item in ignore) def _error_alert_key(service: str, status: int, path: str, method: str) -> str: return f"{service}:{status}:{method}:{path}" def _error_alert_is_throttled(key: str) -> bool: now = datetime.now(tz=timezone.utc).timestamp() cooldown = _error_alert_cooldown_seconds() previous = _ERROR_ALERT_SENT_AT.get(key, 0.0) if now < previous: return True _ERROR_ALERT_SENT_AT[key] = now + cooldown return False async def _send_telegram_alert(message: str) -> None: chat_ids = _error_alert_chat_ids() bot_token = _error_alert_bot_token() if not bot_token or not chat_ids: return url = f"{_error_alert_bot_api_base()}/bot{bot_token}/sendMessage" payload = {"text": message} async with httpx.AsyncClient(timeout=3.0) as client: for chat_id in chat_ids: payload["chat_id"] = chat_id try: response = await client.post(url, json=payload) except httpx.RequestError: logger.exception("Unable to send Telegram error notification") return if response.status_code >= 300: logger.warning( "Telegram error notification failed: status=%s body=%s", response.status_code, response.text, ) def _queue_telegram_error_alert(service: str, method: str, path: str, status: int, detail: str) -> None: if not _error_alert_enabled(): return if not _error_alert_should_alert_path(path): return if not _error_alert_bot_token() or not _error_alert_chat_ids(): return if _error_alert_is_throttled(_error_alert_key(service, status, path, method)): return timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") message = ( f"🚨 [{service}] {status}\n" f"method: {method}\n" f"path: {path}\n" f"time: {timestamp}\n" f"detail: {detail}" ) try: loop = asyncio.get_running_loop() except RuntimeError: logger.warning("No running loop to queue telegram error notification") return loop.create_task(_send_telegram_alert(message)) _LEGAL_HTML = { "privacy": """ KonturAI Privacy Policy

KonturAI Privacy Policy

KonturAI processes customer communication data to route conversations, assist operators, and provide AI-supported support workflows across channels including WhatsApp.

Data We Process

How We Use Data

Contact

For privacy-related requests, contact ernurreen@gmail.com.

""", "terms": """ KonturAI Terms of Service

KonturAI Terms of Service

KonturAI provides customer communication tooling for internal support operations, including operator workspaces, AI-assisted workflows, and messaging integrations.

Use of Service

Availability

The service is provided on a best-effort basis and may be updated, improved, or temporarily interrupted for maintenance.

Contact

Questions about these terms can be sent to ernurreen@gmail.com.

""", "data-deletion": """ KonturAI Data Deletion

KonturAI Data Deletion Instructions

To request deletion of personal data processed through KonturAI, send an email to ernurreen@gmail.com with the subject line Data Deletion Request.

Please include

Requests are reviewed and processed within a reasonable timeframe subject to legal, security, and audit retention obligations.

""", } _ROOT = Path(__file__).resolve().parents[1] _LOGIN_UI_DIR = _ROOT / "ui" / "login" _OPERATOR_UI_DIR = _ROOT / "ui" / "operator" _SUPERVISOR_UI_DIR = _ROOT / "ui" / "supervisor" _ANALYST_UI_DIR = _ROOT / "ui" / "analyst" _ADMIN_UI_DIR = _ROOT / "ui" / "admin" _SALES_UI_DIR = _ROOT / "ui" / "sales" _IVR_PREVIEW_AUDIO_DIR = Path( os.getenv("IVR_PREVIEW_AUDIO_DIR", str(_ROOT / ".data_local" / "generated_ivr_yandex")) ).expanduser() if _LOGIN_UI_DIR.exists(): app.mount("/login/assets", StaticFiles(directory=str(_LOGIN_UI_DIR)), name="login_assets") if _OPERATOR_UI_DIR.exists(): app.mount("/operator/assets", StaticFiles(directory=str(_OPERATOR_UI_DIR)), name="operator_assets") if _SUPERVISOR_UI_DIR.exists(): app.mount("/supervisor/assets", StaticFiles(directory=str(_SUPERVISOR_UI_DIR)), name="supervisor_assets") if _ANALYST_UI_DIR.exists(): app.mount("/analyst/assets", StaticFiles(directory=str(_ANALYST_UI_DIR)), name="analyst_assets") if _ADMIN_UI_DIR.exists(): app.mount("/admin/assets", StaticFiles(directory=str(_ADMIN_UI_DIR)), name="admin_assets") if _SALES_UI_DIR.exists(): app.mount("/sales/assets", StaticFiles(directory=str(_SALES_UI_DIR)), name="sales_assets") if _IVR_PREVIEW_AUDIO_DIR.exists(): app.mount("/_preview_audio", StaticFiles(directory=str(_IVR_PREVIEW_AUDIO_DIR)), name="ivr_preview_audio") SERVICE_URLS = { "auth": os.getenv("AUTH_SERVICE_URL", "http://localhost:8001"), "audit": os.getenv("AUDIT_SERVICE_URL", "http://localhost:8002"), "customer": os.getenv("CUSTOMER_SERVICE_URL", "http://localhost:8003"), "interaction": os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004"), "routing": os.getenv("ROUTING_SERVICE_URL", "http://localhost:8005"), "voice": os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006"), "recording": os.getenv("RECORDING_SERVICE_URL", "http://localhost:8013"), "ivr": os.getenv("IVR_SERVICE_URL", "http://localhost:8014"), "event-bus": os.getenv("EVENT_BUS_SERVICE_URL", "http://localhost:8015"), "asterisk-bridge": os.getenv("ASTERISK_BRIDGE_SERVICE_URL", "http://localhost:8016"), "telegram": os.getenv("TELEGRAM_ADAPTER_SERVICE_URL", "http://localhost:8007"), "whatsapp": os.getenv("WHATSAPP_ADAPTER_SERVICE_URL", "http://localhost:8019"), "ai": os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017"), "ai-voice-runtime": os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018"), "webchat": os.getenv("WEBCHAT_ADAPTER_SERVICE_URL", "http://localhost:8011"), "email": os.getenv("EMAIL_ADAPTER_SERVICE_URL", "http://localhost:8012"), "kb": os.getenv("KB_SERVICE_URL", "http://localhost:8008"), "reporting": os.getenv("REPORTING_SERVICE_URL", "http://localhost:8009"), "supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"), "sales": os.getenv("SALES_SERVICE_URL", "http://localhost:8020"), } SERVICE_PATH_PREFIXES = { "sales": os.getenv("SALES_SERVICE_PATH_PREFIX", "/api/v1"), } SERVICE_PATH_PREFIX_EXEMPTIONS = { "sales": ("api/v1", "internal"), } def _resolve_service_path(service: str, path: str) -> str: normalized = path.lstrip("/") prefix = SERVICE_PATH_PREFIXES.get(service, "").strip("/") if not prefix: return normalized exemptions = SERVICE_PATH_PREFIX_EXEMPTIONS.get(service, ()) if any(normalized == item or normalized.startswith(f"{item}/") for item in exemptions): return normalized return f"{prefix}/{normalized}" if normalized else prefix @app.middleware("http") async def error_notification_middleware(request: Request, call_next): try: response = await call_next(request) except Exception as exc: status_code = 500 if isinstance(exc, HTTPException): status_code = exc.status_code if status_code >= _error_alert_min_status(): _queue_telegram_error_alert( service=_error_alert_service_name(), method=request.method, path=str(request.url.path), status=status_code, detail=f"{type(exc).__name__}: {exc}", ) raise if response.status_code >= _error_alert_min_status(): _queue_telegram_error_alert( service=_error_alert_service_name(), method=request.method, path=str(request.url.path), status=response.status_code, detail=f"HTTP {response.status_code} from {request.method} {request.url.path}", ) return response @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="api-gateway") @app.get("/") def login_ui() -> FileResponse: if not _LOGIN_UI_DIR.exists(): raise HTTPException(status_code=404, detail="Login UI not found") return FileResponse(_LOGIN_UI_DIR / "index.html") @app.get("/login") def login_alias() -> FileResponse: return login_ui() @app.get("/registry") def registry() -> dict: return {"services": SERVICE_URLS} @app.get("/operator/config") def operator_ui_config() -> dict[str, Any]: return { "features": { "whatsapp": _env_flag("OPERATOR_WHATSAPP_ENABLED", default=False), } } @app.get("/contracts") def contracts() -> dict: return { "stage_1": ["/auth/*", "/users/*", "/health", "/audit/events"], "stage_2": [ "/customers/*", "/interactions/*", "/queues/*", "/integrations/voice/events", "/integrations/telegram/webhook", "/integrations/whatsapp/webhook", "/integrations/webchat/messages", "/integrations/email/messages", ], "stage_3": [ "/knowledge/*", "/reports/kpi", "/reports/export", "/supervisor/realtime", ], "stage_4": [ "/recordings/*", ], "stage_5": [ "/ivr/flows", "/ivr/sessions", ], "stage_6": [ "/reports/agents/overview", "/reports/agents/timeseries", "/reports/coverage", "/reports/drilldown", "/reports/export", "/reports/facts/interactions", "/reports/kpi", "/reports/timeseries", "/reports/views", "/reports/views/*", ], "stage_8": [ "/bus/outbox", "/bus/outbox/*", ], "stage_9": [ "/asterisk/status", "/asterisk/events", "/asterisk/events/*", ], "stage_11": [ "/asterisk/live-calls", "/asterisk/live-calls/*", ], "stage_12": [ "/asterisk/recent-calls", ], "stage_14": [ "/asterisk/browser-softphone/config", ], "stage_15": [ "/integrations/telegram/bot/webhook", "/integrations/telegram/threads", "/integrations/telegram/threads/*", "/integrations/whatsapp/provider/webhook", "/integrations/whatsapp/threads", "/integrations/whatsapp/threads/*", ], "stage_16": [ "/ai/telegram/threads/*", "/ai/whatsapp/threads/*", "/ai/analytics/overview", "/ai/analytics/timeseries", "/ai/analytics/voice-name-flow/overview", "/ai/analytics/voice-name-flow/timeseries", "/ai/analytics/drilldown", "/ai/analytics/sessions/*", ], "stage_17": [ "/ai/voice/sessions/*", "/asterisk/live-calls/*/ai-summary", ], "stage_18": [ "/api/v1/leads", "/api/v1/deals", "/api/v1/messages/inbound-webhook", "/api/v1/calls/inbound-webhook", "/api/v1/deals/*/workspace", "/api/v1/payments/webhook", ], } @app.get("/operator") def operator_ui() -> FileResponse: if not _OPERATOR_UI_DIR.exists(): raise HTTPException(status_code=404, detail="Operator UI not found") return FileResponse(_OPERATOR_UI_DIR / "index.html") @app.get("/dashboard") def dashboard_ui() -> FileResponse: # Backward-compatible entrypoint used in production bookmarks. # Route dashboard directly to the sales cockpit so users can continue # their sales funnel workflow from legacy /dashboard links. return sales_ui() @app.get("/supervisor") def supervisor_ui() -> FileResponse: if not _SUPERVISOR_UI_DIR.exists(): raise HTTPException(status_code=404, detail="Supervisor UI not found") return FileResponse(_SUPERVISOR_UI_DIR / "index.html") @app.get("/analyst") def analyst_ui() -> FileResponse: if not _ANALYST_UI_DIR.exists(): raise HTTPException(status_code=404, detail="Analyst UI not found") return FileResponse(_ANALYST_UI_DIR / "index.html") @app.get("/admin") def admin_ui() -> FileResponse: if not _ADMIN_UI_DIR.exists(): raise HTTPException(status_code=404, detail="Admin UI not found") return FileResponse(_ADMIN_UI_DIR / "index.html") @app.get("/sales") def sales_ui() -> FileResponse: if not _SALES_UI_DIR.exists(): raise HTTPException(status_code=404, detail="Sales UI not found") return FileResponse(_SALES_UI_DIR / "index.html") @app.get("/privacy") def privacy_page() -> HTMLResponse: return HTMLResponse(_LEGAL_HTML["privacy"]) @app.get("/terms") def terms_page() -> HTMLResponse: return HTMLResponse(_LEGAL_HTML["terms"]) @app.get("/data-deletion") def data_deletion_page() -> HTMLResponse: return HTMLResponse(_LEGAL_HTML["data-deletion"]) async def _forward(method: str, service: str, path: str, request: Request) -> Response: base = SERVICE_URLS.get(service) if not base: raise HTTPException(status_code=404, detail="Unknown service") target_path = _resolve_service_path(service, path) url = f"{base.rstrip('/')}/{target_path}" headers = { "X-User": request.headers.get("X-User", ""), "X-Role": request.headers.get("X-Role", ""), } if request.headers.get("Authorization"): headers["Authorization"] = request.headers["Authorization"] if request.headers.get("X-Tenant-ID"): headers["X-Tenant-ID"] = request.headers["X-Tenant-ID"] if request.headers.get("X-Provider-Account-ID"): headers["X-Provider-Account-ID"] = request.headers["X-Provider-Account-ID"] if request.headers.get("X-Provider-Name"): headers["X-Provider-Name"] = request.headers["X-Provider-Name"] if request.headers.get("X-Telegram-Bot-Api-Secret-Token"): headers["X-Telegram-Bot-Api-Secret-Token"] = request.headers[ "X-Telegram-Bot-Api-Secret-Token" ] if request.headers.get("X-WhatsApp-Webhook-Secret"): headers["X-WhatsApp-Webhook-Secret"] = request.headers["X-WhatsApp-Webhook-Secret"] if request.headers.get("X-Hub-Signature-256"): headers["X-Hub-Signature-256"] = request.headers["X-Hub-Signature-256"] if request.headers.get("Content-Type"): headers["Content-Type"] = request.headers["Content-Type"] payload: Any = None if method in {"POST", "PATCH", "PUT"}: payload = await request.body() if payload == b"": payload = None try: async with httpx.AsyncClient(timeout=20) as client: resp = await client.request( method, url, params=dict(request.query_params), content=payload, headers=headers, ) except httpx.RequestError: _queue_telegram_error_alert( service=_error_alert_service_name(), method=method, path=f"/proxy/{service}/{path}", status=502, detail=f"Upstream service unavailable: service={service} base_url={base}", ) logger.warning( "Upstream service unavailable: service=%s base_url=%s method=%s path=%s", service, base, method, path, exc_info=True, ) return JSONResponse( status_code=502, content={ "detail": "Upstream service unavailable", "service": service, }, ) if 300 <= resp.status_code < 400 and resp.headers.get("location"): return RedirectResponse(url=resp.headers["location"], status_code=resp.status_code) content_type = resp.headers.get("content-type", "") if "application/json" in content_type: return JSONResponse(status_code=resp.status_code, content=resp.json()) if "text/html" in content_type: return HTMLResponse(status_code=resp.status_code, content=resp.text) if content_type.startswith("text/"): return Response( status_code=resp.status_code, content=resp.text, media_type=content_type.split(";", 1)[0], ) if content_type.startswith("audio/") or "application/octet-stream" in content_type: passthrough_headers = {} if resp.headers.get("content-disposition"): passthrough_headers["Content-Disposition"] = resp.headers["content-disposition"] return Response( status_code=resp.status_code, content=resp.content, media_type=content_type.split(";", 1)[0], headers=passthrough_headers, ) return JSONResponse(status_code=resp.status_code, content={"raw": resp.text}) @app.api_route("/proxy/{service}/{path:path}", methods=["GET", "POST", "PATCH", "PUT", "DELETE"]) async def proxy(service: str, path: str, request: Request) -> Response: return await _forward(request.method.upper(), service, path, request)