remove the telegram and whatsapp channels, reduce discord to notifications

All three existed to drive the platform from a chat app. The phone app does that now, so
they are dead weight — three bot gateways, three command parsers, account pairing, admin
config screens and bot tokens sitting in the database.

Gone entirely: telegram/ and whatsapp/, discord/'s bot and command handler, the shared
channel plumbing they were the only users of (pairing.ts, send-and-await.ts, types.ts,
routes.ts and its 20 config/pairing/status endpoints), the six settings components, and
their sections in the integrations screen.

What Discord keeps is the one piece worth keeping — pushing a message out — as
notify/discord.ts, configured by DISCORD_WEBHOOK_URL in the env. No UI, no pairing, no
stored credential, and it never throws: a notification that fails to send is logged and
dropped. Unset means notifications are silently skipped, which is the default state.

Kept deliberately: send-claude-code.ts and send-opencode.ts. They live under channels/ but
have nothing to do with chat apps — they are how /chat and the pipeline executor drive an
agent turn.

Also drops four dependencies with no remaining importer (discord.js,
node-telegram-bot-api, whatsapp-web.js, qrcode), and the /sync-now route added to the email
sidecar an hour ago, whose only consumer was the channel handlers.

The "Channel Models" settings section stays, with its description corrected — it is keyed
off the general access policy rather than anything channel-specific, so it governs non-owner
accounts, not chat apps. Whether that whole class still earns its place is the open
question already noted against origin-validation.

Not touched: the telegram, whatsapp and discord rows in server_integrations, which still
hold their bot tokens. Deleting rows is a different kind of decision and the SQL is in the
handover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 14:05:28 +00:00
co-authored by Claude Opus 5
parent e9e144962d
commit c9d680b133
27 changed files with 58 additions and 3576 deletions
@@ -424,7 +424,7 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
key: 'member-models',
icon: Eye,
title: 'Channel Models',
description: 'Models reachable from Telegram, WhatsApp and Discord',
description: 'Models reachable by non-owner accounts',
content: <MemberModelsSection />,
},
],
@@ -1,180 +0,0 @@
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 DiscordConnection = {
linked: boolean;
discordId?: string;
};
type DiscordStatus = {
configured: boolean;
running: boolean;
serverInvite: string | null;
botHandle: string | null;
};
export const DiscordAccount = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [connection, setConnection] = useState<DiscordConnection>({ linked: false });
const [botStatus, setBotStatus] = useState<DiscordStatus>({
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<DiscordConnection>('/channels/discord/connection').then(setConnection),
client.get<DiscordStatus>('/channels/discord/status').then(setBotStatus),
])
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const handleGenerateCode = async () => {
setIsGenerating(true);
try {
const res = await client.post<{ code: string; expiresIn: number }>('/channels/discord/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/discord/connection');
setConnection({ linked: false });
setPairingCode(null);
toast.success('Discord account unlinked');
} catch {
toast.error('Failed to unlink Discord 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">
Discord integration has not been configured yet. Ask your administrator to set up the Discord 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">Server:</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">
Discord ID: {connection.discordId}
</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 Discord 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 Discord
</Button>
</div>
);
}
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Link your Discord 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 Discord. 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 Discord'}
</Button>
)}
</div>
);
};
@@ -1,237 +0,0 @@
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 DiscordConfig = {
configured: boolean;
enabled: boolean;
botToken: string | null;
serverInvite: string | null;
botHandle: string | null;
};
type DiscordStatus = {
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">Create a Discord application</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
Discord Developer Portal
</a>{' '}
and click <strong>New Application</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Fill in <strong>Name</strong> (e.g. &quot;Officer&quot;), optionally a <strong>Description</strong> and <strong>Tags</strong></li>
<li>Leave everything else empty <strong>Interactions Endpoint URL</strong>, <strong>Linked Roles Verification URL</strong>, and <strong>Terms of Service / Privacy Policy URLs</strong> are not needed</li>
</ul>
<p className="mt-1">Click <strong>Create</strong>.</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
You will see an Application ID and Public Key on this page you can ignore both. Our bot connects via the gateway (WebSocket), not webhooks, so these are not used.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Go to the Bot page</strong>
<p className="mt-1">
In the left sidebar, click <strong>Bot</strong>. A bot user is created automatically with your application.
You can optionally set a custom username and avatar here this is what users will see when they DM the bot.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Enable privileged intents</strong>
<p className="mt-1">
Still on the <strong>Bot</strong> page, scroll down to <strong>Privileged Gateway Intents</strong> and enable:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Presence Intent</strong> shows the bot as online in your server</li>
<li><strong>Server Members Intent</strong> makes the bot visible in the member list so users can find and DM it</li>
<li><strong>Message Content Intent</strong> required to read DM message text</li>
</ul>
<p className="mt-1">Click <strong>Save Changes</strong>.</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the bot token</strong>
<p className="mt-1">
On the <strong>Bot</strong> page, click <strong>Reset Token</strong> (or <strong>View Token</strong> if this is a new bot).
Copy the token you will only see it once.
</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. If it leaks, reset it immediately from this page.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Invite the bot to your server (optional)</strong>
<p className="mt-1">
Open this URL in your browser, replacing{' '}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">YOUR_APP_ID</code>{' '}
with the Application ID from the <strong>General Information</strong> page:
</p>
<code className="mt-1.5 block text-xs bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2 rounded break-all">
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&amp;scope=bot&amp;permissions=0
</code>
<p className="mt-1.5">
Select your server and click <strong>Authorize</strong>. No bot permissions are needed leave them at zero.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Adding the bot to a shared server is optional but has a few advantages:
</p>
<ul className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50 list-disc list-outside pl-5 grid gap-0.5">
<li>Users can find the bot in the member list and right-click <strong>Message</strong> to start a DM</li>
<li>The bot shows as online in the server, so users can see at a glance whether it&apos;s running</li>
<li>Easier onboarding you can pin the pairing instructions in a channel for your team</li>
</ul>
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
Without a shared server, users can still DM the bot by searching its exact username but a shared server makes discovery much simpler.
</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 Discord accounts from their personal integration settings.
</p>
</li>
</ol>
</CollapsibleContent>
</Collapsible>
);
};
export const DiscordBotConfig = () => {
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<DiscordStatus | null>(null);
const fetchStatus = () => {
client
.get<DiscordStatus>('/channels/discord/status')
.then(setStatus)
.catch(() => {});
};
useEffect(() => {
Promise.all([
client.get<DiscordConfig>('/channels/discord/config').then((data) => {
if (data.botToken) setBotToken(data.botToken);
if (data.serverInvite) setServerInvite(data.serverInvite);
if (data.botHandle) setBotHandle(data.botHandle);
}),
client.get<DiscordStatus>('/channels/discord/status').then(setStatus),
])
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
await client.put('/channels/discord/config', {
botToken: botToken.trim(),
serverInvite: serverInvite.trim() || undefined,
botHandle: botHandle.trim() || undefined,
});
toast.success('Discord bot configuration saved');
fetchStatus();
} catch {
toast.error('Failed to save Discord 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="MTIz..."
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Server 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://discord.gg/..."
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Shown to users so they can join the server 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-bot#1234"
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
The bot&apos;s username#discriminator shown to users who want to DM 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>
);
};
@@ -1,199 +0,0 @@
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;
botUsername: string | null;
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,
botUsername: null,
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 botHandle = botStatus.botUsername ? `@${botStatus.botUsername}` : botStatus.botHandle;
const botLink = botStatus.botUsername ? `https://t.me/${botStatus.botUsername}` : null;
const connectionInfo = (botHandle || botStatus.serverInvite) && (
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
{botHandle && (
<div className="flex items-center gap-2 text-sm">
<span className="text-duck-dark/50 dark:text-foreground/50 shrink-0">Bot:</span>
{botLink ? (
<a href={botLink} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
{botHandle}
</a>
) : (
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-2 py-1 rounded">{botHandle}</code>
)}
</div>
)}
{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>
)}
</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 agent via direct messages{botHandle ? ` to ${botHandle}` : ''}.
</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{' '}
{botLink ? (
<a href={botLink} target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
{botHandle}
</a>
) : (
<strong>{botHandle ?? 'the bot'}</strong>
)}{' '}
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>
);
};
@@ -1,254 +0,0 @@
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&apos;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 &quot;bot&quot;, 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&apos;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 [isToggling, setIsToggling] = 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,
enabled: true,
});
toast.success('Telegram bot configuration saved');
fetchStatus();
} catch {
toast.error('Failed to save Telegram bot configuration');
} finally {
setIsSaving(false);
}
};
const handleDisconnect = async () => {
setIsToggling(true);
try {
await client.put('/channels/telegram/config', { enabled: false });
toast.success('Telegram bot disconnected');
fetchStatus();
} catch {
toast.error('Failed to disconnect Telegram bot');
} finally {
setIsToggling(false);
}
};
const handleConnect = async () => {
setIsToggling(true);
try {
await client.put('/channels/telegram/config', { enabled: true });
toast.success('Telegram bot connected');
fetchStatus();
} catch {
toast.error('Failed to connect Telegram bot');
} finally {
setIsToggling(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
? status.enabled
? 'Bot configured but not running'
: 'Disconnected'
: '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&apos;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>
{status?.running ? (
<Button
type="button"
variant="outline"
onClick={handleDisconnect}
disabled={isToggling}
className="w-full h-11 cursor-pointer"
>
{isToggling ? 'Disconnecting...' : 'Disconnect'}
</Button>
) : status?.configured && !status.enabled ? (
<Button
type="button"
variant="outline"
onClick={handleConnect}
disabled={isToggling}
className="w-full h-11 cursor-pointer"
>
{isToggling ? 'Connecting...' : 'Connect'}
</Button>
) : null}
</div>
</div>
);
};
@@ -1,159 +0,0 @@
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>
);
};
@@ -1,215 +0,0 @@
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 QRResponse = {
qr: string | null;
running: boolean;
phone: 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">Click &quot;Connect WhatsApp&quot; 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&apos;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 pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchConfig = useCallback(() => {
client
.get<WhatsAppConfig>('/channels/whatsapp/config')
.then(setConfig)
.catch(() => {});
}, [client]);
const stopPolling = useCallback(() => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, []);
const pollQR = useCallback(() => {
client
.get<QRResponse>('/channels/whatsapp/qr')
.then(async (data) => {
if (data.running) {
// Authenticated — stop polling, update config
stopPolling();
setQrDataUrl(null);
fetchConfig();
return;
}
if (data.qr) {
const dataUrl = await QRCode.toDataURL(data.qr, { width: 256, margin: 2 });
setQrDataUrl(dataUrl);
}
})
.catch(() => {});
}, [client, fetchConfig, stopPolling]);
const startPolling = useCallback(() => {
stopPolling();
pollQR();
pollingRef.current = setInterval(pollQR, 2000);
}, [pollQR, stopPolling]);
useEffect(() => {
client
.get<WhatsAppConfig>('/channels/whatsapp/config')
.then((data) => {
setConfig(data);
// Auto-poll QR if bot is enabled but waiting for scan
if (data.enabled && !data.running) {
startPolling();
}
})
.catch(() => {})
.finally(() => setIsLoading(false));
return stopPolling;
}, []);
const handleConnect = async () => {
setIsConnecting(true);
try {
await client.put('/channels/whatsapp/config', { enabled: true });
startPolling();
} 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 });
stopPolling();
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
? 'Waiting for QR scan...'
: '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 || config?.enabled ? (
<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>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { Puzzle, KeyRound, UserCircle, MessageCircle, Globe, Wrench, Mail } from 'lucide-react';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
@@ -9,12 +9,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { DiscordBotConfig } from './DiscordBotConfig';
import { DiscordAccount } from './DiscordAccount';
import { TelegramBotConfig } from './TelegramBotConfig';
import { TelegramAccount } from './TelegramAccount';
import { WhatsAppBotConfig } from './WhatsAppBotConfig';
import { WhatsAppAccount } from './WhatsAppAccount';
import { BrowserRelay } from './BrowserRelay';
import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
@@ -30,27 +24,6 @@ const enterpriseSections: SettingsSection[] = [
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 />,
},
{
key: 'apify',
icon: Wrench,
@@ -77,27 +50,6 @@ const personalSections: SettingsSection[] = [
// 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 />,
},
{
key: 'browser-relay',
icon: Globe,