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:
@@ -107,11 +107,13 @@
|
|||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"node-telegram-bot-api": "^0.67.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"officerdb": "workspace:*",
|
"officerdb": "workspace:*",
|
||||||
"officerdev": "workspace:*",
|
"officerdev": "workspace:*",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-countup": "^6.5.3",
|
"react-countup": "^6.5.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
@@ -136,6 +138,7 @@
|
|||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"types": "workspace:*",
|
"types": "workspace:*",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"whatsapp-web.js": "^1.34.6",
|
||||||
"widgets": "workspace:*",
|
"widgets": "workspace:*",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
@@ -147,8 +150,10 @@
|
|||||||
"@types/bun": "1.3.5",
|
"@types/bun": "1.3.5",
|
||||||
"@types/chrome": "^0.1.36",
|
"@types/chrome": "^0.1.36",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/node-telegram-bot-api": "^0.64.14",
|
||||||
"@types/nodemailer": "^7.0.5",
|
"@types/nodemailer": "^7.0.5",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
|
|||||||
+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 { GoogleAccount } from './GoogleAccount';
|
||||||
import { DiscordBotConfig } from './DiscordBotConfig';
|
import { DiscordBotConfig } from './DiscordBotConfig';
|
||||||
import { DiscordAccount } from './DiscordAccount';
|
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 GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||||
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||||
|
|
||||||
const enterpriseSections: SettingsSection[] = [
|
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[] = [
|
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 = () => {
|
const IntegrationsSidebar = () => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { migrateSettingsToResources } from './migrate-resources';
|
|||||||
import { generateResourceSkill } from './api/pi/pi-bridge';
|
import { generateResourceSkill } from './api/pi/pi-bridge';
|
||||||
import { initQueue } from './queue';
|
import { initQueue } from './queue';
|
||||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||||
|
import { startTelegramBotIfConfigured } from './channels/telegram/bot';
|
||||||
|
import { startWhatsAppBotIfConfigured } from './channels/whatsapp/bot';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
|
|
||||||
@@ -59,11 +61,19 @@ async function installPi(): Promise<boolean> {
|
|||||||
await migrateSettingsToResources();
|
await migrateSettingsToResources();
|
||||||
generateResourceSkill(DATA_PATH);
|
generateResourceSkill(DATA_PATH);
|
||||||
|
|
||||||
await initQueue().catch(err => {
|
await initQueue().catch((err) => {
|
||||||
console.error('[bootstrap] Failed to initialize queue:', err);
|
console.error('[bootstrap] Failed to initialize queue:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
await startDiscordBotIfConfigured().catch(err => {
|
await startDiscordBotIfConfigured().catch((err) => {
|
||||||
console.error('[channels] Failed to start Discord bot:', err);
|
console.error('[channels] Failed to start Discord bot:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await startTelegramBotIfConfigured().catch((err) => {
|
||||||
|
console.error('[channels] Failed to start Telegram bot:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await startWhatsAppBotIfConfigured().catch((err) => {
|
||||||
|
console.error('[channels] Failed to start WhatsApp bot:', err);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { ChannelProvider } from './types';
|
import type { ChannelProvider } from './types';
|
||||||
import { upsertUserIntegration } from 'officerdb';
|
import { upsertUserIntegration } from 'officerdb';
|
||||||
|
|
||||||
|
const configKeyMap: Record<ChannelProvider, string> = {
|
||||||
|
discord: 'discordId',
|
||||||
|
telegram: 'telegramId',
|
||||||
|
whatsapp: 'whatsappId',
|
||||||
|
};
|
||||||
|
|
||||||
type PairingEntry = {
|
type PairingEntry = {
|
||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -71,7 +77,7 @@ export async function consumePairingCode(code: string, channelUserId: string): P
|
|||||||
await upsertUserIntegration({
|
await upsertUserIntegration({
|
||||||
userId: entry.userId,
|
userId: entry.userId,
|
||||||
provider: entry.provider,
|
provider: entry.provider,
|
||||||
config: { discordId: channelUserId },
|
config: { [configKeyMap[entry.provider]]: channelUserId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { userId: entry.userId, email: entry.email, provider: entry.provider };
|
return { userId: entry.userId, email: entry.email, provider: entry.provider };
|
||||||
|
|||||||
+270
-12
@@ -1,11 +1,16 @@
|
|||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import {
|
import { getServerIntegration, upsertServerIntegration, getUserIntegration, deleteUserIntegration } from 'officerdb';
|
||||||
getServerIntegration,
|
|
||||||
upsertServerIntegration,
|
|
||||||
getUserIntegration,
|
|
||||||
deleteUserIntegration,
|
|
||||||
} from 'officerdb';
|
|
||||||
import { startDiscordBot, stopDiscordBot, isDiscordBotRunning, getDiscordBotUsername } from './discord/bot';
|
import { startDiscordBot, stopDiscordBot, isDiscordBotRunning, getDiscordBotUsername } from './discord/bot';
|
||||||
|
import { startTelegramBot, stopTelegramBot, isTelegramBotRunning, getTelegramBotUsername } from './telegram/bot';
|
||||||
|
import {
|
||||||
|
startWhatsAppBot,
|
||||||
|
stopWhatsAppBot,
|
||||||
|
isWhatsAppBotRunning,
|
||||||
|
getWhatsAppBotPhone,
|
||||||
|
getWhatsAppQR,
|
||||||
|
subscribeQR,
|
||||||
|
disconnectWhatsApp,
|
||||||
|
} from './whatsapp/bot';
|
||||||
import { generatePairingCode } from './pairing';
|
import { generatePairingCode } from './pairing';
|
||||||
|
|
||||||
export const channelsRouter = createRouter();
|
export const channelsRouter = createRouter();
|
||||||
@@ -52,27 +57,36 @@ channelsRouter.put('/discord/config', async (ctx) => {
|
|||||||
if (serverInvite !== undefined) newConfig.serverInvite = serverInvite;
|
if (serverInvite !== undefined) newConfig.serverInvite = serverInvite;
|
||||||
if (botHandle !== undefined) newConfig.botHandle = botHandle;
|
if (botHandle !== undefined) newConfig.botHandle = botHandle;
|
||||||
|
|
||||||
await upsertServerIntegration('discord', newConfig, enabled ?? existing?.enabled ?? true);
|
|
||||||
|
|
||||||
// Restart bot if running or if we have a token and it's enabled
|
|
||||||
const shouldRun = enabled ?? existing?.enabled ?? true;
|
const shouldRun = enabled ?? existing?.enabled ?? true;
|
||||||
const token = (botToken ?? existingConfig.botToken) as string | undefined;
|
const token = (botToken ?? existingConfig.botToken) as string | undefined;
|
||||||
|
|
||||||
if (token && shouldRun) {
|
// If a new token is provided, validate it by starting the bot before saving
|
||||||
|
if (botToken && shouldRun) {
|
||||||
|
try {
|
||||||
|
await startDiscordBot(botToken);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[channels] Discord bot token validation failed:', err);
|
||||||
|
return ctx.json({ error: 'Invalid bot token — connection failed' }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertServerIntegration('discord', newConfig, shouldRun);
|
||||||
|
|
||||||
|
// Start/restart with existing token (already validated on initial save)
|
||||||
|
if (!botToken && token && shouldRun) {
|
||||||
try {
|
try {
|
||||||
await startDiscordBot(token);
|
await startDiscordBot(token);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[channels] Failed to start Discord bot:', err);
|
console.error('[channels] Failed to start Discord bot:', err);
|
||||||
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
||||||
}
|
}
|
||||||
return ctx.json({ success: true, botStarted: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldRun) {
|
if (!shouldRun) {
|
||||||
await stopDiscordBot();
|
await stopDiscordBot();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.json({ success: true, botStarted: false });
|
return ctx.json({ success: true, botStarted: token && shouldRun });
|
||||||
});
|
});
|
||||||
|
|
||||||
channelsRouter.get('/discord/status', async (ctx) => {
|
channelsRouter.get('/discord/status', async (ctx) => {
|
||||||
@@ -114,3 +128,247 @@ channelsRouter.delete('/discord/connection', async (ctx) => {
|
|||||||
const deleted = await deleteUserIntegration(user.id, 'discord');
|
const deleted = await deleteUserIntegration(user.id, 'discord');
|
||||||
return ctx.json({ success: deleted });
|
return ctx.json({ success: deleted });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Admin: Telegram config ──
|
||||||
|
|
||||||
|
channelsRouter.get('/telegram/config', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const integration = await getServerIntegration('telegram');
|
||||||
|
if (!integration) return ctx.json({ configured: false });
|
||||||
|
|
||||||
|
const config = integration.config as Record<string, unknown>;
|
||||||
|
const botToken = config.botToken as string | undefined;
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
configured: !!botToken,
|
||||||
|
enabled: integration.enabled,
|
||||||
|
botToken: botToken ? `${botToken.slice(0, 8)}...${botToken.slice(-4)}` : null,
|
||||||
|
serverInvite: (config.serverInvite as string) ?? null,
|
||||||
|
botHandle: (config.botHandle as string) ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.put('/telegram/config', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const body = ctx.get('body') as Record<string, unknown>;
|
||||||
|
const botToken = body.botToken as string | undefined;
|
||||||
|
const enabled = body.enabled as boolean | undefined;
|
||||||
|
const serverInvite = body.serverInvite as string | undefined;
|
||||||
|
const botHandle = body.botHandle as string | undefined;
|
||||||
|
|
||||||
|
if (!botToken && enabled === undefined && serverInvite === undefined && botHandle === undefined) {
|
||||||
|
return ctx.json({ error: 'At least one field required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getServerIntegration('telegram');
|
||||||
|
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>;
|
||||||
|
const newConfig = { ...existingConfig };
|
||||||
|
if (botToken) newConfig.botToken = botToken;
|
||||||
|
if (serverInvite !== undefined) newConfig.serverInvite = serverInvite;
|
||||||
|
if (botHandle !== undefined) newConfig.botHandle = botHandle;
|
||||||
|
|
||||||
|
const shouldRun = enabled ?? existing?.enabled ?? true;
|
||||||
|
const token = (botToken ?? existingConfig.botToken) as string | undefined;
|
||||||
|
|
||||||
|
// If a new token is provided, validate it by starting the bot before saving
|
||||||
|
if (botToken && shouldRun) {
|
||||||
|
try {
|
||||||
|
await startTelegramBot(botToken);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[channels] Telegram bot token validation failed:', err);
|
||||||
|
return ctx.json({ error: 'Invalid bot token — connection failed' }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertServerIntegration('telegram', newConfig, shouldRun);
|
||||||
|
|
||||||
|
// Start/restart with existing token (already validated on initial save)
|
||||||
|
if (!botToken && token && shouldRun) {
|
||||||
|
try {
|
||||||
|
await startTelegramBot(token);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[channels] Failed to start Telegram bot:', err);
|
||||||
|
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRun) {
|
||||||
|
await stopTelegramBot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ success: true, botStarted: token && shouldRun });
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.get('/telegram/status', async (ctx) => {
|
||||||
|
const integration = await getServerIntegration('telegram');
|
||||||
|
const config = (integration?.config ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
configured: !!config.botToken,
|
||||||
|
enabled: integration?.enabled ?? false,
|
||||||
|
running: isTelegramBotRunning(),
|
||||||
|
botUsername: getTelegramBotUsername(),
|
||||||
|
serverInvite: (config.serverInvite as string) ?? null,
|
||||||
|
botHandle: (config.botHandle as string) ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── User: Telegram pairing ──
|
||||||
|
|
||||||
|
channelsRouter.post('/telegram/pair', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const code = generatePairingCode(user.id, user.email, 'telegram');
|
||||||
|
return ctx.json({ code, expiresIn: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.get('/telegram/connection', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const integration = await getUserIntegration(user.id, 'telegram');
|
||||||
|
if (!integration) return ctx.json({ linked: false });
|
||||||
|
|
||||||
|
const config = integration.config as Record<string, unknown>;
|
||||||
|
return ctx.json({
|
||||||
|
linked: true,
|
||||||
|
telegramId: config.telegramId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.delete('/telegram/connection', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const deleted = await deleteUserIntegration(user.id, 'telegram');
|
||||||
|
return ctx.json({ success: deleted });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: WhatsApp config ──
|
||||||
|
|
||||||
|
channelsRouter.get('/whatsapp/config', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const integration = await getServerIntegration('whatsapp');
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
configured: integration?.enabled ?? false,
|
||||||
|
enabled: integration?.enabled ?? false,
|
||||||
|
running: isWhatsAppBotRunning(),
|
||||||
|
phone: getWhatsAppBotPhone(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.put('/whatsapp/config', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
const body = ctx.get('body') as Record<string, unknown>;
|
||||||
|
const enabled = body.enabled as boolean | undefined;
|
||||||
|
|
||||||
|
if (enabled === true) {
|
||||||
|
await upsertServerIntegration('whatsapp', {}, true);
|
||||||
|
try {
|
||||||
|
await startWhatsAppBot();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[channels] Failed to start WhatsApp bot:', err);
|
||||||
|
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
||||||
|
}
|
||||||
|
return ctx.json({ success: true, botStarted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled === false) {
|
||||||
|
await disconnectWhatsApp();
|
||||||
|
return ctx.json({ success: true, botStarted: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ error: 'enabled field required' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.get('/whatsapp/status', async (ctx) => {
|
||||||
|
const integration = await getServerIntegration('whatsapp');
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
configured: integration?.enabled ?? false,
|
||||||
|
enabled: integration?.enabled ?? false,
|
||||||
|
running: isWhatsAppBotRunning(),
|
||||||
|
phone: getWhatsAppBotPhone(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.get('/whatsapp/qr', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
// SSE stream for QR code updates
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const sendEvent = (data: Record<string, unknown>) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send current QR if available
|
||||||
|
const currentQR = getWhatsAppQR();
|
||||||
|
if (currentQR) {
|
||||||
|
sendEvent({ type: 'qr', qr: currentQR });
|
||||||
|
} else if (isWhatsAppBotRunning()) {
|
||||||
|
sendEvent({ type: 'authenticated', phone: getWhatsAppBotPhone() });
|
||||||
|
} else {
|
||||||
|
sendEvent({ type: 'waiting' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = subscribeQR((qr, event) => {
|
||||||
|
if (event === 'qr' && qr) {
|
||||||
|
sendEvent({ type: 'qr', qr });
|
||||||
|
} else if (event === 'authenticated') {
|
||||||
|
sendEvent({ type: 'authenticated', phone: getWhatsAppBotPhone() });
|
||||||
|
} else if (event === 'disconnected') {
|
||||||
|
sendEvent({ type: 'disconnected' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up when client disconnects
|
||||||
|
ctx.req.raw.signal.addEventListener('abort', () => {
|
||||||
|
unsubscribe();
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── User: WhatsApp pairing ──
|
||||||
|
|
||||||
|
channelsRouter.post('/whatsapp/pair', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const code = generatePairingCode(user.id, user.email, 'whatsapp');
|
||||||
|
return ctx.json({ code, expiresIn: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.get('/whatsapp/connection', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const integration = await getUserIntegration(user.id, 'whatsapp');
|
||||||
|
if (!integration) return ctx.json({ linked: false });
|
||||||
|
|
||||||
|
const config = integration.config as Record<string, unknown>;
|
||||||
|
return ctx.json({
|
||||||
|
linked: true,
|
||||||
|
whatsappId: config.whatsappId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
channelsRouter.delete('/whatsapp/connection', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const deleted = await deleteUserIntegration(user.id, 'whatsapp');
|
||||||
|
return ctx.json({ success: deleted });
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { getServerIntegration } from 'officerdb';
|
||||||
|
import { handleTelegramMessage } from './handler';
|
||||||
|
|
||||||
|
let bot: TelegramBot | null = null;
|
||||||
|
let cachedUsername: string | null = null;
|
||||||
|
|
||||||
|
export async function startTelegramBot(token: string): Promise<void> {
|
||||||
|
if (bot) {
|
||||||
|
await stopTelegramBot();
|
||||||
|
}
|
||||||
|
|
||||||
|
bot = new TelegramBot(token, { polling: true });
|
||||||
|
|
||||||
|
bot.on('message', (msg) => {
|
||||||
|
handleTelegramMessage(msg).catch((err) => {
|
||||||
|
console.error('[telegram] Unhandled error in message handler:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const me = await bot.getMe();
|
||||||
|
cachedUsername = me.username ?? null;
|
||||||
|
console.log(`[telegram] Bot logged in as @${cachedUsername}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopTelegramBot(): Promise<void> {
|
||||||
|
if (bot) {
|
||||||
|
await bot.stopPolling();
|
||||||
|
bot = null;
|
||||||
|
cachedUsername = null;
|
||||||
|
console.log('[telegram] Bot stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTelegramBotRunning(): boolean {
|
||||||
|
return bot !== null && bot.isPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTelegramBotUsername(): string | null {
|
||||||
|
return cachedUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTelegramBot(): TelegramBot | null {
|
||||||
|
return bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTelegramBotIfConfigured(): Promise<void> {
|
||||||
|
const integration = await getServerIntegration('telegram');
|
||||||
|
if (!integration?.enabled) return;
|
||||||
|
|
||||||
|
const config = integration.config as Record<string, unknown>;
|
||||||
|
const botToken = config.botToken as string | undefined;
|
||||||
|
if (!botToken) return;
|
||||||
|
|
||||||
|
await startTelegramBot(botToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const MAX_LENGTH = 4096;
|
||||||
|
|
||||||
|
export function chunkMessage(text: string): string[] {
|
||||||
|
if (text.length <= MAX_LENGTH) return [text];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const paragraphs = text.split('\n\n');
|
||||||
|
|
||||||
|
let current = '';
|
||||||
|
|
||||||
|
for (const paragraph of paragraphs) {
|
||||||
|
if (paragraph.length > MAX_LENGTH) {
|
||||||
|
// Flush current chunk
|
||||||
|
if (current) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
// Split long paragraph on newlines
|
||||||
|
const lines = paragraph.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.length > MAX_LENGTH) {
|
||||||
|
// Flush current
|
||||||
|
if (current) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
// Hard-split long line
|
||||||
|
for (let i = 0; i < line.length; i += MAX_LENGTH) {
|
||||||
|
chunks.push(line.slice(i, i + MAX_LENGTH));
|
||||||
|
}
|
||||||
|
} else if (current.length + 1 + line.length > MAX_LENGTH) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
current = line;
|
||||||
|
} else {
|
||||||
|
current += (current ? '\n' : '') + line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (current.length + 2 + paragraph.length > MAX_LENGTH) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
current = paragraph;
|
||||||
|
} else {
|
||||||
|
current += (current ? '\n\n' : '') + paragraph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim()) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import type TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { findUserByIntegrationConfig, readConfigValue } from 'officerdb';
|
||||||
|
import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await';
|
||||||
|
import { consumePairingCode } from '../pairing';
|
||||||
|
import { chunkMessage } from './chunker';
|
||||||
|
import { getTelegramBot } from './bot';
|
||||||
|
import { listPiModels } from '@@/api/pi/list-models';
|
||||||
|
import { enqueue } from '@@/queue/engine';
|
||||||
|
import { readJob } from '@@/queue/storage';
|
||||||
|
import { openEmailDb } from '@@/api/email/email-db';
|
||||||
|
import type { ModelInfo } from '@@/api/pi/types';
|
||||||
|
|
||||||
|
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||||
|
const TYPING_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
type SendFn = (text: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
type AccessPolicy = { allowedModels: string[] };
|
||||||
|
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
||||||
|
|
||||||
|
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||||
|
const allModels = await listPiModels();
|
||||||
|
const policy = await readConfigValue<AccessPolicy>(ACCESS_POLICY_KEY, { allowedModels: [] });
|
||||||
|
const allowed = policy.allowedModels;
|
||||||
|
|
||||||
|
if (allowed.length === 0) return allModels;
|
||||||
|
|
||||||
|
const allowedSet = new Set(allowed);
|
||||||
|
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
|
||||||
|
|
||||||
|
return allModels.filter((m) => {
|
||||||
|
const key = `${m.provider}:${m.id}`;
|
||||||
|
const isExplicitlyAllowed = allowedSet.has(key);
|
||||||
|
const isFromNewProvider = !allowedProviderSet.has(m.provider);
|
||||||
|
return isExplicitlyAllowed || isFromNewProvider;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandContext = {
|
||||||
|
content: string;
|
||||||
|
send: SendFn;
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
telegramId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||||
|
const { send, email } = ctx;
|
||||||
|
|
||||||
|
let countBefore = 0;
|
||||||
|
try {
|
||||||
|
const db = openEmailDb(email);
|
||||||
|
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
||||||
|
countBefore = row.count;
|
||||||
|
db.close();
|
||||||
|
} catch {
|
||||||
|
// DB might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
await send('Syncing emails...');
|
||||||
|
|
||||||
|
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
|
||||||
|
|
||||||
|
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
const current = await readJob(job.id);
|
||||||
|
if (!current) return 'failed';
|
||||||
|
if (current.status === 'completed' || current.status === 'failed' || current.status === 'cancelled') {
|
||||||
|
return current.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await poll();
|
||||||
|
|
||||||
|
if (status !== 'completed') {
|
||||||
|
await send('Email sync failed. Check the queue dashboard for details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = openEmailDb(email);
|
||||||
|
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
||||||
|
const countAfter = row.count;
|
||||||
|
const newCount = countAfter - countBefore;
|
||||||
|
|
||||||
|
if (newCount <= 0) {
|
||||||
|
db.close();
|
||||||
|
await send('Sync complete — no new emails.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = db
|
||||||
|
.query('SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?')
|
||||||
|
.all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>;
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const lines = newest.map((e) => {
|
||||||
|
const sender = e.from_name || e.from_address;
|
||||||
|
return `- *${sender}*: ${e.subject}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let text = `Sync complete — *${newCount}* new email${newCount !== 1 ? 's' : ''}`;
|
||||||
|
if (newCount > 20) text += ` (showing latest 20)`;
|
||||||
|
text += `:\n\n${lines.join('\n')}`;
|
||||||
|
|
||||||
|
const chunks = chunkMessage(text);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await send(chunk);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
||||||
|
const { content, send, telegramId } = ctx;
|
||||||
|
const lower = content.toLowerCase();
|
||||||
|
|
||||||
|
const helpSections: Record<string, string> = {
|
||||||
|
models:
|
||||||
|
'*Models:*\n' +
|
||||||
|
'`!model` — show current model\n' +
|
||||||
|
'`!model <id>` — switch model\n' +
|
||||||
|
'`!models` — list available models',
|
||||||
|
email: '*Email:*\n' + '`!email sync` — sync Gmail and show new emails',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lower === '!help' || lower.startsWith('!help ')) {
|
||||||
|
const topic = content.slice('!help'.length).trim().toLowerCase();
|
||||||
|
if (topic && topic in helpSections) {
|
||||||
|
await send(helpSections[topic]!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (topic) {
|
||||||
|
await send(
|
||||||
|
`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections)
|
||||||
|
.map((k) => `\`${k}\``)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const full = Object.values(helpSections).join('\n\n');
|
||||||
|
await send(full + '\n\n`!help <topic>` — show commands for a topic');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '!models') {
|
||||||
|
const models = await getVisibleModels();
|
||||||
|
if (models.length === 0) {
|
||||||
|
await send('No models available.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = getSessionModel('telegram', telegramId);
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const m of models) {
|
||||||
|
const list = grouped.get(m.provider) ?? [];
|
||||||
|
list.push(m.id === current ? `*${m.id}* (current)` : m.id);
|
||||||
|
grouped.set(m.provider, list);
|
||||||
|
}
|
||||||
|
let text = '*Available models:*\n';
|
||||||
|
for (const [provider, ids] of grouped) {
|
||||||
|
text += `\n_${provider}_\n${ids.map((id) => ` ${id}`).join('\n')}\n`;
|
||||||
|
}
|
||||||
|
text += '\nUse `!model <id>` to switch.';
|
||||||
|
await send(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '!model') {
|
||||||
|
const current = getSessionModel('telegram', telegramId);
|
||||||
|
await send(
|
||||||
|
current
|
||||||
|
? `Current model: *${current}*`
|
||||||
|
: 'No active session yet — the default model will be used on your next message.',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith('!model ')) {
|
||||||
|
const requested = content.slice('!model '.length).trim();
|
||||||
|
if (!requested) {
|
||||||
|
const current = getSessionModel('telegram', telegramId);
|
||||||
|
await send(current ? `Current model: *${current}*` : 'No active session yet.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const models = await getVisibleModels();
|
||||||
|
const match = models.find((m) => m.id === requested || m.name === requested);
|
||||||
|
if (!match) {
|
||||||
|
await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setSessionModel('telegram', telegramId, match.id);
|
||||||
|
await send(`Model switched to *${match.id}*. The new model will be used on your next message.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '!email sync') {
|
||||||
|
await handleEmailSync(ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleTelegramMessage(msg: TelegramBot.Message): Promise<void> {
|
||||||
|
const bot = getTelegramBot();
|
||||||
|
if (!bot) return;
|
||||||
|
|
||||||
|
// Ignore non-private chats, bot messages, non-text
|
||||||
|
if (msg.chat.type !== 'private') return;
|
||||||
|
if (msg.from?.is_bot) return;
|
||||||
|
if (!msg.text) return;
|
||||||
|
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
const telegramId = String(msg.from!.id);
|
||||||
|
const content = msg.text.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const send: SendFn = (text: string) => bot.sendMessage(chatId, text);
|
||||||
|
|
||||||
|
// Look up linked Officer user
|
||||||
|
const linked = await findUserByIntegrationConfig('telegram', 'telegramId', telegramId);
|
||||||
|
|
||||||
|
if (!linked) {
|
||||||
|
if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) {
|
||||||
|
const result = await consumePairingCode(content.toUpperCase(), telegramId);
|
||||||
|
if (result) {
|
||||||
|
await send('Account linked! You can now chat with me.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await send('Invalid or expired pairing code. Please generate a new one from Officer Settings.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(
|
||||||
|
"I don't recognize your Telegram account. To link it:\n" +
|
||||||
|
'1. Go to Officer Settings → Integrations → Telegram\n' +
|
||||||
|
'2. Click "Link Telegram" to get a pairing code\n' +
|
||||||
|
'3. Send the 6-character code to me here',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle commands
|
||||||
|
if (content.startsWith('!')) {
|
||||||
|
const handled = await handleCommand({
|
||||||
|
content,
|
||||||
|
send,
|
||||||
|
userId: linked.user.id,
|
||||||
|
email: linked.user.email,
|
||||||
|
telegramId,
|
||||||
|
});
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
const sendTyping = () => {
|
||||||
|
bot.sendChatAction(chatId, 'typing').catch(() => {});
|
||||||
|
};
|
||||||
|
const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS);
|
||||||
|
sendTyping();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendAndAwait({
|
||||||
|
userId: linked.user.id,
|
||||||
|
email: linked.user.email,
|
||||||
|
username: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||||
|
prompt: content,
|
||||||
|
context: 'telegram',
|
||||||
|
contextId: telegramId,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(typingInterval);
|
||||||
|
|
||||||
|
const signature = `\`${result.model}\`\n`;
|
||||||
|
const chunks = chunkMessage(result.text);
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
await send(i === 0 ? signature + chunks[i]! : chunks[i]!);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(typingInterval);
|
||||||
|
console.error('[telegram] Error handling message:', err);
|
||||||
|
await send('Sorry, something went wrong processing your message.').catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import { Client, LocalAuth } from 'whatsapp-web.js';
|
||||||
|
import { getServerIntegration, upsertServerIntegration } from 'officerdb';
|
||||||
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
import { handleWhatsAppMessage } from './handler';
|
||||||
|
|
||||||
|
let client: Client | null = null;
|
||||||
|
let currentQR: string | null = null;
|
||||||
|
let clientReady = false;
|
||||||
|
|
||||||
|
type QRListener = (qr: string | null, event: 'qr' | 'authenticated' | 'disconnected') => void;
|
||||||
|
const qrListeners = new Set<QRListener>();
|
||||||
|
|
||||||
|
export async function startWhatsAppBot(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
await stopWhatsAppBot();
|
||||||
|
}
|
||||||
|
|
||||||
|
clientReady = false;
|
||||||
|
currentQR = null;
|
||||||
|
|
||||||
|
client = new Client({
|
||||||
|
authStrategy: new LocalAuth({ dataPath: join(DATA_PATH, '.wwebjs_auth') }),
|
||||||
|
puppeteer: {
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('qr', (qr) => {
|
||||||
|
currentQR = qr;
|
||||||
|
for (const listener of qrListeners) {
|
||||||
|
listener(qr, 'qr');
|
||||||
|
}
|
||||||
|
console.log('[whatsapp] QR code received — scan with your phone');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('ready', () => {
|
||||||
|
clientReady = true;
|
||||||
|
currentQR = null;
|
||||||
|
for (const listener of qrListeners) {
|
||||||
|
listener(null, 'authenticated');
|
||||||
|
}
|
||||||
|
const phone = client?.info?.wid?.user ?? 'unknown';
|
||||||
|
console.log(`[whatsapp] Bot ready — phone: ${phone}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('authenticated', () => {
|
||||||
|
console.log('[whatsapp] Authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('auth_failure', (msg) => {
|
||||||
|
console.error('[whatsapp] Auth failure:', msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('disconnected', (reason) => {
|
||||||
|
clientReady = false;
|
||||||
|
currentQR = null;
|
||||||
|
for (const listener of qrListeners) {
|
||||||
|
listener(null, 'disconnected');
|
||||||
|
}
|
||||||
|
console.log('[whatsapp] Disconnected:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('message', (msg) => {
|
||||||
|
handleWhatsAppMessage(msg).catch((err) => {
|
||||||
|
console.error('[whatsapp] Unhandled error in message handler:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopWhatsAppBot(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.destroy();
|
||||||
|
} catch {
|
||||||
|
// May fail if not connected
|
||||||
|
}
|
||||||
|
client = null;
|
||||||
|
clientReady = false;
|
||||||
|
currentQR = null;
|
||||||
|
console.log('[whatsapp] Bot stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWhatsAppBotRunning(): boolean {
|
||||||
|
return client !== null && clientReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppBotPhone(): string | null {
|
||||||
|
if (!client || !clientReady) return null;
|
||||||
|
return client.info?.wid?.user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppQR(): string | null {
|
||||||
|
return currentQR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeQR(listener: QRListener): () => void {
|
||||||
|
qrListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
qrListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppClient(): Client | null {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWhatsAppBotIfConfigured(): Promise<void> {
|
||||||
|
const integration = await getServerIntegration('whatsapp');
|
||||||
|
if (!integration?.enabled) return;
|
||||||
|
|
||||||
|
await startWhatsAppBot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectWhatsApp(): Promise<void> {
|
||||||
|
await stopWhatsAppBot();
|
||||||
|
await upsertServerIntegration('whatsapp', {}, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import type { Message as WAMessage } from 'whatsapp-web.js';
|
||||||
|
import { findUserByIntegrationConfig, readConfigValue } from 'officerdb';
|
||||||
|
import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await';
|
||||||
|
import { consumePairingCode } from '../pairing';
|
||||||
|
import { getWhatsAppClient } from './bot';
|
||||||
|
import { listPiModels } from '@@/api/pi/list-models';
|
||||||
|
import { enqueue } from '@@/queue/engine';
|
||||||
|
import { readJob } from '@@/queue/storage';
|
||||||
|
import { openEmailDb } from '@@/api/email/email-db';
|
||||||
|
import type { ModelInfo } from '@@/api/pi/types';
|
||||||
|
|
||||||
|
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||||
|
const TYPING_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
type SendFn = (text: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
type AccessPolicy = { allowedModels: string[] };
|
||||||
|
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
||||||
|
|
||||||
|
function extractPhone(waId: string): string {
|
||||||
|
// WhatsApp ID format: 5511999999999@c.us → 5511999999999
|
||||||
|
return waId.split('@')[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||||
|
const allModels = await listPiModels();
|
||||||
|
const policy = await readConfigValue<AccessPolicy>(ACCESS_POLICY_KEY, { allowedModels: [] });
|
||||||
|
const allowed = policy.allowedModels;
|
||||||
|
|
||||||
|
if (allowed.length === 0) return allModels;
|
||||||
|
|
||||||
|
const allowedSet = new Set(allowed);
|
||||||
|
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
|
||||||
|
|
||||||
|
return allModels.filter((m) => {
|
||||||
|
const key = `${m.provider}:${m.id}`;
|
||||||
|
const isExplicitlyAllowed = allowedSet.has(key);
|
||||||
|
const isFromNewProvider = !allowedProviderSet.has(m.provider);
|
||||||
|
return isExplicitlyAllowed || isFromNewProvider;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandContext = {
|
||||||
|
content: string;
|
||||||
|
send: SendFn;
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
whatsappId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||||
|
const { send, email } = ctx;
|
||||||
|
|
||||||
|
let countBefore = 0;
|
||||||
|
try {
|
||||||
|
const db = openEmailDb(email);
|
||||||
|
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
||||||
|
countBefore = row.count;
|
||||||
|
db.close();
|
||||||
|
} catch {
|
||||||
|
// DB might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
await send('Syncing emails...');
|
||||||
|
|
||||||
|
const job = await enqueue({ lane: 'google-api', type: 'gmail-sync', userId: email });
|
||||||
|
|
||||||
|
const poll = async (): Promise<'completed' | 'failed' | 'cancelled'> => {
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
const current = await readJob(job.id);
|
||||||
|
if (!current) return 'failed';
|
||||||
|
if (current.status === 'completed' || current.status === 'failed' || current.status === 'cancelled') {
|
||||||
|
return current.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = await poll();
|
||||||
|
|
||||||
|
if (status !== 'completed') {
|
||||||
|
await send('Email sync failed. Check the queue dashboard for details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = openEmailDb(email);
|
||||||
|
const row = db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number };
|
||||||
|
const countAfter = row.count;
|
||||||
|
const newCount = countAfter - countBefore;
|
||||||
|
|
||||||
|
if (newCount <= 0) {
|
||||||
|
db.close();
|
||||||
|
await send('Sync complete — no new emails.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = db
|
||||||
|
.query('SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?')
|
||||||
|
.all(Math.min(newCount, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>;
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const lines = newest.map((e) => {
|
||||||
|
const sender = e.from_name || e.from_address;
|
||||||
|
return `- *${sender}*: ${e.subject}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let text = `Sync complete — *${newCount}* new email${newCount !== 1 ? 's' : ''}`;
|
||||||
|
if (newCount > 20) text += ` (showing latest 20)`;
|
||||||
|
text += `:\n\n${lines.join('\n')}`;
|
||||||
|
|
||||||
|
await send(text);
|
||||||
|
} catch (err) {
|
||||||
|
await send(`Sync complete but failed to read results: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
||||||
|
const { content, send, whatsappId } = ctx;
|
||||||
|
const lower = content.toLowerCase();
|
||||||
|
|
||||||
|
const helpSections: Record<string, string> = {
|
||||||
|
models:
|
||||||
|
'*Models:*\n' +
|
||||||
|
'`!model` — show current model\n' +
|
||||||
|
'`!model <id>` — switch model\n' +
|
||||||
|
'`!models` — list available models',
|
||||||
|
email: '*Email:*\n' + '`!email sync` — sync Gmail and show new emails',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lower === '!help' || lower.startsWith('!help ')) {
|
||||||
|
const topic = content.slice('!help'.length).trim().toLowerCase();
|
||||||
|
if (topic && topic in helpSections) {
|
||||||
|
await send(helpSections[topic]!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (topic) {
|
||||||
|
await send(
|
||||||
|
`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections)
|
||||||
|
.map((k) => `\`${k}\``)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const full = Object.values(helpSections).join('\n\n');
|
||||||
|
await send(full + '\n\n`!help <topic>` — show commands for a topic');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '!models') {
|
||||||
|
const models = await getVisibleModels();
|
||||||
|
if (models.length === 0) {
|
||||||
|
await send('No models available.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = getSessionModel('whatsapp', whatsappId);
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const m of models) {
|
||||||
|
const list = grouped.get(m.provider) ?? [];
|
||||||
|
list.push(m.id === current ? `*${m.id}* (current)` : m.id);
|
||||||
|
grouped.set(m.provider, list);
|
||||||
|
}
|
||||||
|
let text = '*Available models:*\n';
|
||||||
|
for (const [provider, ids] of grouped) {
|
||||||
|
text += `\n_${provider}_\n${ids.map((id) => ` ${id}`).join('\n')}\n`;
|
||||||
|
}
|
||||||
|
text += '\nUse `!model <id>` to switch.';
|
||||||
|
await send(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '!model') {
|
||||||
|
const current = getSessionModel('whatsapp', whatsappId);
|
||||||
|
await send(
|
||||||
|
current
|
||||||
|
? `Current model: *${current}*`
|
||||||
|
: 'No active session yet — the default model will be used on your next message.',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith('!model ')) {
|
||||||
|
const requested = content.slice('!model '.length).trim();
|
||||||
|
if (!requested) {
|
||||||
|
const current = getSessionModel('whatsapp', whatsappId);
|
||||||
|
await send(current ? `Current model: *${current}*` : 'No active session yet.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const models = await getVisibleModels();
|
||||||
|
const match = models.find((m) => m.id === requested || m.name === requested);
|
||||||
|
if (!match) {
|
||||||
|
await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setSessionModel('whatsapp', whatsappId, match.id);
|
||||||
|
await send(`Model switched to *${match.id}*. The new model will be used on your next message.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === '!email sync') {
|
||||||
|
await handleEmailSync(ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWhatsAppMessage(msg: WAMessage): Promise<void> {
|
||||||
|
const waClient = getWhatsAppClient();
|
||||||
|
if (!waClient) return;
|
||||||
|
|
||||||
|
// Ignore group chats, status broadcasts, own messages
|
||||||
|
if (msg.from.endsWith('@g.us')) return;
|
||||||
|
if (msg.from === 'status@broadcast') return;
|
||||||
|
if (msg.fromMe) return;
|
||||||
|
if (!msg.body) return;
|
||||||
|
|
||||||
|
const phone = extractPhone(msg.from);
|
||||||
|
const content = msg.body.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const send: SendFn = (text: string) => waClient.sendMessage(msg.from, text);
|
||||||
|
|
||||||
|
// Look up linked Officer user
|
||||||
|
const linked = await findUserByIntegrationConfig('whatsapp', 'whatsappId', phone);
|
||||||
|
|
||||||
|
if (!linked) {
|
||||||
|
if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) {
|
||||||
|
const result = await consumePairingCode(content.toUpperCase(), phone);
|
||||||
|
if (result) {
|
||||||
|
await send('Account linked! You can now chat with me.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await send('Invalid or expired pairing code. Please generate a new one from Officer Settings.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(
|
||||||
|
"I don't recognize your WhatsApp number. To link it:\n" +
|
||||||
|
'1. Go to Officer Settings → Integrations → WhatsApp\n' +
|
||||||
|
'2. Click "Link WhatsApp" to get a pairing code\n' +
|
||||||
|
'3. Send the 6-character code to me here',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle commands
|
||||||
|
if (content.startsWith('!')) {
|
||||||
|
const handled = await handleCommand({
|
||||||
|
content,
|
||||||
|
send,
|
||||||
|
userId: linked.user.id,
|
||||||
|
email: linked.user.email,
|
||||||
|
whatsappId: phone,
|
||||||
|
});
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
const sendTyping = async () => {
|
||||||
|
try {
|
||||||
|
const chat = await msg.getChat();
|
||||||
|
await chat.sendStateTyping();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS);
|
||||||
|
sendTyping();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendAndAwait({
|
||||||
|
userId: linked.user.id,
|
||||||
|
email: linked.user.email,
|
||||||
|
username: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||||
|
prompt: content,
|
||||||
|
context: 'whatsapp',
|
||||||
|
contextId: phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(typingInterval);
|
||||||
|
|
||||||
|
// WhatsApp has 65k char limit — no chunking needed
|
||||||
|
const signature = `\`${result.model}\`\n`;
|
||||||
|
await send(signature + result.text);
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(typingInterval);
|
||||||
|
console.error('[whatsapp] Error handling message:', err);
|
||||||
|
await send('Sorry, something went wrong processing your message.').catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user