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 search_space_id: int
class UpdateAccountSearchSpaceRequest(BaseModel):
search_space_id: int
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"
@ -704,21 +708,27 @@ async def list_bindings(
@router.get("/connections") @router.get("/connections")
async def list_connections( async def list_connections(
platform: ExternalChatPlatform | None = None,
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]]:
result = await session.execute( filters = [
select(ExternalChatBinding, ExternalChatAccount)
.join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id)
.where(
ExternalChatBinding.user_id == user.id, ExternalChatBinding.user_id == user.id,
ExternalChatBinding.state.in_( ExternalChatBinding.state.in_(
[ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED] [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(*filters)
) )
connections: list[dict[str, Any]] = [] connections: list[dict[str, Any]] = []
baileys_account_ids: set[int] = set()
for binding, account in result.all(): for binding, account in result.all():
binding_metadata = binding.external_metadata or {} binding_metadata = binding.external_metadata or {}
kind = str(binding_metadata.get("kind") or "") kind = str(binding_metadata.get("kind") or "")
@ -728,6 +738,10 @@ async def list_connections(
account_state = account.cursor_state or {} account_state = account.cursor_state or {}
workspace_name = None workspace_name = None
workspace_id = 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: if account.platform == ExternalChatPlatform.SLACK:
workspace_name = account_state.get("team_name") workspace_name = account_state.get("team_name")
workspace_id = account_state.get("team_id") workspace_id = account_state.get("team_id")
@ -737,17 +751,30 @@ async def list_connections(
elif account.platform == ExternalChatPlatform.WHATSAPP: elif account.platform == ExternalChatPlatform.WHATSAPP:
workspace_name = account_state.get("display_phone_number") workspace_name = account_state.get("display_phone_number")
workspace_id = account_state.get("phone_number_id") 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( connections.append(
{ {
"id": binding.id, "id": connection_id,
"account_id": account.id,
"route_type": route_type,
"platform": account.platform.value, "platform": account.platform.value,
"mode": account.mode.value,
"state": binding.state.value, "state": binding.state.value,
"search_space_id": binding.search_space_id, "search_space_id": search_space_id,
"display_name": binding.external_display_name "display_name": display_name or workspace_name,
or binding.external_username "external_username": (
or workspace_name, None
"external_username": binding.external_username, if account.mode == ExternalChatAccountMode.SELF_HOST_BYO
else binding.external_username
),
"workspace_name": workspace_name, "workspace_name": workspace_name,
"workspace_id": workspace_id, "workspace_id": workspace_id,
"health_status": account.health_status.value, "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 return connections
@ -807,6 +865,44 @@ async def update_binding_search_space(
return {"ok": True} 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}") @router.delete("/bindings/{binding_id}")
async def delete_binding( async def delete_binding(
binding_id: int, binding_id: int,
@ -821,6 +917,41 @@ async def delete_binding(
return {"ok": True} 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") @router.post("/bindings/{binding_id}/resume")
async def resume_external_chat_binding( async def resume_external_chat_binding(
binding_id: int, binding_id: int,

View file

@ -85,6 +85,8 @@ async def request_pairing_code(
account.mode = ExternalChatAccountMode.SELF_HOST_BYO account.mode = ExternalChatAccountMode.SELF_HOST_BYO
account.owner_search_space_id = body.search_space_id account.owner_search_space_id = body.search_space_id
account.health_status = ExternalChatHealthStatus.UNKNOWN account.health_status = ExternalChatHealthStatus.UNKNOWN
account.suspended_at = None
account.suspended_reason = None
account.last_health_check_at = datetime.now(UTC) account.last_health_check_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)

View file

@ -1,11 +1,10 @@
"use client"; "use client";
import { MessageCircle, RefreshCw, ShieldAlert } from "lucide-react"; import { RefreshCw, ShieldAlert } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useState, useTransition } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@ -15,14 +14,19 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import type { SearchSpace } from "@/contracts/types/search-space.types"; import type { SearchSpace } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
import { cn } from "@/lib/utils";
type GatewayConnection = { type GatewayConnection = {
id: number; id: number;
account_id?: number | null;
route_type?: "account" | "binding";
platform: string; platform: string;
mode?: string;
state: string; state: string;
search_space_id: number; search_space_id: number;
display_name?: string | null; display_name?: string | null;
@ -33,15 +37,6 @@ type GatewayConnection = {
suspended_reason?: string | null; 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 = { type Pairing = {
binding_id: number; binding_id: number;
code: string; code: string;
@ -50,6 +45,7 @@ type Pairing = {
}; };
type PairingPlatform = "telegram" | "whatsapp"; type PairingPlatform = "telegram" | "whatsapp";
type GatewayPlatform = PairingPlatform | "slack" | "discord";
type BaileysHealth = { type BaileysHealth = {
status: string; status: string;
@ -66,31 +62,47 @@ export function MessagingChannelsContent() {
const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true"; const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true";
const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true"; const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true";
const [connections, setConnections] = useState<GatewayConnection[]>([]); const [connections, setConnections] = useState<GatewayConnection[]>([]);
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]); const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [pairing, setPairing] = useState<Pairing | null>(null); const [pairing, setPairing] = useState<Pairing | null>(null);
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null); const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null); const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
const [loading, setLoading] = useState(true); const [refreshingPlatform, setRefreshingPlatform] = useState<GatewayPlatform | null>(null);
const [isPending, startTransition] = useTransition();
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 () => { const refresh = useCallback(async () => {
setLoading(true); const [nextConnections, spaces] = await Promise.all([
const [connectionsRes, platformsRes, spaces] = await Promise.all([ fetchConnections(),
authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections`),
authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/platforms`),
searchSpacesApiService.getSearchSpaces(), searchSpacesApiService.getSearchSpaces(),
]); ]);
setConnections(await connectionsRes.json()); setConnections(nextConnections);
setPlatforms(await platformsRes.json());
setSearchSpaces(spaces); setSearchSpaces(spaces);
setLoading(false); }, [fetchConnections]);
}, []);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
}, [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 () => { const refreshBaileysHealth = useCallback(async () => {
if (whatsappMode !== "baileys") return; if (whatsappMode !== "baileys") return;
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/health`); const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/health`);
@ -111,7 +123,7 @@ export function MessagingChannelsContent() {
}); });
setPairing(await res.json()); setPairing(await res.json());
setPairingPlatform(platform); setPairingPlatform(platform);
await refresh(); await refreshPlatform(platform);
} }
async function installSlackGateway() { async function installSlackGateway() {
@ -136,59 +148,77 @@ export function MessagingChannelsContent() {
} }
} }
function refreshBaileys() { async function refreshBaileys() {
startTransition(async () => {
await refreshBaileysHealth(); await refreshBaileysHealth();
await refresh(); await refreshPlatform("whatsapp");
});
} }
async function revoke(id: number) { const connectionKey = (connection: GatewayConnection) =>
await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}`, { 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", 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 previousConnections = connections;
const parsedSearchSpaceId = Number(nextSearchSpaceId); const parsedSearchSpaceId = Number(nextSearchSpaceId);
const targetKey = connectionKey(connection);
setConnections((current) => setConnections((current) =>
current.map((connection) => 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( const url =
`${BACKEND_URL}/api/v1/gateway/bindings/${id}/search-space`, 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", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ search_space_id: parsedSearchSpaceId }), body: JSON.stringify({ search_space_id: parsedSearchSpaceId }),
} });
);
if (!res.ok) { if (!res.ok) {
setConnections(previousConnections); setConnections(previousConnections);
toast.error("Failed to update messaging route"); toast.error("Failed to update messaging route");
return; return;
} }
toast.success("Messaging route updated"); toast.success("Messaging route updated");
await refresh(); await refreshPlatform(connection.platform as GatewayPlatform);
} }
async function resume(id: number) { async function resume(connection: GatewayConnection) {
await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}/resume`, { await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/resume`, {
method: "POST", 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 baileysQr = baileysHealth?.qr || null;
const currentSearchSpaceName = const hasTelegramConnection = connections.some(
searchSpaces.find((space) => space.id === searchSpaceId)?.name || "this search space"; (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) => { const platformLabel = (platform: string) => {
switch (platform) { switch (platform) {
case "discord": case "discord":
@ -204,16 +234,85 @@ export function MessagingChannelsContent() {
} }
}; };
const connectionTitle = (connection: GatewayConnection) => const connectionTitle = (connection: GatewayConnection) =>
connection.workspace_name || connection.platform === "whatsapp" && connection.mode === "self_host_byo"
? "WhatsApp Bridge"
: connection.workspace_name ||
connection.display_name || connection.display_name ||
connection.external_username || connection.external_username ||
`${platformLabel(connection.platform)} connection`; `${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) => { const renderPairingPanel = (platform: PairingPlatform) => {
if (!pairing || pairingPlatform !== platform) return null; if (!pairing || pairingPlatform !== platform) return null;
return ( return (
<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">Pairing code</p> <p className="text-xs font-medium">Pairing code</p>
<p className="mt-2 font-mono text-lg">{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}> <a className="mt-2 block text-sm text-primary underline" href={pairing.deep_link}>
Open {platform === "whatsapp" ? "WhatsApp" : "Telegram"} pairing link Open {platform === "whatsapp" ? "WhatsApp" : "Telegram"} pairing link
@ -227,136 +326,153 @@ export function MessagingChannelsContent() {
}; };
return ( return (
<div className="space-y-5"> <div className="grid items-stretch gap-3 sm:grid-cols-2">
<Card> <Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-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-base"> <CardTitle className="flex items-center gap-2 text-sm">Telegram</CardTitle>
<MessageCircle className="h-4 w-4" />
Telegram
</CardTitle>
<Badge variant={telegram?.health_status === "ok" ? "default" : "secondary"}>
{telegram?.health_status ?? "not configured"}
</Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">Pair Telegram with this search space.</p>
Pair a Telegram chat with this search space. Telegram conversations stay in Telegram and
are not mirrored in SurfSense chat history.
</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3 p-4 pt-0">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={() => startPairing("telegram")}>Pair Telegram Chat</Button> {hasTelegramConnection ? null : (
<Button variant="outline" onClick={refresh} disabled={loading}> <Button size="sm" onClick={() => startPairing("telegram")}>
<RefreshCw className="mr-2 h-4 w-4" /> Pair Telegram Chat
</Button>
)}
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("telegram")}
disabled={isRefreshing("telegram")}
>
<RefreshCw className={refreshIconClassName("telegram")} />
Refresh Refresh
</Button> </Button>
</div> </div>
{renderPairingPanel("telegram")} {hasTelegramConnection ? null : renderPairingPanel("telegram")}
<Separator className="bg-accent" />
{renderConnectionRows("telegram", "No Telegram chats connected yet.")}
</CardContent> </CardContent>
</Card> </Card>
{slackGatewayEnabled ? ( {slackGatewayEnabled ? (
<Card> <Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-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-base"> <CardTitle className="flex items-center gap-2 text-sm">Slack</CardTitle>
<MessageCircle className="h-4 w-4" />
Slack Bot
</CardTitle>
<Badge variant={slack?.health_status === "ok" ? "default" : "secondary"}>
{slack ? "enabled" : "not enabled"}
</Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enable the SurfSense Slack bot so teammates can mention it in Slack. This is separate Enable the SurfSense Slack bot so teammates can mention it in Slack.
from the Slack search connector.
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3 p-4 pt-0">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={installSlackGateway}>Add Slack Workspace</Button> <Button size="sm" onClick={installSlackGateway}>
<Button variant="outline" onClick={refresh} disabled={loading}> Add Slack Workspace
<RefreshCw className="mr-2 h-4 w-4" /> </Button>
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("slack")}
disabled={isRefreshing("slack")}
>
<RefreshCw className={refreshIconClassName("slack")} />
Refresh Refresh
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <Separator className="bg-accent" />
New Slack workspace connections will route to {currentSearchSpaceName} first. You can {renderConnectionRows("slack", "No Slack workspaces connected yet.")}
change each connection's search space below.
</p>
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
{discordGatewayEnabled ? ( {discordGatewayEnabled ? (
<Card> <Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-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-base"> <CardTitle className="flex items-center gap-2 text-sm">Discord</CardTitle>
<MessageCircle className="h-4 w-4" />
Discord Bot
</CardTitle>
<Badge variant={discord?.health_status === "ok" ? "default" : "secondary"}>
{discord ? "enabled" : "not enabled"}
</Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enable the SurfSense Discord bot so teammates can mention it in Discord. This is Enable the SurfSense Discord bot so teammates can mention it in Discord.
separate from the Discord connector.
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3 p-4 pt-0">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={installDiscordGateway}>Add Discord Server</Button> <Button size="sm" onClick={installDiscordGateway}>
<Button variant="outline" onClick={refresh} disabled={loading}> Add Discord Server
<RefreshCw className="mr-2 h-4 w-4" /> </Button>
<Button
size="sm"
variant="secondary"
className={refreshButtonClassName}
onClick={() => refreshPlatform("discord")}
disabled={isRefreshing("discord")}
>
<RefreshCw className={refreshIconClassName("discord")} />
Refresh Refresh
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <Separator className="bg-accent" />
New Discord server connections will route to {currentSearchSpaceName} first. You can {renderConnectionRows("discord", "No Discord servers connected yet.")}
change each connection's search space below.
</p>
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
{whatsappMode !== "disabled" ? ( {whatsappMode !== "disabled" ? (
<Card> <Card className="group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-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-base"> <CardTitle className="flex items-center gap-2 text-sm">WhatsApp</CardTitle>
<MessageCircle className="h-4 w-4" />
WhatsApp
</CardTitle>
<Badge variant={whatsapp?.health_status === "ok" ? "default" : "secondary"}>
{whatsapp?.health_status ?? "not configured"}
</Badge>
</div> </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. 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> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3 p-4 pt-0">
{whatsappMode === "cloud" ? ( {whatsappMode === "cloud" ? (
<div className="space-y-3"> <div className="space-y-3">
<Button onClick={() => startPairing("whatsapp")}>Pair WhatsApp</Button> <div className="flex flex-wrap gap-2">
{renderPairingPanel("whatsapp")} {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> </div>
) : null} ) : null}
{whatsappMode === "baileys" ? ( {whatsappMode === "baileys" ? (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-muted-foreground"> <Button
Self-hosted WhatsApp uses Message Yourself mode. After pairing, send messages in size="sm"
your own WhatsApp chat with yourself; messages from other chats are ignored. variant="secondary"
</p> className={refreshButtonClassName}
<Button variant="outline" onClick={refreshBaileys} disabled={isPending}> onClick={refreshBaileys}
disabled={isRefreshing("whatsapp")}
>
<RefreshCw className={refreshIconClassName("whatsapp")} />
Refresh WhatsApp Bridge Refresh WhatsApp Bridge
</Button> </Button>
{baileysQr ? ( {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="text-sm font-medium">WhatsApp QR pairing</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Scan this QR from WhatsApp &gt; Linked Devices &gt; Link a Device. Scan this QR from WhatsApp &gt; Linked Devices &gt; Link a Device.
@ -376,71 +492,11 @@ export function MessagingChannelsContent() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
<Separator className="bg-accent" />
{renderConnectionRows("whatsapp", "No WhatsApp chats connected yet.")}
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : 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> </div>
); );
} }