whatsapp: respect no_proxy in proxy-backed web sessions
This commit is contained in:
parent
46843211c8
commit
2d4a80ccbb
@ -5,14 +5,10 @@ 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(),
|
||||
const { httpsProxyAgentSpy, envHttpProxyAgentSpy, undiciFetchMock } = vi.hoisted(() => ({
|
||||
httpsProxyAgentSpy: vi.fn(),
|
||||
undiciProxyAgentSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:https", () => ({
|
||||
request: httpsRequestMock,
|
||||
envHttpProxyAgentSpy: vi.fn(),
|
||||
undiciFetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("https-proxy-agent", () => ({
|
||||
@ -27,16 +23,17 @@ vi.mock("https-proxy-agent", () => ({
|
||||
|
||||
vi.mock("undici", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("undici")>();
|
||||
class MockProxyAgent {
|
||||
proxyUrl: string;
|
||||
constructor(proxyUrl: string) {
|
||||
this.proxyUrl = proxyUrl;
|
||||
undiciProxyAgentSpy(proxyUrl);
|
||||
class MockEnvHttpProxyAgent {
|
||||
options?: Record<string, unknown>;
|
||||
constructor(options?: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
envHttpProxyAgentSpy(options);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
ProxyAgent: MockProxyAgent,
|
||||
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
|
||||
fetch: undiciFetchMock,
|
||||
};
|
||||
});
|
||||
|
||||
@ -98,6 +95,8 @@ describe("web session", () => {
|
||||
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(() => {
|
||||
@ -137,43 +136,54 @@ describe("web session", () => {
|
||||
expect(passed?.agent).toBeDefined();
|
||||
expect(passed?.fetchAgent).toBeDefined();
|
||||
expect(httpsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128");
|
||||
expect(undiciProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:3128");
|
||||
expect(envHttpProxyAgentSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes the WhatsApp Web version through the proxy when Baileys falls back", async () => {
|
||||
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<typeof vi.fn>;
|
||||
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,
|
||||
});
|
||||
httpsRequestMock.mockImplementationOnce(
|
||||
(
|
||||
_url: string,
|
||||
_opts: Record<string, unknown>,
|
||||
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;
|
||||
},
|
||||
);
|
||||
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<typeof vi.fn>;
|
||||
const passed = makeWASocket.mock.calls[0]?.[0] as { version?: unknown } | undefined;
|
||||
expect(passed?.version).toEqual([2, 3000, 1035441841]);
|
||||
expect(httpsRequestMock).toHaveBeenCalled();
|
||||
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 () => {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import {
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
@ -15,8 +14,9 @@ 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 { EnvHttpProxyAgent } from "undici";
|
||||
import { resolveEnvHttpProxyUrl } from "../../../src/infra/net/proxy-env.js";
|
||||
import { resolveProxyFetchFromEnv } from "../../../src/infra/net/proxy-fetch.js";
|
||||
import {
|
||||
maybeRestoreCredsFromBackup,
|
||||
readCredsJsonRaw,
|
||||
@ -39,6 +39,13 @@ export {
|
||||
const credsSaveQueues = new Map<string, Promise<void>>();
|
||||
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,
|
||||
@ -108,40 +115,118 @@ function extractWhatsAppWebVersion(source: string): [number, number, number] | n
|
||||
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();
|
||||
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<string | null> {
|
||||
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<typeof getChildLogger>,
|
||||
): Promise<[number, number, number]> {
|
||||
@ -150,12 +235,8 @@ async function resolveWhatsAppWebVersion(
|
||||
return latest.version;
|
||||
}
|
||||
|
||||
const proxyUrl = resolveEnvHttpProxyUrl("https");
|
||||
if (!proxyUrl) {
|
||||
return latest.version;
|
||||
}
|
||||
|
||||
const proxyVersion = await fetchLatestWhatsAppWebVersionViaProxy(proxyUrl);
|
||||
const fetchImpl = resolveProxyFetchFromEnv() ?? globalThis.fetch;
|
||||
const proxyVersion = fetchImpl ? await fetchLatestWhatsAppWebVersion(fetchImpl) : null;
|
||||
if (!proxyVersion) {
|
||||
return latest.version;
|
||||
}
|
||||
@ -192,8 +273,11 @@ export async function createWaSocket(
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
const version = await resolveWhatsAppWebVersion(sessionLogger);
|
||||
const proxyUrl = resolveEnvHttpProxyUrl("https");
|
||||
const wsAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
const fetchAgent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
|
||||
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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user