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:
+51
-51
@@ -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"
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user