mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
feat(gateway): enhance WhatsApp account management and connection handling
This commit is contained in:
parent
2d1a6be776
commit
a151e8f729
3 changed files with 399 additions and 210 deletions
|
|
@ -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 > Linked Devices > 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue