From 389a51d4942fd7bbb6c740e678d38634e0ea3afc Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Fri, 29 May 2026 13:37:45 +0530
Subject: [PATCH] feat(gateway): enhance WhatsApp bridge with pairing timeout
and health check integration
---
.../routes/gateway_whatsapp_baileys_routes.py | 4 +-
.../scripts/whatsapp-bridge/bridge.js | 101 ++++++++++++++++--
.../components/MessagingChannelsContent.tsx | 80 +++++++-------
surfsense_web/package.json | 1 +
surfsense_web/pnpm-lock.yaml | 88 +++++++++++++++
5 files changed, 227 insertions(+), 47 deletions(-)
diff --git a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
index 6a7680c0d..24209f86f 100644
--- a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
+++ b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py
@@ -90,7 +90,9 @@ async def request_pairing_code(
@router.get("/health")
-async def bridge_health() -> dict[str, Any]:
+async def bridge_health(
+ user: User = Depends(current_active_user),
+) -> dict[str, Any]:
_ensure_baileys_enabled()
adapter = WhatsAppBaileysAdapter()
try:
diff --git a/surfsense_backend/scripts/whatsapp-bridge/bridge.js b/surfsense_backend/scripts/whatsapp-bridge/bridge.js
index 84b28030b..017456654 100644
--- a/surfsense_backend/scripts/whatsapp-bridge/bridge.js
+++ b/surfsense_backend/scripts/whatsapp-bridge/bridge.js
@@ -8,7 +8,7 @@ import {
} from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom";
import express from "express";
-import { mkdirSync } from "node:fs";
+import { mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
import pino from "pino";
import qrcode from "qrcode-terminal";
@@ -16,6 +16,7 @@ import qrcode from "qrcode-terminal";
const PORT = Number(process.env.PORT || "9929");
const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR || "/data/sessions";
const SEND_TIMEOUT_MS = Number(process.env.WHATSAPP_SEND_TIMEOUT_MS || "60000");
+const PAIRING_TIMEOUT_MS = Number(process.env.WHATSAPP_PAIRING_TIMEOUT_MS || "30000");
const MAX_QUEUE_SIZE = Number(process.env.WHATSAPP_MAX_QUEUE_SIZE || "100");
const WHATSAPP_MODE = process.env.WHATSAPP_MODE || "self-chat";
const SENT_ECHO_TTL_MS = 60_000;
@@ -34,6 +35,82 @@ let sock = null;
let connectionState = "disconnected";
let latestQr = null;
let starting = null;
+let pendingPairing = null;
+
+function resetSessionState() {
+ sock = null;
+ latestQr = null;
+ sentKeys.clear();
+ recentlySentIds.clear();
+ mkdirSync(SESSION_DIR, { recursive: true });
+ for (const entry of readdirSync(SESSION_DIR)) {
+ rmSync(path.join(SESSION_DIR, entry), { recursive: true, force: true });
+ }
+}
+
+function resolvePendingPairing(payload) {
+ if (!pendingPairing) return;
+ clearTimeout(pendingPairing.timer);
+ pendingPairing.resolve(payload);
+ pendingPairing = null;
+}
+
+function rejectPendingPairing(error) {
+ if (!pendingPairing) return;
+ clearTimeout(pendingPairing.timer);
+ pendingPairing.reject(error);
+ pendingPairing = null;
+}
+
+async function maybeRequestPairingCode(update = {}) {
+ if (!pendingPairing || pendingPairing.inFlight || !sock) return;
+
+ const canRequestPairingCode =
+ update.connection === "connecting" ||
+ Boolean(update.qr) ||
+ Boolean(latestQr);
+
+ if (!canRequestPairingCode) return;
+
+ pendingPairing.inFlight = true;
+ connectionState = "pairing";
+ try {
+ const code = await sock.requestPairingCode(pendingPairing.phoneNumber);
+ resolvePendingPairing({ status: "pairing", pairing_code: code, expires_in: 60 });
+ } catch (error) {
+ rejectPendingPairing(error);
+ }
+}
+
+function requestPairingCodeWhenReady(phoneNumber) {
+ if (connectionState === "connected") {
+ return Promise.resolve({ status: "connected", pairing_code: null, expires_in: 0 });
+ }
+
+ if (pendingPairing) {
+ return Promise.reject(new Error("A WhatsApp pairing request is already in progress"));
+ }
+
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingPairing = null;
+ reject(new Error("Timed out waiting for WhatsApp to become ready for pairing"));
+ }, PAIRING_TIMEOUT_MS);
+
+ pendingPairing = {
+ phoneNumber,
+ resolve,
+ reject,
+ timer,
+ inFlight: false,
+ };
+
+ void startSocket()
+ .then(() => maybeRequestPairingCode())
+ .catch((error) => rejectPendingPairing(error));
+ void maybeRequestPairingCode();
+ });
+}
function normalizeText(message) {
const content = message?.message || {};
@@ -111,24 +188,33 @@ async function startSocket() {
latestQr = qr;
connectionState = "qr";
qrcode.generate(qr, { small: true });
+ void maybeRequestPairingCode(update);
}
if (connection === "open") {
latestQr = null;
connectionState = "connected";
console.log("WhatsApp connected");
+ resolvePendingPairing({ status: "connected", pairing_code: null, expires_in: 0 });
}
if (connection === "close") {
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
connectionState = "disconnected";
if (reason === DisconnectReason.loggedOut) {
- console.error("WhatsApp logged out; clear the session volume and pair again.");
- process.exit(1);
+ console.error("WhatsApp logged out; clearing session and waiting for pairing.");
+ connectionState = "logged_out";
+ resetSessionState();
+ setTimeout(() => {
+ starting = null;
+ void startSocket();
+ }, 1000);
+ return;
}
setTimeout(() => {
starting = null;
void startSocket();
}, reason === 515 ? 1000 : 3000);
}
+ void maybeRequestPairingCode(update);
});
sock.ev.on("messages.upsert", ({ messages, type }) => {
@@ -167,6 +253,7 @@ app.get("/health", (_req, res) => {
res.json({
status: connectionState,
hasQr: Boolean(latestQr),
+ qr: latestQr,
queueDepth: messageQueue.length,
user: sock?.user || null,
});
@@ -238,17 +325,11 @@ app.post("/typing", async (req, res) => {
app.post("/pair", async (req, res) => {
try {
- await startSocket();
const phoneNumber = String(req.body?.phoneNumber || req.body?.phone_number || "").replace(/\D/g, "");
- if (connectionState === "connected") {
- return res.json({ status: "connected", pairing_code: null, expires_in: 0 });
- }
if (!phoneNumber) {
return res.status(400).json({ error: "phoneNumber is required for pairing code" });
}
- connectionState = "pairing";
- const code = await sock.requestPairingCode(phoneNumber);
- res.json({ status: "pairing", pairing_code: code, expires_in: 60 });
+ res.json(await requestPairingCodeWhenReady(phoneNumber));
} catch (error) {
res.status(500).json({ error: error?.message || "pairing failed" });
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx
index 248abb121..b44f3ecbb 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx
@@ -2,6 +2,7 @@
import { MessageCircle, RefreshCw, ShieldAlert } from "lucide-react";
import { useParams } from "next/navigation";
+import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useState, useTransition } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,16 +38,23 @@ type Pairing = {
type PairingPlatform = "telegram" | "whatsapp";
+type BaileysHealth = {
+ status: string;
+ hasQr: boolean;
+ qr?: string | null;
+ queueDepth?: number;
+ user?: unknown;
+};
+
export function MessagingChannelsContent() {
const params = useParams<{ search_space_id: string }>();
const searchSpaceId = Number(params.search_space_id);
+ const whatsappMode = process.env.NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE ?? "disabled";
const [bindings, setBindings] = useState([]);
const [platforms, setPlatforms] = useState([]);
const [pairing, setPairing] = useState(null);
const [pairingPlatform, setPairingPlatform] = useState(null);
- const [whatsappStatus, setWhatsappStatus] = useState(null);
- const [baileysPhone, setBaileysPhone] = useState("");
- const [baileysCode, setBaileysCode] = useState(null);
+ const [baileysHealth, setBaileysHealth] = useState(null);
const [loading, setLoading] = useState(true);
const [isPending, startTransition] = useTransition();
@@ -65,6 +73,18 @@ export function MessagingChannelsContent() {
void refresh();
}, [refresh]);
+ const refreshBaileysHealth = useCallback(async () => {
+ if (whatsappMode !== "baileys") return;
+ const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/health`);
+ if (!res.ok) return;
+ const data = (await res.json()) as BaileysHealth;
+ setBaileysHealth(data);
+ }, [whatsappMode]);
+
+ useEffect(() => {
+ void refreshBaileysHealth();
+ }, [refreshBaileysHealth]);
+
async function startPairing(platform: PairingPlatform) {
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/start`, {
method: "POST",
@@ -76,25 +96,9 @@ export function MessagingChannelsContent() {
await refresh();
}
- function pairBaileys() {
+ function refreshBaileys() {
startTransition(async () => {
- setWhatsappStatus("Requesting WhatsApp pairing code...");
- const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/pair`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ search_space_id: searchSpaceId, phone_number: baileysPhone }),
- });
- if (!res.ok) {
- setWhatsappStatus("Unable to request pairing code. Check the whatsapp-bridge service.");
- return;
- }
- const data = await res.json();
- setBaileysCode(data.pairing_code ?? null);
- setWhatsappStatus(
- data.status === "connected"
- ? "WhatsApp bridge is connected."
- : "Enter the pairing code in WhatsApp.",
- );
+ await refreshBaileysHealth();
await refresh();
});
}
@@ -115,7 +119,7 @@ export function MessagingChannelsContent() {
const telegram = platforms.find((p) => p.platform === "telegram");
const whatsapp = platforms.find((p) => p.platform === "whatsapp");
- const whatsappMode = process.env.NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE ?? "disabled";
+ const baileysQr = baileysHealth?.qr || null;
const activeBindings = bindings.filter((binding) => binding.search_space_id === searchSpaceId);
const renderPairingPanel = (platform: PairingPlatform) => {
if (!pairing || pairingPlatform !== platform) return null;
@@ -195,26 +199,30 @@ export function MessagingChannelsContent() {
Self-hosted WhatsApp uses Message Yourself mode. After pairing, send messages in
your own WhatsApp chat with yourself; messages from other chats are ignored.
- setBaileysPhone(event.target.value)}
- />
-