fix(whatsapp): reconnect on crypto unhandled rejection

This commit is contained in:
Peter Steinberger
2026-01-05 13:55:37 +00:00
parent 7619534bc0
commit f24fe4e9cd
4 changed files with 114 additions and 23 deletions

View File

@@ -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) {