feat(gateway): enhance WhatsApp account management and connection handling

This commit is contained in:
Anish Sarkar 2026-06-01 22:49:46 +05:30
parent 2d1a6be776
commit a151e8f729
3 changed files with 399 additions and 210 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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<GatewayConnection[]>([]);
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [pairing, setPairing] = useState<Pairing | null>(null);
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
const [loading, setLoading] = useState(true);
const [isPending, startTransition] = useTransition();
const [refreshingPlatform, setRefreshingPlatform] = useState<GatewayPlatform | null>(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 <p className="text-xs text-muted-foreground">{emptyText}</p>;
}
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Connected accounts</p>
{platformConnections.map((connection, index) => (
<div key={connectionKey(connection)} className="space-y-2">
{index > 0 ? <Separator className="bg-accent" /> : null}
<div className="space-y-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium">{connectionTitle(connection)}</p>
{connection.suspended_reason ? (
<p className="mt-1 flex items-center gap-1 text-xs text-destructive">
<ShieldAlert className="h-3 w-3" />
{connection.suspended_reason}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(connection.search_space_id)}
onValueChange={(value) => updateConnectionSearchSpace(connection, value)}
disabled={searchSpaces.length === 0}
>
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
<SelectValue placeholder="Select search space" />
</SelectTrigger>
<SelectContent>
{searchSpaces.map((space) => (
<SelectItem key={space.id} value={String(space.id)}>
{space.name}
</SelectItem>
))}
</SelectContent>
</Select>
{connection.state === "suspended" ? (
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => resume(connection)}
>
Resume
</Button>
) : null}
<Button
size="sm"
variant="destructive"
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
onClick={() => revoke(connection)}
>
Disconnect
</Button>
</div>
</div>
</div>
))}
</div>
);
};
const renderPairingPanel = (platform: PairingPlatform) => {
if (!pairing || pairingPlatform !== platform) return null;
return (
<div className="rounded-md border border-border bg-muted/30 p-4">
<p className="text-sm font-medium">Pairing code</p>
<div className="rounded-lg border border-accent bg-accent/20 p-3">
<p className="text-xs font-medium">Pairing code</p>
<p className="mt-2 font-mono text-lg">{pairing.code}</p>
<a className="mt-2 block text-sm text-primary underline" href={pairing.deep_link}>
Open {platform === "whatsapp" ? "WhatsApp" : "Telegram"} pairing link
@ -227,136 +326,153 @@ export function MessagingChannelsContent() {
};
return (
<div className="space-y-5">
<Card>
<CardHeader className="space-y-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">
<CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageCircle className="h-4 w-4" />
Telegram
</CardTitle>
<Badge variant={telegram?.health_status === "ok" ? "default" : "secondary"}>
{telegram?.health_status ?? "not configured"}
</Badge>
<CardTitle className="flex items-center gap-2 text-sm">Telegram</CardTitle>
</div>
<p className="text-sm text-muted-foreground">
Pair a Telegram chat with this search space. Telegram conversations stay in Telegram and
are not mirrored in SurfSense chat history.
</p>
<p className="text-xs text-muted-foreground">Pair Telegram with this search space.</p>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 p-4 pt-0">
<div className="flex flex-wrap gap-2">
<Button onClick={() => startPairing("telegram")}>Pair Telegram Chat</Button>
<Button variant="outline" onClick={refresh} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
{hasTelegramConnection ? null : (
<Button size="sm" onClick={() => startPairing("telegram")}>
Pair Telegram Chat
</Button>
)}
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("telegram")}
disabled={isRefreshing("telegram")}
>
<RefreshCw className={refreshIconClassName("telegram")} />
Refresh
</Button>
</div>
{renderPairingPanel("telegram")}
{hasTelegramConnection ? null : renderPairingPanel("telegram")}
<Separator className="bg-accent" />
{renderConnectionRows("telegram", "No Telegram chats connected yet.")}
</CardContent>
</Card>
{slackGatewayEnabled ? (
<Card>
<CardHeader className="space-y-2">
<Card className="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">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageCircle className="h-4 w-4" />
Slack Bot
</CardTitle>
<Badge variant={slack?.health_status === "ok" ? "default" : "secondary"}>
{slack ? "enabled" : "not enabled"}
</Badge>
<CardTitle className="flex items-center gap-2 text-sm">Slack</CardTitle>
</div>
<p className="text-sm text-muted-foreground">
Enable the SurfSense Slack bot so teammates can mention it in Slack. This is separate
from the Slack search connector.
<p className="text-xs text-muted-foreground">
Enable the SurfSense Slack bot so teammates can mention it in Slack.
</p>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 p-4 pt-0">
<div className="flex flex-wrap gap-2">
<Button onClick={installSlackGateway}>Add Slack Workspace</Button>
<Button variant="outline" onClick={refresh} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
<Button size="sm" onClick={installSlackGateway}>
Add Slack Workspace
</Button>
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("slack")}
disabled={isRefreshing("slack")}
>
<RefreshCw className={refreshIconClassName("slack")} />
Refresh
</Button>
</div>
<p className="text-xs text-muted-foreground">
New Slack workspace connections will route to {currentSearchSpaceName} first. You can
change each connection's search space below.
</p>
<Separator className="bg-accent" />
{renderConnectionRows("slack", "No Slack workspaces connected yet.")}
</CardContent>
</Card>
) : null}
{discordGatewayEnabled ? (
<Card>
<CardHeader className="space-y-2">
<Card className="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">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageCircle className="h-4 w-4" />
Discord Bot
</CardTitle>
<Badge variant={discord?.health_status === "ok" ? "default" : "secondary"}>
{discord ? "enabled" : "not enabled"}
</Badge>
<CardTitle className="flex items-center gap-2 text-sm">Discord</CardTitle>
</div>
<p className="text-sm text-muted-foreground">
Enable the SurfSense Discord bot so teammates can mention it in Discord. This is
separate from the Discord connector.
<p className="text-xs text-muted-foreground">
Enable the SurfSense Discord bot so teammates can mention it in Discord.
</p>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 p-4 pt-0">
<div className="flex flex-wrap gap-2">
<Button onClick={installDiscordGateway}>Add Discord Server</Button>
<Button variant="outline" onClick={refresh} disabled={loading}>
<RefreshCw className="mr-2 h-4 w-4" />
<Button size="sm" onClick={installDiscordGateway}>
Add Discord Server
</Button>
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("discord")}
disabled={isRefreshing("discord")}
>
<RefreshCw className={refreshIconClassName("discord")} />
Refresh
</Button>
</div>
<p className="text-xs text-muted-foreground">
New Discord server connections will route to {currentSearchSpaceName} first. You can
change each connection's search space below.
</p>
<Separator className="bg-accent" />
{renderConnectionRows("discord", "No Discord servers connected yet.")}
</CardContent>
</Card>
) : null}
{whatsappMode !== "disabled" ? (
<Card>
<CardHeader className="space-y-2">
<Card className="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">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageCircle className="h-4 w-4" />
WhatsApp
</CardTitle>
<Badge variant={whatsapp?.health_status === "ok" ? "default" : "secondary"}>
{whatsapp?.health_status ?? "not configured"}
</Badge>
<CardTitle className="flex items-center gap-2 text-sm">WhatsApp</CardTitle>
</div>
<p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
Pair this search space with WhatsApp using the configured gateway mode.
{whatsappMode === "baileys"
? " Send messages to your own WhatsApp chat. Other chats are ignored."
: ""}
</p>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 p-4 pt-0">
{whatsappMode === "cloud" ? (
<div className="space-y-3">
<Button onClick={() => startPairing("whatsapp")}>Pair WhatsApp</Button>
{renderPairingPanel("whatsapp")}
<div className="flex flex-wrap gap-2">
{hasWhatsAppConnection ? null : (
<Button size="sm" onClick={() => startPairing("whatsapp")}>
Pair WhatsApp
</Button>
)}
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("whatsapp")}
disabled={isRefreshing("whatsapp")}
>
<RefreshCw className={refreshIconClassName("whatsapp")} />
Refresh
</Button>
</div>
{hasWhatsAppConnection ? null : renderPairingPanel("whatsapp")}
</div>
) : null}
{whatsappMode === "baileys" ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Self-hosted WhatsApp uses Message Yourself mode. After pairing, send messages in
your own WhatsApp chat with yourself; messages from other chats are ignored.
</p>
<Button variant="outline" onClick={refreshBaileys} disabled={isPending}>
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={refreshBaileys}
disabled={isRefreshing("whatsapp")}
>
<RefreshCw className={refreshIconClassName("whatsapp")} />
Refresh WhatsApp Bridge
</Button>
{baileysQr ? (
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="rounded-lg border border-accent bg-accent/20 p-3">
<p className="text-sm font-medium">WhatsApp QR pairing</p>
<p className="mt-1 text-xs text-muted-foreground">
Scan this QR from WhatsApp &gt; Linked Devices &gt; Link a Device.
@ -376,71 +492,11 @@ export function MessagingChannelsContent() {
) : null}
</div>
) : null}
<Separator className="bg-accent" />
{renderConnectionRows("whatsapp", "No WhatsApp chats connected yet.")}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="text-base">Connected Messaging Channels</CardTitle>
<p className="text-sm text-muted-foreground">
Choose which search space each external channel should use when messages arrive.
</p>
</CardHeader>
<CardContent className="space-y-3">
{connections.length === 0 ? (
<p className="text-sm text-muted-foreground">No messaging channels connected yet.</p>
) : (
connections.map((connection) => (
<div
key={connection.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border p-3"
>
<div className="min-w-0">
<p className="text-sm font-medium">{connectionTitle(connection)}</p>
<p className="text-xs text-muted-foreground">
{platformLabel(connection.platform)}
{connection.external_username ? ` · ${connection.external_username}` : ""}
{connection.state ? ` · ${connection.state}` : ""}
</p>
{connection.suspended_reason ? (
<p className="mt-1 flex items-center gap-1 text-xs text-destructive">
<ShieldAlert className="h-3 w-3" />
{connection.suspended_reason}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(connection.search_space_id)}
onValueChange={(value) => updateConnectionSearchSpace(connection.id, value)}
disabled={searchSpaces.length === 0 || isPending}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Select search space" />
</SelectTrigger>
<SelectContent>
{searchSpaces.map((space) => (
<SelectItem key={space.id} value={String(space.id)}>
{space.name}
</SelectItem>
))}
</SelectContent>
</Select>
{connection.state === "suspended" ? (
<Button size="sm" variant="outline" onClick={() => resume(connection.id)}>
Resume
</Button>
) : null}
<Button size="sm" variant="destructive" onClick={() => revoke(connection.id)}>
Disconnect
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}