from __future__ import annotations import os from pathlib import Path 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") _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"), } 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"} @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("/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") url = f"{base.rstrip('/')}/{path.lstrip('/')}" 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-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 async with httpx.AsyncClient(timeout=20) as client: resp = await client.request( method, url, params=dict(request.query_params), content=payload, headers=headers, ) 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)