diff --git a/surfsense_backend/app/gateway/inbox_processor.py b/surfsense_backend/app/gateway/inbox_processor.py index d47206443..478c42a5e 100644 --- a/surfsense_backend/app/gateway/inbox_processor.py +++ b/surfsense_backend/app/gateway/inbox_processor.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.config import config from app.db import ( ExternalChatAccount, + ExternalChatAccountMode, ExternalChatBinding, ExternalChatBindingState, ExternalChatEventStatus, @@ -40,6 +41,21 @@ 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: @@ -293,6 +309,11 @@ async def _dispatch_inbound_event( 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) diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py index a47bcd0c5..5b0f57ed3 100644 --- a/surfsense_backend/app/routes/gateway_webhook_routes.py +++ b/surfsense_backend/app/routes/gateway_webhook_routes.py @@ -16,7 +16,7 @@ from uuid import UUID import httpx from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import JSONResponse, RedirectResponse, Response @@ -173,6 +173,21 @@ class UpdateAccountSearchSpaceRequest(BaseModel): search_space_id: int +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() + ) + + def _classify_telegram_event(payload: dict[str, Any]) -> str: if "message" in payload: return "message" @@ -712,6 +727,10 @@ async def list_connections( user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> list[dict[str, Any]]: + active_whatsapp_mode = _active_whatsapp_account_mode() + if platform == ExternalChatPlatform.WHATSAPP and active_whatsapp_mode is None: + return [] + filters = [ ExternalChatBinding.user_id == user.id, ExternalChatBinding.state.in_( @@ -720,6 +739,17 @@ async def list_connections( ] if platform is not None: 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, + ) + ) result = await session.execute( select(ExternalChatBinding, ExternalChatAccount) @@ -782,7 +812,10 @@ async def list_connections( } ) - if platform is None or platform == ExternalChatPlatform.WHATSAPP: + if ( + active_whatsapp_mode == ExternalChatAccountMode.SELF_HOST_BYO + and (platform is None or platform == ExternalChatPlatform.WHATSAPP) + ): account_result = await session.execute( select(ExternalChatAccount).where( ExternalChatAccount.owner_user_id == user.id, @@ -855,6 +888,9 @@ async def update_binding_search_space( ExternalChatBindingState.SUSPENDED, }: raise HTTPException(status_code=400, detail="Only active bindings can be routed") + account = await session.get(ExternalChatAccount, binding.account_id) + if account is None or _is_inactive_whatsapp_account(account): + raise HTTPException(status_code=404, detail="Binding not found") await check_search_space_access(session, user, body.search_space_id) if binding.search_space_id != body.search_space_id: @@ -878,6 +914,7 @@ async def update_gateway_account_search_space( or account.owner_user_id != user.id or account.platform != ExternalChatPlatform.WHATSAPP or account.mode != ExternalChatAccountMode.SELF_HOST_BYO + or _is_inactive_whatsapp_account(account) ): raise HTTPException(status_code=404, detail="Gateway account not found") @@ -912,6 +949,9 @@ async def delete_binding( binding = await session.get(ExternalChatBinding, binding_id) if binding is None or binding.user_id != user.id: raise HTTPException(status_code=404, detail="Binding not found") + account = await session.get(ExternalChatAccount, binding.account_id) + if account is None or _is_inactive_whatsapp_account(account): + raise HTTPException(status_code=404, detail="Binding not found") revoke_binding(binding) await session.commit() return {"ok": True} @@ -929,6 +969,7 @@ async def delete_gateway_account( or account.owner_user_id != user.id or account.platform != ExternalChatPlatform.WHATSAPP or account.mode != ExternalChatAccountMode.SELF_HOST_BYO + or _is_inactive_whatsapp_account(account) ): raise HTTPException(status_code=404, detail="Gateway account not found") @@ -961,6 +1002,9 @@ async def resume_external_chat_binding( binding = await session.get(ExternalChatBinding, binding_id) if binding is None or binding.user_id != user.id: raise HTTPException(status_code=404, detail="Binding not found") + account = await session.get(ExternalChatAccount, binding.account_id) + if account is None or _is_inactive_whatsapp_account(account): + raise HTTPException(status_code=404, detail="Binding not found") resume_binding(binding) binding.updated_at = datetime.now(UTC) await session.commit() 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 0aa156980..9aa97c816 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 @@ -208,12 +208,18 @@ export function MessagingChannelsContent() { 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" + (connection) => connection.platform === "whatsapp" && isConnectionInActiveMode(connection) ); const isRefreshing = (platform: GatewayPlatform) => refreshingPlatform === platform; const refreshButtonClassName = "gap-2"; @@ -242,7 +248,7 @@ export function MessagingChannelsContent() { `${platformLabel(connection.platform)} connection`; const renderConnectionRows = (platform: GatewayConnection["platform"], emptyText: string) => { const platformConnections = connections.filter( - (connection) => connection.platform === platform + (connection) => connection.platform === platform && isConnectionInActiveMode(connection) ); if (platformConnections.length === 0) { @@ -327,7 +333,7 @@ export function MessagingChannelsContent() { return (
- +
Telegram @@ -360,7 +366,7 @@ export function MessagingChannelsContent() { {slackGatewayEnabled ? ( - +
Slack @@ -392,7 +398,7 @@ export function MessagingChannelsContent() { ) : null} {discordGatewayEnabled ? ( - +
Discord @@ -424,16 +430,15 @@ export function MessagingChannelsContent() { ) : null} {whatsappMode !== "disabled" ? ( - +
WhatsApp

- Pair this search space with WhatsApp using the configured gateway mode. {whatsappMode === "baileys" - ? " Send messages to your own WhatsApp chat. Other chats are ignored." - : ""} + ? "Use the WhatsApp bridge for your own WhatsApp chat. Other chats are ignored." + : "Pair this search space with WhatsApp Cloud API."}

@@ -469,7 +474,7 @@ export function MessagingChannelsContent() { disabled={isRefreshing("whatsapp")} > - Refresh WhatsApp Bridge + Refresh {baileysQr ? (