mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
Merge pull request #1444 from AnishSarkar22/feat/whatsapp-gateway-integration
feat: Add external chat gateways for Telegram, WhatsApp, Slack, and Discord
This commit is contained in:
commit
a80a9cb87c
101 changed files with 11660 additions and 37 deletions
|
|
@ -55,6 +55,9 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|||
# -- Redis exposed port (dev only; Redis is internal-only in prod) --
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
|
||||
# WHATSAPP_BRIDGE_PORT=9929
|
||||
|
||||
# -- Frontend Build Args --
|
||||
# In dev, the frontend is built from source and these are passed as build args.
|
||||
# In prod, they are automatically derived from AUTH_TYPE, ETL_SERVICE, and the port settings above.
|
||||
|
|
@ -67,7 +70,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|||
# ------------------------------------------------------------------------------
|
||||
# ONLY set these if you are serving SurfSense on a real domain via a reverse
|
||||
# proxy (e.g. Caddy, Nginx, Cloudflare Tunnel).
|
||||
# For standard localhost deployments, leave all of these commented out —
|
||||
# For standard localhost deployments, leave all of these commented out.
|
||||
# they are automatically derived from the port settings above.
|
||||
#
|
||||
# NEXT_FRONTEND_URL=https://app.yourdomain.com
|
||||
|
|
@ -89,7 +92,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|||
# Only change this if you manage publications manually.
|
||||
# ZERO_APP_PUBLICATIONS=zero_publication
|
||||
|
||||
# Sync worker tuning — zero-cache defaults ZERO_NUM_SYNC_WORKERS to the number
|
||||
# Sync worker tuning. zero-cache defaults ZERO_NUM_SYNC_WORKERS to the number
|
||||
# of CPU cores, which can exceed the connection pool limits on high-core machines.
|
||||
# Each sync worker needs at least 1 connection from both the UPSTREAM and CVR
|
||||
# pools, so these constraints must hold:
|
||||
|
|
@ -134,7 +137,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|||
# SSL mode for database connections: disable, require, verify-ca, verify-full
|
||||
# DB_SSLMODE=disable
|
||||
|
||||
# Full DATABASE_URL override — when set, takes precedence over the individual
|
||||
# Full DATABASE_URL override. When set, this takes precedence over the individual
|
||||
# DB_USER / DB_PASSWORD / DB_NAME / DB_HOST / DB_PORT settings above.
|
||||
# Use this for managed databases (AWS RDS, GCP Cloud SQL, Supabase, etc.)
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@your-rds-host:5432/surfsense?sslmode=require
|
||||
|
|
@ -149,7 +152,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|||
# REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Stripe (pay-as-you-go page packs — disabled by default)
|
||||
# Stripe (pay-as-you-go page packs, disabled by default)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Set TRUE to allow users to buy additional page packs via Stripe Checkout
|
||||
|
|
@ -168,7 +171,7 @@ STRIPE_PAGE_BUYING_ENABLED=FALSE
|
|||
# STRIPE_TOKEN_BUYING_ENABLED=FALSE
|
||||
# STRIPE_PREMIUM_TOKEN_PRICE_ID=price_...
|
||||
# STRIPE_CREDIT_MICROS_PER_UNIT=1000000
|
||||
# DEPRECATED — STRIPE_TOKENS_PER_UNIT=1000000
|
||||
# DEPRECATED: STRIPE_TOKENS_PER_UNIT=1000000
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# TTS & STT (Text-to-Speech / Speech-to-Text)
|
||||
|
|
@ -263,7 +266,44 @@ STT_SERVICE=local/base
|
|||
# COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# SearXNG (bundled web search — works out of the box, no config needed)
|
||||
# Messaging Channels (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Configure only the external chat channels you want to use.
|
||||
|
||||
# -- Telegram --
|
||||
# TELEGRAM_SHARED_BOT_TOKEN=
|
||||
# TELEGRAM_SHARED_BOT_USERNAME=
|
||||
# TELEGRAM_WEBHOOK_SECRET=
|
||||
# GATEWAY_BASE_URL=http://localhost:8929
|
||||
# GATEWAY_TELEGRAM_INTAKE_MODE=webhook
|
||||
|
||||
# -- WhatsApp --
|
||||
# GATEWAY_WHATSAPP_INTAKE_MODE=disabled
|
||||
# WHATSAPP_SHARED_BUSINESS_TOKEN=
|
||||
# WHATSAPP_SHARED_PHONE_NUMBER_ID=
|
||||
# WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER=
|
||||
# WHATSAPP_SHARED_WABA_ID=
|
||||
# WHATSAPP_GRAPH_API_VERSION=v25.0
|
||||
# WHATSAPP_WEBHOOK_VERIFY_TOKEN=
|
||||
# WHATSAPP_WEBHOOK_APP_SECRET=
|
||||
# WHATSAPP_BRIDGE_URL=http://whatsapp-bridge:9929
|
||||
|
||||
# -- Slack --
|
||||
# Uses SLACK_CLIENT_ID and SLACK_CLIENT_SECRET from the Slack connector section.
|
||||
#
|
||||
# GATEWAY_SLACK_ENABLED=FALSE
|
||||
# GATEWAY_SLACK_SIGNING_SECRET=
|
||||
# GATEWAY_SLACK_REDIRECT_URI=http://localhost:8929/api/v1/gateway/slack/callback
|
||||
|
||||
# -- Discord --
|
||||
# Uses DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_BOT_TOKEN from the
|
||||
# Discord connector section.
|
||||
#
|
||||
# GATEWAY_DISCORD_ENABLED=FALSE
|
||||
# GATEWAY_DISCORD_REDIRECT_URI=http://localhost:8929/api/v1/gateway/discord/callback
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# SearXNG (bundled web search, works out of the box with no config needed)
|
||||
# ------------------------------------------------------------------------------
|
||||
# SearXNG provides web search to all search spaces automatically.
|
||||
# To access the SearXNG UI directly: http://localhost:8888
|
||||
|
|
@ -273,7 +313,7 @@ STT_SERVICE=local/base
|
|||
# SEARXNG_SECRET=surfsense-searxng-secret
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Daytona Sandbox (optional — cloud code execution for the deep agent)
|
||||
# Daytona Sandbox (optional cloud code execution for the deep agent)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Set DAYTONA_SANDBOX_ENABLED=TRUE and provide credentials to give the agent
|
||||
# an isolated code execution environment via the Daytona cloud API.
|
||||
|
|
@ -364,7 +404,7 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
|
|||
# Premium turns are debited at the actual per-call provider cost reported
|
||||
# by LiteLLM. Only applies to models with billing_tier=premium.
|
||||
# PREMIUM_CREDIT_MICROS_LIMIT=5000000
|
||||
# DEPRECATED — PREMIUM_TOKEN_LIMIT=5000000
|
||||
# DEPRECATED: PREMIUM_TOKEN_LIMIT=5000000
|
||||
|
||||
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
|
||||
# QUOTA_MAX_RESERVE_MICROS=1000000
|
||||
|
|
@ -376,10 +416,10 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
|
|||
# QUOTA_DEFAULT_PODCAST_RESERVE_MICROS=200000
|
||||
|
||||
# Per-video-presentation reservation for the video Celery task ($1.00 default).
|
||||
# Override path bypasses QUOTA_MAX_RESERVE_MICROS clamp — raise with care.
|
||||
# Override path bypasses QUOTA_MAX_RESERVE_MICROS clamp. Raise with care.
|
||||
# QUOTA_DEFAULT_VIDEO_PRESENTATION_RESERVE_MICROS=1000000
|
||||
|
||||
# No-login (anonymous) mode — public users can chat without an account
|
||||
# No-login (anonymous) mode. Public users can chat without an account
|
||||
# Set TRUE to enable /free pages and anonymous chat API
|
||||
NOLOGIN_MODE_ENABLED=FALSE
|
||||
# ANON_TOKEN_LIMIT=1000000
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ services:
|
|||
- AUTH_TYPE=${AUTH_TYPE:-LOCAL}
|
||||
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
|
||||
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
|
||||
- WHATSAPP_BRIDGE_URL=${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
|
||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||
# - DAYTONA_SANDBOX_ENABLED=TRUE
|
||||
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
|
||||
|
|
@ -148,6 +149,25 @@ services:
|
|||
retries: 30
|
||||
start_period: 200s
|
||||
|
||||
whatsapp-bridge:
|
||||
build: ../surfsense_backend/scripts/whatsapp-bridge
|
||||
profiles:
|
||||
- whatsapp
|
||||
ports:
|
||||
- "127.0.0.1:${WHATSAPP_BRIDGE_PORT:-9929}:9929"
|
||||
volumes:
|
||||
- whatsapp_sessions:/data/sessions
|
||||
environment:
|
||||
- PORT=9929
|
||||
- WHATSAPP_MODE=${WHATSAPP_MODE:-self-chat}
|
||||
- WHATSAPP_SESSION_DIR=/data/sessions
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9929/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
celery_worker:
|
||||
build: *backend-build
|
||||
volumes:
|
||||
|
|
@ -282,3 +302,5 @@ volumes:
|
|||
name: surfsense-dev-zero-cache
|
||||
zero_init:
|
||||
name: surfsense-dev-zero-init
|
||||
whatsapp_sessions:
|
||||
name: surfsense-dev-whatsapp-sessions
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ services:
|
|||
UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
|
||||
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
|
||||
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
|
||||
WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
|
||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
||||
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
||||
|
|
@ -143,6 +144,26 @@ services:
|
|||
retries: 30
|
||||
start_period: 200s
|
||||
|
||||
whatsapp-bridge:
|
||||
build: ../surfsense_backend/scripts/whatsapp-bridge
|
||||
profiles:
|
||||
- whatsapp
|
||||
expose:
|
||||
- "9929"
|
||||
volumes:
|
||||
- whatsapp_sessions:/data/sessions
|
||||
environment:
|
||||
PORT: 9929
|
||||
WHATSAPP_MODE: ${WHATSAPP_MODE:-self-chat}
|
||||
WHATSAPP_SESSION_DIR: /data/sessions
|
||||
mem_limit: 512m
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9929/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
celery_worker:
|
||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
||||
volumes:
|
||||
|
|
@ -264,6 +285,7 @@ services:
|
|||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
|
||||
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
|
||||
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
|
||||
NEXT_PUBLIC_WHATSAPP_DISPLAY_PHONE_NUMBER: ${WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER:-}
|
||||
FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000}
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
|
@ -285,3 +307,5 @@ volumes:
|
|||
name: surfsense-zero-cache
|
||||
zero_init:
|
||||
name: surfsense-zero-init
|
||||
whatsapp_sessions:
|
||||
name: surfsense-whatsapp-sessions
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@ REDIS_APP_URL=redis://localhost:6379/0
|
|||
# Optional: TTL in seconds for connector indexing lock key
|
||||
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
|
||||
|
||||
# Telegram Gateway
|
||||
# TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, _ or -
|
||||
# GATEWAY_TELEGRAM_INTAKE_MODE: `webhook` for production, `longpoll` for single-replica self-host fallback, `disabled` to skip Telegram intake
|
||||
TELEGRAM_SHARED_BOT_TOKEN=
|
||||
TELEGRAM_SHARED_BOT_USERNAME=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
GATEWAY_BASE_URL=http://localhost:8000
|
||||
GATEWAY_TELEGRAM_INTAKE_MODE=webhook
|
||||
|
||||
# WhatsApp Gateway
|
||||
# GATEWAY_WHATSAPP_INTAKE_MODE: `cloud` for Meta Cloud API, `baileys` for self-hosted bridge, `disabled` to skip WhatsApp intake
|
||||
GATEWAY_WHATSAPP_INTAKE_MODE=disabled
|
||||
WHATSAPP_SHARED_BUSINESS_TOKEN=
|
||||
WHATSAPP_SHARED_PHONE_NUMBER_ID=
|
||||
WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER=
|
||||
WHATSAPP_SHARED_WABA_ID=
|
||||
WHATSAPP_GRAPH_API_VERSION=v25.0
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN=
|
||||
WHATSAPP_WEBHOOK_APP_SECRET=
|
||||
WHATSAPP_BRIDGE_URL=http://whatsapp-bridge:9929
|
||||
|
||||
# Platform Web Search (SearXNG)
|
||||
# Set this to enable built-in web search. Docker Compose sets it automatically.
|
||||
# Only uncomment if running the backend outside Docker (e.g. uvicorn on host).
|
||||
|
|
@ -98,11 +119,14 @@ CLICKUP_CLIENT_ID=your_clickup_client_id_here
|
|||
CLICKUP_CLIENT_SECRET=your_clickup_client_secret_here
|
||||
CLICKUP_REDIRECT_URI=http://localhost:8000/api/v1/auth/clickup/connector/callback
|
||||
|
||||
# Discord OAuth Configuration
|
||||
# Discord OAuth / Gateway Configuration
|
||||
# The Discord connector and Discord gateway use the same Discord application/bot.
|
||||
DISCORD_CLIENT_ID=your_discord_client_id_here
|
||||
DISCORD_CLIENT_SECRET=your_discord_client_secret_here
|
||||
DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback
|
||||
DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal
|
||||
GATEWAY_DISCORD_ENABLED=FALSE
|
||||
GATEWAY_DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/gateway/discord/callback
|
||||
|
||||
# Atlassian OAuth Configuration (Jira & Confluence)
|
||||
ATLASSIAN_CLIENT_ID=your_atlassian_client_id_here
|
||||
|
|
@ -120,10 +144,14 @@ NOTION_CLIENT_ID=your_notion_client_id_here
|
|||
NOTION_CLIENT_SECRET=your_notion_client_secret_here
|
||||
NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback
|
||||
|
||||
# Slack OAuth Configuration
|
||||
# Slack OAuth / Gateway Configuration
|
||||
# The Slack connector and Slack gateway can use the same Slack app client ID/secret.
|
||||
SLACK_CLIENT_ID=your_slack_client_id_here
|
||||
SLACK_CLIENT_SECRET=your_slack_client_secret_here
|
||||
SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback
|
||||
GATEWAY_SLACK_ENABLED=FALSE
|
||||
GATEWAY_SLACK_SIGNING_SECRET=your_slack_signing_secret_here
|
||||
GATEWAY_SLACK_REDIRECT_URI=http://localhost:8000/api/v1/gateway/slack/callback
|
||||
|
||||
# Microsoft OAuth (Teams & OneDrive)
|
||||
MICROSOFT_CLIENT_ID=your_microsoft_client_id_here
|
||||
|
|
|
|||
611
surfsense_backend/alembic/versions/149_add_gateway_tables.py
Normal file
611
surfsense_backend/alembic/versions/149_add_gateway_tables.py
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
"""add external chat surface tables
|
||||
|
||||
Revision ID: 149
|
||||
Revises: 148
|
||||
Create Date: 2026-05-27
|
||||
|
||||
Adds the lean external chat surface schema:
|
||||
|
||||
* external_chat_accounts
|
||||
* external_chat_bindings
|
||||
* external_chat_inbound_events
|
||||
|
||||
External chat surfaces store Telegram-originated conversations in the existing
|
||||
chat tables. This migration adds ``source`` to ``new_chat_threads`` and
|
||||
``new_chat_messages`` as UI metadata while publishing all chat-message sources
|
||||
through Zero so a future SurfSense UI layer can render external chats. External
|
||||
chat adapter tables are served through REST in v1, so they are intentionally not
|
||||
added to ``zero_publication``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "149"
|
||||
down_revision: str | None = "148"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PUBLICATION_NAME = "zero_publication"
|
||||
|
||||
DOCUMENT_COLS = [
|
||||
"id",
|
||||
"title",
|
||||
"document_type",
|
||||
"search_space_id",
|
||||
"folder_id",
|
||||
"created_by_id",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
USER_COLS = [
|
||||
"id",
|
||||
"pages_limit",
|
||||
"pages_used",
|
||||
"premium_credit_micros_limit",
|
||||
"premium_credit_micros_used",
|
||||
]
|
||||
|
||||
def _has_zero_version(conn, table: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :tbl AND column_name = '_0_version'"
|
||||
),
|
||||
{"tbl": table},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _cols(columns: list[str]) -> str:
|
||||
return ", ".join(columns)
|
||||
|
||||
|
||||
def _table_exists(conn, table: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables "
|
||||
"WHERE table_schema = current_schema() AND table_name = :tbl"
|
||||
),
|
||||
{"tbl": table},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _column_exists(conn, table: str, column: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() "
|
||||
"AND table_name = :tbl AND column_name = :col"
|
||||
),
|
||||
{"tbl": table, "col": column},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _index_exists(conn, index_name: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_indexes "
|
||||
"WHERE schemaname = current_schema() AND indexname = :name"
|
||||
),
|
||||
{"name": index_name},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _constraint_exists(conn, table: str, constraint_name: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.table_constraints "
|
||||
"WHERE table_schema = current_schema() "
|
||||
"AND table_name = :tbl AND constraint_name = :name"
|
||||
),
|
||||
{"tbl": table, "name": constraint_name},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _drop_index_if_exists(index_name: str, table_name: str) -> None:
|
||||
if _index_exists(op.get_bind(), index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
|
||||
|
||||
def _drop_column_if_exists(table_name: str, column_name: str) -> None:
|
||||
if _column_exists(op.get_bind(), table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
||||
|
||||
def _build_set_table_ddl(
|
||||
*, documents_has_zero_ver: bool, user_has_zero_ver: bool
|
||||
) -> str:
|
||||
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
|
||||
user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
|
||||
|
||||
return (
|
||||
f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE "
|
||||
f"notifications, "
|
||||
f"documents ({_cols(doc_cols)}), "
|
||||
f"folders, "
|
||||
f"search_source_connectors, "
|
||||
f"new_chat_messages, "
|
||||
f"chat_comments, "
|
||||
f"chat_session_state, "
|
||||
f'"user" ({_cols(user_cols)})'
|
||||
)
|
||||
|
||||
|
||||
def _create_enum(name: str, values: tuple[str, ...]) -> postgresql.ENUM:
|
||||
enum = postgresql.ENUM(*values, name=name)
|
||||
enum.create(op.get_bind(), checkfirst=True)
|
||||
return postgresql.ENUM(*values, name=name, create_type=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
external_chat_platform_enum = _create_enum(
|
||||
"external_chat_platform", ("telegram", "whatsapp", "signal")
|
||||
)
|
||||
external_chat_account_mode_enum = _create_enum(
|
||||
"external_chat_account_mode", ("cloud_shared", "self_host_byo")
|
||||
)
|
||||
external_chat_health_status_enum = _create_enum(
|
||||
"external_chat_health_status", ("unknown", "ok", "failing")
|
||||
)
|
||||
external_chat_binding_state_enum = _create_enum(
|
||||
"external_chat_binding_state", ("pending", "bound", "revoked", "suspended")
|
||||
)
|
||||
external_chat_peer_kind_enum = _create_enum(
|
||||
"external_chat_peer_kind", ("direct", "group", "channel", "unknown")
|
||||
)
|
||||
external_chat_event_kind_enum = _create_enum(
|
||||
"external_chat_event_kind", ("message", "edited_message", "callback_query", "other")
|
||||
)
|
||||
external_chat_event_status_enum = _create_enum(
|
||||
"external_chat_event_status",
|
||||
("received", "processing", "processed", "ignored", "failed"),
|
||||
)
|
||||
|
||||
if not _table_exists(conn, "external_chat_accounts"):
|
||||
op.create_table(
|
||||
"external_chat_accounts",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("platform", external_chat_platform_enum, nullable=False),
|
||||
sa.Column("mode", external_chat_account_mode_enum, nullable=False),
|
||||
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("owner_search_space_id", sa.Integer(), nullable=True),
|
||||
sa.Column("is_system_account", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("encrypted_credentials", sa.Text(), nullable=True),
|
||||
sa.Column("bot_username", sa.String(255), nullable=True),
|
||||
sa.Column("webhook_secret", sa.String(64), nullable=True),
|
||||
sa.Column(
|
||||
"cursor_state",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"health_status",
|
||||
external_chat_health_status_enum,
|
||||
nullable=False,
|
||||
server_default="unknown",
|
||||
),
|
||||
sa.Column("last_health_check_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("suspended_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("suspended_reason", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() AT TIME ZONE 'utc')"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() AT TIME ZONE 'utc')"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(is_system_account = true AND owner_user_id IS NULL) OR "
|
||||
"(is_system_account = false AND owner_user_id IS NOT NULL)",
|
||||
name="ck_external_chat_accounts_owner_shape",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["owner_user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["owner_search_space_id"], ["searchspaces.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_owner_platform",
|
||||
"external_chat_accounts",
|
||||
["owner_user_id", "platform"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_system_account = false"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
"external_chat_accounts",
|
||||
["platform"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_system_account = true"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_webhook_secret",
|
||||
"external_chat_accounts",
|
||||
["webhook_secret"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("webhook_secret IS NOT NULL"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
if not _table_exists(conn, "external_chat_bindings"):
|
||||
op.create_table(
|
||||
"external_chat_bindings",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("account_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("search_space_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"state",
|
||||
external_chat_binding_state_enum,
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
),
|
||||
sa.Column("pairing_code", sa.Text(), nullable=True),
|
||||
sa.Column("pairing_code_expires_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("external_peer_id", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"external_peer_kind",
|
||||
external_chat_peer_kind_enum,
|
||||
nullable=False,
|
||||
server_default="unknown",
|
||||
),
|
||||
sa.Column(
|
||||
"external_thread_id",
|
||||
sa.Text(),
|
||||
nullable=True,
|
||||
comment="Reserved for Telegram message_thread_id when group/forum support lands.",
|
||||
),
|
||||
sa.Column("external_display_name", sa.Text(), nullable=True),
|
||||
sa.Column("external_username", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"external_metadata",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column("new_chat_thread_id", sa.Integer(), nullable=True),
|
||||
sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("suspended_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column("suspended_reason", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() AT TIME ZONE 'utc')"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() AT TIME ZONE 'utc')"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"], ["external_chat_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["new_chat_thread_id"], ["new_chat_threads.id"], ondelete="SET NULL"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_bindings_account_peer_active",
|
||||
"external_chat_bindings",
|
||||
["account_id", "external_peer_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"state IN ('bound', 'suspended') AND external_peer_id IS NOT NULL"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_bindings_pairing_code_pending",
|
||||
"external_chat_bindings",
|
||||
["pairing_code"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("state = 'pending'"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_external_chat_bindings_user_state",
|
||||
"external_chat_bindings",
|
||||
["user_id", "state"],
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_external_chat_bindings_search_space_state",
|
||||
"external_chat_bindings",
|
||||
["search_space_id", "state"],
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
if not _table_exists(conn, "external_chat_inbound_events"):
|
||||
op.create_table(
|
||||
"external_chat_inbound_events",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("account_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("external_chat_binding_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("platform", external_chat_platform_enum, nullable=False),
|
||||
sa.Column("event_dedupe_key", sa.Text(), nullable=False),
|
||||
sa.Column("external_event_id", sa.Text(), nullable=True),
|
||||
sa.Column("external_message_id", sa.Text(), nullable=True),
|
||||
sa.Column("event_kind", external_chat_event_kind_enum, nullable=False),
|
||||
sa.Column(
|
||||
"raw_payload",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("request_id", sa.String(64), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
external_chat_event_status_enum,
|
||||
nullable=False,
|
||||
server_default="received",
|
||||
),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"received_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() AT TIME ZONE 'utc')"),
|
||||
),
|
||||
sa.Column("processed_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("(now() AT TIME ZONE 'utc')"),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"], ["external_chat_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["external_chat_binding_id"], ["external_chat_bindings.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
"event_dedupe_key",
|
||||
name="uq_external_chat_inbound_account_dedupe_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_external_chat_inbound_status_received_at",
|
||||
"external_chat_inbound_events",
|
||||
["status", "received_at"],
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_external_chat_inbound_binding_received_at",
|
||||
"external_chat_inbound_events",
|
||||
["external_chat_binding_id", "received_at"],
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_external_chat_inbound_request_id",
|
||||
"external_chat_inbound_events",
|
||||
["request_id"],
|
||||
postgresql_where=sa.text("request_id IS NOT NULL"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
if not _column_exists(conn, "new_chat_threads", "source"):
|
||||
op.add_column(
|
||||
"new_chat_threads",
|
||||
sa.Column("source", sa.Text(), nullable=False, server_default="surfsense"),
|
||||
)
|
||||
op.alter_column("new_chat_threads", "source", type_=sa.Text())
|
||||
if not _column_exists(conn, "new_chat_threads", "external_chat_binding_id"):
|
||||
op.add_column(
|
||||
"new_chat_threads",
|
||||
sa.Column("external_chat_binding_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
if not _constraint_exists(
|
||||
conn, "new_chat_threads", "fk_new_chat_threads_external_chat_external_chat_binding_id"
|
||||
):
|
||||
op.create_foreign_key(
|
||||
"fk_new_chat_threads_external_chat_external_chat_binding_id",
|
||||
"new_chat_threads",
|
||||
"external_chat_bindings",
|
||||
["external_chat_binding_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_new_chat_threads_source", "new_chat_threads", ["source"], if_not_exists=True)
|
||||
op.create_index(
|
||||
"ix_new_chat_threads_external_chat_binding_id",
|
||||
"new_chat_threads",
|
||||
["external_chat_binding_id"],
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
if not _column_exists(conn, "new_chat_messages", "source"):
|
||||
op.add_column(
|
||||
"new_chat_messages",
|
||||
sa.Column("source", sa.Text(), nullable=False, server_default="surfsense"),
|
||||
)
|
||||
op.alter_column("new_chat_messages", "source", type_=sa.Text())
|
||||
if not _column_exists(conn, "new_chat_messages", "platform_metadata"):
|
||||
op.add_column(
|
||||
"new_chat_messages",
|
||||
sa.Column("platform_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_new_chat_messages_source",
|
||||
"new_chat_messages",
|
||||
["source"],
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_new_chat_messages_inbound_platform",
|
||||
"new_chat_messages",
|
||||
[
|
||||
"thread_id",
|
||||
sa.text("(platform_metadata->>'platform')"),
|
||||
sa.text("(platform_metadata->>'external_message_id')"),
|
||||
],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"platform_metadata IS NOT NULL "
|
||||
"AND platform_metadata->>'direction' = 'inbound'"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY FULL")
|
||||
|
||||
exists = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
|
||||
{"name": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
if exists:
|
||||
documents_has_zero_ver = _has_zero_version(conn, "documents")
|
||||
user_has_zero_ver = _has_zero_version(conn, "user")
|
||||
tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
|
||||
with tx:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-144-external-chat'"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
_build_set_table_ddl(
|
||||
documents_has_zero_ver=documents_has_zero_ver,
|
||||
user_has_zero_ver=user_has_zero_ver,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-144-external-chat'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
exists = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :name"),
|
||||
{"name": PUBLICATION_NAME},
|
||||
).fetchone()
|
||||
if exists:
|
||||
documents_has_zero_ver = _has_zero_version(conn, "documents")
|
||||
user_has_zero_ver = _has_zero_version(conn, "user")
|
||||
# Restore the publication shape from migration 143.
|
||||
doc_cols = DOCUMENT_COLS + (['"_0_version"'] if documents_has_zero_ver else [])
|
||||
user_cols = USER_COLS + (['"_0_version"'] if user_has_zero_ver else [])
|
||||
ddl = (
|
||||
f"ALTER PUBLICATION {PUBLICATION_NAME} SET TABLE "
|
||||
f"notifications, "
|
||||
f"documents ({_cols(doc_cols)}), "
|
||||
f"folders, "
|
||||
f"search_source_connectors, "
|
||||
f"new_chat_messages, "
|
||||
f"chat_comments, "
|
||||
f"chat_session_state, "
|
||||
f'"user" ({_cols(user_cols)})'
|
||||
)
|
||||
tx = conn.begin_nested() if conn.in_transaction() else conn.begin()
|
||||
with tx:
|
||||
conn.execute(
|
||||
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'pre-144-downgrade'")
|
||||
)
|
||||
conn.execute(sa.text(ddl))
|
||||
conn.execute(
|
||||
sa.text(f"COMMENT ON PUBLICATION {PUBLICATION_NAME} IS 'post-144-downgrade'")
|
||||
)
|
||||
|
||||
if _column_exists(conn, "new_chat_messages", "source"):
|
||||
op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY DEFAULT")
|
||||
_drop_index_if_exists("uq_new_chat_messages_inbound_platform", "new_chat_messages")
|
||||
_drop_index_if_exists("ix_new_chat_messages_source", "new_chat_messages")
|
||||
_drop_column_if_exists("new_chat_messages", "platform_metadata")
|
||||
_drop_column_if_exists("new_chat_messages", "source")
|
||||
|
||||
_drop_index_if_exists("ix_new_chat_threads_external_chat_binding_id", "new_chat_threads")
|
||||
_drop_index_if_exists("ix_new_chat_threads_source", "new_chat_threads")
|
||||
if _constraint_exists(
|
||||
conn, "new_chat_threads", "fk_new_chat_threads_external_chat_external_chat_binding_id"
|
||||
):
|
||||
op.drop_constraint(
|
||||
"fk_new_chat_threads_external_chat_external_chat_binding_id",
|
||||
"new_chat_threads",
|
||||
type_="foreignkey",
|
||||
)
|
||||
_drop_column_if_exists("new_chat_threads", "external_chat_binding_id")
|
||||
_drop_column_if_exists("new_chat_threads", "source")
|
||||
|
||||
_drop_index_if_exists(
|
||||
"ix_external_chat_inbound_binding_received_at", "external_chat_inbound_events"
|
||||
)
|
||||
_drop_index_if_exists("ix_external_chat_inbound_request_id", "external_chat_inbound_events")
|
||||
_drop_index_if_exists("ix_external_chat_inbound_status_received_at", "external_chat_inbound_events")
|
||||
if _table_exists(conn, "external_chat_inbound_events"):
|
||||
op.drop_table("external_chat_inbound_events")
|
||||
|
||||
_drop_index_if_exists(
|
||||
"ix_external_chat_bindings_search_space_state",
|
||||
"external_chat_bindings",
|
||||
)
|
||||
_drop_index_if_exists(
|
||||
"ix_external_chat_bindings_user_state", "external_chat_bindings"
|
||||
)
|
||||
_drop_index_if_exists(
|
||||
"uq_external_chat_bindings_pairing_code_pending",
|
||||
"external_chat_bindings",
|
||||
)
|
||||
_drop_index_if_exists(
|
||||
"uq_external_chat_bindings_account_peer_active",
|
||||
"external_chat_bindings",
|
||||
)
|
||||
if _table_exists(conn, "external_chat_bindings"):
|
||||
op.drop_table("external_chat_bindings")
|
||||
|
||||
_drop_index_if_exists("uq_external_chat_accounts_system_platform", "external_chat_accounts")
|
||||
_drop_index_if_exists("uq_external_chat_accounts_owner_platform", "external_chat_accounts")
|
||||
_drop_index_if_exists("uq_external_chat_accounts_webhook_secret", "external_chat_accounts")
|
||||
if _table_exists(conn, "external_chat_accounts"):
|
||||
op.drop_table("external_chat_accounts")
|
||||
|
||||
for enum_name in (
|
||||
"external_chat_event_status",
|
||||
"external_chat_event_kind",
|
||||
"external_chat_peer_kind",
|
||||
"external_chat_binding_state",
|
||||
"external_chat_health_status",
|
||||
"external_chat_account_mode",
|
||||
"external_chat_platform",
|
||||
):
|
||||
postgresql.ENUM(name=enum_name).drop(conn, checkfirst=True)
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"""add slack gateway platform
|
||||
|
||||
Revision ID: 150
|
||||
Revises: 149
|
||||
Create Date: 2026-05-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "150"
|
||||
down_revision: str | None = "149"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _enum_value_exists(enum_name: str, value: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_enum e "
|
||||
"JOIN pg_type t ON t.oid = e.enumtypid "
|
||||
"WHERE t.typname = :enum_name AND e.enumlabel = :value"
|
||||
),
|
||||
{"enum_name": enum_name, "value": value},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_indexes "
|
||||
"WHERE schemaname = current_schema() AND indexname = :index_name"
|
||||
),
|
||||
{"index_name": index_name},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _enum_value_exists("external_chat_platform", "slack"):
|
||||
op.execute("ALTER TYPE external_chat_platform ADD VALUE 'slack'")
|
||||
|
||||
if _index_exists("uq_external_chat_accounts_system_platform"):
|
||||
op.drop_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
table_name="external_chat_accounts",
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
"external_chat_accounts",
|
||||
["platform"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"is_system_account = true AND NOT (cursor_state ? 'team_id')"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_slack_team",
|
||||
"external_chat_accounts",
|
||||
["platform", sa.text("(cursor_state ->> 'team_id')")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"is_system_account = true AND cursor_state ? 'team_id'"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _index_exists("uq_external_chat_accounts_slack_team"):
|
||||
op.drop_index(
|
||||
"uq_external_chat_accounts_slack_team",
|
||||
table_name="external_chat_accounts",
|
||||
)
|
||||
if _index_exists("uq_external_chat_accounts_system_platform"):
|
||||
op.drop_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
table_name="external_chat_accounts",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
"external_chat_accounts",
|
||||
["platform"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_system_account = true"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
# PostgreSQL enum values are intentionally not removed on downgrade.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
"""add discord gateway platform
|
||||
|
||||
Revision ID: 151
|
||||
Revises: 150
|
||||
Create Date: 2026-06-01
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "151"
|
||||
down_revision: str | None = "150"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _enum_value_exists(enum_name: str, value: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_enum e "
|
||||
"JOIN pg_type t ON t.oid = e.enumtypid "
|
||||
"WHERE t.typname = :enum_name AND e.enumlabel = :value"
|
||||
),
|
||||
{"enum_name": enum_name, "value": value},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _index_exists(index_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
return (
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM pg_indexes "
|
||||
"WHERE schemaname = current_schema() AND indexname = :index_name"
|
||||
),
|
||||
{"index_name": index_name},
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _enum_value_exists("external_chat_platform", "discord"):
|
||||
op.execute("ALTER TYPE external_chat_platform ADD VALUE 'discord'")
|
||||
|
||||
if _index_exists("uq_external_chat_accounts_system_platform"):
|
||||
op.drop_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
table_name="external_chat_accounts",
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
"external_chat_accounts",
|
||||
["platform"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"is_system_account = true "
|
||||
"AND NOT (cursor_state ? 'team_id') "
|
||||
"AND NOT (cursor_state ? 'guild_id')"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_discord_guild",
|
||||
"external_chat_accounts",
|
||||
["platform", sa.text("(cursor_state ->> 'guild_id')")],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"is_system_account = true AND cursor_state ? 'guild_id'"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _index_exists("uq_external_chat_accounts_discord_guild"):
|
||||
op.drop_index(
|
||||
"uq_external_chat_accounts_discord_guild",
|
||||
table_name="external_chat_accounts",
|
||||
)
|
||||
if _index_exists("uq_external_chat_accounts_system_platform"):
|
||||
op.drop_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
table_name="external_chat_accounts",
|
||||
)
|
||||
op.create_index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
"external_chat_accounts",
|
||||
["platform"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text(
|
||||
"is_system_account = true AND NOT (cursor_state ? 'team_id')"
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
# PostgreSQL enum values are intentionally not removed on downgrade.
|
||||
|
|
@ -37,6 +37,18 @@ from app.config import (
|
|||
)
|
||||
from app.db import User, create_db_and_tables, get_async_session
|
||||
from app.exceptions import GENERIC_5XX_MESSAGE, ISSUES_URL, SurfSenseError
|
||||
from app.gateway.byo_long_poll import (
|
||||
start_byo_long_poll_supervisors,
|
||||
stop_byo_long_poll_supervisors,
|
||||
)
|
||||
from app.gateway.discord.intake import (
|
||||
start_discord_gateway_supervisor,
|
||||
stop_discord_gateway_supervisor,
|
||||
)
|
||||
from app.gateway.inbox_worker import (
|
||||
start_gateway_inbox_worker,
|
||||
stop_gateway_inbox_worker,
|
||||
)
|
||||
from app.observability import metrics as ot_metrics
|
||||
from app.observability.bootstrap import init_otel, shutdown_otel
|
||||
from app.rate_limiter import get_real_client_ip, limiter
|
||||
|
|
@ -591,12 +603,19 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
register_session_hooks()
|
||||
log_system_snapshot("startup_complete")
|
||||
await start_gateway_inbox_worker()
|
||||
await start_byo_long_poll_supervisors()
|
||||
await start_discord_gateway_supervisor()
|
||||
|
||||
yield
|
||||
|
||||
_stop_openrouter_background_refresh()
|
||||
await close_checkpointer()
|
||||
shutdown_otel()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_discord_gateway_supervisor()
|
||||
await stop_byo_long_poll_supervisors()
|
||||
await stop_gateway_inbox_worker()
|
||||
_stop_openrouter_background_refresh()
|
||||
await close_checkpointer()
|
||||
shutdown_otel()
|
||||
|
||||
|
||||
def registration_allowed():
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ celery_app = Celery(
|
|||
"app.tasks.celery_tasks.document_reindex_tasks",
|
||||
"app.tasks.celery_tasks.stale_notification_cleanup_task",
|
||||
"app.tasks.celery_tasks.stripe_reconciliation_task",
|
||||
"app.tasks.celery_tasks.gateway_tasks",
|
||||
"app.automations.tasks.execute_run",
|
||||
"app.automations.triggers.builtin.schedule.selector",
|
||||
"app.automations.triggers.builtin.event.selector",
|
||||
|
|
@ -245,6 +246,9 @@ celery_app.conf.update(
|
|||
"index_obsidian_attachment": {"queue": CONNECTORS_QUEUE},
|
||||
# Everything else (document processing, podcasts, reindexing,
|
||||
# schedule checker, cleanup) stays on the default fast queue.
|
||||
"gateway.reconcile_inbox": {"queue": f"{CELERY_TASK_DEFAULT_QUEUE}.gateway"},
|
||||
"gateway.health_check": {"queue": f"{CELERY_TASK_DEFAULT_QUEUE}.gateway"},
|
||||
"gateway.retention_sweep": {"queue": f"{CELERY_TASK_DEFAULT_QUEUE}.gateway"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -291,6 +295,21 @@ celery_app.conf.beat_schedule = {
|
|||
"expires": 60,
|
||||
},
|
||||
},
|
||||
"gateway-reconcile-inbox": {
|
||||
"task": "gateway.reconcile_inbox",
|
||||
"schedule": crontab(minute="*"),
|
||||
"options": {"expires": 60},
|
||||
},
|
||||
"gateway-health-check": {
|
||||
"task": "gateway.health_check",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"options": {"expires": 120},
|
||||
},
|
||||
"gateway-retention-sweep": {
|
||||
"task": "gateway.retention_sweep",
|
||||
"schedule": crontab(hour="3", minute="17"),
|
||||
"options": {"expires": 600},
|
||||
},
|
||||
# Fire due automation schedule triggers (Beat entry owned by the schedule
|
||||
# trigger; see app.automations.triggers.builtin.schedule.source).
|
||||
**SCHEDULE_BEAT_SCHEDULE,
|
||||
|
|
|
|||
|
|
@ -541,6 +541,45 @@ class Config:
|
|||
# Backend URL to override the http to https in the OAuth redirect URI
|
||||
BACKEND_URL = os.getenv("BACKEND_URL")
|
||||
|
||||
# Messaging gateway (Telegram v1)
|
||||
TELEGRAM_SHARED_BOT_TOKEN = os.getenv("TELEGRAM_SHARED_BOT_TOKEN")
|
||||
TELEGRAM_SHARED_BOT_USERNAME = os.getenv("TELEGRAM_SHARED_BOT_USERNAME")
|
||||
TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET")
|
||||
GATEWAY_BASE_URL = os.getenv("GATEWAY_BASE_URL", BACKEND_URL)
|
||||
GATEWAY_TELEGRAM_INTAKE_MODE = os.getenv(
|
||||
"GATEWAY_TELEGRAM_INTAKE_MODE", "webhook"
|
||||
).lower()
|
||||
if GATEWAY_TELEGRAM_INTAKE_MODE not in {"webhook", "longpoll", "disabled"}:
|
||||
raise ValueError(
|
||||
"GATEWAY_TELEGRAM_INTAKE_MODE must be one of: webhook, longpoll, disabled"
|
||||
)
|
||||
WHATSAPP_SHARED_BUSINESS_TOKEN = os.getenv("WHATSAPP_SHARED_BUSINESS_TOKEN")
|
||||
WHATSAPP_SHARED_PHONE_NUMBER_ID = os.getenv("WHATSAPP_SHARED_PHONE_NUMBER_ID")
|
||||
WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER = os.getenv(
|
||||
"WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER"
|
||||
)
|
||||
WHATSAPP_SHARED_WABA_ID = os.getenv("WHATSAPP_SHARED_WABA_ID")
|
||||
WHATSAPP_GRAPH_API_VERSION = os.getenv("WHATSAPP_GRAPH_API_VERSION", "v25.0")
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN = os.getenv("WHATSAPP_WEBHOOK_VERIFY_TOKEN")
|
||||
WHATSAPP_WEBHOOK_APP_SECRET = os.getenv("WHATSAPP_WEBHOOK_APP_SECRET")
|
||||
WHATSAPP_BRIDGE_URL = os.getenv("WHATSAPP_BRIDGE_URL", "http://whatsapp-bridge:9929")
|
||||
GATEWAY_WHATSAPP_INTAKE_MODE = os.getenv(
|
||||
"GATEWAY_WHATSAPP_INTAKE_MODE", "disabled"
|
||||
).lower()
|
||||
if GATEWAY_WHATSAPP_INTAKE_MODE not in {"cloud", "baileys", "disabled"}:
|
||||
raise ValueError(
|
||||
"GATEWAY_WHATSAPP_INTAKE_MODE must be one of: cloud, baileys, disabled"
|
||||
)
|
||||
GATEWAY_SLACK_CLIENT_ID = os.getenv("SLACK_CLIENT_ID")
|
||||
GATEWAY_SLACK_CLIENT_SECRET = os.getenv("SLACK_CLIENT_SECRET")
|
||||
GATEWAY_SLACK_ENABLED = os.getenv("GATEWAY_SLACK_ENABLED", "FALSE").upper() == "TRUE"
|
||||
GATEWAY_SLACK_SIGNING_SECRET = os.getenv("GATEWAY_SLACK_SIGNING_SECRET")
|
||||
GATEWAY_SLACK_REDIRECT_URI = os.getenv("GATEWAY_SLACK_REDIRECT_URI")
|
||||
GATEWAY_DISCORD_ENABLED = (
|
||||
os.getenv("GATEWAY_DISCORD_ENABLED", "FALSE").upper() == "TRUE"
|
||||
)
|
||||
GATEWAY_DISCORD_REDIRECT_URI = os.getenv("GATEWAY_DISCORD_REDIRECT_URI")
|
||||
|
||||
# Stripe checkout for pay-as-you-go page packs
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
|
||||
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from sqlalchemy import (
|
|||
TIMESTAMP,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Enum as SQLAlchemyEnum,
|
||||
ForeignKey,
|
||||
|
|
@ -587,6 +588,58 @@ class ChatVisibility(StrEnum):
|
|||
# PUBLIC = "PUBLIC" # Reserved for future implementation
|
||||
|
||||
|
||||
class ExternalChatPlatform(StrEnum):
|
||||
TELEGRAM = "telegram"
|
||||
WHATSAPP = "whatsapp"
|
||||
SLACK = "slack"
|
||||
DISCORD = "discord"
|
||||
SIGNAL = "signal"
|
||||
|
||||
|
||||
class ExternalChatAccountMode(StrEnum):
|
||||
CLOUD_SHARED = "cloud_shared"
|
||||
SELF_HOST_BYO = "self_host_byo"
|
||||
|
||||
|
||||
class ExternalChatHealthStatus(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
OK = "ok"
|
||||
FAILING = "failing"
|
||||
|
||||
|
||||
class ExternalChatBindingState(StrEnum):
|
||||
PENDING = "pending"
|
||||
BOUND = "bound"
|
||||
REVOKED = "revoked"
|
||||
SUSPENDED = "suspended"
|
||||
|
||||
|
||||
class ExternalChatPeerKind(StrEnum):
|
||||
DIRECT = "direct"
|
||||
GROUP = "group"
|
||||
CHANNEL = "channel"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ExternalChatEventKind(StrEnum):
|
||||
MESSAGE = "message"
|
||||
EDITED_MESSAGE = "edited_message"
|
||||
CALLBACK_QUERY = "callback_query"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class ExternalChatEventStatus(StrEnum):
|
||||
RECEIVED = "received"
|
||||
PROCESSING = "processing"
|
||||
PROCESSED = "processed"
|
||||
IGNORED = "ignored"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
def _enum_values(enum_cls):
|
||||
return [item.value for item in enum_cls]
|
||||
|
||||
|
||||
class NewChatThread(BaseModel, TimestampMixin):
|
||||
"""
|
||||
Thread model for the new chat feature using assistant-ui.
|
||||
|
|
@ -659,6 +712,16 @@ class NewChatThread(BaseModel, TimestampMixin):
|
|||
# agent_llm_id changes). Unindexed: all reads are by primary key.
|
||||
pinned_llm_config_id = Column(Integer, nullable=True)
|
||||
|
||||
# Surface metadata for first-party SurfSense and external chat threads.
|
||||
# Zero publishes all chat-message sources; the UI can decide which surfaces to render.
|
||||
source = Column(Text, nullable=False, default="surfsense", server_default="surfsense")
|
||||
external_chat_binding_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("external_chat_bindings.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
search_space = relationship("SearchSpace", back_populates="new_chat_threads")
|
||||
created_by = relationship("User", back_populates="new_chat_threads")
|
||||
|
|
@ -679,6 +742,11 @@ class NewChatThread(BaseModel, TimestampMixin):
|
|||
back_populates="thread",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
external_chat_binding = relationship(
|
||||
"ExternalChatBinding",
|
||||
foreign_keys=[external_chat_binding_id],
|
||||
back_populates="threads",
|
||||
)
|
||||
|
||||
|
||||
class NewChatMessage(BaseModel, TimestampMixin):
|
||||
|
|
@ -732,6 +800,11 @@ class NewChatMessage(BaseModel, TimestampMixin):
|
|||
# a message back to the LangGraph checkpoint that produced its turn.
|
||||
turn_id = Column(String(64), nullable=True, index=True)
|
||||
|
||||
# Mirrors the parent thread source for publication-level filtering.
|
||||
# This denormalization avoids join-dependent logical replication rules.
|
||||
source = Column(Text, nullable=False, default="surfsense", server_default="surfsense")
|
||||
platform_metadata = Column(JSONB, nullable=True)
|
||||
|
||||
# Relationships
|
||||
thread = relationship("NewChatThread", back_populates="messages")
|
||||
author = relationship("User")
|
||||
|
|
@ -748,6 +821,300 @@ class NewChatMessage(BaseModel, TimestampMixin):
|
|||
)
|
||||
|
||||
|
||||
class ExternalChatAccount(Base, TimestampMixin):
|
||||
__tablename__ = "external_chat_accounts"
|
||||
__allow_unmapped__ = True
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
platform = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatPlatform,
|
||||
name="external_chat_platform",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
mode = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatAccountMode,
|
||||
name="external_chat_account_mode",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
owner_user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
owner_search_space_id = Column(
|
||||
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
is_system_account = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
encrypted_credentials = Column(Text, nullable=True)
|
||||
bot_username = Column(String(255), nullable=True)
|
||||
webhook_secret = Column(String(64), nullable=True)
|
||||
cursor_state = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
|
||||
health_status = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatHealthStatus,
|
||||
name="external_chat_health_status",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
default=ExternalChatHealthStatus.UNKNOWN,
|
||||
server_default=ExternalChatHealthStatus.UNKNOWN.value,
|
||||
)
|
||||
last_health_check_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
suspended_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
suspended_reason = Column(Text, nullable=True)
|
||||
updated_at = Column(
|
||||
TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
server_default=text("(now() AT TIME ZONE 'utc')"),
|
||||
)
|
||||
|
||||
owner = relationship("User", foreign_keys=[owner_user_id])
|
||||
owner_search_space = relationship("SearchSpace", foreign_keys=[owner_search_space_id])
|
||||
bindings = relationship(
|
||||
"ExternalChatBinding",
|
||||
back_populates="account",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
inbound_events = relationship(
|
||||
"ExternalChatInboundEvent",
|
||||
back_populates="account",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(is_system_account = true AND owner_user_id IS NULL) OR "
|
||||
"(is_system_account = false AND owner_user_id IS NOT NULL)",
|
||||
name="ck_external_chat_accounts_owner_shape",
|
||||
),
|
||||
Index(
|
||||
"uq_external_chat_accounts_owner_platform",
|
||||
"owner_user_id",
|
||||
"platform",
|
||||
unique=True,
|
||||
postgresql_where=text("is_system_account = false"),
|
||||
),
|
||||
Index(
|
||||
"uq_external_chat_accounts_system_platform",
|
||||
"platform",
|
||||
unique=True,
|
||||
postgresql_where=text(
|
||||
"is_system_account = true "
|
||||
"AND NOT (cursor_state ? 'team_id') "
|
||||
"AND NOT (cursor_state ? 'guild_id')"
|
||||
),
|
||||
),
|
||||
Index(
|
||||
"uq_external_chat_accounts_slack_team",
|
||||
"platform",
|
||||
text("(cursor_state ->> 'team_id')"),
|
||||
unique=True,
|
||||
postgresql_where=text(
|
||||
"is_system_account = true AND cursor_state ? 'team_id'"
|
||||
),
|
||||
),
|
||||
Index(
|
||||
"uq_external_chat_accounts_discord_guild",
|
||||
"platform",
|
||||
text("(cursor_state ->> 'guild_id')"),
|
||||
unique=True,
|
||||
postgresql_where=text(
|
||||
"is_system_account = true AND cursor_state ? 'guild_id'"
|
||||
),
|
||||
),
|
||||
Index(
|
||||
"uq_external_chat_accounts_webhook_secret",
|
||||
"webhook_secret",
|
||||
unique=True,
|
||||
postgresql_where=text("webhook_secret IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExternalChatBinding(Base, TimestampMixin):
|
||||
__tablename__ = "external_chat_bindings"
|
||||
__allow_unmapped__ = True
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
account_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("external_chat_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
search_space_id = Column(
|
||||
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
state = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatBindingState,
|
||||
name="external_chat_binding_state",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
default=ExternalChatBindingState.PENDING,
|
||||
server_default=ExternalChatBindingState.PENDING.value,
|
||||
)
|
||||
pairing_code = Column(Text, nullable=True)
|
||||
pairing_code_expires_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
external_peer_id = Column(Text, nullable=True)
|
||||
external_peer_kind = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatPeerKind,
|
||||
name="external_chat_peer_kind",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
default=ExternalChatPeerKind.UNKNOWN,
|
||||
server_default=ExternalChatPeerKind.UNKNOWN.value,
|
||||
)
|
||||
external_thread_id = Column(Text, nullable=True)
|
||||
external_display_name = Column(Text, nullable=True)
|
||||
external_username = Column(Text, nullable=True)
|
||||
external_metadata = Column(JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
|
||||
new_chat_thread_id = Column(
|
||||
Integer,
|
||||
ForeignKey("new_chat_threads.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
revoked_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
suspended_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
suspended_reason = Column(Text, nullable=True)
|
||||
updated_at = Column(
|
||||
TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
server_default=text("(now() AT TIME ZONE 'utc')"),
|
||||
)
|
||||
|
||||
account = relationship("ExternalChatAccount", back_populates="bindings")
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
search_space = relationship("SearchSpace", foreign_keys=[search_space_id])
|
||||
new_chat_thread = relationship("NewChatThread", foreign_keys=[new_chat_thread_id])
|
||||
threads = relationship(
|
||||
"NewChatThread",
|
||||
back_populates="external_chat_binding",
|
||||
foreign_keys="NewChatThread.external_chat_binding_id",
|
||||
)
|
||||
inbound_events = relationship(
|
||||
"ExternalChatInboundEvent",
|
||||
back_populates="binding",
|
||||
foreign_keys="ExternalChatInboundEvent.external_chat_binding_id",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_external_chat_bindings_account_peer_active",
|
||||
"account_id",
|
||||
"external_peer_id",
|
||||
unique=True,
|
||||
postgresql_where=text(
|
||||
"state IN ('bound', 'suspended') AND external_peer_id IS NOT NULL"
|
||||
),
|
||||
),
|
||||
Index(
|
||||
"uq_external_chat_bindings_pairing_code_pending",
|
||||
"pairing_code",
|
||||
unique=True,
|
||||
postgresql_where=text("state = 'pending'"),
|
||||
),
|
||||
Index("ix_external_chat_bindings_user_state", "user_id", "state"),
|
||||
Index("ix_external_chat_bindings_search_space_state", "search_space_id", "state"),
|
||||
)
|
||||
|
||||
|
||||
class ExternalChatInboundEvent(Base, TimestampMixin):
|
||||
__tablename__ = "external_chat_inbound_events"
|
||||
__allow_unmapped__ = True
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
account_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("external_chat_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
external_chat_binding_id = Column(
|
||||
BigInteger,
|
||||
ForeignKey("external_chat_bindings.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
platform = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatPlatform,
|
||||
name="external_chat_platform",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
event_dedupe_key = Column(Text, nullable=False)
|
||||
external_event_id = Column(Text, nullable=True)
|
||||
external_message_id = Column(Text, nullable=True)
|
||||
event_kind = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatEventKind,
|
||||
name="external_chat_event_kind",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
raw_payload = Column(JSONB, nullable=True)
|
||||
request_id = Column(String(64), nullable=True)
|
||||
status = Column(
|
||||
SQLAlchemyEnum(
|
||||
ExternalChatEventStatus,
|
||||
name="external_chat_event_status",
|
||||
values_callable=_enum_values,
|
||||
),
|
||||
nullable=False,
|
||||
default=ExternalChatEventStatus.RECEIVED,
|
||||
server_default=ExternalChatEventStatus.RECEIVED.value,
|
||||
)
|
||||
attempt_count = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_error = Column(Text, nullable=True)
|
||||
received_at = Column(
|
||||
TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
server_default=text("(now() AT TIME ZONE 'utc')"),
|
||||
)
|
||||
processed_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
|
||||
account = relationship("ExternalChatAccount", back_populates="inbound_events")
|
||||
binding = relationship("ExternalChatBinding", back_populates="inbound_events")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"account_id",
|
||||
"event_dedupe_key",
|
||||
name="uq_external_chat_inbound_account_dedupe_key",
|
||||
),
|
||||
Index("ix_external_chat_inbound_status_received_at", "status", "received_at"),
|
||||
Index(
|
||||
"ix_external_chat_inbound_binding_received_at",
|
||||
"external_chat_binding_id",
|
||||
"received_at",
|
||||
),
|
||||
Index(
|
||||
"ix_external_chat_inbound_request_id",
|
||||
"request_id",
|
||||
postgresql_where=text("request_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TokenUsage(BaseModel, TimestampMixin):
|
||||
"""
|
||||
Tracks LLM token consumption per assistant turn.
|
||||
|
|
|
|||
2
surfsense_backend/app/gateway/__init__.py
Normal file
2
surfsense_backend/app/gateway/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Messaging gateway infrastructure for external chat channels."""
|
||||
|
||||
138
surfsense_backend/app/gateway/accounts.py
Normal file
138
surfsense_backend/app/gateway/accounts.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""External chat account helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ExternalChatAccount,
|
||||
ExternalChatAccountMode,
|
||||
ExternalChatHealthStatus,
|
||||
ExternalChatPlatform,
|
||||
)
|
||||
from app.utils.oauth_security import TokenEncryption
|
||||
|
||||
|
||||
def account_token(account: ExternalChatAccount) -> str | None:
|
||||
if account.is_system_account and account.platform == ExternalChatPlatform.TELEGRAM:
|
||||
return config.TELEGRAM_SHARED_BOT_TOKEN
|
||||
if not account.encrypted_credentials:
|
||||
return None
|
||||
return TokenEncryption(config.SECRET_KEY or "").decrypt_token(
|
||||
account.encrypted_credentials
|
||||
)
|
||||
|
||||
|
||||
def slack_account_credentials(account: ExternalChatAccount) -> dict:
|
||||
"""Decrypt Slack gateway credentials stored as encrypted JSON."""
|
||||
if not account.encrypted_credentials:
|
||||
return {}
|
||||
raw = TokenEncryption(config.SECRET_KEY or "").decrypt_token(account.encrypted_credentials)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# Backward-compatible fallback if a token string was stored directly.
|
||||
return {"bot_token": raw}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def discord_account_credentials(account: ExternalChatAccount) -> dict:
|
||||
"""Decrypt Discord gateway credentials stored as encrypted JSON."""
|
||||
if not account.encrypted_credentials:
|
||||
return {}
|
||||
raw = TokenEncryption(config.SECRET_KEY or "").decrypt_token(account.encrypted_credentials)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# Backward-compatible fallback if a token string was stored directly.
|
||||
return {"bot_token": raw}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
async def get_or_create_system_telegram_account(
|
||||
session: AsyncSession,
|
||||
) -> ExternalChatAccount:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.TELEGRAM,
|
||||
ExternalChatAccount.is_system_account.is_(True),
|
||||
)
|
||||
)
|
||||
account = result.scalars().first()
|
||||
if account is not None:
|
||||
return account
|
||||
account = ExternalChatAccount(
|
||||
platform=ExternalChatPlatform.TELEGRAM,
|
||||
mode=ExternalChatAccountMode.CLOUD_SHARED,
|
||||
is_system_account=True,
|
||||
bot_username=config.TELEGRAM_SHARED_BOT_USERNAME,
|
||||
webhook_secret=config.TELEGRAM_WEBHOOK_SECRET,
|
||||
cursor_state={},
|
||||
health_status=ExternalChatHealthStatus.UNKNOWN,
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
return account
|
||||
|
||||
|
||||
async def get_or_create_system_whatsapp_account(
|
||||
session: AsyncSession,
|
||||
) -> ExternalChatAccount:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP,
|
||||
ExternalChatAccount.is_system_account.is_(True),
|
||||
)
|
||||
)
|
||||
account = result.scalars().first()
|
||||
if account is not None:
|
||||
return account
|
||||
account = ExternalChatAccount(
|
||||
platform=ExternalChatPlatform.WHATSAPP,
|
||||
mode=ExternalChatAccountMode.CLOUD_SHARED,
|
||||
is_system_account=True,
|
||||
cursor_state={
|
||||
"phone_number_id": config.WHATSAPP_SHARED_PHONE_NUMBER_ID,
|
||||
"display_phone_number": config.WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER,
|
||||
"waba_id": config.WHATSAPP_SHARED_WABA_ID,
|
||||
},
|
||||
health_status=ExternalChatHealthStatus.UNKNOWN,
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
return account
|
||||
|
||||
|
||||
async def get_slack_account_by_team(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
team_id: str,
|
||||
) -> ExternalChatAccount | None:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.SLACK,
|
||||
ExternalChatAccount.is_system_account.is_(True),
|
||||
ExternalChatAccount.cursor_state["team_id"].astext == team_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_discord_account_by_guild(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
guild_id: str,
|
||||
) -> ExternalChatAccount | None:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.DISCORD,
|
||||
ExternalChatAccount.is_system_account.is_(True),
|
||||
ExternalChatAccount.cursor_state["guild_id"].astext == guild_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
101
surfsense_backend/app/gateway/agent_invoke.py
Normal file
101
surfsense_backend/app/gateway/agent_invoke.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Invoke SurfSense chat agent for external chat surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import ExternalChatBinding, NewChatMessage
|
||||
from app.gateway.auth_invariant import assert_authorization_invariant
|
||||
from app.gateway.base.translator import BaseStreamTranslator, GatewayStreamEvent
|
||||
from app.gateway.bindings import get_or_create_thread_for_binding
|
||||
from app.gateway.hitl_filter import DEFAULT_HITL_TOOL_NAMES
|
||||
from app.gateway.thread_lock import acquire_thread_lock, release_thread_lock
|
||||
from app.observability.metrics import record_gateway_turn_latency
|
||||
from app.tasks.chat.stream_new_chat import stream_new_chat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _events_from_sse(chunks: AsyncIterator[str]) -> AsyncIterator[GatewayStreamEvent]:
|
||||
saw_text = False
|
||||
async for chunk in chunks:
|
||||
for raw_line in chunk.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload = line.removeprefix("data:").strip()
|
||||
if payload == "[DONE]":
|
||||
logger.info("Gateway SSE normalized: done")
|
||||
yield GatewayStreamEvent(type="done")
|
||||
continue
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
event_type = str(data.get("type") or "")
|
||||
if event_type == "text-delta":
|
||||
delta = data.get("delta", "")
|
||||
if delta and not saw_text:
|
||||
logger.info("Gateway SSE normalized: text stream started")
|
||||
saw_text = True
|
||||
yield GatewayStreamEvent(type="text-delta", data={"delta": delta})
|
||||
elif event_type in {"finish", "done"}:
|
||||
logger.info("Gateway SSE normalized: %s", event_type)
|
||||
yield GatewayStreamEvent(type="finish", data=data)
|
||||
elif event_type == "data-interrupt-request":
|
||||
logger.info("Gateway SSE normalized: interrupt request")
|
||||
yield GatewayStreamEvent(type="data-interrupt-request", data=data)
|
||||
|
||||
|
||||
async def call_agent_for_gateway(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
binding: ExternalChatBinding,
|
||||
user_text: str,
|
||||
translator: BaseStreamTranslator,
|
||||
platform_label: str = "telegram",
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
user = await assert_authorization_invariant(session, binding)
|
||||
thread = await get_or_create_thread_for_binding(session, binding)
|
||||
await session.commit()
|
||||
|
||||
if not acquire_thread_lock(thread.id):
|
||||
raise RuntimeError("gateway_thread_busy")
|
||||
|
||||
try:
|
||||
stream = stream_new_chat(
|
||||
user_query=user_text,
|
||||
search_space_id=binding.search_space_id,
|
||||
chat_id=thread.id,
|
||||
user_id=str(user.id),
|
||||
needs_history_bootstrap=thread.needs_history_bootstrap,
|
||||
thread_visibility=thread.visibility,
|
||||
current_user_display_name=user.display_name or "A team member",
|
||||
disabled_tools=sorted(DEFAULT_HITL_TOOL_NAMES),
|
||||
request_id=request_id or "gateway",
|
||||
)
|
||||
events = _events_from_sse(stream)
|
||||
try:
|
||||
await translator.translate(events)
|
||||
finally:
|
||||
await events.aclose()
|
||||
await stream.aclose()
|
||||
await session.execute(
|
||||
update(NewChatMessage)
|
||||
.where(
|
||||
NewChatMessage.thread_id == thread.id,
|
||||
NewChatMessage.source == "surfsense",
|
||||
)
|
||||
.values(source=platform_label)
|
||||
)
|
||||
await session.commit()
|
||||
record_gateway_turn_latency(0, platform=platform_label)
|
||||
finally:
|
||||
release_thread_lock(thread.id)
|
||||
|
||||
55
surfsense_backend/app/gateway/auth_invariant.py
Normal file
55
surfsense_backend/app/gateway/auth_invariant.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Authorization invariants for external-chat-routed turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import ExternalChatBinding, Permission, User
|
||||
from app.gateway.bindings import suspend_binding
|
||||
from app.observability.metrics import record_gateway_auth_invariant_failure
|
||||
from app.utils.rbac import check_permission, check_search_space_access
|
||||
|
||||
|
||||
class GatewaySuspendedError(RuntimeError):
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
async def _fail(
|
||||
session: AsyncSession,
|
||||
binding: ExternalChatBinding,
|
||||
reason: str,
|
||||
) -> None:
|
||||
suspend_binding(binding, reason)
|
||||
record_gateway_auth_invariant_failure(cause=reason)
|
||||
await session.flush()
|
||||
raise GatewaySuspendedError(reason)
|
||||
|
||||
|
||||
async def assert_authorization_invariant(
|
||||
session: AsyncSession,
|
||||
binding: ExternalChatBinding,
|
||||
) -> User:
|
||||
if binding.state != "bound":
|
||||
await _fail(session, binding, "binding_not_bound")
|
||||
|
||||
user = await session.get(User, binding.user_id)
|
||||
if user is None:
|
||||
await _fail(session, binding, "owner_missing")
|
||||
|
||||
try:
|
||||
await check_search_space_access(session, user, binding.search_space_id)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
binding.search_space_id,
|
||||
Permission.CHATS_CREATE.value,
|
||||
"External chat owner no longer has permission to chat in this search space",
|
||||
)
|
||||
except HTTPException as exc:
|
||||
await _fail(session, binding, f"rbac_{exc.status_code}")
|
||||
|
||||
return user
|
||||
|
||||
2
surfsense_backend/app/gateway/base/__init__.py
Normal file
2
surfsense_backend/app/gateway/base/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Base gateway interfaces."""
|
||||
|
||||
70
surfsense_backend/app/gateway/base/adapter.py
Normal file
70
surfsense_backend/app/gateway/base/adapter.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Platform adapter interfaces for messaging gateways."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedInboundEvent:
|
||||
platform: str
|
||||
event_kind: str
|
||||
external_peer_id: str | None
|
||||
external_peer_kind: str
|
||||
external_message_id: str | None
|
||||
external_user_id: str | None
|
||||
text: str | None
|
||||
raw_payload: dict[str, Any]
|
||||
display_name: str | None = None
|
||||
username: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformSendResult:
|
||||
external_message_id: str
|
||||
raw_response: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
platform: str
|
||||
|
||||
@abstractmethod
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
"""Parse a provider webhook/update into the gateway's normalized shape."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
"""Send a new platform message."""
|
||||
|
||||
@abstractmethod
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
"""Edit an existing platform message."""
|
||||
|
||||
@abstractmethod
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
"""Validate configured credentials and return account metadata."""
|
||||
|
||||
async def fetch_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Yield provider updates for long-polling adapters."""
|
||||
if False:
|
||||
yield {} # pragma: no cover
|
||||
raise NotImplementedError("This adapter does not support long-polling")
|
||||
|
||||
41
surfsense_backend/app/gateway/base/commands.py
Normal file
41
surfsense_backend/app/gateway/base/commands.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Provider-neutral command hooks for external chat gateways."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gateway.base.adapter import BasePlatformAdapter, ParsedInboundEvent
|
||||
|
||||
|
||||
def command_name(text: str | None) -> str | None:
|
||||
if not text or not text.startswith("/"):
|
||||
return None
|
||||
return text.split(maxsplit=1)[0].split("@", 1)[0].lower()
|
||||
|
||||
|
||||
class BaseGatewayCommands:
|
||||
"""Default command behavior for platforms without slash-command onboarding."""
|
||||
|
||||
async def handle_start_command(
|
||||
self,
|
||||
*,
|
||||
session,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def handle_help_command(
|
||||
self,
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
self,
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
return None
|
||||
38
surfsense_backend/app/gateway/base/formatting.py
Normal file
38
surfsense_backend/app/gateway/base/formatting.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Provider-neutral message formatting helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MAX_GATEWAY_TEXT_CHARS = 4096
|
||||
|
||||
|
||||
def split_text_message(
|
||||
text: str,
|
||||
*,
|
||||
max_chars: int = MAX_GATEWAY_TEXT_CHARS,
|
||||
) -> list[str]:
|
||||
"""Split outbound text at readable boundaries without exceeding platform caps."""
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
if len(remaining) <= max_chars:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
|
||||
candidate = remaining[:max_chars]
|
||||
boundary = max(
|
||||
candidate.rfind("\n\n"),
|
||||
candidate.rfind("\n"),
|
||||
candidate.rfind(". "),
|
||||
candidate.rfind(" "),
|
||||
)
|
||||
if boundary <= max(200, max_chars // 2):
|
||||
boundary = max_chars
|
||||
split_at = boundary + (2 if candidate[boundary : boundary + 2] == ". " else 1)
|
||||
chunk = remaining[:split_at].rstrip()
|
||||
chunks.append(chunk or remaining[:max_chars])
|
||||
remaining = remaining[split_at:].lstrip()
|
||||
|
||||
return chunks
|
||||
19
surfsense_backend/app/gateway/base/identity.py
Normal file
19
surfsense_backend/app/gateway/base/identity.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Gateway identity helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
def normalize_external_peer_id(value: str | int | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def hash_external_id(value: str | int | None) -> str | None:
|
||||
normalized = normalize_external_peer_id(value)
|
||||
if not normalized:
|
||||
return None
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
28
surfsense_backend/app/gateway/base/translator.py
Normal file
28
surfsense_backend/app/gateway/base/translator.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Base stream translator for platform-specific outbound UX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayStreamEvent:
|
||||
"""Small provider-neutral event shape consumed by translators.
|
||||
|
||||
The existing chat stack emits Vercel/assistant-ui events. Gateway code
|
||||
normalizes the subset it needs into this shape before handing it to the
|
||||
platform translator.
|
||||
"""
|
||||
|
||||
type: str
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BaseStreamTranslator(ABC):
|
||||
@abstractmethod
|
||||
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
|
||||
"""Consume agent stream events and emit platform messages."""
|
||||
|
||||
67
surfsense_backend/app/gateway/bindings.py
Normal file
67
surfsense_backend/app/gateway/bindings.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""External chat binding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import (
|
||||
ChatVisibility,
|
||||
ExternalChatBinding,
|
||||
ExternalChatBindingState,
|
||||
NewChatThread,
|
||||
)
|
||||
|
||||
|
||||
async def get_or_create_thread_for_binding(
|
||||
session: AsyncSession,
|
||||
binding: ExternalChatBinding,
|
||||
) -> NewChatThread:
|
||||
if binding.new_chat_thread_id is not None:
|
||||
result = await session.execute(
|
||||
select(NewChatThread).where(NewChatThread.id == binding.new_chat_thread_id)
|
||||
)
|
||||
thread = result.scalars().first()
|
||||
if thread is not None and not thread.archived:
|
||||
return thread
|
||||
|
||||
source = str((binding.external_metadata or {}).get("platform") or "").strip()
|
||||
if not source:
|
||||
kind = str((binding.external_metadata or {}).get("kind") or "")
|
||||
source = "slack" if kind.startswith("slack_") else "telegram"
|
||||
|
||||
thread = NewChatThread(
|
||||
title=f"{source.title()} chat",
|
||||
search_space_id=binding.search_space_id,
|
||||
created_by_id=binding.user_id,
|
||||
visibility=ChatVisibility.PRIVATE,
|
||||
source=source,
|
||||
external_chat_binding_id=binding.id,
|
||||
)
|
||||
session.add(thread)
|
||||
await session.flush()
|
||||
binding.new_chat_thread_id = thread.id
|
||||
return thread
|
||||
|
||||
|
||||
def suspend_binding(binding: ExternalChatBinding, reason: str) -> None:
|
||||
now = datetime.now(UTC)
|
||||
binding.state = ExternalChatBindingState.SUSPENDED
|
||||
binding.suspended_at = now
|
||||
binding.suspended_reason = reason
|
||||
|
||||
|
||||
def revoke_binding(binding: ExternalChatBinding) -> None:
|
||||
now = datetime.now(UTC)
|
||||
binding.state = ExternalChatBindingState.REVOKED
|
||||
binding.revoked_at = now
|
||||
binding.new_chat_thread_id = None
|
||||
|
||||
|
||||
def resume_binding(binding: ExternalChatBinding) -> None:
|
||||
binding.state = ExternalChatBindingState.BOUND
|
||||
binding.suspended_at = None
|
||||
binding.suspended_reason = None
|
||||
|
||||
157
surfsense_backend/app/gateway/byo_long_poll.py
Normal file
157
surfsense_backend/app/gateway/byo_long_poll.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""FastAPI lifespan integration for self-hosted BYO Telegram long-polling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ExternalChatAccount,
|
||||
ExternalChatAccountMode,
|
||||
ExternalChatPlatform,
|
||||
async_session_maker,
|
||||
)
|
||||
from app.gateway.accounts import account_token
|
||||
from app.gateway.inbox import persist_inbound_event
|
||||
from app.gateway.runner import _run_telegram_account
|
||||
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
|
||||
from app.observability.metrics import record_gateway_inbox_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_tasks: set[asyncio.Task[None]] = set()
|
||||
_shutdown_event: asyncio.Event | None = None
|
||||
|
||||
|
||||
async def _sleep_or_shutdown(seconds: float) -> None:
|
||||
if _shutdown_event is None:
|
||||
await asyncio.sleep(seconds)
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(_shutdown_event.wait(), timeout=seconds)
|
||||
except TimeoutError:
|
||||
return
|
||||
|
||||
|
||||
async def _byo_account_supervisor(account_id: int, token: str) -> None:
|
||||
while _shutdown_event is None or not _shutdown_event.is_set():
|
||||
try:
|
||||
await _run_telegram_account(account_id, token)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"BYO Telegram long-poll failed account_id=%s; retrying in 30s",
|
||||
account_id,
|
||||
)
|
||||
await _sleep_or_shutdown(30)
|
||||
|
||||
|
||||
async def _whatsapp_baileys_supervisor() -> None:
|
||||
adapter = WhatsAppBaileysAdapter()
|
||||
while _shutdown_event is None or not _shutdown_event.is_set():
|
||||
try:
|
||||
async for raw_event in adapter.fetch_updates(offset=None):
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP,
|
||||
ExternalChatAccount.mode == ExternalChatAccountMode.SELF_HOST_BYO,
|
||||
ExternalChatAccount.is_system_account.is_(False),
|
||||
ExternalChatAccount.suspended_at.is_(None),
|
||||
)
|
||||
)
|
||||
account = result.scalars().first()
|
||||
if account is None:
|
||||
continue
|
||||
message_id = str(raw_event.get("messageId") or "")
|
||||
if not message_id:
|
||||
continue
|
||||
inbox_id = await persist_inbound_event(
|
||||
session,
|
||||
account_id=account.id,
|
||||
platform=ExternalChatPlatform.WHATSAPP,
|
||||
event_dedupe_key=f"baileys:{message_id}",
|
||||
external_event_id=message_id,
|
||||
external_message_id=message_id,
|
||||
event_kind="message",
|
||||
raw_payload=raw_event,
|
||||
)
|
||||
await session.commit()
|
||||
record_gateway_inbox_write(
|
||||
platform="whatsapp",
|
||||
dedup_skipped=inbox_id is None,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("WhatsApp Baileys intake failed; retrying in 10s")
|
||||
await _sleep_or_shutdown(10)
|
||||
|
||||
|
||||
async def start_byo_long_poll_supervisors() -> None:
|
||||
"""Start one BYO long-poll supervisor per active non-system Telegram account."""
|
||||
|
||||
global _shutdown_event
|
||||
if (
|
||||
config.GATEWAY_TELEGRAM_INTAKE_MODE != "longpoll"
|
||||
and config.GATEWAY_WHATSAPP_INTAKE_MODE != "baileys"
|
||||
):
|
||||
return
|
||||
if _tasks:
|
||||
return
|
||||
|
||||
_shutdown_event = asyncio.Event()
|
||||
if config.GATEWAY_TELEGRAM_INTAKE_MODE == "longpoll":
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.TELEGRAM,
|
||||
ExternalChatAccount.is_system_account.is_(False),
|
||||
ExternalChatAccount.suspended_at.is_(None),
|
||||
)
|
||||
)
|
||||
accounts = list(result.scalars())
|
||||
|
||||
for account in accounts:
|
||||
token = account_token(account)
|
||||
if not token:
|
||||
continue
|
||||
task = asyncio.create_task(
|
||||
_byo_account_supervisor(int(account.id), token),
|
||||
name=f"gateway-byo-telegram-{account.id}",
|
||||
)
|
||||
_tasks.add(task)
|
||||
task.add_done_callback(_tasks.discard)
|
||||
logger.info("Started BYO Telegram long-poll supervisor account_id=%s", account.id)
|
||||
|
||||
if config.GATEWAY_WHATSAPP_INTAKE_MODE == "baileys":
|
||||
task = asyncio.create_task(
|
||||
_whatsapp_baileys_supervisor(),
|
||||
name="gateway-byo-whatsapp-baileys",
|
||||
)
|
||||
_tasks.add(task)
|
||||
task.add_done_callback(_tasks.discard)
|
||||
logger.info("Started WhatsApp Baileys bridge intake supervisor")
|
||||
|
||||
|
||||
async def stop_byo_long_poll_supervisors() -> None:
|
||||
"""Cancel and await all BYO long-poll supervisors."""
|
||||
|
||||
global _shutdown_event
|
||||
if _shutdown_event is not None:
|
||||
_shutdown_event.set()
|
||||
tasks = list(_tasks)
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=10)
|
||||
except TimeoutError:
|
||||
logger.warning("Timed out waiting for BYO Telegram long-poll supervisors to stop")
|
||||
_tasks.clear()
|
||||
_shutdown_event = None
|
||||
|
||||
1
surfsense_backend/app/gateway/discord/__init__.py
Normal file
1
surfsense_backend/app/gateway/discord/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Discord gateway platform integration."""
|
||||
135
surfsense_backend/app/gateway/discord/adapter.py
Normal file
135
surfsense_backend/app/gateway/discord/adapter.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Discord platform adapter for bot mentions and replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.gateway.base.adapter import (
|
||||
BasePlatformAdapter,
|
||||
ParsedInboundEvent,
|
||||
PlatformSendResult,
|
||||
)
|
||||
from app.gateway.discord.client import DiscordGatewayClient
|
||||
|
||||
MENTION_RE = re.compile(r"<@!?\d+>\s*")
|
||||
|
||||
|
||||
def discord_user_peer_id(guild_id: str, discord_user_id: str) -> str:
|
||||
return f"discord_user:{guild_id}:{discord_user_id}"
|
||||
|
||||
|
||||
def discord_thread_peer_id(guild_id: str, channel_id: str, thread_key: str) -> str:
|
||||
return f"discord_thread:{guild_id}:{channel_id}:{thread_key}"
|
||||
|
||||
|
||||
class DiscordAdapter(BasePlatformAdapter):
|
||||
platform = "discord"
|
||||
|
||||
def __init__(self, bot_token: str, *, bot_user_id: str | None = None) -> None:
|
||||
self.bot_user_id = bot_user_id
|
||||
self.client = DiscordGatewayClient(bot_token)
|
||||
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
event = raw_payload.get("event") or raw_payload
|
||||
event_kind = str(raw_payload.get("type") or event.get("type") or "message")
|
||||
guild_id = str(event.get("guild_id") or "")
|
||||
channel_id = str(event.get("channel_id") or "")
|
||||
author = event.get("author") or {}
|
||||
discord_user_id = str(author.get("id") or event.get("author_id") or "")
|
||||
message_id = str(event.get("id") or event.get("message_id") or "")
|
||||
bot_user_id = self.bot_user_id or str(raw_payload.get("bot_user_id") or "")
|
||||
|
||||
if not guild_id or not channel_id or not discord_user_id or not message_id:
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=event_kind,
|
||||
external_peer_id=None,
|
||||
external_peer_kind="unknown",
|
||||
external_message_id=message_id or None,
|
||||
external_user_id=discord_user_id or None,
|
||||
text=None,
|
||||
raw_payload=raw_payload,
|
||||
metadata={
|
||||
"guild_id": guild_id,
|
||||
"channel_id": channel_id,
|
||||
"bot_user_id": bot_user_id,
|
||||
},
|
||||
)
|
||||
|
||||
text = str(event.get("content") or "")
|
||||
if bot_user_id:
|
||||
text = text.replace(f"<@{bot_user_id}>", "")
|
||||
text = text.replace(f"<@!{bot_user_id}>", "")
|
||||
text = MENTION_RE.sub("", text).strip()
|
||||
|
||||
thread_key = str(
|
||||
event.get("thread_id")
|
||||
or (event.get("message_reference") or {}).get("message_id")
|
||||
or message_id
|
||||
)
|
||||
thread_peer_id = discord_thread_peer_id(guild_id, channel_id, thread_key)
|
||||
user_peer_id = discord_user_peer_id(guild_id, discord_user_id)
|
||||
mentions = event.get("mentions") or []
|
||||
mentions_bot = bool(
|
||||
bot_user_id
|
||||
and any(str(mention.get("id")) == bot_user_id for mention in mentions)
|
||||
)
|
||||
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=event_kind,
|
||||
external_peer_id=thread_peer_id,
|
||||
external_peer_kind="channel",
|
||||
external_message_id=message_id,
|
||||
external_user_id=discord_user_id,
|
||||
text=text,
|
||||
raw_payload=raw_payload,
|
||||
display_name=event.get("channel_name"),
|
||||
username=author.get("username") or discord_user_id,
|
||||
metadata={
|
||||
"guild_id": guild_id,
|
||||
"channel_id": channel_id,
|
||||
"discord_user_id": discord_user_id,
|
||||
"message_id": message_id,
|
||||
"thread_key": thread_key,
|
||||
"bot_user_id": bot_user_id,
|
||||
"discord_user_peer_id": user_peer_id,
|
||||
"discord_thread_peer_id": thread_peer_id,
|
||||
"mentions_bot": mentions_bot,
|
||||
"is_dm": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
del parse_mode
|
||||
return await self.client.send_message(
|
||||
channel_id=external_peer_id,
|
||||
content=text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
del parse_mode
|
||||
return await self.client.update_message(
|
||||
channel_id=external_peer_id,
|
||||
message_id=external_message_id,
|
||||
content=text,
|
||||
)
|
||||
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
return await self.client.validate()
|
||||
109
surfsense_backend/app/gateway/discord/client.py
Normal file
109
surfsense_backend/app/gateway/discord/client.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Discord REST API client for gateway bot operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
|
||||
DISCORD_API = "https://discord.com/api/v10"
|
||||
|
||||
|
||||
class DiscordGatewayClient:
|
||||
def __init__(self, bot_token: str) -> None:
|
||||
self.bot_token = bot_token
|
||||
|
||||
async def api_call(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
retry_rate_limit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{DISCORD_API}{path}",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers={
|
||||
"Authorization": f"Bot {self.bot_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if response.status_code == 429 and retry_rate_limit:
|
||||
data = response.json()
|
||||
retry_after = float(data.get("retry_after") or 1.0)
|
||||
await asyncio.sleep(min(retry_after, 5.0))
|
||||
return await self.api_call(
|
||||
method,
|
||||
path,
|
||||
payload=payload,
|
||||
params=params,
|
||||
retry_rate_limit=False,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
content: str,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
payload: dict[str, Any] = {
|
||||
"content": content,
|
||||
"allowed_mentions": {"parse": []},
|
||||
}
|
||||
if reply_to_message_id:
|
||||
payload["message_reference"] = {
|
||||
"message_id": reply_to_message_id,
|
||||
"channel_id": channel_id,
|
||||
"fail_if_not_exists": False,
|
||||
}
|
||||
data = await self.api_call(
|
||||
"POST",
|
||||
f"/channels/{channel_id}/messages",
|
||||
payload=payload,
|
||||
)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(data.get("id", "")),
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
async def update_message(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
) -> PlatformSendResult:
|
||||
data = await self.api_call(
|
||||
"PATCH",
|
||||
f"/channels/{channel_id}/messages/{message_id}",
|
||||
payload={"content": content, "allowed_mentions": {"parse": []}},
|
||||
)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(data.get("id") or message_id),
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
async def validate(self) -> dict[str, Any]:
|
||||
data = await self.api_call("GET", "/users/@me")
|
||||
return {
|
||||
"ok": True,
|
||||
"bot_user_id": data.get("id"),
|
||||
"bot_username": data.get("username"),
|
||||
"global_name": data.get("global_name"),
|
||||
}
|
||||
|
||||
async def get_guild(self, guild_id: str) -> dict[str, Any]:
|
||||
return await self.api_call("GET", f"/guilds/{guild_id}")
|
||||
66
surfsense_backend/app/gateway/discord/commands.py
Normal file
66
surfsense_backend/app/gateway/discord/commands.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Discord command/onboarding handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gateway.base.adapter import ParsedInboundEvent
|
||||
from app.gateway.base.commands import BaseGatewayCommands
|
||||
from app.gateway.discord.adapter import DiscordAdapter
|
||||
from app.gateway.ratelimit import acquire_token
|
||||
|
||||
HELP_TEXT = (
|
||||
"SurfSense Discord commands:\n"
|
||||
"`/new` - start a fresh SurfSense conversation for this Discord thread\n"
|
||||
"`/help` - show this help\n\n"
|
||||
"Mention the SurfSense bot in a Discord channel to ask your agent a question. "
|
||||
"Discord search remains controlled by the Discord connector in SurfSense."
|
||||
)
|
||||
|
||||
|
||||
class DiscordGatewayCommands(BaseGatewayCommands):
|
||||
async def handle_help_command(
|
||||
self,
|
||||
*,
|
||||
adapter: DiscordAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
channel_id = event.metadata.get("channel_id")
|
||||
message_id = event.metadata.get("message_id")
|
||||
if not channel_id:
|
||||
return True
|
||||
await adapter.send_message(
|
||||
external_peer_id=channel_id,
|
||||
text=HELP_TEXT,
|
||||
reply_to_message_id=message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
self,
|
||||
*,
|
||||
adapter: DiscordAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
channel_id = event.metadata.get("channel_id")
|
||||
message_id = event.metadata.get("message_id")
|
||||
guild_id = event.metadata.get("guild_id")
|
||||
discord_user_id = event.metadata.get("discord_user_id")
|
||||
if not channel_id or not message_id:
|
||||
return
|
||||
|
||||
wait_ms = await acquire_token(
|
||||
f"discord:onboarded:{guild_id}:{discord_user_id}",
|
||||
capacity=1,
|
||||
refill_per_sec=1 / 3600,
|
||||
)
|
||||
if wait_ms > 0:
|
||||
return
|
||||
|
||||
await adapter.send_message(
|
||||
external_peer_id=channel_id,
|
||||
reply_to_message_id=message_id,
|
||||
text=(
|
||||
"Hi! Connect your Discord user to SurfSense before using the bot here: "
|
||||
f"{dashboard_url}"
|
||||
),
|
||||
)
|
||||
201
surfsense_backend/app/gateway/discord/intake.py
Normal file
201
surfsense_backend/app/gateway/discord/intake.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""FastAPI lifespan supervisor for Discord Gateway WebSocket intake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
|
||||
from app.config import config
|
||||
from app.db import ExternalChatPlatform, async_session_maker
|
||||
from app.gateway.accounts import get_discord_account_by_guild
|
||||
from app.gateway.inbox import discord_message_dedupe_key, persist_inbound_event
|
||||
from app.observability.metrics import record_gateway_inbox_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_task: asyncio.Task[None] | None = None
|
||||
_client: discord.Client | None = None
|
||||
_shutdown_event: asyncio.Event | None = None
|
||||
|
||||
|
||||
def _message_reference_payload(message: discord.Message) -> dict[str, Any] | None:
|
||||
if message.reference is None:
|
||||
return None
|
||||
return {
|
||||
"message_id": str(message.reference.message_id)
|
||||
if message.reference.message_id
|
||||
else None,
|
||||
"channel_id": str(message.reference.channel_id)
|
||||
if message.reference.channel_id
|
||||
else None,
|
||||
"guild_id": str(message.reference.guild_id)
|
||||
if message.reference.guild_id
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_message(message: discord.Message, *, bot_user_id: str | None) -> dict[str, Any]:
|
||||
guild = message.guild
|
||||
channel = message.channel
|
||||
thread_id = str(channel.id) if isinstance(channel, discord.Thread) else None
|
||||
parent_id = str(channel.parent_id) if isinstance(channel, discord.Thread) else None
|
||||
return {
|
||||
"type": "message",
|
||||
"bot_user_id": bot_user_id,
|
||||
"event": {
|
||||
"type": "message",
|
||||
"id": str(message.id),
|
||||
"guild_id": str(guild.id) if guild else None,
|
||||
"guild_name": guild.name if guild else None,
|
||||
"channel_id": parent_id or str(message.channel.id),
|
||||
"thread_id": thread_id,
|
||||
"channel_name": getattr(channel, "name", None),
|
||||
"content": message.content,
|
||||
"author": {
|
||||
"id": str(message.author.id),
|
||||
"username": message.author.name,
|
||||
"bot": message.author.bot,
|
||||
},
|
||||
"mentions": [
|
||||
{"id": str(user.id), "username": user.name}
|
||||
for user in message.mentions
|
||||
],
|
||||
"message_reference": _message_reference_payload(message),
|
||||
"created_at": message.created_at.isoformat()
|
||||
if message.created_at
|
||||
else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _persist_message(message: discord.Message, *, bot_user_id: str | None) -> None:
|
||||
if message.guild is None:
|
||||
return
|
||||
guild_id = str(message.guild.id)
|
||||
raw_payload = _serialize_message(message, bot_user_id=bot_user_id)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
account = await get_discord_account_by_guild(session, guild_id=guild_id)
|
||||
if account is None:
|
||||
logger.info("Ignoring Discord message for uninstalled guild_id=%s", guild_id)
|
||||
return
|
||||
|
||||
inbox_id = await persist_inbound_event(
|
||||
session,
|
||||
account_id=account.id,
|
||||
platform=ExternalChatPlatform.DISCORD,
|
||||
event_dedupe_key=discord_message_dedupe_key(message.id),
|
||||
external_event_id=str(message.id),
|
||||
external_message_id=str(message.id),
|
||||
event_kind="message",
|
||||
raw_payload=raw_payload,
|
||||
request_id=f"gateway_{uuid.uuid4().hex[:16]}",
|
||||
)
|
||||
await session.commit()
|
||||
record_gateway_inbox_write(platform="discord", dedup_skipped=inbox_id is None)
|
||||
logger.info(
|
||||
"Persisted Discord gateway message_id=%s guild_id=%s inbox_id=%s",
|
||||
message.id,
|
||||
guild_id,
|
||||
inbox_id,
|
||||
)
|
||||
|
||||
|
||||
def _build_client() -> discord.Client:
|
||||
intents = discord.Intents.default()
|
||||
intents.guilds = True
|
||||
intents.messages = True
|
||||
intents.message_content = True
|
||||
client = discord.Client(intents=intents)
|
||||
|
||||
@client.event
|
||||
async def on_ready() -> None:
|
||||
logger.info(
|
||||
"Discord gateway connected as %s (%s)",
|
||||
client.user,
|
||||
getattr(client.user, "id", None),
|
||||
)
|
||||
|
||||
@client.event
|
||||
async def on_message(message: discord.Message) -> None:
|
||||
if message.author.bot:
|
||||
return
|
||||
bot_user = client.user
|
||||
if bot_user is None:
|
||||
return
|
||||
if message.author.id == bot_user.id:
|
||||
return
|
||||
bot_user_id = str(bot_user.id)
|
||||
mention_ids = {str(user.id) for user in message.mentions}
|
||||
if bot_user_id not in mention_ids:
|
||||
return
|
||||
logger.info(
|
||||
"Received Discord gateway mention message_id=%s guild_id=%s channel_id=%s content_present=%s",
|
||||
message.id,
|
||||
getattr(message.guild, "id", None),
|
||||
getattr(message.channel, "id", None),
|
||||
bool(message.content),
|
||||
)
|
||||
try:
|
||||
await _persist_message(message, bot_user_id=bot_user_id)
|
||||
except Exception:
|
||||
logger.exception("Discord gateway failed to persist message_id=%s", message.id)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
async def _run_discord_gateway() -> None:
|
||||
global _client
|
||||
token = config.DISCORD_BOT_TOKEN
|
||||
if not token:
|
||||
logger.warning("Discord gateway enabled but DISCORD_BOT_TOKEN is not set")
|
||||
return
|
||||
|
||||
while _shutdown_event is None or not _shutdown_event.is_set():
|
||||
_client = _build_client()
|
||||
try:
|
||||
await _client.start(token)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Discord gateway WebSocket failed; retrying in 30s")
|
||||
finally:
|
||||
if _client is not None and not _client.is_closed():
|
||||
await _client.close()
|
||||
if _shutdown_event is not None and _shutdown_event.is_set():
|
||||
break
|
||||
try:
|
||||
await asyncio.wait_for(_shutdown_event.wait(), timeout=30.0)
|
||||
except (TimeoutError, AttributeError):
|
||||
continue
|
||||
|
||||
|
||||
async def start_discord_gateway_supervisor() -> None:
|
||||
global _shutdown_event, _task
|
||||
if not config.GATEWAY_DISCORD_ENABLED:
|
||||
return
|
||||
if _task is not None and not _task.done():
|
||||
return
|
||||
_shutdown_event = asyncio.Event()
|
||||
_task = asyncio.create_task(_run_discord_gateway(), name="gateway-discord-intake")
|
||||
logger.info("Started Discord gateway intake supervisor")
|
||||
|
||||
|
||||
async def stop_discord_gateway_supervisor() -> None:
|
||||
global _client, _shutdown_event, _task
|
||||
if _shutdown_event is not None:
|
||||
_shutdown_event.set()
|
||||
if _client is not None and not _client.is_closed():
|
||||
await _client.close()
|
||||
if _task is not None:
|
||||
_task.cancel()
|
||||
with suppress(TimeoutError, asyncio.CancelledError):
|
||||
await asyncio.wait_for(_task, timeout=10)
|
||||
_client = None
|
||||
_task = None
|
||||
_shutdown_event = None
|
||||
86
surfsense_backend/app/gateway/discord/translator.py
Normal file
86
surfsense_backend/app/gateway/discord/translator.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Translate agent stream events into Discord replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
from app.gateway.base.formatting import split_text_message
|
||||
from app.gateway.base.translator import BaseStreamTranslator, GatewayStreamEvent
|
||||
from app.gateway.discord.adapter import DiscordAdapter
|
||||
from app.gateway.ratelimit import wait_for_token
|
||||
from app.observability.metrics import (
|
||||
record_gateway_hitl_aborted,
|
||||
record_gateway_outbound,
|
||||
record_gateway_rate_limit_hit,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISCORD_MAX_MESSAGE_CHARS = 1900
|
||||
HITL_UNSUPPORTED_MESSAGE = (
|
||||
"This action requires approval and is not yet supported from Discord. "
|
||||
"Try again with a different request."
|
||||
)
|
||||
|
||||
|
||||
class DiscordStreamTranslator(BaseStreamTranslator):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
adapter: DiscordAdapter,
|
||||
channel_id: str,
|
||||
reply_to_message_id: str | None,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.channel_id = channel_id
|
||||
self.reply_to_message_id = reply_to_message_id
|
||||
self._buffer = ""
|
||||
|
||||
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
if event.type in {"text-delta", "text_delta", "text"}:
|
||||
self._buffer += str(event.data.get("text") or event.data.get("delta") or "")
|
||||
elif event.type in {"data-interrupt-request", "interrupt"}:
|
||||
await self._handle_hitl_interrupt()
|
||||
return
|
||||
elif event.type in {"finish", "done"}:
|
||||
break
|
||||
|
||||
await self._flush_final()
|
||||
|
||||
async def _flush_final(self) -> None:
|
||||
if not self._buffer:
|
||||
return
|
||||
for chunk in split_text_message(self._buffer, max_chars=DISCORD_MAX_MESSAGE_CHARS):
|
||||
await self._send_text(chunk)
|
||||
|
||||
async def _send_text(self, text: str) -> PlatformSendResult:
|
||||
await self._throttle()
|
||||
try:
|
||||
result = await self.adapter.send_message(
|
||||
external_peer_id=self.channel_id,
|
||||
text=text,
|
||||
reply_to_message_id=self.reply_to_message_id,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="discord", kind="send", status="failed")
|
||||
raise
|
||||
record_gateway_outbound(platform="discord", kind="send", status="sent")
|
||||
return result
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
chat_wait = await wait_for_token(
|
||||
f"discord:channel:{self.channel_id}",
|
||||
capacity=5,
|
||||
refill_per_sec=1.0,
|
||||
)
|
||||
if chat_wait:
|
||||
record_gateway_rate_limit_hit(bucket="discord:channel")
|
||||
|
||||
async def _handle_hitl_interrupt(self) -> None:
|
||||
if self._buffer:
|
||||
await self._flush_final()
|
||||
await self._send_text(HITL_UNSUPPORTED_MESSAGE)
|
||||
record_gateway_hitl_aborted(platform="discord")
|
||||
35
surfsense_backend/app/gateway/hitl_filter.py
Normal file
35
surfsense_backend/app/gateway/hitl_filter.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Filter approval-required tools from gateway agent invocations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_HITL_TOOL_NAMES = {
|
||||
"delete_document",
|
||||
"delete_folder",
|
||||
"delete_note",
|
||||
"delete_report",
|
||||
"delete_connector",
|
||||
"send_email",
|
||||
"share_chat",
|
||||
}
|
||||
|
||||
|
||||
def _tool_name(tool: Any) -> str | None:
|
||||
if isinstance(tool, str):
|
||||
return tool
|
||||
return getattr(tool, "name", None) or getattr(tool, "__name__", None)
|
||||
|
||||
|
||||
def filter_hitl_tools(
|
||||
toolkit: Iterable[Any] | None,
|
||||
*,
|
||||
blocked_names: set[str] | None = None,
|
||||
) -> list[Any] | None:
|
||||
"""Return a toolkit with known approval-required tools removed."""
|
||||
if toolkit is None:
|
||||
return None
|
||||
blocked = blocked_names or DEFAULT_HITL_TOOL_NAMES
|
||||
return [tool for tool in toolkit if (_tool_name(tool) or "") not in blocked]
|
||||
|
||||
54
surfsense_backend/app/gateway/inbox.py
Normal file
54
surfsense_backend/app/gateway/inbox.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Durable gateway inbox helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import ExternalChatInboundEvent, ExternalChatPlatform
|
||||
|
||||
|
||||
def telegram_event_dedupe_key(update_id: int | str) -> str:
|
||||
return f"update:{update_id}"
|
||||
|
||||
|
||||
def slack_event_dedupe_key(event_id: int | str) -> str:
|
||||
return f"slack_event:{event_id}"
|
||||
|
||||
|
||||
def discord_message_dedupe_key(message_id: int | str) -> str:
|
||||
return f"discord_message:{message_id}"
|
||||
|
||||
|
||||
async def persist_inbound_event(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
account_id: int,
|
||||
platform: ExternalChatPlatform,
|
||||
event_dedupe_key: str,
|
||||
event_kind: str,
|
||||
raw_payload: dict,
|
||||
external_event_id: str | None = None,
|
||||
external_message_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> int | None:
|
||||
stmt = (
|
||||
insert(ExternalChatInboundEvent)
|
||||
.values(
|
||||
account_id=account_id,
|
||||
platform=platform,
|
||||
event_dedupe_key=event_dedupe_key,
|
||||
external_event_id=external_event_id,
|
||||
external_message_id=external_message_id,
|
||||
event_kind=event_kind,
|
||||
raw_payload=raw_payload,
|
||||
request_id=request_id,
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=["account_id", "event_dedupe_key"],
|
||||
)
|
||||
.returning(ExternalChatInboundEvent.id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
440
surfsense_backend/app/gateway/inbox_processor.py
Normal file
440
surfsense_backend/app/gateway/inbox_processor.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"""Long-lived external chat inbox processing.
|
||||
|
||||
This module owns the agent-turn execution path for external chat surfaces.
|
||||
FastAPI calls into it after webhook and BYO long-poll intake persist inbox rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ExternalChatAccount,
|
||||
ExternalChatAccountMode,
|
||||
ExternalChatBinding,
|
||||
ExternalChatBindingState,
|
||||
ExternalChatEventStatus,
|
||||
ExternalChatInboundEvent,
|
||||
ExternalChatPeerKind,
|
||||
ExternalChatPlatform,
|
||||
NewChatThread,
|
||||
async_session_maker,
|
||||
)
|
||||
from app.gateway.agent_invoke import call_agent_for_gateway
|
||||
from app.gateway.base.commands import command_name
|
||||
from app.gateway.bindings import get_or_create_thread_for_binding
|
||||
from app.gateway.registry import resolve_platform_bundle
|
||||
from app.observability.metrics import record_gateway_inbox_processed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SessionMaker = async_sessionmaker[AsyncSession] | Callable[[], AsyncSession]
|
||||
|
||||
|
||||
def _dashboard_url() -> str:
|
||||
return config.NEXT_FRONTEND_URL or "/dashboard"
|
||||
|
||||
|
||||
def _active_whatsapp_account_mode() -> ExternalChatAccountMode | None:
|
||||
if config.GATEWAY_WHATSAPP_INTAKE_MODE == "cloud":
|
||||
return ExternalChatAccountMode.CLOUD_SHARED
|
||||
if config.GATEWAY_WHATSAPP_INTAKE_MODE == "baileys":
|
||||
return ExternalChatAccountMode.SELF_HOST_BYO
|
||||
return None
|
||||
|
||||
|
||||
def _is_inactive_whatsapp_account(account: ExternalChatAccount) -> bool:
|
||||
return (
|
||||
account.platform == ExternalChatPlatform.WHATSAPP
|
||||
and account.mode != _active_whatsapp_account_mode()
|
||||
)
|
||||
|
||||
|
||||
async def claim_next_inbound_event(
|
||||
session_maker: SessionMaker = async_session_maker,
|
||||
) -> int | None:
|
||||
"""Claim the oldest received inbox event for processing."""
|
||||
|
||||
async with session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ExternalChatInboundEvent)
|
||||
.where(ExternalChatInboundEvent.status == ExternalChatEventStatus.RECEIVED)
|
||||
.order_by(ExternalChatInboundEvent.received_at.asc())
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(1)
|
||||
)
|
||||
event = result.scalars().first()
|
||||
if event is None:
|
||||
return None
|
||||
event.status = ExternalChatEventStatus.PROCESSING
|
||||
event.attempt_count += 1
|
||||
await session.commit()
|
||||
return int(event.id)
|
||||
|
||||
|
||||
async def process_inbound_event(
|
||||
inbox_id: int,
|
||||
session_maker: SessionMaker = async_session_maker,
|
||||
) -> None:
|
||||
"""Process one external chat inbox row and mark its terminal status."""
|
||||
|
||||
async with session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ExternalChatInboundEvent)
|
||||
.where(ExternalChatInboundEvent.id == inbox_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
event = result.scalars().first()
|
||||
if event is None or event.status in {
|
||||
ExternalChatEventStatus.PROCESSED,
|
||||
ExternalChatEventStatus.IGNORED,
|
||||
}:
|
||||
return
|
||||
if event.status == ExternalChatEventStatus.RECEIVED:
|
||||
event.status = ExternalChatEventStatus.PROCESSING
|
||||
event.attempt_count += 1
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
await _dispatch_inbound_event(inbox_id, session_maker)
|
||||
except RuntimeError as exc:
|
||||
if str(exc) == "gateway_thread_busy":
|
||||
async with session_maker() as session:
|
||||
await session.execute(
|
||||
update(ExternalChatInboundEvent)
|
||||
.where(ExternalChatInboundEvent.id == inbox_id)
|
||||
.values(
|
||||
status=ExternalChatEventStatus.RECEIVED,
|
||||
last_error="gateway_thread_busy",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
raise
|
||||
await _mark_failed(inbox_id, str(exc), session_maker)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await _mark_failed(inbox_id, str(exc), session_maker)
|
||||
raise
|
||||
|
||||
async with session_maker() as session:
|
||||
event = await session.get(ExternalChatInboundEvent, inbox_id)
|
||||
if event is not None and event.status == ExternalChatEventStatus.PROCESSING:
|
||||
event.status = ExternalChatEventStatus.PROCESSED
|
||||
event.processed_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
record_gateway_inbox_processed(platform=event.platform.value, status="processed")
|
||||
|
||||
|
||||
async def _mark_failed(
|
||||
inbox_id: int,
|
||||
error: str,
|
||||
session_maker: SessionMaker,
|
||||
) -> None:
|
||||
async with session_maker() as session:
|
||||
await session.execute(
|
||||
update(ExternalChatInboundEvent)
|
||||
.where(ExternalChatInboundEvent.id == inbox_id)
|
||||
.values(status=ExternalChatEventStatus.FAILED, last_error=error)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _resolve_binding_for_event(
|
||||
session: AsyncSession,
|
||||
account: ExternalChatAccount,
|
||||
parsed,
|
||||
) -> ExternalChatBinding | None:
|
||||
if account.platform == ExternalChatPlatform.SLACK:
|
||||
return await _resolve_slack_thread_binding(session, account, parsed)
|
||||
if account.platform == ExternalChatPlatform.DISCORD:
|
||||
return await _resolve_discord_thread_binding(session, account, parsed)
|
||||
|
||||
result = await session.execute(
|
||||
select(ExternalChatBinding).where(
|
||||
ExternalChatBinding.account_id == account.id,
|
||||
ExternalChatBinding.external_peer_id == parsed.external_peer_id,
|
||||
ExternalChatBinding.state.in_(
|
||||
[ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED]
|
||||
),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def _resolve_slack_thread_binding(
|
||||
session: AsyncSession,
|
||||
account: ExternalChatAccount,
|
||||
parsed,
|
||||
) -> ExternalChatBinding | None:
|
||||
user_peer_id = parsed.metadata.get("slack_user_peer_id")
|
||||
thread_peer_id = parsed.metadata.get("slack_thread_peer_id") or parsed.external_peer_id
|
||||
if not user_peer_id or not thread_peer_id:
|
||||
return None
|
||||
|
||||
user_result = await session.execute(
|
||||
select(ExternalChatBinding).where(
|
||||
ExternalChatBinding.account_id == account.id,
|
||||
ExternalChatBinding.external_peer_id == user_peer_id,
|
||||
ExternalChatBinding.state.in_(
|
||||
[ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED]
|
||||
),
|
||||
)
|
||||
)
|
||||
user_binding = user_result.scalars().first()
|
||||
if user_binding is None:
|
||||
return None
|
||||
|
||||
thread_result = await session.execute(
|
||||
select(ExternalChatBinding).where(
|
||||
ExternalChatBinding.account_id == account.id,
|
||||
ExternalChatBinding.external_peer_id == thread_peer_id,
|
||||
ExternalChatBinding.state.in_(
|
||||
[ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED]
|
||||
),
|
||||
)
|
||||
)
|
||||
thread_binding = thread_result.scalars().first()
|
||||
if thread_binding is not None:
|
||||
return thread_binding
|
||||
|
||||
thread_binding = ExternalChatBinding(
|
||||
account_id=account.id,
|
||||
user_id=user_binding.user_id,
|
||||
search_space_id=user_binding.search_space_id,
|
||||
state=ExternalChatBindingState.BOUND,
|
||||
external_peer_id=thread_peer_id,
|
||||
external_peer_kind=ExternalChatPeerKind.CHANNEL,
|
||||
external_thread_id=parsed.metadata.get("thread_ts"),
|
||||
external_display_name=parsed.metadata.get("channel_id"),
|
||||
external_username=parsed.external_user_id,
|
||||
external_metadata={
|
||||
"kind": "slack_thread",
|
||||
"team_id": parsed.metadata.get("team_id"),
|
||||
"channel_id": parsed.metadata.get("channel_id"),
|
||||
"thread_ts": parsed.metadata.get("thread_ts"),
|
||||
"slack_user_id": parsed.metadata.get("slack_user_id"),
|
||||
"user_binding_id": user_binding.id,
|
||||
},
|
||||
)
|
||||
session.add(thread_binding)
|
||||
await session.flush()
|
||||
return thread_binding
|
||||
|
||||
|
||||
async def _resolve_discord_thread_binding(
|
||||
session: AsyncSession,
|
||||
account: ExternalChatAccount,
|
||||
parsed,
|
||||
) -> ExternalChatBinding | None:
|
||||
user_peer_id = parsed.metadata.get("discord_user_peer_id")
|
||||
thread_peer_id = parsed.metadata.get("discord_thread_peer_id") or parsed.external_peer_id
|
||||
if not user_peer_id or not thread_peer_id:
|
||||
return None
|
||||
|
||||
user_result = await session.execute(
|
||||
select(ExternalChatBinding).where(
|
||||
ExternalChatBinding.account_id == account.id,
|
||||
ExternalChatBinding.external_peer_id == user_peer_id,
|
||||
ExternalChatBinding.state.in_(
|
||||
[ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED]
|
||||
),
|
||||
)
|
||||
)
|
||||
user_binding = user_result.scalars().first()
|
||||
if user_binding is None:
|
||||
return None
|
||||
|
||||
thread_result = await session.execute(
|
||||
select(ExternalChatBinding).where(
|
||||
ExternalChatBinding.account_id == account.id,
|
||||
ExternalChatBinding.external_peer_id == thread_peer_id,
|
||||
ExternalChatBinding.state.in_(
|
||||
[ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED]
|
||||
),
|
||||
)
|
||||
)
|
||||
thread_binding = thread_result.scalars().first()
|
||||
if thread_binding is not None:
|
||||
return thread_binding
|
||||
|
||||
thread_binding = ExternalChatBinding(
|
||||
account_id=account.id,
|
||||
user_id=user_binding.user_id,
|
||||
search_space_id=user_binding.search_space_id,
|
||||
state=ExternalChatBindingState.BOUND,
|
||||
external_peer_id=thread_peer_id,
|
||||
external_peer_kind=ExternalChatPeerKind.CHANNEL,
|
||||
external_thread_id=parsed.metadata.get("thread_key"),
|
||||
external_display_name=parsed.metadata.get("channel_id"),
|
||||
external_username=parsed.external_user_id,
|
||||
external_metadata={
|
||||
"kind": "discord_thread",
|
||||
"guild_id": parsed.metadata.get("guild_id"),
|
||||
"channel_id": parsed.metadata.get("channel_id"),
|
||||
"thread_key": parsed.metadata.get("thread_key"),
|
||||
"discord_user_id": parsed.metadata.get("discord_user_id"),
|
||||
"user_binding_id": user_binding.id,
|
||||
},
|
||||
)
|
||||
session.add(thread_binding)
|
||||
await session.flush()
|
||||
return thread_binding
|
||||
|
||||
|
||||
def _reply_target(parsed) -> tuple[str | None, str | None]:
|
||||
if parsed.platform == "slack":
|
||||
return parsed.metadata.get("channel_id"), parsed.metadata.get("thread_ts")
|
||||
if parsed.platform == "discord":
|
||||
return parsed.metadata.get("channel_id"), parsed.metadata.get("message_id")
|
||||
return parsed.external_peer_id, None
|
||||
|
||||
|
||||
async def _dispatch_inbound_event(
|
||||
inbox_id: int,
|
||||
session_maker: SessionMaker,
|
||||
) -> None:
|
||||
async with session_maker() as session:
|
||||
event = await session.get(ExternalChatInboundEvent, inbox_id)
|
||||
if event is None:
|
||||
return
|
||||
account = await session.get(ExternalChatAccount, event.account_id)
|
||||
if account is None:
|
||||
event.status = ExternalChatEventStatus.IGNORED
|
||||
event.last_error = "account_missing"
|
||||
await session.commit()
|
||||
return
|
||||
if _is_inactive_whatsapp_account(account):
|
||||
event.status = ExternalChatEventStatus.IGNORED
|
||||
event.last_error = "inactive_whatsapp_mode"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
bundle = resolve_platform_bundle(account)
|
||||
except RuntimeError as exc:
|
||||
event.status = ExternalChatEventStatus.FAILED
|
||||
event.last_error = str(exc)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
adapter = bundle.adapter
|
||||
parsed = adapter.parse_inbound(event.raw_payload or {})
|
||||
if parsed.external_peer_id is None:
|
||||
event.status = ExternalChatEventStatus.IGNORED
|
||||
event.last_error = "missing_external_peer_id"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
_update_account_cursor(account, parsed.metadata.get("update_id"))
|
||||
|
||||
binding = await _resolve_binding_for_event(session, account, parsed)
|
||||
|
||||
if (
|
||||
account.platform
|
||||
not in {ExternalChatPlatform.SLACK, ExternalChatPlatform.DISCORD}
|
||||
and parsed.external_peer_kind != ExternalChatPeerKind.DIRECT.value
|
||||
):
|
||||
if hasattr(adapter, "leave_chat"):
|
||||
await adapter.leave_chat(external_peer_id=parsed.external_peer_id)
|
||||
event.status = ExternalChatEventStatus.IGNORED
|
||||
event.last_error = "group_rejected"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
cmd = command_name(parsed.text)
|
||||
if cmd == "/start":
|
||||
handled = await bundle.commands.handle_start_command(
|
||||
session=session, adapter=adapter, event=parsed
|
||||
)
|
||||
await session.commit()
|
||||
if handled:
|
||||
return
|
||||
|
||||
if binding is None:
|
||||
if bundle.auto_bind_owner and account.owner_user_id and account.owner_search_space_id:
|
||||
binding = ExternalChatBinding(
|
||||
account_id=account.id,
|
||||
user_id=account.owner_user_id,
|
||||
search_space_id=account.owner_search_space_id,
|
||||
state=ExternalChatBindingState.BOUND,
|
||||
external_peer_id=parsed.external_peer_id,
|
||||
external_peer_kind=parsed.external_peer_kind,
|
||||
external_display_name=parsed.display_name,
|
||||
external_username=parsed.username,
|
||||
external_metadata=parsed.metadata,
|
||||
)
|
||||
session.add(binding)
|
||||
await session.flush()
|
||||
else:
|
||||
await bundle.commands.send_unbound_onboarding(
|
||||
adapter=adapter,
|
||||
event=parsed,
|
||||
dashboard_url=_dashboard_url(),
|
||||
)
|
||||
event.status = ExternalChatEventStatus.IGNORED
|
||||
event.last_error = "unbound_chat"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
event.external_chat_binding_id = binding.id
|
||||
|
||||
if cmd == "/help":
|
||||
handled = await bundle.commands.handle_help_command(adapter=adapter, event=parsed)
|
||||
if handled:
|
||||
event.status = ExternalChatEventStatus.PROCESSED
|
||||
await session.commit()
|
||||
return
|
||||
if cmd == "/new":
|
||||
binding.new_chat_thread_id = None
|
||||
reply_peer_id, reply_message_id = _reply_target(parsed)
|
||||
if reply_peer_id:
|
||||
await adapter.send_message(
|
||||
external_peer_id=reply_peer_id,
|
||||
text="Started a new SurfSense conversation.",
|
||||
reply_to_message_id=reply_message_id,
|
||||
)
|
||||
event.status = ExternalChatEventStatus.PROCESSED
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
if not parsed.text:
|
||||
event.status = ExternalChatEventStatus.IGNORED
|
||||
event.last_error = "empty_message"
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
thread = await get_or_create_thread_for_binding(session, binding)
|
||||
await session.commit()
|
||||
|
||||
translator = bundle.translator_factory(adapter, parsed)
|
||||
await call_agent_for_gateway(
|
||||
session=session,
|
||||
binding=binding,
|
||||
user_text=parsed.text,
|
||||
translator=translator,
|
||||
platform_label=bundle.platform_label,
|
||||
request_id=event.request_id or f"gateway:{inbox_id}",
|
||||
)
|
||||
|
||||
thread = await session.get(NewChatThread, thread.id)
|
||||
if thread is not None:
|
||||
thread.source = bundle.platform_label
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _update_account_cursor(account: ExternalChatAccount, update_id: object) -> None:
|
||||
if update_id is None:
|
||||
return
|
||||
account.cursor_state = {
|
||||
**(account.cursor_state or {}),
|
||||
"last_update_id": max(
|
||||
int((account.cursor_state or {}).get("last_update_id", 0)),
|
||||
int(update_id),
|
||||
),
|
||||
}
|
||||
55
surfsense_backend/app/gateway/inbox_worker.py
Normal file
55
surfsense_backend/app/gateway/inbox_worker.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""FastAPI lifespan worker for gateway inbox processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
|
||||
from app.gateway.inbox_processor import claim_next_inbound_event, process_inbound_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_task: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
async def _process_inbox_forever() -> None:
|
||||
logger.info("Gateway inbox processor started in FastAPI process")
|
||||
while True:
|
||||
try:
|
||||
inbox_id = await claim_next_inbound_event()
|
||||
if inbox_id is None:
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
logger.info("Gateway processing inbox_id=%s", inbox_id)
|
||||
await process_inbound_event(inbox_id)
|
||||
logger.info("Gateway processed inbox_id=%s", inbox_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except RuntimeError as exc:
|
||||
if str(exc) == "gateway_thread_busy":
|
||||
logger.info("Gateway inbox_id busy; will retry from RECEIVED state")
|
||||
else:
|
||||
logger.exception("Gateway inbox processor failed one iteration")
|
||||
await asyncio.sleep(1)
|
||||
except Exception:
|
||||
logger.exception("Gateway inbox processor failed one iteration")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def start_gateway_inbox_worker() -> None:
|
||||
global _task
|
||||
if _task is not None and not _task.done():
|
||||
return
|
||||
_task = asyncio.create_task(_process_inbox_forever(), name="gateway-inbox-worker")
|
||||
|
||||
|
||||
async def stop_gateway_inbox_worker() -> None:
|
||||
global _task
|
||||
if _task is None:
|
||||
return
|
||||
_task.cancel()
|
||||
with suppress(TimeoutError, asyncio.CancelledError):
|
||||
await asyncio.wait_for(_task, timeout=10)
|
||||
_task = None
|
||||
|
||||
54
surfsense_backend/app/gateway/pairing.py
Normal file
54
surfsense_backend/app/gateway/pairing.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Pairing code lifecycle for external chat bindings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import ExternalChatBindingState, ExternalChatBinding
|
||||
|
||||
PAIRING_CODE_TTL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def generate_pairing_code() -> str:
|
||||
return secrets.token_urlsafe(6)
|
||||
|
||||
|
||||
def pairing_expires_at() -> datetime:
|
||||
return datetime.now(UTC) + PAIRING_CODE_TTL
|
||||
|
||||
|
||||
async def redeem_pairing_code(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
code: str,
|
||||
external_peer_id: str,
|
||||
external_peer_kind: str,
|
||||
external_display_name: str | None,
|
||||
external_username: str | None,
|
||||
external_metadata: dict | None = None,
|
||||
) -> ExternalChatBinding | None:
|
||||
result = await session.execute(
|
||||
select(ExternalChatBinding).where(
|
||||
ExternalChatBinding.pairing_code == code,
|
||||
ExternalChatBinding.state == ExternalChatBindingState.PENDING,
|
||||
ExternalChatBinding.pairing_code_expires_at > datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
binding = result.scalars().first()
|
||||
if binding is None:
|
||||
return None
|
||||
|
||||
binding.state = ExternalChatBindingState.BOUND
|
||||
binding.pairing_code = None
|
||||
binding.pairing_code_expires_at = None
|
||||
binding.external_peer_id = external_peer_id
|
||||
binding.external_peer_kind = external_peer_kind
|
||||
binding.external_display_name = external_display_name
|
||||
binding.external_username = external_username
|
||||
binding.external_metadata = external_metadata or {}
|
||||
return binding
|
||||
|
||||
136
surfsense_backend/app/gateway/ratelimit.py
Normal file
136
surfsense_backend/app/gateway/ratelimit.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Redis token-bucket rate limiter for gateway outbound traffic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import config
|
||||
from app.observability.metrics import record_gateway_redis_fallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_BUCKET_LUA = """
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local refill_rate = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local consume = tonumber(ARGV[4])
|
||||
|
||||
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
|
||||
local tokens = tonumber(bucket[1]) or capacity
|
||||
local last_refill = tonumber(bucket[2]) or now
|
||||
|
||||
local elapsed = math.max(0, now - last_refill)
|
||||
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
|
||||
|
||||
if tokens >= consume then
|
||||
tokens = tokens - consume
|
||||
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
|
||||
redis.call('EXPIRE', KEYS[1], 3600)
|
||||
return 0
|
||||
else
|
||||
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
|
||||
redis.call('EXPIRE', KEYS[1], 3600)
|
||||
local needed = consume - tokens
|
||||
return math.ceil((needed / refill_rate) * 1000)
|
||||
end
|
||||
"""
|
||||
|
||||
_redis_client: aioredis.Redis | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MemoryBucket:
|
||||
tokens: float
|
||||
last_refill: float
|
||||
|
||||
|
||||
_memory_buckets: dict[str, _MemoryBucket] = {}
|
||||
_memory_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _redis() -> aioredis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = aioredis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def _memory_fallback_acquire(
|
||||
scope: str,
|
||||
capacity: int,
|
||||
refill_per_sec: float,
|
||||
consume: float,
|
||||
) -> int:
|
||||
now = time.time()
|
||||
async with _memory_lock:
|
||||
bucket = _memory_buckets.get(scope)
|
||||
if bucket is None:
|
||||
bucket = _MemoryBucket(tokens=float(capacity), last_refill=now)
|
||||
_memory_buckets[scope] = bucket
|
||||
|
||||
elapsed = max(0.0, now - bucket.last_refill)
|
||||
bucket.tokens = min(float(capacity), bucket.tokens + elapsed * refill_per_sec)
|
||||
bucket.last_refill = now
|
||||
|
||||
if bucket.tokens >= consume:
|
||||
bucket.tokens -= consume
|
||||
return 0
|
||||
|
||||
needed = consume - bucket.tokens
|
||||
return int((needed / refill_per_sec) * 1000) if refill_per_sec > 0 else 1000
|
||||
|
||||
|
||||
async def acquire_token(
|
||||
scope: str,
|
||||
*,
|
||||
capacity: int,
|
||||
refill_per_sec: float,
|
||||
consume: float = 1.0,
|
||||
) -> int:
|
||||
"""Return 0 if allowed, otherwise milliseconds to wait.
|
||||
|
||||
Redis is the primary coordination mechanism. If Redis is unavailable,
|
||||
fall back to per-process memory so the gateway degrades instead of failing
|
||||
closed during a short Redis outage.
|
||||
"""
|
||||
|
||||
redis_key = f"gateway:bucket:{scope}"
|
||||
try:
|
||||
wait_ms = await _redis().eval(
|
||||
_TOKEN_BUCKET_LUA,
|
||||
1,
|
||||
redis_key,
|
||||
capacity,
|
||||
refill_per_sec,
|
||||
time.time(),
|
||||
consume,
|
||||
)
|
||||
return int(wait_ms)
|
||||
except (aioredis.RedisError, OSError) as exc:
|
||||
logger.warning("Redis rate limiter unavailable; using memory fallback: %s", exc)
|
||||
record_gateway_redis_fallback()
|
||||
return await _memory_fallback_acquire(scope, capacity, refill_per_sec, consume)
|
||||
|
||||
|
||||
async def wait_for_token(
|
||||
scope: str,
|
||||
*,
|
||||
capacity: int,
|
||||
refill_per_sec: float,
|
||||
consume: float = 1.0,
|
||||
) -> int:
|
||||
wait_ms = await acquire_token(
|
||||
scope,
|
||||
capacity=capacity,
|
||||
refill_per_sec=refill_per_sec,
|
||||
consume=consume,
|
||||
)
|
||||
if wait_ms > 0:
|
||||
await asyncio.sleep(wait_ms / 1000)
|
||||
return wait_ms
|
||||
|
||||
189
surfsense_backend/app/gateway/registry.py
Normal file
189
surfsense_backend/app/gateway/registry.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""Resolve gateway platform implementations from account rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.db import ExternalChatAccount, ExternalChatAccountMode, ExternalChatPlatform
|
||||
from app.gateway.accounts import (
|
||||
account_token,
|
||||
discord_account_credentials,
|
||||
slack_account_credentials,
|
||||
)
|
||||
from app.gateway.base.adapter import BasePlatformAdapter, ParsedInboundEvent
|
||||
from app.gateway.base.commands import BaseGatewayCommands
|
||||
from app.gateway.base.translator import BaseStreamTranslator
|
||||
from app.gateway.telegram.adapter import TelegramAdapter
|
||||
from app.gateway.telegram.commands import TelegramGatewayCommands
|
||||
from app.gateway.telegram.translator import TelegramStreamTranslator
|
||||
|
||||
TranslatorFactory = Callable[
|
||||
[BasePlatformAdapter, ParsedInboundEvent],
|
||||
BaseStreamTranslator,
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformBundle:
|
||||
adapter: BasePlatformAdapter
|
||||
translator_factory: TranslatorFactory
|
||||
platform_label: str
|
||||
commands: BaseGatewayCommands
|
||||
auto_bind_owner: bool = False
|
||||
|
||||
|
||||
def _telegram_translator_factory(
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> BaseStreamTranslator:
|
||||
if event.external_peer_id is None:
|
||||
raise RuntimeError("missing_external_peer_id")
|
||||
return TelegramStreamTranslator(
|
||||
adapter=adapter, # type: ignore[arg-type]
|
||||
external_peer_id=event.external_peer_id,
|
||||
)
|
||||
|
||||
|
||||
def _whatsapp_cloud_translator_factory(
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> BaseStreamTranslator:
|
||||
if event.external_peer_id is None:
|
||||
raise RuntimeError("missing_external_peer_id")
|
||||
from app.gateway.whatsapp.translator import WhatsAppCloudStreamTranslator
|
||||
|
||||
return WhatsAppCloudStreamTranslator(
|
||||
adapter=adapter,
|
||||
external_peer_id=event.external_peer_id,
|
||||
inbound_message_id=event.external_message_id,
|
||||
)
|
||||
|
||||
|
||||
def _whatsapp_baileys_translator_factory(
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> BaseStreamTranslator:
|
||||
if event.external_peer_id is None:
|
||||
raise RuntimeError("missing_external_peer_id")
|
||||
from app.gateway.whatsapp.translator_baileys import WhatsAppBaileysStreamTranslator
|
||||
|
||||
return WhatsAppBaileysStreamTranslator(
|
||||
adapter=adapter,
|
||||
external_peer_id=event.external_peer_id,
|
||||
)
|
||||
|
||||
|
||||
def _slack_translator_factory(
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> BaseStreamTranslator:
|
||||
channel_id = event.metadata.get("channel_id")
|
||||
thread_ts = event.metadata.get("thread_ts")
|
||||
if not channel_id or not thread_ts:
|
||||
raise RuntimeError("missing_slack_thread_metadata")
|
||||
from app.gateway.slack.translator import SlackStreamTranslator
|
||||
|
||||
return SlackStreamTranslator(
|
||||
adapter=adapter, # type: ignore[arg-type]
|
||||
channel_id=channel_id,
|
||||
thread_ts=thread_ts,
|
||||
)
|
||||
|
||||
|
||||
def _discord_translator_factory(
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> BaseStreamTranslator:
|
||||
channel_id = event.metadata.get("channel_id")
|
||||
message_id = event.metadata.get("message_id")
|
||||
if not channel_id:
|
||||
raise RuntimeError("missing_discord_channel_metadata")
|
||||
from app.gateway.discord.translator import DiscordStreamTranslator
|
||||
|
||||
return DiscordStreamTranslator(
|
||||
adapter=adapter, # type: ignore[arg-type]
|
||||
channel_id=channel_id,
|
||||
reply_to_message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
def resolve_platform_bundle(account: ExternalChatAccount) -> PlatformBundle:
|
||||
if account.platform == ExternalChatPlatform.TELEGRAM:
|
||||
token = account_token(account)
|
||||
if not token:
|
||||
raise RuntimeError("missing_telegram_token")
|
||||
return PlatformBundle(
|
||||
adapter=TelegramAdapter(token),
|
||||
translator_factory=_telegram_translator_factory,
|
||||
platform_label="telegram",
|
||||
commands=TelegramGatewayCommands(),
|
||||
)
|
||||
|
||||
if account.platform == ExternalChatPlatform.WHATSAPP:
|
||||
if account.mode == ExternalChatAccountMode.CLOUD_SHARED:
|
||||
from app.gateway.whatsapp.adapter_cloud import WhatsAppCloudAdapter
|
||||
from app.gateway.whatsapp.commands import WhatsAppGatewayCommands
|
||||
from app.gateway.whatsapp.credentials import (
|
||||
load_system_whatsapp_credentials,
|
||||
)
|
||||
|
||||
return PlatformBundle(
|
||||
adapter=WhatsAppCloudAdapter(load_system_whatsapp_credentials()),
|
||||
translator_factory=_whatsapp_cloud_translator_factory,
|
||||
platform_label="whatsapp",
|
||||
commands=WhatsAppGatewayCommands(),
|
||||
auto_bind_owner=False,
|
||||
)
|
||||
if account.mode == ExternalChatAccountMode.SELF_HOST_BYO:
|
||||
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
|
||||
|
||||
return PlatformBundle(
|
||||
adapter=WhatsAppBaileysAdapter(),
|
||||
translator_factory=_whatsapp_baileys_translator_factory,
|
||||
platform_label="whatsapp",
|
||||
commands=BaseGatewayCommands(),
|
||||
auto_bind_owner=True,
|
||||
)
|
||||
|
||||
if account.platform == ExternalChatPlatform.SLACK:
|
||||
from app.gateway.slack.adapter import SlackAdapter
|
||||
from app.gateway.slack.commands import SlackGatewayCommands
|
||||
|
||||
credentials = slack_account_credentials(account)
|
||||
bot_token = credentials.get("bot_token")
|
||||
if not bot_token:
|
||||
raise RuntimeError("missing_slack_bot_token")
|
||||
cursor_state = account.cursor_state or {}
|
||||
return PlatformBundle(
|
||||
adapter=SlackAdapter(
|
||||
bot_token,
|
||||
bot_user_id=cursor_state.get("bot_user_id"),
|
||||
),
|
||||
translator_factory=_slack_translator_factory,
|
||||
platform_label="slack",
|
||||
commands=SlackGatewayCommands(),
|
||||
auto_bind_owner=False,
|
||||
)
|
||||
|
||||
if account.platform == ExternalChatPlatform.DISCORD:
|
||||
from app.gateway.discord.adapter import DiscordAdapter
|
||||
from app.gateway.discord.commands import DiscordGatewayCommands
|
||||
|
||||
credentials = discord_account_credentials(account)
|
||||
bot_token = credentials.get("bot_token")
|
||||
if not bot_token:
|
||||
raise RuntimeError("missing_discord_bot_token")
|
||||
cursor_state = account.cursor_state or {}
|
||||
return PlatformBundle(
|
||||
adapter=DiscordAdapter(
|
||||
bot_token,
|
||||
bot_user_id=cursor_state.get("bot_user_id"),
|
||||
),
|
||||
translator_factory=_discord_translator_factory,
|
||||
platform_label="discord",
|
||||
commands=DiscordGatewayCommands(),
|
||||
auto_bind_owner=False,
|
||||
)
|
||||
|
||||
raise RuntimeError(f"unsupported_gateway_platform:{account.platform.value}:{account.mode.value}")
|
||||
65
surfsense_backend/app/gateway/runner.py
Normal file
65
surfsense_backend/app/gateway/runner.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Telegram BYO long-poll helper for FastAPI lifespan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import ExternalChatPlatform, ExternalChatAccount, async_session_maker, engine
|
||||
from app.gateway.inbox import persist_inbound_event, telegram_event_dedupe_key
|
||||
from app.gateway.telegram.adapter import TelegramAdapter
|
||||
from app.observability.metrics import record_gateway_byo_longpoll_running_delta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _lock_key(token: str) -> int:
|
||||
digest = hashlib.sha256(f"gateway:telegram:{token}".encode()).digest()
|
||||
return int.from_bytes(digest[:8], "big", signed=True)
|
||||
|
||||
|
||||
async def _run_telegram_account(account_id: int, token: str) -> None:
|
||||
async with engine.connect() as conn:
|
||||
lock_key = _lock_key(token)
|
||||
got_lock = await conn.scalar(
|
||||
text("SELECT pg_try_advisory_lock(:key)"),
|
||||
{"key": lock_key},
|
||||
)
|
||||
if not got_lock:
|
||||
logger.warning("Another Telegram gateway runner is active; exiting")
|
||||
return
|
||||
|
||||
record_gateway_byo_longpoll_running_delta(1, account_id=account_id)
|
||||
try:
|
||||
adapter = TelegramAdapter(token)
|
||||
async with async_session_maker() as session:
|
||||
account = await session.get(ExternalChatAccount, account_id)
|
||||
offset = None
|
||||
if account is not None:
|
||||
offset = int((account.cursor_state or {}).get("last_update_id", 0)) + 1
|
||||
|
||||
async for update in adapter.fetch_updates(offset=offset):
|
||||
request_id = f"gateway_{uuid.uuid4().hex[:16]}"
|
||||
async with async_session_maker() as session:
|
||||
parsed = adapter.parse_inbound(update)
|
||||
inbox_id = await persist_inbound_event(
|
||||
session,
|
||||
account_id=account_id,
|
||||
platform=ExternalChatPlatform.TELEGRAM,
|
||||
event_dedupe_key=telegram_event_dedupe_key(update["update_id"]),
|
||||
external_event_id=str(update["update_id"]),
|
||||
external_message_id=parsed.external_message_id,
|
||||
event_kind=parsed.event_kind,
|
||||
raw_payload=update,
|
||||
request_id=request_id,
|
||||
)
|
||||
await session.commit()
|
||||
if inbox_id is not None:
|
||||
logger.debug("Persisted Telegram polling update inbox_id=%s", inbox_id)
|
||||
finally:
|
||||
record_gateway_byo_longpoll_running_delta(-1, account_id=account_id)
|
||||
await conn.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key})
|
||||
|
||||
1
surfsense_backend/app/gateway/slack/__init__.py
Normal file
1
surfsense_backend/app/gateway/slack/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Slack gateway integration."""
|
||||
120
surfsense_backend/app/gateway/slack/adapter.py
Normal file
120
surfsense_backend/app/gateway/slack/adapter.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Slack platform adapter for app mentions and threaded replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.gateway.base.adapter import (
|
||||
BasePlatformAdapter,
|
||||
ParsedInboundEvent,
|
||||
PlatformSendResult,
|
||||
)
|
||||
from app.gateway.slack.client import SlackGatewayClient
|
||||
|
||||
MENTION_RE = re.compile(r"<@[^>]+>\s*")
|
||||
|
||||
|
||||
def slack_user_peer_id(team_id: str, slack_user_id: str) -> str:
|
||||
return f"slack_user:{team_id}:{slack_user_id}"
|
||||
|
||||
|
||||
def slack_thread_peer_id(team_id: str, channel_id: str, thread_ts: str) -> str:
|
||||
return f"slack_thread:{team_id}:{channel_id}:{thread_ts}"
|
||||
|
||||
|
||||
class SlackAdapter(BasePlatformAdapter):
|
||||
platform = "slack"
|
||||
|
||||
def __init__(self, bot_token: str, *, bot_user_id: str | None = None) -> None:
|
||||
self.bot_user_id = bot_user_id
|
||||
self.client = SlackGatewayClient(bot_token)
|
||||
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
event = raw_payload.get("event") or {}
|
||||
event_type = str(event.get("type") or "other")
|
||||
team_id = str(raw_payload.get("team_id") or event.get("team") or "")
|
||||
channel_id = str(event.get("channel") or "")
|
||||
slack_user_id = str(event.get("user") or "")
|
||||
message_ts = str(event.get("ts") or "")
|
||||
thread_ts = str(event.get("thread_ts") or message_ts)
|
||||
bot_user_id = self.bot_user_id or str(raw_payload.get("authorizations", [{}])[0].get("user_id") or "")
|
||||
|
||||
if not channel_id or not slack_user_id or not message_ts:
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=event_type,
|
||||
external_peer_id=None,
|
||||
external_peer_kind="unknown",
|
||||
external_message_id=message_ts or None,
|
||||
external_user_id=slack_user_id or None,
|
||||
text=None,
|
||||
raw_payload=raw_payload,
|
||||
metadata={"team_id": team_id, "bot_user_id": bot_user_id},
|
||||
)
|
||||
|
||||
text = str(event.get("text") or "")
|
||||
if bot_user_id:
|
||||
text = text.replace(f"<@{bot_user_id}>", "")
|
||||
text = MENTION_RE.sub("", text).strip()
|
||||
|
||||
peer_kind = "direct" if str(event.get("channel_type")) == "im" else "channel"
|
||||
thread_key = slack_thread_peer_id(team_id, channel_id, thread_ts)
|
||||
user_key = slack_user_peer_id(team_id, slack_user_id)
|
||||
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=event_type,
|
||||
external_peer_id=thread_key,
|
||||
external_peer_kind=peer_kind,
|
||||
external_message_id=message_ts,
|
||||
external_user_id=slack_user_id,
|
||||
text=text,
|
||||
raw_payload=raw_payload,
|
||||
display_name=None,
|
||||
username=slack_user_id,
|
||||
metadata={
|
||||
"team_id": team_id,
|
||||
"channel_id": channel_id,
|
||||
"slack_user_id": slack_user_id,
|
||||
"message_ts": message_ts,
|
||||
"thread_ts": thread_ts,
|
||||
"bot_user_id": bot_user_id,
|
||||
"slack_user_peer_id": user_key,
|
||||
"slack_thread_peer_id": thread_key,
|
||||
"channel_type": event.get("channel_type"),
|
||||
},
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
del parse_mode
|
||||
return await self.client.send_message(
|
||||
channel=external_peer_id,
|
||||
text=text,
|
||||
thread_ts=reply_to_message_id,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
del parse_mode
|
||||
return await self.client.update_message(
|
||||
channel=external_peer_id,
|
||||
ts=external_message_id,
|
||||
text=text,
|
||||
)
|
||||
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
return await self.client.validate()
|
||||
72
surfsense_backend/app/gateway/slack/client.py
Normal file
72
surfsense_backend/app/gateway/slack/client.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Slack Web API client for gateway bot operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
|
||||
SLACK_API = "https://slack.com/api"
|
||||
|
||||
|
||||
class SlackGatewayClient:
|
||||
def __init__(self, bot_token: str) -> None:
|
||||
self.bot_token = bot_token
|
||||
|
||||
async def api_call(self, method: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(
|
||||
f"{SLACK_API}/{method}",
|
||||
json=payload or {},
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.bot_token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("ok", False):
|
||||
error = data.get("error", "unknown_error")
|
||||
raise RuntimeError(f"Slack API {method} failed: {error}")
|
||||
return data
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
text: str,
|
||||
thread_ts: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
payload: dict[str, Any] = {"channel": channel, "text": text}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
data = await self.api_call("chat.postMessage", payload)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(data.get("ts", "")),
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
async def update_message(
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
ts: str,
|
||||
text: str,
|
||||
) -> PlatformSendResult:
|
||||
data = await self.api_call("chat.update", {"channel": channel, "ts": ts, "text": text})
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(data.get("ts") or ts),
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
async def validate(self) -> dict[str, Any]:
|
||||
data = await self.api_call("auth.test")
|
||||
return {
|
||||
"ok": True,
|
||||
"team_id": data.get("team_id"),
|
||||
"team": data.get("team"),
|
||||
"bot_user_id": data.get("user_id"),
|
||||
"bot_username": data.get("user"),
|
||||
}
|
||||
64
surfsense_backend/app/gateway/slack/commands.py
Normal file
64
surfsense_backend/app/gateway/slack/commands.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Slack command/onboarding handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gateway.base.adapter import ParsedInboundEvent
|
||||
from app.gateway.base.commands import BaseGatewayCommands
|
||||
from app.gateway.ratelimit import acquire_token
|
||||
from app.gateway.slack.adapter import SlackAdapter
|
||||
|
||||
HELP_TEXT = (
|
||||
"SurfSense Slack commands:\n"
|
||||
"`/new` - start a fresh SurfSense conversation in this thread\n"
|
||||
"`/help` - show this help\n\n"
|
||||
"Mention the SurfSense bot in a channel thread to ask your agent a question."
|
||||
)
|
||||
|
||||
|
||||
class SlackGatewayCommands(BaseGatewayCommands):
|
||||
async def handle_help_command(
|
||||
self,
|
||||
*,
|
||||
adapter: SlackAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
channel_id = event.metadata.get("channel_id")
|
||||
thread_ts = event.metadata.get("thread_ts")
|
||||
if not channel_id or not thread_ts:
|
||||
return True
|
||||
await adapter.send_message(
|
||||
external_peer_id=channel_id,
|
||||
text=HELP_TEXT,
|
||||
reply_to_message_id=thread_ts,
|
||||
)
|
||||
return True
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
self,
|
||||
*,
|
||||
adapter: SlackAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
channel_id = event.metadata.get("channel_id")
|
||||
thread_ts = event.metadata.get("thread_ts")
|
||||
slack_user_id = event.metadata.get("slack_user_id")
|
||||
if not channel_id or not thread_ts:
|
||||
return
|
||||
|
||||
wait_ms = await acquire_token(
|
||||
f"slack:onboarded:{event.metadata.get('team_id')}:{slack_user_id}",
|
||||
capacity=1,
|
||||
refill_per_sec=1 / 3600,
|
||||
)
|
||||
if wait_ms > 0:
|
||||
return
|
||||
|
||||
await adapter.send_message(
|
||||
external_peer_id=channel_id,
|
||||
reply_to_message_id=thread_ts,
|
||||
text=(
|
||||
"Hi! Connect your Slack user to SurfSense before using the bot here: "
|
||||
f"{dashboard_url}"
|
||||
),
|
||||
)
|
||||
86
surfsense_backend/app/gateway/slack/translator.py
Normal file
86
surfsense_backend/app/gateway/slack/translator.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Translate agent stream events into Slack thread replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
from app.gateway.base.formatting import split_text_message
|
||||
from app.gateway.base.translator import BaseStreamTranslator, GatewayStreamEvent
|
||||
from app.gateway.ratelimit import wait_for_token
|
||||
from app.gateway.slack.adapter import SlackAdapter
|
||||
from app.observability.metrics import (
|
||||
record_gateway_hitl_aborted,
|
||||
record_gateway_outbound,
|
||||
record_gateway_rate_limit_hit,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLACK_MAX_MESSAGE_CHARS = 35000
|
||||
HITL_UNSUPPORTED_MESSAGE = (
|
||||
"This action requires approval and is not yet supported from Slack. "
|
||||
"Try again with a different request."
|
||||
)
|
||||
|
||||
|
||||
class SlackStreamTranslator(BaseStreamTranslator):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
adapter: SlackAdapter,
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.channel_id = channel_id
|
||||
self.thread_ts = thread_ts
|
||||
self._buffer = ""
|
||||
|
||||
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
if event.type in {"text-delta", "text_delta", "text"}:
|
||||
self._buffer += str(event.data.get("text") or event.data.get("delta") or "")
|
||||
elif event.type in {"data-interrupt-request", "interrupt"}:
|
||||
await self._handle_hitl_interrupt()
|
||||
return
|
||||
elif event.type in {"finish", "done"}:
|
||||
break
|
||||
|
||||
await self._flush_final()
|
||||
|
||||
async def _flush_final(self) -> None:
|
||||
if not self._buffer:
|
||||
return
|
||||
for chunk in split_text_message(self._buffer, max_chars=SLACK_MAX_MESSAGE_CHARS):
|
||||
await self._send_text(chunk)
|
||||
|
||||
async def _send_text(self, text: str) -> PlatformSendResult:
|
||||
await self._throttle()
|
||||
try:
|
||||
result = await self.adapter.send_message(
|
||||
external_peer_id=self.channel_id,
|
||||
text=text,
|
||||
reply_to_message_id=self.thread_ts,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="slack", kind="send", status="failed")
|
||||
raise
|
||||
record_gateway_outbound(platform="slack", kind="send", status="sent")
|
||||
return result
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
chat_wait = await wait_for_token(
|
||||
f"slack:channel:{self.channel_id}",
|
||||
capacity=1,
|
||||
refill_per_sec=1.0,
|
||||
)
|
||||
if chat_wait:
|
||||
record_gateway_rate_limit_hit(bucket="slack:channel")
|
||||
|
||||
async def _handle_hitl_interrupt(self) -> None:
|
||||
if self._buffer:
|
||||
await self._flush_final()
|
||||
await self._send_text(HITL_UNSUPPORTED_MESSAGE)
|
||||
record_gateway_hitl_aborted(platform="slack")
|
||||
2
surfsense_backend/app/gateway/telegram/__init__.py
Normal file
2
surfsense_backend/app/gateway/telegram/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Telegram gateway adapter."""
|
||||
|
||||
114
surfsense_backend/app/gateway/telegram/adapter.py
Normal file
114
surfsense_backend/app/gateway/telegram/adapter.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Telegram platform adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from app.gateway.base.adapter import (
|
||||
BasePlatformAdapter,
|
||||
ParsedInboundEvent,
|
||||
PlatformSendResult,
|
||||
)
|
||||
from app.gateway.telegram.client import TelegramClient
|
||||
|
||||
|
||||
class TelegramAdapter(BasePlatformAdapter):
|
||||
platform = "telegram"
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self.client = TelegramClient(token)
|
||||
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
event_kind = "other"
|
||||
message = raw_payload.get("message")
|
||||
if message is not None:
|
||||
event_kind = "message"
|
||||
else:
|
||||
message = raw_payload.get("edited_message")
|
||||
if message is not None:
|
||||
event_kind = "edited_message"
|
||||
|
||||
if message is None:
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=event_kind,
|
||||
external_peer_id=None,
|
||||
external_peer_kind="unknown",
|
||||
external_message_id=None,
|
||||
external_user_id=None,
|
||||
text=None,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
|
||||
chat = message.get("chat") or {}
|
||||
sender = message.get("from") or {}
|
||||
chat_type = str(chat.get("type") or "unknown")
|
||||
peer_kind = {
|
||||
"private": "direct",
|
||||
"group": "group",
|
||||
"supergroup": "group",
|
||||
"channel": "channel",
|
||||
}.get(chat_type, "unknown")
|
||||
display_name = chat.get("title") or " ".join(
|
||||
part
|
||||
for part in (sender.get("first_name"), sender.get("last_name"))
|
||||
if part
|
||||
)
|
||||
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=event_kind,
|
||||
external_peer_id=str(chat["id"]) if chat.get("id") is not None else None,
|
||||
external_peer_kind=peer_kind,
|
||||
external_message_id=(
|
||||
str(message["message_id"]) if message.get("message_id") is not None else None
|
||||
),
|
||||
external_user_id=str(sender["id"]) if sender.get("id") is not None else None,
|
||||
text=message.get("text") or message.get("caption"),
|
||||
raw_payload=raw_payload,
|
||||
display_name=display_name or None,
|
||||
username=sender.get("username") or chat.get("username"),
|
||||
metadata={"chat_type": chat_type, "update_id": raw_payload.get("update_id")},
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
return await self.client.send_message(
|
||||
chat_id=external_peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
return await self.client.edit_message(
|
||||
chat_id=external_peer_id,
|
||||
message_id=external_message_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
return await self.client.validate()
|
||||
|
||||
async def leave_chat(self, *, external_peer_id: str) -> None:
|
||||
await self.client.leave_chat(chat_id=external_peer_id)
|
||||
|
||||
async def fetch_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]:
|
||||
async for update in self.client.get_updates(offset=offset):
|
||||
yield update
|
||||
|
||||
109
surfsense_backend/app/gateway/telegram/client.py
Normal file
109
surfsense_backend/app/gateway/telegram/client.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Thin async Telegram Bot API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from telegram import Bot
|
||||
from telegram.error import BadRequest, RetryAfter
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
|
||||
|
||||
def retry_after_seconds(value: int | timedelta) -> float:
|
||||
if isinstance(value, timedelta):
|
||||
return value.total_seconds()
|
||||
return float(value)
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
def __init__(self, token: str) -> None:
|
||||
self.token = token
|
||||
self.bot = Bot(token=token)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if parse_mode:
|
||||
kwargs["parse_mode"] = parse_mode
|
||||
if reply_to_message_id:
|
||||
kwargs["reply_to_message_id"] = int(reply_to_message_id)
|
||||
try:
|
||||
msg = await self.bot.send_message(chat_id=chat_id, text=text, **kwargs)
|
||||
except RetryAfter as exc:
|
||||
await asyncio.sleep(retry_after_seconds(exc.retry_after))
|
||||
msg = await self.bot.send_message(chat_id=chat_id, text=text, **kwargs)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(msg.message_id),
|
||||
raw_response=msg.to_dict(),
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if parse_mode:
|
||||
kwargs["parse_mode"] = parse_mode
|
||||
try:
|
||||
msg = await self.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=int(message_id),
|
||||
text=text,
|
||||
**kwargs,
|
||||
)
|
||||
except RetryAfter as exc:
|
||||
await asyncio.sleep(retry_after_seconds(exc.retry_after))
|
||||
msg = await self.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=int(message_id),
|
||||
text=text,
|
||||
**kwargs,
|
||||
)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(msg.message_id),
|
||||
raw_response=msg.to_dict(),
|
||||
)
|
||||
|
||||
async def validate(self) -> dict[str, Any]:
|
||||
me = await self.bot.get_me()
|
||||
return me.to_dict()
|
||||
|
||||
async def leave_chat(self, *, chat_id: str) -> None:
|
||||
await self.bot.leave_chat(chat_id=chat_id)
|
||||
|
||||
async def get_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]:
|
||||
next_offset = offset
|
||||
while True:
|
||||
updates = await self.bot.get_updates(
|
||||
offset=next_offset,
|
||||
timeout=30,
|
||||
allowed_updates=["message", "edited_message"],
|
||||
)
|
||||
for update in updates:
|
||||
next_offset = update.update_id + 1
|
||||
yield update.to_dict()
|
||||
|
||||
|
||||
async def retry_plaintext_on_bad_markdown(call, *args, **kwargs) -> PlatformSendResult:
|
||||
try:
|
||||
return await call(*args, **kwargs)
|
||||
except BadRequest as exc:
|
||||
if "can't parse entities" not in str(exc).lower():
|
||||
raise
|
||||
kwargs["parse_mode"] = None
|
||||
return await call(*args, **kwargs)
|
||||
|
||||
117
surfsense_backend/app/gateway/telegram/commands.py
Normal file
117
surfsense_backend/app/gateway/telegram/commands.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Telegram command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gateway.base.adapter import ParsedInboundEvent
|
||||
from app.gateway.base.commands import BaseGatewayCommands
|
||||
from app.gateway.pairing import redeem_pairing_code
|
||||
from app.gateway.ratelimit import acquire_token
|
||||
from app.gateway.telegram.adapter import TelegramAdapter
|
||||
|
||||
HELP_TEXT = (
|
||||
"SurfSense Telegram commands:\n"
|
||||
"/start <code> - pair this chat\n"
|
||||
"/new - start a fresh conversation\n"
|
||||
"/help - show this help"
|
||||
)
|
||||
|
||||
|
||||
async def handle_start_command(
|
||||
*,
|
||||
session,
|
||||
adapter: TelegramAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
text = event.text or ""
|
||||
parts = text.split(maxsplit=1)
|
||||
if len(parts) != 2 or not event.external_peer_id:
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id or "",
|
||||
text="Generate a pairing code in SurfSense Settings > Messaging Channels, then send /start CODE here.",
|
||||
)
|
||||
return True
|
||||
|
||||
binding = await redeem_pairing_code(
|
||||
session,
|
||||
code=parts[1].strip(),
|
||||
external_peer_id=event.external_peer_id,
|
||||
external_peer_kind=event.external_peer_kind,
|
||||
external_display_name=event.display_name,
|
||||
external_username=event.username,
|
||||
external_metadata=event.metadata,
|
||||
)
|
||||
if binding is None:
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id,
|
||||
text="That pairing code is invalid or expired. Generate a new code in SurfSense.",
|
||||
)
|
||||
return True
|
||||
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id,
|
||||
text="SurfSense is connected. Send a message here to chat with your agent.",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def handle_help_command(*, adapter: TelegramAdapter, event: ParsedInboundEvent) -> bool:
|
||||
if not event.external_peer_id:
|
||||
return True
|
||||
await adapter.send_message(external_peer_id=event.external_peer_id, text=HELP_TEXT)
|
||||
return True
|
||||
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
*,
|
||||
adapter: TelegramAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
if not event.external_peer_id:
|
||||
return
|
||||
wait_ms = await acquire_token(
|
||||
f"tg:onboarded:{event.external_peer_id}",
|
||||
capacity=1,
|
||||
refill_per_sec=1 / 3600,
|
||||
)
|
||||
if wait_ms > 0:
|
||||
return
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id,
|
||||
text=(
|
||||
"Hi! To use SurfSense via Telegram, generate a pairing code at "
|
||||
f"{dashboard_url} and send /start CODE here."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TelegramGatewayCommands(BaseGatewayCommands):
|
||||
async def handle_start_command(
|
||||
self,
|
||||
*,
|
||||
session,
|
||||
adapter: TelegramAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
return await handle_start_command(session=session, adapter=adapter, event=event)
|
||||
|
||||
async def handle_help_command(
|
||||
self,
|
||||
*,
|
||||
adapter: TelegramAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
return await handle_help_command(adapter=adapter, event=event)
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
self,
|
||||
*,
|
||||
adapter: TelegramAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
await send_unbound_onboarding(
|
||||
adapter=adapter,
|
||||
event=event,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
59
surfsense_backend/app/gateway/telegram/formatting.py
Normal file
59
surfsense_backend/app/gateway/telegram/formatting.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Telegram formatting helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.gateway.base.formatting import split_text_message
|
||||
|
||||
MARKDOWN_V2_RESERVED = r"_*[]()~`>#+-=|{}.!"
|
||||
MAX_TELEGRAM_MESSAGE_UNITS = 4096
|
||||
|
||||
_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!])")
|
||||
|
||||
|
||||
def escape_markdown_v2(text: str) -> str:
|
||||
"""Escape all Telegram MarkdownV2 reserved characters."""
|
||||
return _RESERVED_RE.sub(r"\\\1", text)
|
||||
|
||||
|
||||
def _utf16_len(text: str) -> int:
|
||||
return len(text.encode("utf-16-le")) // 2
|
||||
|
||||
|
||||
def _split_at_boundary(text: str, max_units: int) -> tuple[str, str]:
|
||||
if _utf16_len(text) <= max_units:
|
||||
return text, ""
|
||||
|
||||
# Build a hard upper bound by code point, then walk back to natural
|
||||
# boundaries. Telegram's limit is UTF-16 code units, so verify candidates.
|
||||
end = min(len(text), max_units)
|
||||
while end > 0 and _utf16_len(text[:end]) > max_units:
|
||||
end -= 1
|
||||
|
||||
candidate = text[:end]
|
||||
boundary = max(candidate.rfind("\n\n"), candidate.rfind(". "), candidate.rfind("\n"))
|
||||
if boundary > max(200, end // 2):
|
||||
end = boundary + (2 if candidate[boundary : boundary + 2] in {"\n\n", ". "} else 1)
|
||||
|
||||
return text[:end], text[end:]
|
||||
|
||||
|
||||
def chunk_message(
|
||||
text: str,
|
||||
*,
|
||||
max_units: int = MAX_TELEGRAM_MESSAGE_UNITS,
|
||||
) -> list[str]:
|
||||
"""Split a Telegram message at paragraph/sentence boundaries."""
|
||||
if max_units == MAX_TELEGRAM_MESSAGE_UNITS:
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
chunk, remaining = _split_at_boundary(remaining, max_units)
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
return split_text_message(text, max_chars=max_units)
|
||||
|
||||
171
surfsense_backend/app/gateway/telegram/translator.py
Normal file
171
surfsense_backend/app/gateway/telegram/translator.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Translate agent stream events into Telegram messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from telegram.constants import ParseMode
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
from app.gateway.base.translator import BaseStreamTranslator, GatewayStreamEvent
|
||||
from app.gateway.ratelimit import wait_for_token
|
||||
from app.gateway.telegram.adapter import TelegramAdapter
|
||||
from app.gateway.telegram.client import retry_plaintext_on_bad_markdown
|
||||
from app.gateway.telegram.formatting import chunk_message, escape_markdown_v2
|
||||
from app.observability.metrics import (
|
||||
record_gateway_hitl_aborted,
|
||||
record_gateway_outbound,
|
||||
record_gateway_rate_limit_hit,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HITL_UNSUPPORTED_MESSAGE = (
|
||||
"This action requires approval and is not yet supported from Telegram. "
|
||||
"Try again with a different request."
|
||||
)
|
||||
|
||||
|
||||
class TelegramStreamTranslator(BaseStreamTranslator):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
adapter: TelegramAdapter,
|
||||
external_peer_id: str,
|
||||
assistant_message_id: int | None = None,
|
||||
debounce_seconds: float = 1.5,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.external_peer_id = external_peer_id
|
||||
self.assistant_message_id = assistant_message_id
|
||||
self.debounce_seconds = debounce_seconds
|
||||
self._buffer = ""
|
||||
self._last_flush_at = 0.0
|
||||
self._external_message_ids: list[str] = []
|
||||
self._plaintext_mode = False
|
||||
|
||||
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
if event.type in {"text-delta", "text_delta", "text"}:
|
||||
self._buffer += str(event.data.get("text") or event.data.get("delta") or "")
|
||||
await self._maybe_flush()
|
||||
elif event.type in {"data-interrupt-request", "interrupt"}:
|
||||
await self._handle_hitl_interrupt()
|
||||
return
|
||||
elif event.type in {"finish", "done"}:
|
||||
break
|
||||
|
||||
await self._flush(final=True)
|
||||
|
||||
async def _maybe_flush(self) -> None:
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now - self._last_flush_at < self.debounce_seconds:
|
||||
return
|
||||
await self._flush(final=False)
|
||||
self._last_flush_at = now
|
||||
|
||||
async def _flush(self, *, final: bool) -> None:
|
||||
if not self._buffer:
|
||||
return
|
||||
|
||||
chunks = chunk_message(self._buffer)
|
||||
# During streaming, keep edits on the last chunk only. At final flush,
|
||||
# send any additional chunks and mark the message as finalized by the
|
||||
# persistence layer (wired through agent/task code).
|
||||
if len(chunks) > 1:
|
||||
for chunk in chunks[:-1]:
|
||||
result = await self._send_text(chunk)
|
||||
self._external_message_ids.append(result.external_message_id)
|
||||
self._buffer = chunks[-1]
|
||||
|
||||
text = self._format_text(self._buffer)
|
||||
if self._external_message_ids:
|
||||
await self._edit_text(self._external_message_ids[-1], text)
|
||||
else:
|
||||
result = await self._send_text(self._buffer)
|
||||
self._external_message_ids.append(result.external_message_id)
|
||||
|
||||
if final:
|
||||
logger.debug(
|
||||
"Telegram gateway finalized assistant message id=%s external_ids=%s",
|
||||
self.assistant_message_id,
|
||||
self._external_message_ids,
|
||||
)
|
||||
|
||||
def _format_text(self, text: str) -> str:
|
||||
return text if self._plaintext_mode else escape_markdown_v2(text)
|
||||
|
||||
async def _send_text(self, text: str) -> PlatformSendResult:
|
||||
await self._throttle()
|
||||
parse_mode = None if self._plaintext_mode else ParseMode.MARKDOWN_V2
|
||||
logger.info(
|
||||
"Telegram gateway sending message peer=%s chars=%d",
|
||||
self.external_peer_id,
|
||||
len(text),
|
||||
)
|
||||
try:
|
||||
result = await retry_plaintext_on_bad_markdown(
|
||||
self.adapter.send_message,
|
||||
external_peer_id=self.external_peer_id,
|
||||
text=self._format_text(text),
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="telegram", kind="send", status="failed")
|
||||
raise
|
||||
logger.info(
|
||||
"Telegram gateway sent message peer=%s message_id=%s",
|
||||
self.external_peer_id,
|
||||
result.external_message_id,
|
||||
)
|
||||
record_gateway_outbound(platform="telegram", kind="send", status="sent")
|
||||
return result
|
||||
|
||||
async def _edit_text(self, message_id: str, text: str) -> PlatformSendResult:
|
||||
await self._throttle()
|
||||
parse_mode = None if self._plaintext_mode else ParseMode.MARKDOWN_V2
|
||||
logger.info(
|
||||
"Telegram gateway editing message peer=%s message_id=%s chars=%d",
|
||||
self.external_peer_id,
|
||||
message_id,
|
||||
len(text),
|
||||
)
|
||||
try:
|
||||
result = await retry_plaintext_on_bad_markdown(
|
||||
self.adapter.edit_message,
|
||||
external_peer_id=self.external_peer_id,
|
||||
external_message_id=message_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="telegram", kind="edit", status="failed")
|
||||
raise
|
||||
logger.info(
|
||||
"Telegram gateway edited message peer=%s message_id=%s",
|
||||
self.external_peer_id,
|
||||
result.external_message_id,
|
||||
)
|
||||
record_gateway_outbound(platform="telegram", kind="edit", status="edited")
|
||||
return result
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
chat_wait = await wait_for_token(
|
||||
f"tg:chat:{self.external_peer_id}",
|
||||
capacity=1,
|
||||
refill_per_sec=1.0,
|
||||
)
|
||||
if chat_wait:
|
||||
record_gateway_rate_limit_hit(bucket="tg:chat")
|
||||
global_wait = await wait_for_token("tg:global", capacity=25, refill_per_sec=25.0)
|
||||
if global_wait:
|
||||
record_gateway_rate_limit_hit(bucket="tg:global")
|
||||
|
||||
async def _handle_hitl_interrupt(self) -> None:
|
||||
if self._buffer:
|
||||
await self._flush(final=False)
|
||||
await self._send_text(HITL_UNSUPPORTED_MESSAGE)
|
||||
record_gateway_hitl_aborted(platform="telegram")
|
||||
|
||||
40
surfsense_backend/app/gateway/thread_lock.py
Normal file
40
surfsense_backend/app/gateway/thread_lock.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Redis-backed distributed locks for gateway conversation turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import redis
|
||||
|
||||
from app.config import config
|
||||
from app.observability.metrics import record_gateway_thread_lock_contention
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
def _redis() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def _lock_key(thread_id: int) -> str:
|
||||
return f"gateway:thread_lock:{thread_id}"
|
||||
|
||||
|
||||
def acquire_thread_lock(thread_id: int, ttl: int = 60) -> bool:
|
||||
acquired = bool(_redis().set(_lock_key(thread_id), "1", nx=True, ex=ttl))
|
||||
if not acquired:
|
||||
record_gateway_thread_lock_contention()
|
||||
return acquired
|
||||
|
||||
|
||||
def release_thread_lock(thread_id: int) -> None:
|
||||
try:
|
||||
_redis().delete(_lock_key(thread_id))
|
||||
except redis.RedisError as exc:
|
||||
logger.warning("Failed to release gateway thread lock for %s: %s", thread_id, exc)
|
||||
|
||||
1
surfsense_backend/app/gateway/whatsapp/__init__.py
Normal file
1
surfsense_backend/app/gateway/whatsapp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""WhatsApp gateway implementations."""
|
||||
118
surfsense_backend/app/gateway/whatsapp/adapter_baileys.py
Normal file
118
surfsense_backend/app/gateway/whatsapp/adapter_baileys.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Baileys bridge platform adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import config
|
||||
from app.gateway.base.adapter import (
|
||||
BasePlatformAdapter,
|
||||
ParsedInboundEvent,
|
||||
PlatformSendResult,
|
||||
)
|
||||
|
||||
|
||||
class WhatsAppBaileysAdapter(BasePlatformAdapter):
|
||||
platform = "whatsapp"
|
||||
|
||||
def __init__(self, bridge_url: str | None = None) -> None:
|
||||
self.bridge_url = (bridge_url or config.WHATSAPP_BRIDGE_URL).rstrip("/")
|
||||
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
chat_id = str(raw_payload.get("chatId") or "")
|
||||
sender_id = str(raw_payload.get("senderId") or chat_id)
|
||||
message_id = str(raw_payload.get("messageId") or "")
|
||||
body = raw_payload.get("body")
|
||||
is_group = bool(raw_payload.get("isGroup"))
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind="message",
|
||||
external_peer_id=chat_id or None,
|
||||
external_peer_kind="group" if is_group else "direct",
|
||||
external_message_id=message_id or None,
|
||||
external_user_id=sender_id or None,
|
||||
text=str(body) if body is not None else None,
|
||||
raw_payload=raw_payload,
|
||||
display_name=str(raw_payload.get("chatName") or sender_id or chat_id) or None,
|
||||
username=None,
|
||||
metadata={
|
||||
"sender_id": sender_id,
|
||||
"from_me": bool(raw_payload.get("fromMe")),
|
||||
"timestamp": raw_payload.get("timestamp"),
|
||||
},
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
payload: dict[str, Any] = {"chatId": external_peer_id, "message": text}
|
||||
if reply_to_message_id:
|
||||
payload["replyTo"] = reply_to_message_id
|
||||
data = await self._post("/send", payload)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(data.get("messageId") or ""),
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
data = await self._post(
|
||||
"/edit",
|
||||
{
|
||||
"chatId": external_peer_id,
|
||||
"messageId": external_message_id,
|
||||
"message": text,
|
||||
},
|
||||
)
|
||||
return PlatformSendResult(
|
||||
external_message_id=str(data.get("messageId") or external_message_id),
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
async def send_typing_indicator(self, *, external_peer_id: str) -> None:
|
||||
await self._post("/typing", {"chatId": external_peer_id}, expect_json=False)
|
||||
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(f"{self.bridge_url}/health")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def fetch_updates(self, *, offset: int | None) -> AsyncIterator[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(timeout=35) as client:
|
||||
response = await client.get(f"{self.bridge_url}/messages")
|
||||
response.raise_for_status()
|
||||
for message in response.json():
|
||||
if isinstance(message, dict):
|
||||
yield message
|
||||
|
||||
async def request_pairing_code(self, *, phone_number: str) -> dict[str, Any]:
|
||||
return await self._post("/pair", {"phoneNumber": phone_number})
|
||||
|
||||
async def _post(
|
||||
self,
|
||||
path: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
expect_json: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(f"{self.bridge_url}{path}", json=payload)
|
||||
response.raise_for_status()
|
||||
if not expect_json or response.status_code == 204:
|
||||
return {}
|
||||
return response.json()
|
||||
149
surfsense_backend/app/gateway/whatsapp/adapter_cloud.py
Normal file
149
surfsense_backend/app/gateway/whatsapp/adapter_cloud.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""WhatsApp Cloud API platform adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.gateway.base.adapter import (
|
||||
BasePlatformAdapter,
|
||||
ParsedInboundEvent,
|
||||
PlatformSendResult,
|
||||
)
|
||||
from app.gateway.whatsapp.client_cloud import WhatsAppCloudClient
|
||||
from app.gateway.whatsapp.credentials import WhatsAppCredentials
|
||||
|
||||
|
||||
class WhatsAppCloudAdapter(BasePlatformAdapter):
|
||||
platform = "whatsapp"
|
||||
|
||||
def __init__(self, credentials: WhatsAppCredentials) -> None:
|
||||
self.credentials = credentials
|
||||
self.client = WhatsAppCloudClient(
|
||||
business_token=credentials["business_token"],
|
||||
phone_number_id=credentials["phone_number_id"],
|
||||
api_version=credentials.get("api_version"),
|
||||
)
|
||||
|
||||
def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent:
|
||||
message = _first_message(raw_payload)
|
||||
if message is None:
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind="other",
|
||||
external_peer_id=None,
|
||||
external_peer_kind="unknown",
|
||||
external_message_id=None,
|
||||
external_user_id=None,
|
||||
text=None,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
|
||||
contact = _first_contact(raw_payload, message.get("from"))
|
||||
text = _message_text(message)
|
||||
wa_id = str(message.get("from") or "")
|
||||
return ParsedInboundEvent(
|
||||
platform=self.platform,
|
||||
event_kind=str(message.get("type") or "message"),
|
||||
external_peer_id=wa_id or None,
|
||||
external_peer_kind="direct",
|
||||
external_message_id=str(message.get("id")) if message.get("id") else None,
|
||||
external_user_id=wa_id or None,
|
||||
text=text,
|
||||
raw_payload=raw_payload,
|
||||
display_name=(contact.get("profile") or {}).get("name"),
|
||||
username=None,
|
||||
metadata={
|
||||
"phone_number_id": _metadata(raw_payload).get("phone_number_id"),
|
||||
"display_phone_number": _metadata(raw_payload).get("display_phone_number"),
|
||||
"timestamp": message.get("timestamp"),
|
||||
"message_type": message.get("type"),
|
||||
},
|
||||
)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
return await self.client.send_text(
|
||||
to=external_peer_id,
|
||||
text=text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
*,
|
||||
external_peer_id: str,
|
||||
external_message_id: str,
|
||||
text: str,
|
||||
parse_mode: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
raise NotImplementedError("WhatsApp Cloud API does not support message edits")
|
||||
|
||||
async def send_typing_indicator(self, *, inbound_message_id: str) -> None:
|
||||
await self.client.send_typing_indicator(message_id=inbound_message_id)
|
||||
|
||||
async def validate_credentials(self) -> dict[str, Any]:
|
||||
return await self.client.validate()
|
||||
|
||||
|
||||
def _changes(raw_payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
changes: list[dict[str, Any]] = []
|
||||
for entry in raw_payload.get("entry") or []:
|
||||
if isinstance(entry, dict):
|
||||
changes.extend(
|
||||
change for change in (entry.get("changes") or []) if isinstance(change, dict)
|
||||
)
|
||||
return changes
|
||||
|
||||
|
||||
def _first_message(raw_payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
for change in _changes(raw_payload):
|
||||
value = change.get("value") or {}
|
||||
messages = value.get("messages") or []
|
||||
if messages and isinstance(messages[0], dict):
|
||||
return messages[0]
|
||||
if "message" in raw_payload and isinstance(raw_payload["message"], dict):
|
||||
return raw_payload["message"]
|
||||
return None
|
||||
|
||||
|
||||
def _first_contact(
|
||||
raw_payload: dict[str, Any],
|
||||
wa_id: object,
|
||||
) -> dict[str, Any]:
|
||||
for change in _changes(raw_payload):
|
||||
value = change.get("value") or {}
|
||||
for contact in value.get("contacts") or []:
|
||||
if isinstance(contact, dict) and (
|
||||
wa_id is None or str(contact.get("wa_id")) == str(wa_id)
|
||||
):
|
||||
return contact
|
||||
return {}
|
||||
|
||||
|
||||
def _metadata(raw_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
for change in _changes(raw_payload):
|
||||
value = change.get("value") or {}
|
||||
metadata = value.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
return metadata
|
||||
return {}
|
||||
|
||||
|
||||
def _message_text(message: dict[str, Any]) -> str | None:
|
||||
message_type = message.get("type")
|
||||
if message_type == "text":
|
||||
return (message.get("text") or {}).get("body")
|
||||
if message_type == "button":
|
||||
return (message.get("button") or {}).get("text")
|
||||
if message_type == "interactive":
|
||||
interactive = message.get("interactive") or {}
|
||||
button_reply = interactive.get("button_reply") or {}
|
||||
list_reply = interactive.get("list_reply") or {}
|
||||
return button_reply.get("title") or list_reply.get("title")
|
||||
return None
|
||||
99
surfsense_backend/app/gateway/whatsapp/client_cloud.py
Normal file
99
surfsense_backend/app/gateway/whatsapp/client_cloud.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Small httpx wrapper for the WhatsApp Cloud API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import config
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
from app.gateway.ratelimit import wait_for_token
|
||||
from app.observability.metrics import record_gateway_rate_limit_hit
|
||||
|
||||
|
||||
class WhatsAppCloudClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
business_token: str,
|
||||
phone_number_id: str,
|
||||
api_version: str | None = None,
|
||||
) -> None:
|
||||
self.business_token = business_token
|
||||
self.phone_number_id = phone_number_id
|
||||
self.api_version = api_version or config.WHATSAPP_GRAPH_API_VERSION
|
||||
self.base_url = f"https://graph.facebook.com/{self.api_version}"
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
text: str,
|
||||
reply_to_message_id: str | None = None,
|
||||
) -> PlatformSendResult:
|
||||
payload: dict[str, Any] = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": to,
|
||||
"type": "text",
|
||||
"text": {"preview_url": True, "body": text},
|
||||
}
|
||||
if reply_to_message_id:
|
||||
payload["context"] = {"message_id": reply_to_message_id}
|
||||
data = await self._post(f"/{self.phone_number_id}/messages", json=payload)
|
||||
message_id = str((data.get("messages") or [{}])[0].get("id") or "")
|
||||
return PlatformSendResult(external_message_id=message_id, raw_response=data)
|
||||
|
||||
async def send_typing_indicator(self, *, message_id: str) -> dict[str, Any]:
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"status": "read",
|
||||
"message_id": message_id,
|
||||
"typing_indicator": {"type": "text"},
|
||||
}
|
||||
return await self._post(f"/{self.phone_number_id}/messages", json=payload)
|
||||
|
||||
async def validate(self) -> dict[str, Any]:
|
||||
return await self._get(
|
||||
f"/{self.phone_number_id}",
|
||||
params={
|
||||
"fields": "verified_name,quality_rating,account_review_status,display_phone_number"
|
||||
},
|
||||
)
|
||||
|
||||
async def _post(self, path: str, *, json: dict[str, Any]) -> dict[str, Any]:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}{path}",
|
||||
headers={"Authorization": f"Bearer {self.business_token}"},
|
||||
json=json,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _get(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}{path}",
|
||||
headers={"Authorization": f"Bearer {self.business_token}"},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
wait_ms = await wait_for_token(
|
||||
f"wa:phone:{self.phone_number_id}",
|
||||
capacity=10,
|
||||
refill_per_sec=10.0,
|
||||
)
|
||||
if wait_ms:
|
||||
record_gateway_rate_limit_hit(bucket="wa:phone")
|
||||
123
surfsense_backend/app/gateway/whatsapp/commands.py
Normal file
123
surfsense_backend/app/gateway/whatsapp/commands.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""WhatsApp command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gateway.base.adapter import BasePlatformAdapter, ParsedInboundEvent
|
||||
from app.gateway.base.commands import BaseGatewayCommands
|
||||
from app.gateway.pairing import redeem_pairing_code
|
||||
from app.gateway.ratelimit import acquire_token
|
||||
|
||||
HELP_TEXT = (
|
||||
"SurfSense WhatsApp commands:\n"
|
||||
"/start <code> - pair this chat\n"
|
||||
"/new - start a fresh conversation\n"
|
||||
"/help - show this help"
|
||||
)
|
||||
|
||||
|
||||
async def handle_start_command(
|
||||
*,
|
||||
session,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
text = event.text or ""
|
||||
parts = text.split(maxsplit=1)
|
||||
if len(parts) != 2 or not event.external_peer_id:
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id or "",
|
||||
text=(
|
||||
"Generate a pairing code in SurfSense Settings > Messaging Channels, "
|
||||
"then send /start CODE here."
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
binding = await redeem_pairing_code(
|
||||
session,
|
||||
code=parts[1].strip(),
|
||||
external_peer_id=event.external_peer_id,
|
||||
external_peer_kind=event.external_peer_kind,
|
||||
external_display_name=event.display_name,
|
||||
external_username=event.username,
|
||||
external_metadata=event.metadata,
|
||||
)
|
||||
if binding is None:
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id,
|
||||
text="That pairing code is invalid or expired. Generate a new code in SurfSense.",
|
||||
)
|
||||
return True
|
||||
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id,
|
||||
text="SurfSense is connected. Send a message here to chat with your agent.",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def handle_help_command(
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
if not event.external_peer_id:
|
||||
return True
|
||||
await adapter.send_message(external_peer_id=event.external_peer_id, text=HELP_TEXT)
|
||||
return True
|
||||
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
if not event.external_peer_id:
|
||||
return
|
||||
wait_ms = await acquire_token(
|
||||
f"wa:onboarded:{event.external_peer_id}",
|
||||
capacity=1,
|
||||
refill_per_sec=1 / 3600,
|
||||
)
|
||||
if wait_ms > 0:
|
||||
return
|
||||
await adapter.send_message(
|
||||
external_peer_id=event.external_peer_id,
|
||||
text=(
|
||||
"Hi! To use SurfSense via WhatsApp, generate a pairing code at "
|
||||
f"{dashboard_url} and send /start CODE here."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WhatsAppGatewayCommands(BaseGatewayCommands):
|
||||
async def handle_start_command(
|
||||
self,
|
||||
*,
|
||||
session,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
return await handle_start_command(session=session, adapter=adapter, event=event)
|
||||
|
||||
async def handle_help_command(
|
||||
self,
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
) -> bool:
|
||||
return await handle_help_command(adapter=adapter, event=event)
|
||||
|
||||
async def send_unbound_onboarding(
|
||||
self,
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: ParsedInboundEvent,
|
||||
dashboard_url: str,
|
||||
) -> None:
|
||||
await send_unbound_onboarding(
|
||||
adapter=adapter,
|
||||
event=event,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
31
surfsense_backend/app/gateway/whatsapp/credentials.py
Normal file
31
surfsense_backend/app/gateway/whatsapp/credentials.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Credential helpers for WhatsApp gateway accounts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
from app.config import config
|
||||
|
||||
|
||||
class WhatsAppCredentials(TypedDict, total=False):
|
||||
business_token: str
|
||||
waba_id: str
|
||||
phone_number_id: str
|
||||
business_id: str
|
||||
registration_pin: str
|
||||
api_version: str
|
||||
|
||||
|
||||
def load_system_whatsapp_credentials() -> WhatsAppCredentials:
|
||||
if not (
|
||||
config.WHATSAPP_SHARED_BUSINESS_TOKEN
|
||||
and config.WHATSAPP_SHARED_PHONE_NUMBER_ID
|
||||
):
|
||||
raise RuntimeError("whatsapp_system_credentials_not_configured")
|
||||
|
||||
return {
|
||||
"business_token": config.WHATSAPP_SHARED_BUSINESS_TOKEN,
|
||||
"phone_number_id": config.WHATSAPP_SHARED_PHONE_NUMBER_ID,
|
||||
"waba_id": config.WHATSAPP_SHARED_WABA_ID,
|
||||
"api_version": config.WHATSAPP_GRAPH_API_VERSION,
|
||||
}
|
||||
90
surfsense_backend/app/gateway/whatsapp/translator.py
Normal file
90
surfsense_backend/app/gateway/whatsapp/translator.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Translate agent stream events into WhatsApp Cloud API messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from app.gateway.base.adapter import BasePlatformAdapter, PlatformSendResult
|
||||
from app.gateway.base.formatting import split_text_message
|
||||
from app.gateway.base.translator import BaseStreamTranslator, GatewayStreamEvent
|
||||
from app.gateway.whatsapp.adapter_cloud import WhatsAppCloudAdapter
|
||||
from app.observability.metrics import (
|
||||
record_gateway_hitl_aborted,
|
||||
record_gateway_outbound,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HITL_UNSUPPORTED_MESSAGE = (
|
||||
"This action requires approval and is not yet supported from WhatsApp. "
|
||||
"Try again with a different request."
|
||||
)
|
||||
|
||||
|
||||
class WhatsAppCloudStreamTranslator(BaseStreamTranslator):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
external_peer_id: str,
|
||||
inbound_message_id: str | None = None,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.external_peer_id = external_peer_id
|
||||
self.inbound_message_id = inbound_message_id
|
||||
self._buffer = ""
|
||||
self._typing_sent = False
|
||||
|
||||
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
|
||||
async for event in events:
|
||||
if event.type in {"text-delta", "text_delta", "text"}:
|
||||
if not self._typing_sent:
|
||||
await self._send_typing_indicator()
|
||||
self._buffer += str(event.data.get("text") or event.data.get("delta") or "")
|
||||
elif event.type in {"data-interrupt-request", "interrupt"}:
|
||||
await self._handle_hitl_interrupt()
|
||||
return
|
||||
elif event.type in {"finish", "done"}:
|
||||
break
|
||||
|
||||
await self._flush_final()
|
||||
|
||||
async def _flush_final(self) -> None:
|
||||
if not self._buffer:
|
||||
return
|
||||
for chunk in split_text_message(self._buffer):
|
||||
await self._send_text(chunk)
|
||||
|
||||
async def _send_typing_indicator(self) -> None:
|
||||
self._typing_sent = True
|
||||
if not self.inbound_message_id:
|
||||
return
|
||||
if not isinstance(self.adapter, WhatsAppCloudAdapter):
|
||||
return
|
||||
try:
|
||||
await self.adapter.send_typing_indicator(
|
||||
inbound_message_id=self.inbound_message_id
|
||||
)
|
||||
record_gateway_outbound(platform="whatsapp", kind="typing", status="sent")
|
||||
except Exception:
|
||||
logger.debug("WhatsApp typing indicator failed", exc_info=True)
|
||||
record_gateway_outbound(platform="whatsapp", kind="typing", status="failed")
|
||||
|
||||
async def _send_text(self, text: str) -> PlatformSendResult:
|
||||
try:
|
||||
result = await self.adapter.send_message(
|
||||
external_peer_id=self.external_peer_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="whatsapp", kind="send", status="failed")
|
||||
raise
|
||||
record_gateway_outbound(platform="whatsapp", kind="send", status="sent")
|
||||
return result
|
||||
|
||||
async def _handle_hitl_interrupt(self) -> None:
|
||||
if self._buffer:
|
||||
await self._flush_final()
|
||||
await self._send_text(HITL_UNSUPPORTED_MESSAGE)
|
||||
record_gateway_hitl_aborted(platform="whatsapp")
|
||||
123
surfsense_backend/app/gateway/whatsapp/translator_baileys.py
Normal file
123
surfsense_backend/app/gateway/whatsapp/translator_baileys.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Translate agent stream events into Baileys bridge messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from app.gateway.base.adapter import BasePlatformAdapter, PlatformSendResult
|
||||
from app.gateway.base.formatting import split_text_message
|
||||
from app.gateway.base.translator import BaseStreamTranslator, GatewayStreamEvent
|
||||
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
|
||||
from app.observability.metrics import (
|
||||
record_gateway_hitl_aborted,
|
||||
record_gateway_outbound,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HITL_UNSUPPORTED_MESSAGE = (
|
||||
"This action requires approval and is not yet supported from WhatsApp. "
|
||||
"Try again with a different request."
|
||||
)
|
||||
|
||||
|
||||
class WhatsAppBaileysStreamTranslator(BaseStreamTranslator):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
adapter: BasePlatformAdapter,
|
||||
external_peer_id: str,
|
||||
debounce_seconds: float = 1.5,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.external_peer_id = external_peer_id
|
||||
self.debounce_seconds = debounce_seconds
|
||||
self._buffer = ""
|
||||
self._last_flush_at = 0.0
|
||||
self._external_message_ids: list[str] = []
|
||||
|
||||
async def translate(self, events: AsyncIterator[GatewayStreamEvent]) -> None:
|
||||
await self._send_typing_indicator()
|
||||
async for event in events:
|
||||
if event.type in {"text-delta", "text_delta", "text"}:
|
||||
self._buffer += str(event.data.get("text") or event.data.get("delta") or "")
|
||||
await self._maybe_flush()
|
||||
elif event.type in {"data-interrupt-request", "interrupt"}:
|
||||
await self._handle_hitl_interrupt()
|
||||
return
|
||||
elif event.type in {"finish", "done"}:
|
||||
break
|
||||
|
||||
await self._flush(final=True)
|
||||
|
||||
async def _maybe_flush(self) -> None:
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now - self._last_flush_at < self.debounce_seconds:
|
||||
return
|
||||
await self._flush(final=False)
|
||||
self._last_flush_at = now
|
||||
|
||||
async def _flush(self, *, final: bool) -> None:
|
||||
if not self._buffer:
|
||||
return
|
||||
|
||||
chunks = split_text_message(self._buffer)
|
||||
if len(chunks) > 1:
|
||||
for chunk in chunks[:-1]:
|
||||
result = await self._send_text(chunk)
|
||||
self._external_message_ids.append(result.external_message_id)
|
||||
self._buffer = chunks[-1]
|
||||
|
||||
if self._external_message_ids:
|
||||
await self._edit_text(self._external_message_ids[-1], self._buffer)
|
||||
else:
|
||||
result = await self._send_text(self._buffer)
|
||||
self._external_message_ids.append(result.external_message_id)
|
||||
|
||||
if final:
|
||||
logger.debug(
|
||||
"WhatsApp Baileys finalized external_ids=%s",
|
||||
self._external_message_ids,
|
||||
)
|
||||
|
||||
async def _send_typing_indicator(self) -> None:
|
||||
if not isinstance(self.adapter, WhatsAppBaileysAdapter):
|
||||
return
|
||||
try:
|
||||
await self.adapter.send_typing_indicator(external_peer_id=self.external_peer_id)
|
||||
record_gateway_outbound(platform="whatsapp", kind="typing", status="sent")
|
||||
except Exception:
|
||||
logger.debug("WhatsApp Baileys typing indicator failed", exc_info=True)
|
||||
|
||||
async def _send_text(self, text: str) -> PlatformSendResult:
|
||||
try:
|
||||
result = await self.adapter.send_message(
|
||||
external_peer_id=self.external_peer_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="whatsapp", kind="send", status="failed")
|
||||
raise
|
||||
record_gateway_outbound(platform="whatsapp", kind="send", status="sent")
|
||||
return result
|
||||
|
||||
async def _edit_text(self, message_id: str, text: str) -> PlatformSendResult:
|
||||
try:
|
||||
result = await self.adapter.edit_message(
|
||||
external_peer_id=self.external_peer_id,
|
||||
external_message_id=message_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception:
|
||||
record_gateway_outbound(platform="whatsapp", kind="edit", status="failed")
|
||||
raise
|
||||
record_gateway_outbound(platform="whatsapp", kind="edit", status="edited")
|
||||
return result
|
||||
|
||||
async def _handle_hitl_interrupt(self) -> None:
|
||||
if self._buffer:
|
||||
await self._flush(final=False)
|
||||
await self._send_text(HITL_UNSUPPORTED_MESSAGE)
|
||||
record_gateway_hitl_aborted(platform="whatsapp")
|
||||
|
|
@ -314,6 +314,135 @@ def _celery_queue_latency():
|
|||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_redis_fallback():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.redis.fallback",
|
||||
description="Count of gateway Redis fallback uses.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_thread_lock_contention():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.thread_lock.contention",
|
||||
description="Count of gateway per-thread lock contention events.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_inbox_writes():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.inbox.writes",
|
||||
description="Count of gateway inbound event inbox writes.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_inbox_processed():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.inbox.processed",
|
||||
description="Count of gateway inbound event processing outcomes.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_inbound_reconciled():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.inbound.reconciled",
|
||||
description="Count of gateway inbox events re-enqueued by reconciliation.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_outbound():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.outbound",
|
||||
description="Count of gateway outbound platform operations.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_turn_latency():
|
||||
return _get_meter().create_histogram(
|
||||
"surfsense.gateway.turn.latency",
|
||||
unit="ms",
|
||||
description="Latency of gateway-routed agent turns.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_rate_limit_hits():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.rate_limit.hits",
|
||||
description="Count of gateway outbound rate limit waits.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_health_check_failures():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.health_check.failures",
|
||||
description="Count of gateway account health-check failures.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_auth_invariant_failures():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.auth_invariant.failures",
|
||||
description="Count of gateway authorization invariant failures.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_hitl_aborted():
|
||||
return _get_meter().create_counter(
|
||||
"surfsense.gateway.hitl.aborted",
|
||||
description="Count of gateway turns aborted because HITL is unsupported.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_active_bindings():
|
||||
return _get_meter().create_up_down_counter(
|
||||
"surfsense.gateway.active_bindings",
|
||||
description="Current change in active gateway bindings.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_inbox_enqueued():
|
||||
return _get_meter().create_counter(
|
||||
"gateway_inbox_enqueued_total",
|
||||
description="Count of gateway inbox rows enqueued for worker processing.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_inbox_sweep_replayed():
|
||||
return _get_meter().create_counter(
|
||||
"gateway_inbox_sweep_replayed_total",
|
||||
description="Count of received gateway inbox rows replayed by the sweep.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_byo_longpoll_running():
|
||||
return _get_meter().create_up_down_counter(
|
||||
"gateway_byo_longpoll_running",
|
||||
description="Current change in BYO Telegram long-poll supervisors holding a poll loop.",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _gateway_webhook_parse_errors():
|
||||
return _get_meter().create_counter(
|
||||
"gateway_webhook_parse_error_total",
|
||||
description="Count of malformed gateway webhook payloads.",
|
||||
)
|
||||
|
||||
|
||||
def record_model_call_duration(
|
||||
duration_ms: float, *, model: str | None, provider: str | None
|
||||
) -> None:
|
||||
|
|
@ -569,6 +698,78 @@ def record_celery_queue_latency(
|
|||
)
|
||||
|
||||
|
||||
def record_gateway_redis_fallback() -> None:
|
||||
_add(_gateway_redis_fallback(), 1, {})
|
||||
|
||||
|
||||
def record_gateway_thread_lock_contention() -> None:
|
||||
_add(_gateway_thread_lock_contention(), 1, {})
|
||||
|
||||
|
||||
def record_gateway_inbox_write(*, platform: str, dedup_skipped: bool) -> None:
|
||||
_add(
|
||||
_gateway_inbox_writes(),
|
||||
1,
|
||||
{"platform": platform, "dedup.skipped": bool(dedup_skipped)},
|
||||
)
|
||||
|
||||
|
||||
def record_gateway_inbox_processed(*, platform: str, status: str) -> None:
|
||||
_add(_gateway_inbox_processed(), 1, {"platform": platform, "status": status})
|
||||
|
||||
|
||||
def record_gateway_inbound_reconciled(*, reason: str) -> None:
|
||||
_add(_gateway_inbound_reconciled(), 1, {"reason": reason})
|
||||
|
||||
|
||||
def record_gateway_outbound(*, platform: str, kind: str, status: str) -> None:
|
||||
_add(
|
||||
_gateway_outbound(),
|
||||
1,
|
||||
{"platform": platform, "kind": kind, "status": status},
|
||||
)
|
||||
|
||||
|
||||
def record_gateway_turn_latency(duration_ms: float, *, platform: str) -> None:
|
||||
_record(_gateway_turn_latency(), duration_ms, {"platform": platform})
|
||||
|
||||
|
||||
def record_gateway_rate_limit_hit(*, bucket: str) -> None:
|
||||
_add(_gateway_rate_limit_hits(), 1, {"bucket": bucket})
|
||||
|
||||
|
||||
def record_gateway_health_check_failure(*, platform: str) -> None:
|
||||
_add(_gateway_health_check_failures(), 1, {"platform": platform})
|
||||
|
||||
|
||||
def record_gateway_auth_invariant_failure(*, cause: str) -> None:
|
||||
_add(_gateway_auth_invariant_failures(), 1, {"cause": cause})
|
||||
|
||||
|
||||
def record_gateway_hitl_aborted(*, platform: str) -> None:
|
||||
_add(_gateway_hitl_aborted(), 1, {"platform": platform})
|
||||
|
||||
|
||||
def record_gateway_active_bindings_delta(delta: int, *, platform: str) -> None:
|
||||
_add(_gateway_active_bindings(), delta, {"platform": platform})
|
||||
|
||||
|
||||
def record_gateway_inbox_enqueued(*, intake: str, outcome: str) -> None:
|
||||
_add(_gateway_inbox_enqueued(), 1, {"intake": intake, "outcome": outcome})
|
||||
|
||||
|
||||
def record_gateway_inbox_sweep_replayed() -> None:
|
||||
_add(_gateway_inbox_sweep_replayed(), 1, {})
|
||||
|
||||
|
||||
def record_gateway_byo_longpoll_running_delta(delta: int, *, account_id: int) -> None:
|
||||
_add(_gateway_byo_longpoll_running(), delta, {"account_id": account_id})
|
||||
|
||||
|
||||
def record_gateway_webhook_parse_error() -> None:
|
||||
_add(_gateway_webhook_parse_errors(), 1, {})
|
||||
|
||||
|
||||
def _runtime_snapshot_value(key: str, transform: Any = None) -> list[Any]:
|
||||
from opentelemetry.metrics import Observation
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ 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 .gateway_whatsapp_baileys_routes import router as gateway_whatsapp_baileys_router
|
||||
from .gateway_whatsapp_webhook_routes import router as gateway_whatsapp_webhook_router
|
||||
from .google_calendar_add_connector_route import (
|
||||
router as google_calendar_add_connector_router,
|
||||
)
|
||||
|
|
@ -69,6 +72,9 @@ 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(gateway_whatsapp_webhook_router)
|
||||
router.include_router(gateway_whatsapp_baileys_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}
|
||||
|
|
|
|||
1065
surfsense_backend/app/routes/gateway_webhook_routes.py
Normal file
1065
surfsense_backend/app/routes/gateway_webhook_routes.py
Normal file
File diff suppressed because it is too large
Load diff
105
surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
Normal file
105
surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Routes for the self-hosted WhatsApp Baileys bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ExternalChatAccount,
|
||||
ExternalChatAccountMode,
|
||||
ExternalChatHealthStatus,
|
||||
ExternalChatPlatform,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_search_space_access
|
||||
|
||||
router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"])
|
||||
|
||||
|
||||
class BaileysPairRequest(BaseModel):
|
||||
search_space_id: int
|
||||
phone_number: str
|
||||
|
||||
|
||||
def _ensure_baileys_enabled() -> None:
|
||||
if config.GATEWAY_WHATSAPP_INTAKE_MODE != "baileys":
|
||||
raise HTTPException(status_code=404, detail="WhatsApp Baileys gateway is disabled")
|
||||
if config.is_cloud():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Baileys is only available for self-hosted SurfSense installs",
|
||||
)
|
||||
|
||||
|
||||
async def _get_user_whatsapp_account(
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
) -> ExternalChatAccount | None:
|
||||
result = await session.execute(
|
||||
select(ExternalChatAccount).where(
|
||||
ExternalChatAccount.owner_user_id == user.id,
|
||||
ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP,
|
||||
ExternalChatAccount.is_system_account.is_(False),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
@router.post("/pair")
|
||||
async def request_pairing_code(
|
||||
body: BaileysPairRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> dict[str, Any]:
|
||||
_ensure_baileys_enabled()
|
||||
await check_search_space_access(session, user, body.search_space_id)
|
||||
adapter = WhatsAppBaileysAdapter()
|
||||
try:
|
||||
pairing = await adapter.request_pairing_code(phone_number=body.phone_number)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
account = await _get_user_whatsapp_account(session, user)
|
||||
if account is None:
|
||||
account = ExternalChatAccount(
|
||||
platform=ExternalChatPlatform.WHATSAPP,
|
||||
mode=ExternalChatAccountMode.SELF_HOST_BYO,
|
||||
owner_user_id=user.id,
|
||||
owner_search_space_id=body.search_space_id,
|
||||
is_system_account=False,
|
||||
cursor_state={},
|
||||
health_status=ExternalChatHealthStatus.UNKNOWN,
|
||||
)
|
||||
session.add(account)
|
||||
else:
|
||||
account.mode = ExternalChatAccountMode.SELF_HOST_BYO
|
||||
account.owner_search_space_id = body.search_space_id
|
||||
account.health_status = ExternalChatHealthStatus.UNKNOWN
|
||||
account.suspended_at = None
|
||||
account.suspended_reason = None
|
||||
account.last_health_check_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
return {"account_id": account.id, **pairing}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def bridge_health(
|
||||
user: User = Depends(current_active_user),
|
||||
) -> dict[str, Any]:
|
||||
_ensure_baileys_enabled()
|
||||
adapter = WhatsAppBaileysAdapter()
|
||||
try:
|
||||
return await adapter.validate_credentials()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
205
surfsense_backend/app/routes/gateway_whatsapp_webhook_routes.py
Normal file
205
surfsense_backend/app/routes/gateway_whatsapp_webhook_routes.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""WhatsApp Cloud API webhook routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ExternalChatHealthStatus,
|
||||
ExternalChatPlatform,
|
||||
get_async_session,
|
||||
)
|
||||
from app.gateway.accounts import get_or_create_system_whatsapp_account
|
||||
from app.gateway.inbox import persist_inbound_event
|
||||
from app.observability.metrics import (
|
||||
record_gateway_inbox_write,
|
||||
record_gateway_outbound,
|
||||
record_gateway_webhook_parse_error,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/gateway/webhooks/whatsapp", tags=["gateway"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_whatsapp_enabled() -> None:
|
||||
if config.GATEWAY_WHATSAPP_INTAKE_MODE == "disabled":
|
||||
raise HTTPException(status_code=404, detail="WhatsApp gateway is disabled")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def verify_whatsapp_webhook(
|
||||
hub_mode: str = Query(alias="hub.mode"),
|
||||
hub_verify_token: str = Query(alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(alias="hub.challenge"),
|
||||
) -> Response:
|
||||
_ensure_whatsapp_enabled()
|
||||
if (
|
||||
hub_mode == "subscribe"
|
||||
and config.WHATSAPP_WEBHOOK_VERIFY_TOKEN
|
||||
and hmac.compare_digest(hub_verify_token, config.WHATSAPP_WEBHOOK_VERIFY_TOKEN)
|
||||
):
|
||||
return Response(content=hub_challenge, media_type="text/plain")
|
||||
raise HTTPException(status_code=403, detail="Invalid WhatsApp webhook token")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def whatsapp_webhook(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> Response:
|
||||
_ensure_whatsapp_enabled()
|
||||
raw_body = await request.body()
|
||||
_verify_signature(raw_body, request.headers.get("X-Hub-Signature-256"))
|
||||
try:
|
||||
payload = json.loads(raw_body)
|
||||
except ValueError:
|
||||
record_gateway_webhook_parse_error()
|
||||
return Response(status_code=200)
|
||||
|
||||
try:
|
||||
await _process_payload(session, payload)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("WhatsApp webhook processing failed")
|
||||
return Response(status_code=200)
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
def _verify_signature(raw_body: bytes, header_signature: str | None) -> None:
|
||||
if not config.WHATSAPP_WEBHOOK_APP_SECRET:
|
||||
raise HTTPException(status_code=500, detail="WhatsApp app secret is not configured")
|
||||
received = (header_signature or "").removeprefix("sha256=")
|
||||
expected = hmac.new(
|
||||
config.WHATSAPP_WEBHOOK_APP_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not received or not hmac.compare_digest(received, expected):
|
||||
raise HTTPException(status_code=403, detail="Invalid WhatsApp webhook signature")
|
||||
|
||||
|
||||
async def _process_payload(session: AsyncSession, payload: dict[str, Any]) -> None:
|
||||
for entry in payload.get("entry") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
for change in entry.get("changes") or []:
|
||||
if not isinstance(change, dict):
|
||||
continue
|
||||
field = change.get("field")
|
||||
value = change.get("value") or {}
|
||||
if field == "messages":
|
||||
await _process_messages_change(session, payload, entry, change, value)
|
||||
elif field == "account_update":
|
||||
await _handle_account_update(session, entry, value)
|
||||
elif field == "phone_number_quality_update":
|
||||
await _handle_phone_number_quality_update(session, entry, value)
|
||||
|
||||
|
||||
async def _process_messages_change(
|
||||
session: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
entry: dict[str, Any],
|
||||
change: dict[str, Any],
|
||||
value: dict[str, Any],
|
||||
) -> None:
|
||||
statuses = [status for status in value.get("statuses") or [] if isinstance(status, dict)]
|
||||
for status in statuses:
|
||||
record_gateway_outbound(
|
||||
platform="whatsapp",
|
||||
kind="status",
|
||||
status=str(status.get("status") or "unknown"),
|
||||
)
|
||||
|
||||
messages = [msg for msg in value.get("messages") or [] if isinstance(msg, dict)]
|
||||
if not messages:
|
||||
return
|
||||
|
||||
account = await get_or_create_system_whatsapp_account(session)
|
||||
metadata = value.get("metadata") or {}
|
||||
if isinstance(metadata, dict):
|
||||
cursor_state = dict(account.cursor_state or {})
|
||||
for key in ("phone_number_id", "display_phone_number"):
|
||||
if metadata.get(key):
|
||||
cursor_state[key] = metadata[key]
|
||||
account.cursor_state = cursor_state
|
||||
|
||||
for msg in messages:
|
||||
message_id = str(msg.get("id") or "")
|
||||
if not message_id:
|
||||
continue
|
||||
request_id = f"gateway_{uuid.uuid4().hex[:16]}"
|
||||
inbox_id = await persist_inbound_event(
|
||||
session,
|
||||
account_id=account.id,
|
||||
platform=ExternalChatPlatform.WHATSAPP,
|
||||
event_dedupe_key=f"wamid:{message_id}",
|
||||
external_event_id=message_id,
|
||||
external_message_id=message_id,
|
||||
event_kind="message",
|
||||
raw_payload=_single_message_payload(payload, entry, change, msg),
|
||||
request_id=request_id,
|
||||
)
|
||||
record_gateway_inbox_write(platform="whatsapp", dedup_skipped=inbox_id is None)
|
||||
|
||||
|
||||
async def _handle_account_update(
|
||||
session: AsyncSession,
|
||||
entry: dict[str, Any],
|
||||
value: dict[str, Any],
|
||||
) -> None:
|
||||
account = await get_or_create_system_whatsapp_account(session)
|
||||
cursor_state = dict(account.cursor_state or {})
|
||||
if entry.get("id"):
|
||||
cursor_state["waba_id"] = str(entry.get("id"))
|
||||
cursor_state["account_update"] = value
|
||||
account.cursor_state = cursor_state
|
||||
event = str(value.get("event") or value.get("type") or "").upper()
|
||||
if event in {"DISABLED_UPDATE", "ACCOUNT_RESTRICTION", "PARTNER_REMOVED"}:
|
||||
account.health_status = ExternalChatHealthStatus.FAILING
|
||||
account.suspended_at = datetime.now(UTC)
|
||||
account.suspended_reason = event.lower()
|
||||
elif event in {"VERIFIED_ACCOUNT", "ACCOUNT_ENABLED", "REINSTATED"}:
|
||||
account.health_status = ExternalChatHealthStatus.OK
|
||||
account.suspended_at = None
|
||||
account.suspended_reason = None
|
||||
account.last_health_check_at = datetime.now(UTC)
|
||||
|
||||
|
||||
async def _handle_phone_number_quality_update(
|
||||
session: AsyncSession,
|
||||
entry: dict[str, Any],
|
||||
value: dict[str, Any],
|
||||
) -> None:
|
||||
account = await get_or_create_system_whatsapp_account(session)
|
||||
cursor_state = dict(account.cursor_state or {})
|
||||
if entry.get("id"):
|
||||
cursor_state["waba_id"] = str(entry.get("id"))
|
||||
cursor_state["quality_update"] = value
|
||||
account.cursor_state = cursor_state
|
||||
account.last_health_check_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _single_message_payload(
|
||||
payload: dict[str, Any],
|
||||
entry: dict[str, Any],
|
||||
change: dict[str, Any],
|
||||
message: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
value = dict(change.get("value") or {})
|
||||
value["messages"] = [message]
|
||||
value.pop("statuses", None)
|
||||
single_change = {**change, "value": value}
|
||||
single_entry = {**entry, "changes": [single_change]}
|
||||
return {"object": payload.get("object"), "entry": [single_entry]}
|
||||
166
surfsense_backend/app/tasks/celery_tasks/gateway_tasks.py
Normal file
166
surfsense_backend/app/tasks/celery_tasks/gateway_tasks.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Celery maintenance tasks for external chat surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.db import (
|
||||
ExternalChatAccount,
|
||||
ExternalChatEventStatus,
|
||||
ExternalChatHealthStatus,
|
||||
ExternalChatInboundEvent,
|
||||
ExternalChatPlatform,
|
||||
)
|
||||
from app.gateway.inbox import persist_inbound_event, telegram_event_dedupe_key
|
||||
from app.gateway.registry import resolve_platform_bundle
|
||||
from app.gateway.telegram.adapter import TelegramAdapter
|
||||
from app.observability.metrics import (
|
||||
record_gateway_health_check_failure,
|
||||
record_gateway_inbound_reconciled,
|
||||
)
|
||||
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="gateway.process_inbound_event")
|
||||
def process_inbound_event_task(inbox_id: int) -> None:
|
||||
logger.warning(
|
||||
"Ignoring gateway.process_inbound_event for inbox_id=%s; "
|
||||
"FastAPI owns external chat agent turn processing.",
|
||||
inbox_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@celery_app.task(name="gateway.reconcile_inbox")
|
||||
def reconcile_inbox_task() -> None:
|
||||
async def _run() -> None:
|
||||
session_maker = get_celery_session_maker()
|
||||
async with session_maker() as session:
|
||||
stale_threshold = datetime.now(UTC) - timedelta(minutes=10)
|
||||
result = await session.execute(
|
||||
update(ExternalChatInboundEvent)
|
||||
.where(
|
||||
ExternalChatInboundEvent.status == ExternalChatEventStatus.PROCESSING,
|
||||
ExternalChatInboundEvent.received_at < stale_threshold,
|
||||
)
|
||||
.values(
|
||||
status=ExternalChatEventStatus.RECEIVED,
|
||||
last_error="stale processing reset for FastAPI inbox worker",
|
||||
)
|
||||
)
|
||||
for _ in range(result.rowcount or 0):
|
||||
record_gateway_inbound_reconciled(reason="stale_processing_reset")
|
||||
await session.commit()
|
||||
|
||||
return run_async_celery_task(_run)
|
||||
|
||||
|
||||
@celery_app.task(name="gateway.health_check")
|
||||
def gateway_health_check_task() -> None:
|
||||
async def _run() -> None:
|
||||
session_maker = get_celery_session_maker()
|
||||
async with session_maker() as session:
|
||||
result = await session.execute(select(ExternalChatAccount))
|
||||
accounts = list(result.scalars())
|
||||
for account in accounts:
|
||||
try:
|
||||
bundle = resolve_platform_bundle(account)
|
||||
metadata = await bundle.adapter.validate_credentials()
|
||||
account.health_status = ExternalChatHealthStatus.OK
|
||||
if account.platform == ExternalChatPlatform.TELEGRAM:
|
||||
account.bot_username = metadata.get("username")
|
||||
elif account.platform == ExternalChatPlatform.WHATSAPP:
|
||||
cursor_state = dict(account.cursor_state or {})
|
||||
for key in (
|
||||
"quality_rating",
|
||||
"account_review_status",
|
||||
"status",
|
||||
):
|
||||
if key in metadata:
|
||||
cursor_state[key] = metadata[key]
|
||||
account.cursor_state = cursor_state
|
||||
elif account.platform == ExternalChatPlatform.SLACK:
|
||||
cursor_state = dict(account.cursor_state or {})
|
||||
for key in ("team_id", "team", "bot_user_id", "bot_username"):
|
||||
if key in metadata:
|
||||
cursor_state[key] = metadata[key]
|
||||
account.cursor_state = cursor_state
|
||||
account.bot_username = metadata.get("bot_username")
|
||||
elif account.platform == ExternalChatPlatform.DISCORD:
|
||||
cursor_state = dict(account.cursor_state or {})
|
||||
for key in ("bot_user_id", "bot_username", "global_name"):
|
||||
if key in metadata:
|
||||
cursor_state[key] = metadata[key]
|
||||
account.cursor_state = cursor_state
|
||||
account.bot_username = metadata.get("bot_username")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"External chat health check failed platform=%s account_id=%s",
|
||||
account.platform.value,
|
||||
account.id,
|
||||
exc_info=True,
|
||||
)
|
||||
account.health_status = ExternalChatHealthStatus.FAILING
|
||||
record_gateway_health_check_failure(platform=account.platform.value)
|
||||
account.last_health_check_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
return run_async_celery_task(_run)
|
||||
|
||||
|
||||
@celery_app.task(name="gateway.enqueue_received_sweep")
|
||||
def enqueue_received_sweep_task() -> int:
|
||||
logger.info(
|
||||
"Skipping gateway.enqueue_received_sweep; "
|
||||
"FastAPI inbox worker scans RECEIVED rows directly."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@celery_app.task(name="gateway.retention_sweep")
|
||||
def gateway_retention_sweep_task() -> None:
|
||||
async def _run() -> None:
|
||||
session_maker = get_celery_session_maker()
|
||||
async with session_maker() as session:
|
||||
raw_cutoff = datetime.now(UTC) - timedelta(days=30)
|
||||
delete_cutoff = datetime.now(UTC) - timedelta(days=365)
|
||||
await session.execute(
|
||||
update(ExternalChatInboundEvent)
|
||||
.where(ExternalChatInboundEvent.received_at < raw_cutoff)
|
||||
.values(raw_payload=None)
|
||||
)
|
||||
result = await session.execute(
|
||||
select(ExternalChatInboundEvent).where(
|
||||
ExternalChatInboundEvent.received_at < delete_cutoff
|
||||
)
|
||||
)
|
||||
for event in result.scalars():
|
||||
await session.delete(event)
|
||||
await session.commit()
|
||||
|
||||
return run_async_celery_task(_run)
|
||||
|
||||
|
||||
async def enqueue_telegram_update(account_id: int, raw_update: dict) -> int | None:
|
||||
session_maker = get_celery_session_maker()
|
||||
async with session_maker() as session:
|
||||
parsed = TelegramAdapter("placeholder").parse_inbound(raw_update)
|
||||
inbox_id = await persist_inbound_event(
|
||||
session,
|
||||
account_id=account_id,
|
||||
platform=ExternalChatPlatform.TELEGRAM,
|
||||
event_dedupe_key=telegram_event_dedupe_key(raw_update["update_id"]),
|
||||
external_event_id=str(raw_update["update_id"]),
|
||||
external_message_id=parsed.external_message_id,
|
||||
event_kind=parsed.event_kind,
|
||||
raw_payload=raw_update,
|
||||
)
|
||||
await session.commit()
|
||||
return inbox_id
|
||||
|
||||
|
|
@ -87,6 +87,7 @@ dependencies = [
|
|||
"opentelemetry-instrumentation-httpx>=0.61b0",
|
||||
"opentelemetry-instrumentation-celery>=0.61b0",
|
||||
"opentelemetry-instrumentation-logging>=0.61b0",
|
||||
"python-telegram-bot>=22.7",
|
||||
"croniter>=2.0.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -140,11 +140,11 @@ start_worker() {
|
|||
if [ -n "${CELERY_QUEUES}" ]; then
|
||||
QUEUE_ARGS="--queues=${CELERY_QUEUES}"
|
||||
else
|
||||
# When no queues specified, consume from BOTH the default queue and
|
||||
# the connectors queue. Without --queues, Celery only consumes from
|
||||
# the default queue, leaving connector indexing tasks stuck.
|
||||
# When no queues specified, consume from the default, connectors, and
|
||||
# gateway maintenance queues. Without --queues, Celery only consumes
|
||||
# from the default queue, leaving connector/gateway maintenance tasks stuck.
|
||||
DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}"
|
||||
QUEUE_ARGS="--queues=${DEFAULT_Q},${DEFAULT_Q}.connectors"
|
||||
QUEUE_ARGS="--queues=${DEFAULT_Q},${DEFAULT_Q}.connectors,${DEFAULT_Q}.gateway"
|
||||
fi
|
||||
|
||||
echo "Starting Celery Worker (autoscale=${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}, max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD}, queues=${CELERY_QUEUES:-all})..."
|
||||
|
|
|
|||
61
surfsense_backend/scripts/register_webhook.py
Normal file
61
surfsense_backend/scripts/register_webhook.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""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
|
||||
|
||||
from app.db import async_session_maker
|
||||
from app.gateway.accounts import get_or_create_system_telegram_account
|
||||
|
||||
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
|
||||
|
||||
async with async_session_maker() as session:
|
||||
account = await get_or_create_system_telegram_account(session)
|
||||
account.webhook_secret = secret
|
||||
await session.commit()
|
||||
account_id = int(account.id)
|
||||
|
||||
webhook_url = f"{base_url.rstrip('/')}/api/v1/gateway/webhooks/telegram/{account_id}"
|
||||
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()))
|
||||
|
||||
15
surfsense_backend/scripts/whatsapp-bridge/Dockerfile
Normal file
15
surfsense_backend/scripts/whatsapp-bridge/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --silent
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV WHATSAPP_SESSION_DIR=/data/sessions
|
||||
EXPOSE 9929
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:9929/health || exit 1
|
||||
|
||||
CMD ["node", "bridge.js"]
|
||||
343
surfsense_backend/scripts/whatsapp-bridge/bridge.js
Normal file
343
surfsense_backend/scripts/whatsapp-bridge/bridge.js
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { Boom } from "@hapi/boom";
|
||||
import express from "express";
|
||||
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import qrcode from "qrcode-terminal";
|
||||
|
||||
const PORT = Number(process.env.PORT || "9929");
|
||||
const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR || "/data/sessions";
|
||||
const SEND_TIMEOUT_MS = Number(process.env.WHATSAPP_SEND_TIMEOUT_MS || "60000");
|
||||
const PAIRING_TIMEOUT_MS = Number(process.env.WHATSAPP_PAIRING_TIMEOUT_MS || "30000");
|
||||
const MAX_QUEUE_SIZE = Number(process.env.WHATSAPP_MAX_QUEUE_SIZE || "100");
|
||||
const WHATSAPP_MODE = process.env.WHATSAPP_MODE || "self-chat";
|
||||
const SENT_ECHO_TTL_MS = 60_000;
|
||||
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
|
||||
const logger = pino({ level: process.env.WHATSAPP_DEBUG ? "debug" : "warn" });
|
||||
const messageQueue = [];
|
||||
const sentKeys = new Map();
|
||||
const recentlySentIds = new Set();
|
||||
|
||||
let sock = null;
|
||||
let connectionState = "disconnected";
|
||||
let latestQr = null;
|
||||
let starting = null;
|
||||
let pendingPairing = null;
|
||||
|
||||
function resetSessionState() {
|
||||
sock = null;
|
||||
latestQr = null;
|
||||
sentKeys.clear();
|
||||
recentlySentIds.clear();
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
for (const entry of readdirSync(SESSION_DIR)) {
|
||||
rmSync(path.join(SESSION_DIR, entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePendingPairing(payload) {
|
||||
if (!pendingPairing) return;
|
||||
clearTimeout(pendingPairing.timer);
|
||||
pendingPairing.resolve(payload);
|
||||
pendingPairing = null;
|
||||
}
|
||||
|
||||
function rejectPendingPairing(error) {
|
||||
if (!pendingPairing) return;
|
||||
clearTimeout(pendingPairing.timer);
|
||||
pendingPairing.reject(error);
|
||||
pendingPairing = null;
|
||||
}
|
||||
|
||||
async function maybeRequestPairingCode(update = {}) {
|
||||
if (!pendingPairing || pendingPairing.inFlight || !sock) return;
|
||||
|
||||
const canRequestPairingCode =
|
||||
update.connection === "connecting" ||
|
||||
Boolean(update.qr) ||
|
||||
Boolean(latestQr);
|
||||
|
||||
if (!canRequestPairingCode) return;
|
||||
|
||||
pendingPairing.inFlight = true;
|
||||
connectionState = "pairing";
|
||||
try {
|
||||
const code = await sock.requestPairingCode(pendingPairing.phoneNumber);
|
||||
resolvePendingPairing({ status: "pairing", pairing_code: code, expires_in: 60 });
|
||||
} catch (error) {
|
||||
rejectPendingPairing(error);
|
||||
}
|
||||
}
|
||||
|
||||
function requestPairingCodeWhenReady(phoneNumber) {
|
||||
if (connectionState === "connected") {
|
||||
return Promise.resolve({ status: "connected", pairing_code: null, expires_in: 0 });
|
||||
}
|
||||
|
||||
if (pendingPairing) {
|
||||
return Promise.reject(new Error("A WhatsApp pairing request is already in progress"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingPairing = null;
|
||||
reject(new Error("Timed out waiting for WhatsApp to become ready for pairing"));
|
||||
}, PAIRING_TIMEOUT_MS);
|
||||
|
||||
pendingPairing = {
|
||||
phoneNumber,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
inFlight: false,
|
||||
};
|
||||
|
||||
void startSocket()
|
||||
.then(() => maybeRequestPairingCode())
|
||||
.catch((error) => rejectPendingPairing(error));
|
||||
void maybeRequestPairingCode();
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeText(message) {
|
||||
const content = message?.message || {};
|
||||
return (
|
||||
content.conversation ||
|
||||
content.extendedTextMessage?.text ||
|
||||
content.imageMessage?.caption ||
|
||||
content.videoMessage?.caption ||
|
||||
content.documentMessage?.caption ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function enqueueMessage(message) {
|
||||
const remoteJid = message?.key?.remoteJid;
|
||||
const id = message?.key?.id;
|
||||
if (!remoteJid || !id || !message?.message) return;
|
||||
if (messageQueue.length >= MAX_QUEUE_SIZE) messageQueue.shift();
|
||||
messageQueue.push({
|
||||
event: "messages.upsert",
|
||||
key: message.key,
|
||||
chatId: remoteJid,
|
||||
senderId: message.key.participant || remoteJid,
|
||||
messageId: id,
|
||||
fromMe: Boolean(message.key.fromMe),
|
||||
isGroup: remoteJid.endsWith("@g.us"),
|
||||
body: normalizeText(message),
|
||||
timestamp: Number(message.messageTimestamp || Date.now() / 1000),
|
||||
raw: message,
|
||||
});
|
||||
}
|
||||
|
||||
function rememberSentMessage(sent) {
|
||||
const sentId = sent?.key?.id;
|
||||
if (!sentId) return;
|
||||
sentKeys.set(sentId, sent.key);
|
||||
recentlySentIds.add(sentId);
|
||||
setTimeout(() => {
|
||||
recentlySentIds.delete(sentId);
|
||||
}, SENT_ECHO_TTL_MS).unref?.();
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`sendMessage timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function startSocket() {
|
||||
if (starting) return starting;
|
||||
starting = (async () => {
|
||||
connectionState = "connecting";
|
||||
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ["SurfSense", "Chrome", "120.0"],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
getMessage: async () => ({ conversation: "" }),
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", saveCreds);
|
||||
sock.ev.on("connection.update", (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
if (qr) {
|
||||
latestQr = qr;
|
||||
connectionState = "qr";
|
||||
qrcode.generate(qr, { small: true });
|
||||
void maybeRequestPairingCode(update);
|
||||
}
|
||||
if (connection === "open") {
|
||||
latestQr = null;
|
||||
connectionState = "connected";
|
||||
console.log("WhatsApp connected");
|
||||
resolvePendingPairing({ status: "connected", pairing_code: null, expires_in: 0 });
|
||||
}
|
||||
if (connection === "close") {
|
||||
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
|
||||
connectionState = "disconnected";
|
||||
if (reason === DisconnectReason.loggedOut) {
|
||||
console.error("WhatsApp logged out; clearing session and waiting for pairing.");
|
||||
connectionState = "logged_out";
|
||||
resetSessionState();
|
||||
setTimeout(() => {
|
||||
starting = null;
|
||||
void startSocket();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
starting = null;
|
||||
void startSocket();
|
||||
}, reason === 515 ? 1000 : 3000);
|
||||
}
|
||||
void maybeRequestPairingCode(update);
|
||||
});
|
||||
|
||||
sock.ev.on("messages.upsert", ({ messages, type }) => {
|
||||
if (type !== "notify" && type !== "append") return;
|
||||
for (const message of messages || []) {
|
||||
const chatId = message?.key?.remoteJid;
|
||||
if (!chatId) continue;
|
||||
if (chatId.endsWith("@g.us") || chatId.includes("status@broadcast")) continue;
|
||||
|
||||
if (message?.key?.fromMe) {
|
||||
if (WHATSAPP_MODE !== "self-chat") continue;
|
||||
if (recentlySentIds.has(message.key.id)) continue;
|
||||
|
||||
const myNumber = (sock.user?.id || "").replace(/:.*@/, "@").replace(/@.*/, "");
|
||||
const myLid = (sock.user?.lid || "").replace(/:.*@/, "@").replace(/@.*/, "");
|
||||
const chatNumber = chatId.replace(/@.*/, "");
|
||||
const isSelfChat =
|
||||
(myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
|
||||
if (!isSelfChat) continue;
|
||||
} else if (WHATSAPP_MODE === "self-chat") {
|
||||
continue;
|
||||
}
|
||||
|
||||
enqueueMessage(message);
|
||||
}
|
||||
});
|
||||
})();
|
||||
try {
|
||||
await starting;
|
||||
} finally {
|
||||
starting = null;
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({
|
||||
status: connectionState,
|
||||
hasQr: Boolean(latestQr),
|
||||
qr: latestQr,
|
||||
queueDepth: messageQueue.length,
|
||||
user: sock?.user || null,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/messages", (_req, res) => {
|
||||
const messages = messageQueue.splice(0, messageQueue.length);
|
||||
res.json(messages);
|
||||
});
|
||||
|
||||
app.post("/send", async (req, res) => {
|
||||
try {
|
||||
if (!sock || connectionState !== "connected") {
|
||||
return res.status(503).json({ error: "WhatsApp is not connected" });
|
||||
}
|
||||
const { chatId, message, replyTo } = req.body || {};
|
||||
if (!chatId || !message) {
|
||||
return res.status(400).json({ error: "chatId and message are required" });
|
||||
}
|
||||
const payload = { text: String(message) };
|
||||
if (replyTo) {
|
||||
payload.contextInfo = { stanzaId: String(replyTo) };
|
||||
}
|
||||
const sent = await withTimeout(sock.sendMessage(chatId, payload), SEND_TIMEOUT_MS);
|
||||
rememberSentMessage(sent);
|
||||
res.json({ messageId: sent?.key?.id || null, raw: sent });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || "send failed" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/edit", async (req, res) => {
|
||||
try {
|
||||
if (!sock || connectionState !== "connected") {
|
||||
return res.status(503).json({ error: "WhatsApp is not connected" });
|
||||
}
|
||||
const { chatId, messageId, message } = req.body || {};
|
||||
if (!chatId || !messageId || !message) {
|
||||
return res.status(400).json({ error: "chatId, messageId and message are required" });
|
||||
}
|
||||
const key = sentKeys.get(String(messageId)) || {
|
||||
remoteJid: chatId,
|
||||
id: String(messageId),
|
||||
fromMe: true,
|
||||
};
|
||||
const sent = await withTimeout(
|
||||
sock.sendMessage(chatId, { text: String(message), edit: key }),
|
||||
SEND_TIMEOUT_MS,
|
||||
);
|
||||
rememberSentMessage(sent);
|
||||
res.json({ messageId: sent?.key?.id || messageId, raw: sent });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || "edit failed" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/typing", async (req, res) => {
|
||||
try {
|
||||
if (!sock || connectionState !== "connected") return res.status(204).end();
|
||||
const { chatId } = req.body || {};
|
||||
if (chatId) {
|
||||
await sock.sendPresenceUpdate("composing", chatId);
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch {
|
||||
res.status(204).end();
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/pair", async (req, res) => {
|
||||
try {
|
||||
const phoneNumber = String(req.body?.phoneNumber || req.body?.phone_number || "").replace(/\D/g, "");
|
||||
if (!phoneNumber) {
|
||||
return res.status(400).json({ error: "phoneNumber is required for pairing code" });
|
||||
}
|
||||
res.json(await requestPairingCodeWhenReady(phoneNumber));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message || "pairing failed" });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(
|
||||
`SurfSense WhatsApp bridge listening on ${PORT}; session=${path.resolve(SESSION_DIR)}; mode=${WHATSAPP_MODE}`,
|
||||
);
|
||||
void startSocket();
|
||||
});
|
||||
2150
surfsense_backend/scripts/whatsapp-bridge/package-lock.json
generated
Normal file
2150
surfsense_backend/scripts/whatsapp-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
surfsense_backend/scripts/whatsapp-bridge/package.json
Normal file
16
surfsense_backend/scripts/whatsapp-bridge/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "surfsense-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/boom": "latest",
|
||||
"@whiskeysockets/baileys": "latest",
|
||||
"express": "latest",
|
||||
"pino": "latest",
|
||||
"qrcode-terminal": "latest"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.gateway import byo_long_poll
|
||||
from app.gateway import runner
|
||||
|
||||
|
||||
class ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._rows)
|
||||
|
||||
|
||||
class SessionContext:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.session
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def cleanup_supervisors():
|
||||
yield
|
||||
await byo_long_poll.stop_byo_long_poll_supervisors()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_byo_long_poll_noops_when_mode_is_webhook(monkeypatch):
|
||||
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "webhook")
|
||||
|
||||
await byo_long_poll.start_byo_long_poll_supervisors()
|
||||
|
||||
assert byo_long_poll._tasks == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatch):
|
||||
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll")
|
||||
session = mocker.AsyncMock()
|
||||
session.execute.return_value = ScalarResult([])
|
||||
monkeypatch.setattr(
|
||||
byo_long_poll,
|
||||
"async_session_maker",
|
||||
lambda: SessionContext(session),
|
||||
)
|
||||
|
||||
await byo_long_poll.start_byo_long_poll_supervisors()
|
||||
|
||||
assert byo_long_poll._tasks == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_byo_long_poll_spawns_one_supervisor_per_account(mocker, monkeypatch):
|
||||
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll")
|
||||
accounts = [mocker.Mock(id=1), mocker.Mock(id=2)]
|
||||
session = mocker.AsyncMock()
|
||||
session.execute.return_value = ScalarResult(accounts)
|
||||
monkeypatch.setattr(
|
||||
byo_long_poll,
|
||||
"async_session_maker",
|
||||
lambda: SessionContext(session),
|
||||
)
|
||||
monkeypatch.setattr(byo_long_poll, "account_token", lambda account: f"token-{account.id}")
|
||||
|
||||
async def forever(_account_id: int, _token: str) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(byo_long_poll, "_byo_account_supervisor", forever)
|
||||
|
||||
await byo_long_poll.start_byo_long_poll_supervisors()
|
||||
|
||||
assert len(byo_long_poll._tasks) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supervisor_retries_after_run_returns(mocker, monkeypatch):
|
||||
byo_long_poll._shutdown_event = asyncio.Event()
|
||||
run = mocker.AsyncMock(side_effect=[None, None])
|
||||
monkeypatch.setattr(byo_long_poll, "_run_telegram_account", run)
|
||||
sleep_count = 0
|
||||
|
||||
async def fake_sleep(_seconds: float) -> None:
|
||||
nonlocal sleep_count
|
||||
sleep_count += 1
|
||||
if sleep_count >= 2:
|
||||
assert byo_long_poll._shutdown_event is not None
|
||||
byo_long_poll._shutdown_event.set()
|
||||
|
||||
monkeypatch.setattr(byo_long_poll, "_sleep_or_shutdown", fake_sleep)
|
||||
|
||||
await byo_long_poll._byo_account_supervisor(7, "token")
|
||||
|
||||
assert run.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch):
|
||||
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll")
|
||||
session = mocker.AsyncMock()
|
||||
session.execute.return_value = ScalarResult([mocker.Mock(id=1)])
|
||||
monkeypatch.setattr(
|
||||
byo_long_poll,
|
||||
"async_session_maker",
|
||||
lambda: SessionContext(session),
|
||||
)
|
||||
monkeypatch.setattr(byo_long_poll, "account_token", lambda _account: "token")
|
||||
|
||||
async def forever(_account_id: int, _token: str) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(byo_long_poll, "_byo_account_supervisor", forever)
|
||||
|
||||
await byo_long_poll.start_byo_long_poll_supervisors()
|
||||
await byo_long_poll.stop_byo_long_poll_supervisors()
|
||||
|
||||
assert byo_long_poll._tasks == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_telegram_account_persists_for_fastapi_inbox_worker(mocker, monkeypatch):
|
||||
class ConnectionContext:
|
||||
async def __aenter__(self):
|
||||
conn = mocker.AsyncMock()
|
||||
conn.scalar.return_value = True
|
||||
return conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class EngineStub:
|
||||
def connect(self):
|
||||
return ConnectionContext()
|
||||
|
||||
class AdapterStub:
|
||||
def __init__(self, _token: str) -> None:
|
||||
pass
|
||||
|
||||
async def fetch_updates(self, *, offset: int | None):
|
||||
yield {"update_id": 11, "message": {"message_id": 5}}
|
||||
|
||||
def parse_inbound(self, update):
|
||||
return mocker.Mock(external_message_id="5", event_kind="message")
|
||||
|
||||
first_session = mocker.AsyncMock()
|
||||
first_session.get.return_value = mocker.Mock(cursor_state={})
|
||||
second_session = mocker.AsyncMock()
|
||||
contexts = iter([SessionContext(first_session), SessionContext(second_session)])
|
||||
monkeypatch.setattr(runner, "engine", EngineStub())
|
||||
monkeypatch.setattr(runner, "async_session_maker", lambda: next(contexts))
|
||||
monkeypatch.setattr(runner, "TelegramAdapter", AdapterStub)
|
||||
persist = mocker.AsyncMock(return_value=42)
|
||||
monkeypatch.setattr(runner, "persist_inbound_event", persist)
|
||||
|
||||
await runner._run_telegram_account(123, "token")
|
||||
|
||||
second_session.commit.assert_awaited_once()
|
||||
persist.assert_awaited_once()
|
||||
assert persist.await_args.kwargs["request_id"].startswith("gateway_")
|
||||
|
||||
92
surfsense_backend/tests/unit/gateway/test_discord_adapter.py
Normal file
92
surfsense_backend/tests/unit/gateway/test_discord_adapter.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.base.adapter import PlatformSendResult
|
||||
from app.gateway.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
def _discord_payload(content: str = "<@999> summarize this channel"):
|
||||
return {
|
||||
"type": "message",
|
||||
"bot_user_id": "999",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"id": "111",
|
||||
"guild_id": "222",
|
||||
"guild_name": "SurfSense Guild",
|
||||
"channel_id": "333",
|
||||
"channel_name": "general",
|
||||
"content": content,
|
||||
"author": {"id": "444", "username": "anish", "bot": False},
|
||||
"mentions": [{"id": "999", "username": "SurfSense"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_discord_adapter_parses_mention_and_strips_bot_mention():
|
||||
adapter = DiscordAdapter("discord-token", bot_user_id="999")
|
||||
|
||||
parsed = adapter.parse_inbound(_discord_payload())
|
||||
|
||||
assert parsed.platform == "discord"
|
||||
assert parsed.text == "summarize this channel"
|
||||
assert parsed.external_peer_id == "discord_thread:222:333:111"
|
||||
assert parsed.metadata["discord_user_peer_id"] == "discord_user:222:444"
|
||||
assert parsed.metadata["discord_thread_peer_id"] == "discord_thread:222:333:111"
|
||||
assert parsed.metadata["mentions_bot"] is True
|
||||
|
||||
|
||||
def test_discord_adapter_strips_nickname_mention():
|
||||
adapter = DiscordAdapter("discord-token", bot_user_id="999")
|
||||
|
||||
parsed = adapter.parse_inbound(_discord_payload("<@!999> continue"))
|
||||
|
||||
assert parsed.text == "continue"
|
||||
|
||||
|
||||
def test_discord_adapter_uses_message_reference_as_thread_key():
|
||||
adapter = DiscordAdapter("discord-token", bot_user_id="999")
|
||||
payload = _discord_payload("<@999> continue")
|
||||
payload["event"]["id"] = "112"
|
||||
payload["event"]["message_reference"] = {
|
||||
"message_id": "111",
|
||||
"channel_id": "333",
|
||||
"guild_id": "222",
|
||||
}
|
||||
|
||||
parsed = adapter.parse_inbound(payload)
|
||||
|
||||
assert parsed.external_peer_id == "discord_thread:222:333:111"
|
||||
assert parsed.metadata["message_id"] == "112"
|
||||
assert parsed.metadata["thread_key"] == "111"
|
||||
|
||||
|
||||
def test_discord_adapter_returns_missing_peer_for_incomplete_payload():
|
||||
adapter = DiscordAdapter("discord-token", bot_user_id="999")
|
||||
|
||||
parsed = adapter.parse_inbound({"event": {"id": "111"}})
|
||||
|
||||
assert parsed.external_peer_id is None
|
||||
assert parsed.external_peer_kind == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_adapter_sends_message(mocker):
|
||||
adapter = DiscordAdapter("discord-token", bot_user_id="999")
|
||||
adapter.client.send_message = mocker.AsyncMock(
|
||||
return_value=PlatformSendResult(external_message_id="555")
|
||||
)
|
||||
|
||||
result = await adapter.send_message(
|
||||
external_peer_id="333",
|
||||
text="hello",
|
||||
reply_to_message_id="111",
|
||||
)
|
||||
|
||||
assert result.external_message_id == "555"
|
||||
adapter.client.send_message.assert_awaited_once_with(
|
||||
channel_id="333",
|
||||
content="hello",
|
||||
reply_to_message_id="111",
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.tasks.celery_tasks import gateway_tasks
|
||||
|
||||
|
||||
def test_enqueue_received_sweep_is_noop_guard(mocker):
|
||||
apply_async = mocker.Mock()
|
||||
mocker.patch.object(gateway_tasks.process_inbound_event_task, "apply_async", apply_async)
|
||||
info = mocker.patch.object(gateway_tasks.logger, "info")
|
||||
|
||||
replayed = gateway_tasks.enqueue_received_sweep_task.run()
|
||||
|
||||
apply_async.assert_not_called()
|
||||
assert replayed == 0
|
||||
info.assert_called_once()
|
||||
|
||||
18
surfsense_backend/tests/unit/gateway/test_formatting.py
Normal file
18
surfsense_backend/tests/unit/gateway/test_formatting.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from app.gateway.telegram.formatting import chunk_message, escape_markdown_v2
|
||||
|
||||
|
||||
def test_escape_markdown_v2_reserved_chars():
|
||||
text = r"_*[]()~`>#+-=|{}.!"
|
||||
|
||||
assert escape_markdown_v2(text) == r"\_\*\[\]\(\)\~\`\>\#\+\-\=\|\{\}\.\!"
|
||||
|
||||
|
||||
def test_chunk_message_preserves_content_and_limits_size():
|
||||
text = "First paragraph.\n\n" + ("x" * 5000)
|
||||
|
||||
chunks = chunk_message(text, max_units=4096)
|
||||
|
||||
assert "".join(chunks) == text
|
||||
assert len(chunks) > 1
|
||||
assert all(len(chunk.encode("utf-16-le")) // 2 <= 4096 for chunk in chunks)
|
||||
|
||||
15
surfsense_backend/tests/unit/gateway/test_hitl_filter.py
Normal file
15
surfsense_backend/tests/unit/gateway/test_hitl_filter.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from app.gateway.hitl_filter import filter_hitl_tools
|
||||
|
||||
|
||||
class Tool:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
def test_filter_hitl_tools_removes_known_approval_tools():
|
||||
tools = [Tool("delete_document"), Tool("search"), "send_email", "summarize"]
|
||||
|
||||
filtered = filter_hitl_tools(tools)
|
||||
|
||||
assert [getattr(tool, "name", tool) for tool in filtered] == ["search", "summarize"]
|
||||
|
||||
45
surfsense_backend/tests/unit/gateway/test_inbox_worker.py
Normal file
45
surfsense_backend/tests/unit/gateway/test_inbox_worker.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway import inbox_worker
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbox_worker_claims_and_processes_in_fastapi_process(mocker, monkeypatch):
|
||||
claim = mocker.AsyncMock(return_value=7)
|
||||
process = mocker.AsyncMock(side_effect=asyncio.CancelledError)
|
||||
monkeypatch.setattr(inbox_worker, "claim_next_inbound_event", claim)
|
||||
monkeypatch.setattr(inbox_worker, "process_inbound_event", process)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await inbox_worker._process_inbox_forever()
|
||||
|
||||
claim.assert_awaited_once()
|
||||
process.assert_awaited_once_with(7)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop_gateway_inbox_worker(mocker, monkeypatch):
|
||||
started = asyncio.Event()
|
||||
stopped = asyncio.Event()
|
||||
|
||||
async def run_forever():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
stopped.set()
|
||||
|
||||
monkeypatch.setattr(inbox_worker, "_process_inbox_forever", run_forever)
|
||||
inbox_worker._task = None
|
||||
|
||||
await inbox_worker.start_gateway_inbox_worker()
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
await inbox_worker.stop_gateway_inbox_worker()
|
||||
|
||||
assert stopped.is_set()
|
||||
assert inbox_worker._task is None
|
||||
|
||||
41
surfsense_backend/tests/unit/gateway/test_pairing.py
Normal file
41
surfsense_backend/tests/unit/gateway/test_pairing.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import ExternalChatBindingState
|
||||
from app.gateway.pairing import generate_pairing_code, redeem_pairing_code
|
||||
|
||||
|
||||
def test_generate_pairing_code_is_short_display_token():
|
||||
code = generate_pairing_code()
|
||||
|
||||
assert len(code) >= 8
|
||||
assert "\n" not in code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redeem_pairing_code_binds_pending_row(mocker):
|
||||
binding = mocker.Mock()
|
||||
binding.state = ExternalChatBindingState.PENDING
|
||||
binding.pairing_code_expires_at = datetime.now(UTC) + timedelta(minutes=1)
|
||||
scalars = mocker.Mock()
|
||||
scalars.first.return_value = binding
|
||||
result = mocker.Mock()
|
||||
result.scalars.return_value = scalars
|
||||
session = mocker.AsyncMock()
|
||||
session.execute.return_value = result
|
||||
|
||||
redeemed = await redeem_pairing_code(
|
||||
session,
|
||||
code="abc",
|
||||
external_peer_id="telegram:123",
|
||||
external_peer_kind="direct",
|
||||
external_display_name="Anish",
|
||||
external_username="anish",
|
||||
)
|
||||
|
||||
assert redeemed is binding
|
||||
assert binding.state == ExternalChatBindingState.BOUND
|
||||
assert binding.external_peer_id == "telegram:123"
|
||||
assert binding.pairing_code is None
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.tasks.celery_tasks import gateway_tasks
|
||||
|
||||
|
||||
def test_process_inbound_event_task_is_noop_guard(mocker):
|
||||
warning = mocker.patch.object(gateway_tasks.logger, "warning")
|
||||
|
||||
assert gateway_tasks.process_inbound_event_task.run(123) is None
|
||||
|
||||
warning.assert_called_once()
|
||||
assert "FastAPI owns external chat agent turn processing" in warning.call_args.args[0]
|
||||
|
||||
47
surfsense_backend/tests/unit/gateway/test_slack_adapter.py
Normal file
47
surfsense_backend/tests/unit/gateway/test_slack_adapter.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.gateway.slack.adapter import SlackAdapter
|
||||
|
||||
|
||||
def test_slack_adapter_parses_app_mention_and_strips_bot_mention():
|
||||
adapter = SlackAdapter("xoxb-test", bot_user_id="U_BOT")
|
||||
|
||||
parsed = adapter.parse_inbound(
|
||||
{
|
||||
"team_id": "T123",
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"channel": "C123",
|
||||
"user": "U123",
|
||||
"text": "<@U_BOT> summarize this thread",
|
||||
"ts": "1717000000.000100",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert parsed.platform == "slack"
|
||||
assert parsed.text == "summarize this thread"
|
||||
assert parsed.external_peer_id == "slack_thread:T123:C123:1717000000.000100"
|
||||
assert parsed.metadata["slack_user_peer_id"] == "slack_user:T123:U123"
|
||||
assert parsed.metadata["thread_ts"] == "1717000000.000100"
|
||||
|
||||
|
||||
def test_slack_adapter_uses_existing_thread_ts():
|
||||
adapter = SlackAdapter("xoxb-test", bot_user_id="U_BOT")
|
||||
|
||||
parsed = adapter.parse_inbound(
|
||||
{
|
||||
"team_id": "T123",
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"channel": "C123",
|
||||
"user": "U123",
|
||||
"text": "<@U_BOT> continue",
|
||||
"ts": "1717000001.000200",
|
||||
"thread_ts": "1717000000.000100",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert parsed.external_peer_id == "slack_thread:T123:C123:1717000000.000100"
|
||||
assert parsed.metadata["message_ts"] == "1717000001.000200"
|
||||
302
surfsense_backend/tests/unit/gateway/test_webhook_routes.py
Normal file
302
surfsense_backend/tests/unit/gateway/test_webhook_routes.py
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import inspect
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import ExternalChatAccount, ExternalChatAccountMode, ExternalChatPlatform
|
||||
from app.routes import gateway_webhook_routes as routes
|
||||
|
||||
|
||||
class RequestStub:
|
||||
def __init__(self, payload=None, *, headers=None, json_exc: Exception | None = None):
|
||||
self.headers = headers or {}
|
||||
self._payload = payload
|
||||
self._json_exc = json_exc
|
||||
|
||||
async def json(self):
|
||||
if self._json_exc is not None:
|
||||
raise self._json_exc
|
||||
return self._payload
|
||||
|
||||
async def body(self):
|
||||
return json.dumps(self._payload).encode()
|
||||
|
||||
|
||||
def _account(secret: str = "secret") -> ExternalChatAccount:
|
||||
return ExternalChatAccount(
|
||||
id=123,
|
||||
platform=ExternalChatPlatform.TELEGRAM,
|
||||
webhook_secret=secret,
|
||||
bot_username="surf_bot",
|
||||
)
|
||||
|
||||
|
||||
def _slack_account() -> ExternalChatAccount:
|
||||
return ExternalChatAccount(
|
||||
id=456,
|
||||
platform=ExternalChatPlatform.SLACK,
|
||||
mode=ExternalChatAccountMode.CLOUD_SHARED,
|
||||
is_system_account=True,
|
||||
cursor_state={"team_id": "T123", "bot_user_id": "U_BOT"},
|
||||
)
|
||||
|
||||
|
||||
def _signed_slack_request(payload: dict, *, secret: str = "signing-secret") -> RequestStub:
|
||||
body = json.dumps(payload).encode()
|
||||
timestamp = str(int(time.time()))
|
||||
digest = hmac.new(
|
||||
secret.encode(),
|
||||
b"v0:" + timestamp.encode() + b":" + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
class SlackRequestStub(RequestStub):
|
||||
async def body(self):
|
||||
return body
|
||||
|
||||
return SlackRequestStub(
|
||||
payload,
|
||||
headers={
|
||||
"X-Slack-Request-Timestamp": timestamp,
|
||||
"X-Slack-Signature": f"v0={digest}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _call_webhook(*, request: RequestStub, account_id: int, session):
|
||||
return await routes.telegram_webhook(
|
||||
request=request,
|
||||
account_id=account_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_webhook_returns_200_on_null_update_id(mocker):
|
||||
session = mocker.AsyncMock()
|
||||
session.get.return_value = _account()
|
||||
request = RequestStub(
|
||||
{"message": {"message_id": 7}},
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "secret"},
|
||||
)
|
||||
|
||||
response = await _call_webhook(
|
||||
request=request,
|
||||
account_id=123,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_webhook_returns_200_on_bad_json(mocker, monkeypatch):
|
||||
parse_metric = mocker.Mock()
|
||||
monkeypatch.setattr(routes, "record_gateway_webhook_parse_error", parse_metric)
|
||||
request = RequestStub(json_exc=ValueError("bad json"))
|
||||
|
||||
response = await _call_webhook(
|
||||
request=request,
|
||||
account_id=123,
|
||||
session=mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
parse_metric.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_webhook_account_rejects_missing_or_wrong_header(mocker):
|
||||
session = mocker.AsyncMock()
|
||||
session.get.return_value = _account()
|
||||
|
||||
with pytest.raises(routes.HTTPException) as missing:
|
||||
await routes._resolve_webhook_account(
|
||||
session,
|
||||
account_id=123,
|
||||
header_secret=None,
|
||||
)
|
||||
assert missing.value.status_code == 403
|
||||
|
||||
with pytest.raises(routes.HTTPException) as wrong:
|
||||
await routes._resolve_webhook_account(
|
||||
session,
|
||||
account_id=123,
|
||||
header_secret="wrong",
|
||||
)
|
||||
assert wrong.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_webhook_persists_for_fastapi_inbox_worker(mocker, monkeypatch):
|
||||
session = mocker.AsyncMock()
|
||||
session.get.return_value = _account()
|
||||
persist = mocker.AsyncMock(return_value=99)
|
||||
monkeypatch.setattr(routes, "persist_inbound_event", persist)
|
||||
|
||||
request = RequestStub(
|
||||
{
|
||||
"update_id": 10,
|
||||
"message": {"message_id": 7, "chat": {"id": 1}, "from": {"id": 2}},
|
||||
},
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "secret"},
|
||||
)
|
||||
|
||||
response = await _call_webhook(
|
||||
request=request,
|
||||
account_id=123,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
persist.assert_awaited_once()
|
||||
session.commit.assert_awaited_once()
|
||||
assert persist.await_args.kwargs["request_id"].startswith("gateway_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_webhook_commits_dedup_without_enqueue(mocker, monkeypatch):
|
||||
session = mocker.AsyncMock()
|
||||
session.get.return_value = _account()
|
||||
monkeypatch.setattr(routes, "persist_inbound_event", mocker.AsyncMock(return_value=None))
|
||||
|
||||
request = RequestStub(
|
||||
{"update_id": 10, "message": {"message_id": 7}},
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "secret"},
|
||||
)
|
||||
|
||||
response = await _call_webhook(
|
||||
request=request,
|
||||
account_id=123,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
def test_telegram_webhook_does_not_use_slowapi_limiter():
|
||||
route_source = inspect.getsource(routes)
|
||||
|
||||
assert "@limiter.limit" not in route_source
|
||||
|
||||
|
||||
def test_verify_slack_signature_accepts_valid_signature():
|
||||
payload = b'{"type":"event_callback"}'
|
||||
timestamp = str(int(time.time()))
|
||||
digest = hmac.new(
|
||||
b"secret",
|
||||
b"v0:" + timestamp.encode() + b":" + payload,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
assert routes.verify_slack_signature(
|
||||
signing_secret="secret",
|
||||
timestamp=timestamp,
|
||||
signature=f"v0={digest}",
|
||||
body=payload,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_webhook_url_verification(monkeypatch, mocker):
|
||||
monkeypatch.setattr(routes.config, "GATEWAY_SLACK_SIGNING_SECRET", "signing-secret")
|
||||
request = _signed_slack_request({"type": "url_verification", "challenge": "abc123"})
|
||||
|
||||
response = await routes.slack_webhook(request=request, session=mocker.AsyncMock())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["challenge"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_webhook_persists_event(monkeypatch, mocker):
|
||||
monkeypatch.setattr(routes.config, "GATEWAY_SLACK_SIGNING_SECRET", "signing-secret")
|
||||
session = mocker.AsyncMock()
|
||||
monkeypatch.setattr(routes, "get_slack_account_by_team", mocker.AsyncMock(return_value=_slack_account()))
|
||||
persist = mocker.AsyncMock(return_value=100)
|
||||
monkeypatch.setattr(routes, "persist_inbound_event", persist)
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"team_id": "T123",
|
||||
"event_id": "Ev123",
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"channel": "C123",
|
||||
"user": "U123",
|
||||
"text": "<@U_BOT> hello",
|
||||
"ts": "1717000000.000100",
|
||||
},
|
||||
}
|
||||
request = _signed_slack_request(payload)
|
||||
|
||||
response = await routes.slack_webhook(request=request, session=session)
|
||||
|
||||
assert response.status_code == 200
|
||||
persist.assert_awaited_once()
|
||||
assert persist.await_args.kwargs["event_dedupe_key"] == "slack_event:Ev123"
|
||||
assert persist.await_args.kwargs["platform"] == ExternalChatPlatform.SLACK
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_webhook_ignores_self_event(monkeypatch, mocker):
|
||||
monkeypatch.setattr(routes.config, "GATEWAY_SLACK_SIGNING_SECRET", "signing-secret")
|
||||
session = mocker.AsyncMock()
|
||||
monkeypatch.setattr(routes, "get_slack_account_by_team", mocker.AsyncMock(return_value=_slack_account()))
|
||||
persist = mocker.AsyncMock(return_value=100)
|
||||
monkeypatch.setattr(routes, "persist_inbound_event", persist)
|
||||
request = _signed_slack_request(
|
||||
{
|
||||
"type": "event_callback",
|
||||
"team_id": "T123",
|
||||
"event_id": "Ev123",
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"channel": "C123",
|
||||
"user": "U_BOT",
|
||||
"text": "loop",
|
||||
"ts": "1717000000.000100",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
response = await routes.slack_webhook(request=request, session=session)
|
||||
|
||||
assert response.status_code == 200
|
||||
persist.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_gateway_install_returns_oauth_url(monkeypatch):
|
||||
monkeypatch.setattr(routes.config, "DISCORD_CLIENT_ID", "discord-client")
|
||||
monkeypatch.setattr(
|
||||
routes.config,
|
||||
"GATEWAY_DISCORD_REDIRECT_URI",
|
||||
"http://localhost:8000/api/v1/gateway/discord/callback",
|
||||
)
|
||||
monkeypatch.setattr(routes.config, "SECRET_KEY", "test-secret")
|
||||
|
||||
response = await routes.install_discord_gateway(
|
||||
search_space_id=123,
|
||||
user=SimpleNamespace(id="00000000-0000-0000-0000-000000000001"),
|
||||
)
|
||||
|
||||
assert response["auth_url"].startswith("https://discord.com/api/oauth2/authorize?")
|
||||
assert "client_id=discord-client" in response["auth_url"]
|
||||
assert "gateway%2Fdiscord%2Fcallback" in response["auth_url"]
|
||||
assert "scope=identify+guilds+bot" in response["auth_url"]
|
||||
|
||||
|
||||
def test_discord_gateway_callback_does_not_create_search_source_connector():
|
||||
callback_source = inspect.getsource(routes.discord_gateway_callback)
|
||||
|
||||
assert "SearchSourceConnector" not in callback_source
|
||||
|
||||
15
surfsense_backend/uv.lock
generated
15
surfsense_backend/uv.lock
generated
|
|
@ -7025,6 +7025,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-telegram-bot"
|
||||
version = "22.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpcore", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.1.post1"
|
||||
|
|
@ -8183,6 +8196,7 @@ dependencies = [
|
|||
{ name = "pypandoc-binary" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "python-ffmpeg" },
|
||||
{ name = "python-telegram-bot" },
|
||||
{ name = "redis" },
|
||||
{ name = "rerankers", extra = ["flashrank"] },
|
||||
{ name = "sentence-transformers" },
|
||||
|
|
@ -8280,6 +8294,7 @@ requires-dist = [
|
|||
{ name = "pypandoc-binary", specifier = ">=1.16.2" },
|
||||
{ name = "pypdf", specifier = ">=5.1.0" },
|
||||
{ name = "python-ffmpeg", specifier = ">=2.0.12" },
|
||||
{ name = "python-telegram-bot", specifier = ">=22.7" },
|
||||
{ name = "redis", specifier = ">=5.2.1" },
|
||||
{ name = "rerankers", extras = ["flashrank"], specifier = ">=0.7.1" },
|
||||
{ name = "sentence-transformers", specifier = ">=3.4.1" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,567 @@
|
|||
"use client";
|
||||
|
||||
import { RefreshCw, ShieldAlert } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GatewayConnection = {
|
||||
id: number;
|
||||
account_id?: number | null;
|
||||
route_type?: "account" | "binding";
|
||||
platform: string;
|
||||
mode?: string;
|
||||
state: string;
|
||||
search_space_id: number;
|
||||
display_name?: string | null;
|
||||
external_username?: string | null;
|
||||
workspace_name?: string | null;
|
||||
workspace_id?: string | null;
|
||||
health_status: string;
|
||||
suspended_reason?: string | null;
|
||||
};
|
||||
|
||||
type GatewayConfig = {
|
||||
telegram_enabled: boolean;
|
||||
whatsapp_intake_mode: "disabled" | "cloud" | "baileys";
|
||||
slack_enabled: boolean;
|
||||
discord_enabled: boolean;
|
||||
};
|
||||
|
||||
type GatewayConfigState = GatewayConfig | null;
|
||||
|
||||
type Pairing = {
|
||||
binding_id: number;
|
||||
code: string;
|
||||
deep_link: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
type PairingPlatform = "telegram" | "whatsapp";
|
||||
type GatewayPlatform = PairingPlatform | "slack" | "discord";
|
||||
|
||||
type BaileysHealth = {
|
||||
status: string;
|
||||
hasQr: boolean;
|
||||
qr?: string | null;
|
||||
queueDepth?: number;
|
||||
user?: unknown;
|
||||
};
|
||||
|
||||
export function MessagingChannelsContent() {
|
||||
const params = useParams<{ search_space_id: string }>();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const [gatewayConfig, setGatewayConfig] = useState<GatewayConfigState>(null);
|
||||
const [connections, setConnections] = useState<GatewayConnection[]>([]);
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [pairing, setPairing] = useState<Pairing | null>(null);
|
||||
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
||||
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
||||
const [refreshingPlatform, setRefreshingPlatform] = useState<GatewayPlatform | null>(null);
|
||||
const isGatewayConfigLoading = gatewayConfig === null;
|
||||
const telegramGatewayEnabled = gatewayConfig?.telegram_enabled ?? false;
|
||||
const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled";
|
||||
const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false;
|
||||
const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false;
|
||||
|
||||
const fetchConnections = useCallback(async (platform?: GatewayPlatform) => {
|
||||
const query = platform ? `?platform=${encodeURIComponent(platform)}` : "";
|
||||
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections${query}`);
|
||||
return (await res.json()) as GatewayConnection[];
|
||||
}, []);
|
||||
|
||||
const fetchGatewayConfig = useCallback(async () => {
|
||||
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/config`);
|
||||
return (await res.json()) as GatewayConfig;
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
|
||||
fetchConnections(),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
fetchGatewayConfig(),
|
||||
]);
|
||||
setConnections(nextConnections);
|
||||
setSearchSpaces(spaces);
|
||||
setGatewayConfig(nextGatewayConfig);
|
||||
}, [fetchConnections, fetchGatewayConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const refreshPlatform = useCallback(
|
||||
async (platform: GatewayPlatform) => {
|
||||
setRefreshingPlatform(platform);
|
||||
try {
|
||||
const nextConnections = await fetchConnections(platform);
|
||||
setConnections((current) => [
|
||||
...current.filter((connection) => connection.platform !== platform),
|
||||
...nextConnections,
|
||||
]);
|
||||
} finally {
|
||||
setRefreshingPlatform(null);
|
||||
}
|
||||
},
|
||||
[fetchConnections]
|
||||
);
|
||||
|
||||
const refreshBaileysHealth = useCallback(async () => {
|
||||
if (whatsappMode !== "baileys") return;
|
||||
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/health`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as BaileysHealth;
|
||||
setBaileysHealth(data);
|
||||
}, [whatsappMode]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBaileysHealth();
|
||||
}, [refreshBaileysHealth]);
|
||||
|
||||
async function startPairing(platform: PairingPlatform) {
|
||||
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ platform, search_space_id: searchSpaceId }),
|
||||
});
|
||||
setPairing(await res.json());
|
||||
setPairingPlatform(platform);
|
||||
await refreshPlatform(platform);
|
||||
}
|
||||
|
||||
async function installSlackGateway() {
|
||||
const res = await authenticatedFetch(
|
||||
`${BACKEND_URL}/api/v1/gateway/slack/install?search_space_id=${searchSpaceId}`
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { auth_url?: string };
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
}
|
||||
}
|
||||
|
||||
async function installDiscordGateway() {
|
||||
const res = await authenticatedFetch(
|
||||
`${BACKEND_URL}/api/v1/gateway/discord/install?search_space_id=${searchSpaceId}`
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { auth_url?: string };
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBaileys() {
|
||||
await refreshBaileysHealth();
|
||||
await refreshPlatform("whatsapp");
|
||||
}
|
||||
|
||||
const connectionKey = (connection: GatewayConnection) =>
|
||||
connection.route_type === "account" && connection.account_id
|
||||
? `account:${connection.account_id}`
|
||||
: `binding:${connection.id}`;
|
||||
|
||||
async function revoke(connection: GatewayConnection) {
|
||||
const url =
|
||||
connection.route_type === "account" && connection.account_id
|
||||
? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}`
|
||||
: `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}`;
|
||||
await authenticatedFetch(url, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
async function updateConnectionSearchSpace(
|
||||
connection: GatewayConnection,
|
||||
nextSearchSpaceId: string
|
||||
) {
|
||||
const previousConnections = connections;
|
||||
const parsedSearchSpaceId = Number(nextSearchSpaceId);
|
||||
const targetKey = connectionKey(connection);
|
||||
setConnections((current) =>
|
||||
current.map((connection) =>
|
||||
connectionKey(connection) === targetKey
|
||||
? { ...connection, search_space_id: parsedSearchSpaceId }
|
||||
: connection
|
||||
)
|
||||
);
|
||||
const url =
|
||||
connection.route_type === "account" && connection.account_id
|
||||
? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}/search-space`
|
||||
: `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/search-space`;
|
||||
const res = await authenticatedFetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ search_space_id: parsedSearchSpaceId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setConnections(previousConnections);
|
||||
toast.error("Failed to update messaging route");
|
||||
return;
|
||||
}
|
||||
toast.success("Messaging route updated");
|
||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
async function resume(connection: GatewayConnection) {
|
||||
await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/resume`, {
|
||||
method: "POST",
|
||||
});
|
||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
const isConnectionInActiveMode = (connection: GatewayConnection) => {
|
||||
if (connection.platform !== "whatsapp") return true;
|
||||
if (whatsappMode === "baileys") return connection.mode === "self_host_byo";
|
||||
if (whatsappMode === "cloud") return connection.mode !== "self_host_byo";
|
||||
return false;
|
||||
};
|
||||
const baileysQr = baileysHealth?.qr || null;
|
||||
const hasTelegramConnection = connections.some(
|
||||
(connection) => connection.platform === "telegram"
|
||||
);
|
||||
const hasWhatsAppConnection = connections.some(
|
||||
(connection) => connection.platform === "whatsapp" && isConnectionInActiveMode(connection)
|
||||
);
|
||||
const hasEnabledGateway =
|
||||
telegramGatewayEnabled ||
|
||||
whatsappMode !== "disabled" ||
|
||||
slackGatewayEnabled ||
|
||||
discordGatewayEnabled;
|
||||
const isRefreshing = (platform: GatewayPlatform) => refreshingPlatform === platform;
|
||||
const refreshButtonClassName = "gap-2";
|
||||
const refreshIconClassName = (platform: GatewayPlatform) =>
|
||||
cn("mr-2 h-4 w-4", isRefreshing(platform) && "animate-spin");
|
||||
const platformLabel = (platform: string) => {
|
||||
switch (platform) {
|
||||
case "discord":
|
||||
return "Discord";
|
||||
case "slack":
|
||||
return "Slack";
|
||||
case "telegram":
|
||||
return "Telegram";
|
||||
case "whatsapp":
|
||||
return "WhatsApp";
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
};
|
||||
const connectionTitle = (connection: GatewayConnection) =>
|
||||
connection.platform === "whatsapp" && connection.mode === "self_host_byo"
|
||||
? "WhatsApp Bridge"
|
||||
: connection.workspace_name ||
|
||||
connection.display_name ||
|
||||
connection.external_username ||
|
||||
`${platformLabel(connection.platform)} connection`;
|
||||
const renderConnectionRows = (platform: GatewayConnection["platform"], emptyText: string) => {
|
||||
const platformConnections = connections.filter(
|
||||
(connection) => connection.platform === platform && isConnectionInActiveMode(connection)
|
||||
);
|
||||
|
||||
if (platformConnections.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-24 items-center justify-center text-center">
|
||||
<p className="text-xs text-muted-foreground">{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Connected accounts</p>
|
||||
{platformConnections.map((connection, index) => (
|
||||
<div key={connectionKey(connection)} className="space-y-2">
|
||||
{index > 0 ? <Separator className="bg-accent" /> : null}
|
||||
<div className="space-y-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{connectionTitle(connection)}</p>
|
||||
{connection.suspended_reason ? (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-destructive">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{connection.suspended_reason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={String(connection.search_space_id)}
|
||||
onValueChange={(value) => updateConnectionSearchSpace(connection, value)}
|
||||
disabled={searchSpaces.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
|
||||
<SelectValue placeholder="Select search space" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchSpaces.map((space) => (
|
||||
<SelectItem key={space.id} value={String(space.id)}>
|
||||
{space.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{connection.state === "suspended" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => resume(connection)}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
|
||||
onClick={() => revoke(connection)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderPairingPanel = (platform: PairingPlatform) => {
|
||||
if (!pairing || pairingPlatform !== platform) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-accent bg-accent/20 p-3">
|
||||
<p className="text-xs font-medium">Pairing code</p>
|
||||
<p className="mt-2 font-mono text-lg">{pairing.code}</p>
|
||||
<a className="mt-2 block text-sm text-primary underline" href={pairing.deep_link}>
|
||||
Open {platform === "whatsapp" ? "WhatsApp" : "Telegram"} pairing link
|
||||
</a>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Expires at {new Date(pairing.expires_at).toLocaleString()}. SurfSense stores this
|
||||
channel's messages for agent memory and operational debugging.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderGatewaySkeletons = () => (
|
||||
<>
|
||||
{[0, 1].map((index) => (
|
||||
<Card key={index} className="h-full overflow-hidden border-accent bg-accent/20">
|
||||
<CardHeader className="space-y-3 p-4">
|
||||
<Skeleton className="h-4 w-24 bg-accent" />
|
||||
<Skeleton className="h-3 w-3/4 bg-accent" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<Skeleton className="h-8 w-40 bg-accent" />
|
||||
<Separator className="bg-accent" />
|
||||
<Skeleton className="h-10 w-full bg-accent" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid items-stretch gap-3 sm:grid-cols-2">
|
||||
{isGatewayConfigLoading ? renderGatewaySkeletons() : null}
|
||||
|
||||
{!isGatewayConfigLoading && !hasEnabledGateway ? (
|
||||
<Card className="col-span-full border-accent bg-accent/20">
|
||||
<CardHeader className="space-y-1.5 p-4">
|
||||
<CardTitle className="text-sm">No messaging gateways enabled</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{telegramGatewayEnabled ? (
|
||||
<Card className="order-1 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">Telegram</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect Telegram to chat with SurfSense.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasTelegramConnection ? null : (
|
||||
<Button size="sm" onClick={() => startPairing("telegram")}>
|
||||
Pair Telegram Chat
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("telegram")}
|
||||
disabled={isRefreshing("telegram")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("telegram")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasTelegramConnection ? null : renderPairingPanel("telegram")}
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("telegram", "No Telegram chats connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{slackGatewayEnabled ? (
|
||||
<Card className="order-4 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">Slack</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable the SurfSense Slack bot so teammates can mention it in Slack.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={installSlackGateway}>
|
||||
Add Slack Workspace
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("slack")}
|
||||
disabled={isRefreshing("slack")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("slack")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("slack", "No Slack workspaces connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{discordGatewayEnabled ? (
|
||||
<Card className="order-3 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">Discord</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable the SurfSense Discord bot so teammates can mention it in Discord.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={installDiscordGateway}>
|
||||
Add Discord Server
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("discord")}
|
||||
disabled={isRefreshing("discord")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("discord")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("discord", "No Discord servers connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{whatsappMode !== "disabled" ? (
|
||||
<Card className="order-2 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">WhatsApp</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{whatsappMode === "baileys"
|
||||
? 'Use "Message Yourself". Other chats are ignored.'
|
||||
: "Connect WhatsApp to chat with Surfsense."}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{whatsappMode === "cloud" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasWhatsAppConnection ? null : (
|
||||
<Button size="sm" onClick={() => startPairing("whatsapp")}>
|
||||
Pair WhatsApp
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("whatsapp")}
|
||||
disabled={isRefreshing("whatsapp")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("whatsapp")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{hasWhatsAppConnection ? null : renderPairingPanel("whatsapp")}
|
||||
</div>
|
||||
) : null}
|
||||
{whatsappMode === "baileys" ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={refreshBaileys}
|
||||
disabled={isRefreshing("whatsapp")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("whatsapp")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{baileysQr ? (
|
||||
<div className="rounded-lg border border-accent bg-accent/20 p-3">
|
||||
<p className="text-sm font-medium">WhatsApp QR pairing</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Scan this QR from WhatsApp > Linked Devices > Link a Device.
|
||||
</p>
|
||||
<div className="mt-3 inline-block rounded-md bg-white p-3">
|
||||
<QRCodeSVG value={baileysQr} size={192} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{baileysHealth ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bridge status: {baileysHealth.status}
|
||||
{typeof baileysHealth.queueDepth === "number"
|
||||
? `, queue: ${baileysHealth.queueDepth}`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("whatsapp", "No WhatsApp chats connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Keyboard,
|
||||
KeyRound,
|
||||
Library,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
|
|
@ -29,7 +30,8 @@ export type UserSettingsTab =
|
|||
| "agent-status"
|
||||
| "purchases"
|
||||
| "desktop"
|
||||
| "hotkeys";
|
||||
| "hotkeys"
|
||||
| "messaging-channels";
|
||||
|
||||
const DEFAULT_TAB: UserSettingsTab = "profile";
|
||||
|
||||
|
|
@ -83,6 +85,11 @@ export function UserSettingsLayoutShell({ searchSpaceId, children }: UserSetting
|
|||
label: "Agent Status",
|
||||
icon: <Workflow className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "messaging-channels" as const,
|
||||
label: "Messaging Channels",
|
||||
icon: <MessageCircle className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases" as const,
|
||||
label: "Purchase History",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { MessagingChannelsContent } from "../components/MessagingChannelsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <MessagingChannelsContent />;
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -36,7 +36,6 @@ export const MCPTrustedTools: FC<MCPTrustedToolsProps> = ({ connector }) => {
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Trusted Tools
|
||||
</h3>
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,19 @@ Uncomment the connectors you want to use. Redirect URIs follow the pattern `http
|
|||
| Microsoft (Teams & OneDrive) | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `TEAMS_REDIRECT_URI`, `ONEDRIVE_REDIRECT_URI` |
|
||||
| Dropbox | `DROPBOX_APP_KEY`, `DROPBOX_APP_SECRET`, `DROPBOX_REDIRECT_URI` |
|
||||
|
||||
### Messaging Channels
|
||||
|
||||
Configure these in the same `docker/.env` file when you want users to chat with
|
||||
SurfSense from external apps. See [Messaging Channels](/docs/messaging-channels)
|
||||
for full setup.
|
||||
|
||||
| Channel | Variables |
|
||||
|---------|-----------|
|
||||
| Telegram | `TELEGRAM_SHARED_BOT_TOKEN`, `TELEGRAM_SHARED_BOT_USERNAME`, `TELEGRAM_WEBHOOK_SECRET`, `GATEWAY_BASE_URL`, `GATEWAY_TELEGRAM_INTAKE_MODE` |
|
||||
| WhatsApp | `GATEWAY_WHATSAPP_INTAKE_MODE`, `WHATSAPP_SHARED_BUSINESS_TOKEN`, `WHATSAPP_SHARED_PHONE_NUMBER_ID`, `WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER`, `WHATSAPP_SHARED_WABA_ID`, `WHATSAPP_WEBHOOK_VERIFY_TOKEN`, `WHATSAPP_WEBHOOK_APP_SECRET` |
|
||||
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `GATEWAY_SLACK_ENABLED`, `GATEWAY_SLACK_SIGNING_SECRET`, `GATEWAY_SLACK_REDIRECT_URI` |
|
||||
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `GATEWAY_DISCORD_ENABLED`, `GATEWAY_DISCORD_REDIRECT_URI` |
|
||||
|
||||
### Observability (optional)
|
||||
|
||||
| Variable | Description |
|
||||
|
|
@ -187,9 +200,9 @@ Postgres. Before this design, a silent migration failure would leave
|
|||
|
||||
The backend exposes two endpoints:
|
||||
|
||||
- `GET /health` — lightweight liveness probe (always returns 200 if the
|
||||
- `GET /health`: lightweight liveness probe (always returns 200 if the
|
||||
process is up).
|
||||
- `GET /ready` — readiness probe that confirms `zero_publication` exists.
|
||||
- `GET /ready`: readiness probe that confirms `zero_publication` exists.
|
||||
Returns 503 if not. The compose `backend.healthcheck` uses `/ready` so the
|
||||
container only reports `healthy` once the schema is actually usable by
|
||||
zero-cache.
|
||||
|
|
@ -247,7 +260,7 @@ docker compose exec db psql -U surfsense -d surfsense \
|
|||
```
|
||||
|
||||
The default migration timeout is 900 seconds. Slow disks (Windows / WSL2)
|
||||
may need more — set `MIGRATION_TIMEOUT` in `.env` to increase it.
|
||||
may need more. Set `MIGRATION_TIMEOUT` in `.env` to increase it.
|
||||
|
||||
### Zero-cache stuck on `Unknown or invalid publications`
|
||||
|
||||
|
|
@ -258,7 +271,7 @@ Error: Unknown or invalid publications. Specified: [zero_publication]. Found: []
|
|||
```
|
||||
|
||||
This means `zero-cache` started before `zero_publication` was created. With
|
||||
the current compose files this should be impossible — the `migrations`
|
||||
the current compose files this should be impossible. The `migrations`
|
||||
service blocks `zero-cache` from starting. If you see it, your stack
|
||||
predates the fix or you brought up `zero-cache` manually with `docker
|
||||
compose up zero-cache` before the migrations service ran.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ icon: BookOpen
|
|||
---
|
||||
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card';
|
||||
import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart } from 'lucide-react';
|
||||
import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart, MessageCircle } from 'lucide-react';
|
||||
|
||||
Welcome to **SurfSense's Documentation!** Here, you'll find everything you need to get the most out of SurfSense. Dive in to explore how SurfSense can be your AI-powered research companion.
|
||||
|
||||
|
|
@ -40,6 +40,12 @@ Welcome to **SurfSense's Documentation!** Here, you'll find everything you need
|
|||
description="Integrate with third-party services"
|
||||
href="/docs/connectors"
|
||||
/>
|
||||
<Card
|
||||
icon={<MessageCircle />}
|
||||
title="Messaging Channels"
|
||||
description="Chat with SurfSense from Telegram, WhatsApp, Slack, and Discord"
|
||||
href="/docs/messaging-channels"
|
||||
/>
|
||||
<Card
|
||||
icon={<BookOpen />}
|
||||
title="How-To Guides"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ Complete all the [setup steps](/docs), including:
|
|||
|
||||
The backend is the core of SurfSense. Follow these steps to set it up:
|
||||
|
||||
### Optional: Messaging Channels
|
||||
|
||||
SurfSense can expose the same backend agent through Telegram, WhatsApp, Slack,
|
||||
and Discord. For manual installs, configure the relevant channel variables in
|
||||
`surfsense_backend/.env`.
|
||||
|
||||
See [Messaging Channels](/docs/messaging-channels) for the channel-specific
|
||||
setup guides.
|
||||
|
||||
### 1. Environment Configuration
|
||||
|
||||
First, create and configure your environment variables by copying the example file:
|
||||
|
|
@ -350,7 +359,7 @@ redis-cli ping
|
|||
|
||||
### 6. Start Celery Worker
|
||||
|
||||
In a new terminal window, start the Celery worker to handle background tasks:
|
||||
In a new terminal window, start the Celery worker to handle background tasks. For external chat surfaces, Celery only runs maintenance tasks; agent turns run inside the FastAPI process.
|
||||
|
||||
**If using uv:**
|
||||
|
||||
|
|
@ -358,9 +367,9 @@ In a new terminal window, start the Celery worker to handle background tasks:
|
|||
# Make sure you're in the surfsense_backend directory
|
||||
cd surfsense_backend
|
||||
|
||||
# Start Celery worker (consume both default and connectors queues)
|
||||
# Start Celery worker (consume default, connectors, and external chat maintenance queues)
|
||||
DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}"
|
||||
uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues="${DEFAULT_Q},${DEFAULT_Q}.connectors"
|
||||
uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues="${DEFAULT_Q},${DEFAULT_Q}.connectors,${DEFAULT_Q}.gateway"
|
||||
```
|
||||
|
||||
**If using pip/venv:**
|
||||
|
|
@ -374,9 +383,9 @@ source .venv/bin/activate # Linux/macOS
|
|||
# OR
|
||||
.venv\Scripts\activate # Windows
|
||||
|
||||
# Start Celery worker (consume both default and connectors queues)
|
||||
# Start Celery worker (consume default, connectors, and external chat maintenance queues)
|
||||
DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}"
|
||||
celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues="${DEFAULT_Q},${DEFAULT_Q}.connectors"
|
||||
celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues="${DEFAULT_Q},${DEFAULT_Q}.connectors,${DEFAULT_Q}.gateway"
|
||||
```
|
||||
|
||||
**Optional: Start Flower for monitoring Celery tasks:**
|
||||
|
|
@ -457,7 +466,7 @@ If everything is set up correctly, you should see output indicating the server i
|
|||
|
||||
## Zero-Cache Setup
|
||||
|
||||
**zero-cache** is the Rocicorp Zero server that sits between PostgreSQL and the browser. It streams real-time updates (notifications, document indexing status, chat comments, collaboration indicators) to all connected clients via WebSocket. The frontend connects to it on startup — without zero-cache running, you will not see live updates and many parts of the UI will sit on stale data.
|
||||
**zero-cache** is the Rocicorp Zero server that sits between PostgreSQL and the browser. It streams real-time updates (notifications, document indexing status, chat comments, collaboration indicators) to all connected clients via WebSocket. The frontend connects to it on startup. Without zero-cache running, you will not see live updates and many parts of the UI will sit on stale data.
|
||||
|
||||
For an overview of how Zero works and the list of synced tables, see the [Real-Time Sync with Zero](/docs/how-to/zero-sync) guide.
|
||||
|
||||
|
|
@ -539,7 +548,7 @@ cd ../docker
|
|||
docker compose -f docker-compose.deps-only.yml up -d
|
||||
```
|
||||
|
||||
The deps-only stack exposes zero-cache on port `4848` (default) — keep `NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848` in your `surfsense_web/.env`.
|
||||
The deps-only stack exposes zero-cache on port `4848` by default. Keep `NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848` in your `surfsense_web/.env`.
|
||||
|
||||
## Frontend Setup
|
||||
|
||||
|
|
@ -675,7 +684,7 @@ To verify your installation:
|
|||
1. Open your browser and navigate to `http://localhost:3000`
|
||||
2. Sign in with your Google account (or local credentials if `AUTH_TYPE=LOCAL`)
|
||||
3. Create a search space and try uploading a document
|
||||
4. Watch the upload status update live without refreshing — this confirms zero-cache is wired up correctly
|
||||
4. Watch the upload status update live without refreshing. This confirms zero-cache is wired up correctly
|
||||
5. Test the chat functionality with your uploaded content
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
|||
76
surfsense_web/content/docs/messaging-channels/discord.mdx
Normal file
76
surfsense_web/content/docs/messaging-channels/discord.mdx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
title: Discord
|
||||
description: Enable the SurfSense bot for in-Discord agent chat
|
||||
---
|
||||
|
||||
# Discord Messaging Channel
|
||||
|
||||
The Discord messaging channel lets users mention the SurfSense bot in Discord
|
||||
and chat with the SurfSense backend agent from a Discord channel.
|
||||
|
||||
This is separate from the Discord connector. The messaging channel handles bot
|
||||
mentions and replies; the connector gives the agent Discord channel/message read
|
||||
tools.
|
||||
|
||||
## Discord Application Settings
|
||||
|
||||
Create or reuse a Discord application in the
|
||||
[Discord Developer Portal](https://discord.com/developers/applications).
|
||||
|
||||
In **OAuth2 > Redirects**, add both callback URLs if the same application powers
|
||||
the connector and messaging channel:
|
||||
|
||||
```bash
|
||||
https://your-backend.example.com/api/v1/auth/discord/connector/callback
|
||||
https://your-backend.example.com/api/v1/gateway/discord/callback
|
||||
```
|
||||
|
||||
For local OAuth testing, replace the host with your local or public tunnel URL,
|
||||
and make sure `DISCORD_REDIRECT_URI` and `GATEWAY_DISCORD_REDIRECT_URI` match
|
||||
the Discord dashboard exactly.
|
||||
|
||||
## Bot Permissions And Intents
|
||||
|
||||
In **Bot > Privileged Gateway Intents**, enable:
|
||||
|
||||
- **Message Content Intent** so SurfSense can read text after a bot mention.
|
||||
|
||||
When installing the bot, grant:
|
||||
|
||||
- View Channels
|
||||
- Send Messages
|
||||
- Send Messages in Threads
|
||||
- Read Message History
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For Docker installs, add these to `docker/.env`. For manual installs, add them to
|
||||
`surfsense_backend/.env`.
|
||||
|
||||
```bash
|
||||
DISCORD_CLIENT_ID=your_discord_client_id
|
||||
DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
GATEWAY_DISCORD_ENABLED=TRUE
|
||||
GATEWAY_DISCORD_REDIRECT_URI=https://your-backend.example.com/api/v1/gateway/discord/callback
|
||||
```
|
||||
|
||||
The messaging channel uses the same Discord app credentials as the Discord
|
||||
connector. `DISCORD_REDIRECT_URI` remains the connector callback;
|
||||
`GATEWAY_DISCORD_REDIRECT_URI` is the separate messaging channel install
|
||||
callback.
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
1. Discord sends a `MESSAGE_CREATE` event over its WebSocket API.
|
||||
2. SurfSense stores the event in the durable gateway inbox.
|
||||
3. SurfSense resolves the Discord user binding to a SurfSense user and search space.
|
||||
4. SurfSense runs the backend agent with that user's permissions.
|
||||
5. The agent reply is posted back to the same Discord channel.
|
||||
|
||||
## Deployment Note
|
||||
|
||||
Only one running backend process should connect to Discord with a
|
||||
given bot token. For multi-replica deployments, enable
|
||||
`GATEWAY_DISCORD_ENABLED=TRUE` in a single backend process and leave it disabled
|
||||
in other API replicas.
|
||||
60
surfsense_web/content/docs/messaging-channels/docker.mdx
Normal file
60
surfsense_web/content/docs/messaging-channels/docker.mdx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
---
|
||||
title: Docker Setup
|
||||
description: Configure messaging channels for Docker and one-line installs
|
||||
---
|
||||
|
||||
# Docker Setup
|
||||
|
||||
For Docker and one-line installs, configure messaging channels in the generated
|
||||
`docker/.env` file. You do not need to edit `surfsense_backend/.env.example`.
|
||||
|
||||
The Compose stack passes `docker/.env` into the backend, worker, and beat
|
||||
containers. Database, Redis, SearXNG, and internal Docker networking are already
|
||||
wired by Compose.
|
||||
|
||||
## Public URLs
|
||||
|
||||
For localhost-only testing, the defaults are enough for the SurfSense UI, but
|
||||
public webhooks from Telegram, WhatsApp, and Slack require a public HTTPS backend
|
||||
URL. Use your deployed backend URL or a tunnel such as Cloudflare Tunnel or
|
||||
ngrok.
|
||||
|
||||
When using a custom domain or tunnel, set:
|
||||
|
||||
```bash
|
||||
BACKEND_URL=https://api.example.com
|
||||
GATEWAY_BASE_URL=https://api.example.com
|
||||
NEXT_FRONTEND_URL=https://app.example.com
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.example.com
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Uncomment only the channel you are enabling in `docker/.env`.
|
||||
|
||||
| Channel | Main variables |
|
||||
| --- | --- |
|
||||
| Telegram | `TELEGRAM_SHARED_BOT_TOKEN`, `TELEGRAM_SHARED_BOT_USERNAME`, `TELEGRAM_WEBHOOK_SECRET`, `GATEWAY_BASE_URL`, `GATEWAY_TELEGRAM_INTAKE_MODE` |
|
||||
| WhatsApp Cloud API | `GATEWAY_WHATSAPP_INTAKE_MODE`, `WHATSAPP_SHARED_BUSINESS_TOKEN`, `WHATSAPP_SHARED_PHONE_NUMBER_ID`, `WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER`, `WHATSAPP_SHARED_WABA_ID`, `WHATSAPP_WEBHOOK_VERIFY_TOKEN`, `WHATSAPP_WEBHOOK_APP_SECRET` |
|
||||
| WhatsApp Baileys | `GATEWAY_WHATSAPP_INTAKE_MODE`, `WHATSAPP_BRIDGE_URL`, `WHATSAPP_MODE` |
|
||||
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `GATEWAY_SLACK_ENABLED`, `GATEWAY_SLACK_SIGNING_SECRET`, `GATEWAY_SLACK_REDIRECT_URI` |
|
||||
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `GATEWAY_DISCORD_ENABLED`, `GATEWAY_DISCORD_REDIRECT_URI` |
|
||||
|
||||
After editing `docker/.env`, restart the stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For WhatsApp Baileys, start the Compose profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile whatsapp up -d
|
||||
```
|
||||
|
||||
Then follow the per-channel setup pages:
|
||||
|
||||
- [Telegram](/docs/messaging-channels/telegram)
|
||||
- [WhatsApp](/docs/messaging-channels/whatsapp)
|
||||
- [Slack](/docs/messaging-channels/slack)
|
||||
- [Discord](/docs/messaging-channels/discord)
|
||||
42
surfsense_web/content/docs/messaging-channels/index.mdx
Normal file
42
surfsense_web/content/docs/messaging-channels/index.mdx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
title: Messaging Channels
|
||||
description: Chat with SurfSense from Telegram, WhatsApp, Slack, and Discord
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card';
|
||||
|
||||
Choose the external chat app you want to connect to SurfSense. Each guide shows
|
||||
the required app setup, environment variables, and pairing flow.
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Telegram"
|
||||
description="Enable SurfSense chat from Telegram"
|
||||
href="/docs/messaging-channels/telegram"
|
||||
/>
|
||||
<Card
|
||||
title="WhatsApp"
|
||||
description="Enable SurfSense chat from WhatsApp"
|
||||
href="/docs/messaging-channels/whatsapp"
|
||||
/>
|
||||
<Card
|
||||
title="Slack"
|
||||
description="Enable the SurfSense bot for in-Slack agent chat"
|
||||
href="/docs/messaging-channels/slack"
|
||||
/>
|
||||
<Card
|
||||
title="Discord"
|
||||
description="Enable the SurfSense bot for in-Discord agent chat"
|
||||
href="/docs/messaging-channels/discord"
|
||||
/>
|
||||
<Card
|
||||
title="Docker Setup"
|
||||
description="Configure messaging channels for Docker and one-line installs"
|
||||
href="/docs/messaging-channels/docker"
|
||||
/>
|
||||
<Card
|
||||
title="Troubleshooting"
|
||||
description="Common pairing, webhook, and bot reply issues"
|
||||
href="/docs/messaging-channels/troubleshooting"
|
||||
/>
|
||||
</Cards>
|
||||
13
surfsense_web/content/docs/messaging-channels/meta.json
Normal file
13
surfsense_web/content/docs/messaging-channels/meta.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"title": "Messaging Channels",
|
||||
"icon": "MessageCircle",
|
||||
"pages": [
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"slack",
|
||||
"discord",
|
||||
"docker",
|
||||
"troubleshooting"
|
||||
],
|
||||
"defaultOpen": false
|
||||
}
|
||||
84
surfsense_web/content/docs/messaging-channels/slack.mdx
Normal file
84
surfsense_web/content/docs/messaging-channels/slack.mdx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
title: Slack
|
||||
description: Enable the SurfSense bot for in-Slack agent chat
|
||||
---
|
||||
|
||||
# Slack Messaging Channel
|
||||
|
||||
The Slack messaging channel lets users mention the SurfSense bot in Slack and
|
||||
chat with the SurfSense backend agent from a Slack thread.
|
||||
|
||||
This is separate from the Slack connector. The messaging channel handles bot
|
||||
mentions and replies; the connector gives the agent Slack search/read tools.
|
||||
|
||||
## Required Slack App Scopes
|
||||
|
||||
Add these **Bot Token Scopes** in Slack OAuth & Permissions:
|
||||
|
||||
| Scope | Purpose |
|
||||
| --- | --- |
|
||||
| `app_mentions:read` | Receive bot mention events |
|
||||
| `chat:write` | Reply in Slack threads |
|
||||
| `channels:read` | Read public channel metadata |
|
||||
| `groups:read` | Read private channel metadata where the bot is present |
|
||||
| `im:write` | Send onboarding or direct replies |
|
||||
| `users:read` | Resolve Slack users |
|
||||
| `team:read` | Resolve workspace metadata |
|
||||
|
||||
Optional scopes:
|
||||
|
||||
- `im:history` if you support direct message chat with the bot.
|
||||
- `commands` if you add slash commands.
|
||||
|
||||
Avoid `channels:history` and `groups:history` for the messaging channel unless
|
||||
you specifically need gateway-side context reads. Slack workspace search should
|
||||
stay with the Slack connector.
|
||||
|
||||
## Event Subscriptions
|
||||
|
||||
Enable Slack Events API and subscribe to:
|
||||
|
||||
- `app_mention`
|
||||
|
||||
Set the request URL to:
|
||||
|
||||
```bash
|
||||
https://your-backend.example.com/api/v1/gateway/webhooks/slack
|
||||
```
|
||||
|
||||
Slack must be able to reach this URL. Do not use `localhost` for event
|
||||
subscriptions.
|
||||
|
||||
## OAuth Redirect URLs
|
||||
|
||||
If the same Slack app powers both the connector and messaging channel, add both
|
||||
redirect URLs in **OAuth & Permissions**:
|
||||
|
||||
```bash
|
||||
https://your-backend.example.com/api/v1/auth/slack/connector/callback
|
||||
https://your-backend.example.com/api/v1/gateway/slack/callback
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For Docker installs, add these to `docker/.env`. For manual installs, add them to
|
||||
`surfsense_backend/.env`.
|
||||
|
||||
```bash
|
||||
SLACK_CLIENT_ID=your_slack_client_id
|
||||
SLACK_CLIENT_SECRET=your_slack_client_secret
|
||||
GATEWAY_SLACK_ENABLED=TRUE
|
||||
GATEWAY_SLACK_SIGNING_SECRET=your_slack_signing_secret
|
||||
GATEWAY_SLACK_REDIRECT_URI=https://your-backend.example.com/api/v1/gateway/slack/callback
|
||||
```
|
||||
|
||||
After changing Slack scopes, redirect URLs, or event subscriptions, reinstall
|
||||
the Slack app to your workspace so Slack grants the updated permissions.
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
1. Slack sends an `app_mention` event to SurfSense.
|
||||
2. SurfSense verifies the Slack signature and stores the event in the gateway inbox.
|
||||
3. SurfSense resolves the Slack user binding to a SurfSense user and search space.
|
||||
4. SurfSense runs the backend agent with that user's permissions.
|
||||
5. The agent reply is posted back in the same Slack thread.
|
||||
62
surfsense_web/content/docs/messaging-channels/telegram.mdx
Normal file
62
surfsense_web/content/docs/messaging-channels/telegram.mdx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
title: Telegram
|
||||
description: Enable SurfSense chat from Telegram
|
||||
---
|
||||
|
||||
# Telegram Messaging Channel
|
||||
|
||||
Telegram lets users chat with the SurfSense agent from a Telegram bot. Users pair
|
||||
their Telegram chat from **User Settings > Messaging Channels**.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For Docker installs, add these to `docker/.env`. For manual installs, add them to
|
||||
`surfsense_backend/.env`.
|
||||
|
||||
```bash
|
||||
TELEGRAM_SHARED_BOT_TOKEN=123456:bot-token-from-botfather
|
||||
TELEGRAM_SHARED_BOT_USERNAME=your_bot_username
|
||||
TELEGRAM_WEBHOOK_SECRET=generate-a-long-random-secret
|
||||
GATEWAY_BASE_URL=https://api.example.com
|
||||
GATEWAY_TELEGRAM_INTAKE_MODE=webhook
|
||||
```
|
||||
|
||||
`TELEGRAM_WEBHOOK_SECRET` must be 1-256 characters and contain only `A-Z`, `a-z`,
|
||||
`0-9`, `_`, or `-`.
|
||||
|
||||
## Intake Modes
|
||||
|
||||
| Mode | Use when |
|
||||
| --- | --- |
|
||||
| `webhook` | Production or any deployment with a public HTTPS backend URL |
|
||||
| `longpoll` | Single-replica self-host installs that cannot expose a public HTTPS webhook |
|
||||
| `disabled` | You do not want Telegram intake enabled |
|
||||
|
||||
For SaaS-style or multi-replica deployments, use `webhook`. Long polling should
|
||||
only run in a single backend process.
|
||||
|
||||
## Webhook URL
|
||||
|
||||
Telegram webhooks use this shape:
|
||||
|
||||
```text
|
||||
${GATEWAY_BASE_URL}/api/v1/gateway/webhooks/telegram/{account_id}
|
||||
```
|
||||
|
||||
After deploying the backend, register the webhook:
|
||||
|
||||
```bash
|
||||
cd surfsense_backend
|
||||
uv run python scripts/register_webhook.py
|
||||
```
|
||||
|
||||
If switching a bot from long polling to webhooks, delete any existing Telegram
|
||||
webhook or pending `getUpdates` session before relying on the new mode.
|
||||
|
||||
## Pairing Flow
|
||||
|
||||
1. The user opens **User Settings > Messaging Channels**.
|
||||
2. The user starts Telegram pairing.
|
||||
3. SurfSense provides a pairing code or bot link.
|
||||
4. The user sends the pairing command to the Telegram bot.
|
||||
5. SurfSense binds that Telegram chat to the selected search space.
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
title: Troubleshooting
|
||||
description: Common messaging channel pairing, webhook, and bot reply issues
|
||||
---
|
||||
|
||||
# Messaging Channels Troubleshooting
|
||||
|
||||
## The Bot Does Not Reply
|
||||
|
||||
Check that:
|
||||
|
||||
- The channel is enabled in the backend environment.
|
||||
- The backend restarted after the environment change.
|
||||
- The external platform can reach your public HTTPS backend URL.
|
||||
- The user paired the channel from **User Settings > Messaging Channels**.
|
||||
- Redis is running, because gateway inbox processing uses backend coordination
|
||||
and rate-limit state.
|
||||
|
||||
## Telegram
|
||||
|
||||
Check that:
|
||||
|
||||
- `TELEGRAM_SHARED_BOT_TOKEN` and `TELEGRAM_SHARED_BOT_USERNAME` are correct.
|
||||
- `GATEWAY_TELEGRAM_INTAKE_MODE` is one of `webhook`, `longpoll`, or `disabled`.
|
||||
- `TELEGRAM_WEBHOOK_SECRET` contains only `A-Z`, `a-z`, `0-9`, `_`, or `-`.
|
||||
- Webhook mode uses a public HTTPS `GATEWAY_BASE_URL`.
|
||||
- Long polling runs in only one backend process.
|
||||
|
||||
## WhatsApp
|
||||
|
||||
For Meta Cloud API, check that:
|
||||
|
||||
- `GATEWAY_WHATSAPP_INTAKE_MODE=cloud`.
|
||||
- The Meta webhook URL is `${GATEWAY_BASE_URL}/api/v1/gateway/webhooks/whatsapp`.
|
||||
- The Meta verify token matches `WHATSAPP_WEBHOOK_VERIFY_TOKEN`.
|
||||
- `WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER` contains the public WhatsApp number
|
||||
users should message.
|
||||
|
||||
For Baileys, check that:
|
||||
|
||||
- `GATEWAY_WHATSAPP_INTAKE_MODE=baileys`.
|
||||
- The `whatsapp` Compose profile is running.
|
||||
- The bridge is paired and healthy.
|
||||
- You are messaging the account's Message Yourself chat.
|
||||
|
||||
## Slack
|
||||
|
||||
Check that:
|
||||
|
||||
- `GATEWAY_SLACK_ENABLED=TRUE`.
|
||||
- The Slack signing secret matches `GATEWAY_SLACK_SIGNING_SECRET`.
|
||||
- Slack Events API is enabled and subscribed to `app_mention`.
|
||||
- The Slack event request URL is public HTTPS and points to
|
||||
`/api/v1/gateway/webhooks/slack`.
|
||||
- The Slack app was reinstalled after scope or redirect URL changes.
|
||||
|
||||
## Discord
|
||||
|
||||
Check that:
|
||||
|
||||
- `GATEWAY_DISCORD_ENABLED=TRUE`.
|
||||
- The bot token is valid.
|
||||
- Message Content Intent is enabled.
|
||||
- The bot can view and send messages in the channel.
|
||||
- Exactly one backend process is running the Discord listener.
|
||||
- The Discord user is paired to a SurfSense user and search space.
|
||||
75
surfsense_web/content/docs/messaging-channels/whatsapp.mdx
Normal file
75
surfsense_web/content/docs/messaging-channels/whatsapp.mdx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
title: WhatsApp
|
||||
description: Enable SurfSense chat from WhatsApp
|
||||
---
|
||||
|
||||
# WhatsApp Messaging Channel
|
||||
|
||||
WhatsApp supports two intake modes:
|
||||
|
||||
- `cloud` uses the official Meta WhatsApp Cloud API with a SurfSense-owned system
|
||||
WhatsApp Business Account.
|
||||
- `baileys` uses the unofficial Baileys WebSocket bridge for self-hosted,
|
||||
one-tenant Message Yourself installs.
|
||||
|
||||
Use `cloud` for production and shared deployments.
|
||||
|
||||
## Meta Cloud API
|
||||
|
||||
Create a Meta app, provision a WhatsApp Business Account and phone number, and
|
||||
create a long-lived system user token with WhatsApp permissions.
|
||||
|
||||
Point the Meta app webhook to:
|
||||
|
||||
```text
|
||||
${GATEWAY_BASE_URL}/api/v1/gateway/webhooks/whatsapp
|
||||
```
|
||||
|
||||
Set the webhook verify token in Meta to the same value as
|
||||
`WHATSAPP_WEBHOOK_VERIFY_TOKEN`.
|
||||
|
||||
For Docker installs, add these to `docker/.env`. For manual installs, add them to
|
||||
`surfsense_backend/.env`.
|
||||
|
||||
```bash
|
||||
GATEWAY_WHATSAPP_INTAKE_MODE=cloud
|
||||
WHATSAPP_SHARED_BUSINESS_TOKEN=your-system-user-token
|
||||
WHATSAPP_SHARED_PHONE_NUMBER_ID=your-meta-phone-number-id
|
||||
WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER=15551234567
|
||||
WHATSAPP_SHARED_WABA_ID=your-waba-id
|
||||
WHATSAPP_GRAPH_API_VERSION=v25.0
|
||||
WHATSAPP_WEBHOOK_VERIFY_TOKEN=generate-a-long-random-secret
|
||||
WHATSAPP_WEBHOOK_APP_SECRET=your-meta-app-secret
|
||||
```
|
||||
|
||||
Users open **User Settings > Messaging Channels**, click **Pair WhatsApp**, and
|
||||
open the returned `wa.me` link. WhatsApp pre-fills `/start CODE`; the user must
|
||||
press send to bind the chat.
|
||||
|
||||
## Baileys Self-Hosted Mode
|
||||
|
||||
Baileys is unofficial. Use it only for single-tenant self-hosted installs where
|
||||
the operator accepts the risk of a personal WhatsApp session bridge.
|
||||
|
||||
```bash
|
||||
GATEWAY_WHATSAPP_INTAKE_MODE=baileys
|
||||
WHATSAPP_BRIDGE_URL=http://whatsapp-bridge:9929
|
||||
WHATSAPP_MODE=self-chat
|
||||
docker compose --profile whatsapp up -d
|
||||
```
|
||||
|
||||
After pairing, use WhatsApp's Message Yourself chat. The bridge only forwards
|
||||
messages from your own self-chat and ignores groups, other chats, and other
|
||||
people.
|
||||
|
||||
The `whatsapp-bridge` container stores Baileys auth state in the
|
||||
`surfsense-whatsapp-sessions` Docker volume. That volume contains account
|
||||
takeover material. Treat it like a secret.
|
||||
|
||||
To intentionally reset pairing:
|
||||
|
||||
```bash
|
||||
docker compose --profile whatsapp down
|
||||
docker volume rm surfsense-whatsapp-sessions
|
||||
docker compose --profile whatsapp up -d
|
||||
```
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"installation",
|
||||
"manual-installation",
|
||||
"docker-installation",
|
||||
"messaging-channels",
|
||||
"connectors",
|
||||
"how-to",
|
||||
"---Developers---",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Download,
|
||||
FlaskConical,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Radar,
|
||||
Unplug,
|
||||
Wrench,
|
||||
|
|
@ -27,6 +28,7 @@ const DOCS_ICONS: Record<string, React.ComponentType> = {
|
|||
Download,
|
||||
FlaskConical,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Radar,
|
||||
Unplug,
|
||||
Wrench,
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@
|
|||
"postgres": "^3.4.7",
|
||||
"posthog-js": "^1.336.1",
|
||||
"posthog-node": "^5.24.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.13.2",
|
||||
|
|
|
|||
12
surfsense_web/pnpm-lock.yaml
generated
12
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -302,6 +302,9 @@ importers:
|
|||
posthog-node:
|
||||
specifier: ^5.24.4
|
||||
version: 5.24.17
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@19.2.4)
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -7740,6 +7743,11 @@ packages:
|
|||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode.react@4.2.0:
|
||||
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
query-selector-shadow-dom@1.0.1:
|
||||
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
||||
|
||||
|
|
@ -16887,6 +16895,10 @@ snapshots:
|
|||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode.react@4.2.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
query-selector-shadow-dom@1.0.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue