feat(gateway): wire WhatsApp bridge runtime

This commit is contained in:
Anish Sarkar 2026-05-29 10:20:25 +05:30
parent 76a594ac60
commit 51bf2a8361
2 changed files with 102 additions and 17 deletions

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:3000}
# 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,23 @@ services:
retries: 30 retries: 30
start_period: 200s start_period: 200s
whatsapp-bridge:
build: ../surfsense_backend/scripts/whatsapp-bridge
profiles:
- whatsapp
volumes:
- whatsapp_sessions:/data/sessions
environment:
WHATSAPP_MODE: ${WHATSAPP_MODE:-self-chat}
WHATSAPP_SESSION_DIR: /data/sessions
mem_limit: 512m
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/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 +282,8 @@ 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_GATEWAY_WHATSAPP_INTAKE_MODE: ${GATEWAY_WHATSAPP_INTAKE_MODE:-disabled}
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 +305,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

@ -8,9 +8,17 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from app.config import config from app.config import config
from app.db import ExternalChatPlatform, ExternalChatAccount, async_session_maker from app.db import (
ExternalChatAccount,
ExternalChatAccountMode,
ExternalChatPlatform,
async_session_maker,
)
from app.gateway.accounts import account_token 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.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__) logger = logging.getLogger(__name__)
@ -42,37 +50,92 @@ async def _byo_account_supervisor(account_id: int, token: str) -> None:
await _sleep_or_shutdown(30) 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: 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 config.GATEWAY_TELEGRAM_INTAKE_MODE != "longpoll": if (
config.GATEWAY_TELEGRAM_INTAKE_MODE != "longpoll"
and config.GATEWAY_WHATSAPP_INTAKE_MODE != "baileys"
):
return return
if _tasks: if _tasks:
return return
_shutdown_event = asyncio.Event() _shutdown_event = asyncio.Event()
async with async_session_maker() as session: if config.GATEWAY_TELEGRAM_INTAKE_MODE == "longpoll":
result = await session.execute( async with async_session_maker() as session:
select(ExternalChatAccount).where( result = await session.execute(
ExternalChatAccount.platform == ExternalChatPlatform.TELEGRAM, select(ExternalChatAccount).where(
ExternalChatAccount.is_system_account.is_(False), ExternalChatAccount.platform == ExternalChatPlatform.TELEGRAM,
ExternalChatAccount.suspended_at.is_(None), ExternalChatAccount.is_system_account.is_(False),
ExternalChatAccount.suspended_at.is_(None),
)
) )
) accounts = list(result.scalars())
accounts = list(result.scalars())
for account in accounts: for account in accounts:
token = account_token(account) token = account_token(account)
if not token: if not token:
continue 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( task = asyncio.create_task(
_byo_account_supervisor(int(account.id), token), _whatsapp_baileys_supervisor(),
name=f"gateway-byo-telegram-{account.id}", name="gateway-byo-whatsapp-baileys",
) )
_tasks.add(task) _tasks.add(task)
task.add_done_callback(_tasks.discard) task.add_done_callback(_tasks.discard)
logger.info("Started BYO Telegram long-poll supervisor account_id=%s", account.id) logger.info("Started WhatsApp Baileys bridge intake supervisor")
async def stop_byo_long_poll_supervisors() -> None: async def stop_byo_long_poll_supervisors() -> None: