From a151e8f72998d7ff93601c997c1ec2d2cbcd28b9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:49:46 +0530 Subject: [PATCH] feat(gateway): enhance WhatsApp account management and connection handling --- .../app/routes/gateway_webhook_routes.py | 155 +++++- .../routes/gateway_whatsapp_baileys_routes.py | 2 + .../components/MessagingChannelsContent.tsx | 452 ++++++++++-------- 3 files changed, 399 insertions(+), 210 deletions(-) diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py index d4b574c26..a47bcd0c5 100644 --- a/surfsense_backend/app/routes/gateway_webhook_routes.py +++ b/surfsense_backend/app/routes/gateway_webhook_routes.py @@ -169,6 +169,10 @@ class UpdateBindingSearchSpaceRequest(BaseModel): search_space_id: int +class UpdateAccountSearchSpaceRequest(BaseModel): + search_space_id: int + + def _classify_telegram_event(payload: dict[str, Any]) -> str: if "message" in payload: return "message" @@ -704,21 +708,27 @@ async def list_bindings( @router.get("/connections") async def list_connections( + platform: ExternalChatPlatform | None = None, user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> list[dict[str, Any]]: + filters = [ + ExternalChatBinding.user_id == user.id, + ExternalChatBinding.state.in_( + [ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED] + ), + ] + if platform is not None: + filters.append(ExternalChatAccount.platform == platform) + result = await session.execute( select(ExternalChatBinding, ExternalChatAccount) .join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id) - .where( - ExternalChatBinding.user_id == user.id, - ExternalChatBinding.state.in_( - [ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED] - ), - ) + .where(*filters) ) connections: list[dict[str, Any]] = [] + baileys_account_ids: set[int] = set() for binding, account in result.all(): binding_metadata = binding.external_metadata or {} kind = str(binding_metadata.get("kind") or "") @@ -728,6 +738,10 @@ async def list_connections( account_state = account.cursor_state or {} workspace_name = None workspace_id = None + route_type = "binding" + connection_id = binding.id + search_space_id = binding.search_space_id + display_name = binding.external_display_name or binding.external_username if account.platform == ExternalChatPlatform.SLACK: workspace_name = account_state.get("team_name") workspace_id = account_state.get("team_id") @@ -737,17 +751,30 @@ async def list_connections( elif account.platform == ExternalChatPlatform.WHATSAPP: workspace_name = account_state.get("display_phone_number") workspace_id = account_state.get("phone_number_id") + if account.mode == ExternalChatAccountMode.SELF_HOST_BYO: + if int(account.id) in baileys_account_ids: + continue + baileys_account_ids.add(int(account.id)) + route_type = "account" + connection_id = account.id + search_space_id = account.owner_search_space_id or binding.search_space_id + display_name = "WhatsApp Bridge" connections.append( { - "id": binding.id, + "id": connection_id, + "account_id": account.id, + "route_type": route_type, "platform": account.platform.value, + "mode": account.mode.value, "state": binding.state.value, - "search_space_id": binding.search_space_id, - "display_name": binding.external_display_name - or binding.external_username - or workspace_name, - "external_username": binding.external_username, + "search_space_id": search_space_id, + "display_name": display_name or workspace_name, + "external_username": ( + None + if account.mode == ExternalChatAccountMode.SELF_HOST_BYO + else binding.external_username + ), "workspace_name": workspace_name, "workspace_id": workspace_id, "health_status": account.health_status.value, @@ -755,6 +782,37 @@ async def list_connections( } ) + if platform is None or platform == ExternalChatPlatform.WHATSAPP: + account_result = await session.execute( + select(ExternalChatAccount).where( + ExternalChatAccount.owner_user_id == user.id, + ExternalChatAccount.platform == ExternalChatPlatform.WHATSAPP, + ExternalChatAccount.mode == ExternalChatAccountMode.SELF_HOST_BYO, + ExternalChatAccount.owner_search_space_id.is_not(None), + ) + ) + for account in account_result.scalars(): + if int(account.id) in baileys_account_ids: + continue + account_state = account.cursor_state or {} + connections.append( + { + "id": account.id, + "account_id": account.id, + "route_type": "account", + "platform": account.platform.value, + "mode": account.mode.value, + "state": "bound", + "search_space_id": account.owner_search_space_id, + "display_name": "WhatsApp Bridge", + "external_username": None, + "workspace_name": account_state.get("display_phone_number"), + "workspace_id": account_state.get("phone_number_id"), + "health_status": account.health_status.value, + "suspended_reason": account.suspended_reason, + } + ) + return connections @@ -807,6 +865,44 @@ async def update_binding_search_space( return {"ok": True} +@router.patch("/accounts/{account_id}/search-space") +async def update_gateway_account_search_space( + account_id: int, + body: UpdateAccountSearchSpaceRequest, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> dict[str, bool]: + account = await session.get(ExternalChatAccount, account_id) + if ( + account is None + or account.owner_user_id != user.id + or account.platform != ExternalChatPlatform.WHATSAPP + or account.mode != ExternalChatAccountMode.SELF_HOST_BYO + ): + raise HTTPException(status_code=404, detail="Gateway account not found") + + await check_search_space_access(session, user, body.search_space_id) + account.owner_search_space_id = body.search_space_id + account.updated_at = datetime.now(UTC) + + result = await session.execute( + select(ExternalChatBinding).where( + ExternalChatBinding.account_id == account.id, + ExternalChatBinding.user_id == user.id, + ExternalChatBinding.state.in_( + [ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED] + ), + ) + ) + for binding in result.scalars(): + binding.search_space_id = body.search_space_id + binding.new_chat_thread_id = None + binding.updated_at = datetime.now(UTC) + + await session.commit() + return {"ok": True} + + @router.delete("/bindings/{binding_id}") async def delete_binding( binding_id: int, @@ -821,6 +917,41 @@ async def delete_binding( return {"ok": True} +@router.delete("/accounts/{account_id}") +async def delete_gateway_account( + account_id: int, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> dict[str, bool]: + account = await session.get(ExternalChatAccount, account_id) + if ( + account is None + or account.owner_user_id != user.id + or account.platform != ExternalChatPlatform.WHATSAPP + or account.mode != ExternalChatAccountMode.SELF_HOST_BYO + ): + raise HTTPException(status_code=404, detail="Gateway account not found") + + result = await session.execute( + select(ExternalChatBinding).where( + ExternalChatBinding.account_id == account.id, + ExternalChatBinding.user_id == user.id, + ExternalChatBinding.state.in_( + [ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED] + ), + ) + ) + for binding in result.scalars(): + revoke_binding(binding) + + account.owner_search_space_id = None + account.suspended_at = datetime.now(UTC) + account.suspended_reason = "disconnected" + account.updated_at = datetime.now(UTC) + await session.commit() + return {"ok": True} + + @router.post("/bindings/{binding_id}/resume") async def resume_external_chat_binding( binding_id: int, diff --git a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py index fa49d0558..5ab669503 100644 --- a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py +++ b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py @@ -85,6 +85,8 @@ async def request_pairing_code( 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) 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 ff6365499..0aa156980 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 @@ -1,11 +1,10 @@ "use client"; -import { MessageCircle, RefreshCw, ShieldAlert } from "lucide-react"; +import { RefreshCw, ShieldAlert } from "lucide-react"; import { useParams } from "next/navigation"; import { QRCodeSVG } from "qrcode.react"; -import { useCallback, useEffect, useState, useTransition } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -15,14 +14,19 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; 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; @@ -33,15 +37,6 @@ type GatewayConnection = { suspended_reason?: string | null; }; -type Platform = { - id: number; - platform: string; - mode: string; - bot_username?: string | null; - health_status: string; - last_health_check_at?: string | null; -}; - type Pairing = { binding_id: number; code: string; @@ -50,6 +45,7 @@ type Pairing = { }; type PairingPlatform = "telegram" | "whatsapp"; +type GatewayPlatform = PairingPlatform | "slack" | "discord"; type BaileysHealth = { status: string; @@ -66,31 +62,47 @@ export function MessagingChannelsContent() { const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true"; const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true"; const [connections, setConnections] = useState([]); - const [platforms, setPlatforms] = useState([]); const [searchSpaces, setSearchSpaces] = useState([]); const [pairing, setPairing] = useState(null); const [pairingPlatform, setPairingPlatform] = useState(null); const [baileysHealth, setBaileysHealth] = useState(null); - const [loading, setLoading] = useState(true); - const [isPending, startTransition] = useTransition(); + const [refreshingPlatform, setRefreshingPlatform] = useState(null); + + 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 refresh = useCallback(async () => { - setLoading(true); - const [connectionsRes, platformsRes, spaces] = await Promise.all([ - authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections`), - authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/platforms`), + const [nextConnections, spaces] = await Promise.all([ + fetchConnections(), searchSpacesApiService.getSearchSpaces(), ]); - setConnections(await connectionsRes.json()); - setPlatforms(await platformsRes.json()); + setConnections(nextConnections); setSearchSpaces(spaces); - setLoading(false); - }, []); + }, [fetchConnections]); 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`); @@ -111,7 +123,7 @@ export function MessagingChannelsContent() { }); setPairing(await res.json()); setPairingPlatform(platform); - await refresh(); + await refreshPlatform(platform); } async function installSlackGateway() { @@ -136,59 +148,77 @@ export function MessagingChannelsContent() { } } - function refreshBaileys() { - startTransition(async () => { - await refreshBaileysHealth(); - await refresh(); - }); + async function refreshBaileys() { + await refreshBaileysHealth(); + await refreshPlatform("whatsapp"); } - async function revoke(id: number) { - await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}`, { + 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 refresh(); + await refreshPlatform(connection.platform as GatewayPlatform); } - async function updateConnectionSearchSpace(id: number, nextSearchSpaceId: string) { + async function updateConnectionSearchSpace( + connection: GatewayConnection, + nextSearchSpaceId: string + ) { const previousConnections = connections; const parsedSearchSpaceId = Number(nextSearchSpaceId); + const targetKey = connectionKey(connection); setConnections((current) => current.map((connection) => - connection.id === id ? { ...connection, search_space_id: parsedSearchSpaceId } : connection + connectionKey(connection) === targetKey + ? { ...connection, search_space_id: parsedSearchSpaceId } + : connection ) ); - const res = await authenticatedFetch( - `${BACKEND_URL}/api/v1/gateway/bindings/${id}/search-space`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ search_space_id: parsedSearchSpaceId }), - } - ); + 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 refresh(); + await refreshPlatform(connection.platform as GatewayPlatform); } - async function resume(id: number) { - await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}/resume`, { + async function resume(connection: GatewayConnection) { + await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/resume`, { method: "POST", }); - await refresh(); + await refreshPlatform(connection.platform as GatewayPlatform); } - const telegram = platforms.find((p) => p.platform === "telegram"); - const whatsapp = platforms.find((p) => p.platform === "whatsapp"); - const slack = platforms.find((p) => p.platform === "slack"); - const discord = platforms.find((p) => p.platform === "discord"); const baileysQr = baileysHealth?.qr || null; - const currentSearchSpaceName = - searchSpaces.find((space) => space.id === searchSpaceId)?.name || "this search space"; + const hasTelegramConnection = connections.some( + (connection) => connection.platform === "telegram" + ); + const hasWhatsAppConnection = connections.some( + (connection) => connection.platform === "whatsapp" + ); + 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": @@ -204,16 +234,85 @@ export function MessagingChannelsContent() { } }; const connectionTitle = (connection: GatewayConnection) => - connection.workspace_name || - connection.display_name || - connection.external_username || - `${platformLabel(connection.platform)} connection`; + 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 + ); + + if (platformConnections.length === 0) { + return

{emptyText}

; + } + + return ( +
+

Connected accounts

+ {platformConnections.map((connection, index) => ( +
+ {index > 0 ? : null} +
+
+

{connectionTitle(connection)}

+ {connection.suspended_reason ? ( +

+ + {connection.suspended_reason} +

+ ) : null} +
+
+ + {connection.state === "suspended" ? ( + + ) : null} + +
+
+
+ ))} +
+ ); + }; const renderPairingPanel = (platform: PairingPlatform) => { if (!pairing || pairingPlatform !== platform) return null; return ( -
-

Pairing code

+
+

Pairing code

{pairing.code}

Open {platform === "whatsapp" ? "WhatsApp" : "Telegram"} pairing link @@ -227,136 +326,153 @@ export function MessagingChannelsContent() { }; return ( -
- - +
+ +
- - - Telegram - - - {telegram?.health_status ?? "not configured"} - + Telegram
-

- Pair a Telegram chat with this search space. Telegram conversations stay in Telegram and - are not mirrored in SurfSense chat history. -

+

Pair Telegram with this search space.

- +
- - + )} +
- {renderPairingPanel("telegram")} + {hasTelegramConnection ? null : renderPairingPanel("telegram")} + + {renderConnectionRows("telegram", "No Telegram chats connected yet.")}
{slackGatewayEnabled ? ( - - + +
- - - Slack Bot - - - {slack ? "enabled" : "not enabled"} - + Slack
-

- Enable the SurfSense Slack bot so teammates can mention it in Slack. This is separate - from the Slack search connector. +

+ Enable the SurfSense Slack bot so teammates can mention it in Slack.

- +
- - +
-

- New Slack workspace connections will route to {currentSearchSpaceName} first. You can - change each connection's search space below. -

+ + {renderConnectionRows("slack", "No Slack workspaces connected yet.")}
) : null} {discordGatewayEnabled ? ( - - + +
- - - Discord Bot - - - {discord ? "enabled" : "not enabled"} - + Discord
-

- Enable the SurfSense Discord bot so teammates can mention it in Discord. This is - separate from the Discord connector. +

+ Enable the SurfSense Discord bot so teammates can mention it in Discord.

- +
- - +
-

- New Discord server connections will route to {currentSearchSpaceName} first. You can - change each connection's search space below. -

+ + {renderConnectionRows("discord", "No Discord servers connected yet.")}
) : null} {whatsappMode !== "disabled" ? ( - - + +
- - - WhatsApp - - - {whatsapp?.health_status ?? "not configured"} - + 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." + : ""}

- + {whatsappMode === "cloud" ? (
- - {renderPairingPanel("whatsapp")} +
+ {hasWhatsAppConnection ? null : ( + + )} + +
+ {hasWhatsAppConnection ? null : renderPairingPanel("whatsapp")}
) : null} {whatsappMode === "baileys" ? (
-

- Self-hosted WhatsApp uses Message Yourself mode. After pairing, send messages in - your own WhatsApp chat with yourself; messages from other chats are ignored. -

- {baileysQr ? ( -
+

WhatsApp QR pairing

Scan this QR from WhatsApp > Linked Devices > Link a Device. @@ -376,71 +492,11 @@ export function MessagingChannelsContent() { ) : null}

) : null} + + {renderConnectionRows("whatsapp", "No WhatsApp chats connected yet.")} ) : null} - - - - Connected Messaging Channels -

- Choose which search space each external channel should use when messages arrive. -

-
- - {connections.length === 0 ? ( -

No messaging channels connected yet.

- ) : ( - connections.map((connection) => ( -
-
-

{connectionTitle(connection)}

-

- {platformLabel(connection.platform)} - {connection.external_username ? ` · ${connection.external_username}` : ""} - {connection.state ? ` · ${connection.state}` : ""} -

- {connection.suspended_reason ? ( -

- - {connection.suspended_reason} -

- ) : null} -
-
- - {connection.state === "suspended" ? ( - - ) : null} - -
-
- )) - )} -
-
); }