whatsapp qr code polling, validate token before save, fix bot startup timing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 12:41:46 +00:00
co-authored by Claude Opus 4.6
parent b698e3d962
commit 209d58a525
2 changed files with 62 additions and 103 deletions
@@ -13,10 +13,10 @@ type WhatsAppConfig = {
phone: string | null; phone: string | null;
}; };
type SSEEvent = { type QRResponse = {
type: 'qr' | 'authenticated' | 'disconnected' | 'waiting'; qr: string | null;
qr?: string; running: boolean;
phone?: string; phone: string | null;
}; };
const SetupGuide = () => { const SetupGuide = () => {
@@ -72,7 +72,7 @@ export const WhatsAppBotConfig = () => {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null); const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchConfig = useCallback(() => { const fetchConfig = useCallback(() => {
client client
@@ -81,60 +81,59 @@ export const WhatsAppBotConfig = () => {
.catch(() => {}); .catch(() => {});
}, [client]); }, [client]);
useEffect(() => { const stopPolling = useCallback(() => {
fetchConfig(); if (pollingRef.current) {
setIsLoading(false); clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, []); }, []);
const connectSSE = useCallback(() => { const pollQR = useCallback(() => {
if (eventSourceRef.current) { client
eventSourceRef.current.close(); .get<QRResponse>('/channels/whatsapp/qr')
} .then(async (data) => {
if (data.running) {
const token = localStorage.getItem('token') ?? sessionStorage.getItem('token') ?? ''; // Authenticated — stop polling, update config
const url = `/api/channels/whatsapp/qr?token=${encodeURIComponent(token)}`; stopPolling();
const es = new EventSource(url); setQrDataUrl(null);
eventSourceRef.current = es; fetchConfig();
return;
es.onmessage = async (ev) => { }
try { if (data.qr) {
const data = JSON.parse(ev.data) as SSEEvent;
if (data.type === 'qr' && data.qr) {
const dataUrl = await QRCode.toDataURL(data.qr, { width: 256, margin: 2 }); const dataUrl = await QRCode.toDataURL(data.qr, { width: 256, margin: 2 });
setQrDataUrl(dataUrl); setQrDataUrl(dataUrl);
} else if (data.type === 'authenticated') {
setQrDataUrl(null);
es.close();
fetchConfig();
} else if (data.type === 'disconnected') {
setQrDataUrl(null);
es.close();
fetchConfig();
} }
} catch { })
// ignore parse errors .catch(() => {});
} }, [client, fetchConfig, stopPolling]);
};
es.onerror = () => { const startPolling = useCallback(() => {
es.close(); stopPolling();
setQrDataUrl(null); pollQR();
}; pollingRef.current = setInterval(pollQR, 2000);
}, [fetchConfig]); }, [pollQR, stopPolling]);
useEffect(() => { useEffect(() => {
return () => { client
if (eventSourceRef.current) { .get<WhatsAppConfig>('/channels/whatsapp/config')
eventSourceRef.current.close(); .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 () => { const handleConnect = async () => {
setIsConnecting(true); setIsConnecting(true);
try { try {
await client.put('/channels/whatsapp/config', { enabled: true }); await client.put('/channels/whatsapp/config', { enabled: true });
connectSSE(); startPolling();
} catch { } catch {
toast.error('Failed to start WhatsApp connection'); toast.error('Failed to start WhatsApp connection');
} finally { } finally {
@@ -146,10 +145,7 @@ export const WhatsAppBotConfig = () => {
setIsDisconnecting(true); setIsDisconnecting(true);
try { try {
await client.put('/channels/whatsapp/config', { enabled: false }); await client.put('/channels/whatsapp/config', { enabled: false });
if (eventSourceRef.current) { stopPolling();
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setQrDataUrl(null); setQrDataUrl(null);
toast.success('WhatsApp disconnected'); toast.success('WhatsApp disconnected');
fetchConfig(); fetchConfig();
@@ -170,7 +166,11 @@ export const WhatsAppBotConfig = () => {
className={`h-2.5 w-2.5 rounded-full shrink-0 ${config.running ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} 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"> <span className="text-sm text-duck-dark dark:text-foreground">
{config.running ? `Connected as +${config.phone}` : config.enabled ? 'Starting...' : 'Not connected'} {config.running
? `Connected as +${config.phone}`
: config.enabled
? 'Waiting for QR scan...'
: 'Not connected'}
</span> </span>
</div> </div>
)} )}
@@ -189,7 +189,7 @@ export const WhatsAppBotConfig = () => {
</div> </div>
)} )}
{config?.running ? ( {config?.running || config?.enabled ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
+11 -52
View File
@@ -8,7 +8,6 @@ import {
isWhatsAppBotRunning, isWhatsAppBotRunning,
getWhatsAppBotPhone, getWhatsAppBotPhone,
getWhatsAppQR, getWhatsAppQR,
subscribeQR,
disconnectWhatsApp, disconnectWhatsApp,
} from './whatsapp/bot'; } from './whatsapp/bot';
import { generatePairingCode } from './pairing'; import { generatePairingCode } from './pairing';
@@ -268,13 +267,12 @@ channelsRouter.put('/whatsapp/config', async (ctx) => {
if (enabled === true) { if (enabled === true) {
await upsertServerIntegration('whatsapp', {}, true); await upsertServerIntegration('whatsapp', {}, true);
try { // Don't await — initialization is slow (launches Chromium) and QR events
await startWhatsAppBot(); // are delivered via SSE. Return immediately so the client can connect SSE.
} catch (err) { startWhatsAppBot().catch((err) => {
console.error('[channels] Failed to start WhatsApp bot:', err); console.error('[channels] Failed to start WhatsApp bot:', err);
return ctx.json({ success: true, botStarted: false, error: String(err) }); });
} return ctx.json({ success: true, botStarted: false });
return ctx.json({ success: true, botStarted: true });
} }
if (enabled === false) { if (enabled === false) {
@@ -300,51 +298,12 @@ channelsRouter.get('/whatsapp/qr', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
// SSE stream for QR code updates const qr = getWhatsAppQR();
return new Response( return ctx.json({
new ReadableStream({ qr,
start(controller) { running: isWhatsAppBotRunning(),
const encoder = new TextEncoder(); phone: getWhatsAppBotPhone(),
});
const sendEvent = (data: Record<string, unknown>) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
// Send current QR if available
const currentQR = getWhatsAppQR();
if (currentQR) {
sendEvent({ type: 'qr', qr: currentQR });
} else if (isWhatsAppBotRunning()) {
sendEvent({ type: 'authenticated', phone: getWhatsAppBotPhone() });
} else {
sendEvent({ type: 'waiting' });
}
const unsubscribe = subscribeQR((qr, event) => {
if (event === 'qr' && qr) {
sendEvent({ type: 'qr', qr });
} else if (event === 'authenticated') {
sendEvent({ type: 'authenticated', phone: getWhatsAppBotPhone() });
} else if (event === 'disconnected') {
sendEvent({ type: 'disconnected' });
}
});
// Clean up when client disconnects
ctx.req.raw.signal.addEventListener('abort', () => {
unsubscribe();
controller.close();
});
},
}),
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
},
);
}); });
// ── User: WhatsApp pairing ── // ── User: WhatsApp pairing ──