835 lines
29 KiB
Python
835 lines
29 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
|
|
from urllib.parse import parse_qsl
|
|
|
|
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] = {}
|
|
_ERROR_ALERT_MAX_MESSAGE_LENGTH = 3800
|
|
_ERROR_ALERT_HEADER_PREVIEW_LIMIT = 120
|
|
_ERROR_ALERT_BODY_PREVIEW_LIMIT = 1200
|
|
|
|
|
|
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 _normalize_telegram_chat_id(raw_id: str) -> str:
|
|
value = raw_id.strip()
|
|
if not value:
|
|
return value
|
|
|
|
lowered = value.lower()
|
|
for prefix in ("https://t.me/", "http://t.me/", "t.me/"):
|
|
if lowered.startswith(prefix):
|
|
value = value[len(prefix):].strip()
|
|
break
|
|
|
|
if not value.startswith(("@", "-100")) and not value.isdigit():
|
|
value = f"@{value}"
|
|
return value
|
|
|
|
|
|
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()
|
|
normalized = [_normalize_telegram_chat_id(item) for item in raw.split(",")]
|
|
return [item for item in normalized if item]
|
|
|
|
|
|
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_mask_value(value: str) -> str:
|
|
text = value.strip()
|
|
if len(text) <= 6:
|
|
return "<hidden>"
|
|
return f"{text[:3]}...{text[-2:]}"
|
|
|
|
|
|
def _error_alert_headers_preview(headers: Any) -> list[str]:
|
|
sensitive = {
|
|
"authorization",
|
|
"x-telegram-bot-api-secret-token",
|
|
"x-whatsapp-webhook-secret",
|
|
"x-hub-signature-256",
|
|
"cookie",
|
|
"set-cookie",
|
|
"proxy-authorization",
|
|
"x-api-key",
|
|
"api-key",
|
|
"x-auth-token",
|
|
"x-service-token",
|
|
}
|
|
result: list[str] = []
|
|
for key, value in headers.items():
|
|
key_lower = key.lower()
|
|
if key_lower in sensitive:
|
|
value = _error_alert_mask_value(value)
|
|
result.append(f"{key}: {value}")
|
|
|
|
return result[:_ERROR_ALERT_HEADER_PREVIEW_LIMIT]
|
|
|
|
|
|
def _error_alert_mask_query(query: str) -> str:
|
|
if not query:
|
|
return "-"
|
|
|
|
sensitive = {
|
|
"token",
|
|
"access_token",
|
|
"refresh_token",
|
|
"authorization",
|
|
"password",
|
|
"secret",
|
|
"api_key",
|
|
"apikey",
|
|
"x_api_key",
|
|
"x-telegram-bot-api-secret-token",
|
|
"x-whatsapp-webhook-secret",
|
|
"signature",
|
|
}
|
|
parsed = parse_qsl(query, keep_blank_values=True)
|
|
if not parsed:
|
|
return query
|
|
|
|
values = []
|
|
for key, value in parsed:
|
|
key_lower = key.lower()
|
|
if key_lower in sensitive:
|
|
values.append(f"{key}=<hidden>")
|
|
else:
|
|
values.append(f"{key}={value}")
|
|
return "&".join(values)
|
|
|
|
|
|
def _error_alert_request_context(request: Request) -> dict[str, Any]:
|
|
query = request.url.query
|
|
client = request.client
|
|
client_addr = f"{client.host}:{client.port}" if client else "unknown"
|
|
|
|
return {
|
|
"client": client_addr,
|
|
"http_version": request.scope.get("http_version", "-"),
|
|
"query": _error_alert_mask_query(query),
|
|
"user_agent": request.headers.get("user-agent", "-"),
|
|
"referer": request.headers.get("referer", "-"),
|
|
"host": request.headers.get("host", "-"),
|
|
"request_id": request.headers.get("x-request-id")
|
|
or request.headers.get("x-correlation-id")
|
|
or request.headers.get("x-amzn-trace-id")
|
|
or "-",
|
|
"x_user": request.headers.get("x-user", "-"),
|
|
"x_role": request.headers.get("x-role", "-"),
|
|
"x_tenant_id": request.headers.get("x-tenant-id", "-"),
|
|
"x_forwarded_for": request.headers.get("x-forwarded-for", "-"),
|
|
"headers_count": len(request.headers),
|
|
"headers": _error_alert_headers_preview(request.headers),
|
|
}
|
|
|
|
|
|
def _error_alert_message_payload(
|
|
*,
|
|
service: str,
|
|
method: str,
|
|
path: str,
|
|
status: int,
|
|
detail: str,
|
|
request: Request,
|
|
extra: dict[str, Any] | None = None,
|
|
) -> str:
|
|
timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
request_context = _error_alert_request_context(request)
|
|
|
|
lines = [
|
|
f"🚨 [{service}] {status}",
|
|
f"time: {timestamp}",
|
|
f"method: {method}",
|
|
f"path: {path}",
|
|
f"service: {service}",
|
|
f"client: {request_context['client']}",
|
|
f"http_version: {request_context['http_version']}",
|
|
f"query: {request_context['query']}",
|
|
f"request_id: {request_context['request_id']}",
|
|
f"user_agent: {request_context['user_agent']}",
|
|
f"referer: {request_context['referer']}",
|
|
f"host: {request_context['host']}",
|
|
f"x_user: {request_context['x_user']}",
|
|
f"x_role: {request_context['x_role']}",
|
|
f"x_tenant_id: {request_context['x_tenant_id']}",
|
|
f"x_forwarded_for: {request_context['x_forwarded_for']}",
|
|
"",
|
|
"headers:",
|
|
]
|
|
|
|
lines.extend(f" {item}" for item in request_context["headers"])
|
|
|
|
if extra:
|
|
lines.extend(("", "extra:"))
|
|
for key, value in extra.items():
|
|
lines.append(f" {key}: {value}")
|
|
|
|
lines.append("")
|
|
lines.append(f"detail: {detail}")
|
|
|
|
full_message = "\n".join(lines)
|
|
if len(full_message) <= _ERROR_ALERT_MAX_MESSAGE_LENGTH:
|
|
return full_message
|
|
|
|
cutoff = _ERROR_ALERT_MAX_MESSAGE_LENGTH - 80
|
|
if cutoff < 100:
|
|
return full_message[:_ERROR_ALERT_MAX_MESSAGE_LENGTH]
|
|
|
|
return f"{full_message[:cutoff]}\n... (truncated)"
|
|
|
|
|
|
def _error_alert_body_preview(payload: bytes | None) -> str:
|
|
if payload is None:
|
|
return "-"
|
|
if len(payload) > _ERROR_ALERT_BODY_PREVIEW_LIMIT:
|
|
payload = payload[:_ERROR_ALERT_BODY_PREVIEW_LIMIT]
|
|
truncated = True
|
|
else:
|
|
truncated = False
|
|
|
|
try:
|
|
text = payload.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
text = payload.decode("utf-8", errors="replace")
|
|
|
|
if truncated:
|
|
return f"{text}\n... (truncated body preview)"
|
|
return text
|
|
|
|
|
|
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,
|
|
request: Request | None = None,
|
|
extra: dict[str, Any] | None = None,
|
|
) -> 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
|
|
|
|
if request is None:
|
|
return
|
|
|
|
message = _error_alert_message_payload(
|
|
service=service,
|
|
method=method,
|
|
path=path,
|
|
status=status,
|
|
detail=detail,
|
|
request=request,
|
|
extra=extra,
|
|
)
|
|
|
|
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}",
|
|
request=request,
|
|
)
|
|
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}",
|
|
request=request,
|
|
extra={
|
|
"response_content_type": response.headers.get("content-type", "-"),
|
|
"response_content_length": response.headers.get("content-length", "-"),
|
|
},
|
|
)
|
|
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/*",
|
|
"/ai/operator/config",
|
|
],
|
|
"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
|
|
|
|
body_preview = "-"
|
|
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 as exc:
|
|
body_preview = _error_alert_body_preview(payload)
|
|
_queue_telegram_error_alert(
|
|
service=_error_alert_service_name(),
|
|
method=method,
|
|
path=f"/proxy/{service}/{path}",
|
|
status=502,
|
|
detail=f"{type(exc).__name__}: {exc}",
|
|
request=request,
|
|
extra={
|
|
"upstream_service": service,
|
|
"upstream_base_url": base,
|
|
"upstream_target_path": target_path,
|
|
"upstream_url": url,
|
|
"request_body_preview": body_preview,
|
|
},
|
|
)
|
|
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)
|