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
|
|
@ -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]]:
|
||||
result = await session.execute(
|
||||
select(ExternalChatBinding, ExternalChatAccount)
|
||||
.join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id)
|
||||
.where(
|
||||
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(*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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
async function refreshBaileys() {
|
||||
await refreshBaileysHealth();
|
||||
await refresh();
|
||||
});
|
||||
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`,
|
||||
{
|
||||
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.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