From dd643c82b55efef49787568e946816ecabb6215e Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Tue, 28 Apr 2026 15:46:47 +0200 Subject: [PATCH] fix(whatsapp): expose Baileys socket timing (#73580) Merged via squash. Prepared head SHA: d34755262f8e543cc50a88acb48391a9bda2c42a Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/channels/whatsapp.md | 20 ++++++- docs/gateway/config-channels.md | 7 +++ extensions/whatsapp/src/auto-reply/monitor.ts | 3 ++ extensions/whatsapp/src/auto-reply/types.ts | 2 + .../whatsapp/src/connection-controller.ts | 7 +++ extensions/whatsapp/src/inbound/monitor.ts | 2 + extensions/whatsapp/src/login-qr.ts | 6 +++ extensions/whatsapp/src/login.ts | 4 ++ extensions/whatsapp/src/session.test.ts | 23 +++++++- extensions/whatsapp/src/session.ts | 14 ++++- extensions/whatsapp/src/socket-timing.test.ts | 49 +++++++++++++++++ extensions/whatsapp/src/socket-timing.ts | 38 +++++++++++++ src/config/schema.base.generated.ts | 53 +++++++++++++++++++ src/config/schema.help.quality.test.ts | 4 ++ src/config/schema.help.ts | 8 +++ src/config/schema.labels.ts | 4 ++ src/config/schema.test.ts | 19 +++++++ src/config/types.base.ts | 10 ++++ src/config/zod-schema.ts | 8 +++ 21 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 extensions/whatsapp/src/socket-timing.test.ts create mode 100644 extensions/whatsapp/src/socket-timing.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 170e35e96bd..1306b2393ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Memory-core/dreaming: retry managed dreaming cron registration after startup when the cron service is not reachable yet, so the scheduled Memory Dreaming Promotion sweep recovers without waiting for heartbeat traffic. Fixes #72841. Thanks @amknight. - Acpx/runtime: validate the runtime session mode at the `AcpxRuntime.ensureSession` wrapper boundary so callers that pass anything other than `persistent` or `oneshot` get a clear `ACP_INVALID_RUNTIME_OPTION` error instead of silently round-tripping through the encoded handle as a default `persistent` mode and later throwing `SessionResumeRequiredError`. Investigation context: #73071. (#73548) Thanks @amknight. - CLI/infer: keep web-search fallback on missing provider API keys, preserve structured validation errors from the selected provider, and let per-request image describe prompts override configured media-entry prompts. (#63263) Thanks @Spolen23. +- WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark. ## 2026.4.27 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 293aefe4872..ed5f835a8d3 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -85842690af24b21a5e074d722930af95faaf6e91a918061bdc1b5c956860a7a0 config-baseline.json -86ad0927d992bc873affb3e20a31c6e3c95b2185a91f46cc8e6262a723a78f7d config-baseline.core.json +39c5c0620611f355f20d5e9d2ddd74e198c344c63d5551a987e4b7538833ceac config-baseline.json +805bd3f63ff7327da45c01b78dbc990ed53bd13b89e0cbf50f319aa99334ba92 config-baseline.core.json 323a9fd49a669951ca5b3442d95aad243bd1330083f9857e83a8dcfae2bbc9d0 config-baseline.channel.json 1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 8ef330d5d49..08af114f43b 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -147,6 +147,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - Gateway owns the WhatsApp socket and reconnect loop. - The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window. +- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts. - Outbound sends require an active WhatsApp listener for the target account. - Status and broadcast chats are ignored (`@status`, `@broadcast`). - Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session). @@ -520,6 +521,23 @@ Behavior notes: restarts when WhatsApp Web transport activity stops, the socket closes, or application-level activity stays silent beyond the longer safety window. + If logs show repeated `status=408 Request Time-out Connection was lost`, tune + Baileys socket timings under `web.whatsapp`. Start by shortening + `keepAliveIntervalMs` below your network's idle timeout and increasing + `connectTimeoutMs` on slow or lossy links: + + ```json5 + { + web: { + whatsapp: { + keepAliveIntervalMs: 15000, + connectTimeoutMs: 60000, + defaultQueryTimeoutMs: 60000, + }, + }, + } + ``` + Fix: ```bash @@ -643,7 +661,7 @@ High-signal WhatsApp fields: - access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` - multi-account: `accounts..enabled`, `accounts..authDir`, account-level overrides -- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*` +- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*` - session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` - prompts: `groups..systemPrompt`, `groups["*"].systemPrompt`, `direct..systemPrompt`, `direct["*"].systemPrompt` diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index fa2672d3d18..65790911f3f 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -96,6 +96,13 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ```json5 { + web: { + whatsapp: { + keepAliveIntervalMs: 25000, + connectTimeoutMs: 60000, + defaultQueryTimeoutMs: 60000, + }, + }, channels: { whatsapp: { dmPolicy: "pairing", // pairing | allowlist | open | disabled diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 912c0bf74ed..712850bbc7e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -28,6 +28,7 @@ import { sleepWithAbort, } from "../reconnect.js"; import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js"; +import { resolveWhatsAppSocketTiming } from "../socket-timing.js"; import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; import { buildMentionConfig } from "./mentions.js"; @@ -181,6 +182,7 @@ export async function monitorWebChannel( const maxMediaBytes = resolveWhatsAppMediaMaxBytes(account); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, tuning.heartbeatSeconds); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); + const socketTiming = resolveWhatsAppSocketTiming(cfg, tuning.socketTiming); const baseMentionConfig = buildMentionConfig(cfg); const groupHistoryLimit = account.historyLimit ?? @@ -229,6 +231,7 @@ export async function monitorWebChannel( messageTimeoutMs, watchdogCheckMs, reconnectPolicy, + socketTiming, abortSignal, sleep, isNonRetryableStatus: isNonRetryableWebCloseStatus, diff --git a/extensions/whatsapp/src/auto-reply/types.ts b/extensions/whatsapp/src/auto-reply/types.ts index f8fa115795d..68c3ee545f6 100644 --- a/extensions/whatsapp/src/auto-reply/types.ts +++ b/extensions/whatsapp/src/auto-reply/types.ts @@ -1,5 +1,6 @@ import type { WebInboundMessage } from "../inbound/types.js"; import type { ReconnectPolicy } from "../reconnect.js"; +import type { WhatsAppSocketTimingOptions } from "../socket-timing.js"; export type WebChannelHealthState = | "starting" @@ -32,6 +33,7 @@ export type WebChannelStatus = { export type WebMonitorTuning = { reconnect?: Partial; + socketTiming?: WhatsAppSocketTimingOptions; heartbeatSeconds?: number; messageTimeoutMs?: number; watchdogCheckMs?: number; diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index a0fc5f04b28..56ab1caa72b 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -14,6 +14,7 @@ import { logoutWeb, waitForWaConnection, } from "./session.js"; +import type { WhatsAppSocketTimingOptions } from "./socket-timing.js"; const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; const WHATSAPP_LOGIN_RESTART_MESSAGE = @@ -171,6 +172,7 @@ export async function waitForWhatsAppLoginResult(params: { runtime: RuntimeEnv; waitForConnection?: typeof waitForWaConnection; createSocket?: typeof createWaSocket; + socketTiming?: WhatsAppSocketTimingOptions; onQr?: (qr: string) => void; onSocketReplaced?: (sock: WaSocket) => void; }): Promise { @@ -196,6 +198,7 @@ export async function waitForWhatsAppLoginResult(params: { try { currentSock = await createSocket(false, params.verbose, { authDir: params.authDir, + ...params.socketTiming, onQr: params.onQr, }); params.onSocketReplaced?.(currentSock); @@ -249,6 +252,7 @@ export class WhatsAppConnectionController { private readonly abortSignal?: AbortSignal; private readonly sleep: (ms: number, signal?: AbortSignal) => Promise; private readonly isNonRetryableStatus: (statusCode: unknown) => boolean; + private readonly socketTiming: WhatsAppSocketTimingOptions; private readonly abortPromise?: Promise<"aborted">; private readonly disconnectRetryController = new AbortController(); @@ -267,6 +271,7 @@ export class WhatsAppConnectionController { abortSignal?: AbortSignal; sleep?: (ms: number, signal?: AbortSignal) => Promise; isNonRetryableStatus?: (statusCode: unknown) => boolean; + socketTiming?: WhatsAppSocketTimingOptions; }) { this.accountId = params.accountId; this.authDir = params.authDir; @@ -280,6 +285,7 @@ export class WhatsAppConnectionController { this.abortSignal = params.abortSignal; this.sleep = params.sleep ?? ((ms: number, signal?: AbortSignal) => sleepWithAbort(ms, signal)); this.isNonRetryableStatus = params.isNonRetryableStatus ?? (() => false); + this.socketTiming = params.socketTiming ?? {}; this.socketRef = { current: null }; this.abortPromise = params.abortSignal && @@ -378,6 +384,7 @@ export class WhatsAppConnectionController { try { sock = await createWaSocket(false, this.verbose, { authDir: this.authDir, + ...this.socketTiming, }); await waitForWaConnection(sock); diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 4b193854091..b55e689cd02 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -17,6 +17,7 @@ import { cacheInboundMessageMeta } from "../quoted-message.js"; import { DEFAULT_RECONNECT_POLICY, computeBackoff, sleepWithAbort } from "../reconnect.js"; import type { OpenClawConfig } from "../runtime-api.js"; import { createWaSocket, formatError, getStatusCode, waitForWaConnection } from "../session.js"; +import { resolveWhatsAppSocketTiming } from "../socket-timing.js"; import { resolveJidToE164 } from "../text-runtime.js"; import { checkInboundAccessControl } from "./access-control.js"; import { @@ -774,6 +775,7 @@ export async function attachWebInboxToSocket( export async function monitorWebInbox(options: MonitorWebInboxOptions) { const sock = await createWaSocket(false, options.verbose, { authDir: options.authDir, + ...resolveWhatsAppSocketTiming(options.cfg), }); await waitForWaConnection(sock); return attachWebInboxToSocket({ diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index 656ed282702..885b7fecb1d 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -16,6 +16,7 @@ import { readWebSelfId, WHATSAPP_AUTH_UNSTABLE_CODE, } from "./session.js"; +import { resolveWhatsAppSocketTiming, type WhatsAppSocketTimingOptions } from "./socket-timing.js"; type WaSocket = Awaited>; export type StartWebLoginWithQrResult = { @@ -45,6 +46,7 @@ type ActiveLogin = { qrRenderPromise: Promise | null; verbose: boolean; runtime: RuntimeEnv; + socketTiming: WhatsAppSocketTimingOptions; }; type LoginQrRaceResult = @@ -178,6 +180,7 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) { isLegacyAuthDir: login.isLegacyAuthDir, verbose: login.verbose, runtime: login.runtime, + socketTiming: login.socketTiming, onQr: (qr) => { const current = activeLogins.get(accountId); if (!current || current.id !== login.id) { @@ -282,6 +285,7 @@ export async function startWebLoginWithQr( const runtime = opts.runtime ?? defaultRuntime; const cfg = getRuntimeConfig(); const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); + const socketTiming = resolveWhatsAppSocketTiming(cfg); const authState = await readWebAuthExistsForDecision(account.authDir); if (authState.outcome === "unstable") { return { @@ -327,6 +331,7 @@ export async function startWebLoginWithQr( try { sock = await createWaSocket(false, Boolean(opts.verbose), { authDir: account.authDir, + ...socketTiming, onQr: (qr: string) => { pendingQr = qr; const current = activeLogins.get(account.accountId); @@ -370,6 +375,7 @@ export async function startWebLoginWithQr( qrRenderPromise: null, verbose: Boolean(opts.verbose), runtime, + socketTiming, }; resetQrUpdateSignal(login); activeLogins.set(account.accountId, login); diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index b08bde6de2b..5c7e8da61b3 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -7,6 +7,7 @@ import { resolveWhatsAppAccount } from "./accounts.js"; import { restoreCredsFromBackupIfNeeded } from "./auth-store.js"; import { closeWaSocketSoon, waitForWhatsAppLoginResult } from "./connection-controller.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; +import { resolveWhatsAppSocketTiming } from "./socket-timing.js"; export async function loginWeb( verbose: boolean, @@ -16,9 +17,11 @@ export async function loginWeb( ) { const cfg = getRuntimeConfig(); const account = resolveWhatsAppAccount({ cfg, accountId }); + const socketTiming = resolveWhatsAppSocketTiming(cfg); const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir); let sock = await createWaSocket(true, verbose, { authDir: account.authDir, + ...socketTiming, }); logInfo("Waiting for WhatsApp connection...", runtime); try { @@ -29,6 +32,7 @@ export async function loginWeb( verbose, runtime, waitForConnection, + socketTiming, onSocketReplaced: (replacementSock) => { sock = replacementSock; }, diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index d6b0e50af58..3b1cc5a2f05 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -15,6 +15,7 @@ let logWebSelfId: typeof import("./session.js").logWebSelfId; let waitForWaConnection: typeof import("./session.js").waitForWaConnection; let waitForCredsSaveQueue: typeof import("./session.js").waitForCredsSaveQueue; let writeCredsJsonAtomically: typeof import("./session.js").writeCredsJsonAtomically; +let DEFAULT_WHATSAPP_SOCKET_TIMING: typeof import("./socket-timing.js").DEFAULT_WHATSAPP_SOCKET_TIMING; async function flushCredsUpdate() { await new Promise((resolve) => setImmediate(resolve)); @@ -148,6 +149,7 @@ describe("web session", () => { waitForCredsSaveQueue, writeCredsJsonAtomically, } = await import("./session.js")); + ({ DEFAULT_WHATSAPP_SOCKET_TIMING } = await import("./socket-timing.js")); }); beforeEach(() => { @@ -171,7 +173,10 @@ describe("web session", () => { await createWaSocket(true, false, { authDir }); const makeWASocket = baileys.makeWASocket as ReturnType; expect(makeWASocket).toHaveBeenCalledWith( - expect.objectContaining({ printQRInTerminal: false }), + expect.objectContaining({ + printQRInTerminal: false, + ...DEFAULT_WHATSAPP_SOCKET_TIMING, + }), ); const passed = makeWASocket.mock.calls[0][0]; const passedLogger = (passed as { logger?: { level?: string; trace?: unknown } }).logger; @@ -187,6 +192,22 @@ describe("web session", () => { openMock.restore(); }); + it("passes explicit Baileys socket timing overrides", async () => { + await createWaSocket(false, false, { + keepAliveIntervalMs: 10_000, + connectTimeoutMs: 90_000, + defaultQueryTimeoutMs: 120_000, + }); + + expect(baileys.makeWASocket).toHaveBeenCalledWith( + expect.objectContaining({ + keepAliveIntervalMs: 10_000, + connectTimeoutMs: 90_000, + defaultQueryTimeoutMs: 120_000, + }), + ); + }); + it("uses ambient env proxy agent when HTTPS_PROXY is configured", async () => { vi.stubEnv("HTTPS_PROXY", "http://proxy.test:8080"); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index bd7a53d0931..b421620aea8 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -34,6 +34,10 @@ import { makeWASocket, useMultiFileAuthState, } from "./session.runtime.js"; +import { + DEFAULT_WHATSAPP_SOCKET_TIMING, + type WhatsAppSocketTimingOptions, +} from "./socket-timing.js"; export { formatError, getStatusCode } from "./session-errors.js"; export { @@ -126,7 +130,7 @@ async function printTerminalQr(qr: string): Promise { export async function createWaSocket( printQr: boolean, verbose: boolean, - opts: { authDir?: string; onQr?: (qr: string) => void } = {}, + opts: { authDir?: string; onQr?: (qr: string) => void } & WhatsAppSocketTimingOptions = {}, ): Promise> { const baseLogger = getChildLogger( { module: "baileys" }, @@ -151,6 +155,13 @@ export async function createWaSocket( const { version } = await fetchLatestBaileysVersion(); const agent = await resolveEnvProxyAgent(sessionLogger); const fetchAgent = await resolveEnvFetchDispatcher(sessionLogger, agent); + const socketTiming = { + keepAliveIntervalMs: + opts.keepAliveIntervalMs ?? DEFAULT_WHATSAPP_SOCKET_TIMING.keepAliveIntervalMs, + connectTimeoutMs: opts.connectTimeoutMs ?? DEFAULT_WHATSAPP_SOCKET_TIMING.connectTimeoutMs, + defaultQueryTimeoutMs: + opts.defaultQueryTimeoutMs ?? DEFAULT_WHATSAPP_SOCKET_TIMING.defaultQueryTimeoutMs, + }; const sock = makeWASocket({ auth: { creds: state.creds, @@ -162,6 +173,7 @@ export async function createWaSocket( browser: ["openclaw", "cli", VERSION], syncFullHistory: false, markOnlineOnConnect: false, + ...socketTiming, agent, // Baileys types still model `fetchAgent` as a Node agent even though the // runtime path accepts an undici dispatcher for upload fetches. diff --git a/extensions/whatsapp/src/socket-timing.test.ts b/extensions/whatsapp/src/socket-timing.test.ts new file mode 100644 index 00000000000..691fe69b20e --- /dev/null +++ b/extensions/whatsapp/src/socket-timing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_WHATSAPP_SOCKET_TIMING, resolveWhatsAppSocketTiming } from "./socket-timing.js"; + +describe("resolveWhatsAppSocketTiming", () => { + it("uses OpenClaw's explicit WhatsApp Web socket defaults", () => { + expect(resolveWhatsAppSocketTiming({})).toEqual(DEFAULT_WHATSAPP_SOCKET_TIMING); + }); + + it("reads Baileys timing values from web.whatsapp config", () => { + expect( + resolveWhatsAppSocketTiming({ + web: { + whatsapp: { + keepAliveIntervalMs: 10_000, + connectTimeoutMs: 90_000, + defaultQueryTimeoutMs: 120_000, + }, + }, + }), + ).toEqual({ + keepAliveIntervalMs: 10_000, + connectTimeoutMs: 90_000, + defaultQueryTimeoutMs: 120_000, + }); + }); + + it("lets call-site overrides take precedence over config", () => { + expect( + resolveWhatsAppSocketTiming( + { + web: { + whatsapp: { + keepAliveIntervalMs: 10_000, + connectTimeoutMs: 90_000, + defaultQueryTimeoutMs: 120_000, + }, + }, + }, + { + keepAliveIntervalMs: 20_000, + }, + ), + ).toEqual({ + keepAliveIntervalMs: 20_000, + connectTimeoutMs: 90_000, + defaultQueryTimeoutMs: 120_000, + }); + }); +}); diff --git a/extensions/whatsapp/src/socket-timing.ts b/extensions/whatsapp/src/socket-timing.ts new file mode 100644 index 00000000000..e0fb6cfed95 --- /dev/null +++ b/extensions/whatsapp/src/socket-timing.ts @@ -0,0 +1,38 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; + +export type WhatsAppSocketTimingOptions = { + keepAliveIntervalMs?: number; + connectTimeoutMs?: number; + defaultQueryTimeoutMs?: number; +}; + +export const DEFAULT_WHATSAPP_SOCKET_TIMING: Required = { + keepAliveIntervalMs: 25_000, + connectTimeoutMs: 60_000, + defaultQueryTimeoutMs: 60_000, +}; + +function positiveInteger(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +export function resolveWhatsAppSocketTiming( + cfg: OpenClawConfig, + overrides?: WhatsAppSocketTimingOptions, +): Required { + const configured = cfg.web?.whatsapp; + return { + keepAliveIntervalMs: + positiveInteger(overrides?.keepAliveIntervalMs) ?? + positiveInteger(configured?.keepAliveIntervalMs) ?? + DEFAULT_WHATSAPP_SOCKET_TIMING.keepAliveIntervalMs, + connectTimeoutMs: + positiveInteger(overrides?.connectTimeoutMs) ?? + positiveInteger(configured?.connectTimeoutMs) ?? + DEFAULT_WHATSAPP_SOCKET_TIMING.connectTimeoutMs, + defaultQueryTimeoutMs: + positiveInteger(overrides?.defaultQueryTimeoutMs) ?? + positiveInteger(configured?.defaultQueryTimeoutMs) ?? + DEFAULT_WHATSAPP_SOCKET_TIMING.defaultQueryTimeoutMs, + }; +} diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index be91773d40d..9f101633086 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -21678,6 +21678,39 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Reconnect backoff policy for web channel reconnect attempts after transport failure. Keep bounded retries and jitter tuned to avoid thundering-herd reconnect behavior.", }, + whatsapp: { + type: "object", + properties: { + keepAliveIntervalMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + title: "WhatsApp Web Keepalive Interval (ms)", + description: + "Baileys WhatsApp Web application ping interval in milliseconds. Lower values detect and refresh idle links sooner; keep this comfortably below your network's idle-flow timeout.", + }, + connectTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + title: "WhatsApp Web Connect Timeout (ms)", + description: + "Maximum time in milliseconds Baileys waits for the WhatsApp WebSocket opening handshake. Use a higher value on slow or lossy networks that report opening handshake 408 timeouts.", + }, + defaultQueryTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + title: "WhatsApp Web Query Timeout (ms)", + description: + "Default Baileys query timeout in milliseconds for WhatsApp Web requests. Keep aligned with upstream unless a network-specific investigation shows queries need longer.", + }, + }, + additionalProperties: false, + title: "WhatsApp Web Socket Timing", + description: + "WhatsApp Web socket timing controls passed directly to Baileys. Tune these when network edges, proxies, or NATs are closing otherwise healthy WhatsApp Web sessions.", + }, }, additionalProperties: false, title: "Web Channel", @@ -28020,6 +28053,26 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.", tags: ["performance"], }, + "web.whatsapp": { + label: "WhatsApp Web Socket Timing", + help: "WhatsApp Web socket timing controls passed directly to Baileys. Tune these when network edges, proxies, or NATs are closing otherwise healthy WhatsApp Web sessions.", + tags: ["advanced"], + }, + "web.whatsapp.keepAliveIntervalMs": { + label: "WhatsApp Web Keepalive Interval (ms)", + help: "Baileys WhatsApp Web application ping interval in milliseconds. Lower values detect and refresh idle links sooner; keep this comfortably below your network's idle-flow timeout.", + tags: ["performance"], + }, + "web.whatsapp.connectTimeoutMs": { + label: "WhatsApp Web Connect Timeout (ms)", + help: "Maximum time in milliseconds Baileys waits for the WhatsApp WebSocket opening handshake. Use a higher value on slow or lossy networks that report opening handshake 408 timeouts.", + tags: ["performance"], + }, + "web.whatsapp.defaultQueryTimeoutMs": { + label: "WhatsApp Web Query Timeout (ms)", + help: "Default Baileys query timeout in milliseconds for WhatsApp Web requests. Keep aligned with upstream unless a network-specific investigation shows queries need longer.", + tags: ["performance"], + }, "discovery.wideArea": { label: "Wide-area Discovery", help: "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index fca17404d78..e1591d9657c 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -308,6 +308,10 @@ const TARGET_KEYS = [ "web.reconnect.factor", "web.reconnect.jitter", "web.reconnect.maxAttempts", + "web.whatsapp", + "web.whatsapp.keepAliveIntervalMs", + "web.whatsapp.connectTimeoutMs", + "web.whatsapp.defaultQueryTimeoutMs", "discovery", "discovery.wideArea.domain", "discovery.wideArea.enabled", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bd7765fada5..be6a7e81ca4 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -401,6 +401,14 @@ export const FIELD_HELP: Record = { "Randomization factor (0-1) applied to reconnect delays to desynchronize clients after outage events. Keep non-zero jitter in multi-client deployments to reduce synchronized spikes.", "web.reconnect.maxAttempts": "Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.", + "web.whatsapp": + "WhatsApp Web socket timing controls passed directly to Baileys. Tune these when network edges, proxies, or NATs are closing otherwise healthy WhatsApp Web sessions.", + "web.whatsapp.keepAliveIntervalMs": + "Baileys WhatsApp Web application ping interval in milliseconds. Lower values detect and refresh idle links sooner; keep this comfortably below your network's idle-flow timeout.", + "web.whatsapp.connectTimeoutMs": + "Maximum time in milliseconds Baileys waits for the WhatsApp WebSocket opening handshake. Use a higher value on slow or lossy networks that report opening handshake 408 timeouts.", + "web.whatsapp.defaultQueryTimeoutMs": + "Default Baileys query timeout in milliseconds for WhatsApp Web requests. Keep aligned with upstream unless a network-specific investigation shows queries need longer.", canvasHost: "Canvas host settings for serving canvas assets and local live-reload behavior used by canvas-enabled workflows. Keep disabled unless canvas-hosted assets are actively used.", "canvasHost.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index ce63efef686..381a349b521 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -802,6 +802,10 @@ export const FIELD_LABELS: Record = { "web.reconnect.factor": "Web Reconnect Backoff Factor", "web.reconnect.jitter": "Web Reconnect Jitter", "web.reconnect.maxAttempts": "Web Reconnect Max Attempts", + "web.whatsapp": "WhatsApp Web Socket Timing", + "web.whatsapp.keepAliveIntervalMs": "WhatsApp Web Keepalive Interval (ms)", + "web.whatsapp.connectTimeoutMs": "WhatsApp Web Connect Timeout (ms)", + "web.whatsapp.defaultQueryTimeoutMs": "WhatsApp Web Query Timeout (ms)", discovery: "Discovery", "discovery.wideArea": "Wide-area Discovery", "discovery.wideArea.enabled": "Wide-area Discovery Enabled", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index beaf4d20d45..7d3d91843d7 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -3,6 +3,7 @@ import { SENSITIVE_URL_HINT_TAG } from "../shared/net/redact-sensitive-url.js"; import { buildConfigSchema, lookupConfigSchema } from "./schema.js"; import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; +import { OpenClawSchema } from "./zod-schema.js"; describe("config schema", () => { type SchemaInput = NonNullable[0]>; @@ -290,6 +291,24 @@ describe("config schema", () => { expect(parsed?.web?.fetch?.maxResponseBytes).toBe(2_000_000); }); + it("accepts WhatsApp Web Baileys socket timing in the runtime zod schema", () => { + const parsed = OpenClawSchema.parse({ + web: { + whatsapp: { + keepAliveIntervalMs: 15_000, + connectTimeoutMs: 60_000, + defaultQueryTimeoutMs: 90_000, + }, + }, + }); + + expect(parsed.web?.whatsapp).toEqual({ + keepAliveIntervalMs: 15_000, + connectTimeoutMs: 60_000, + defaultQueryTimeoutMs: 90_000, + }); + }); + it("accepts web fetch ssrfPolicy in the runtime zod schema", () => { const parsed = ToolsSchema.parse({ web: { diff --git a/src/config/types.base.ts b/src/config/types.base.ts index d96005461d3..bb29d1e2a23 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -290,11 +290,21 @@ export type WebReconnectConfig = { maxAttempts?: number; // 0 = unlimited }; +export type WebWhatsAppConfig = { + /** Baileys application ping interval in milliseconds. Default: 25000. */ + keepAliveIntervalMs?: number; + /** WebSocket opening handshake timeout in milliseconds. Default: 60000. */ + connectTimeoutMs?: number; + /** Baileys query timeout in milliseconds. Default: 60000. */ + defaultQueryTimeoutMs?: number; +}; + export type WebConfig = { /** If false, do not start the WhatsApp web provider. Default: true. */ enabled?: boolean; heartbeatSeconds?: number; reconnect?: WebReconnectConfig; + whatsapp?: WebWhatsAppConfig; }; // Provider docking: allowlists keyed by provider id (and internal "webchat"). diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 08ee6fb6d64..5581c772831 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -678,6 +678,14 @@ export const OpenClawSchema = z }) .strict() .optional(), + whatsapp: z + .object({ + keepAliveIntervalMs: z.number().int().positive().optional(), + connectTimeoutMs: z.number().int().positive().optional(), + defaultQueryTimeoutMs: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .optional(),