419 lines
16 KiB
Python
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)
|