From 799a83239f40ded5d731bb417b986078e18833d3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:58:28 +0530 Subject: [PATCH] feat(gateway): add Slack and Telegram gateway configuration and enablement checks --- docker/docker-compose.yml | 1 - surfsense_backend/.env.example | 5 +- surfsense_backend/app/config/__init__.py | 1 + .../app/routes/gateway_webhook_routes.py | 79 ++++++++-- surfsense_web/.env.example | 8 -- .../components/MessagingChannelsContent.tsx | 136 +++++++++++++----- 6 files changed, 168 insertions(+), 62 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 66ad55b77..11f4fdb5c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -285,7 +285,6 @@ services: NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted} - NEXT_PUBLIC_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} labels: diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 170bece32..808d29051 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -17,7 +17,7 @@ REDIS_APP_URL=redis://localhost:6379/0 # 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 +# 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= @@ -25,7 +25,7 @@ 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: `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= @@ -149,6 +149,7 @@ NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback SLACK_CLIENT_ID=your_slack_client_id_here SLACK_CLIENT_SECRET=your_slack_client_secret_here SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback +GATEWAY_SLACK_ENABLED=FALSE GATEWAY_SLACK_SIGNING_SECRET=your_slack_signing_secret_here GATEWAY_SLACK_REDIRECT_URI=http://localhost:8000/api/v1/gateway/slack/callback diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 98c1d5dec..f3c05f2d6 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -572,6 +572,7 @@ class Config: ) 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 = ( diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py index 5b0f57ed3..9c890b610 100644 --- a/surfsense_backend/app/routes/gateway_webhook_routes.py +++ b/surfsense_backend/app/routes/gateway_webhook_routes.py @@ -188,6 +188,36 @@ def _is_inactive_whatsapp_account(account: ExternalChatAccount) -> bool: ) +def _telegram_gateway_enabled() -> bool: + return ( + config.GATEWAY_TELEGRAM_INTAKE_MODE != "disabled" + and bool(config.TELEGRAM_SHARED_BOT_TOKEN) + and bool(config.TELEGRAM_SHARED_BOT_USERNAME) + and ( + config.GATEWAY_TELEGRAM_INTAKE_MODE != "webhook" + or bool(config.TELEGRAM_WEBHOOK_SECRET) + ) + ) + + +def _slack_gateway_enabled() -> bool: + return bool( + config.GATEWAY_SLACK_ENABLED + and config.GATEWAY_SLACK_CLIENT_ID + and config.GATEWAY_SLACK_CLIENT_SECRET + and config.GATEWAY_SLACK_SIGNING_SECRET + ) + + +def _discord_gateway_enabled() -> bool: + return bool( + config.GATEWAY_DISCORD_ENABLED + and config.DISCORD_CLIENT_ID + and config.DISCORD_CLIENT_SECRET + and config.DISCORD_BOT_TOKEN + ) + + def _classify_telegram_event(payload: dict[str, Any]) -> str: if "message" in payload: return "message" @@ -208,7 +238,7 @@ async def install_slack_gateway( user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> dict[str, str]: - if not config.GATEWAY_SLACK_CLIENT_ID: + if not _slack_gateway_enabled(): raise HTTPException(status_code=500, detail="Slack gateway OAuth is not configured") await check_search_space_access(session, user, search_space_id) state = _get_state_manager().generate_secure_state(search_space_id, user.id) @@ -242,7 +272,7 @@ async def slack_gateway_callback( return _slack_frontend_redirect(space_id or 0, error="slack_gateway_oauth_denied") if not code or state_data is None: raise HTTPException(status_code=400, detail="Invalid Slack gateway OAuth callback") - if not config.GATEWAY_SLACK_CLIENT_ID or not config.GATEWAY_SLACK_CLIENT_SECRET: + if not _slack_gateway_enabled(): raise HTTPException(status_code=500, detail="Slack gateway OAuth is not configured") user_id = UUID(state_data["user_id"]) @@ -357,7 +387,7 @@ async def install_discord_gateway( user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> dict[str, str]: - if not config.DISCORD_CLIENT_ID: + if not _discord_gateway_enabled(): raise HTTPException(status_code=500, detail="Discord gateway OAuth is not configured") await check_search_space_access(session, user, search_space_id) state = _get_state_manager().generate_secure_state(search_space_id, user.id) @@ -393,10 +423,8 @@ async def discord_gateway_callback( return _discord_frontend_redirect(space_id or 0, error="discord_gateway_oauth_denied") if not code or state_data is None: raise HTTPException(status_code=400, detail="Invalid Discord gateway OAuth callback") - if not config.DISCORD_CLIENT_ID or not config.DISCORD_CLIENT_SECRET: + if not _discord_gateway_enabled(): raise HTTPException(status_code=500, detail="Discord gateway OAuth is not configured") - if not config.DISCORD_BOT_TOKEN: - raise HTTPException(status_code=500, detail="Discord gateway bot token is not configured") user_id = UUID(state_data["user_id"]) token_payload = { @@ -518,6 +546,9 @@ async def slack_webhook( request: Request, session: AsyncSession = Depends(get_async_session), ) -> Response: + if not _slack_gateway_enabled(): + return Response(status_code=200) + body = await request.body() if not verify_slack_signature( signing_secret=config.GATEWAY_SLACK_SIGNING_SECRET or "", @@ -594,6 +625,9 @@ async def telegram_webhook( account_id: int, session: AsyncSession = Depends(get_async_session), ) -> Response: + if not _telegram_gateway_enabled(): + return Response(status_code=200) + request_id = f"gateway_{uuid.uuid4().hex[:16]}" try: payload = await request.json() @@ -644,6 +678,8 @@ async def start_binding( await check_search_space_access(session, user, body.search_space_id) code = generate_pairing_code() if body.platform == ExternalChatPlatform.TELEGRAM: + if not _telegram_gateway_enabled(): + raise HTTPException(status_code=400, detail="Telegram gateway is disabled") account = await get_or_create_system_telegram_account(session) username = account.bot_username or config.TELEGRAM_SHARED_BOT_USERNAME if not username: @@ -730,6 +766,8 @@ async def list_connections( active_whatsapp_mode = _active_whatsapp_account_mode() if platform == ExternalChatPlatform.WHATSAPP and active_whatsapp_mode is None: return [] + if platform == ExternalChatPlatform.TELEGRAM and not _telegram_gateway_enabled(): + return [] filters = [ ExternalChatBinding.user_id == user.id, @@ -741,15 +779,18 @@ async def list_connections( filters.append(ExternalChatAccount.platform == platform) if platform == ExternalChatPlatform.WHATSAPP and active_whatsapp_mode is not None: filters.append(ExternalChatAccount.mode == active_whatsapp_mode) - elif active_whatsapp_mode is None: - filters.append(ExternalChatAccount.platform != ExternalChatPlatform.WHATSAPP) else: - filters.append( - or_( - ExternalChatAccount.platform != ExternalChatPlatform.WHATSAPP, - ExternalChatAccount.mode == active_whatsapp_mode, + if not _telegram_gateway_enabled(): + filters.append(ExternalChatAccount.platform != ExternalChatPlatform.TELEGRAM) + if active_whatsapp_mode is None: + filters.append(ExternalChatAccount.platform != ExternalChatPlatform.WHATSAPP) + else: + filters.append( + or_( + ExternalChatAccount.platform != ExternalChatPlatform.WHATSAPP, + ExternalChatAccount.mode == active_whatsapp_mode, + ) ) - ) result = await session.execute( select(ExternalChatBinding, ExternalChatAccount) @@ -873,6 +914,18 @@ async def list_platforms( ] +@router.get("/config") +async def get_gateway_config( + user: User = Depends(current_active_user), +) -> dict[str, bool | str]: + return { + "telegram_enabled": _telegram_gateway_enabled(), + "whatsapp_intake_mode": config.GATEWAY_WHATSAPP_INTAKE_MODE, + "slack_enabled": _slack_gateway_enabled(), + "discord_enabled": _discord_gateway_enabled(), + } + + @router.patch("/bindings/{binding_id}/search-space") async def update_binding_search_space( binding_id: int, diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index e4aaf91d7..5fb9d07d1 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -7,14 +7,6 @@ NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848 -# Messaging gateway options -# WhatsApp UI toggle: disabled, cloud, or baileys -NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE=disabled -# Slack gateway UI toggle: true or false -NEXT_PUBLIC_GATEWAY_SLACK_ENABLED=false -# Discord gateway UI toggle: true or false -NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED=false - # Contact Form Vars (optional) DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx index 9aa97c816..b0cb6699c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx @@ -15,6 +15,7 @@ import { 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"; @@ -37,6 +38,15 @@ type GatewayConnection = { 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; @@ -58,15 +68,18 @@ type BaileysHealth = { export function MessagingChannelsContent() { const params = useParams<{ search_space_id: string }>(); const searchSpaceId = Number(params.search_space_id); - const whatsappMode = process.env.NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE ?? "disabled"; - const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true"; - const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true"; + const [gatewayConfig, setGatewayConfig] = useState(null); const [connections, setConnections] = useState([]); const [searchSpaces, setSearchSpaces] = useState([]); const [pairing, setPairing] = useState(null); const [pairingPlatform, setPairingPlatform] = useState(null); const [baileysHealth, setBaileysHealth] = useState(null); const [refreshingPlatform, setRefreshingPlatform] = useState(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)}` : ""; @@ -74,14 +87,21 @@ export function MessagingChannelsContent() { 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] = await Promise.all([ + const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([ fetchConnections(), searchSpacesApiService.getSearchSpaces(), + fetchGatewayConfig(), ]); setConnections(nextConnections); setSearchSpaces(spaces); - }, [fetchConnections]); + setGatewayConfig(nextGatewayConfig); + }, [fetchConnections, fetchGatewayConfig]); useEffect(() => { void refresh(); @@ -221,6 +241,11 @@ export function MessagingChannelsContent() { 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) => @@ -252,7 +277,11 @@ export function MessagingChannelsContent() { ); if (platformConnections.length === 0) { - return

{emptyText}

; + return ( +
+

{emptyText}

+
+ ); } return ( @@ -330,40 +359,71 @@ export function MessagingChannelsContent() { ); }; + const renderGatewaySkeletons = () => ( + <> + {[0, 1].map((index) => ( + + + + + + + + + + + + ))} + + ); return (
- - -
- Telegram -
-

Pair Telegram with this search space.

-
- -
- {hasTelegramConnection ? null : ( - - )} - -
+ {isGatewayConfigLoading ? renderGatewaySkeletons() : null} - {hasTelegramConnection ? null : renderPairingPanel("telegram")} - - {renderConnectionRows("telegram", "No Telegram chats connected yet.")} -
-
+ {!isGatewayConfigLoading && !hasEnabledGateway ? ( + + + No messaging gateways enabled + + + ) : null} + + {telegramGatewayEnabled ? ( + + +
+ Telegram +
+

+ Connect Telegram to chat with SurfSense. +

+
+ +
+ {hasTelegramConnection ? null : ( + + )} + +
+ + {hasTelegramConnection ? null : renderPairingPanel("telegram")} + + {renderConnectionRows("telegram", "No Telegram chats connected yet.")} +
+
+ ) : null} {slackGatewayEnabled ? ( @@ -437,8 +497,8 @@ export function MessagingChannelsContent() {

{whatsappMode === "baileys" - ? "Use the WhatsApp bridge for your own WhatsApp chat. Other chats are ignored." - : "Pair this search space with WhatsApp Cloud API."} + ? 'Use "Message Yourself". Other chats are ignored.' + : "Connect WhatsApp to chat with Surfsense."}