from __future__ import annotations import asyncio import logging import os from pathlib import Path from datetime import datetime, timezone from typing import Any from urllib.parse import parse_qsl 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] = {} _ERROR_ALERT_MAX_MESSAGE_LENGTH = 3800 _ERROR_ALERT_HEADER_PREVIEW_LIMIT = 120 _ERROR_ALERT_BODY_PREVIEW_LIMIT = 1200 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_mask_value(value: str) -> str: text = value.strip() if len(text) <= 6: return "" return f"{text[:3]}...{text[-2:]}" def _error_alert_headers_preview(headers: Any) -> list[str]: sensitive = { "authorization", "x-telegram-bot-api-secret-token", "x-whatsapp-webhook-secret", "x-hub-signature-256", "cookie", "set-cookie", "proxy-authorization", "x-api-key", "api-key", "x-auth-token", "x-service-token", } result: list[str] = [] for key, value in headers.items(): key_lower = key.lower() if key_lower in sensitive: value = _error_alert_mask_value(value) result.append(f"{key}: {value}") return result[:_ERROR_ALERT_HEADER_PREVIEW_LIMIT] def _error_alert_mask_query(query: str) -> str: if not query: return "-" sensitive = { "token", "access_token", "refresh_token", "authorization", "password", "secret", "api_key", "apikey", "x_api_key", "x-telegram-bot-api-secret-token", "x-whatsapp-webhook-secret", "signature", } parsed = parse_qsl(query, keep_blank_values=True) if not parsed: return query values = [] for key, value in parsed: key_lower = key.lower() if key_lower in sensitive: values.append(f"{key}=") else: values.append(f"{key}={value}") return "&".join(values) def _error_alert_request_context(request: Request) -> dict[str, Any]: query = request.url.query client = request.client client_addr = f"{client.host}:{client.port}" if client else "unknown" return { "client": client_addr, "http_version": request.scope.get("http_version", "-"), "query": _error_alert_mask_query(query), "user_agent": request.headers.get("user-agent", "-"), "referer": request.headers.get("referer", "-"), "host": request.headers.get("host", "-"), "request_id": request.headers.get("x-request-id") or request.headers.get("x-correlation-id") or request.headers.get("x-amzn-trace-id") or "-", "x_user": request.headers.get("x-user", "-"), "x_role": request.headers.get("x-role", "-"), "x_tenant_id": request.headers.get("x-tenant-id", "-"), "x_forwarded_for": request.headers.get("x-forwarded-for", "-"), "headers_count": len(request.headers), "headers": _error_alert_headers_preview(request.headers), } def _error_alert_message_payload( *, service: str, method: str, path: str, status: int, detail: str, request: Request, extra: dict[str, Any] | None = None, ) -> str: timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") request_context = _error_alert_request_context(request) lines = [ f"🚨 [{service}] {status}", f"time: {timestamp}", f"method: {method}", f"path: {path}", f"service: {service}", f"client: {request_context['client']}", f"http_version: {request_context['http_version']}", f"query: {request_context['query']}", f"request_id: {request_context['request_id']}", f"user_agent: {request_context['user_agent']}", f"referer: {request_context['referer']}", f"host: {request_context['host']}", f"x_user: {request_context['x_user']}", f"x_role: {request_context['x_role']}", f"x_tenant_id: {request_context['x_tenant_id']}", f"x_forwarded_for: {request_context['x_forwarded_for']}", "", "headers:", ] lines.extend(f" {item}" for item in request_context["headers"]) if extra: lines.extend(("", "extra:")) for key, value in extra.items(): lines.append(f" {key}: {value}") lines.append("") lines.append(f"detail: {detail}") full_message = "\n".join(lines) if len(full_message) <= _ERROR_ALERT_MAX_MESSAGE_LENGTH: return full_message cutoff = _ERROR_ALERT_MAX_MESSAGE_LENGTH - 80 if cutoff < 100: return full_message[:_ERROR_ALERT_MAX_MESSAGE_LENGTH] return f"{full_message[:cutoff]}\n... (truncated)" def _error_alert_body_preview(payload: bytes | None) -> str: if payload is None: return "-" if len(payload) > _ERROR_ALERT_BODY_PREVIEW_LIMIT: payload = payload[:_ERROR_ALERT_BODY_PREVIEW_LIMIT] truncated = True else: truncated = False try: text = payload.decode("utf-8") except UnicodeDecodeError: text = payload.decode("utf-8", errors="replace") if truncated: return f"{text}\n... (truncated body preview)" return text 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, request: Request | None = None, extra: dict[str, Any] | None = None, ) -> 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 if request is None: return message = _error_alert_message_payload( service=service, method=method, path=path, status=status, detail=detail, request=request, extra=extra, ) 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

  • Profile data shared by the messaging platform, including display name and phone number.
  • Message content and delivery metadata needed to operate support conversations.
  • Operational data used for routing, analytics, and service quality monitoring.

How We Use Data

  • Deliver inbound and outbound customer support messages.
  • Route conversations to AI or human operators.
  • Generate summaries, audit trails, and service performance reports.

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

  • The service is intended for authorized business use only.
  • Users are responsible for ensuring their communications comply with applicable law and platform policies.
  • KonturAI may log operational events to maintain service reliability and auditability.

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

  • Your phone number or account identifier used in the conversation.
  • The approximate date of the interaction.
  • Any details needed to identify the record you want removed.

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}", request=request, ) 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}", request=request, extra={ "response_content_type": response.headers.get("content-type", "-"), "response_content_length": response.headers.get("content-length", "-"), }, ) 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/*", "/ai/operator/config", ], "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 body_preview = "-" 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 as exc: body_preview = _error_alert_body_preview(payload) _queue_telegram_error_alert( service=_error_alert_service_name(), method=method, path=f"/proxy/{service}/{path}", status=502, detail=f"{type(exc).__name__}: {exc}", request=request, extra={ "upstream_service": service, "upstream_base_url": base, "upstream_target_path": target_path, "upstream_url": url, "request_body_preview": body_preview, }, ) 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)