Files
call-center/gateway/app.py
T

623 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import logging
import os
from pathlib import Path
from datetime import datetime, timezone
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")
logger = logging.getLogger("api-gateway.proxy")
_ERROR_ALERT_SENT_AT: dict[str, float] = {}
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 _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()
return [item.strip() for item in raw.split(",") if item.strip()]
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_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) -> 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
timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
message = (
f"🚨 [{service}] {status}\n"
f"method: {method}\n"
f"path: {path}\n"
f"time: {timestamp}\n"
f"detail: {detail}"
)
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": """<!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"),
}
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}",
)
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}",
)
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/*",
],
"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
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:
_queue_telegram_error_alert(
service=_error_alert_service_name(),
method=method,
path=f"/proxy/{service}/{path}",
status=502,
detail=f"Upstream service unavailable: service={service} base_url={base}",
)
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)