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
- 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"),
}
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)