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:
Rohan Verma 2026-06-01 13:04:57 -07:00 committed by GitHub
commit a80a9cb87c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 11660 additions and 37 deletions

View file

@ -55,6 +55,9 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# -- Redis exposed port (dev only; Redis is internal-only in prod) -- # -- Redis exposed port (dev only; Redis is internal-only in prod) --
# REDIS_PORT=6379 # REDIS_PORT=6379
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
# WHATSAPP_BRIDGE_PORT=9929
# -- Frontend Build Args -- # -- Frontend Build Args --
# In dev, the frontend is built from source and these are passed as 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. # 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 # ONLY set these if you are serving SurfSense on a real domain via a reverse
# proxy (e.g. Caddy, Nginx, Cloudflare Tunnel). # 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. # they are automatically derived from the port settings above.
# #
# NEXT_FRONTEND_URL=https://app.yourdomain.com # 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. # Only change this if you manage publications manually.
# ZERO_APP_PUBLICATIONS=zero_publication # 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. # 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 # Each sync worker needs at least 1 connection from both the UPSTREAM and CVR
# pools, so these constraints must hold: # 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 # SSL mode for database connections: disable, require, verify-ca, verify-full
# DB_SSLMODE=disable # 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. # DB_USER / DB_PASSWORD / DB_NAME / DB_HOST / DB_PORT settings above.
# Use this for managed databases (AWS RDS, GCP Cloud SQL, Supabase, etc.) # 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 # 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 # 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 # 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_TOKEN_BUYING_ENABLED=FALSE
# STRIPE_PREMIUM_TOKEN_PRICE_ID=price_... # STRIPE_PREMIUM_TOKEN_PRICE_ID=price_...
# STRIPE_CREDIT_MICROS_PER_UNIT=1000000 # 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) # 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 # 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. # SearXNG provides web search to all search spaces automatically.
# To access the SearXNG UI directly: http://localhost:8888 # To access the SearXNG UI directly: http://localhost:8888
@ -273,7 +313,7 @@ STT_SERVICE=local/base
# SEARXNG_SECRET=surfsense-searxng-secret # 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 # Set DAYTONA_SANDBOX_ENABLED=TRUE and provide credentials to give the agent
# an isolated code execution environment via the Daytona cloud API. # 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 # Premium turns are debited at the actual per-call provider cost reported
# by LiteLLM. Only applies to models with billing_tier=premium. # by LiteLLM. Only applies to models with billing_tier=premium.
# PREMIUM_CREDIT_MICROS_LIMIT=5000000 # 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). # Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
# QUOTA_MAX_RESERVE_MICROS=1000000 # QUOTA_MAX_RESERVE_MICROS=1000000
@ -376,10 +416,10 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# QUOTA_DEFAULT_PODCAST_RESERVE_MICROS=200000 # QUOTA_DEFAULT_PODCAST_RESERVE_MICROS=200000
# Per-video-presentation reservation for the video Celery task ($1.00 default). # 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 # 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 # Set TRUE to enable /free pages and anonymous chat API
NOLOGIN_MODE_ENABLED=FALSE NOLOGIN_MODE_ENABLED=FALSE
# ANON_TOKEN_LIMIT=1000000 # ANON_TOKEN_LIMIT=1000000

View file

@ -126,6 +126,7 @@ services:
- AUTH_TYPE=${AUTH_TYPE:-LOCAL} - AUTH_TYPE=${AUTH_TYPE:-LOCAL}
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000} - NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080} - 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 uncomment and set credentials to enable cloud code execution
# - DAYTONA_SANDBOX_ENABLED=TRUE # - DAYTONA_SANDBOX_ENABLED=TRUE
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-} # - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
@ -148,6 +149,25 @@ services:
retries: 30 retries: 30
start_period: 200s 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: celery_worker:
build: *backend-build build: *backend-build
volumes: volumes:
@ -282,3 +302,5 @@ volumes:
name: surfsense-dev-zero-cache name: surfsense-dev-zero-cache
zero_init: zero_init:
name: surfsense-dev-zero-init name: surfsense-dev-zero-init
whatsapp_sessions:
name: surfsense-dev-whatsapp-sessions

View file

@ -118,6 +118,7 @@ services:
UNSTRUCTURED_HAS_PATCHED_LOOP: "1" UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}} NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080} 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 uncomment and set credentials to enable cloud code execution
# DAYTONA_SANDBOX_ENABLED: "TRUE" # DAYTONA_SANDBOX_ENABLED: "TRUE"
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} # DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
@ -143,6 +144,26 @@ services:
retries: 30 retries: 30
start_period: 200s 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: celery_worker:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest} image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
volumes: volumes:
@ -264,6 +285,7 @@ services:
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted} 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} FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000}
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
@ -285,3 +307,5 @@ volumes:
name: surfsense-zero-cache name: surfsense-zero-cache
zero_init: zero_init:
name: surfsense-zero-init name: surfsense-zero-init
whatsapp_sessions:
name: surfsense-whatsapp-sessions

View file

@ -15,6 +15,27 @@ REDIS_APP_URL=redis://localhost:6379/0
# Optional: TTL in seconds for connector indexing lock key # Optional: TTL in seconds for connector indexing lock key
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800 # 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) # Platform Web Search (SearXNG)
# Set this to enable built-in web search. Docker Compose sets it automatically. # 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). # 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_CLIENT_SECRET=your_clickup_client_secret_here
CLICKUP_REDIRECT_URI=http://localhost:8000/api/v1/auth/clickup/connector/callback 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_ID=your_discord_client_id_here
DISCORD_CLIENT_SECRET=your_discord_client_secret_here DISCORD_CLIENT_SECRET=your_discord_client_secret_here
DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback
DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal 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 OAuth Configuration (Jira & Confluence)
ATLASSIAN_CLIENT_ID=your_atlassian_client_id_here 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_CLIENT_SECRET=your_notion_client_secret_here
NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback 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_ID=your_slack_client_id_here
SLACK_CLIENT_SECRET=your_slack_client_secret_here SLACK_CLIENT_SECRET=your_slack_client_secret_here
SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback 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 OAuth (Teams & OneDrive)
MICROSOFT_CLIENT_ID=your_microsoft_client_id_here MICROSOFT_CLIENT_ID=your_microsoft_client_id_here

View 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)

View file

@ -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.

View file

@ -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.

View file

@ -37,6 +37,18 @@ from app.config import (
) )
from app.db import User, create_db_and_tables, get_async_session from app.db import User, create_db_and_tables, get_async_session
from app.exceptions import GENERIC_5XX_MESSAGE, ISSUES_URL, SurfSenseError 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 import metrics as ot_metrics
from app.observability.bootstrap import init_otel, shutdown_otel from app.observability.bootstrap import init_otel, shutdown_otel
from app.rate_limiter import get_real_client_ip, limiter from app.rate_limiter import get_real_client_ip, limiter
@ -591,12 +603,19 @@ async def lifespan(app: FastAPI):
register_session_hooks() register_session_hooks()
log_system_snapshot("startup_complete") log_system_snapshot("startup_complete")
await start_gateway_inbox_worker()
await start_byo_long_poll_supervisors()
await start_discord_gateway_supervisor()
yield try:
yield
_stop_openrouter_background_refresh() finally:
await close_checkpointer() await stop_discord_gateway_supervisor()
shutdown_otel() await stop_byo_long_poll_supervisors()
await stop_gateway_inbox_worker()
_stop_openrouter_background_refresh()
await close_checkpointer()
shutdown_otel()
def registration_allowed(): def registration_allowed():

View file

@ -188,6 +188,7 @@ celery_app = Celery(
"app.tasks.celery_tasks.document_reindex_tasks", "app.tasks.celery_tasks.document_reindex_tasks",
"app.tasks.celery_tasks.stale_notification_cleanup_task", "app.tasks.celery_tasks.stale_notification_cleanup_task",
"app.tasks.celery_tasks.stripe_reconciliation_task", "app.tasks.celery_tasks.stripe_reconciliation_task",
"app.tasks.celery_tasks.gateway_tasks",
"app.automations.tasks.execute_run", "app.automations.tasks.execute_run",
"app.automations.triggers.builtin.schedule.selector", "app.automations.triggers.builtin.schedule.selector",
"app.automations.triggers.builtin.event.selector", "app.automations.triggers.builtin.event.selector",
@ -245,6 +246,9 @@ celery_app.conf.update(
"index_obsidian_attachment": {"queue": CONNECTORS_QUEUE}, "index_obsidian_attachment": {"queue": CONNECTORS_QUEUE},
# Everything else (document processing, podcasts, reindexing, # Everything else (document processing, podcasts, reindexing,
# schedule checker, cleanup) stays on the default fast queue. # 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, "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 # Fire due automation schedule triggers (Beat entry owned by the schedule
# trigger; see app.automations.triggers.builtin.schedule.source). # trigger; see app.automations.triggers.builtin.schedule.source).
**SCHEDULE_BEAT_SCHEDULE, **SCHEDULE_BEAT_SCHEDULE,

View file

@ -541,6 +541,45 @@ class Config:
# Backend URL to override the http to https in the OAuth redirect URI # Backend URL to override the http to https in the OAuth redirect URI
BACKEND_URL = os.getenv("BACKEND_URL") 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 checkout for pay-as-you-go page packs
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")

View file

@ -14,6 +14,7 @@ from sqlalchemy import (
TIMESTAMP, TIMESTAMP,
BigInteger, BigInteger,
Boolean, Boolean,
CheckConstraint,
Column, Column,
Enum as SQLAlchemyEnum, Enum as SQLAlchemyEnum,
ForeignKey, ForeignKey,
@ -587,6 +588,58 @@ class ChatVisibility(StrEnum):
# PUBLIC = "PUBLIC" # Reserved for future implementation # 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): class NewChatThread(BaseModel, TimestampMixin):
""" """
Thread model for the new chat feature using assistant-ui. 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. # agent_llm_id changes). Unindexed: all reads are by primary key.
pinned_llm_config_id = Column(Integer, nullable=True) 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 # Relationships
search_space = relationship("SearchSpace", back_populates="new_chat_threads") search_space = relationship("SearchSpace", back_populates="new_chat_threads")
created_by = relationship("User", 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", back_populates="thread",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
external_chat_binding = relationship(
"ExternalChatBinding",
foreign_keys=[external_chat_binding_id],
back_populates="threads",
)
class NewChatMessage(BaseModel, TimestampMixin): class NewChatMessage(BaseModel, TimestampMixin):
@ -732,6 +800,11 @@ class NewChatMessage(BaseModel, TimestampMixin):
# a message back to the LangGraph checkpoint that produced its turn. # a message back to the LangGraph checkpoint that produced its turn.
turn_id = Column(String(64), nullable=True, index=True) 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 # Relationships
thread = relationship("NewChatThread", back_populates="messages") thread = relationship("NewChatThread", back_populates="messages")
author = relationship("User") 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): class TokenUsage(BaseModel, TimestampMixin):
""" """
Tracks LLM token consumption per assistant turn. Tracks LLM token consumption per assistant turn.

View file

@ -0,0 +1,2 @@
"""Messaging gateway infrastructure for external chat channels."""

View 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()

View 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)

View 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

View file

@ -0,0 +1,2 @@
"""Base gateway interfaces."""

View 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")

View 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

View 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

View 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()

View 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."""

View 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

View 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

View file

@ -0,0 +1 @@
"""Discord gateway platform integration."""

View 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()

View 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}")

View 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}"
),
)

View 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

View 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")

View 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]

View 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()

View 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),
),
}

View 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

View 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

View 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

View 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}")

View 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})

View file

@ -0,0 +1 @@
"""Slack gateway integration."""

View 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()

View 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"),
}

View 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}"
),
)

View 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")

View file

@ -0,0 +1,2 @@
"""Telegram gateway adapter."""

View 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

View 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)

View 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,
)

View 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)

View 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")

View 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)

View file

@ -0,0 +1 @@
"""WhatsApp gateway implementations."""

View 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()

View 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

View 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")

View 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,
)

View 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,
}

View 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")

View 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")

View file

@ -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( def record_model_call_duration(
duration_ms: float, *, model: str | None, provider: str | None duration_ms: float, *, model: str | None, provider: str | None
) -> 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]: def _runtime_snapshot_value(key: str, transform: Any = None) -> list[Any]:
from opentelemetry.metrics import Observation from opentelemetry.metrics import Observation

View file

@ -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 .editor_routes import router as editor_router
from .export_routes import router as export_router from .export_routes import router as export_router
from .folders_routes import router as folders_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 ( from .google_calendar_add_connector_route import (
router as google_calendar_add_connector_router, router as google_calendar_add_connector_router,
) )
@ -69,6 +72,9 @@ router.include_router(editor_router)
router.include_router(export_router) router.include_router(export_router)
router.include_router(documents_router) router.include_router(documents_router)
router.include_router(folders_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(notes_router)
router.include_router(new_chat_router) # Chat with assistant-ui persistence router.include_router(new_chat_router) # Chat with assistant-ui persistence
router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_id} router.include_router(agent_revert_router) # POST /threads/{id}/revert/{action_id}

File diff suppressed because it is too large Load diff

View 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

View 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]}

View 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

View file

@ -87,6 +87,7 @@ dependencies = [
"opentelemetry-instrumentation-httpx>=0.61b0", "opentelemetry-instrumentation-httpx>=0.61b0",
"opentelemetry-instrumentation-celery>=0.61b0", "opentelemetry-instrumentation-celery>=0.61b0",
"opentelemetry-instrumentation-logging>=0.61b0", "opentelemetry-instrumentation-logging>=0.61b0",
"python-telegram-bot>=22.7",
"croniter>=2.0.0", "croniter>=2.0.0",
] ]

View file

@ -140,11 +140,11 @@ start_worker() {
if [ -n "${CELERY_QUEUES}" ]; then if [ -n "${CELERY_QUEUES}" ]; then
QUEUE_ARGS="--queues=${CELERY_QUEUES}" QUEUE_ARGS="--queues=${CELERY_QUEUES}"
else else
# When no queues specified, consume from BOTH the default queue and # When no queues specified, consume from the default, connectors, and
# the connectors queue. Without --queues, Celery only consumes from # gateway maintenance queues. Without --queues, Celery only consumes
# the default queue, leaving connector indexing tasks stuck. # from the default queue, leaving connector/gateway maintenance tasks stuck.
DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}" 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 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})..." echo "Starting Celery Worker (autoscale=${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}, max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD}, queues=${CELERY_QUEUES:-all})..."

View 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()))

View 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"]

View 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();
});

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

@ -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_")

View 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",
)

View file

@ -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()

View 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)

View 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"]

View 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

View 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

View file

@ -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]

View 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"

View 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

View file

@ -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 }, { 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]] [[package]]
name = "pytz" name = "pytz"
version = "2026.1.post1" version = "2026.1.post1"
@ -8183,6 +8196,7 @@ dependencies = [
{ name = "pypandoc-binary" }, { name = "pypandoc-binary" },
{ name = "pypdf" }, { name = "pypdf" },
{ name = "python-ffmpeg" }, { name = "python-ffmpeg" },
{ name = "python-telegram-bot" },
{ name = "redis" }, { name = "redis" },
{ name = "rerankers", extra = ["flashrank"] }, { name = "rerankers", extra = ["flashrank"] },
{ name = "sentence-transformers" }, { name = "sentence-transformers" },
@ -8280,6 +8294,7 @@ requires-dist = [
{ name = "pypandoc-binary", specifier = ">=1.16.2" }, { name = "pypandoc-binary", specifier = ">=1.16.2" },
{ name = "pypdf", specifier = ">=5.1.0" }, { name = "pypdf", specifier = ">=5.1.0" },
{ name = "python-ffmpeg", specifier = ">=2.0.12" }, { name = "python-ffmpeg", specifier = ">=2.0.12" },
{ name = "python-telegram-bot", specifier = ">=22.7" },
{ name = "redis", specifier = ">=5.2.1" }, { name = "redis", specifier = ">=5.2.1" },
{ name = "rerankers", extras = ["flashrank"], specifier = ">=0.7.1" }, { name = "rerankers", extras = ["flashrank"], specifier = ">=0.7.1" },
{ name = "sentence-transformers", specifier = ">=3.4.1" }, { name = "sentence-transformers", specifier = ">=3.4.1" },

View file

@ -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 &gt; Linked Devices &gt; 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>
);
}

View file

@ -5,6 +5,7 @@ import {
Keyboard, Keyboard,
KeyRound, KeyRound,
Library, Library,
MessageCircle,
Monitor, Monitor,
ReceiptText, ReceiptText,
ShieldCheck, ShieldCheck,
@ -29,7 +30,8 @@ export type UserSettingsTab =
| "agent-status" | "agent-status"
| "purchases" | "purchases"
| "desktop" | "desktop"
| "hotkeys"; | "hotkeys"
| "messaging-channels";
const DEFAULT_TAB: UserSettingsTab = "profile"; const DEFAULT_TAB: UserSettingsTab = "profile";
@ -83,6 +85,11 @@ export function UserSettingsLayoutShell({ searchSpaceId, children }: UserSetting
label: "Agent Status", label: "Agent Status",
icon: <Workflow className="h-4 w-4" />, 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, value: "purchases" as const,
label: "Purchase History", label: "Purchase History",

View file

@ -0,0 +1,6 @@
import { MessagingChannelsContent } from "../components/MessagingChannelsContent";
export default function Page() {
return <MessagingChannelsContent />;
}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ShieldCheck, Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -36,7 +36,6 @@ export const MCPTrustedTools: FC<MCPTrustedToolsProps> = ({ connector }) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2"> <h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Trusted Tools Trusted Tools
</h3> </h3>

View file

@ -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` | | 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` | | 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) ### Observability (optional)
| Variable | Description | | Variable | Description |
@ -187,9 +200,9 @@ Postgres. Before this design, a silent migration failure would leave
The backend exposes two endpoints: 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). 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 Returns 503 if not. The compose `backend.healthcheck` uses `/ready` so the
container only reports `healthy` once the schema is actually usable by container only reports `healthy` once the schema is actually usable by
zero-cache. 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) 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` ### 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 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 service blocks `zero-cache` from starting. If you see it, your stack
predates the fix or you brought up `zero-cache` manually with `docker predates the fix or you brought up `zero-cache` manually with `docker
compose up zero-cache` before the migrations service ran. compose up zero-cache` before the migrations service ran.

View file

@ -5,7 +5,7 @@ icon: BookOpen
--- ---
import { Card, Cards } from 'fumadocs-ui/components/card'; 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. 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" description="Integrate with third-party services"
href="/docs/connectors" href="/docs/connectors"
/> />
<Card
icon={<MessageCircle />}
title="Messaging Channels"
description="Chat with SurfSense from Telegram, WhatsApp, Slack, and Discord"
href="/docs/messaging-channels"
/>
<Card <Card
icon={<BookOpen />} icon={<BookOpen />}
title="How-To Guides" title="How-To Guides"

View file

@ -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: 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 ### 1. Environment Configuration
First, create and configure your environment variables by copying the example file: First, create and configure your environment variables by copying the example file:
@ -350,7 +359,7 @@ redis-cli ping
### 6. Start Celery Worker ### 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:** **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 # Make sure you're in the surfsense_backend directory
cd surfsense_backend 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}" 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:** **If using pip/venv:**
@ -374,9 +383,9 @@ source .venv/bin/activate # Linux/macOS
# OR # OR
.venv\Scripts\activate # Windows .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}" 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:** **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 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. 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 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 ## Frontend Setup
@ -675,7 +684,7 @@ To verify your installation:
1. Open your browser and navigate to `http://localhost:3000` 1. Open your browser and navigate to `http://localhost:3000`
2. Sign in with your Google account (or local credentials if `AUTH_TYPE=LOCAL`) 2. Sign in with your Google account (or local credentials if `AUTH_TYPE=LOCAL`)
3. Create a search space and try uploading a document 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 5. Test the chat functionality with your uploaded content
## Troubleshooting ## Troubleshooting

View 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.

View 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)

View 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>

View file

@ -0,0 +1,13 @@
{
"title": "Messaging Channels",
"icon": "MessageCircle",
"pages": [
"telegram",
"whatsapp",
"slack",
"discord",
"docker",
"troubleshooting"
],
"defaultOpen": false
}

View 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.

View 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.

View file

@ -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.

View 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
```

View file

@ -9,6 +9,7 @@
"installation", "installation",
"manual-installation", "manual-installation",
"docker-installation", "docker-installation",
"messaging-channels",
"connectors", "connectors",
"how-to", "how-to",
"---Developers---", "---Developers---",

View file

@ -7,6 +7,7 @@ import {
Download, Download,
FlaskConical, FlaskConical,
Heart, Heart,
MessageCircle,
Radar, Radar,
Unplug, Unplug,
Wrench, Wrench,
@ -27,6 +28,7 @@ const DOCS_ICONS: Record<string, React.ComponentType> = {
Download, Download,
FlaskConical, FlaskConical,
Heart, Heart,
MessageCircle,
Radar, Radar,
Unplug, Unplug,
Wrench, Wrench,

View file

@ -127,6 +127,7 @@
"postgres": "^3.4.7", "postgres": "^3.4.7",
"posthog-js": "^1.336.1", "posthog-js": "^1.336.1",
"posthog-node": "^5.24.4", "posthog-node": "^5.24.4",
"qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.3", "react": "^19.2.3",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",

View file

@ -302,6 +302,9 @@ importers:
posthog-node: posthog-node:
specifier: ^5.24.4 specifier: ^5.24.4
version: 5.24.17 version: 5.24.17
qrcode.react:
specifier: ^4.2.0
version: 4.2.0(react@19.2.4)
radix-ui: radix-ui:
specifier: ^1.4.3 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) 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==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} 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: query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
@ -16887,6 +16895,10 @@ snapshots:
punycode@2.3.1: {} 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: {} query-selector-shadow-dom@1.0.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}

Some files were not shown because too many files have changed in this diff Show more