Files
call-center/gateway/app.py
T

419 lines
16 KiB
Python

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": """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KonturAI Privacy Policy</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #f5f7fb; color: #172033; }
main { max-width: 760px; margin: 48px auto; background: #ffffff; padding: 40px; border-radius: 18px; box-shadow: 0 10px 40px rgba(23, 32, 51, 0.08); }
h1, h2 { color: #0d3b2e; }
p, li { line-height: 1.6; }
a { color: #0b6bcb; }
</style>
</head>
<body>
<main>
<h1>KonturAI Privacy Policy</h1>
<p>KonturAI processes customer communication data to route conversations, assist operators, and provide AI-supported support workflows across channels including WhatsApp.</p>
<h2>Data We Process</h2>
<ul>
<li>Profile data shared by the messaging platform, including display name and phone number.</li>
<li>Message content and delivery metadata needed to operate support conversations.</li>
<li>Operational data used for routing, analytics, and service quality monitoring.</li>
</ul>
<h2>How We Use Data</h2>
<ul>
<li>Deliver inbound and outbound customer support messages.</li>
<li>Route conversations to AI or human operators.</li>
<li>Generate summaries, audit trails, and service performance reports.</li>
</ul>
<h2>Contact</h2>
<p>For privacy-related requests, contact <a href="mailto:ernurreen@gmail.com">ernurreen@gmail.com</a>.</p>
</main>
</body>
</html>
""",
"terms": """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KonturAI Terms of Service</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #f5f7fb; color: #172033; }
main { max-width: 760px; margin: 48px auto; background: #ffffff; padding: 40px; border-radius: 18px; box-shadow: 0 10px 40px rgba(23, 32, 51, 0.08); }
h1, h2 { color: #0d3b2e; }
p, li { line-height: 1.6; }
</style>
</head>
<body>
<main>
<h1>KonturAI Terms of Service</h1>
<p>KonturAI provides customer communication tooling for internal support operations, including operator workspaces, AI-assisted workflows, and messaging integrations.</p>
<h2>Use of Service</h2>
<ul>
<li>The service is intended for authorized business use only.</li>
<li>Users are responsible for ensuring their communications comply with applicable law and platform policies.</li>
<li>KonturAI may log operational events to maintain service reliability and auditability.</li>
</ul>
<h2>Availability</h2>
<p>The service is provided on a best-effort basis and may be updated, improved, or temporarily interrupted for maintenance.</p>
<h2>Contact</h2>
<p>Questions about these terms can be sent to ernurreen@gmail.com.</p>
</main>
</body>
</html>
""",
"data-deletion": """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KonturAI Data Deletion</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #f5f7fb; color: #172033; }
main { max-width: 760px; margin: 48px auto; background: #ffffff; padding: 40px; border-radius: 18px; box-shadow: 0 10px 40px rgba(23, 32, 51, 0.08); }
h1, h2 { color: #0d3b2e; }
p, li { line-height: 1.6; }
code { background: #eef3fb; padding: 2px 6px; border-radius: 6px; }
</style>
</head>
<body>
<main>
<h1>KonturAI Data Deletion Instructions</h1>
<p>To request deletion of personal data processed through KonturAI, send an email to <a href="mailto:ernurreen@gmail.com">ernurreen@gmail.com</a> with the subject line <code>Data Deletion Request</code>.</p>
<h2>Please include</h2>
<ul>
<li>Your phone number or account identifier used in the conversation.</li>
<li>The approximate date of the interaction.</li>
<li>Any details needed to identify the record you want removed.</li>
</ul>
<p>Requests are reviewed and processed within a reasonable timeframe subject to legal, security, and audit retention obligations.</p>
</main>
</body>
</html>
""",
}
_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("/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")
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)