mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
feat(gateway): expose binding and webhook APIs
This commit is contained in:
parent
bd86a72587
commit
d32e8c6a90
3 changed files with 286 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ from .dropbox_add_connector_route import router as dropbox_add_connector_router
|
|||
from .editor_routes import router as editor_router
|
||||
from .export_routes import router as export_router
|
||||
from .folders_routes import router as folders_router
|
||||
from .gateway_webhook_routes import router as gateway_router
|
||||
from .google_calendar_add_connector_route import (
|
||||
router as google_calendar_add_connector_router,
|
||||
)
|
||||
|
|
@ -68,6 +69,7 @@ router.include_router(editor_router)
|
|||
router.include_router(export_router)
|
||||
router.include_router(documents_router)
|
||||
router.include_router(folders_router)
|
||||
router.include_router(gateway_router)
|
||||
router.include_router(notes_router)
|
||||
router.include_router(new_chat_router) # Chat with assistant-ui persistence
|
||||
router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_id}
|
||||
|
|
|
|||
232
surfsense_backend/app/routes/gateway_webhook_routes.py
Normal file
232
surfsense_backend/app/routes/gateway_webhook_routes.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""Messaging gateway routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
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 (
|
||||
GatewayBindingState,
|
||||
GatewayConversationBinding,
|
||||
GatewayPlatform,
|
||||
GatewayPlatformAccount,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.gateway.accounts import get_or_create_system_telegram_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
|
||||
from app.rate_limiter import limiter
|
||||
from app.users import current_active_user
|
||||
|
||||
router = APIRouter(prefix="/gateway", tags=["gateway"])
|
||||
|
||||
|
||||
class StartBindingRequest(BaseModel):
|
||||
platform: GatewayPlatform = GatewayPlatform.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,
|
||||
*,
|
||||
secret: str,
|
||||
header_secret: str | None,
|
||||
) -> GatewayPlatformAccount:
|
||||
if config.TELEGRAM_WEBHOOK_SECRET and secret == config.TELEGRAM_WEBHOOK_SECRET:
|
||||
if header_secret != config.TELEGRAM_WEBHOOK_SECRET:
|
||||
raise HTTPException(status_code=403, detail="Invalid Telegram webhook secret")
|
||||
return await get_or_create_system_telegram_account(session)
|
||||
|
||||
result = await session.execute(
|
||||
select(GatewayPlatformAccount).where(
|
||||
GatewayPlatformAccount.platform == GatewayPlatform.TELEGRAM
|
||||
)
|
||||
)
|
||||
for account in result.scalars():
|
||||
metadata = account.account_metadata or {}
|
||||
webhook_secret = metadata.get("webhook_secret")
|
||||
if webhook_secret and webhook_secret == secret:
|
||||
if header_secret != webhook_secret:
|
||||
raise HTTPException(status_code=403, detail="Invalid Telegram webhook secret")
|
||||
return account
|
||||
|
||||
raise HTTPException(status_code=404, detail="Gateway account not found")
|
||||
|
||||
|
||||
@router.post("/webhooks/telegram/{secret}")
|
||||
@limiter.limit("60/minute", key_func=lambda request: f"tg-webhook:{request.path_params['secret']}")
|
||||
async def telegram_webhook(
|
||||
request: Request,
|
||||
secret: str,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> Response:
|
||||
payload = await request.json()
|
||||
account = await _resolve_webhook_account(
|
||||
session,
|
||||
secret=secret,
|
||||
header_secret=request.headers.get("X-Telegram-Bot-Api-Secret-Token"),
|
||||
)
|
||||
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=GatewayPlatform.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,
|
||||
)
|
||||
await session.commit()
|
||||
record_gateway_inbox_write(platform="telegram", dedup_skipped=inbox_id is None)
|
||||
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:
|
||||
if body.platform != GatewayPlatform.TELEGRAM:
|
||||
raise HTTPException(status_code=400, detail="Only Telegram is supported in v1")
|
||||
|
||||
account = await get_or_create_system_telegram_account(session)
|
||||
code = generate_pairing_code()
|
||||
expires_at = pairing_expires_at()
|
||||
binding = GatewayConversationBinding(
|
||||
account_id=account.id,
|
||||
user_id=user.id,
|
||||
search_space_id=body.search_space_id,
|
||||
state=GatewayBindingState.PENDING,
|
||||
pairing_code=code,
|
||||
pairing_code_expires_at=expires_at,
|
||||
)
|
||||
session.add(binding)
|
||||
await session.commit()
|
||||
await session.refresh(binding)
|
||||
|
||||
username = account.account_metadata.get("bot_username") or config.TELEGRAM_SHARED_BOT_USERNAME
|
||||
if not username:
|
||||
raise HTTPException(status_code=500, detail="Telegram bot username is not configured")
|
||||
return StartBindingResponse(
|
||||
binding_id=binding.id,
|
||||
code=code,
|
||||
deep_link=f"https://t.me/{username}?start={code}",
|
||||
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(GatewayConversationBinding).where(
|
||||
GatewayConversationBinding.user_id == user.id
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": binding.id,
|
||||
"platform": "telegram",
|
||||
"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 in result.scalars()
|
||||
]
|
||||
|
||||
|
||||
@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(GatewayPlatformAccount).where(
|
||||
(GatewayPlatformAccount.owner_user_id == user.id)
|
||||
| (GatewayPlatformAccount.is_system_account.is_(True))
|
||||
)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": account.id,
|
||||
"platform": account.platform.value,
|
||||
"mode": account.mode.value,
|
||||
"bot_username": (account.account_metadata or {}).get("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(GatewayConversationBinding, 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_gateway_binding(
|
||||
binding_id: int,
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, bool]:
|
||||
binding = await session.get(GatewayConversationBinding, 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}
|
||||
|
||||
52
surfsense_backend/scripts/register_webhook.py
Normal file
52
surfsense_backend/scripts/register_webhook.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Register the SurfSense Telegram webhook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from telegram import Bot
|
||||
|
||||
load_dotenv()
|
||||
|
||||
WEBHOOK_SECRET_RE = re.compile(r"^[A-Za-z0-9_-]{1,256}$")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
token = os.getenv("TELEGRAM_SHARED_BOT_TOKEN")
|
||||
secret = os.getenv("TELEGRAM_WEBHOOK_SECRET")
|
||||
base_url = os.getenv("GATEWAY_BASE_URL") or os.getenv("BACKEND_URL")
|
||||
if not token or not secret or not base_url:
|
||||
print(
|
||||
"Missing TELEGRAM_SHARED_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, or GATEWAY_BASE_URL/BACKEND_URL",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not WEBHOOK_SECRET_RE.fullmatch(secret):
|
||||
print(
|
||||
"TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, '_' or '-'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/v1/gateway/webhooks/telegram/{secret}"
|
||||
bot = Bot(token=token)
|
||||
ok = await bot.set_webhook(
|
||||
url=webhook_url,
|
||||
secret_token=secret,
|
||||
allowed_updates=["message", "edited_message"],
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
if not ok:
|
||||
print("Telegram rejected webhook registration", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Registered Telegram webhook: {webhook_url}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue