From f24fe4e9cdcb4b0e546127c3dde35ab1e93f575c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 13:55:37 +0000 Subject: [PATCH] fix(whatsapp): reconnect on crypto unhandled rejection --- src/index.ts | 2 + src/infra/unhandled-rejections.ts | 26 ++++++++ src/macos/relay.ts | 11 ++-- src/web/auto-reply.ts | 98 ++++++++++++++++++++++++++----- 4 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 src/infra/unhandled-rejections.ts diff --git a/src/index.ts b/src/index.ts index 459b3057e..b4e755bba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { PortInUseError, } from "./infra/ports.js"; import { assertSupportedRuntime } from "./infra/runtime-guard.js"; +import { isUnhandledRejectionHandled } from "./infra/unhandled-rejections.js"; import { enableConsoleCapture } from "./logging.js"; import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { monitorWebProvider } from "./provider-web.js"; @@ -79,6 +80,7 @@ if (isMain) { // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. // These log the error and exit gracefully instead of crashing without trace. process.on("unhandledRejection", (reason, _promise) => { + if (isUnhandledRejectionHandled(reason)) return; console.error( "[clawdbot] Unhandled promise rejection:", reason instanceof Error ? (reason.stack ?? reason.message) : reason, diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts new file mode 100644 index 000000000..5a789fab1 --- /dev/null +++ b/src/infra/unhandled-rejections.ts @@ -0,0 +1,26 @@ +type UnhandledRejectionHandler = (reason: unknown) => boolean; + +const handlers = new Set(); + +export function registerUnhandledRejectionHandler( + handler: UnhandledRejectionHandler, +): () => void { + handlers.add(handler); + return () => { + handlers.delete(handler); + }; +} + +export function isUnhandledRejectionHandled(reason: unknown): boolean { + for (const handler of handlers) { + try { + if (handler(reason)) return true; + } catch (err) { + console.error( + "[clawdbot] Unhandled rejection handler failed:", + err instanceof Error ? (err.stack ?? err.message) : err, + ); + } + } + return false; +} diff --git a/src/macos/relay.ts b/src/macos/relay.ts index d431f576e..34f14781b 100644 --- a/src/macos/relay.ts +++ b/src/macos/relay.ts @@ -32,13 +32,6 @@ async function main() { process.exit(0); } - if (process.env.CLAWDBOT_SMOKE_QR === "1") { - const { renderQrPngBase64 } = await import("../web/qr-image.js"); - await renderQrPngBase64("clawdbot-smoke"); - console.log("smoke: qr ok"); - return; - } - await patchBunLongForProtobuf(); const { loadDotEnv } = await import("../infra/dotenv.js"); @@ -52,11 +45,15 @@ async function main() { const { assertSupportedRuntime } = await import("../infra/runtime-guard.js"); assertSupportedRuntime(); + const { isUnhandledRejectionHandled } = await import( + "../infra/unhandled-rejections.js" + ); const { buildProgram } = await import("../cli/program.js"); const program = buildProgram(); process.on("unhandledRejection", (reason, _promise) => { + if (isUnhandledRejectionHandled(reason)) return; console.error( "[clawdbot] Unhandled promise rejection:", reason instanceof Error ? (reason.stack ?? reason.message) : reason, diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 4e657d24a..bbca52d41 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,5 +1,4 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { normalizeGroupActivation, @@ -26,6 +25,7 @@ import { import { logVerbose, shouldLogVerbose } from "../globals.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; +import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; @@ -49,6 +49,45 @@ const whatsappInboundLog = whatsappLog.child("inbound"); const whatsappOutboundLog = whatsappLog.child("outbound"); const whatsappHeartbeatLog = whatsappLog.child("heartbeat"); +const isLikelyWhatsAppCryptoError = (reason: unknown) => { + const formatReason = (value: unknown): string => { + if (value == null) return ""; + if (typeof value === "string") return value; + if (value instanceof Error) { + return `${value.message}\n${value.stack ?? ""}`; + } + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return Object.prototype.toString.call(value); + } + } + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return String(value); + if (typeof value === "bigint") return String(value); + if (typeof value === "symbol") return value.description ?? value.toString(); + if (typeof value === "function") + return value.name ? `[function ${value.name}]` : "[function]"; + return Object.prototype.toString.call(value); + }; + const raw = + reason instanceof Error + ? `${reason.message}\n${reason.stack ?? ""}` + : formatReason(reason); + const haystack = raw.toLowerCase(); + const hasAuthError = + haystack.includes("unsupported state or unable to authenticate data") || + haystack.includes("bad mac"); + if (!hasAuthError) return false; + return ( + haystack.includes("@whiskeysockets/baileys") || + haystack.includes("baileys") || + haystack.includes("noise-handler") || + haystack.includes("aesdecryptgcm") + ); +}; + // Send via the active gateway-backed listener. The monitor already owns the single // Baileys session, so use its send API directly. async function sendWithIpcFallback( @@ -849,23 +888,35 @@ export async function monitorWebProvider( ); }; - const resolveCommandAllowFrom = () => { + const resolveOwnerList = (selfE164?: string | null) => { const allowFrom = mentionConfig.allowFrom; const raw = - Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : []; + Array.isArray(allowFrom) && allowFrom.length > 0 + ? allowFrom + : selfE164 + ? [selfE164] + : []; return raw .filter((entry): entry is string => Boolean(entry && entry !== "*")) .map((entry) => normalizeE164(entry)) .filter((entry): entry is string => Boolean(entry)); }; - const isCommandAuthorized = (msg: WebInboundMsg) => { - const allowFrom = resolveCommandAllowFrom(); - if (allowFrom.length === 0) return true; - if (mentionConfig.allowFrom?.includes("*")) return true; + const isOwnerSender = (msg: WebInboundMsg) => { const sender = normalizeE164(msg.senderE164 ?? ""); if (!sender) return false; - return allowFrom.includes(sender); + const owners = resolveOwnerList(msg.selfE164 ?? undefined); + return owners.includes(sender); + }; + + const isStatusCommand = (body: string) => { + const trimmed = body.trim().toLowerCase(); + if (!trimmed) return false; + return ( + trimmed === "/status" || + trimmed === "status" || + trimmed.startsWith("/status ") + ); }; const stripMentionsForCommand = (text: string, selfE164?: string | null) => { @@ -912,6 +963,7 @@ export async function monitorWebProvider( let lastMessageAt: number | null = null; let handledMessages = 0; let _lastInboundMsg: WebInboundMsg | null = null; + let unregisterUnhandled: (() => void) | null = null; // Watchdog to detect stuck message processing (e.g., event emitter died) // Should be significantly longer than the reply heartbeat interval to avoid false positives @@ -1182,7 +1234,6 @@ export async function monitorWebProvider( SenderName: msg.senderName, SenderE164: msg.senderE164, WasMentioned: msg.wasMentioned, - CommandAuthorized: isCommandAuthorized(msg), Surface: "whatsapp", }, { @@ -1323,15 +1374,12 @@ export async function monitorWebProvider( noteGroupMember(conversationId, msg.senderE164, msg.senderName); const commandBody = stripMentionsForCommand(msg.body, msg.selfE164); const activationCommand = parseActivationCommand(commandBody); - const commandAuthorized = isCommandAuthorized(msg); - const statusCommand = hasControlCommand(commandBody); - const hasAnyMention = (msg.mentionedJids?.length ?? 0) > 0; + const isOwner = isOwnerSender(msg); + const statusCommand = isStatusCommand(commandBody); const shouldBypassMention = - commandAuthorized && - (activationCommand.hasCommand || statusCommand) && - !hasAnyMention; + isOwner && (activationCommand.hasCommand || statusCommand); - if (activationCommand.hasCommand && !commandAuthorized) { + if (activationCommand.hasCommand && !isOwner) { logVerbose( `Ignoring /activation from non-owner in group ${conversationId}`, ); @@ -1393,9 +1441,27 @@ export async function monitorWebProvider( ); setActiveWebListener(listener); + unregisterUnhandled = registerUnhandledRejectionHandler((reason) => { + if (!isLikelyWhatsAppCryptoError(reason)) return false; + const errorStr = formatError(reason); + reconnectLogger.warn( + { connectionId, error: errorStr }, + "web reconnect: unhandled rejection from WhatsApp socket; forcing reconnect", + ); + listener.signalClose?.({ + status: 499, + isLoggedOut: false, + error: reason, + }); + return true; + }); const closeListener = async () => { setActiveWebListener(null); + if (unregisterUnhandled) { + unregisterUnhandled(); + unregisterUnhandled = null; + } if (heartbeat) clearInterval(heartbeat); if (watchdogTimer) clearInterval(watchdogTimer); if (backgroundTasks.size > 0) {