telegram and whatsapp channel integrations, validate bot tokens before saving
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type TelegramConnection = {
|
||||
linked: boolean;
|
||||
telegramId?: string;
|
||||
};
|
||||
|
||||
type TelegramStatus = {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
serverInvite: string | null;
|
||||
botHandle: string | null;
|
||||
};
|
||||
|
||||
export const TelegramAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [connection, setConnection] = useState<TelegramConnection>({ linked: false });
|
||||
const [botStatus, setBotStatus] = useState<TelegramStatus>({
|
||||
configured: false,
|
||||
running: false,
|
||||
serverInvite: null,
|
||||
botHandle: null,
|
||||
});
|
||||
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
client.get<TelegramConnection>('/channels/telegram/connection').then(setConnection),
|
||||
client.get<TelegramStatus>('/channels/telegram/status').then(setBotStatus),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleGenerateCode = async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await client.post<{ code: string; expiresIn: number }>('/channels/telegram/pair', {});
|
||||
setPairingCode(res.code);
|
||||
} catch {
|
||||
toast.error('Failed to generate pairing code');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (pairingCode) {
|
||||
navigator.clipboard.writeText(pairingCode);
|
||||
toast.success('Code copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await client.delete('/channels/telegram/connection');
|
||||
setConnection({ linked: false });
|
||||
setPairingCode(null);
|
||||
toast.success('Telegram account unlinked');
|
||||
} catch {
|
||||
toast.error('Failed to unlink Telegram account');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!botStatus.configured) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Telegram integration has not been configured yet. Ask your administrator to set up the Telegram bot in the
|
||||
Enterprise settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const connectionInfo = (botStatus.serverInvite || botStatus.botHandle) && (
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||
{botStatus.serverInvite && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Group:</span>
|
||||
<a
|
||||
href={botStatus.serverInvite}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline truncate"
|
||||
>
|
||||
{botStatus.serverInvite}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{botStatus.botHandle && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botStatus.botHandle}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (connection.linked) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Linked</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">
|
||||
Telegram ID: {connection.telegramId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{connectionInfo}
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
You can send direct messages to the bot on Telegram and they will be handled by your PI agent.
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||
Unlink Telegram
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Link your Telegram account to chat with your PI agent via direct messages.
|
||||
</p>
|
||||
|
||||
{connectionInfo}
|
||||
|
||||
{pairingCode ? (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
|
||||
{pairingCode}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopyCode}
|
||||
className="cursor-pointer shrink-0"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Send this code as a direct message to the bot on Telegram. Expires in 10 minutes.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={isGenerating}
|
||||
className="w-full h-11 cursor-pointer"
|
||||
>
|
||||
Generate New Code
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={isGenerating}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Link Telegram'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type TelegramConfig = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
botToken: string | null;
|
||||
serverInvite: string | null;
|
||||
botHandle: string | null;
|
||||
};
|
||||
|
||||
type TelegramStatus = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
botUsername: string | null;
|
||||
};
|
||||
|
||||
const SetupGuide = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||
Step-by-step setup guide
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Open Telegram and search for @BotFather</strong>
|
||||
<p className="mt-1">
|
||||
BotFather is Telegram's official bot for creating and managing bots. Open Telegram and search for{' '}
|
||||
<strong>@BotFather</strong>, then start a conversation.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Create a new bot</strong>
|
||||
<p className="mt-1">
|
||||
Send <code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">/newbot</code> to
|
||||
BotFather. Follow the prompts to choose a <strong>display name</strong> and a <strong>username</strong>{' '}
|
||||
(must end in "bot", e.g.{' '}
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">my_officer_bot</code>
|
||||
).
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Copy the bot token</strong>
|
||||
<p className="mt-1">BotFather will reply with an HTTP API token. Copy it — you'll paste it below.</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Keep this token secret. Anyone with this token can control your bot. You can regenerate it via BotFather
|
||||
at any time.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Optionally create a group</strong>
|
||||
<p className="mt-1">
|
||||
You can create a Telegram group and add the bot so your team can find it easily. This is optional — users
|
||||
can also search for the bot by username and message it directly.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Paste the token below and save</strong>
|
||||
<p className="mt-1">
|
||||
Paste the bot token into the field below and click <strong>Save</strong>. The bot will come online
|
||||
automatically. Users can then link their Telegram accounts from their personal integration settings.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const TelegramBotConfig = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [botToken, setBotToken] = useState('');
|
||||
const [serverInvite, setServerInvite] = useState('');
|
||||
const [botHandle, setBotHandle] = useState('');
|
||||
const [status, setStatus] = useState<TelegramStatus | null>(null);
|
||||
|
||||
const fetchStatus = () => {
|
||||
client
|
||||
.get<TelegramStatus>('/channels/telegram/status')
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
client.get<TelegramConfig>('/channels/telegram/config').then((data) => {
|
||||
if (data.botToken) setBotToken(data.botToken);
|
||||
if (data.serverInvite) setServerInvite(data.serverInvite);
|
||||
if (data.botHandle) setBotHandle(data.botHandle);
|
||||
}),
|
||||
client.get<TelegramStatus>('/channels/telegram/status').then(setStatus),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await client.put('/channels/telegram/config', {
|
||||
botToken: botToken.trim(),
|
||||
serverInvite: serverInvite.trim() || undefined,
|
||||
botHandle: botHandle.trim() || undefined,
|
||||
});
|
||||
toast.success('Telegram bot configuration saved');
|
||||
fetchStatus();
|
||||
} catch {
|
||||
toast.error('Failed to save Telegram bot configuration');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{status && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div
|
||||
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.running ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
|
||||
/>
|
||||
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||
{status.running
|
||||
? `Online as @${status.botUsername}`
|
||||
: status.configured
|
||||
? 'Bot configured but not running'
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SetupGuide />
|
||||
|
||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Token</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(ev) => setBotToken(ev.target.value)}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Group Invite Link</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
value={serverInvite}
|
||||
onChange={(ev) => setServerInvite(ev.target.value)}
|
||||
placeholder="https://t.me/+..."
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Shown to users so they can join the group and find the bot.
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Bot Handle</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
value={botHandle}
|
||||
onChange={(ev) => setBotHandle(ev.target.value)}
|
||||
placeholder="@my_officer_bot"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
The bot's @username — shown to users who want to message the bot directly.
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !botToken.trim()}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type WhatsAppConnection = {
|
||||
linked: boolean;
|
||||
whatsappId?: string;
|
||||
};
|
||||
|
||||
type WhatsAppStatus = {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
phone: string | null;
|
||||
};
|
||||
|
||||
export const WhatsAppAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [connection, setConnection] = useState<WhatsAppConnection>({ linked: false });
|
||||
const [botStatus, setBotStatus] = useState<WhatsAppStatus>({ configured: false, running: false, phone: null });
|
||||
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
client.get<WhatsAppConnection>('/channels/whatsapp/connection').then(setConnection),
|
||||
client.get<WhatsAppStatus>('/channels/whatsapp/status').then(setBotStatus),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleGenerateCode = async () => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await client.post<{ code: string; expiresIn: number }>('/channels/whatsapp/pair', {});
|
||||
setPairingCode(res.code);
|
||||
} catch {
|
||||
toast.error('Failed to generate pairing code');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (pairingCode) {
|
||||
navigator.clipboard.writeText(pairingCode);
|
||||
toast.success('Code copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await client.delete('/channels/whatsapp/connection');
|
||||
setConnection({ linked: false });
|
||||
setPairingCode(null);
|
||||
toast.success('WhatsApp account unlinked');
|
||||
} catch {
|
||||
toast.error('Failed to unlink WhatsApp account');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!botStatus.configured) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
WhatsApp integration has not been configured yet. Ask your administrator to connect WhatsApp in the Enterprise
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const phoneInfo = botStatus.phone && (
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot phone:</span>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">+{botStatus.phone}</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (connection.linked) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Linked</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">
|
||||
Phone: +{connection.whatsappId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{phoneInfo}
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
You can send messages to the bot on WhatsApp and they will be handled by your PI agent.
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||
Unlink WhatsApp
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Link your WhatsApp account to chat with your PI agent via direct messages.
|
||||
</p>
|
||||
|
||||
{phoneInfo}
|
||||
|
||||
{pairingCode ? (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
|
||||
{pairingCode}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopyCode}
|
||||
className="cursor-pointer shrink-0"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Send this code to{botStatus.phone ? ` +${botStatus.phone}` : ' the bot'} on WhatsApp. Expires in 10 minutes.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={isGenerating}
|
||||
className="w-full h-11 cursor-pointer"
|
||||
>
|
||||
Generate New Code
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={isGenerating}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Link WhatsApp'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type WhatsAppConfig = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
phone: string | null;
|
||||
};
|
||||
|
||||
type SSEEvent = {
|
||||
type: 'qr' | 'authenticated' | 'disconnected' | 'waiting';
|
||||
qr?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
const SetupGuide = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||
Step-by-step setup guide
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Click "Connect WhatsApp" below</strong>
|
||||
<p className="mt-1">This will start the WhatsApp Web client and generate a QR code.</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Open WhatsApp on your phone</strong>
|
||||
<p className="mt-1">
|
||||
Go to <strong>Settings</strong> → <strong>Linked Devices</strong> → <strong>Link a Device</strong>.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Scan the QR code</strong>
|
||||
<p className="mt-1">
|
||||
Point your phone camera at the QR code shown below. The bot will come online automatically once scanned.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Done</strong>
|
||||
<p className="mt-1">
|
||||
Users can then link their WhatsApp accounts from their personal integration settings and chat via direct
|
||||
messages.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
The session persists across restarts — you won't need to re-scan unless you disconnect.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const WhatsAppBotConfig = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [config, setConfig] = useState<WhatsAppConfig | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const fetchConfig = useCallback(() => {
|
||||
client
|
||||
.get<WhatsAppConfig>('/channels/whatsapp/config')
|
||||
.then(setConfig)
|
||||
.catch(() => {});
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const connectSSE = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token') ?? sessionStorage.getItem('token') ?? '';
|
||||
const url = `/api/channels/whatsapp/qr?token=${encodeURIComponent(token)}`;
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = async (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data) as SSEEvent;
|
||||
if (data.type === 'qr' && data.qr) {
|
||||
const dataUrl = await QRCode.toDataURL(data.qr, { width: 256, margin: 2 });
|
||||
setQrDataUrl(dataUrl);
|
||||
} else if (data.type === 'authenticated') {
|
||||
setQrDataUrl(null);
|
||||
es.close();
|
||||
fetchConfig();
|
||||
} else if (data.type === 'disconnected') {
|
||||
setQrDataUrl(null);
|
||||
es.close();
|
||||
fetchConfig();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setQrDataUrl(null);
|
||||
};
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await client.put('/channels/whatsapp/config', { enabled: true });
|
||||
connectSSE();
|
||||
} catch {
|
||||
toast.error('Failed to start WhatsApp connection');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
await client.put('/channels/whatsapp/config', { enabled: false });
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setQrDataUrl(null);
|
||||
toast.success('WhatsApp disconnected');
|
||||
fetchConfig();
|
||||
} catch {
|
||||
toast.error('Failed to disconnect WhatsApp');
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{config && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div
|
||||
className={`h-2.5 w-2.5 rounded-full shrink-0 ${config.running ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
|
||||
/>
|
||||
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||
{config.running ? `Connected as +${config.phone}` : config.enabled ? 'Starting...' : 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SetupGuide />
|
||||
|
||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
|
||||
{qrDataUrl && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="WhatsApp QR Code"
|
||||
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10"
|
||||
/>
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">Scan this QR code with WhatsApp</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
className="w-full h-11 cursor-pointer"
|
||||
>
|
||||
{isDisconnecting ? 'Disconnecting...' : 'Disconnect WhatsApp'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect WhatsApp'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,18 +11,74 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
import { GoogleAccount } from './GoogleAccount';
|
||||
import { DiscordBotConfig } from './DiscordBotConfig';
|
||||
import { DiscordAccount } from './DiscordAccount';
|
||||
import { TelegramBotConfig } from './TelegramBotConfig';
|
||||
import { TelegramAccount } from './TelegramAccount';
|
||||
import { WhatsAppBotConfig } from './WhatsAppBotConfig';
|
||||
import { WhatsAppAccount } from './WhatsAppAccount';
|
||||
|
||||
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||
|
||||
const enterpriseSections: SettingsSection[] = [
|
||||
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
|
||||
{ key: 'discord-bot', icon: MessageCircle, title: 'Discord Bot', description: 'Bot token for Discord integration', content: <DiscordBotConfig /> },
|
||||
{
|
||||
key: 'google-oauth',
|
||||
icon: KeyRound,
|
||||
title: 'Google OAuth',
|
||||
description: 'Client ID and secret for Google APIs',
|
||||
content: <GoogleOAuthConfig />,
|
||||
},
|
||||
{
|
||||
key: 'discord-bot',
|
||||
icon: MessageCircle,
|
||||
title: 'Discord Bot',
|
||||
description: 'Bot token for Discord integration',
|
||||
content: <DiscordBotConfig />,
|
||||
},
|
||||
{
|
||||
key: 'telegram-bot',
|
||||
icon: MessageCircle,
|
||||
title: 'Telegram Bot',
|
||||
description: 'Bot token for Telegram integration',
|
||||
content: <TelegramBotConfig />,
|
||||
},
|
||||
{
|
||||
key: 'whatsapp-bot',
|
||||
icon: MessageCircle,
|
||||
title: 'WhatsApp',
|
||||
description: 'WhatsApp Web connection',
|
||||
content: <WhatsAppBotConfig />,
|
||||
},
|
||||
];
|
||||
|
||||
const personalSections: SettingsSection[] = [
|
||||
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
|
||||
{ key: 'discord-account', icon: MessageCircle, title: 'Discord', description: 'Link your Discord account', content: <DiscordAccount /> },
|
||||
{
|
||||
key: 'google-account',
|
||||
icon: UserCircle,
|
||||
title: 'Google Account',
|
||||
description: 'Connect your Google account',
|
||||
content: <GoogleAccount />,
|
||||
},
|
||||
{
|
||||
key: 'discord-account',
|
||||
icon: MessageCircle,
|
||||
title: 'Discord',
|
||||
description: 'Link your Discord account',
|
||||
content: <DiscordAccount />,
|
||||
},
|
||||
{
|
||||
key: 'telegram-account',
|
||||
icon: MessageCircle,
|
||||
title: 'Telegram',
|
||||
description: 'Link your Telegram account',
|
||||
content: <TelegramAccount />,
|
||||
},
|
||||
{
|
||||
key: 'whatsapp-account',
|
||||
icon: MessageCircle,
|
||||
title: 'WhatsApp',
|
||||
description: 'Link your WhatsApp account',
|
||||
content: <WhatsAppAccount />,
|
||||
},
|
||||
];
|
||||
|
||||
const IntegrationsSidebar = () => {
|
||||
|
||||
Reference in New Issue
Block a user