diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 609c912b710..90a30fbf6a3 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -5,6 +5,38 @@ 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 { httpsProxyAgentSpy, envHttpProxyAgentSpy, undiciFetchMock } = vi.hoisted(() => ({ + httpsProxyAgentSpy: vi.fn(), + envHttpProxyAgentSpy: vi.fn(), + undiciFetchMock: vi.fn(), +})); + +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 MockEnvHttpProxyAgent { + options?: Record; + constructor(options?: Record) { + this.options = options; + envHttpProxyAgentSpy(options); + } + } + return { + ...actual, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, + fetch: undiciFetchMock, + }; +}); + const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = await import("./session.js"); const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState); @@ -59,6 +91,12 @@ 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; + delete process.env.no_proxy; + delete process.env.NO_PROXY; }); afterEach(() => { @@ -85,6 +123,69 @@ 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(envHttpProxyAgentSpy).toHaveBeenCalled(); + }); + + it("does not force the websocket through the proxy when NO_PROXY excludes web.whatsapp.com", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + process.env.NO_PROXY = "web.whatsapp.com"; + + 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).toBeUndefined(); + expect(passed?.fetchAgent).toBeDefined(); + expect(httpsProxyAgentSpy).not.toHaveBeenCalled(); + expect(envHttpProxyAgentSpy).toHaveBeenCalled(); + }); + + it("refreshes the WhatsApp Web version with Baileys headers when Baileys falls back", async () => { + process.env.HTTPS_PROXY = "http://proxy.test:3128"; + vi.mocked(baileys.fetchLatestBaileysVersion).mockResolvedValueOnce({ + version: [2, 3000, 1027934701], + isLatest: false, + }); + undiciFetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + body: null, + text: vi.fn().mockResolvedValue('self.client_revision="1035441841";'), + } as unknown as Response); + + 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(undiciFetchMock).toHaveBeenCalledWith( + "https://web.whatsapp.com/sw.js", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "sec-fetch-site": "none", + "user-agent": expect.stringContaining("Chrome/131.0.0.0"), + }), + }), + ); + }); + 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 3c9c7f74c1f..f23089fcd33 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -7,12 +7,16 @@ 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 { EnvHttpProxyAgent } from "undici"; +import { resolveEnvHttpProxyUrl } from "../../../src/infra/net/proxy-env.js"; +import { resolveProxyFetchFromEnv } from "../../../src/infra/net/proxy-fetch.js"; import { maybeRestoreCredsFromBackup, readCredsJsonRaw, @@ -36,6 +40,15 @@ 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"; +const WHATSAPP_WEB_SW_MAX_BYTES = 512 * 1024; +const WHATSAPP_WEB_VERSION_HEADERS = { + "sec-fetch-site": "none", + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +} as const; +const WHATSAPP_WEB_SOCKET_HOST = "web.whatsapp.com"; + function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, @@ -92,6 +105,153 @@ 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]; +} + +function parseNoProxyRules(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.no_proxy ?? env.NO_PROXY; + if (!raw) { + return []; + } + return raw + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); +} + +function stripNoProxyPort(rule: string): string { + if (rule.startsWith("[") && rule.includes("]")) { + const end = rule.indexOf("]"); + return end >= 0 ? rule.slice(0, end + 1) : rule; + } + const colonIndex = rule.lastIndexOf(":"); + if (colonIndex <= 0 || rule.includes(".")) { + return colonIndex > 0 ? rule.slice(0, colonIndex) : rule; + } + return rule; +} + +function shouldBypassEnvProxyForHostname( + hostname: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const normalizedHost = hostname.trim().toLowerCase(); + if (!normalizedHost) { + return false; + } + return parseNoProxyRules(env).some((rawRule) => { + if (rawRule === "*") { + return true; + } + const rule = stripNoProxyPort(rawRule).replace(/^\*\./, ".").toLowerCase(); + if (!rule) { + return false; + } + if (rule.startsWith(".")) { + const bareRule = rule.slice(1); + return normalizedHost === bareRule || normalizedHost.endsWith(rule); + } + return normalizedHost === rule || normalizedHost.endsWith(`.${rule}`); + }); +} + +async function readResponseTextCapped( + response: Response, + maxBytes: number, +): Promise { + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader) { + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return null; + } + } + if (!response.body) { + const text = await response.text(); + return Buffer.byteLength(text, "utf8") <= maxBytes ? text : null; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + try { + await reader.cancel("WhatsApp sw.js response too large"); + } catch { + // ignore reader cancellation errors + } + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchLatestWhatsAppWebVersion( + fetchImpl: typeof fetch, +): Promise<[number, number, number] | null> { + try { + const response = await fetchImpl(WHATSAPP_WEB_SW_URL, { + method: "GET", + headers: WHATSAPP_WEB_VERSION_HEADERS, + }); + if (!response.ok) { + return null; + } + const source = await readResponseTextCapped(response, WHATSAPP_WEB_SW_MAX_BYTES); + if (!source) { + return null; + } + return extractWhatsAppWebVersion(source); + } catch { + return null; + } +} + +async function resolveWhatsAppWebVersion( + sessionLogger: ReturnType, +): Promise<[number, number, number]> { + const latest = await fetchLatestBaileysVersion(); + if (latest.isLatest) { + return latest.version; + } + + const fetchImpl = resolveProxyFetchFromEnv() ?? globalThis.fetch; + const proxyVersion = fetchImpl ? await fetchLatestWhatsAppWebVersion(fetchImpl) : null; + 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. @@ -113,7 +273,13 @@ 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 && !shouldBypassEnvProxyForHostname(WHATSAPP_WEB_SOCKET_HOST) + ? new HttpsProxyAgent(proxyUrl) + : undefined; + const fetchAgent = proxyUrl ? new EnvHttpProxyAgent() : undefined; const sock = makeWASocket({ auth: { creds: state.creds, @@ -125,6 +291,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));