Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 10 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,11 @@ export const api = {
{ method: "DELETE" },
),

getWhatsappQR: () =>
fetchJSON<WhatsappQRResponse>("/api/messaging/whatsapp/qr"),
resetWhatsappSession: () =>
fetchJSON<{ ok: boolean }>("/api/messaging/whatsapp/reset", { method: "POST" }),

// Gateway / update actions
restartGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
Expand Down Expand Up @@ -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;
Expand Down
134 changes: 134 additions & 0 deletions web/src/pages/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
MessagingPlatformEnvVar,
MessagingPlatformUpdate,
TelegramOnboardingStartResponse,
WhatsappQRResponse,
} from "@/lib/api";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
Expand Down Expand Up @@ -451,6 +452,9 @@ export default function ChannelsPage() {
showToast={showToast}
/>
)}
{platform.id === "whatsapp" && (
<WhatsappOnboardingPanel showToast={showToast} />
)}
</CardContent>
</Card>
);
Expand Down Expand Up @@ -806,3 +810,133 @@ function TelegramOnboardingPanel({
</div>
);
}

function WhatsappOnboardingPanel({
showToast,
}: {
showToast: (message: string, type: "success" | "error") => void;
}) {
const [data, setData] = useState<WhatsappQRResponse | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string>("");
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",
margin: 1,
width: 224,
});
setQrDataUrl(url);
}
} 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("");
setPhase("idle");
showToast("Session reset — scan the new QR code", "success");
setTimeout(fetchQR, 1500);
} catch (e) {
showToast("Reset failed: " + String(e), "error");
} finally {
setResetting(false);
}
};

const isPending = data?.status === "qr_pending";
const isConnected = data?.status === "connected";

return (
<div className="mt-4">
<div className="rounded-sm border border-border bg-background/35 p-4">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
className="uppercase"
onClick={handleReset}
disabled={phase === "idle" || resetting}
prefix={resetting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
>
{resetting ? "Resetting…" : isPending ? "Connecting…" : isConnected ? "Re-pair with QR" : "Set up with QR"}
</Button>
{phase === "idle" && !isPending && !isConnected && (
<span className="text-xs text-muted-foreground">
WhatsApp bridge not running or not configured.
</span>
)}
</div>

{isPending && qrDataUrl && (
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="warning">Scan to pair</Badge>
</div>
<p className="text-sm text-muted-foreground">
Open WhatsApp on your phone, go to{" "}
<span className="font-courier">Linked Devices</span> →{" "}
<span className="font-courier">Link a Device</span>, and scan
the QR code.
</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
ghost
className="uppercase text-destructive"
onClick={handleReset}
disabled={resetting}
prefix={<X className="h-4 w-4" />}
>
Cancel
</Button>
</div>
</div>

<div className="flex flex-col items-center justify-center gap-3">
<img
src={qrDataUrl}
alt="WhatsApp QR code"
className="h-56 w-56 bg-white p-2"
/>
<Badge tone="warning">waiting</Badge>
</div>
</div>
)}

{isConnected && (
<div className="mt-4 grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="success">Connected</Badge>
</div>
<p className="text-sm text-muted-foreground">
WhatsApp is paired and connected.
</p>
</div>
)}
</div>
</div>
);
}