From 46843211c821c98b0a8a4ba44490dc38050470d4 Mon Sep 17 00:00:00 2001 From: nando Date: Thu, 19 Mar 2026 00:13:31 +0000 Subject: [PATCH] whatsapp: route Baileys socket and version refresh through proxy --- extensions/whatsapp/src/session.test.ts | 91 +++++++++++++++++++++++++ extensions/whatsapp/src/session.ts | 86 ++++++++++++++++++++++- 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index d86de75ffa7..0171a57dc9e 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -5,6 +5,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; +const { httpsRequestMock, httpsProxyAgentSpy, undiciProxyAgentSpy } = vi.hoisted(() => ({ + httpsRequestMock: vi.fn(), + httpsProxyAgentSpy: vi.fn(), + undiciProxyAgentSpy: vi.fn(), +})); + +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + +vi.mock("https-proxy-agent", () => ({ + HttpsProxyAgent: vi.fn(function MockHttpsProxyAgent( + this: { proxyUrl: string }, + proxyUrl: string, + ) { + this.proxyUrl = proxyUrl; + httpsProxyAgentSpy(proxyUrl); + }), +})); + +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + class MockProxyAgent { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + } + } + return { + ...actual, + ProxyAgent: MockProxyAgent, + }; +}); + const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = await import("./session.js"); const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState); @@ -59,6 +94,10 @@ describe("web session", () => { vi.clearAllMocks(); resetBaileysMocks(); resetLoadConfigMock(); + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; }); afterEach(() => { @@ -85,6 +124,58 @@ describe("web session", () => { expect(saveCreds).toHaveBeenCalled(); }); + it("adds explicit proxy agents when https proxy env is configured", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + + await createWaSocket(false, false); + + const makeWASocket = baileys.makeWASocket as ReturnType; + const passed = makeWASocket.mock.calls[0]?.[0] as + | { agent?: unknown; fetchAgent?: unknown } + | undefined; + + expect(passed?.agent).toBeDefined(); + expect(passed?.fetchAgent).toBeDefined(); + expect(httpsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); + expect(undiciProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128"); + }); + + it("refreshes the WhatsApp Web version through the proxy when Baileys falls back", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + vi.mocked(baileys.fetchLatestBaileysVersion).mockResolvedValueOnce({ + version: [2, 3000, 1027934701], + isLatest: false, + }); + httpsRequestMock.mockImplementationOnce( + ( + _url: string, + _opts: Record, + cb: (res: EventEmitter & { statusCode?: number }) => void, + ) => { + const res = new EventEmitter() as EventEmitter & { statusCode?: number }; + res.statusCode = 200; + const req = new EventEmitter() as EventEmitter & { + end: () => void; + destroy: (_err?: Error) => void; + }; + req.end = () => { + cb(res); + res.emit("data", 'self.client_revision="1035441841";'); + res.emit("end"); + }; + req.destroy = () => {}; + return req; + }, + ); + + await createWaSocket(false, false); + + const makeWASocket = baileys.makeWASocket as ReturnType; + const passed = makeWASocket.mock.calls[0]?.[0] as { version?: unknown } | undefined; + expect(passed?.version).toEqual([2, 3000, 1035441841]); + expect(httpsRequestMock).toHaveBeenCalled(); + }); + it("waits for connection open", async () => { const ev = new EventEmitter(); const promise = waitForWaConnection({ ev } as unknown as ReturnType< diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 80690b110eb..70a296eb5fa 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; +import { request as httpsRequest } from "node:https"; import { DisconnectReason, fetchLatestBaileysVersion, @@ -7,12 +8,15 @@ import { makeWASocket, useMultiFileAuthState, } from "@whiskeysockets/baileys"; +import { HttpsProxyAgent } from "https-proxy-agent"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { VERSION } from "openclaw/plugin-sdk/cli-runtime"; import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import qrcode from "qrcode-terminal"; +import { ProxyAgent } from "undici"; +import { resolveEnvHttpProxyUrl } from "../../../src/infra/net/proxy-env.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, @@ -34,6 +38,8 @@ export { // Per-authDir queues so multi-account creds saves don't block each other. const credsSaveQueues = new Map>(); const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; +const WHATSAPP_WEB_SW_URL = "https://web.whatsapp.com/sw.js"; + function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, @@ -90,6 +96,79 @@ async function safeSaveCreds( } } +function extractWhatsAppWebVersion(source: string): [number, number, number] | null { + const match = source.match(/client_revision[^0-9]*(\d+)/); + if (!match) { + return null; + } + const revision = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(revision) || revision <= 0) { + return null; + } + return [2, 3000, revision]; +} + +async function fetchLatestWhatsAppWebVersionViaProxy( + proxyUrl: string, +): Promise<[number, number, number] | null> { + return await new Promise((resolve) => { + const agent = new HttpsProxyAgent(proxyUrl); + const req = httpsRequest( + WHATSAPP_WEB_SW_URL, + { + agent, + timeout: 10_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: string | Buffer) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + res.on("end", () => { + const statusCode = typeof res.statusCode === "number" ? res.statusCode : 0; + if (statusCode < 200 || statusCode >= 300) { + resolve(null); + return; + } + resolve(extractWhatsAppWebVersion(Buffer.concat(chunks).toString("utf8"))); + }); + }, + ); + req.on("timeout", () => { + req.destroy(new Error("Timed out fetching WhatsApp Web version")); + }); + req.on("error", () => resolve(null)); + req.end(); + }); +} + +async function resolveWhatsAppWebVersion( + sessionLogger: ReturnType, +): Promise<[number, number, number]> { + const latest = await fetchLatestBaileysVersion(); + if (latest.isLatest) { + return latest.version; + } + + const proxyUrl = resolveEnvHttpProxyUrl("https"); + if (!proxyUrl) { + return latest.version; + } + + const proxyVersion = await fetchLatestWhatsAppWebVersionViaProxy(proxyUrl); + if (!proxyVersion) { + return latest.version; + } + + sessionLogger.info( + { + version: proxyVersion, + }, + "using proxy-refreshed WhatsApp Web version", + ); + return proxyVersion; +} + /** * Create a Baileys socket backed by the multi-file auth store we keep on disk. * Consumers can opt into QR printing for interactive login flows. @@ -111,7 +190,10 @@ export async function createWaSocket( const sessionLogger = getChildLogger({ module: "web-session" }); maybeRestoreCredsFromBackup(authDir); const { state, saveCreds } = await useMultiFileAuthState(authDir); - const { version } = await fetchLatestBaileysVersion(); + const version = await resolveWhatsAppWebVersion(sessionLogger); + const proxyUrl = resolveEnvHttpProxyUrl("https"); + const wsAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; + const fetchAgent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined; const sock = makeWASocket({ auth: { creds: state.creds, @@ -123,6 +205,8 @@ export async function createWaSocket( browser: ["openclaw", "cli", VERSION], syncFullHistory: false, markOnlineOnConnect: false, + ...(wsAgent ? { agent: wsAgent } : {}), + ...(fetchAgent ? { fetchAgent } : {}), }); sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger));