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:
+1
-4
@@ -42,6 +42,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@noble/secp256k1": "^3.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -105,7 +106,6 @@
|
||||
"cron": "^4.3.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"definitions": "workspace:*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"ecpair": "3.0.1",
|
||||
@@ -131,13 +131,11 @@
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-pty": "^1.1.0",
|
||||
"node-telegram-bot-api": "^0.67.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
"officerdb": "workspace:*",
|
||||
"officerdev": "workspace:*",
|
||||
"pg": "^8.16.3",
|
||||
"postgres": "^3.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
@@ -163,7 +161,6 @@
|
||||
"types": "workspace:*",
|
||||
"varuint-bitcoin": "^2.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"whatsapp-web.js": "^1.34.6",
|
||||
"widgets": "workspace:*",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^4.2.1"
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
|
||||
-180
@@ -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>
|
||||
);
|
||||
};
|
||||
-237
@@ -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. "Officer"), 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&scope=bot&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'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'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>
|
||||
);
|
||||
};
|
||||
-199
@@ -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>
|
||||
);
|
||||
};
|
||||
-254
@@ -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'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 [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'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>
|
||||
);
|
||||
};
|
||||
-159
@@ -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>
|
||||
);
|
||||
};
|
||||
-215
@@ -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 "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 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,
|
||||
|
||||
@@ -2,9 +2,6 @@ import { mkdirSync } from 'node:fs';
|
||||
import { DATA_PATH, ensureItemDirs } from './data-path';
|
||||
import { ensureToolLoader } from './ensure-tool-loader';
|
||||
// Queue is now owned by the sidecar process
|
||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||
import { startTelegramBotIfConfigured } from './channels/telegram/bot';
|
||||
import { startWhatsAppBotIfConfigured } from './channels/whatsapp/bot';
|
||||
import { startChatEventRetention } from './api/chat/retention';
|
||||
|
||||
mkdirSync(DATA_PATH, { recursive: true });
|
||||
@@ -16,15 +13,6 @@ ensureItemDirs();
|
||||
|
||||
startChatEventRetention();
|
||||
|
||||
await startDiscordBotIfConfigured().catch((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,65 +0,0 @@
|
||||
import { Client, GatewayIntentBits, Partials, Events } from 'discord.js';
|
||||
import { getServerIntegration } from 'officerdb';
|
||||
import { handleDiscordMessage } from './handler';
|
||||
|
||||
let client: Client | null = null;
|
||||
|
||||
export async function startDiscordBot(token: string): Promise<void> {
|
||||
if (client) {
|
||||
await stopDiscordBot();
|
||||
}
|
||||
|
||||
client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildPresences,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
|
||||
client.on(Events.MessageCreate, (message) => {
|
||||
handleDiscordMessage(message).catch((err) => {
|
||||
console.error('[discord] Unhandled error in message handler:', err);
|
||||
});
|
||||
});
|
||||
|
||||
client.once(Events.ClientReady, (c) => {
|
||||
console.log(`[discord] Bot logged in as ${c.user.tag}`);
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
}
|
||||
|
||||
export async function stopDiscordBot(): Promise<void> {
|
||||
if (client) {
|
||||
client.destroy();
|
||||
client = null;
|
||||
console.log('[discord] Bot stopped');
|
||||
}
|
||||
}
|
||||
|
||||
export function isDiscordBotRunning(): boolean {
|
||||
return client !== null && client.isReady();
|
||||
}
|
||||
|
||||
export function getDiscordBotUsername(): string | null {
|
||||
return client?.user?.tag ?? null;
|
||||
}
|
||||
|
||||
type DiscordConfig = {
|
||||
botToken: string;
|
||||
};
|
||||
|
||||
export async function startDiscordBotIfConfigured(): Promise<void> {
|
||||
const integration = await getServerIntegration('discord');
|
||||
if (!integration?.enabled) return;
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
const botToken = config.botToken as string | undefined;
|
||||
if (!botToken) return;
|
||||
|
||||
await startDiscordBot(botToken);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import type { Message as DiscordMessage } from 'discord.js';
|
||||
import { findUserByIntegrationConfig, readConfigValue } from 'officerdb';
|
||||
import { sendAndAwait, getSessionModel, setSessionModel } from '../send-and-await';
|
||||
import { consumePairingCode } from '../pairing';
|
||||
import { chunkMessage } from './chunker';
|
||||
import { listChatModels } from '@@/api/chat/list-models';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import type { ModelInfo } from '@@/api/chat/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||
const TYPING_INTERVAL_MS = 8_000;
|
||||
|
||||
type SendableChannel = { send: (content: string) => Promise<unknown> };
|
||||
|
||||
type AccessPolicy = { allowedModels: string[] };
|
||||
const ACCESS_POLICY_KEY = 'chat-access-policy';
|
||||
|
||||
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||
const allModels = await listChatModels();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
import { runEmailSyncCommand } from '../email-sync-command';
|
||||
|
||||
type CommandContext = {
|
||||
content: string;
|
||||
channel: SendableChannel;
|
||||
userId: number;
|
||||
email: string;
|
||||
discordId: string;
|
||||
};
|
||||
|
||||
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||
const { channel, userId } = ctx;
|
||||
await channel.send('Syncing emails...');
|
||||
const text = await runEmailSyncCommand(userId);
|
||||
for (const chunk of chunkMessage(text)) await channel.send(chunk);
|
||||
}
|
||||
|
||||
async function handleCommand(ctx: CommandContext): Promise<boolean> {
|
||||
const { content, channel, discordId } = 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 channel.send(helpSections[topic]!);
|
||||
return true;
|
||||
}
|
||||
if (topic) {
|
||||
await channel.send(
|
||||
`Unknown topic: \`${topic}\`\nAvailable: ${Object.keys(helpSections)
|
||||
.map((k) => `\`${k}\``)
|
||||
.join(', ')}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const full = Object.values(helpSections).join('\n\n');
|
||||
await channel.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 channel.send('No models available.');
|
||||
return true;
|
||||
}
|
||||
const current = getSessionModel('discord', ctx.userId, discordId);
|
||||
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 channel.send(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lower === '!model') {
|
||||
const current = getSessionModel('discord', ctx.userId, discordId);
|
||||
await channel.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('discord', ctx.userId, discordId);
|
||||
await channel.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 channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
|
||||
return true;
|
||||
}
|
||||
setSessionModel('discord', ctx.userId, discordId, match.id);
|
||||
await channel.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;
|
||||
}
|
||||
|
||||
// Not a recognized command — pass through to PI
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleDiscordMessage(message: DiscordMessage): Promise<void> {
|
||||
// Ignore bots and non-DM messages
|
||||
if (message.author.bot) return;
|
||||
if (!message.channel.isDMBased() || !('send' in message.channel)) return;
|
||||
|
||||
const channel = message.channel;
|
||||
const discordId = message.author.id;
|
||||
const content = message.content.trim();
|
||||
if (!content) return;
|
||||
|
||||
// Look up linked Officer user
|
||||
const linked = await findUserByIntegrationConfig('discord', 'discordId', discordId);
|
||||
|
||||
if (!linked) {
|
||||
// Check if this is a pairing code
|
||||
if (PAIRING_CODE_PATTERN.test(content.toUpperCase())) {
|
||||
const result = await consumePairingCode(content.toUpperCase(), discordId);
|
||||
if (result) {
|
||||
await channel.send('Account linked! You can now chat with me.');
|
||||
return;
|
||||
}
|
||||
await channel.send('Invalid or expired pairing code. Please generate a new one from Officer Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
await channel.send(
|
||||
"I don't recognize your Discord account. To link it:\n" +
|
||||
'1. Go to Officer Settings → Integrations → Discord\n' +
|
||||
'2. Click "Link Discord" 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,
|
||||
channel,
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
discordId,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Start typing indicator with keep-alive
|
||||
const sendTyping = () => {
|
||||
if ('sendTyping' in channel) {
|
||||
(channel as { sendTyping: () => Promise<void> }).sendTyping().catch(() => {});
|
||||
}
|
||||
};
|
||||
const typingInterval = setInterval(sendTyping, TYPING_INTERVAL_MS);
|
||||
sendTyping();
|
||||
|
||||
try {
|
||||
const result = await sendAndAwait({
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
username: toShellUsername(linked.user.username ?? '', linked.user.email),
|
||||
prompt: content,
|
||||
context: 'discord',
|
||||
contextId: discordId,
|
||||
});
|
||||
|
||||
clearInterval(typingInterval);
|
||||
|
||||
const signature = `\`${result.model}\`\n`;
|
||||
const chunks = chunkMessage(result.text);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
await channel.send(i === 0 ? signature + chunks[i]! : chunks[i]!);
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(typingInterval);
|
||||
console.error('[discord] Error handling message:', err);
|
||||
await channel.send('Sorry, something went wrong processing your message.').catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { getEmailServerUrl } from '../api/email/router';
|
||||
|
||||
// The "sync my email" chat command, once, for all three channels.
|
||||
//
|
||||
// Telegram, Discord and WhatsApp each carried their own copy: open the mail store directly, count rows,
|
||||
// enqueue a `gmail-sync` job, poll it, count again, diff. That coupled three chat bridges to the mail
|
||||
// schema, and the job type was hardcoded to the OAuth path even though the account syncs over IMAP — so
|
||||
// the command was already broken before sync moved into the sidecar. Now it is one HTTP call to the
|
||||
// sidecar, which does the sync and reports what arrived.
|
||||
|
||||
type SyncNowResponse = {
|
||||
saved: number;
|
||||
skipped?: number;
|
||||
errors?: number;
|
||||
newest: Array<{ from_name: string | null; from_address: string; subject: string }>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Runs the sync and returns the message to send back to the user. */
|
||||
export async function runEmailSyncCommand(userId: number): Promise<string> {
|
||||
const base = getEmailServerUrl();
|
||||
if (!base) return 'Email is not available right now — the mail service is starting up.';
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${base}/sync-now`, {
|
||||
method: 'POST',
|
||||
// Loopback-only, same trust as the platform's own proxy.
|
||||
headers: { 'X-Officer-User': String(userId) },
|
||||
});
|
||||
} catch {
|
||||
return 'Email sync failed — the mail service is unreachable.';
|
||||
}
|
||||
|
||||
if (!res.ok) return `Email sync failed (${res.status}).`;
|
||||
|
||||
const body = (await res.json()) as SyncNowResponse;
|
||||
if (body.error) return body.error;
|
||||
if (body.saved <= 0) return 'Sync complete — no new emails.';
|
||||
|
||||
const lines = body.newest.map((e) => `- *${e.from_name || e.from_address}*: ${e.subject}`);
|
||||
let text = `Sync complete — *${body.saved}* new email${body.saved !== 1 ? 's' : ''}`;
|
||||
if (body.saved > 20) text += ' (showing latest 20)';
|
||||
return `${text}:\n\n${lines.join('\n')}`;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { ChannelProvider } from './types';
|
||||
import { upsertUserIntegration } from 'officerdb';
|
||||
|
||||
const configKeyMap: Record<ChannelProvider, string> = {
|
||||
discord: 'discordId',
|
||||
telegram: 'telegramId',
|
||||
whatsapp: 'whatsappId',
|
||||
};
|
||||
|
||||
type PairingEntry = {
|
||||
userId: number;
|
||||
email: string;
|
||||
provider: ChannelProvider;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const pairingCodes = new Map<string, PairingEntry>();
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I ambiguity
|
||||
|
||||
function generateCode(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]!;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function cleanupExpiredCodes(): void {
|
||||
const now = Date.now();
|
||||
for (const [code, entry] of pairingCodes) {
|
||||
if (entry.expiresAt <= now) {
|
||||
pairingCodes.delete(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePairingCode(userId: number, email: string, provider: ChannelProvider): string {
|
||||
cleanupExpiredCodes();
|
||||
|
||||
// Revoke any existing code for this user+provider
|
||||
for (const [code, entry] of pairingCodes) {
|
||||
if (entry.userId === userId && entry.provider === provider) {
|
||||
pairingCodes.delete(code);
|
||||
}
|
||||
}
|
||||
|
||||
let code: string;
|
||||
do {
|
||||
code = generateCode();
|
||||
} while (pairingCodes.has(code));
|
||||
|
||||
pairingCodes.set(code, {
|
||||
userId,
|
||||
email,
|
||||
provider,
|
||||
expiresAt: Date.now() + CODE_TTL_MS,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
type PairingResult = {
|
||||
userId: number;
|
||||
email: string;
|
||||
provider: ChannelProvider;
|
||||
};
|
||||
|
||||
export async function consumePairingCode(code: string, channelUserId: string): Promise<PairingResult | null> {
|
||||
const entry = pairingCodes.get(code.toUpperCase());
|
||||
if (!entry || entry.expiresAt <= Date.now()) return null;
|
||||
|
||||
pairingCodes.delete(code.toUpperCase());
|
||||
|
||||
await upsertUserIntegration({
|
||||
userId: entry.userId,
|
||||
provider: entry.provider,
|
||||
config: { [configKeyMap[entry.provider]]: channelUserId },
|
||||
});
|
||||
|
||||
return { userId: entry.userId, email: entry.email, provider: entry.provider };
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { getServerIntegration, upsertServerIntegration, getUserIntegration, deleteUserIntegration } from 'officerdb';
|
||||
import { startDiscordBot, stopDiscordBot, isDiscordBotRunning, getDiscordBotUsername } from './discord/bot';
|
||||
import { startTelegramBot, stopTelegramBot, isTelegramBotRunning, getTelegramBotUsername } from './telegram/bot';
|
||||
import {
|
||||
startWhatsAppBot,
|
||||
stopWhatsAppBot,
|
||||
isWhatsAppBotRunning,
|
||||
getWhatsAppBotPhone,
|
||||
getWhatsAppQR,
|
||||
disconnectWhatsApp,
|
||||
} from './whatsapp/bot';
|
||||
import { generatePairingCode } from './pairing';
|
||||
|
||||
export const channelsRouter = createRouter();
|
||||
|
||||
// ── Admin: Discord config ──
|
||||
|
||||
channelsRouter.get('/discord/config', async (ctx) => {
|
||||
const integration = await getServerIntegration('discord');
|
||||
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('/discord/config', async (ctx) => {
|
||||
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('discord');
|
||||
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 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 {
|
||||
await startDiscordBot(token);
|
||||
} catch (err) {
|
||||
console.error('[channels] Failed to start Discord bot:', err);
|
||||
return ctx.json({ success: true, botStarted: false, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRun) {
|
||||
await stopDiscordBot();
|
||||
}
|
||||
|
||||
return ctx.json({ success: true, botStarted: token && shouldRun });
|
||||
});
|
||||
|
||||
channelsRouter.get('/discord/status', async (ctx) => {
|
||||
const integration = await getServerIntegration('discord');
|
||||
const config = (integration?.config ?? {}) as Record<string, unknown>;
|
||||
|
||||
return ctx.json({
|
||||
configured: !!config.botToken,
|
||||
enabled: integration?.enabled ?? false,
|
||||
running: isDiscordBotRunning(),
|
||||
botUsername: getDiscordBotUsername(),
|
||||
serverInvite: (config.serverInvite as string) ?? null,
|
||||
botHandle: (config.botHandle as string) ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// ── User: Discord pairing ──
|
||||
|
||||
channelsRouter.post('/discord/pair', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const code = generatePairingCode(user.id, user.email, 'discord');
|
||||
return ctx.json({ code, expiresIn: 600 });
|
||||
});
|
||||
|
||||
channelsRouter.get('/discord/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const integration = await getUserIntegration(user.id, 'discord');
|
||||
if (!integration) return ctx.json({ linked: false });
|
||||
|
||||
const config = integration.config as Record<string, unknown>;
|
||||
return ctx.json({
|
||||
linked: true,
|
||||
discordId: config.discordId,
|
||||
});
|
||||
});
|
||||
|
||||
channelsRouter.delete('/discord/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const deleted = await deleteUserIntegration(user.id, 'discord');
|
||||
return ctx.json({ success: deleted });
|
||||
});
|
||||
|
||||
// ── Admin: Telegram config ──
|
||||
|
||||
channelsRouter.get('/telegram/config', async (ctx) => {
|
||||
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 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 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 body = ctx.get('body') as Record<string, unknown>;
|
||||
const enabled = body.enabled as boolean | undefined;
|
||||
|
||||
if (enabled === true) {
|
||||
await upsertServerIntegration('whatsapp', {}, true);
|
||||
// Don't await — initialization is slow (launches Chromium) and QR events
|
||||
// are delivered via SSE. Return immediately so the client can connect SSE.
|
||||
startWhatsAppBot().catch((err) => {
|
||||
console.error('[channels] Failed to start WhatsApp bot:', err);
|
||||
});
|
||||
return ctx.json({ success: true, botStarted: false });
|
||||
}
|
||||
|
||||
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 qr = getWhatsAppQR();
|
||||
return ctx.json({
|
||||
qr,
|
||||
running: isWhatsAppBotRunning(),
|
||||
phone: getWhatsAppBotPhone(),
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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 });
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { MessageCost } from '@@/api/chat/types';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { logger } from '@@/api/chat/logger';
|
||||
import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
|
||||
type SendAndAwaitParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
context: string;
|
||||
contextId: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type SendAndAwaitResult = {
|
||||
text: string;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
// Per-session mutex to serialize concurrent prompts
|
||||
const sessionLocks = new Map<string, Promise<void>>();
|
||||
|
||||
// Channel model overrides — survive session eviction/recreation
|
||||
const channelModelOverrides = new Map<string, string>();
|
||||
|
||||
function buildSessionId(context: string, userId: number, contextId: string): string {
|
||||
return `channel-${context}-${userId}-${contextId}`;
|
||||
}
|
||||
|
||||
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||
try {
|
||||
const settings = await getUserSettings(userId);
|
||||
const chat = settings?.chat as Record<string, unknown> | undefined;
|
||||
return (chat?.defaultModel as string) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionModel(context: string, userId: number, contextId: string): string | null {
|
||||
return channelModelOverrides.get(buildSessionId(context, userId, contextId)) ?? null;
|
||||
}
|
||||
|
||||
export function setSessionModel(context: string, userId: number, contextId: string, model: string): void {
|
||||
const sessionId = buildSessionId(context, userId, contextId);
|
||||
channelModelOverrides.set(sessionId, model);
|
||||
// Reset the Claude session so the next prompt starts fresh under the new model.
|
||||
clearClaudeCodeSession(sessionId);
|
||||
logger.info('Channel model override stored', { sessionId, model });
|
||||
}
|
||||
|
||||
export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
|
||||
const { userId, context, contextId } = params;
|
||||
const sessionId = buildSessionId(context, userId, contextId);
|
||||
|
||||
// Serialize per session — if two messages arrive at once, the second waits for the first.
|
||||
const existing = sessionLocks.get(sessionId) ?? Promise.resolve();
|
||||
let releaseLock: () => void;
|
||||
const lockPromise = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
const chained = existing.then(() => lockPromise);
|
||||
sessionLocks.set(sessionId, chained);
|
||||
|
||||
await existing;
|
||||
|
||||
try {
|
||||
const override = channelModelOverrides.get(sessionId);
|
||||
let model = params.model ?? override ?? (await getUserDefaultModel(userId)) ?? DEFAULT_MODEL;
|
||||
// Claude-only: coerce any legacy non-Claude model preference to the Claude default.
|
||||
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
||||
|
||||
return await sendClaudeCode({
|
||||
userId: params.userId,
|
||||
email: params.email,
|
||||
username: params.username,
|
||||
prompt: params.prompt,
|
||||
sessionKey: sessionId,
|
||||
model,
|
||||
});
|
||||
} finally {
|
||||
releaseLock!();
|
||||
if (sessionLocks.get(sessionId) === chained) sessionLocks.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
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 { listChatModels } from '@@/api/chat/list-models';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import type { ModelInfo } from '@@/api/chat/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
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 = 'chat-access-policy';
|
||||
|
||||
async function getVisibleModels(): Promise<ModelInfo[]> {
|
||||
const allModels = await listChatModels();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
import { runEmailSyncCommand } from '../email-sync-command';
|
||||
|
||||
type CommandContext = {
|
||||
content: string;
|
||||
send: SendFn;
|
||||
userId: number;
|
||||
email: string;
|
||||
telegramId: string;
|
||||
};
|
||||
|
||||
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||
const { send, userId } = ctx;
|
||||
await send('Syncing emails...');
|
||||
const text = await runEmailSyncCommand(userId);
|
||||
for (const chunk of chunkMessage(text)) await send(chunk);
|
||||
}
|
||||
|
||||
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', ctx.userId, 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', ctx.userId, 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', ctx.userId, 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', ctx.userId, 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: toShellUsername(linked.user.username ?? '', linked.user.email),
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type ChannelProvider = 'discord' | 'telegram' | 'whatsapp';
|
||||
|
||||
export type ChannelBot = {
|
||||
provider: ChannelProvider;
|
||||
start: (token: string) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
isRunning: () => boolean;
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import { join } from 'path';
|
||||
import { rmSync } from 'node:fs';
|
||||
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> {
|
||||
if (client) {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch {
|
||||
// May fail if not authenticated
|
||||
}
|
||||
}
|
||||
await stopWhatsAppBot();
|
||||
|
||||
// Remove cached session so a new QR is shown on next connect
|
||||
const authPath = join(DATA_PATH, '.wwebjs_auth');
|
||||
rmSync(authPath, { recursive: true, force: true });
|
||||
|
||||
await upsertServerIntegration('whatsapp', {}, false);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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 { listChatModels } from '@@/api/chat/list-models';
|
||||
import { enqueueJob } from '../../queue/init';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import type { ModelInfo } from '@@/api/chat/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
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 = 'chat-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 listChatModels();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
import { runEmailSyncCommand } from '../email-sync-command';
|
||||
|
||||
type CommandContext = {
|
||||
content: string;
|
||||
send: SendFn;
|
||||
userId: number;
|
||||
email: string;
|
||||
whatsappId: string;
|
||||
};
|
||||
|
||||
async function handleEmailSync(ctx: CommandContext): Promise<void> {
|
||||
const { send, userId } = ctx;
|
||||
await send('Syncing emails...');
|
||||
const text = await runEmailSyncCommand(userId);
|
||||
await send(text); // WhatsApp's 65k limit means no chunking is needed
|
||||
}
|
||||
|
||||
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', ctx.userId, 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', ctx.userId, 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', ctx.userId, 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', ctx.userId, 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: toShellUsername(linked.user.username ?? '', linked.user.email),
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
import { queueRouter } from './api/queue/queue';
|
||||
import { emailRouter } from './api/email/router';
|
||||
import { channelsRouter } from './channels/routes';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { desktopRouter } from './api/desktop/rest';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
@@ -123,7 +122,6 @@ protectedRouter.route('/dock', dockRouter);
|
||||
protectedRouter.route('/integrations', integrationsRouter);
|
||||
protectedRouter.route('/queue', queueRouter);
|
||||
protectedRouter.route('/email', emailRouter);
|
||||
protectedRouter.route('/channels', channelsRouter);
|
||||
protectedRouter.route('/browser', browserRouter);
|
||||
protectedRouter.route('/bug-report', bugReportRouter);
|
||||
protectedRouter.route('/chat', chatRouter);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Discord notifications, and nothing else.
|
||||
//
|
||||
// Officer used to run a full Discord bot: a gateway connection, command parsing, account pairing, an admin
|
||||
// config screen and a token in the database — and the same again for Telegram and WhatsApp. All three
|
||||
// existed to drive the platform from a chat app, which the phone app does now. What is left is the one
|
||||
// piece worth keeping: the ability to push a message out.
|
||||
//
|
||||
// Configured by env, so there is no UI, no pairing and no stored credential:
|
||||
// DISCORD_WEBHOOK_URL a channel webhook. Unset = notifications are silently skipped.
|
||||
|
||||
const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
|
||||
|
||||
/** True when a webhook is configured; callers can skip building a message otherwise. */
|
||||
export const isDiscordNotifyConfigured = (): boolean => Boolean(WEBHOOK_URL);
|
||||
|
||||
/**
|
||||
* Post a message to the configured Discord channel. Never throws and never blocks anything important — a
|
||||
* notification that fails to send is logged and dropped, not retried.
|
||||
*/
|
||||
export async function notifyDiscord(content: string): Promise<void> {
|
||||
if (!WEBHOOK_URL) return;
|
||||
try {
|
||||
const res = await fetch(WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
// Discord rejects anything over 2000 characters outright.
|
||||
body: JSON.stringify({ content: content.slice(0, 2000) }),
|
||||
});
|
||||
if (!res.ok) console.error(`[notify:discord] webhook returned ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error('[notify:discord] failed to send:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { getEmailAttachmentCacheDir } from '@@/data-path';
|
||||
import { getEmailAccounts } from 'officerdb';
|
||||
import { openEmailDb, openUserEmailDb, rowToSummary, getSyncMeta, searchEmails } from './store';
|
||||
import { accountsRouter } from './accounts';
|
||||
import { performResync } from './resync';
|
||||
|
||||
export const emailRouter = createRouter();
|
||||
|
||||
@@ -365,33 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /sync-now — run a resync and report what arrived.
|
||||
//
|
||||
// For the chat channels ("sync my email" from Telegram/Discord/WhatsApp). They used to open the mail
|
||||
// store directly and enqueue a `gmail-sync` job, which stopped existing when sync moved in here; and the
|
||||
// job type was hardcoded to the OAuth path even though an app-password account syncs over IMAP, so that
|
||||
// command had been failing regardless. One call now: sync, then say what is new.
|
||||
emailRouter.post('/sync-now', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const accounts = await getEmailAccounts(user.id);
|
||||
const account = accounts.find((a) => a.enabled) ?? accounts[0];
|
||||
if (!account) return ctx.json({ saved: 0, newest: [], error: 'No email account configured' });
|
||||
|
||||
const result = await performResync({ accountId: account.id, userEmail: user.email, userId: user.id });
|
||||
|
||||
const db = openEmailDb(user.email, account.email);
|
||||
try {
|
||||
const newest =
|
||||
result.saved > 0
|
||||
? (db
|
||||
.query('SELECT from_name, from_address, subject FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ?')
|
||||
.all(Math.min(result.saved, 20)) as Array<{ from_name: string | null; from_address: string; subject: string }>)
|
||||
: [];
|
||||
return ctx.json({ saved: result.saved, skipped: result.skipped, errors: result.errors, newest });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
emailRouter.get('/sync-status', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
Reference in New Issue
Block a user