From 2af7b92e4ce7d322932942b3cd974447e6da6c7d Mon Sep 17 00:00:00 2001 From: Nestor Ale Date: Mon, 15 Jun 2026 11:56:31 -0300 Subject: [PATCH 1/2] feat(whatsapp): QR pairing panel in dashboard Add WhatsApp QR code pairing directly in the Channels page so users can link their phone without touching the terminal. Changes: - bridge.js: expose GET /qr (returns raw Baileys QR string + status) and POST /reset-session (wipes session files, triggers new QR). Track latestQR and qr_pending state for the dashboard to poll. - web_server.py: proxy /api/messaging/whatsapp/qr and /api/messaging/whatsapp/reset to the local bridge on port 3000. - api.ts: add getWhatsappQR() and resetWhatsappSession() client methods plus WhatsappQRResponse interface. - ChannelsPage.tsx: add WhatsappOnboardingPanel that polls every 3 s, renders a QR image via the qrcode library when status is qr_pending, and provides a Reset and re-pair button. --- hermes_cli/web_server.py | 36 ++++++++++ scripts/whatsapp-bridge/bridge.js | 28 ++++++++ web/src/lib/api.ts | 10 +++ web/src/pages/ChannelsPage.tsx | 111 ++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0e77b3d7a253..aca6fbf82b23 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4738,6 +4738,42 @@ async def cancel_telegram_onboarding(pairing_id: str): return {"ok": True} + + +# --------------------------------------------------------------------------- +# WhatsApp QR pairing endpoints +# --------------------------------------------------------------------------- + +_WHATSAPP_BRIDGE_PORT = int(os.environ.get("WHATSAPP_BRIDGE_PORT", "3000")) + + +def _whatsapp_bridge_request(method, path): + import urllib.request as _ur + url = f"http://127.0.0.1:{_WHATSAPP_BRIDGE_PORT}{path}" + data = b"{}" if method == "POST" else None + headers = {"Host": "localhost"} + if data: + headers["Content-Type"] = "application/json" + req = _ur.Request(url, data=data, method=method, headers=headers) + try: + with _ur.urlopen(req, timeout=10) as resp: + import json as _json + return _json.loads(resp.read().decode()) + except Exception as exc: + raise HTTPException(status_code=503, detail=f"Bridge unreachable: {exc}") + + +@app.get("/api/messaging/whatsapp/qr") +async def get_whatsapp_qr(): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: _whatsapp_bridge_request("GET", "/qr")) + + +@app.post("/api/messaging/whatsapp/reset") +async def reset_whatsapp_session(): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: _whatsapp_bridge_request("POST", "/reset-session")) + @app.get("/api/messaging/platforms") async def get_messaging_platforms(profile: Optional[str] = None): # Profile-scoped so the dashboard's global profile switcher shows the diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4c65740c0174..d4c57c610e54 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -196,6 +196,7 @@ const MAX_RECENT_IDS = 50; let sock = null; let connectionState = 'disconnected'; +let latestQR = null; async function startSocket() { const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); @@ -224,6 +225,8 @@ async function startSocket() { const { connection, lastDisconnect, qr } = update; if (qr) { + latestQR = qr; + connectionState = "qr_pending"; console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n'); qrcode.generate(qr, { small: true }); console.log('\nWaiting for scan...\n'); @@ -234,6 +237,7 @@ async function startSocket() { connectionState = 'disconnected'; if (reason === DisconnectReason.loggedOut) { + latestQR = null; console.log('❌ Logged out. Delete session and restart to re-authenticate.'); process.exit(1); } else { @@ -714,6 +718,30 @@ app.get('/chat/:id', async (req, res) => { }); }); +// QR code for dashboard pairing +app.get("/qr", (req, res) => { + res.json({ status: connectionState, qr: latestQR }); +}); + +// Reset session +app.post("/reset-session", async (req, res) => { + try { + const { readdirSync: rd, unlinkSync: ul, existsSync: ex } = await import("fs"); + if (ex(SESSION_DIR)) { + for (const f of rd(SESSION_DIR)) { + try { ul(SESSION_DIR + "/" + f); } catch (_) {} + } + } + latestQR = null; + connectionState = "disconnected"; + if (sock) { try { sock.end(undefined); } catch (_) {} sock = null; } + setTimeout(startSocket, 500); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + // Health check app.get('/health', (req, res) => { res.json({ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fab64b64c846..626275a2baee 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -817,6 +817,11 @@ export const api = { { method: "DELETE" }, ), + getWhatsappQR: () => + fetchJSON("/api/messaging/whatsapp/qr"), + resetWhatsappSession: () => + fetchJSON<{ ok: boolean }>("/api/messaging/whatsapp/reset", { method: "POST" }), + // Gateway / update actions restartGateway: () => fetchJSON("/api/gateway/restart", { method: "POST" }), @@ -1630,6 +1635,11 @@ export interface EnvVarInfo { channel_managed?: boolean; } +export interface WhatsappQRResponse { + status: "disconnected" | "qr_pending" | "connected" | string; + qr: string | null; +} + export interface TelegramOnboardingStartResponse { pairing_id: string; suggested_username: string; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index ea5e81b5afb4..34f5cef9bc86 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -29,6 +29,7 @@ import type { MessagingPlatformEnvVar, MessagingPlatformUpdate, TelegramOnboardingStartResponse, + WhatsappQRResponse, } from "@/lib/api"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { usePageHeader } from "@/contexts/usePageHeader"; @@ -451,6 +452,9 @@ export default function ChannelsPage() { showToast={showToast} /> )} + {platform.id === "whatsapp" && ( + + )} ); @@ -806,3 +810,110 @@ function TelegramOnboardingPanel({ ); } + +function WhatsappOnboardingPanel({ + showToast, +}: { + showToast: (message: string, type: "success" | "error") => void; +}) { + const [data, setData] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [resetting, setResetting] = useState(false); + + const fetchQR = useCallback(async () => { + try { + const res = await api.getWhatsappQR(); + setData(res); + if (res.qr) { + const url = await QRCode.toDataURL(res.qr, { + errorCorrectionLevel: "M", + margin: 1, + width: 224, + }); + setQrDataUrl(url); + } else { + setQrDataUrl(""); + } + } catch { + // bridge not running — silently skip + } + }, []); + + useEffect(() => { + fetchQR(); + const timer = setInterval(fetchQR, 3000); + return () => clearInterval(timer); + }, [fetchQR]); + + const handleReset = async () => { + if (!window.confirm("Delete WhatsApp session and re-pair with a new QR code?")) return; + setResetting(true); + try { + await api.resetWhatsappSession(); + setData(null); + setQrDataUrl(""); + showToast("Session reset — scan the new QR code", "success"); + setTimeout(fetchQR, 1500); + } catch (e) { + showToast(`Reset failed: ${e}`, "error"); + } finally { + setResetting(false); + } + }; + + if (!data) return null; + + const isConnected = data.status === "connected"; + const isPending = data.status === "qr_pending"; + + return ( +
+
+ WhatsApp pairing + {isConnected && ( + Connected + )} + {isPending && ( + Scan QR to pair + )} + {!isConnected && !isPending && ( + {data.status} + )} +
+ + {isPending && qrDataUrl && ( +
+

+ Open WhatsApp on your phone → Linked Devices → Link a Device +

+ WhatsApp QR code +
+ )} + + {isConnected && ( +

+ WhatsApp is connected. Use the button below to re-pair if needed. +

+ )} + +
+ +
+
+ ); +} From d88f74936ac123efb78ad2b1f96941f4a30f896b Mon Sep 17 00:00:00 2001 From: Nestor Ale Date: Mon, 15 Jun 2026 12:19:05 -0300 Subject: [PATCH 2/2] fix(whatsapp): match QR panel visual style to Telegram onboarding Redesign WhatsappOnboardingPanel to use the same visual patterns: - Rounded border with muted background container - Two-column grid with QR image on right, controls on left - Same button/badge/tone styling as TelegramOnboardingPanel - Status phases: idle, connecting (QR shown), ready (connected) - Cancel button during QR pairing, Re-pair button when connected --- web/src/pages/ChannelsPage.tsx | 123 +++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 50 deletions(-) diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 34f5cef9bc86..7860d29bbe95 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -819,11 +819,17 @@ function WhatsappOnboardingPanel({ const [data, setData] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(""); const [resetting, setResetting] = useState(false); + const [phase, setPhase] = useState<"idle" | "connecting" | "ready">("idle"); const fetchQR = useCallback(async () => { try { const res = await api.getWhatsappQR(); setData(res); + if (res.status === "connected") { + setPhase("ready"); + } else if (res.status === "qr_pending") { + setPhase("connecting"); + } if (res.qr) { const url = await QRCode.toDataURL(res.qr, { errorCorrectionLevel: "M", @@ -831,8 +837,6 @@ function WhatsappOnboardingPanel({ width: 224, }); setQrDataUrl(url); - } else { - setQrDataUrl(""); } } catch { // bridge not running — silently skip @@ -852,67 +856,86 @@ function WhatsappOnboardingPanel({ await api.resetWhatsappSession(); setData(null); setQrDataUrl(""); + setPhase("idle"); showToast("Session reset — scan the new QR code", "success"); setTimeout(fetchQR, 1500); } catch (e) { - showToast(`Reset failed: ${e}`, "error"); + showToast("Reset failed: " + String(e), "error"); } finally { setResetting(false); } }; - if (!data) return null; - - const isConnected = data.status === "connected"; - const isPending = data.status === "qr_pending"; + const isPending = data?.status === "qr_pending"; + const isConnected = data?.status === "connected"; return ( -
-
- WhatsApp pairing - {isConnected && ( - Connected - )} - {isPending && ( - Scan QR to pair - )} - {!isConnected && !isPending && ( - {data.status} - )} -
- - {isPending && qrDataUrl && ( -
-

- Open WhatsApp on your phone → Linked Devices → Link a Device -

- WhatsApp QR code +
+
+
+ + {phase === "idle" && !isPending && !isConnected && ( + + WhatsApp bridge not running or not configured. + + )}
- )} - {isConnected && ( -

- WhatsApp is connected. Use the button below to re-pair if needed. -

- )} + {isPending && qrDataUrl && ( +
+
+
+ Scan to pair +
+

+ Open WhatsApp on your phone, go to{" "} + Linked Devices →{" "} + Link a Device, and scan + the QR code. +

+
+ +
+
-
- +
+ WhatsApp QR code + waiting +
+
+ )} + + {isConnected && ( +
+
+ Connected +
+

+ WhatsApp is paired and connected. +

+
+ )}
);