Files
openclaw/extensions/whatsapp/src/connection-controller.ts
Marcus Castro 80d11f1246 refactor(whatsapp): use runtime context for connection controllers (#104949)
* refactor(whatsapp): replace connection controller registry

* fix(whatsapp): remove controller context import cycle
2026-07-12 14:22:57 -03:00

886 lines
28 KiB
TypeScript

// Whatsapp plugin module implements connection controller behavior.
import type { GroupMetadata, WASocket, WAMessageKey, proto } from "baileys";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import { info } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY } from "./connection-controller-runtime-context.js";
import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js";
import type { ActiveWebListener, WebListenerCloseReason } from "./inbound/types.js";
import { computeBackoff, sleepWithAbort, type ReconnectPolicy } from "./reconnect.js";
import { getWhatsAppRuntime } from "./runtime.js";
import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
readWebAuthExistsForDecision,
waitForWaConnection,
WhatsAppAuthUnstableError,
} from "./session.js";
import {
DEFAULT_WHATSAPP_SOCKET_TIMING,
type WhatsAppSocketTimingOptions,
} from "./socket-timing.js";
const LOGGED_OUT_STATUS = 401;
const POST_PAIRING_RESTART_STATUS = 515;
const TIMED_OUT_STATUS = 408;
const WHATSAPP_LOGIN_RESTART_MESSAGE =
"WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…";
const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
"WhatsApp connection timed out before login; retrying with a fresh socket…";
const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE =
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.";
const WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE =
"WhatsApp connected, but saving the linked credentials has not settled on disk yet. Retry login in a moment.";
const WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE =
"WhatsApp connected, but the linked credentials were not found on disk. Retry login in a moment.";
const WHATSAPP_LOGIN_AUTH_NOT_CLEARED_MESSAGE =
"existing auth could not be cleared. Remove or fix the configured WhatsApp auth directory, then retry login.";
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
export const WHATSAPP_WATCHDOG_TIMEOUT_ERROR = "watchdog-timeout";
type TimerHandle = ReturnType<typeof setInterval>;
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
export type ManagedWhatsAppListener = ActiveWebListener & {
close?: () => Promise<void>;
onClose?: Promise<WebListenerCloseReason>;
signalClose?: (reason?: WebListenerCloseReason) => void;
};
type WhatsAppLiveConnection = {
connectionId: string;
startedAt: number;
sock: WASocket;
listener: ManagedWhatsAppListener;
heartbeat: TimerHandle | null;
watchdogTimer: TimerHandle | null;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
unregisterUnhandled: (() => void) | null;
unregisterTransportActivity: (() => void) | null;
openedAfterRecentInbound: boolean;
backgroundTasks: Set<Promise<unknown>>;
closePromise: Promise<WebListenerCloseReason>;
resolveClose: (reason: WebListenerCloseReason) => void;
};
type WhatsAppConnectionSnapshot = {
connectionId: string;
startedAt: number;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
reconnectAttempts: number;
uptimeMs: number;
};
type NormalizedConnectionCloseReason = {
statusCode?: number;
statusLabel: number | "unknown";
isLoggedOut: boolean;
error?: unknown;
errorText: string;
};
type WhatsAppConnectionCloseDecision = {
action: "stop" | "retry";
delayMs?: number;
reconnectAttempts: number;
healthState: "logged-out" | "conflict" | "stopped" | "reconnecting";
normalized: NormalizedConnectionCloseReason;
};
type WhatsAppReconnectAttemptDecision = {
action: "stop" | "retry";
delayMs?: number;
reconnectAttempts: number;
healthState: "stopped" | "reconnecting";
};
type LoginSocketRestartKind = "post-pairing" | "timeout";
function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
function getLoginSocketRestartKind(statusCode: number | undefined): LoginSocketRestartKind | null {
if (statusCode === POST_PAIRING_RESTART_STATUS) {
return "post-pairing";
}
if (statusCode === TIMED_OUT_STATUS) {
return "timeout";
}
return null;
}
function getLoginSocketRestartMessage(kind: LoginSocketRestartKind): string {
return kind === "timeout"
? WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE
: WHATSAPP_LOGIN_RESTART_MESSAGE;
}
type SocketActivityEmitter = {
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
};
function createLiveConnection(params: {
connectionId: string;
sock: WASocket;
listener: ManagedWhatsAppListener;
openedAfterRecentInbound: boolean;
}): WhatsAppLiveConnection {
let closeResolved = false;
let resolveClosePromise = (_reason: WebListenerCloseReason) => {};
const closePromise = new Promise<WebListenerCloseReason>((resolve) => {
resolveClosePromise = (reason: WebListenerCloseReason) => {
if (closeResolved) {
return;
}
closeResolved = true;
resolve(reason);
};
});
return {
connectionId: params.connectionId,
startedAt: Date.now(),
sock: params.sock,
listener: params.listener,
heartbeat: null,
watchdogTimer: null,
lastInboundAt: null,
lastTransportActivityAt: Date.now(),
handledMessages: 0,
unregisterUnhandled: null,
unregisterTransportActivity: null,
openedAfterRecentInbound: params.openedAfterRecentInbound,
backgroundTasks: new Set<Promise<unknown>>(),
closePromise,
resolveClose: resolveClosePromise,
};
}
export function closeWaSocket(
sock:
| {
end?: (error: Error | undefined) => void;
ws?: { close?: () => void };
}
| null
| undefined,
): void {
try {
if (typeof sock?.end === "function") {
sock.end(new Error("OpenClaw WhatsApp socket close"));
return;
}
sock?.ws?.close?.();
} catch {
// ignore best-effort shutdown failures
}
}
export function closeWaSocketSoon(
sock:
| {
end?: (error: Error | undefined) => void;
ws?: { close?: () => void };
}
| null
| undefined,
delayMs = 500,
): void {
setTimeout(() => {
closeWaSocket(sock);
}, delayMs);
}
type WhatsAppLoginWaitResult =
| {
outcome: "connected";
restarted: boolean;
sock: WaSocket;
}
| {
outcome: "logged-out";
message: string;
statusCode: number;
error: unknown;
}
| {
outcome: "failed";
message: string;
statusCode?: number;
error: unknown;
};
type CredentialPersistenceFailure = { error: unknown };
async function waitForLoginSocket(params: {
wait: () => Promise<void>;
credentialPersistenceFailure?: Promise<CredentialPersistenceFailure>;
}): Promise<void> {
if (!params.credentialPersistenceFailure) {
await params.wait();
return;
}
const outcome = await Promise.race([
params.wait().then(() => ({ kind: "connected" }) as const),
params.credentialPersistenceFailure.then((failure) => ({
kind: "credential-persistence-failed" as const,
failure,
})),
]);
if (outcome.kind === "credential-persistence-failed") {
throw outcome.failure.error;
}
}
function throwIfCredentialPersistenceFailed(
getFailure?: () => CredentialPersistenceFailure | null,
): void {
const failure = getFailure?.();
if (failure) {
throw failure.error;
}
}
export async function waitForWhatsAppLoginResult(params: {
sock: WaSocket;
authDir: string;
isLegacyAuthDir: boolean;
verbose: boolean;
runtime: RuntimeEnv;
waitForConnection?: typeof waitForWaConnection;
createSocket?: typeof createWaSocket;
socketTiming?: WhatsAppSocketTimingOptions;
onQr?: (qr: string) => void;
onSocketReplaced?: (sock: WaSocket) => void;
beforeCredentialPersistence?: () => Promise<void>;
onCredentialPersistenceError?: (error: unknown) => void;
onCredentialPersistenceTask?: (task: Promise<unknown>) => void;
waitForCredentialPersistence?: () => Promise<void>;
credentialPersistenceFailure?: Promise<CredentialPersistenceFailure>;
getCredentialPersistenceFailure?: () => CredentialPersistenceFailure | null;
}): Promise<WhatsAppLoginWaitResult> {
const wait = params.waitForConnection ?? waitForWaConnection;
const createSocket = params.createSocket ?? createWaSocket;
let currentSock = params.sock;
let postPairingRestarted = false;
let timeoutRestarted = false;
let loggedOutRestarted = false;
const replaceLoginSocket = async (
opts: { closeCurrent?: boolean } = {},
): Promise<WhatsAppLoginWaitResult | null> => {
if (opts.closeCurrent ?? true) {
closeWaSocket(currentSock);
}
try {
currentSock = await createSocket(false, params.verbose, {
authDir: params.authDir,
...params.socketTiming,
onQr: params.onQr,
beforeCredentialPersistence: params.beforeCredentialPersistence,
onCredentialPersistenceError: params.onCredentialPersistenceError,
onCredentialPersistenceTask: params.onCredentialPersistenceTask,
});
params.onSocketReplaced?.(currentSock);
return null;
} catch (createErr) {
return {
outcome: "failed",
message: formatError(createErr),
statusCode: getStatusCode(createErr),
error: createErr,
};
}
};
while (true) {
try {
await waitForLoginSocket({
wait: async () => await wait(currentSock, { timeout: "none" }),
credentialPersistenceFailure: params.credentialPersistenceFailure,
});
await params.waitForCredentialPersistence?.();
throwIfCredentialPersistenceFailed(params.getCredentialPersistenceFailure);
// Socket open only proves in-memory auth; require persisted creds before success.
const persistedAuth = await readWebAuthExistsForDecision(params.authDir);
throwIfCredentialPersistenceFailed(params.getCredentialPersistenceFailure);
if (persistedAuth.outcome === "unstable") {
return {
outcome: "failed",
message: WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE,
error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE),
};
}
if (!persistedAuth.exists) {
return {
outcome: "failed",
message: WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE,
error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_NOT_PERSISTED_MESSAGE),
};
}
return {
outcome: "connected",
restarted: postPairingRestarted || timeoutRestarted || loggedOutRestarted,
sock: currentSock,
};
} catch (err) {
const statusCode = getStatusCode(err);
const restartKind = getLoginSocketRestartKind(statusCode);
const canRestart =
(restartKind === "post-pairing" && !postPairingRestarted) ||
(restartKind === "timeout" && !timeoutRestarted);
if (restartKind && canRestart) {
if (restartKind === "post-pairing") {
postPairingRestarted = true;
} else {
timeoutRestarted = true;
}
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
const replacementFailure = await replaceLoginSocket();
if (replacementFailure) {
return replacementFailure;
}
continue;
}
if (statusCode === LOGGED_OUT_STATUS) {
if (loggedOutRestarted) {
return {
outcome: "logged-out",
message: WHATSAPP_LOGGED_OUT_RELINK_MESSAGE,
statusCode: LOGGED_OUT_STATUS,
error: err,
};
}
closeWaSocket(currentSock);
const cleared = await logoutWeb({
authDir: params.authDir,
isLegacyAuthDir: params.isLegacyAuthDir,
runtime: params.runtime,
beforeCredentialPersistence: params.beforeCredentialPersistence,
});
if (!cleared) {
const existingAuth = await readWebAuthExistsForDecision(params.authDir);
if (existingAuth.outcome === "unstable") {
return {
outcome: "failed",
message: WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE,
error: new WhatsAppAuthUnstableError(WHATSAPP_LOGIN_AUTH_UNSTABLE_MESSAGE),
};
}
if (existingAuth.exists) {
return {
outcome: "failed",
message: WHATSAPP_LOGIN_AUTH_NOT_CLEARED_MESSAGE,
error: err,
};
}
}
loggedOutRestarted = true;
const replacementFailure = await replaceLoginSocket({ closeCurrent: false });
if (replacementFailure) {
return replacementFailure;
}
continue;
}
return {
outcome: "failed",
message: formatError(err),
statusCode,
error: err,
};
}
}
}
export class WhatsAppConnectionController {
readonly accountId: string;
readonly authDir: string;
readonly socketRef: { current: WASocket | null };
private readonly reconnectPolicy: ReconnectPolicy;
private readonly heartbeatSeconds: number;
private readonly keepAlive: boolean;
private readonly transportTimeoutMs: number;
private readonly messageTimeoutMs: number;
private readonly appSilenceTimeoutMs: number;
private readonly watchdogCheckMs: number;
private readonly verbose: boolean;
private readonly abortSignal?: AbortSignal;
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
private readonly isNonRetryableStatus: (statusCode: unknown) => boolean;
private readonly socketTiming: Required<WhatsAppSocketTimingOptions>;
private readonly abortPromise?: Promise<"aborted">;
private readonly disconnectRetryController = new AbortController();
private current: WhatsAppLiveConnection | null = null;
private runtimeContextLease: { dispose: () => void } | null = null;
private reconnectAttempts = 0;
private lastHandledInboundAt: number | null = null;
constructor(params: {
accountId: string;
authDir: string;
verbose: boolean;
keepAlive: boolean;
heartbeatSeconds: number;
transportTimeoutMs: number;
messageTimeoutMs: number;
watchdogCheckMs: number;
reconnectPolicy: ReconnectPolicy;
abortSignal?: AbortSignal;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
isNonRetryableStatus?: (statusCode: unknown) => boolean;
socketTiming?: WhatsAppSocketTimingOptions;
}) {
this.accountId = params.accountId;
this.authDir = params.authDir;
this.verbose = params.verbose;
this.keepAlive = params.keepAlive;
this.heartbeatSeconds = params.heartbeatSeconds;
this.transportTimeoutMs = params.transportTimeoutMs;
this.messageTimeoutMs = params.messageTimeoutMs;
this.appSilenceTimeoutMs = params.messageTimeoutMs * 4;
this.watchdogCheckMs = params.watchdogCheckMs;
this.reconnectPolicy = params.reconnectPolicy;
this.abortSignal = params.abortSignal;
this.sleep = params.sleep ?? ((ms: number, signal?: AbortSignal) => sleepWithAbort(ms, signal));
this.isNonRetryableStatus = params.isNonRetryableStatus ?? (() => false);
this.socketTiming = {
...DEFAULT_WHATSAPP_SOCKET_TIMING,
...params.socketTiming,
};
this.socketRef = { current: null };
this.abortPromise =
params.abortSignal &&
new Promise<"aborted">((resolve) => {
params.abortSignal?.addEventListener("abort", () => resolve("aborted"), { once: true });
});
if (params.abortSignal?.aborted) {
this.stopDisconnectRetries();
} else {
params.abortSignal?.addEventListener("abort", () => this.stopDisconnectRetries(), {
once: true,
});
}
}
getActiveListener(): ActiveWebListener | null {
return this.current?.listener ?? null;
}
getCurrentSock(): WASocket | null {
return this.socketRef.current;
}
getSelfIdentity(): WhatsAppSelfIdentity | null {
const user = this.socketRef.current?.user as
| { id?: string | null; lid?: string | null }
| undefined;
if (!user) {
return null;
}
const jid = user.id ?? null;
const lid = user.lid ?? null;
if (!jid && !lid) {
return null;
}
// Pre-resolve via the controller's authDir so e164 is populated from the
// auth-state PN<->LID mapping. That lets `identitiesOverlap()` recognize a
// successor logged into the same account even when the original socket
// exposes only the PN form and the successor exposes only the LID form.
const resolved = resolveComparableIdentity({ jid, lid }, this.authDir);
return { jid: resolved.jid, lid: resolved.lid, e164: resolved.e164 };
}
getReconnectAttempts(): number {
return this.reconnectAttempts;
}
isStopRequested(): boolean {
return this.abortSignal?.aborted === true;
}
shouldRetryDisconnect(): boolean {
return (
this.keepAlive && !this.isStopRequested() && !this.disconnectRetryController.signal.aborted
);
}
getDisconnectRetryAbortSignal(): AbortSignal {
return this.disconnectRetryController.signal;
}
noteInbound(timestamp = Date.now()): void {
if (!this.current) {
return;
}
this.current.handledMessages += 1;
this.current.lastInboundAt = timestamp;
this.current.lastTransportActivityAt = timestamp;
this.current.openedAfterRecentInbound = false;
this.lastHandledInboundAt = timestamp;
}
noteTransportActivity(timestamp = Date.now()): void {
if (!this.current) {
return;
}
this.current.lastTransportActivityAt = timestamp;
}
getCurrentSnapshot(
connection: WhatsAppLiveConnection | null = this.current,
): WhatsAppConnectionSnapshot | null {
if (!connection) {
return null;
}
return {
connectionId: connection.connectionId,
startedAt: connection.startedAt,
lastInboundAt: connection.lastInboundAt,
lastTransportActivityAt: connection.lastTransportActivityAt,
handledMessages: connection.handledMessages,
reconnectAttempts: this.reconnectAttempts,
uptimeMs: Date.now() - connection.startedAt,
};
}
setUnhandledRejectionCleanup(unregister: (() => void) | null): void {
if (!this.current) {
unregister?.();
return;
}
this.current.unregisterUnhandled?.();
this.current.unregisterUnhandled = unregister;
}
async openConnection(params: {
connectionId: string;
createListener: (context: {
sock: WASocket;
connection: WhatsAppLiveConnection;
}) => Promise<ManagedWhatsAppListener>;
onHeartbeat?: (snapshot: WhatsAppConnectionSnapshot) => void;
onWatchdogTimeout?: (snapshot: WhatsAppConnectionSnapshot) => void;
getMessage?: (key: WAMessageKey) => Promise<proto.IMessage | undefined>;
cachedGroupMetadata?: (jid: string) => Promise<GroupMetadata | undefined>;
}): Promise<WhatsAppLiveConnection> {
if (this.current) {
await this.closeCurrentConnection();
}
let sock: WaSocket | null = null;
let connection: WhatsAppLiveConnection | null = null;
try {
sock = await createWaSocket(false, this.verbose, {
authDir: this.authDir,
...this.socketTiming,
...(params.getMessage ? { getMessage: params.getMessage } : {}),
...(params.cachedGroupMetadata ? { cachedGroupMetadata: params.cachedGroupMetadata } : {}),
});
await waitForWaConnection(sock, { timeoutMs: this.socketTiming.connectTimeoutMs });
const channelRuntime = getWhatsAppRuntime().channel;
this.socketRef.current = sock;
const placeholderListener = {} as ManagedWhatsAppListener;
connection = createLiveConnection({
connectionId: params.connectionId,
sock,
listener: placeholderListener,
openedAfterRecentInbound: this.isOpeningAfterRecentInbound(),
});
const listener = await params.createListener({ sock, connection });
connection.listener = listener;
this.current = connection;
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
const previousRuntimeContextLease = this.runtimeContextLease;
// Outbound adapters and agent tools read this context outside the gateway account task,
// so the plugin-injected runtime is the shared owner for this internal capability.
this.runtimeContextLease = registerChannelRuntimeContext({
channelRuntime,
channelId: "whatsapp",
accountId: this.accountId,
capability: WHATSAPP_CONNECTION_CONTROLLER_CAPABILITY,
context: this,
abortSignal: this.abortSignal,
});
// Publish the ready replacement before releasing the old lease. Runtime-context
// lease tokens keep stale disposal from unregistering the replacement.
previousRuntimeContextLease?.dispose();
this.startTimers(connection, {
onHeartbeat: params.onHeartbeat,
onWatchdogTimeout: params.onWatchdogTimeout,
});
return connection;
} catch (err) {
if (this.socketRef.current === sock) {
this.socketRef.current = null;
}
closeWaSocket(sock);
if (connection?.unregisterUnhandled) {
connection.unregisterUnhandled();
}
connection?.unregisterTransportActivity?.();
throw err;
}
}
async waitForClose(): Promise<WebListenerCloseReason | "aborted"> {
const connection = this.current;
if (!connection) {
return "aborted";
}
const listenerClose =
connection.listener.onClose?.catch((err: unknown) => ({
status: 500,
isLoggedOut: false,
error: err,
})) ?? createNeverResolvePromise<WebListenerCloseReason>();
return await Promise.race([
connection.closePromise,
listenerClose,
this.abortPromise ?? createNeverResolvePromise<"aborted">(),
]);
}
normalizeCloseReason(reason: WebListenerCloseReason): NormalizedConnectionCloseReason {
const statusCode =
(typeof reason === "object" && reason && "status" in reason
? (reason as { status?: number }).status
: undefined) ?? undefined;
return {
statusCode,
statusLabel: typeof statusCode === "number" ? statusCode : "unknown",
isLoggedOut:
typeof reason === "object" &&
reason !== null &&
"isLoggedOut" in reason &&
(reason as { isLoggedOut?: boolean }).isLoggedOut === true,
error: reason?.error,
errorText: formatError(reason),
};
}
resolveCloseDecision(
reason: WebListenerCloseReason | "aborted",
): WhatsAppConnectionCloseDecision | "aborted" {
if (reason === "aborted" || this.isStopRequested()) {
return "aborted";
}
const current = this.current;
if (current && Date.now() - current.startedAt > this.heartbeatSeconds * 1000) {
this.reconnectAttempts = 0;
}
const normalized = this.normalizeCloseReason(reason);
if (normalized.isLoggedOut) {
return {
action: "stop",
reconnectAttempts: this.reconnectAttempts,
healthState: "logged-out",
normalized,
};
}
if (this.isNonRetryableStatus(normalized.statusCode)) {
return {
action: "stop",
reconnectAttempts: this.reconnectAttempts,
healthState: "conflict",
normalized,
};
}
const retryDecision = this.consumeReconnectAttempt();
if (retryDecision.action === "stop") {
return {
action: "stop",
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
normalized,
};
}
return {
action: "retry",
delayMs: retryDecision.delayMs,
reconnectAttempts: retryDecision.reconnectAttempts,
healthState: retryDecision.healthState,
normalized,
};
}
resolveSetupErrorDecision(error: unknown): WhatsAppConnectionCloseDecision | "aborted" | null {
const statusCode = getStatusCode(error);
if (typeof statusCode !== "number") {
return null;
}
return this.resolveCloseDecision({
status: statusCode,
isLoggedOut: statusCode === LOGGED_OUT_STATUS,
error,
});
}
consumeReconnectAttempt(): WhatsAppReconnectAttemptDecision {
this.reconnectAttempts += 1;
if (
this.reconnectPolicy.maxAttempts > 0 &&
this.reconnectAttempts >= this.reconnectPolicy.maxAttempts
) {
return {
action: "stop",
reconnectAttempts: this.reconnectAttempts,
healthState: "stopped",
};
}
return {
action: "retry",
delayMs: computeBackoff(this.reconnectPolicy, this.reconnectAttempts),
reconnectAttempts: this.reconnectAttempts,
healthState: "reconnecting",
};
}
forceClose(reason: WebListenerCloseReason): void {
const connection = this.current;
if (!connection) {
return;
}
connection.resolveClose(reason);
connection.listener.signalClose?.(reason);
}
async closeCurrentConnection(): Promise<void> {
const connection = this.current;
if (!connection) {
return;
}
this.current = null;
if (this.socketRef.current === connection.sock) {
this.socketRef.current = null;
}
connection.unregisterUnhandled?.();
connection.unregisterTransportActivity?.();
if (connection.heartbeat) {
clearInterval(connection.heartbeat);
}
if (connection.watchdogTimer) {
clearInterval(connection.watchdogTimer);
}
if (connection.backgroundTasks.size > 0) {
await Promise.allSettled(connection.backgroundTasks);
connection.backgroundTasks.clear();
}
try {
await connection.listener.close?.();
} catch {
// best-effort close
}
closeWaSocket(connection.sock);
}
async waitBeforeRetry(delayMs: number): Promise<void> {
await this.sleep(delayMs, this.abortSignal);
}
async shutdown(): Promise<void> {
this.stopDisconnectRetries();
await this.closeCurrentConnection();
this.runtimeContextLease?.dispose();
this.runtimeContextLease = null;
}
private startTimers(
connection: WhatsAppLiveConnection,
hooks: {
onHeartbeat?: (snapshot: WhatsAppConnectionSnapshot) => void;
onWatchdogTimeout?: (snapshot: WhatsAppConnectionSnapshot) => void;
},
): void {
if (!this.keepAlive) {
return;
}
connection.heartbeat = setInterval(() => {
const snapshot = this.getCurrentSnapshot(connection);
if (!snapshot) {
return;
}
hooks.onHeartbeat?.(snapshot);
}, this.heartbeatSeconds * 1000);
connection.watchdogTimer = setInterval(() => {
const now = Date.now();
const transportStaleForMs = now - connection.lastTransportActivityAt;
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
const appSilentForMs = now - appBaselineAt;
const appSilenceTimeoutMs = connection.openedAfterRecentInbound
? this.messageTimeoutMs
: this.appSilenceTimeoutMs;
if (transportStaleForMs <= this.transportTimeoutMs && appSilentForMs <= appSilenceTimeoutMs) {
return;
}
const snapshot = this.getCurrentSnapshot(connection);
if (!snapshot) {
return;
}
hooks.onWatchdogTimeout?.(snapshot);
this.forceClose({
status: 499,
isLoggedOut: false,
error: WHATSAPP_WATCHDOG_TIMEOUT_ERROR,
});
}, this.watchdogCheckMs);
}
private attachTransportActivityListener(sock: WASocket): (() => void) | null {
const ws = sock.ws as SocketActivityEmitter | undefined;
if (!ws || typeof ws.on !== "function") {
return null;
}
const noteActivity = () => this.noteTransportActivity();
ws.on("frame", noteActivity);
return () => {
if (typeof ws.off === "function") {
ws.off("frame", noteActivity);
return;
}
ws.removeListener?.("frame", noteActivity);
};
}
private isOpeningAfterRecentInbound(): boolean {
if (this.reconnectAttempts <= 0 || this.lastHandledInboundAt === null) {
return false;
}
return Date.now() - this.lastHandledInboundAt <= this.appSilenceTimeoutMs;
}
private stopDisconnectRetries(): void {
if (!this.disconnectRetryController.signal.aborted) {
this.disconnectRetryController.abort();
}
}
}