SurfSense/surfsense_backend/app/routes/gateway_webhook_routes.py

267 lines
8.9 KiB
Python

"""Messaging gateway routes."""
from __future__ import annotations
import hmac
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import Response
from app.config import config
from app.db import (
ExternalChatAccount,
ExternalChatBinding,
ExternalChatBindingState,
ExternalChatPlatform,
User,
get_async_session,
)
from app.gateway.accounts import (
get_or_create_system_telegram_account,
get_or_create_system_whatsapp_account,
)
from app.gateway.bindings import resume_binding, revoke_binding
from app.gateway.inbox import persist_inbound_event, telegram_event_dedupe_key
from app.gateway.pairing import generate_pairing_code, pairing_expires_at
from app.observability.metrics import (
record_gateway_inbox_write,
record_gateway_webhook_parse_error,
)
from app.users import current_active_user
router = APIRouter(prefix="/gateway", tags=["gateway"])
logger = logging.getLogger(__name__)
class StartBindingRequest(BaseModel):
platform: ExternalChatPlatform = ExternalChatPlatform.TELEGRAM
search_space_id: int
class StartBindingResponse(BaseModel):
binding_id: int
code: str
deep_link: str
expires_at: datetime
def _classify_telegram_event(payload: dict[str, Any]) -> str:
if "message" in payload:
return "message"
if "edited_message" in payload:
return "edited_message"
if "callback_query" in payload:
return "callback_query"
return "other"
def _telegram_message(payload: dict[str, Any]) -> dict[str, Any] | None:
return payload.get("message") or payload.get("edited_message")
async def _resolve_webhook_account(
session: AsyncSession,
*,
account_id: int,
header_secret: str | None,
) -> ExternalChatAccount:
account = await session.get(ExternalChatAccount, account_id)
if account is None or account.platform != ExternalChatPlatform.TELEGRAM:
raise HTTPException(status_code=404, detail="Gateway account not found")
expected_secret = account.webhook_secret or ""
if not expected_secret or not hmac.compare_digest(header_secret or "", expected_secret):
raise HTTPException(status_code=403, detail="Invalid Telegram webhook secret")
return account
@router.post("/webhooks/telegram/{account_id}")
async def telegram_webhook(
request: Request,
account_id: int,
session: AsyncSession = Depends(get_async_session),
) -> Response:
request_id = f"gateway_{uuid.uuid4().hex[:16]}"
try:
payload = await request.json()
except ValueError:
record_gateway_webhook_parse_error()
return Response(status_code=200)
account = await _resolve_webhook_account(
session,
account_id=account_id,
header_secret=request.headers.get("X-Telegram-Bot-Api-Secret-Token"),
)
try:
update_id = payload.get("update_id")
if update_id is None:
return Response(status_code=200)
message = _telegram_message(payload) or {}
inbox_id = await persist_inbound_event(
session,
account_id=account.id,
platform=ExternalChatPlatform.TELEGRAM,
event_dedupe_key=telegram_event_dedupe_key(update_id),
external_event_id=str(update_id),
external_message_id=(
str(message["message_id"]) if message.get("message_id") is not None else None
),
event_kind=_classify_telegram_event(payload),
raw_payload=payload,
request_id=request_id,
)
await session.commit()
record_gateway_inbox_write(platform="telegram", dedup_skipped=inbox_id is None)
return Response(status_code=200)
except Exception:
await session.rollback()
logger.exception("Telegram webhook processing failed account_id=%s", account_id)
return Response(status_code=200)
@router.post("/bindings/start", response_model=StartBindingResponse)
async def start_binding(
body: StartBindingRequest,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> StartBindingResponse:
code = generate_pairing_code()
if body.platform == ExternalChatPlatform.TELEGRAM:
account = await get_or_create_system_telegram_account(session)
username = account.bot_username or config.TELEGRAM_SHARED_BOT_USERNAME
if not username:
raise HTTPException(
status_code=500,
detail="Telegram bot username is not configured",
)
deep_link = f"https://t.me/{username}?start={code}"
elif body.platform == ExternalChatPlatform.WHATSAPP:
if config.GATEWAY_WHATSAPP_INTAKE_MODE != "cloud":
raise HTTPException(
status_code=400,
detail="WhatsApp /start pairing requires GATEWAY_WHATSAPP_INTAKE_MODE=cloud",
)
account = await get_or_create_system_whatsapp_account(session)
phone = config.WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER
if not phone:
raise HTTPException(
status_code=500,
detail="WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER is not configured",
)
normalized_phone = "".join(ch for ch in phone if ch.isdigit())
if not normalized_phone:
raise HTTPException(
status_code=500,
detail="WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER must contain digits",
)
deep_link = f"https://wa.me/{normalized_phone}?text={quote(f'/start {code}')}"
else:
raise HTTPException(status_code=400, detail="Unsupported platform")
expires_at = pairing_expires_at()
binding = ExternalChatBinding(
account_id=account.id,
user_id=user.id,
search_space_id=body.search_space_id,
state=ExternalChatBindingState.PENDING,
pairing_code=code,
pairing_code_expires_at=expires_at,
)
session.add(binding)
await session.commit()
await session.refresh(binding)
return StartBindingResponse(
binding_id=binding.id,
code=code,
deep_link=deep_link,
expires_at=expires_at,
)
@router.get("/bindings")
async def list_bindings(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> list[dict[str, Any]]:
result = await session.execute(
select(ExternalChatBinding, ExternalChatAccount)
.join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id)
.where(ExternalChatBinding.user_id == user.id)
)
return [
{
"id": binding.id,
"platform": account.platform.value,
"state": binding.state.value,
"search_space_id": binding.search_space_id,
"external_display_name": binding.external_display_name,
"external_username": binding.external_username,
"suspended_reason": binding.suspended_reason,
}
for binding, account in result.all()
]
@router.get("/platforms")
async def list_platforms(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> list[dict[str, Any]]:
result = await session.execute(
select(ExternalChatAccount).where(
(ExternalChatAccount.owner_user_id == user.id)
| (ExternalChatAccount.is_system_account.is_(True))
)
)
return [
{
"id": account.id,
"platform": account.platform.value,
"mode": account.mode.value,
"bot_username": account.bot_username,
"health_status": account.health_status.value,
"last_health_check_at": account.last_health_check_at,
}
for account in result.scalars()
]
@router.delete("/bindings/{binding_id}")
async def delete_binding(
binding_id: int,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found")
revoke_binding(binding)
await session.commit()
return {"ok": True}
@router.post("/bindings/{binding_id}/resume")
async def resume_external_chat_binding(
binding_id: int,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> dict[str, bool]:
binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found")
resume_binding(binding)
binding.updated_at = datetime.now(UTC)
await session.commit()
return {"ok": True}