feat(gateway): introduce GATEWAY_TELEGRAM_INTAKE_MODE for Telegram integration

This commit is contained in:
Anish Sarkar 2026-05-28 05:02:07 +05:30
parent 7ff0120fc9
commit c958fe5bc6
5 changed files with 53 additions and 14 deletions

View file

@ -17,10 +17,12 @@ REDIS_APP_URL=redis://localhost:6379/0
# Telegram Gateway # Telegram Gateway
# TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, _ or - # 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_TOKEN=
TELEGRAM_SHARED_BOT_USERNAME= TELEGRAM_SHARED_BOT_USERNAME=
TELEGRAM_WEBHOOK_SECRET= TELEGRAM_WEBHOOK_SECRET=
GATEWAY_BASE_URL=http://localhost:8000 GATEWAY_BASE_URL=http://localhost:8000
GATEWAY_TELEGRAM_INTAKE_MODE=webhook
# 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.

View file

@ -546,9 +546,13 @@ class Config:
TELEGRAM_SHARED_BOT_USERNAME = os.getenv("TELEGRAM_SHARED_BOT_USERNAME") TELEGRAM_SHARED_BOT_USERNAME = os.getenv("TELEGRAM_SHARED_BOT_USERNAME")
TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET") TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET")
GATEWAY_BASE_URL = os.getenv("GATEWAY_BASE_URL", BACKEND_URL) GATEWAY_BASE_URL = os.getenv("GATEWAY_BASE_URL", BACKEND_URL)
GATEWAY_BYO_LONGPOLL_ENABLED = ( GATEWAY_TELEGRAM_INTAKE_MODE = os.getenv(
os.getenv("GATEWAY_BYO_LONGPOLL_ENABLED", "TRUE").upper() == "TRUE" "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"
)
# 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")

View file

@ -46,7 +46,7 @@ async def start_byo_long_poll_supervisors() -> None:
"""Start one BYO long-poll supervisor per active non-system Telegram account.""" """Start one BYO long-poll supervisor per active non-system Telegram account."""
global _shutdown_event global _shutdown_event
if not config.GATEWAY_BYO_LONGPOLL_ENABLED: if config.GATEWAY_TELEGRAM_INTAKE_MODE != "longpoll":
return return
if _tasks: if _tasks:
return return

View file

@ -38,8 +38,8 @@ async def cleanup_supervisors():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_byo_long_poll_noops_when_flag_off(monkeypatch): async def test_start_byo_long_poll_noops_when_mode_is_webhook(monkeypatch):
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_BYO_LONGPOLL_ENABLED", False) monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "webhook")
await byo_long_poll.start_byo_long_poll_supervisors() await byo_long_poll.start_byo_long_poll_supervisors()
@ -48,7 +48,7 @@ async def test_start_byo_long_poll_noops_when_flag_off(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatch): async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatch):
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_BYO_LONGPOLL_ENABLED", True) monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll")
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.execute.return_value = ScalarResult([]) session.execute.return_value = ScalarResult([])
monkeypatch.setattr( monkeypatch.setattr(
@ -64,7 +64,7 @@ async def test_start_byo_long_poll_noops_when_no_byo_accounts(mocker, monkeypatc
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_byo_long_poll_spawns_one_supervisor_per_account(mocker, monkeypatch): async def test_start_byo_long_poll_spawns_one_supervisor_per_account(mocker, monkeypatch):
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_BYO_LONGPOLL_ENABLED", True) monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll")
accounts = [mocker.Mock(id=1), mocker.Mock(id=2)] accounts = [mocker.Mock(id=1), mocker.Mock(id=2)]
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.execute.return_value = ScalarResult(accounts) session.execute.return_value = ScalarResult(accounts)
@ -108,7 +108,7 @@ async def test_supervisor_retries_after_run_returns(mocker, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch): async def test_shutdown_cancels_running_supervisors(mocker, monkeypatch):
monkeypatch.setattr(byo_long_poll.config, "GATEWAY_BYO_LONGPOLL_ENABLED", True) monkeypatch.setattr(byo_long_poll.config, "GATEWAY_TELEGRAM_INTAKE_MODE", "longpoll")
session = mocker.AsyncMock() session = mocker.AsyncMock()
session.execute.return_value = ScalarResult([mocker.Mock(id=1)]) session.execute.return_value = ScalarResult([mocker.Mock(id=1)])
monkeypatch.setattr( monkeypatch.setattr(

View file

@ -39,6 +39,39 @@ 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: Telegram External Chat Surface
SurfSense can expose the same canonical chat agent through Telegram. The `external_chat_*` tables store adapter identity, delivery configuration, and durable inbox rows. The actual chat thread and messages remain in `new_chat_threads` and `new_chat_messages`, and all chat-message sources are eligible for Zero replication so a future SurfSense UI layer can render external chat surfaces. The web app initially shows pairing, health, revoke, and resume controls under **User Settings > Messaging Channels**.
Add these variables to `surfsense_backend/.env` when enabling the Telegram surface:
```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
```
`GATEWAY_TELEGRAM_INTAKE_MODE` must be `webhook`, `longpoll`, or `disabled`. Use `webhook` for production/SaaS deployments, `longpoll` only for single-replica self-host installs that cannot expose a public HTTPS webhook, and `disabled` to skip Telegram intake. `TELEGRAM_WEBHOOK_SECRET` must use only `A-Z`, `a-z`, `0-9`, `_`, and `-` characters. `REDIS_APP_URL` is reused for external chat rate limits and per-thread locks. The webhook URL shape is:
```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
```
Keep the FastAPI backend, Celery worker, and Celery beat running. Telegram webhooks write inbound updates into `external_chat_inbound_events`. The FastAPI process owns external chat inbox processing and runs the same SurfSense agent used by web UI chats, then replies back to Telegram. Celery remains maintenance-only for external chat reconciliation, health checks, and retention sweeps. There is no separate gateway service, `SERVICE_ROLE=gateway` process, or Celery agent-processing path.
For self-hosted BYO Telegram bots without a public HTTPS URL, set `GATEWAY_TELEGRAM_INTAKE_MODE=longpoll`. The FastAPI process starts one lifespan long-poll supervisor per non-system Telegram account and writes updates into the same durable inbox. The FastAPI inbox worker then processes those rows in-process through the canonical `new_chat_*` surface. This fallback is intended for single-replica self-hosted installs. For SaaS-style multi-replica deployments, prefer public webhooks and keep `GATEWAY_TELEGRAM_INTAKE_MODE=webhook` so API replicas skip BYO polling entirely. Telegram does not allow `get_updates()` while a webhook is active, so delete any existing webhook for a BYO bot before relying on long polling.
When upgrading from an older gateway-runner deployment, apply the rewritten migration 144 external chat schema, deploy the new backend, worker, and beat images, then stop the old `gateway` service. Wait about 30 seconds for any old Telegram `getUpdates` long-poll request to release its advisory lock before starting the new API process. Register each webhook again with the account-id URL above and the per-account `webhook_secret`. If you roll back before using the migration in production, restore the old image and downgrade the schema first.
### 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 +383,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 +391,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 +407,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:**