Files
call-center/gateway/app.py
T

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)