// 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; type WaSocket = Awaited>; export type ManagedWhatsAppListener = ActiveWebListener & { close?: () => Promise; onClose?: Promise; 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>; closePromise: Promise; 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(): Promise { return new Promise(() => {}); } 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((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>(), 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; credentialPersistenceFailure?: Promise; }): Promise { 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; onCredentialPersistenceError?: (error: unknown) => void; onCredentialPersistenceTask?: (task: Promise) => void; waitForCredentialPersistence?: () => Promise; credentialPersistenceFailure?: Promise; getCredentialPersistenceFailure?: () => CredentialPersistenceFailure | null; }): Promise { 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 => { 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; private readonly isNonRetryableStatus: (statusCode: unknown) => boolean; private readonly socketTiming: Required; 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; 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; onHeartbeat?: (snapshot: WhatsAppConnectionSnapshot) => void; onWatchdogTimeout?: (snapshot: WhatsAppConnectionSnapshot) => void; getMessage?: (key: WAMessageKey) => Promise; cachedGroupMetadata?: (jid: string) => Promise; }): Promise { 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 { const connection = this.current; if (!connection) { return "aborted"; } const listenerClose = connection.listener.onClose?.catch((err: unknown) => ({ status: 500, isLoggedOut: false, error: err, })) ?? createNeverResolvePromise(); 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 { 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 { await this.sleep(delayMs, this.abortSignal); } async shutdown(): Promise { 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(); } } }