feat(gateway): improve WhatsApp account mode handling and connection filtering

This commit is contained in:
Anish Sarkar 2026-06-01 23:08:56 +05:30
parent a151e8f729
commit fc2467be3d
3 changed files with 82 additions and 12 deletions

View file

@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import config from app.config import config
from app.db import ( from app.db import (
ExternalChatAccount, ExternalChatAccount,
ExternalChatAccountMode,
ExternalChatBinding, ExternalChatBinding,
ExternalChatBindingState, ExternalChatBindingState,
ExternalChatEventStatus, ExternalChatEventStatus,
@ -40,6 +41,21 @@ def _dashboard_url() -> str:
return config.NEXT_FRONTEND_URL or "/dashboard" 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( async def claim_next_inbound_event(
session_maker: SessionMaker = async_session_maker, session_maker: SessionMaker = async_session_maker,
) -> int | None: ) -> int | None:
@ -293,6 +309,11 @@ async def _dispatch_inbound_event(
event.last_error = "account_missing" event.last_error = "account_missing"
await session.commit() await session.commit()
return return
if _is_inactive_whatsapp_account(account):
event.status = ExternalChatEventStatus.IGNORED
event.last_error = "inactive_whatsapp_mode"
await session.commit()
return
try: try:
bundle = resolve_platform_bundle(account) bundle = resolve_platform_bundle(account)

View file

@ -16,7 +16,7 @@ from uuid import UUID
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import JSONResponse, RedirectResponse, Response from starlette.responses import JSONResponse, RedirectResponse, Response
@ -173,6 +173,21 @@ class UpdateAccountSearchSpaceRequest(BaseModel):
search_space_id: int 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: def _classify_telegram_event(payload: dict[str, Any]) -> str:
if "message" in payload: if "message" in payload:
return "message" return "message"
@ -712,6 +727,10 @@ async def list_connections(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
active_whatsapp_mode = _active_whatsapp_account_mode()
if platform == ExternalChatPlatform.WHATSAPP and active_whatsapp_mode is None:
return []
filters = [ filters = [
ExternalChatBinding.user_id == user.id, ExternalChatBinding.user_id == user.id,
ExternalChatBinding.state.in_( ExternalChatBinding.state.in_(
@ -720,6 +739,17 @@ async def list_connections(
] ]
if platform is not None: if platform is not None:
filters.append(ExternalChatAccount.platform == platform) 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( result = await session.execute(
select(ExternalChatBinding, ExternalChatAccount) 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( account_result = await session.execute(
select(ExternalChatAccount).where( select(ExternalChatAccount).where(
ExternalChatAccount.owner_user_id == user.id, ExternalChatAccount.owner_user_id == user.id,
@ -855,6 +888,9 @@ async def update_binding_search_space(
ExternalChatBindingState.SUSPENDED, ExternalChatBindingState.SUSPENDED,
}: }:
raise HTTPException(status_code=400, detail="Only active bindings can be routed") 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) await check_search_space_access(session, user, body.search_space_id)
if binding.search_space_id != 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.owner_user_id != user.id
or account.platform != ExternalChatPlatform.WHATSAPP or account.platform != ExternalChatPlatform.WHATSAPP
or account.mode != ExternalChatAccountMode.SELF_HOST_BYO or account.mode != ExternalChatAccountMode.SELF_HOST_BYO
or _is_inactive_whatsapp_account(account)
): ):
raise HTTPException(status_code=404, detail="Gateway account not found") 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) binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id: if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found") 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) revoke_binding(binding)
await session.commit() await session.commit()
return {"ok": True} return {"ok": True}
@ -929,6 +969,7 @@ async def delete_gateway_account(
or account.owner_user_id != user.id or account.owner_user_id != user.id
or account.platform != ExternalChatPlatform.WHATSAPP or account.platform != ExternalChatPlatform.WHATSAPP
or account.mode != ExternalChatAccountMode.SELF_HOST_BYO or account.mode != ExternalChatAccountMode.SELF_HOST_BYO
or _is_inactive_whatsapp_account(account)
): ):
raise HTTPException(status_code=404, detail="Gateway account not found") 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) binding = await session.get(ExternalChatBinding, binding_id)
if binding is None or binding.user_id != user.id: if binding is None or binding.user_id != user.id:
raise HTTPException(status_code=404, detail="Binding not found") 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) resume_binding(binding)
binding.updated_at = datetime.now(UTC) binding.updated_at = datetime.now(UTC)
await session.commit() await session.commit()

View file

@ -208,12 +208,18 @@ export function MessagingChannelsContent() {
await refreshPlatform(connection.platform as GatewayPlatform); 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 baileysQr = baileysHealth?.qr || null;
const hasTelegramConnection = connections.some( const hasTelegramConnection = connections.some(
(connection) => connection.platform === "telegram" (connection) => connection.platform === "telegram"
); );
const hasWhatsAppConnection = connections.some( const hasWhatsAppConnection = connections.some(
(connection) => connection.platform === "whatsapp" (connection) => connection.platform === "whatsapp" && isConnectionInActiveMode(connection)
); );
const isRefreshing = (platform: GatewayPlatform) => refreshingPlatform === platform; const isRefreshing = (platform: GatewayPlatform) => refreshingPlatform === platform;
const refreshButtonClassName = "gap-2"; const refreshButtonClassName = "gap-2";
@ -242,7 +248,7 @@ export function MessagingChannelsContent() {
`${platformLabel(connection.platform)} connection`; `${platformLabel(connection.platform)} connection`;
const renderConnectionRows = (platform: GatewayConnection["platform"], emptyText: string) => { const renderConnectionRows = (platform: GatewayConnection["platform"], emptyText: string) => {
const platformConnections = connections.filter( const platformConnections = connections.filter(
(connection) => connection.platform === platform (connection) => connection.platform === platform && isConnectionInActiveMode(connection)
); );
if (platformConnections.length === 0) { if (platformConnections.length === 0) {
@ -327,7 +333,7 @@ export function MessagingChannelsContent() {
return ( return (
<div className="grid items-stretch gap-3 sm:grid-cols-2"> <div className="grid items-stretch gap-3 sm:grid-cols-2">
<Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-1 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-sm">Telegram</CardTitle> <CardTitle className="flex items-center gap-2 text-sm">Telegram</CardTitle>
@ -360,7 +366,7 @@ export function MessagingChannelsContent() {
</Card> </Card>
{slackGatewayEnabled ? ( {slackGatewayEnabled ? (
<Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-4 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-sm">Slack</CardTitle> <CardTitle className="flex items-center gap-2 text-sm">Slack</CardTitle>
@ -392,7 +398,7 @@ export function MessagingChannelsContent() {
) : null} ) : null}
{discordGatewayEnabled ? ( {discordGatewayEnabled ? (
<Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-3 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-sm">Discord</CardTitle> <CardTitle className="flex items-center gap-2 text-sm">Discord</CardTitle>
@ -424,16 +430,15 @@ export function MessagingChannelsContent() {
) : null} ) : null}
{whatsappMode !== "disabled" ? ( {whatsappMode !== "disabled" ? (
<Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-2 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-sm">WhatsApp</CardTitle> <CardTitle className="flex items-center gap-2 text-sm">WhatsApp</CardTitle>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Pair this search space with WhatsApp using the configured gateway mode.
{whatsappMode === "baileys" {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."}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 p-4 pt-0"> <CardContent className="space-y-3 p-4 pt-0">
@ -469,7 +474,7 @@ export function MessagingChannelsContent() {
disabled={isRefreshing("whatsapp")} disabled={isRefreshing("whatsapp")}
> >
<RefreshCw className={refreshIconClassName("whatsapp")} /> <RefreshCw className={refreshIconClassName("whatsapp")} />
Refresh WhatsApp Bridge Refresh
</Button> </Button>
{baileysQr ? ( {baileysQr ? (
<div className="rounded-lg border border-accent bg-accent/20 p-3"> <div className="rounded-lg border border-accent bg-accent/20 p-3">