'use strict';
const EventEmitter = require('events');
const puppeteer = require('puppeteer');
const Util = require('./util/Util');
const InterfaceController = require('./util/InterfaceController');
const {
WhatsWebURL,
DefaultOptions,
Events,
ConnectionStatus,
WAState,
MessageTypes,
} = require('./util/Constants');
const { LoadUtils } = require('./util/Injected/Utils');
const ChatFactory = require('./factories/ChatFactory');
const ContactFactory = require('./factories/ContactFactory');
const WebCacheFactory = require('./webCache/WebCacheFactory');
const {
ClientInfo,
Message,
MessageMedia,
Contact,
Location,
Poll,
PollVote,
GroupNotification,
Label,
Call,
Buttons,
List,
Reaction,
Broadcast,
ScheduledEvent,
} = require('./structures');
const NoAuth = require('./authStrategies/NoAuth');
const {
exposeFunctionIfAbsent,
getBrowserConnectionState,
isBrowserConnected,
prepareRoutedFunction,
} = require('./util/Puppeteer');
const { serializeMessageId } = require('./util/MessageId');
const {
COMPANION_FINGERPRINT_VERSION,
COMPANION_PRIVATE_MODULE_ABI,
extractCanonicalCompanionIdentity,
} = require('./session/CompanionIdentity');
const PUBLIC_CLIENT_EVENTS = new Set(Object.values(Events));
const CONNECTION_STATUS_VALUES = new Set(Object.values(ConnectionStatus));
const CONNECTION_STATUS_TERMINAL = new Set([
ConnectionStatus.LOGGED_OUT,
ConnectionStatus.INVALID_SESSION,
ConnectionStatus.LEASE_LOST,
ConnectionStatus.STOPPED,
ConnectionStatus.ERROR,
]);
const CONNECTION_STATUS_FIELDS = [
'status',
'connected',
'authenticated',
'sessionValid',
'recoverable',
'qrAvailable',
'reason',
'errorCode',
];
const ONLINE_PROOF_REQUIREMENTS = Object.freeze([
'document_epoch_current',
'sdk_utilities_ready',
'event_bridge_ready',
'socket_state_connected',
'app_state_synced',
'socket_transport_connected',
'companion_registered',
'companion_identity_available',
'pairing_context_inactive',
]);
const ONLINE_PAIRING_SOCKET_STATES = new Set([
WAState.PAIRING,
WAState.UNPAIRED,
WAState.UNPAIRED_IDLE,
]);
const ONLINE_PROOF_LOG_INTERVAL_MS = 5000;
const BROWSER_PROCESS_EXIT_TIMEOUT_MS = 5000;
const MAIN_FRAME_NAVIGATION_SUCCESSOR_TIMEOUT_MS = 5000;
const CLEAN_OWNED_BROWSER_PAGE_URL = 'about:blank';
const WHATS_WEB_ORIGIN = new URL(WhatsWebURL).origin;
const TRANSIENT_NAVIGATION_ERROR_PATTERN =
/Execution context was destroyed|Execution context is not available in detached frame|Cannot find context with specified id|Attempted to use detached Frame|Navigating frame was detached|frame was detached|net::ERR_ABORTED/iu;
const TRANSIENT_NAVIGATION_ERROR_CODES = new Set([
'wwebjs_connected_profile_navigation_superseded',
'wwebjs_event_bridge_navigation_superseded',
'wwebjs_legacy_volume_navigation_superseded',
'wwebjs_online_readiness_navigation_superseded',
'wwebjs_ready_utility_navigation_superseded',
]);
const CLIENT_PAGE_BINDINGS = Object.freeze([
'onAddMessageCiphertextEvent',
'onAddMessageEvent',
'onAppStateChangedEvent',
'onAppStateHasSyncedEvent',
'onArchiveChatEvent',
'onAuthAppStateChangedEvent',
'onBatteryStateChangedEvent',
'onChangeMessageEvent',
'onChangeMessageTypeEvent',
'onChatStateDebugEvent',
'onChatStateEvent',
'onChatUnreadCountEvent',
'onCiphertextFailedEvent',
'onCodeReceivedEvent',
'onEditMessageEvent',
'onIncomingCall',
'onLogoutEvent',
'onMessageAckEvent',
'onMessageMediaUploadedEvent',
'onMessagePinnedEvent',
'onOfflineProgressUpdateEvent',
'onPollVoteEvent',
'onQRChangedEvent',
'onReaction',
'onRemoveChatEvent',
'onRemoveMessageEvent',
]);
const sanitizeConnectionCode = (value, fallback) => {
if (value == null || value === '') return undefined;
const normalized = String(value).trim().toLowerCase();
return /^[a-z0-9][a-z0-9_.:-]{0,127}$/u.test(normalized)
? normalized
: fallback;
};
const isTransientNavigationError = (error) =>
TRANSIENT_NAVIGATION_ERROR_CODES.has(error?.code) ||
TRANSIENT_NAVIGATION_ERROR_PATTERN.test(String(error?.message || error));
const isExplicitNavigationSupersession = (error) =>
TRANSIENT_NAVIGATION_ERROR_CODES.has(error?.code || error?.message);
const createOwnedBrowserTargetIsolationError = (cause) => {
const error =
cause === undefined
? new Error('wwebjs_owned_browser_target_isolation_failed')
: new Error('wwebjs_owned_browser_target_isolation_failed', {
cause,
});
error.code = error.message;
return error;
};
const createOnlineProofDiagnostic = (proof, expectedDocumentEpoch, error) => {
const socketState = Object.values(WAState).includes(proof?.socketState)
? proof.socketState
: 'UNKNOWN';
// WA Web retains the last Conn.ref after a successful QR pairing on some
// builds. It is pairing material, but its mere presence is not evidence
// that the pairing surface is still active. Registration and the socket
// state are authoritative for that distinction; the retained-ref bit is
// kept as sanitized diagnostic evidence only.
const pairingContextActive =
proof?.registered !== true ||
ONLINE_PAIRING_SOCKET_STATES.has(socketState);
const requirements = {
document_epoch_current:
Number.isFinite(proof?.documentEpoch) &&
proof.documentEpoch === expectedDocumentEpoch,
sdk_utilities_ready: proof?.utilitiesReady === true,
event_bridge_ready: proof?.eventListenersReady === true,
socket_state_connected: proof?.socketState === WAState.CONNECTED,
app_state_synced: proof?.hasSynced === true,
socket_transport_connected: proof?.socketConnected === true,
companion_registered: proof?.registered === true,
companion_identity_available: proof?.hasMe === true,
pairing_context_inactive: pairingContextActive === false,
};
const failedRequirements = ONLINE_PROOF_REQUIREMENTS.filter(
(requirement) => requirements[requirement] !== true,
);
const errorCode = error
? sanitizeConnectionCode(
error?.code || error?.message,
'wwebjs_online_proof_failed',
)
: failedRequirements.length > 0
? 'wwebjs_online_protocol_unproven'
: undefined;
return Object.freeze({
...requirements,
pairing_reference_retained: proof?.pairingReferenceRetained === true,
socket_state: socketState,
failed_requirements: Object.freeze(failedRequirements),
...(errorCode ? { error_code: errorCode } : {}),
});
};
const connectionStatusDefaults = (status, previous) => {
const valid = previous?.sessionValid ?? null;
const authenticated = previous?.authenticated === true;
switch (status) {
case ConnectionStatus.QR:
return [false, false, null, true, false];
case ConnectionStatus.ONLINE:
return [true, true, true, false, false];
case ConnectionStatus.RECONNECTING:
return [false, authenticated, valid, true, false];
case ConnectionStatus.OFFLINE:
return [false, authenticated, valid, true, false];
case ConnectionStatus.LOGGED_OUT:
case ConnectionStatus.INVALID_SESSION:
return [false, false, false, false, false];
case ConnectionStatus.CONFLICT:
return [false, authenticated, valid, true, false];
case ConnectionStatus.LEASE_LOST:
return [false, authenticated, valid, true, false];
case ConnectionStatus.HANDOFF:
return [false, authenticated, valid, true, false];
case ConnectionStatus.STOPPED:
return [false, false, valid, false, false];
case ConnectionStatus.ERROR:
return [false, false, valid, false, false];
case ConnectionStatus.INITIALIZING:
case ConnectionStatus.RESTORING:
return [false, false, null, true, false];
case ConnectionStatus.CONNECTING:
default:
return [false, authenticated, valid, true, false];
}
};
/**
* Starting point for interacting with the WhatsApp Web API
* @extends {EventEmitter}
* @param {object} options - Client options
* @param {AuthStrategy} options.authStrategy - Determines how to save and restore sessions. If not set, NoAuth will be used.
* @param {string} options.webVersion - The version of WhatsApp Web to use. Use options.webVersionCache to configure how the version is retrieved.
* @param {object} options.webVersionCache - Determines how to retrieve the WhatsApp Web version. Defaults to a strict local cache and fails if the requested version is missing.
* @param {number} options.authTimeoutMs - Timeout for authentication selector in puppeteer
* @param {function} options.evalOnNewDoc - function to eval on new doc
* @param {object} options.puppeteer - Puppeteer launch options. View docs here: https://github.com/puppeteer/puppeteer/
* @param {number} options.qrMaxRetries - How many times should the qrcode be refreshed before giving up
* @param {number} options.takeoverOnConflict - If another whatsapp web session is detected (another browser), take over the session in the current browser
* @param {number} options.takeoverTimeoutMs - How much time to wait before taking over the session
* @param {string} options.userAgent - User agent to use in puppeteer
* @param {string} options.ffmpegPath - Ffmpeg path to use when formatting videos to webp while sending stickers
* @param {boolean} options.bypassCSP - Sets bypassing of page's Content-Security-Policy.
* @param {boolean} options.emitHistoricalEvents - Emit events from synchronization/history. When false, synchronized historical events are ignored.
* @param {boolean} options.resolveCiphertextMessages - When `true`, attempts to resolve ciphertext messages by polling the Store.
* @param {number[]} options.ciphertextResolutionDelaysMs - Retry delays in milliseconds used to resolve ciphertext messages.
* @param {string} options.deviceName - Sets the device name of a current linked device., i.e.: 'TEST'.
* @param {string} options.browserName - Sets the browser name of a current linked device, i.e.: 'Firefox'.
* @param {object} options.proxyAuthentication - Proxy Authentication object.
*
* @fires Client#qr
* @fires Client#authenticated
* @fires Client#auth_failure
* @fires Client#ready
* @fires Client#message
* @fires Client#message_ack
* @fires Client#message_create
* @fires Client#message_revoke_me
* @fires Client#message_revoke_everyone
* @fires Client#message_ciphertext
* @fires Client#message_pinned
* @fires Client#message_edit
* @fires Client#chat_state
* @fires Client#media_uploaded
* @fires Client#group_join
* @fires Client#group_leave
* @fires Client#group_update
* @fires Client#disconnected
* @fires Client#change_state
* @fires Client#contact_changed
* @fires Client#group_admin_changed
* @fires Client#group_membership_request
* @fires Client#vote_update
*/
class Client extends EventEmitter {
constructor(options = {}) {
super();
const requestedWebVersionCacheType = options.webVersionCache?.type;
this.options = Util.mergeDefault(DefaultOptions, options);
// `none` is a complete cache policy, not a partial override of the
// bundled default. Keeping the default integrity manifest after the
// caller disables the cache makes a live document look pinned and
// rejects valid profiles whose WhatsApp service worker advanced the
// Web build between restarts.
if (requestedWebVersionCacheType === 'none') {
this.options.webVersionCache = { type: 'none' };
}
if (!this.options.authStrategy) {
this.authStrategy = new NoAuth();
} else {
this.authStrategy = this.options.authStrategy;
}
this.authStrategy.setup(this);
this.authStrategy.validateClientOptions?.(this.options);
/**
* @type {puppeteer.Browser}
*/
this.pupBrowser = null;
/**
* @type {puppeteer.Page}
*/
this.pupPage = null;
this.currentIndexHtml = null;
this.lastLoggedOut = false;
this._framenavigatedTail = Promise.resolve();
this._framenavigatedRegistration = null;
this._framenavigatedRegistered = false;
this._mainFrameNavigationSequence = 0;
this._logoutRecoveryEpoch = 0;
this._logoutRecoveryInProgress = false;
this._stagedPublicEvents = null;
this._publicEventsBlocked = false;
this._authReadyInFlightDocumentEpoch = null;
this._postReadyUtilityRecovery = null;
this._eventBridgeDocumentEpoch = null;
this._eventBridgeAttachState = null;
this._connectionStatusChangedAtMs = Date.now();
this._connectionStatusListenerFailures = 0;
this._connectionStatusPublishQueue = [];
this._connectionStatusPublishing = false;
this._connectionRuntimeEpoch = 0;
this._connectionDocumentEpoch = null;
this._connectionProofAttempt = 0;
this._lastOnlineProofDiagnostic = null;
this._onlineReadinessWaits = new Set();
this._onlineReadinessWaitActive = false;
this._authenticatedPublished = false;
this._lastWaState = null;
this._lastHasSynced = false;
this._connectionStatus = Object.freeze({
provider: 'wwebjs',
status: ConnectionStatus.INITIALIZING,
connected: false,
authenticated: false,
sessionValid: null,
recoverable: true,
qrAvailable: false,
sequence: 1,
changedAt: new Date(
this._connectionStatusChangedAtMs,
).toISOString(),
});
Util.setFfmpegPath(this.options.ffmpegPath);
}
/**
* Returns an immutable provider-neutral connection snapshot without a
* browser or database round trip.
* @returns {Readonly<object>}
*/
getConnectionStatus() {
if (this._connectionStatus.status === ConnectionStatus.ONLINE) {
let runtimeHealthy;
try {
runtimeHealthy =
isBrowserConnected(this.pupBrowser) &&
this.pupPage != null &&
typeof this.pupPage.isClosed === 'function' &&
this.pupPage.isClosed() === false &&
this._lastWaState === WAState.CONNECTED &&
this._lastHasSynced === true;
} catch {
runtimeHealthy = false;
}
if (!runtimeHealthy) {
this._setConnectionStatus(ConnectionStatus.OFFLINE, {
reason: 'runtime_unavailable',
});
} else if (
typeof this.authStrategy?.store?.assertLocalLease === 'function'
) {
try {
this.authStrategy.store.assertLocalLease();
} catch {
this._setConnectionStatus(ConnectionStatus.LEASE_LOST, {
reason: 'lease_lost',
errorCode: 'whatsapp_session_lease_lost',
});
}
}
}
return Object.freeze({ ...this._connectionStatus });
}
/** @private */
_registerConnectionRuntimeGuards(browser, page) {
const markUnavailable = (reason) => {
if (
this.pupBrowser !== browser ||
this.pupPage !== page ||
this._connectionStatus.status === ConnectionStatus.HANDOFF ||
CONNECTION_STATUS_TERMINAL.has(this._connectionStatus.status)
) {
return;
}
try {
this.authStrategy.logNative?.(
'client.runtime_unavailable',
{
reason,
runtime_epoch: this._connectionRuntimeEpoch,
document_epoch_known: Number.isFinite(
this._connectionDocumentEpoch,
),
browser_connected: isBrowserConnected(browser),
page_closed: page?.isClosed?.() === true,
},
{ force: true },
);
} catch {
// Runtime invalidation is the safety action; diagnostics are
// best effort and must never delay or suppress that state.
}
this._connectionProofAttempt += 1;
this._lastHasSynced = false;
this._setConnectionStatus(ConnectionStatus.OFFLINE, { reason });
};
browser?.once?.('disconnected', () =>
markUnavailable('browser_disconnected'),
);
page?.once?.('close', () => markUnavailable('page_closed'));
page?.once?.('error', () => markUnavailable('page_crashed'));
}
/**
* Claims a quiescent target from a browser launched by this Client. A
* persistent Chromium profile may restore several WhatsApp Web tabs; using
* pages()[0] can therefore bind the SDK to a stale UNLAUNCHED document
* while another restored target owns the connected socket. Only restored
* WhatsApp Web pages are retired. Unrelated user pages are left untouched.
* @private
*/
async _selectOwnedBrowserPage(browser) {
const inspectPage = async (candidate) => {
try {
if (
!candidate ||
typeof candidate.url !== 'function' ||
typeof candidate.isClosed !== 'function'
) {
throw new TypeError(
'wwebjs_owned_browser_page_interface_invalid',
);
}
if (candidate.isClosed()) {
return { closed: true, url: null, whatsapp: false };
}
const url = await candidate.url();
if (typeof url !== 'string' || url.length === 0) {
throw new TypeError(
'wwebjs_owned_browser_page_url_invalid',
);
}
const origin = new URL(url).origin;
return {
closed: false,
url,
whatsapp: origin === WHATS_WEB_ORIGIN,
};
} catch (error) {
if (
error?.code ===
'wwebjs_owned_browser_target_isolation_failed'
) {
throw error;
}
throw createOwnedBrowserTargetIsolationError(error);
}
};
const listPages = async () => {
try {
if (typeof browser?.pages !== 'function') {
throw new TypeError(
'wwebjs_owned_browser_pages_interface_invalid',
);
}
const pages = await browser.pages();
if (!Array.isArray(pages)) {
throw new TypeError(
'wwebjs_owned_browser_pages_result_invalid',
);
}
return pages;
} catch (error) {
if (
error?.code ===
'wwebjs_owned_browser_target_isolation_failed'
) {
throw error;
}
throw createOwnedBrowserTargetIsolationError(error);
}
};
const restoredPages = await listPages();
let page;
for (const candidate of restoredPages) {
const snapshot = await inspectPage(candidate);
if (
!snapshot.closed &&
snapshot.url === CLEAN_OWNED_BROWSER_PAGE_URL
) {
page = candidate;
break;
}
}
const reusedBlankTarget = Boolean(page);
if (!page) {
try {
if (typeof browser?.newPage !== 'function') {
throw new TypeError(
'wwebjs_owned_browser_new_page_unavailable',
);
}
page = await browser.newPage();
} catch (error) {
throw createOwnedBrowserTargetIsolationError(error);
}
}
const selectedSnapshot = await inspectPage(page);
if (
selectedSnapshot.closed ||
selectedSnapshot.url !== CLEAN_OWNED_BROWSER_PAGE_URL
) {
throw createOwnedBrowserTargetIsolationError(
new Error('wwebjs_owned_browser_clean_target_unavailable'),
);
}
let whatsappDuplicatesClosed = 0;
for (const candidate of restoredPages) {
if (candidate === page) continue;
const snapshot = await inspectPage(candidate);
if (snapshot.closed || !snapshot.whatsapp) continue;
// Re-read the URL immediately before closing. A restored target
// may be redirected by an extension or the user while Chromium is
// starting; in that case it is no longer ours to retire.
const confirmedSnapshot = await inspectPage(candidate);
if (confirmedSnapshot.closed || !confirmedSnapshot.whatsapp) {
continue;
}
try {
if (typeof candidate.close !== 'function') {
throw new TypeError(
'wwebjs_owned_browser_page_close_unavailable',
);
}
await candidate.close({ runBeforeUnload: false });
if (candidate.isClosed() !== true) {
throw new Error(
'wwebjs_owned_browser_page_close_unconfirmed',
);
}
whatsappDuplicatesClosed += 1;
} catch (error) {
throw createOwnedBrowserTargetIsolationError(error);
}
}
const isolatedPages = await listPages();
if (!isolatedPages.includes(page)) {
throw createOwnedBrowserTargetIsolationError(
new Error('wwebjs_owned_browser_clean_target_missing'),
);
}
const isolatedTarget = await inspectPage(page);
if (
isolatedTarget.closed ||
isolatedTarget.url !== CLEAN_OWNED_BROWSER_PAGE_URL
) {
throw createOwnedBrowserTargetIsolationError(
new Error('wwebjs_owned_browser_clean_target_changed'),
);
}
for (const candidate of isolatedPages) {
if (candidate === page) continue;
const snapshot = await inspectPage(candidate);
if (!snapshot.closed && snapshot.whatsapp) {
throw createOwnedBrowserTargetIsolationError(
new Error(
'wwebjs_owned_browser_whatsapp_duplicate_remains',
),
);
}
}
this.authStrategy.logNative?.(
'client.owned_browser_targets_isolated',
{
restored_page_count: restoredPages.length,
reused_blank_target: reusedBlankTarget,
whatsapp_duplicates_closed: whatsappDuplicatesClosed,
retained_page_count: isolatedPages.length - 1,
},
{ force: true },
);
return page;
}
/** @private */
async _prepareClientPageBindings(page) {
for (const name of CLIENT_PAGE_BINDINGS) {
await prepareRoutedFunction(page, name);
}
}
/** @private */
_publishConnectionStatus(snapshot) {
this._connectionStatusPublishQueue.push(snapshot);
if (this._connectionStatusPublishing) return false;
this._connectionStatusPublishing = true;
let published = false;
const recordFailure = () => {
this._connectionStatusListenerFailures = Math.min(
Number.MAX_SAFE_INTEGER,
this._connectionStatusListenerFailures + 1,
);
};
try {
while (this._connectionStatusPublishQueue.length > 0) {
const queued = this._connectionStatusPublishQueue.shift();
const listeners = this.rawListeners(Events.CONNECTION_STATUS);
published ||= listeners.length > 0;
for (const listener of listeners) {
try {
const returned = listener.call(this, queued);
if (returned && typeof returned.then === 'function') {
Promise.resolve(returned).catch(recordFailure);
}
} catch {
recordFailure();
}
}
}
} finally {
this._connectionStatusPublishing = false;
}
return published;
}
/** @private */
_setConnectionStatus(status, overrides = {}) {
if (!CONNECTION_STATUS_VALUES.has(status)) {
throw new TypeError('Invalid connection status.');
}
const previous = this._connectionStatus;
if (
previous &&
CONNECTION_STATUS_TERMINAL.has(previous.status) &&
previous.status !== status &&
overrides.allowTerminalRecovery !== true
) {
return Object.freeze({ ...previous });
}
const [
defaultConnected,
defaultAuthenticated,
defaultSessionValid,
defaultRecoverable,
defaultQrAvailable,
] = connectionStatusDefaults(status, previous);
const sessionValid = Object.hasOwn(overrides, 'sessionValid')
? overrides.sessionValid
: defaultSessionValid;
if (
sessionValid !== true &&
sessionValid !== false &&
sessionValid !== null
) {
throw new TypeError('Invalid session validity.');
}
const reason = sanitizeConnectionCode(
overrides.reason,
'wwebjs_connection_reason',
);
const errorCode = sanitizeConnectionCode(
overrides.errorCode,
'wwebjs_connection_error',
);
const candidate = {
provider: 'wwebjs',
status,
connected:
overrides.connected === undefined
? defaultConnected
: overrides.connected === true,
authenticated:
overrides.authenticated === undefined
? defaultAuthenticated
: overrides.authenticated === true,
sessionValid,
recoverable:
overrides.recoverable === undefined
? defaultRecoverable
: overrides.recoverable === true,
qrAvailable:
overrides.qrAvailable === undefined
? defaultQrAvailable
: overrides.qrAvailable === true,
...(reason ? { reason } : {}),
...(errorCode ? { errorCode } : {}),
};
if (
previous &&
CONNECTION_STATUS_FIELDS.every(
(field) => previous[field] === candidate[field],
)
) {
return Object.freeze({ ...previous });
}
this._connectionStatusChangedAtMs = Math.max(
Date.now(),
this._connectionStatusChangedAtMs + 1,
);
const snapshot = Object.freeze({
...candidate,
sequence: (previous?.sequence || 0) + 1,
changedAt: new Date(
this._connectionStatusChangedAtMs,
).toISOString(),
});
this._connectionStatus = snapshot;
this._publishConnectionStatus(snapshot);
return Object.freeze({ ...snapshot });
}
/** @private */
_setConnectionStatusFromWaState(state) {
this._lastWaState = state;
if (state !== WAState.CONNECTED) {
this._connectionProofAttempt += 1;
}
if (this._stagedPublicEvents?.active) {
return this._setConnectionStatus(ConnectionStatus.HANDOFF, {
reason: 'handoff_validation',
});
}
switch (state) {
case WAState.CONNECTED:
if (
this._connectionStatus.status === ConnectionStatus.ONLINE &&
this._lastHasSynced === true
) {
return this._connectionStatus;
}
return this._setConnectionStatus(
this._authReadyPublished
? ConnectionStatus.RECONNECTING
: ConnectionStatus.CONNECTING,
{
authenticated: this._authReadyPublished === true,
sessionValid: this._authReadyPublished ? true : null,
reason: 'socket_connected_unverified',
},
);
case WAState.OPENING:
this._lastHasSynced = false;
return this._setConnectionStatus(
[
ConnectionStatus.ONLINE,
ConnectionStatus.RECONNECTING,
ConnectionStatus.OFFLINE,
].includes(this._connectionStatus.status)
? ConnectionStatus.RECONNECTING
: ConnectionStatus.CONNECTING,
{ reason: 'socket_opening' },
);
case WAState.TIMEOUT:
this._lastHasSynced = false;
return this._setConnectionStatus(
this._authReadyPublished
? ConnectionStatus.RECONNECTING
: ConnectionStatus.CONNECTING,
{
reason: 'socket_timeout',
authenticated: this._authReadyPublished === true,
sessionValid: this._authReadyPublished ? true : null,
},
);
case WAState.UNPAIRED:
case WAState.UNPAIRED_IDLE:
case WAState.PAIRING:
this._lastHasSynced = false;
return this._setConnectionStatus(ConnectionStatus.CONNECTING, {
reason: 'pairing_required',
sessionValid: null,
qrAvailable: false,
});
case WAState.CONFLICT:
this._lastHasSynced = false;
return this._setConnectionStatus(ConnectionStatus.CONFLICT, {
reason: 'socket_conflict',
});
case WAState.UNLAUNCHED:
this._lastHasSynced = false;
return this._setConnectionStatus(ConnectionStatus.CONNECTING, {
reason: 'socket_unlaunched',
});
case WAState.DEPRECATED_VERSION:
case WAState.PROXYBLOCK:
case WAState.SMB_TOS_BLOCK:
case WAState.TOS_BLOCK:
this._lastHasSynced = false;
return this._setConnectionStatus(ConnectionStatus.ERROR, {
recoverable: false,
reason: `socket_${String(state).toLowerCase()}`,
errorCode: `wwebjs_${String(state).toLowerCase()}`,
});
default:
this._lastHasSynced = false;
return this._setConnectionStatus(ConnectionStatus.OFFLINE, {
reason: 'socket_disconnected',
});
}
}
/** @private */
_setConnectionStatusFromError(error) {
const rawCode = error?.code || error?.message || error?.cause?.code;
const errorCode = sanitizeConnectionCode(
rawCode,
'wwebjs_connection_error',
);
if (errorCode?.includes('lease_lost')) {
return this._setConnectionStatus(ConnectionStatus.LEASE_LOST, {
reason: 'lease_lost',
errorCode,
});
}
if (
errorCode &&
/(bad_session|invalid_session|auth_failure|logged_out)/u.test(
errorCode,
)
) {
return this._setConnectionStatus(ConnectionStatus.INVALID_SESSION, {
reason: 'session_invalid',
errorCode,
});
}
return this._setConnectionStatus(ConnectionStatus.ERROR, {
reason: 'initialization_failed',
errorCode,
});
}
/** @private */
_isCanonicalForwardRecoveryPending() {
const strategy = this.authStrategy;
try {
return (
strategy?.isPostPointOfNoReturnRecoveryPending?.() === true ||
strategy?.isCanonicalActivationRecoveryRequired?.() === true
);
} catch {
// A diagnostic recovery probe must never replace the primary
// initialization failure or manufacture a valid session.
return false;
}
}
/** @private */
_setCanonicalForwardRecoveryStatus(error) {
const errorCode = sanitizeConnectionCode(
error?.code || error?.message || error?.cause?.code,
'wwebjs_canonical_forward_recovery_required',
);
return this._setConnectionStatus(ConnectionStatus.HANDOFF, {
connected: false,
authenticated: true,
sessionValid: true,
recoverable: true,
qrAvailable: false,
reason: 'activation_recovery_pending',
errorCode,
allowTerminalRecovery: true,
});
}
/** @private */
_syncConnectionStatusFromEvent(event, args) {
// A post-PONR failure is recovered from the durable activation marker
// by the next fenced owner. Chromium is being terminated and every
// public event is blocked, so late browser callbacks cannot downgrade
// the recoverable handoff to QR/offline or claim it is online.
if (
this._publicEventsBlocked &&
this._connectionStatus.status === ConnectionStatus.HANDOFF
) {
return this._connectionStatus;
}
switch (event) {
case Events.AUTHENTICATED:
if (this._connectionStatus.status === ConnectionStatus.ONLINE) {
return this._connectionStatus;
}
return this._setConnectionStatus(ConnectionStatus.CONNECTING, {
authenticated: true,
sessionValid: true,
reason: 'authenticated',
});
case Events.READY:
// READY is a compatibility event, not transport evidence.
// ONLINE is published only by _publishOnlineConnectionStatus.
return this._connectionStatus;
case Events.QR_RECEIVED:
case Events.CODE_RECEIVED:
// A staging handoff owns an unpublished browser lifecycle. A
// late pairing callback must not escape that boundary by
// mutating the native status even though the public QR/code
// event itself is suppressed by emit(). RemoteAuth handles a
// genuine pairing request as a failed candidate + rollback.
if (
this._stagedPublicEvents?.active ||
this._connectionStatus.status === ConnectionStatus.HANDOFF
) {
return this._connectionStatus;
}
return this._setConnectionStatus(ConnectionStatus.QR, {
reason:
event === Events.CODE_RECEIVED
? 'pairing_code_available'
: 'qr_available',
qrAvailable: true,
sessionValid: null,
});
case Events.AUTHENTICATION_FAILURE:
return this._setConnectionStatus(
ConnectionStatus.INVALID_SESSION,
{
reason: 'authentication_failed',
errorCode: sanitizeConnectionCode(
args[0],
'wwebjs_authentication_failed',
),
},
);
case Events.STATE_CHANGED:
return this._setConnectionStatusFromWaState(args[0]);
case Events.DISCONNECTED: {
const reason = String(args[0] || '').toLowerCase();
this._lastHasSynced = false;
this._lastWaState = args[0] || null;
if (reason === 'logout' || this.lastLoggedOut) {
return this._setConnectionStatus(
ConnectionStatus.LOGGED_OUT,
{ reason: 'logout' },
);
}
if (reason.includes('lease_lost')) {
return this._setConnectionStatus(
ConnectionStatus.LEASE_LOST,
{
reason: 'lease_lost',
errorCode: sanitizeConnectionCode(
reason,
'whatsapp_session_lease_lost',
),
},
);
}
if (reason === WAState.CONFLICT.toLowerCase()) {
return this._setConnectionStatus(
ConnectionStatus.CONFLICT,
{ reason: 'socket_conflict' },
);
}
if (
[WAState.UNPAIRED, WAState.UNPAIRED_IDLE].some(
(value) => value.toLowerCase() === reason,
)
) {
return this._setConnectionStatus(
ConnectionStatus.INVALID_SESSION,
{ reason: 'session_unpaired' },
);
}
return this._setConnectionStatus(ConnectionStatus.OFFLINE, {
reason: 'disconnected',
});
}
default:
return undefined;
}
}
/**
* Publishes ONLINE only after a fresh browser, protocol and fencing proof.
* Every await is guarded by both the runtime epoch and status sequence so
* a late result can never resurrect a disconnected or fenced client.
* @private
*/
async _publishOnlineConnectionStatus(
reason = 'runtime_verified',
{
deferTransientFailure = false,
renewLease = true,
publishOnline = true,
allowOnlineDuringReadinessWait = false,
} = {},
) {
const attempt = ++this._connectionProofAttempt;
const runtimeEpoch = this._connectionRuntimeEpoch;
const documentEpoch = this._connectionDocumentEpoch;
const navigationSequence = this._mainFrameNavigationSequence;
const startingSequence = this._connectionStatus.sequence;
const browser = this.pupBrowser;
const page = this.pupPage;
const store = this.authStrategy?.store;
const readinessAttempt =
allowOnlineDuringReadinessWait === true &&
this._onlineReadinessWaitActive === true;
let diagnostic;
const canAttempt = () =>
attempt === this._connectionProofAttempt &&
runtimeEpoch === this._connectionRuntimeEpoch &&
documentEpoch === this._connectionDocumentEpoch &&
navigationSequence === this._mainFrameNavigationSequence &&
this._connectionStatus.sequence === startingSequence &&
this.pupBrowser === browser &&
this.pupPage === page &&
(this._authReadyPublished === true || readinessAttempt) &&
this._stagedPublicEvents?.active !== true &&
this._publicEventsBlocked !== true &&
this.authStrategy?.shutdownRequested !== true &&
this.authStrategy?.authReady !== false &&
(store?.revisionStatus === undefined ||
store.revisionStatus === 'active') &&
!CONNECTION_STATUS_TERMINAL.has(this._connectionStatus.status);
try {
if (!canAttempt()) return false;
if (
!browser ||
!isBrowserConnected(browser) ||
!page ||
typeof page.isClosed !== 'function' ||
page.isClosed() !== false
) {
throw new Error('wwebjs_online_runtime_unavailable');
}
if (typeof store?.assertLocalLease === 'function') {
store.assertLocalLease();
if (
renewLease === true &&
typeof store.renewLease === 'function'
) {
await store.renewLease();
}
store.assertLocalLease();
}
if (!canAttempt()) return false;
const proof = await page.evaluate(() => {
const resolveModule = (name) => {
try {
const value = window.require?.(name);
return value?.default || value;
} catch {
return undefined;
}
};
const socket = resolveModule('WAWebSocketModel')?.Socket;
const comms = resolveModule('WAComms');
const multiDevice = resolveModule('WAWebUserPrefsMultiDevice');
const me = resolveModule('WAWebUserPrefsMeUser');
const conn = resolveModule('WAWebConnModel')?.Conn;
let socketConnected;
try {
socketConnected = comms?.isSocketConnected?.() === true;
} catch {
socketConnected = false;
}
const documentEpoch = window.performance?.timeOrigin;
return {
documentEpoch,
utilitiesReady:
typeof window.WWebJS === 'object' &&
window.WWebJS !== null &&
typeof window.WWebJS.getMessageModel === 'function' &&
typeof window.WWebJS.getChat === 'function' &&
typeof window.WWebJS.normalizeMessageId === 'function',
eventListenersReady:
Array.isArray(window._wwjsListeners) &&
window._wwjsListeners.length > 0 &&
window._wwjsFullEventBridgeDocumentEpoch ===
documentEpoch,
socketState: String(
socket?.state ?? socket?.get?.('state') ?? 'unknown',
),
hasSynced: socket?.hasSynced === true,
socketConnected,
registered: multiDevice?.isRegistered?.() === true,
hasMe:
me?.getMaybeMeDevicePn?.() != null ||
me?.getMaybeMePnUser?.() != null ||
me?.getMaybeMeLidUser?.() != null,
// Conn.ref can retain the last successfully consumed QR
// reference. Never expose its value; registration and
// socket state decide whether pairing is still active.
pairingReferenceRetained: Boolean(conn?.ref),
};
});
if (!canAttempt()) return false;
if (typeof store?.assertLocalLease === 'function') {
store.assertLocalLease();
}
diagnostic = createOnlineProofDiagnostic(
proof,
this._connectionDocumentEpoch,
);
this._lastOnlineProofDiagnostic = diagnostic;
if (diagnostic.failed_requirements.length > 0) {
throw new Error('wwebjs_online_protocol_unproven');
}
if (!canAttempt()) return false;
if (
publishOnline !== true ||
(this._onlineReadinessWaitActive === true &&
allowOnlineDuringReadinessWait !== true)
) {
return true;
}
this._lastWaState = WAState.CONNECTED;
this._lastHasSynced = true;
this._setConnectionStatus(ConnectionStatus.ONLINE, {
authenticated: true,
sessionValid: true,
reason,
});
return this._connectionStatus.status === ConnectionStatus.ONLINE;
} catch (error) {
if (
attempt !== this._connectionProofAttempt ||
runtimeEpoch !== this._connectionRuntimeEpoch ||
this._connectionStatus.sequence !== startingSequence
) {
return false;
}
const code = sanitizeConnectionCode(
error?.code || error?.message,
'wwebjs_online_proof_failed',
);
diagnostic = diagnostic
? Object.freeze({ ...diagnostic, error_code: code })
: createOnlineProofDiagnostic(
undefined,
this._connectionDocumentEpoch,
error,
);
this._lastOnlineProofDiagnostic = diagnostic;
if (code?.includes('lease_lost')) {
this._setConnectionStatus(ConnectionStatus.LEASE_LOST, {
reason: 'lease_lost',
errorCode: code,
});
} else if (
deferTransientFailure !== true &&
!CONNECTION_STATUS_TERMINAL.has(
this._connectionStatus.status,
) &&
this._stagedPublicEvents?.active !== true
) {
this._lastHasSynced = false;
this._setConnectionStatus(
this._authReadyPublished
? ConnectionStatus.RECONNECTING
: ConnectionStatus.CONNECTING,
{
reason: 'online_proof_failed',
authenticated: this._authReadyPublished === true,
sessionValid: this._authReadyPublished ? true : null,
},
);
}
return false;
}
}
/**
* Waits for multiple fresh samples of the complete online proof. Fresh QR
* pairings briefly expose authenticated state before every private module
* converges, so a single transient sample must not terminalize a durable
* session. The final timeout remains fail-closed.
* @private
*/
async _waitForOnlineConnectionStatus(
reason = 'runtime_verified',
{ timeoutMs, pollIntervalMs } = {},
) {
const configuredTimeout = Number(timeoutMs);
const configuredPollInterval = Number(pollIntervalMs);
const boundedTimeoutMs = Math.min(
30000,
Math.max(
1,
Number.isFinite(configuredTimeout) && configuredTimeout > 0
? configuredTimeout
: this.options.authTimeoutMs || 30000,
),
);
const boundedPollIntervalMs = Math.min(
1000,
Math.max(
1,
Number.isFinite(configuredPollInterval) &&
configuredPollInterval > 0
? configuredPollInterval
: 250,
),
);
const deadline = Date.now() + boundedTimeoutMs;
const runtimeEpoch = this._connectionRuntimeEpoch;
const documentEpoch = this._connectionDocumentEpoch;
const navigationSequence = this._mainFrameNavigationSequence;
const browser = this.pupBrowser;
const page = this.pupPage;
let attempts = 0;
let consecutiveSuccessfulSamples = 0;
let lastDiagnosticSignature;
let lastLoggedAt = 0;
let lastLeaseRenewedAt = 0;
const requiresFreshReadinessProof = this._authReadyPublished !== true;
const readinessWait = Symbol('online-readiness-wait');
const runtimeIsCurrent = () => {
if (
!browser ||
!page ||
runtimeEpoch !== this._connectionRuntimeEpoch ||
documentEpoch !== this._connectionDocumentEpoch ||
navigationSequence !== this._mainFrameNavigationSequence ||
browser !== this.pupBrowser ||
page !== this.pupPage
) {
return false;
}
try {
if (getBrowserConnectionState(browser) !== true) return false;
if (
typeof page.isClosed !== 'function' ||
page.isClosed() !== false
) {
return false;
}
} catch {
return false;
}
return true;
};
const logProof = (event, diagnostic) => {
this.authStrategy?.logNative?.(
event,
{
reason,
proof_attempts: attempts,
successful_samples: consecutiveSuccessfulSamples,
timeout_ms: boundedTimeoutMs,
...(diagnostic || {}),
},
{ force: true },
);
};
this._onlineReadinessWaits.add(readinessWait);
this._onlineReadinessWaitActive = true;
try {
while (Date.now() <= deadline) {
if (
!runtimeIsCurrent() ||
CONNECTION_STATUS_TERMINAL.has(
this._connectionStatus.status,
)
) {
logProof(
'connection.online_readiness_aborted',
this._lastOnlineProofDiagnostic,
);
return false;
}
if (
this._connectionStatus.status === ConnectionStatus.ONLINE &&
!requiresFreshReadinessProof
) {
return true;
}
attempts += 1;
const now = Date.now();
const renewLease =
attempts === 1 ||
now - lastLeaseRenewedAt >= ONLINE_PROOF_LOG_INTERVAL_MS;
const proofValid = await this._publishOnlineConnectionStatus(
reason,
{
deferTransientFailure: true,
renewLease,
publishOnline: consecutiveSuccessfulSamples > 0,
allowOnlineDuringReadinessWait: true,
},
);
if (renewLease) lastLeaseRenewedAt = Date.now();
const diagnostic = this._lastOnlineProofDiagnostic;
if (proofValid) {
consecutiveSuccessfulSamples += 1;
if (
consecutiveSuccessfulSamples >= 2 &&
this._connectionStatus.status ===
ConnectionStatus.ONLINE
) {
logProof(
'connection.online_readiness_proven',
diagnostic,
);
return true;
}
} else {
consecutiveSuccessfulSamples = 0;
}
if (
!runtimeIsCurrent() ||
CONNECTION_STATUS_TERMINAL.has(
this._connectionStatus.status,
)
) {
logProof('connection.online_readiness_aborted', diagnostic);
return false;
}
const diagnosticSignature = JSON.stringify({
diagnostic: diagnostic || null,
consecutiveSuccessfulSamples,
});
const loggedAt = Date.now();
if (
diagnosticSignature !== lastDiagnosticSignature ||
loggedAt - lastLoggedAt >= ONLINE_PROOF_LOG_INTERVAL_MS
) {
logProof('connection.online_readiness_pending', diagnostic);
lastDiagnosticSignature = diagnosticSignature;
lastLoggedAt = loggedAt;
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
await new Promise((resolve) =>
setTimeout(
resolve,
Math.min(boundedPollIntervalMs, remainingMs),
),
);
}
logProof(
'connection.online_readiness_timeout',
this._lastOnlineProofDiagnostic,
);
return false;
} finally {
this._onlineReadinessWaits.delete(readinessWait);
this._onlineReadinessWaitActive =
this._onlineReadinessWaits.size > 0;
}
}
/**
* Revalidates the document-bound readiness proof after its final await and
* immediately before the authentication-ready latch is committed. This
* catches a document replacement that Chromium has completed before the
* framenavigated handler advances the Node-side navigation sequence.
* @private
*/
async _revalidateAuthenticationReadyRuntime({
signal,
browser,
page,
runtimeEpoch,
documentEpoch,
navigationSequence,
proofAttempt,
connectionStatusSequence,
onlineProofDiagnostic,
}) {
const runtimeIsCurrent = () => {
if (
signal?.aborted === true ||
!browser ||
!page ||
browser !== this.pupBrowser ||
page !== this.pupPage ||
runtimeEpoch !== this._connectionRuntimeEpoch ||
documentEpoch !== this._connectionDocumentEpoch ||
navigationSequence !== this._mainFrameNavigationSequence ||
proofAttempt !== this._connectionProofAttempt ||
connectionStatusSequence !== this._connectionStatus.sequence ||
this._connectionStatus.status !== ConnectionStatus.ONLINE ||
this._lastHasSynced !== true ||
onlineProofDiagnostic !== this._lastOnlineProofDiagnostic ||
!Array.isArray(onlineProofDiagnostic?.failed_requirements) ||
onlineProofDiagnostic.failed_requirements.length > 0
) {
return false;
}
try {
if (getBrowserConnectionState(browser) !== true) return false;
if (
typeof page.isClosed !== 'function' ||
page.isClosed() !== false
) {
return false;
}
} catch {
return false;
}
return true;
};
if (!runtimeIsCurrent()) return false;
let currentDocumentEpoch;
try {
currentDocumentEpoch = await page.evaluate(
() => window.performance?.timeOrigin,
);
} catch (error) {
if (isTransientNavigationError(error)) return false;
throw error;
}
return (
runtimeIsCurrent() &&
Number.isFinite(currentDocumentEpoch) &&
currentDocumentEpoch === documentEpoch
);
}
/**
* Waits for Chromium's main-frame navigation event when a document-bound
* guard has already observed an explicit navigation supersession. The new
* document can become observable through `performance.timeOrigin` just
* before Puppeteer dispatches `framenavigated`; closing the browser in that
* narrow interval would retire the healthy successor. The wait is bounded
* and never treats the event alone as success: the registered navigation
* handler must advance the runtime/sequence (or abort the predecessor).
* @private
*/
async _waitForMainFrameNavigationSuccessor({
error,
signal,
browser = this.pupBrowser,
page = this.pupPage,
runtimeEpoch = this._connectionRuntimeEpoch,
navigationSequence = this._mainFrameNavigationSequence,
timeoutMs = MAIN_FRAME_NAVIGATION_SUCCESSOR_TIMEOUT_MS,
} = {}) {
if (!isExplicitNavigationSupersession(error)) return false;
const successorOwnsRuntime = () =>
signal?.aborted === true ||
browser !== this.pupBrowser ||
page !== this.pupPage ||
runtimeEpoch !== this._connectionRuntimeEpoch ||
navigationSequence !== this._mainFrameNavigationSequence;
const pageIsClosed = () => {
try {
return page?.isClosed?.() === true;
} catch {
return true;
}
};
if (successorOwnsRuntime()) return true;
try {
if (getBrowserConnectionState(browser) === false) return false;
} catch {
return false;
}
if (
!page ||
pageIsClosed() ||
typeof page.on !== 'function' ||
typeof page.off !== 'function'
) {
return false;
}
const configuredTimeout = Number(timeoutMs);
const boundedTimeoutMs = Math.min(
30000,
Math.max(
1,
Number.isFinite(configuredTimeout) && configuredTimeout > 0
? configuredTimeout
: MAIN_FRAME_NAVIGATION_SUCCESSOR_TIMEOUT_MS,
),
);
this.authStrategy.logNative?.(
'client.navigation_successor_wait_started',
{
error_code: error.code || error.message,
navigation_sequence: navigationSequence,
runtime_epoch: runtimeEpoch,
timeout_ms: boundedTimeoutMs,
},
{ force: true },
);
const observed = await new Promise((resolve) => {
let settled = false;
let listenerAttached = false;
let timeout;
const finish = (value) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
if (listenerAttached) {
try {
page.off('framenavigated', onFrameNavigated);
} catch {
// A closing page can reject listener mutations. The
// bounded runtime result remains authoritative.
}
}
signal?.removeEventListener?.('abort', onAbort);
resolve(value);
};
const inspectRuntime = () => {
if (successorOwnsRuntime()) {
finish(true);
} else if (pageIsClosed()) {
finish(false);
}
};
const onFrameNavigated = (frame) => {
try {
if (frame?.parentFrame?.() !== null) return;
} catch {
return;
}
// EventEmitter listeners run in registration order, but defer
// the check one microtask so custom page emitters preserve the
// same contract as Puppeteer's registered handler.
queueMicrotask(inspectRuntime);
};
const onAbort = () => finish(successorOwnsRuntime());
try {
page.on('framenavigated', onFrameNavigated);
listenerAttached = true;
} catch {
finish(false);
return;
}
signal?.addEventListener?.('abort', onAbort, { once: true });
timeout = setTimeout(
() => finish(successorOwnsRuntime()),
boundedTimeoutMs,
);
// Close the listener-registration race: the event may have run
// between the caller's first check and this subscription.
queueMicrotask(inspectRuntime);
});
this.authStrategy.logNative?.(
observed
? 'client.navigation_successor_wait_completed'
: 'client.navigation_successor_wait_timed_out',
{
error_code: error.code || error.message,
navigation_sequence: navigationSequence,
current_navigation_sequence: this._mainFrameNavigationSequence,
runtime_epoch: runtimeEpoch,
},
{ force: true },
);
return observed;
}
/** @private */
_emitConnectionLifecycleEvent(event, ...args) {
this._syncConnectionStatusFromEvent(event, args);
return this.emit(event, ...args);
}
/**
* Prevents a PostgreSQL handoff candidate from publishing application
* events before its revision has passed validation and been promoted.
* Events are intentionally suppressed instead of retained: message
* objects hold references to the Client and browser-backed structures, so
* buffering them would make handoff memory use attacker-controlled.
* @private
*/
_beginPublicEventGate() {
if (this._stagedPublicEvents?.active) return;
this._publicEventsBlocked = false;
this._stagedRemoteSessionSaved = false;
this._stagedPublicEvents = {
active: true,
suppressed: 0,
};
this._markConnectionHandoff('handoff_validation');
}
/** @private */
_markConnectionHandoff(reason = 'handoff_preparing') {
this._connectionProofAttempt += 1;
this._lastHasSynced = false;
return this._setConnectionStatus(ConnectionStatus.HANDOFF, { reason });
}
/**
* Confirms that a native handoff still owns the unpublished event gate.
* RemoteAuth invokes this inside its final fenced promotion gate.
*/
assertPublicEventGateHealthy() {
if (!this._stagedPublicEvents?.active) {
const error = new Error('wwebjs_public_event_gate_not_active');
error.code = 'wwebjs_public_event_gate_not_active';
throw error;
}
}
/** @private */
_releasePublicEventGate() {
this.assertPublicEventGateHealthy();
const suppressed = this._stagedPublicEvents.suppressed;
this._stagedRemoteSessionSaved =
this._stagedPublicEvents.remoteSessionSaved === true;
this._stagedPublicEvents = null;
return suppressed;
}
/** @private */
_discardPublicEventGate({ block = false } = {}) {
this._stagedPublicEvents = null;
this._stagedRemoteSessionSaved = false;
if (block) this._publicEventsBlocked = true;
}
/**
* EventEmitter boundary used to hold every public WWebJS event emitted by
* a staging handoff. Internal browser/authentication callbacks continue to
* run, while consumers see nothing until durable promotion succeeds.
*/
emit(event, ...args) {
// Status evidence is emitted only by _publishConnectionStatus. This
// prevents application code from spoofing a native ONLINE snapshot by
// calling EventEmitter.emit directly.
if (event === Events.CONNECTION_STATUS) return false;
const staged = this._stagedPublicEvents;
if (PUBLIC_CLIENT_EVENTS.has(event)) {
if (event === Events.AUTHENTICATION_FAILURE) {
return super.emit(event, ...args);
}
if (this._publicEventsBlocked) return false;
if (staged?.active) {
// RemoteAuth emits this only after its durable checkpoint has
// completed. Preserve the bounded, payload-free lifecycle
// signal so it can be published after the handoff gate opens;
// application events remain deliberately discarded.
if (event === Events.REMOTE_SESSION_SAVED) {
staged.remoteSessionSaved = true;
}
staged.suppressed = Math.min(
Number.MAX_SAFE_INTEGER,
staged.suppressed + 1,
);
return false;
}
}
return super.emit(event, ...args);
}
/** @private */
async _waitForBrowserProcessExit(child, timeoutMs) {
const childExited = () =>
(child.exitCode !== null && child.exitCode !== undefined) ||
(child.signalCode !== null && child.signalCode !== undefined);
if (childExited()) return true;
await new Promise((resolve) => {
let settled = false;
let timeout;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
child.removeListener?.('exit', finish);
child.removeListener?.('close', finish);
resolve();
};
timeout = setTimeout(finish, timeoutMs);
child.once?.('exit', finish);
if (!settled) child.once?.('close', finish);
// The process can exit between the check above and listener
// registration. Recheck after registration so a missed event
// never consumes the entire timeout.
if (childExited()) finish();
});
return childExited();
}
/** @private */
async _runBrowserTerminationOperation(operation, timeoutMs, timeoutCode) {
const boundedTimeoutMs =
Number.isFinite(timeoutMs) && timeoutMs >= 0
? timeoutMs
: BROWSER_PROCESS_EXIT_TIMEOUT_MS;
let timeout;
try {
await Promise.race([
Promise.resolve().then(operation),
new Promise((_, reject) => {
timeout = setTimeout(() => {
const error = new Error(timeoutCode);
error.code = timeoutCode;
error.timeoutMs = boundedTimeoutMs;
reject(error);
}, boundedTimeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
}
/**
* Closes a Puppeteer browser and confirms that an owned Chromium process
* has exited. Puppeteer can report a disconnected browser before its child
* process actually terminates, which would otherwise leave the profile
* locked for the next client.
* @private
*/
async _closeBrowserProcess(
browser,
{
operationTimeoutMs = BROWSER_PROCESS_EXIT_TIMEOUT_MS,
gracefulTimeoutMs = BROWSER_PROCESS_EXIT_TIMEOUT_MS,
terminateTimeoutMs = BROWSER_PROCESS_EXIT_TIMEOUT_MS,
} = {},
) {
if (!browser) return true;
const failures = [];
let child;
try {
child = browser.process?.();
} catch (error) {
failures.push(error);
}
const childTrackable = Boolean(
child && ('exitCode' in child || 'signalCode' in child),
);
const childExited = () =>
childTrackable &&
((child.exitCode !== null && child.exitCode !== undefined) ||
(child.signalCode !== null && child.signalCode !== undefined));
let connected = true;
try {
const connectionState = getBrowserConnectionState(browser);
if (connectionState !== undefined) connected = connectionState;
} catch (error) {
failures.push(error);
}
let closeCompleted = false;
let closeFailed = false;
if (connected) {
try {
if (child) {
if (typeof browser.close !== 'function') {
throw new Error('wwebjs_browser_close_unavailable');
}
await Client.prototype._runBrowserTerminationOperation.call(
this,
() => browser.close(),
operationTimeoutMs,
'wwebjs_browser_close_timeout',
);
} else {
if (typeof browser.disconnect !== 'function') {
throw new Error(
'wwebjs_browser_disconnect_unavailable',
);
}
await Client.prototype._runBrowserTerminationOperation.call(
this,
() => browser.disconnect(),
operationTimeoutMs,
'wwebjs_browser_disconnect_timeout',
);
}
closeCompleted = true;
} catch (error) {
closeFailed = true;
failures.push(error);
}
}
if (childTrackable && !childExited()) {
if (!closeFailed) {
await Client.prototype._waitForBrowserProcessExit.call(
this,
child,
gracefulTimeoutMs,
);
}
if (!childExited()) {
try {
if (typeof child.kill !== 'function') {
throw new Error(
'wwebjs_browser_process_kill_unavailable',
);
}
child.kill('SIGTERM');
} catch (error) {
failures.push(error);
}
if (!childExited()) {
await Client.prototype._waitForBrowserProcessExit.call(
this,
child,
terminateTimeoutMs,
);
}
}
}
let browserTerminationConfirmed;
if (childTrackable) {
browserTerminationConfirmed = childExited();
} else if (closeCompleted || connected === false) {
// Some embedders and unit-test stubs expose only an opaque process
// sentinel. In that case the completed Puppeteer operation is the
// strongest available confirmation.
browserTerminationConfirmed = true;
} else {
try {
browserTerminationConfirmed = !isBrowserConnected(browser);
} catch (error) {
failures.push(error);
browserTerminationConfirmed = false;
}
}
const processExitTimedOut =
childTrackable && !browserTerminationConfirmed;
if (processExitTimedOut) {
const timeoutError = new Error(
'wwebjs_browser_process_exit_timeout',
);
timeoutError.code = 'wwebjs_browser_process_exit_timeout';
failures.push(timeoutError);
}
if (failures.length > 0) {
const failure =
failures.length === 1
? failures[0]
: new AggregateError(
failures,
processExitTimedOut
? 'wwebjs_browser_process_exit_timeout'
: browserTerminationConfirmed
? 'wwebjs_browser_termination_failed'
: 'wwebjs_browser_termination_unconfirmed',
{ cause: failures[0] },
);
if (processExitTimedOut) {
failure.code = 'wwebjs_browser_process_exit_timeout';
}
failure.browserTerminationConfirmed = browserTerminationConfirmed;
throw failure;
}
return true;
}
/** @private */
async _finalizeInitializationFailure(error, browser = this.pupBrowser) {
if (this._isCanonicalForwardRecoveryPending()) {
this._setCanonicalForwardRecoveryStatus(error);
} else {
this._setConnectionStatusFromError(error);
}
this._discardPublicEventGate({ block: true });
const failures = [error];
try {
await this.authStrategy.onInitializationFailure?.(error);
} catch (cleanupError) {
failures.push(cleanupError);
}
let browserTerminationConfirmed;
try {
browserTerminationConfirmed =
await Client.prototype._closeBrowserProcess.call(this, browser);
} catch (browserCloseError) {
failures.push(browserCloseError);
browserTerminationConfirmed =
browserCloseError.browserTerminationConfirmed === true;
}
if (browserTerminationConfirmed) {
try {
await this.authStrategy.afterInitializationFailureBrowserClosed?.(
error,
);
} catch (postCloseCleanupError) {
failures.push(postCloseCleanupError);
}
}
if (failures.length === 1) throw error;
throw new AggregateError(
failures,
'wwebjs_initialization_failure_cleanup_failed',
{ cause: error },
);
}
/** @private */
_emitAuthenticatedOnce(authEventPayload) {
if (this._authenticatedPublished) return false;
// Commit before entering user listeners. EventEmitter stops dispatching
// when a listener throws; retrying the lifecycle flight must not
// publish a second AUTHENTICATED event to earlier listeners.
this._authenticatedPublished = true;
this._emitConnectionLifecycleEvent(
Events.AUTHENTICATED,
authEventPayload,
);
return true;
}
/** @private */
_publishAuthenticationReady(authEventPayload) {
const listenerErrors = [];
if (this._stagedRemoteSessionSaved) {
this._stagedRemoteSessionSaved = false;
try {
this.emit(Events.REMOTE_SESSION_SAVED);
} catch (error) {
listenerErrors.push(error);
}
}
try {
this._emitAuthenticatedOnce(authEventPayload);
} catch (error) {
listenerErrors.push(error);
}
try {
this._emitConnectionLifecycleEvent(Events.READY);
} catch (error) {
listenerErrors.push(error);
}
if (listenerErrors.length === 1) throw listenerErrors[0];
if (listenerErrors.length > 1) {
throw new AggregateError(
listenerErrors,
'wwebjs_auth_ready_listener_failed',
);
}
}
/** @private */
async _ensureWWebJSUtilitiesAfterAuthReady(
expectedDocumentEpoch,
expectedNavigationSequence = this._mainFrameNavigationSequence,
) {
const inspectReadyDocument = () => ({
documentEpoch: window.performance?.timeOrigin,
utilitiesReady:
typeof window.WWebJS === 'object' &&
window.WWebJS !== null &&
typeof window.WWebJS.getMessageModel === 'function' &&
typeof window.WWebJS.getChat === 'function' &&
typeof window.WWebJS.normalizeMessageId === 'function',
eventListenersReady:
Array.isArray(window._wwjsListeners) &&
window._wwjsListeners.length > 0,
});
const assertCurrentDocument = (snapshot) => {
if (
!Number.isFinite(snapshot?.documentEpoch) ||
snapshot.documentEpoch !== expectedDocumentEpoch
) {
// The main-frame handler owns a replacement only after its
// monotonic sequence advances. Mark that stale flight as a
// transient supersession so its serialized successor can
// perform the full readiness pipeline on the new document.
// An unobserved document replacement remains a fail-closed
// integrity failure and must never receive utility injection.
const navigationSuperseded =
Number.isInteger(expectedNavigationSequence) &&
this._mainFrameNavigationSequence >
expectedNavigationSequence &&
this.pupPage?.isClosed?.() !== true;
const error = new Error(
navigationSuperseded
? 'wwebjs_ready_utility_navigation_superseded'
: 'wwebjs_ready_utility_document_replaced',
);
error.code = error.message;
throw error;
}
};
let snapshot = await this.pupPage.evaluate(inspectReadyDocument);
assertCurrentDocument(snapshot);
if (snapshot.utilitiesReady && snapshot.eventListenersReady) {
return false;
}
if (!snapshot.eventListenersReady) {
const error = new Error('wwebjs_ready_event_bridge_missing');
error.code = error.message;
throw error;
}
this.authStrategy.logNative?.(
'client.ready_utility_reinjection_started',
{
document_epoch: expectedDocumentEpoch,
},
{ force: true },
);
await this.pupPage.evaluate(LoadUtils);
await this.pupPage.waitForFunction(
(documentEpoch) =>
window.performance?.timeOrigin === documentEpoch &&
typeof window.WWebJS === 'object' &&
window.WWebJS !== null &&
typeof window.WWebJS.getMessageModel === 'function' &&
typeof window.WWebJS.getChat === 'function' &&
typeof window.WWebJS.normalizeMessageId === 'function' &&
Array.isArray(window._wwjsListeners) &&
window._wwjsListeners.length > 0,
{ timeout: 30000 },
expectedDocumentEpoch,
);
snapshot = await this.pupPage.evaluate(inspectReadyDocument);
assertCurrentDocument(snapshot);
if (!snapshot.utilitiesReady || !snapshot.eventListenersReady) {
const error = new Error(
'wwebjs_ready_utility_reinjection_incomplete',
);
error.code = error.message;
throw error;
}
this.authStrategy.logNative?.(
'client.ready_utility_reinjection_completed',
{
document_epoch: expectedDocumentEpoch,
},
{ force: true },
);
return true;
}
/**
* Installs the message/call/chat event bridge exactly once in a document.
* The authentication listeners in `_wwjsListeners` are necessary for
* reconnect detection, but they are not the full SDK event bridge.
* @private
*/
async _ensureFullEventBridgeForDocument(
expectedDocumentEpoch,
expectedNavigationSequence = this._mainFrameNavigationSequence,
) {
const runtimeEpoch = this._connectionRuntimeEpoch;
const page = this.pupPage;
if (!Number.isFinite(expectedDocumentEpoch) || !page) {
const error = new Error(
'wwebjs_event_bridge_document_epoch_invalid',
);
error.code = error.message;
throw error;
}
const runtimeIsCurrent = () =>
runtimeEpoch === this._connectionRuntimeEpoch &&
expectedDocumentEpoch === this._connectionDocumentEpoch &&
expectedNavigationSequence === this._mainFrameNavigationSequence &&
page === this.pupPage &&
page?.isClosed?.() !== true &&
!CONNECTION_STATUS_TERMINAL.has(this._connectionStatus.status);
const inspectEventBridge = () => {
const documentEpoch = window.performance?.timeOrigin;
return {
documentEpoch,
fullEventBridgeReady:
window._wwjsFullEventBridgeDocumentEpoch === documentEpoch,
};
};
const assertCurrentDocument = (snapshot) => {
if (
runtimeIsCurrent() &&
Number.isFinite(snapshot?.documentEpoch) &&
snapshot.documentEpoch === expectedDocumentEpoch
) {
return;
}
const navigationSuperseded =
page === this.pupPage &&
page?.isClosed?.() !== true &&
(runtimeEpoch !== this._connectionRuntimeEpoch ||
expectedNavigationSequence !==
this._mainFrameNavigationSequence);
const error = new Error(
navigationSuperseded
? 'wwebjs_event_bridge_navigation_superseded'
: 'wwebjs_event_bridge_document_replaced',
);
error.code = error.message;
throw error;
};
const currentState = this._eventBridgeAttachState;
if (
currentState &&
currentState.runtimeEpoch === runtimeEpoch &&
currentState.documentEpoch === expectedDocumentEpoch &&
currentState.navigationSequence === expectedNavigationSequence &&
currentState.page === page
) {
await currentState.promise;
return false;
}
const attachState = {
runtimeEpoch,
documentEpoch: expectedDocumentEpoch,
navigationSequence: expectedNavigationSequence,
page,
promise: null,
};
const task = async () => {
let snapshot = await page.evaluate(inspectEventBridge);
assertCurrentDocument(snapshot);
if (snapshot.fullEventBridgeReady) {
this._eventBridgeDocumentEpoch = expectedDocumentEpoch;
return false;
}
this.authStrategy.logNative?.(
'client.event_bridge_attachment_started',
{
runtime_epoch: runtimeEpoch,
document_epoch: expectedDocumentEpoch,
navigation_sequence: expectedNavigationSequence,
},
{ force: true },
);
await this.attachEventListeners(expectedDocumentEpoch);
snapshot = await page.evaluate(inspectEventBridge);
assertCurrentDocument(snapshot);
if (!snapshot.fullEventBridgeReady) {
const error = new Error(
'wwebjs_event_bridge_attachment_incomplete',
);
error.code = error.message;
throw error;
}
this._eventBridgeDocumentEpoch = expectedDocumentEpoch;
this.authStrategy.logNative?.(
'client.event_bridge_attachment_completed',
{
runtime_epoch: runtimeEpoch,
document_epoch: expectedDocumentEpoch,
navigation_sequence: expectedNavigationSequence,
},
{ force: true },
);
return true;
};
attachState.promise = Promise.resolve().then(task);
this._eventBridgeAttachState = attachState;
return attachState.promise;
}
/**
* Restores the stateless SDK utility namespace after WA Web recovers its
* transport without replacing the document. The original event bridge,
* runtime/document epochs and every online proof remain mandatory. A
* single flight prevents CONNECTED and hasSynced callbacks from racing two
* LoadUtils evaluations in the same realm.
* @private
*/
async _recoverWWebJSUtilitiesAfterReconnect(
reason = 'runtime_reconnected',
) {
const runtimeEpoch = this._connectionRuntimeEpoch;
const documentEpoch = this._connectionDocumentEpoch;
const navigationSequence = this._mainFrameNavigationSequence;
const page = this.pupPage;
const currentRecovery = this._postReadyUtilityRecovery;
if (
currentRecovery &&
currentRecovery.runtimeEpoch === runtimeEpoch &&
currentRecovery.documentEpoch === documentEpoch &&
currentRecovery.navigationSequence === navigationSequence &&
currentRecovery.page === page
) {
return currentRecovery.promise;
}
const runtimeIsCurrent = () =>
this._authReadyPublished === true &&
runtimeEpoch === this._connectionRuntimeEpoch &&
documentEpoch === this._connectionDocumentEpoch &&
navigationSequence === this._mainFrameNavigationSequence &&
page === this.pupPage &&
page?.isClosed?.() !== true &&
!CONNECTION_STATUS_TERMINAL.has(this._connectionStatus.status);
if (!Number.isFinite(documentEpoch) || !runtimeIsCurrent()) {
return false;
}
const recovery = {
runtimeEpoch,
documentEpoch,
navigationSequence,
page,
promise: null,
};
const task = async () => {
try {
const reinjected =
await this._ensureWWebJSUtilitiesAfterAuthReady(
documentEpoch,
navigationSequence,
);
if (!runtimeIsCurrent()) return false;
const eventBridgeAttached =
await this._ensureFullEventBridgeForDocument(
documentEpoch,
navigationSequence,
);
if (!runtimeIsCurrent()) return false;
if (reinjected) {
this.authStrategy.logNative?.(
'client.reconnect_utility_recovered',
{
runtime_epoch: runtimeEpoch,
document_epoch: documentEpoch,
navigation_sequence: navigationSequence,
reason,
},
{ force: true },
);
}
if (eventBridgeAttached) {
this.authStrategy.logNative?.(
'client.reconnect_event_bridge_recovered',
{
runtime_epoch: runtimeEpoch,
document_epoch: documentEpoch,
navigation_sequence: navigationSequence,
reason,
},
{ force: true },
);
}
return await this._publishOnlineConnectionStatus(reason);
} catch (error) {
if (!runtimeIsCurrent()) return false;
const errorCode = sanitizeConnectionCode(
error?.code || error?.message,
'wwebjs_reconnect_utility_recovery_failed',
);
this.authStrategy.logNative?.(
'client.reconnect_utility_recovery_failed',
{
runtime_epoch: runtimeEpoch,
document_epoch: documentEpoch,
navigation_sequence: navigationSequence,
reason,
error_code: errorCode,
},
{ force: true },
);
this._connectionProofAttempt += 1;
this._lastHasSynced = false;
this._setConnectionStatus(ConnectionStatus.RECONNECTING, {
authenticated: true,
sessionValid: true,
reason: 'runtime_utility_recovery_failed',
errorCode,
});
return false;
}
};
recovery.promise = Promise.resolve()
.then(task)
.finally(() => {
if (this._postReadyUtilityRecovery === recovery) {
this._postReadyUtilityRecovery = null;
}
});
this._postReadyUtilityRecovery = recovery;
return recovery.promise;
}
/** @private */
_claimAuthReadyFlight(task, documentEpoch = this._connectionDocumentEpoch) {
if (this._authReadyPublished) {
return { owner: false, promise: Promise.resolve(undefined) };
}
if (this._authReadyInFlight) {
if (this._authReadyInFlightDocumentEpoch === documentEpoch) {
return { owner: false, promise: this._authReadyInFlight };
}
// A main-frame replacement must never run a second readiness
// pipeline beside the first one. Both pipelines can otherwise
// schedule an official app-state sync and race their fenced
// generations. Reuse a successful predecessor, or start the new
// document's task only after a navigation-invalidated predecessor
// has rejected. Non-navigation failures remain fail-closed.
const previousFlight = this._authReadyInFlight;
const promise = previousFlight.catch((error) => {
if (!isTransientNavigationError(error)) throw error;
return task();
});
this._authReadyInFlight = promise;
this._authReadyInFlightDocumentEpoch = documentEpoch;
return { owner: true, promise };
}
const promise = Promise.resolve().then(task);
this._authReadyInFlight = promise;
this._authReadyInFlightDocumentEpoch = documentEpoch;
return { owner: true, promise };
}
/**
* Injection logic
* Private function
*/
async inject() {
// Cancel any previous inject still running
if (this._injectAbort) this._injectAbort.abort();
const abort = new AbortController();
this._injectAbort = abort;
try {
// initialize() installs every Client binding on the quiescent
// about:blank target. Keep this fallback for direct inject() and
// custom lifecycle callers; on the normal path it only verifies
// the existing in-process routes and performs no CDP mutation.
await this._prepareClientPageBindings(this.pupPage);
const authTimeout = this.options.authTimeoutMs || 30000;
await this.pupPage
.waitForFunction('window.Debug?.VERSION != undefined', {
timeout: authTimeout,
signal: abort.signal,
})
.catch((err) => {
if (abort.signal.aborted) throw err;
throw 'auth timeout';
});
if (abort.signal.aborted) return;
this._connectionDocumentEpoch = await this.pupPage.evaluate(
() => window.performance?.timeOrigin,
);
if (!Number.isFinite(this._connectionDocumentEpoch)) {
throw new Error('wwebjs_connection_document_epoch_invalid');
}
await this.setDeviceName(
this.options.deviceName,
this.options.browserName,
);
const pairWithPhoneNumber = this.options.pairWithPhoneNumber;
const version = await this.getWWebVersion();
this.assertPinnedWebVersion(version);
let needAuthHandle;
try {
needAuthHandle = await this.pupPage.waitForFunction(
() => {
const state =
window.require?.('WAWebSocketModel')?.Socket?.state;
if (
!state ||
state === 'OPENING' ||
state === 'UNLAUNCHED' ||
state === 'PAIRING'
) {
return false;
}
return {
need:
state === 'UNPAIRED' ||
state === 'UNPAIRED_IDLE',
state,
};
},
{ timeout: authTimeout },
);
} catch (error) {
if (abort.signal.aborted) throw error;
const timeoutMessage = String(error?.message || error || '');
if (
error?.name !== 'TimeoutError' &&
!/^Waiting failed: \d+ms exceeded$/u.test(timeoutMessage)
) {
throw error;
}
const authStateTimeoutError = new Error(
'wwebjs_auth_state_timeout',
{ cause: error },
);
authStateTimeoutError.code = authStateTimeoutError.message;
throw authStateTimeoutError;
}
const needAuthentication = await needAuthHandle.jsonValue();
if (needAuthentication.need) {
const { failed, failureEventPayload, restart } =
await this.authStrategy.onAuthenticationNeeded();
if (failed) {
/**
* Emitted when there has been an error while trying to restore an existing session
* @event Client#auth_failure
* @param {string} message
*/
this._emitConnectionLifecycleEvent(
Events.AUTHENTICATION_FAILURE,
failureEventPayload,
);
await this.destroy();
if (restart) {
// session restore failed so try again but without session to force new authentication
return this.initialize();
}
return;
}
// Register qr/code events
if (pairWithPhoneNumber.phoneNumber) {
this.requestPairingCode(
pairWithPhoneNumber.phoneNumber,
pairWithPhoneNumber.showNotification,
pairWithPhoneNumber.intervalMs,
);
} else {
let qrRetries = 0;
this.on(Events.LOADING_SCREEN, () => {
qrRetries = 0;
});
await exposeFunctionIfAbsent(
this.pupPage,
'onQRChangedEvent',
async (qr) => {
/**
* Emitted when a QR code is received
* @event Client#qr
* @param {string} qr QR Code
*/
this._emitConnectionLifecycleEvent(
Events.QR_RECEIVED,
qr,
);
if (this.options.qrMaxRetries > 0) {
qrRetries++;
if (qrRetries > this.options.qrMaxRetries) {
this._emitConnectionLifecycleEvent(
Events.DISCONNECTED,
'Max qrcode retries reached',
);
await this.destroy();
}
}
},
);
await this.pupPage.evaluate(async () => {
const registrationInfo = await window
.require('WAWebSignalStoreApi')
.waSignalStore.getRegistrationInfo();
const noiseKeyPair = await window
.require('WAWebUserPrefsInfoStore')
.waNoiseInfo.get();
const staticKeyB64 = window
.require('WABase64')
.encodeB64(noiseKeyPair.staticKeyPair.pubKey);
const identityKeyB64 = window
.require('WABase64')
.encodeB64(registrationInfo.identityKeyPair.pubKey);
const advSecretKey = await window
.require('WAWebUserPrefsMultiDevice')
.getADVSecretKey();
const platform = window.require(
'WAWebCompanionRegClientUtils',
).DEVICE_PLATFORM;
const getQR = (ref) =>
ref +
',' +
staticKeyB64 +
',' +
identityKeyB64 +
',' +
advSecretKey +
',' +
platform;
const onRefChange = (_, ref) => {
if (ref == null) return;
window.onQRChangedEvent(getQR(ref));
};
window.onQRChangedEvent(
getQR(window.require('WAWebConnModel').Conn.ref),
); // initial qr
window
.require('WAWebConnModel')
.Conn.on('change:ref', onRefChange); // future QR changes
// Remove QR listener once authentication succeeds
window
.require('WAWebSocketModel')
.Socket.on('change:hasSynced', () => {
window
.require('WAWebConnModel')
.Conn.off('change:ref', onRefChange);
});
});
}
}
await exposeFunctionIfAbsent(
this.pupPage,
'onAuthAppStateChangedEvent',
async (state) => {
if (
state == 'UNPAIRED_IDLE' &&
!pairWithPhoneNumber.phoneNumber
) {
await this.refreshQR();
}
},
);
const handleAppStateHasSyncedEvent = async (observation) => {
if (
!observation ||
observation.documentEpoch !== this._connectionDocumentEpoch
) {
return;
}
this._lastWaState = observation.state || null;
if (observation.hasSynced !== true) {
this._connectionProofAttempt += 1;
this._lastHasSynced = false;
if (
!CONNECTION_STATUS_TERMINAL.has(
this._connectionStatus.status,
) &&
this._stagedPublicEvents?.active !== true
) {
this._setConnectionStatus(
this._authReadyPublished
? ConnectionStatus.RECONNECTING
: ConnectionStatus.CONNECTING,
{
reason: 'app_state_not_synced',
authenticated:
this._authReadyPublished === true,
sessionValid: this._authReadyPublished
? true
: null,
},
);
}
return;
}
this._lastHasSynced = true;
if (this._authReadyPublished) {
await this._recoverWWebJSUtilitiesAfterReconnect(
'app_state_synced',
);
return;
}
const gatePublicEvents =
this.authStrategy.shouldDelayPublicEventsUntilReady?.() ===
true;
const completeAuthentication = async () => {
if (gatePublicEvents) this._beginPublicEventGate();
const authEventPayload =
await this.authStrategy.getAuthEventPayload();
if (!gatePublicEvents) {
this._emitAuthenticatedOnce(authEventPayload);
}
const injected = await this.pupPage.evaluate(async () => {
return typeof window.WWebJS !== 'undefined';
});
if (!injected) {
if (
this.options.webVersionCache.type === 'local' &&
this.currentIndexHtml
) {
const { type: webCacheType, ...webCacheOptions } =
this.options.webVersionCache;
const webCache = WebCacheFactory.createWebCache(
webCacheType,
webCacheOptions,
);
await webCache.persist(
this.currentIndexHtml,
version,
);
}
// Load util functions (serializers, helper functions)
await this.pupPage.evaluate(LoadUtils);
await this.pupPage
.waitForFunction(
'typeof window.WWebJS !== "undefined"',
{ timeout: 30000 },
)
.catch(() => {
throw 'ready timeout';
});
/**
* Current connection information
* @type {ClientInfo}
*/
this.info = new ClientInfo(
this,
await this.pupPage.evaluate(() => {
return {
...window
.require('WAWebConnModel')
.Conn.serialize(),
wid:
window
.require('WAWebUserPrefsMeUser')
.getMaybeMePnUser() ||
window
.require('WAWebUserPrefsMeUser')
.getMaybeMeLidUser(),
};
}),
);
this.interface = new InterfaceController(this);
await this._ensureFullEventBridgeForDocument(
authenticationDocumentEpoch,
authenticationNavigationSequence,
);
}
// RemoteAuth only resolves after its first durable
// checkpoint and, for handoffs, fenced promotion.
await this.authStrategy.afterAuthReady();
// A connected profile checkpoint briefly freezes the
// renderer. Chromium normally preserves the injected
// utility namespace, but publishing READY without it
// leaves the SDK unable to send or serialize messages.
// Reinstall only the stateless utility layer, and only
// when the original document and browser event bridge
// are still present. Navigation remains fail-closed and
// is handled by the serialized successor injection.
await this._ensureWWebJSUtilitiesAfterAuthReady(
authenticationDocumentEpoch,
authenticationNavigationSequence,
);
await this._ensureFullEventBridgeForDocument(
authenticationDocumentEpoch,
authenticationNavigationSequence,
);
if (gatePublicEvents) this._releasePublicEventGate();
const online =
await this._waitForOnlineConnectionStatus('ready');
const readinessProofAttempt = this._connectionProofAttempt;
const readinessConnectionStatusSequence =
this._connectionStatus.sequence;
const readinessOnlineProofDiagnostic =
this._lastOnlineProofDiagnostic;
const readinessRuntimeCurrent =
online === true &&
(await this._revalidateAuthenticationReadyRuntime({
signal: abort.signal,
browser: authenticationBrowser,
page: authenticationPage,
runtimeEpoch: authenticationRuntimeEpoch,
documentEpoch: authenticationDocumentEpoch,
navigationSequence:
authenticationNavigationSequence,
proofAttempt: readinessProofAttempt,
connectionStatusSequence:
readinessConnectionStatusSequence,
onlineProofDiagnostic:
readinessOnlineProofDiagnostic,
}));
const navigationSuperseded =
abort.signal.aborted ||
authenticationBrowser !== this.pupBrowser ||
authenticationPage !== this.pupPage ||
authenticationDocumentEpoch !==
this._connectionDocumentEpoch ||
authenticationNavigationSequence !==
this._mainFrameNavigationSequence ||
(online === true && !readinessRuntimeCurrent);
if (
navigationSuperseded ||
!online ||
!readinessRuntimeCurrent
) {
const error = new Error(
navigationSuperseded
? 'wwebjs_online_readiness_navigation_superseded'
: 'wwebjs_online_readiness_proof_failed',
);
error.code = error.message;
throw error;
}
this._authReadyPublished = true;
return authEventPayload;
};
const authenticationBrowser = this.pupBrowser;
const authenticationPage = this.pupPage;
const authenticationRuntimeEpoch = this._connectionRuntimeEpoch;
const authenticationDocumentEpoch =
this._connectionDocumentEpoch;
const authenticationNavigationSequence =
this._mainFrameNavigationSequence;
const { owner, promise: flight } = this._claimAuthReadyFlight(
completeAuthentication,
authenticationDocumentEpoch,
);
if (!owner) {
await flight;
return;
}
let authEventPayload;
let publicationSuperseded;
try {
authEventPayload = await flight;
} catch (error) {
const navigationSuccessorObserved =
await this._waitForMainFrameNavigationSuccessor({
error,
signal: abort.signal,
browser: authenticationBrowser,
page: authenticationPage,
runtimeEpoch: authenticationRuntimeEpoch,
navigationSequence:
authenticationNavigationSequence,
});
const navigationSupersededAuthentication =
isTransientNavigationError(error) &&
(navigationSuccessorObserved ||
abort.signal.aborted ||
authenticationRuntimeEpoch !==
this._connectionRuntimeEpoch ||
authenticationDocumentEpoch !==
this._connectionDocumentEpoch ||
authenticationNavigationSequence !==
this._mainFrameNavigationSequence ||
(this._authReadyInFlight !== flight &&
this._authReadyInFlightDocumentEpoch !==
authenticationDocumentEpoch));
if (navigationSupersededAuthentication) {
this.authStrategy.logNative?.(
'client.authentication_navigation_superseded',
{
navigation_sequence:
authenticationNavigationSequence,
current_navigation_sequence:
this._mainFrameNavigationSequence,
document_epoch: authenticationDocumentEpoch,
current_document_epoch:
this._connectionDocumentEpoch,
},
{ force: true },
);
return;
}
let finalizedFailure = error;
try {
await this._finalizeInitializationFailure(error);
} catch (failure) {
finalizedFailure = failure;
}
const errorCode = /^wwebjs_[a-z0-9_]+$/u.test(
error?.code || error?.message || '',
)
? error.code || error.message
: 'wwebjs_post_injection_initialization_failed';
try {
this._syncConnectionStatusFromEvent(
Events.AUTHENTICATION_FAILURE,
[errorCode],
);
super.emit(Events.AUTHENTICATION_FAILURE, errorCode);
} catch (listenerError) {
finalizedFailure = new AggregateError(
[finalizedFailure, listenerError],
'wwebjs_auth_failure_listener_failed',
{ cause: error },
);
}
throw finalizedFailure;
} finally {
publicationSuperseded = Boolean(
this._authReadyInFlight !== flight &&
(authenticationDocumentEpoch !==
this._connectionDocumentEpoch ||
authenticationNavigationSequence !==
this._mainFrameNavigationSequence),
);
if (this._authReadyInFlight === flight) {
this._authReadyInFlight = null;
this._authReadyInFlightDocumentEpoch = null;
}
}
// A newer document owns the serialized successor flight and
// publishes the shared successful result exactly once.
if (publicationSuperseded) {
this.authStrategy.logNative?.(
'client.authentication_publication_superseded',
{
navigation_sequence:
authenticationNavigationSequence,
current_navigation_sequence:
this._mainFrameNavigationSequence,
document_epoch: authenticationDocumentEpoch,
current_document_epoch:
this._connectionDocumentEpoch,
},
{ force: true },
);
return;
}
// Public listener failures occur after durable promotion
// and must never turn into a session rollback.
if (gatePublicEvents) {
this._publishAuthenticationReady(authEventPayload);
} else {
this._emitConnectionLifecycleEvent(Events.READY);
}
};
await exposeFunctionIfAbsent(
this.pupPage,
'onAppStateHasSyncedEvent',
handleAppStateHasSyncedEvent,
);
let lastPercent = null;
await exposeFunctionIfAbsent(
this.pupPage,
'onOfflineProgressUpdateEvent',
async (percent) => {
if (lastPercent !== percent) {
lastPercent = percent;
this.emit(Events.LOADING_SCREEN, percent, 'WhatsApp'); // Message is hardcoded as "WhatsApp" for now
}
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onLogoutEvent',
async () => {
this.lastLoggedOut = true;
await this.pupPage
.waitForNavigation({ waitUntil: 'load', timeout: 5000 })
.catch((_) => _);
},
);
const initialConnectionObservation = await this.pupPage.evaluate(
() => {
const Socket = window.require('WAWebSocketModel').Socket;
const Cmd = window.require('WAWebCmd').Cmd;
let connectionStateSequence = 0;
const connectionObservation = () => ({
state: String(Socket.state || 'unknown'),
hasSynced: Socket.hasSynced === true,
documentEpoch: window.performance?.timeOrigin,
stateSequence: ++connectionStateSequence,
});
const listeners = [
[
Socket,
'change:state',
(_AppState, state) => {
window.onAuthAppStateChangedEvent(state);
},
],
[
Socket,
'change:hasSynced',
() => {
window.onAppStateHasSyncedEvent(
connectionObservation(),
);
},
],
[
Cmd,
'offline_progress_update_from_bridge',
() => {
window.onOfflineProgressUpdateEvent(
window
.require('WAWebOfflineHandler')
.OfflineMessageHandler.getOfflineDeliveryProgress(),
);
},
],
[
Cmd,
'logout',
async () => {
await window.onLogoutEvent();
},
],
[
Cmd,
'logout_from_bridge',
async () => {
await window.onLogoutEvent();
},
],
];
// Clean up old listeners to prevent accumulation on re-inject
if (window._wwjsListeners) {
for (const [
obj,
event,
handler,
] of window._wwjsListeners) {
obj.off(event, handler);
}
}
for (const [obj, event, handler] of listeners) {
obj.on(event, handler);
}
window._wwjsListeners = listeners;
// Atomic hasSynced check in the same synchronous block as listener registration.
// If hasSynced is already true, Backbone won't fire change:hasSynced (no transition).
// If hasSynced is false, the listener above will catch the future transition.
return Socket.hasSynced === true
? connectionObservation()
: null;
},
);
// Restored profiles commonly reach hasSynced before listener
// registration. Run that already-observed transition in the Node
// realm and await its complete auth/checkpoint flight. Dispatching
// it fire-and-forget through the exposed page binding lets inject()
// resolve while afterAuthReady/attachEventListeners are still
// using the page; the caller may then retire Chromium and turn the
// successful restore into a TargetCloseError.
if (initialConnectionObservation) {
await handleAppStateHasSyncedEvent(
initialConnectionObservation,
);
}
} catch (err) {
if (abort.signal.aborted) return; // superseded by newer inject
throw err;
} finally {
if (this._injectAbort === abort) {
this._injectAbort = null;
}
}
}
/**
* Sets up events and requirements, kicks off authentication request
*/
async initialize() {
let /**
* @type {puppeteer.Browser}
*/
browser,
/**
* @type {puppeteer.Page}
*/
page,
initializationRuntimeEpoch,
initializationNavigationSequence;
browser = null;
this._clearFramenavigatedHandler();
this._publicEventsBlocked = false;
this._stagedPublicEvents = null;
this._authReadyInFlight = null;
this._authReadyInFlightDocumentEpoch = null;
this._postReadyUtilityRecovery = null;
this._eventBridgeDocumentEpoch = null;
this._eventBridgeAttachState = null;
this._authReadyPublished = false;
this._logoutRecoveryEpoch += 1;
this._logoutRecoveryInProgress = false;
this._connectionRuntimeEpoch += 1;
this._connectionDocumentEpoch = null;
this._connectionProofAttempt += 1;
this._lastOnlineProofDiagnostic = null;
this._onlineReadinessWaits?.clear?.();
this._onlineReadinessWaitActive = false;
this._authenticatedPublished = false;
this._lastWaState = null;
this._lastHasSynced = false;
this._setConnectionStatus(ConnectionStatus.RESTORING, {
reason: 'initialize',
allowTerminalRecovery: true,
});
try {
await this.authStrategy.beforeBrowserInitialized();
if (
this.authStrategy.shouldDelayPublicEventsUntilReady?.() === true
) {
this._beginPublicEventGate();
}
const puppeteerOpts = this.options.puppeteer;
if (puppeteerOpts && puppeteerOpts.browser && puppeteerOpts.page) {
// External browser + page provided by caller
browser = puppeteerOpts.browser;
page = puppeteerOpts.page;
} else if (
puppeteerOpts &&
(puppeteerOpts.browserWSEndpoint || puppeteerOpts.browserURL)
) {
browser = await puppeteer.connect(puppeteerOpts);
page = await browser.newPage();
} else {
const browserArgs = [...(puppeteerOpts.args || [])];
if (
this.options.userAgent !== false &&
!browserArgs.find((arg) => arg.includes('--user-agent'))
) {
browserArgs.push(`--user-agent=${this.options.userAgent}`);
}
// navigator.webdriver fix
browserArgs.push(
'--disable-blink-features=AutomationControlled',
);
browser = await puppeteer.launch({
...puppeteerOpts,
args: browserArgs,
});
page = await this._selectOwnedBrowserPage(browser);
}
if (this.options.proxyAuthentication !== undefined) {
await page.authenticate(this.options.proxyAuthentication);
}
if (this.options.userAgent !== false) {
await page.setUserAgent(this.options.userAgent);
}
if (this.options.bypassCSP) await page.setBypassCSP(true);
this.pupBrowser = browser;
this.pupPage = page;
this._registerConnectionRuntimeGuards(browser, page);
await this.authStrategy.afterBrowserInitialized();
// Page.exposeFunction internally mutates
// Page.addScriptToEvaluateOnNewDocument. Do all of those target
// mutations on about:blank, before WhatsApp navigation and before
// RemoteAuth releases a canonical reconnect. inject() will only
// swap the local handlers behind these stable routes.
await this._prepareClientPageBindings(page);
this.authStrategy.logNative?.(
'client.page_bindings_prepared',
{
binding_count: CLIENT_PAGE_BINDINGS.length,
target_phase: 'pre_navigation',
},
{ force: true },
);
await this.initWebVersionCache();
if (this.options.evalOnNewDoc !== undefined) {
await page.evaluateOnNewDocument(this.options.evalOnNewDoc);
}
await page.goto(WhatsWebURL, {
waitUntil: 'load',
timeout: 0,
referer: 'https://whatsapp.com/',
});
await this.authStrategy.beforeClientInjected?.();
// Register framenavigated BEFORE inject so that if navigation
// interrupts inject, the handler triggers a fresh inject.
this._registerFramenavigatedHandler();
initializationNavigationSequence =
this._mainFrameNavigationSequence;
initializationRuntimeEpoch = this._connectionRuntimeEpoch;
await this.inject();
} catch (error) {
const navigationSuccessorObserved =
Number.isFinite(initializationNavigationSequence) &&
(await this._waitForMainFrameNavigationSuccessor({
error,
browser,
page,
runtimeEpoch: initializationRuntimeEpoch,
navigationSequence: initializationNavigationSequence,
}));
const navigationRecoveryOwnsFailure =
isTransientNavigationError(error) &&
page === this.pupPage &&
browser === this.pupBrowser &&
page?.isClosed?.() !== true &&
(navigationSuccessorObserved ||
initializationRuntimeEpoch !==
this._connectionRuntimeEpoch ||
initializationNavigationSequence !==
this._mainFrameNavigationSequence);
if (navigationRecoveryOwnsFailure) {
this.authStrategy.logNative?.(
'client.initialization_navigation_recovery_joined',
{
navigation_sequence: initializationNavigationSequence,
current_navigation_sequence:
this._mainFrameNavigationSequence,
runtime_epoch: this._connectionRuntimeEpoch,
},
{ force: true },
);
await this._framenavigatedTail;
if (
page !== this.pupPage ||
browser !== this.pupBrowser ||
page?.isClosed?.() === true ||
CONNECTION_STATUS_TERMINAL.has(
this._connectionStatus.status,
)
) {
await this._finalizeInitializationFailure(error, browser);
}
return;
}
await this._finalizeInitializationFailure(error, browser);
}
}
async _handleMainFrameNavigation(navigation) {
if (
navigation.page !== this.pupPage ||
navigation.runtimeEpoch !== this._connectionRuntimeEpoch ||
(navigation.sequence !== undefined &&
navigation.sequence !== this._mainFrameNavigationSequence) ||
navigation.page?.isClosed?.() === true
) {
return;
}
const isLogout =
navigation.url.includes('post_logout=1') ||
navigation.logoutSignaled;
if (
isLogout &&
navigation.logoutRecoveryEpoch !== this._logoutRecoveryEpoch
) {
return;
}
if (isLogout) {
this._logoutRecoveryEpoch += 1;
this._logoutRecoveryInProgress = true;
this.lastLoggedOut = false;
const preserveInitialPairing = Boolean(
this._authReadyPublished !== true &&
this.authStrategy.isInitialPairingPending?.() === true,
);
if (preserveInitialPairing) {
if (this._injectAbort) this._injectAbort.abort();
this._setConnectionStatus(ConnectionStatus.RESTORING, {
reason: 'pairing_restart',
authenticated: false,
sessionValid: null,
qrAvailable: false,
allowTerminalRecovery: true,
});
this.authStrategy.logNative?.(
'client.initial_pairing_navigation_restart',
{
navigation_reason: navigation.url.includes(
'post_logout=1',
)
? 'post_logout'
: 'logout_callback',
},
{ force: true },
);
} else {
this._emitConnectionLifecycleEvent(
Events.DISCONNECTED,
'LOGOUT',
);
}
try {
await this.authStrategy.logout({ preserveInitialPairing });
await this.authStrategy.beforeBrowserInitialized();
await this.authStrategy.afterBrowserInitialized();
await this.pupPage.goto(WhatsWebURL, {
waitUntil: 'load',
timeout: 0,
referer: 'https://whatsapp.com/',
});
await this.authStrategy.beforeClientInjected?.();
await this.inject();
} finally {
this._logoutRecoveryInProgress = false;
}
return;
}
const documentState = await this.pupPage.evaluate(() => {
const documentEpoch = window.performance?.timeOrigin;
return {
documentEpoch,
storeAvailable: typeof window.WWebJS !== 'undefined',
authEventBridgeReady:
Array.isArray(window._wwjsListeners) &&
window._wwjsListeners.length > 0,
fullEventBridgeReady:
window._wwjsFullEventBridgeDocumentEpoch === documentEpoch,
};
});
if (!Number.isFinite(documentState?.documentEpoch)) {
const error = new Error('wwebjs_navigation_document_epoch_invalid');
error.code = error.message;
throw error;
}
const documentReplaced =
documentState.documentEpoch !== this._connectionDocumentEpoch;
const bridgeRecoveryRequired =
documentReplaced ||
documentState.authEventBridgeReady !== true ||
documentState.fullEventBridgeReady !== true;
// WWebJS can be recreated by the application before our document-bound
// listeners. A post-READY replacement therefore performs inject's
// reconnect branch even when the utility namespace already exists.
// That branch never republishes AUTHENTICATED or READY.
if (
!documentState.storeAvailable ||
this._authReadyPublished !== true ||
bridgeRecoveryRequired
) {
if (this._authReadyPublished === true) {
this._connectionProofAttempt += 1;
this._lastHasSynced = false;
this._postReadyUtilityRecovery = null;
if (documentReplaced) {
this._eventBridgeDocumentEpoch = null;
}
this._setConnectionStatus(ConnectionStatus.RECONNECTING, {
authenticated: true,
sessionValid: true,
reason: documentReplaced
? 'main_frame_document_replaced'
: 'event_bridge_recovery',
});
}
await this.inject();
}
}
async _handleMainFrameNavigationFailure(error, navigation) {
const errorCode = sanitizeConnectionCode(
error?.code || error?.message,
'wwebjs_navigation_recovery_failed',
);
let superseded = Boolean(
navigation &&
(navigation.page !== this.pupPage ||
(navigation.browser !== undefined &&
navigation.browser !== this.pupBrowser) ||
navigation.runtimeEpoch !== this._connectionRuntimeEpoch ||
(navigation.sequence !== undefined &&
navigation.sequence !== this._mainFrameNavigationSequence)),
);
if (!superseded && navigation) {
superseded = await this._waitForMainFrameNavigationSuccessor({
error,
browser: navigation.browser ?? this.pupBrowser,
page: navigation.page,
runtimeEpoch: navigation.runtimeEpoch,
navigationSequence: navigation.sequence,
});
}
if (superseded) {
this.authStrategy.logNative?.(
'client.navigation_recovery_superseded',
{
error_code: errorCode,
navigation_sequence: navigation.sequence,
current_navigation_sequence:
this._mainFrameNavigationSequence,
runtime_epoch: navigation.runtimeEpoch,
},
{ force: true },
);
return;
}
this.authStrategy.logNative?.(
'client.navigation_recovery_failed',
{ error_code: errorCode },
{ force: true },
);
try {
await this._finalizeInitializationFailure(error);
} catch (finalizedError) {
this.authStrategy.logNative?.(
'client.navigation_recovery_finalized',
{
error_code: sanitizeConnectionCode(
finalizedError?.code || finalizedError?.message,
errorCode,
),
},
{ force: true },
);
}
}
_clearFramenavigatedHandler() {
const registration = this._framenavigatedRegistration;
if (registration) {
try {
registration.page?.off?.(
'framenavigated',
registration.handler,
);
} catch {
// The stale target may already be gone. The identity guards
// in the handler still make any late delivery a no-op.
}
}
this._framenavigatedRegistration = null;
this._framenavigatedRegistered = false;
}
_registerFramenavigatedHandler() {
const registeredPage = this.pupPage;
const registeredBrowser = this.pupBrowser;
const registeredRuntimeEpoch = this._connectionRuntimeEpoch;
const currentRegistration = this._framenavigatedRegistration;
if (
currentRegistration?.page === registeredPage &&
currentRegistration.browser === registeredBrowser &&
currentRegistration.runtimeEpoch === registeredRuntimeEpoch
) {
return;
}
this._clearFramenavigatedHandler();
const handler = (frame) => {
if (
registeredPage !== this.pupPage ||
registeredBrowser !== this.pupBrowser ||
registeredRuntimeEpoch !== this._connectionRuntimeEpoch ||
frame.parentFrame() !== null ||
this._logoutRecoveryInProgress
) {
return;
}
try {
if (
getBrowserConnectionState(registeredBrowser) === false ||
registeredPage.isClosed?.() === true
) {
return;
}
} catch {
return;
}
this._mainFrameNavigationSequence += 1;
const navigation = {
browser: registeredBrowser,
page: registeredPage,
runtimeEpoch: registeredRuntimeEpoch,
sequence: this._mainFrameNavigationSequence,
logoutRecoveryEpoch: this._logoutRecoveryEpoch,
logoutSignaled: this.lastLoggedOut,
url: frame.url(),
};
const run = () => this._handleMainFrameNavigation(navigation);
this._framenavigatedTail = this._framenavigatedTail
.then(run, run)
.catch((error) => {
return this._handleMainFrameNavigationFailure(
error,
navigation,
);
});
};
registeredPage.on('framenavigated', handler);
this._framenavigatedRegistration = {
browser: registeredBrowser,
page: registeredPage,
runtimeEpoch: registeredRuntimeEpoch,
handler,
};
this._framenavigatedRegistered = true;
}
/**
* Request authentication via pairing code instead of QR code
* @param {string} phoneNumber - Phone number in international, symbol-free format (e.g. 12025550108 for US, 551155501234 for Brazil)
* @param {boolean} [showNotification = true] - Show notification to pair on phone number
* @param {number} [intervalMs = 180000] - The interval in milliseconds on how frequent to generate pairing code (WhatsApp default to 3 minutes)
* @returns {Promise<string>} - Returns a pairing code in format "ABCDEFGH"
*/
async requestPairingCode(
phoneNumber,
showNotification = true,
intervalMs = 180000,
) {
await exposeFunctionIfAbsent(
this.pupPage,
'onCodeReceivedEvent',
async (code) => {
this._emitConnectionLifecycleEvent(Events.CODE_RECEIVED, code);
return code;
},
);
return await this.pupPage.evaluate(
async (phoneNumber, showNotification, intervalMs) => {
const getCode = async () => {
window
.require('WAWebAltDeviceLinkingApi')
.setPairingType('ALT_DEVICE_LINKING');
await window
.require('WAWebAltDeviceLinkingApi')
.initializeAltDeviceLinking();
return window
.require('WAWebAltDeviceLinkingApi')
.startAltLinkingFlow(phoneNumber, showNotification);
};
if (window.codeInterval) {
clearInterval(window.codeInterval); // remove existing interval
}
window.codeInterval = setInterval(async () => {
const state =
window.require('WAWebSocketModel').Socket.state;
if (state != 'UNPAIRED' && state != 'UNPAIRED_IDLE') {
clearInterval(window.codeInterval);
return;
}
window.onCodeReceivedEvent(await getCode());
}, intervalMs);
return window.onCodeReceivedEvent(await getCode());
},
phoneNumber,
showNotification,
intervalMs,
);
}
/**
* Cancels an active pairing code session and returns to QR code mode
*/
async cancelPairingCode() {
await this.pupPage.evaluate(async () => {
if (window.codeInterval) {
clearInterval(window.codeInterval);
window.codeInterval = undefined;
}
window.require('WAWebLaunchSocketUtils').refreshQR();
await window
.require('WAWebAltDeviceLinkingApi')
.initializeQRLinking();
});
}
/**
* Attach event listeners to WA Web
* Private function
* @param {number} [expectedDocumentEpoch] document identity fence
*/
async attachEventListeners(
expectedDocumentEpoch = this._connectionDocumentEpoch,
) {
const chatStateDebugEnabled =
process.env.WWEBJS_CHAT_STATE_DEBUG === 'true';
const chatStateDebugChatId =
process.env.WWEBJS_CHAT_STATE_DEBUG_CHAT_ID || null;
const chatStateForceAvailable =
process.env.WWEBJS_CHAT_STATE_FORCE_AVAILABLE !== 'false';
const emitHistoricalEvents =
this.options.emitHistoricalEvents !== false;
const defaultCiphertextResolutionDelaysMs = [
2000, 5000, 10000, 20000, 30000, 45000, 60000, 90000, 120000,
];
const normalizeCiphertextResolutionDelay = (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.round(parsed);
};
const parseCiphertextResolutionDelays = (values) => {
if (!Array.isArray(values)) return [];
const normalized = values
.map(normalizeCiphertextResolutionDelay)
.filter((value) => value != null);
if (normalized.length === 0) return [];
return [...new Set(normalized)].sort((a, b) => a - b);
};
const envCiphertextResolutionDelays =
process.env.WWEBJS_CIPHERTEXT_RESOLUTION_DELAYS;
const envCiphertextResolutionDelaysMs = envCiphertextResolutionDelays
? parseCiphertextResolutionDelays(
envCiphertextResolutionDelays
.split(',')
.map((value) => value.trim())
.filter(Boolean),
)
: [];
const optionCiphertextResolutionDelaysMs =
parseCiphertextResolutionDelays(
this.options.ciphertextResolutionDelaysMs,
);
const ciphertextResolutionDelaysMs =
envCiphertextResolutionDelaysMs.length > 0
? envCiphertextResolutionDelaysMs
: optionCiphertextResolutionDelaysMs.length > 0
? optionCiphertextResolutionDelaysMs
: [...defaultCiphertextResolutionDelaysMs];
const resolveCiphertextMessages =
this.options.resolveCiphertextMessages !== false &&
process.env.WWEBJS_RESOLVE_CIPHERTEXT_MESSAGES !== 'false';
const listenerAttachedAtTs = Math.floor(Date.now() / 1000);
const shouldLogChatState = (chatId) =>
!chatStateDebugChatId || chatId === chatStateDebugChatId;
const summarizeChatStateDebugShape = (value) => {
const record =
value && typeof value === 'object' && !Array.isArray(value)
? value
: undefined;
const allowedFieldNames = record
? [
'state',
'isOnline',
'isGroup',
'timestamp',
'deny',
'source',
'stage',
'reason',
'typingUserIds',
'recordingUserIds',
].filter((field) =>
Object.prototype.hasOwnProperty.call(record, field),
)
: [];
return {
payload_type: Array.isArray(value)
? 'array'
: value === null
? 'null'
: typeof value,
field_count: record ? Object.keys(record).length : 0,
allowed_field_names: allowedFieldNames,
typing_user_count: Array.isArray(record?.typingUserIds)
? record.typingUserIds.length
: 0,
recording_user_count: Array.isArray(record?.recordingUserIds)
? record.recordingUserIds.length
: 0,
};
};
const normalizeTimestampSeconds = (value) => {
const timestamp = Number(value);
if (!Number.isFinite(timestamp) || timestamp <= 0) return null;
return timestamp > 1e12 ? timestamp / 1000 : timestamp;
};
const resolveChatTimestampSeconds = (chat) => {
if (!chat || typeof chat !== 'object') return null;
return (
normalizeTimestampSeconds(chat.timestamp) ??
normalizeTimestampSeconds(chat.t) ??
normalizeTimestampSeconds(chat.clientReceivedTsMillis) ??
null
);
};
const resolveMessageTimestampSeconds = (msg) => {
if (!msg || typeof msg !== 'object') return null;
return (
normalizeTimestampSeconds(msg.timestamp) ??
normalizeTimestampSeconds(msg.t) ??
normalizeTimestampSeconds(msg.senderTimestampMs) ??
normalizeTimestampSeconds(msg.senderTimestamp) ??
normalizeTimestampSeconds(msg.clientReceivedTsMillis) ??
normalizeTimestampSeconds(msg.latestEditSenderTimestampMs) ??
null
);
};
const shouldIgnoreHistoricalEvent = (timestampSeconds) => {
if (emitHistoricalEvents) return false;
const timestamp = normalizeTimestampSeconds(timestampSeconds);
if (!Number.isFinite(timestamp)) return false;
return timestamp < listenerAttachedAtTs;
};
const shouldIgnoreHistoricalNonEssentialEvent = (timestampSeconds) => {
if (emitHistoricalEvents) return false;
const timestamp = normalizeTimestampSeconds(timestampSeconds);
if (!Number.isFinite(timestamp)) return true;
return timestamp < listenerAttachedAtTs;
};
const shouldIgnoreHistoricalMessage = (msg) => {
if (emitHistoricalEvents) return false;
if (!msg || typeof msg !== 'object') return false;
if (
shouldIgnoreHistoricalEvent(resolveMessageTimestampSeconds(msg))
) {
return true;
}
return msg.isNewMsg === false;
};
const shouldIgnoreHistoricalPinEvent = (msg, pinData = null) => {
if (emitHistoricalEvents) return false;
if (shouldIgnoreHistoricalMessage(msg)) return true;
if (!pinData || typeof pinData !== 'object') return false;
const pinTimestamp =
normalizeTimestampSeconds(pinData.timestamp) ??
normalizeTimestampSeconds(pinData.timestampMs) ??
normalizeTimestampSeconds(pinData.senderTimestampMs) ??
null;
if (!Number.isFinite(pinTimestamp)) return false;
return shouldIgnoreHistoricalNonEssentialEvent(pinTimestamp);
};
const messageEventCacheTtlMs = 5 * 60 * 1000;
const messageEventCache = new Map();
const recentMessageData = new Map();
const pruneMessageEventCache = () => {
const now = Date.now();
for (const [key, value] of messageEventCache.entries()) {
if (now - value > messageEventCacheTtlMs) {
messageEventCache.delete(key);
}
}
for (const [key, value] of recentMessageData.entries()) {
if (now - value.capturedAt > messageEventCacheTtlMs) {
recentMessageData.delete(key);
}
}
};
const resolveMessageEventKey = (msg) => {
if (!msg || typeof msg !== 'object') return null;
const idObject =
msg.id && typeof msg.id === 'object' ? msg.id : null;
const serializedId = serializeMessageId(idObject);
if (serializedId) return serializedId;
const fromMe = Boolean(idObject?.fromMe || msg.fromMe);
const chatId =
normalizeWidLike(idObject?.remote) ??
normalizeWidLike(msg.from) ??
normalizeWidLike(msg.to);
const idValue =
idObject?.id ??
msg.id ??
msg.key?.id ??
msg.timestamp ??
msg.t ??
null;
if (!chatId || !idValue) return null;
return `${fromMe}_${chatId}_${idValue}`;
};
const hasMessageEventBeenEmitted = (msg) => {
pruneMessageEventCache();
const key = resolveMessageEventKey(msg);
if (!key) return false;
return messageEventCache.has(key);
};
const markMessageEventAsEmitted = (msg) => {
const key = resolveMessageEventKey(msg);
if (!key) return;
pruneMessageEventCache();
messageEventCache.set(key, Date.now());
};
const mediaUploadedEventCache = new Map();
const pruneMediaUploadedEventCache = () => {
const now = Date.now();
for (const [key, value] of mediaUploadedEventCache.entries()) {
if (now - value > messageEventCacheTtlMs) {
mediaUploadedEventCache.delete(key);
}
}
};
const emitMediaUploadedEvent = (msg) => {
if (shouldIgnoreHistoricalMessage(msg) || !msg?.id?.fromMe) return;
const message = new Message(this, msg);
if (!message.hasMedia) return;
pruneMediaUploadedEventCache();
const key = resolveMessageEventKey(msg);
if (key && mediaUploadedEventCache.has(key)) return;
if (key) mediaUploadedEventCache.set(key, Date.now());
/**
* Emitted when media has been uploaded for a message sent by the client.
* Modern WhatsApp Web builds no longer consistently transition
* `isUnsentMedia`; the first server ACK is an authoritative fallback
* because it is impossible before the encrypted upload is accepted.
* @event Client#media_uploaded
* @param {Message} message The message with media that was uploaded
*/
this.emit(Events.MEDIA_UPLOADED, message);
};
const rememberMessageData = (msg) => {
const key = resolveMessageEventKey(msg);
if (!key) return;
pruneMessageEventCache();
recentMessageData.set(key, {
capturedAt: Date.now(),
data: msg,
});
while (recentMessageData.size > 512) {
recentMessageData.delete(recentMessageData.keys().next().value);
}
};
const takeRememberedMessageData = (id) => {
pruneMessageEventCache();
const key = resolveMessageEventKey({ id });
let matchedKey = key;
let entry = key ? recentMessageData.get(key) : undefined;
if (!entry && id?.id) {
for (const [
candidateKey,
candidateEntry,
] of recentMessageData.entries()) {
if (candidateEntry.data?.id?.id !== id.id) continue;
matchedKey = candidateKey;
entry = candidateEntry;
break;
}
}
if (matchedKey) recentMessageData.delete(matchedKey);
return entry?.data;
};
const normalizeWidLike = (wid) => {
if (wid == null) return null;
if (typeof wid === 'string') return wid;
if (typeof wid === 'object' && wid._serialized) {
return wid._serialized;
}
if (typeof wid.toString === 'function') {
return wid.toString();
}
return null;
};
const toUserJidOrNull = (wid) => {
const normalized = normalizeWidLike(wid);
if (typeof normalized !== 'string') return null;
return normalized.toLowerCase().endsWith('@s.whatsapp.net')
? normalized
: null;
};
const toNonEmptyStringOrNull = (value) => {
if (typeof value !== 'string') return null;
return value.trim() ? value : null;
};
const normalizePinnedMessageState = (value) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (value === 1) return true;
if (value === 2) return false;
if (value === 0) return null;
return value > 0;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase().trim();
if (!normalized) return null;
if (
[
'pin',
'pinned',
'pin_for_all',
'set',
'true',
'pininchat$type.pin_for_all',
'1',
].includes(normalized)
) {
return true;
}
if (
[
'unpin',
'unpinned',
'unpin_for_all',
'remove',
'unset',
'pininchat$type.unpin_for_all',
'false',
'2',
'0',
].includes(normalized)
) {
return false;
}
if (
['unknown_type', 'pininchat$type.unknown_type'].includes(
normalized,
)
) {
return null;
}
const maybeNumber = Number(normalized);
if (Number.isFinite(maybeNumber)) {
if (maybeNumber === 1) return true;
if (maybeNumber === 2) return false;
if (maybeNumber === 0) return null;
return maybeNumber > 0;
}
}
return null;
};
const getMessagePinEventData = (msg, externalPinData = null) => {
if (!msg || typeof msg !== 'object') return null;
const hasPinSignals =
msg.type === 'pin_message' ||
msg.type === 'pinned_message' ||
msg.subtype === 'pin_message' ||
msg.subtype === 'pinned_message' ||
externalPinData != null ||
msg.pinMessageType !== undefined ||
msg.pinParentKey !== undefined ||
msg.pinType !== undefined;
if (!hasPinSignals) return null;
const isPinnedSystemMessage =
msg.type === 'pinned_message' ||
msg.subtype === 'pinned_message';
if (isPinnedSystemMessage) {
const serializedPinParentKey = serializeMessageId(
msg.pinParentKey,
);
const serializedParentMsgKey = serializeMessageId(
msg.parentMsgKey,
);
const hasParentKey =
externalPinData?.parentMessageId ||
serializedPinParentKey ||
msg.pinParentKey?.id ||
msg.pinParentKey ||
serializedParentMsgKey ||
msg.parentMsgKey;
if (!hasParentKey) return null;
}
const rawPinType =
externalPinData?.pinType ??
msg.pinMessageType ??
msg.pinType ??
msg.pinActionType ??
msg.pinAction?.type ??
msg.type ??
msg.subtype ??
null;
const pinType = rawPinType == null ? null : String(rawPinType);
let isPinned = externalPinData?.isPinned;
if (isPinned == null) {
isPinned = normalizePinnedMessageState(rawPinType);
}
if (isPinned == null && msg.type === 'pinned_message') {
isPinned = true;
}
if (
isPinned == null &&
typeof msg.type === 'string' &&
msg.type.toLowerCase() === 'pin_message'
) {
isPinned = true;
}
const parentMessageId =
externalPinData?.parentMessageId ||
serializeMessageId(msg.pinParentKey) ||
msg.pinParentKey?.id ||
msg.pinParentKey ||
serializeMessageId(msg.parentMsgKey) ||
msg.parentMsgKey ||
null;
const senderId =
externalPinData?.senderId || msg.author || msg.from || null;
const chatId =
externalPinData?.chatId || msg.id?.remote || msg.from || null;
return {
pinType,
isPinned,
parentMessageId:
parentMessageId == null ? null : String(parentMessageId),
senderId: senderId == null ? null : String(senderId),
chatId: chatId == null ? null : String(chatId),
messageType: msg.type || null,
};
};
const messagePinEventCache = new Map();
const MESSAGE_PIN_CACHE_TTL_MS = 30000;
const cleanupMessagePinEventCache = (now) => {
for (const [key, value] of messagePinEventCache.entries()) {
if (now - value > MESSAGE_PIN_CACHE_TTL_MS) {
messagePinEventCache.delete(key);
}
}
};
const getMessagePinCacheKey = (msg, pinData) => {
const serializedMessageId =
serializeMessageId(msg?.id) ||
`${Boolean(msg?.id?.fromMe)}_${
normalizeWidLike(msg?.id?.remote) || ''
}_${msg?.id?.id || ''}`;
const actionState =
pinData?.isPinned == null
? pinData?.pinType || ''
: String(pinData.isPinned);
const chatId =
pinData?.chatId ||
normalizeWidLike(msg?.id?.remote) ||
normalizeWidLike(msg?.from) ||
'';
const parentMessageId = pinData?.parentMessageId || '';
if (parentMessageId) {
return ['action', chatId, parentMessageId, actionState].join(
'|',
);
}
return ['message', serializedMessageId, actionState].join('|');
};
const shouldEmitMessagePinEvent = (msg, pinData) => {
const now = Date.now();
cleanupMessagePinEventCache(now);
const key = getMessagePinCacheKey(msg, pinData);
if (!key) return true;
if (messagePinEventCache.has(key)) return false;
messagePinEventCache.set(key, now);
return true;
};
const buildFallbackPinMessageData = (msg, pinData) => {
const chatId =
normalizeWidLike(msg?.id?.remote) ||
normalizeWidLike(msg?.from) ||
pinData?.chatId ||
'';
const remoteJid =
toUserJidOrNull(msg?.id?.remoteJid) ||
toUserJidOrNull(msg?.id?.remote) ||
toUserJidOrNull(chatId) ||
null;
const name = remoteJid
? toNonEmptyStringOrNull(msg?.id?.name)
: null;
const fromMe = Boolean(msg?.id?.fromMe || msg?.fromMe);
const idValue = String(msg?.id?.id || `pin_${Date.now()}`);
const serialized =
serializeMessageId(msg?.id) || `${fromMe}_${chatId}_${idValue}`;
const from =
normalizeWidLike(msg?.from) ||
pinData?.senderId ||
chatId ||
'';
const to = normalizeWidLike(msg?.to) || chatId || '';
const author =
normalizeWidLike(msg?.author) || pinData?.senderId || undefined;
return {
...msg,
id: {
fromMe,
remote: chatId,
remoteJid,
name,
id: idValue,
_serialized: serialized,
},
ack: msg?.ack ?? 0,
body: msg?.body || '',
type: msg?.type || 'pin_message',
t: msg?.t || Math.floor(Date.now() / 1000),
from,
to,
author,
mentionedJidList: msg?.mentionedJidList || [],
groupMentions: msg?.groupMentions || [],
links: msg?.links || [],
pinParentKey:
msg?.pinParentKey || pinData?.parentMessageId || null,
pinMessageType: msg?.pinMessageType ?? pinData?.pinType ?? null,
pinType: msg?.pinType ?? pinData?.pinType ?? null,
};
};
const emitMessagePinEvent = (
msg,
{
source = 'unknown',
pinData: externalPinData = null,
prebuiltMessage = null,
} = {},
) => {
if (shouldIgnoreHistoricalPinEvent(msg, externalPinData)) return;
const messagePinEvent = getMessagePinEventData(
msg,
externalPinData,
);
if (!messagePinEvent) return;
if (shouldIgnoreHistoricalPinEvent(msg, messagePinEvent)) return;
if (!shouldEmitMessagePinEvent(msg, messagePinEvent)) return;
let message = prebuiltMessage;
if (!message) {
try {
message = new Message(this, msg);
} catch {
const fallbackData = buildFallbackPinMessageData(
msg,
messagePinEvent,
);
message = new Message(this, fallbackData);
}
}
/**
* Emitted when a message pin/unpin action is created.
* @event Client#message_pinned
* @param {Message} message The message that contains the pin action
* @param {Object} pinData
* @param {?string} pinData.pinType Raw pin type from WhatsApp model
* @param {?boolean} pinData.isPinned Indicates if it is a pin (`true`) or unpin (`false`) when available
* @param {?string} pinData.parentMessageId Parent message id that was pinned/unpinned
* @param {?string} pinData.senderId Sender/contact responsible for the action
* @param {?string} pinData.chatId Chat id where the action happened
* @param {?string} pinData.messageType Underlying WhatsApp message type
* @param {string} pinData.source Internal source that triggered the event
*/
this.emit(Events.MESSAGE_PINNED, message, {
...messagePinEvent,
source,
});
};
await exposeFunctionIfAbsent(
this.pupPage,
'onAddMessageEvent',
(msg) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
if (msg.type === 'gp2') {
const notification = new GroupNotification(this, msg);
if (
['add', 'invite', 'linked_group_join'].includes(
msg.subtype,
)
) {
/**
* Emitted when a user joins the chat via invite link or is added by an admin.
* @event Client#group_join
* @param {GroupNotification} notification GroupNotification with more information about the action
*/
this.emit(Events.GROUP_JOIN, notification);
} else if (
msg.subtype === 'remove' ||
msg.subtype === 'leave'
) {
/**
* Emitted when a user leaves the chat or is removed by an admin.
* @event Client#group_leave
* @param {GroupNotification} notification GroupNotification with more information about the action
*/
this.emit(Events.GROUP_LEAVE, notification);
} else if (
msg.subtype === 'promote' ||
msg.subtype === 'demote'
) {
/**
* Emitted when a current user is promoted to an admin or demoted to a regular user.
* @event Client#group_admin_changed
* @param {GroupNotification} notification GroupNotification with more information about the action
*/
this.emit(Events.GROUP_ADMIN_CHANGED, notification);
} else if (msg.subtype === 'membership_approval_request') {
/**
* Emitted when some user requested to join the group
* that has the membership approval mode turned on
* @event Client#group_membership_request
* @param {GroupNotification} notification GroupNotification with more information about the action
* @param {string} notification.chatId The group ID the request was made for
* @param {string} notification.author The user ID that made a request
* @param {number} notification.timestamp The timestamp the request was made at
*/
this.emit(
Events.GROUP_MEMBERSHIP_REQUEST,
notification,
);
} else {
/**
* Emitted when group settings are updated, such as subject, description or picture.
* @event Client#group_update
* @param {GroupNotification} notification GroupNotification with more information about the action
*/
this.emit(Events.GROUP_UPDATE, notification);
}
return;
}
if (hasMessageEventBeenEmitted(msg)) return;
markMessageEventAsEmitted(msg);
rememberMessageData(msg);
const message = new Message(this, msg);
emitMessagePinEvent(msg, {
source: 'msg_add',
prebuiltMessage: message,
});
/**
* Emitted when a new message is created, which may include the current user's own messages.
* @event Client#message_create
* @param {Message} message The message that was created
*/
this.emit(Events.MESSAGE_CREATE, message);
if (msg.id.fromMe) return;
/**
* Emitted when a new message is received.
* @event Client#message
* @param {Message} message The message that was received
*/
this.emit(Events.MESSAGE_RECEIVED, message);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onChangeMessageTypeEvent',
(msg) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
if (msg.type === 'revoked') {
const originalKey = msg.protocolMessageKey;
const message = new Message(this, {
...msg,
id: originalKey,
});
const originalData = originalKey
? takeRememberedMessageData(originalKey)
: undefined;
const revoked_msg = originalData
? new Message(this, {
...originalData,
id: originalKey,
})
: undefined;
/**
* Emitted when a message is deleted for everyone in the chat.
* @event Client#message_revoke_everyone
* @param {Message} message The message that was revoked, in its current state. It will not contain the original message's data.
* @param {?Message} revoked_msg The message that was revoked, before it was revoked. It will contain the message's original data.
* Note that due to the way this data is captured, it may be possible that this param will be undefined.
*/
this.emit(
Events.MESSAGE_REVOKED_EVERYONE,
message,
revoked_msg,
);
}
if (
msg.type === 'pin_message' ||
msg.type === 'pinned_message'
) {
emitMessagePinEvent(msg, { source: 'msg_change_type' });
}
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onChangeMessageEvent',
(msg) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
if (msg.type !== 'revoked') rememberMessageData(msg);
/**
* The event notification that is received when one of
* the group participants changes their phone number.
*/
const isParticipant =
msg.type === 'gp2' && msg.subtype === 'modify';
/**
* The event notification that is received when one of
* the contacts changes their phone number.
*/
const isContact =
msg.type === 'notification_template' &&
msg.subtype === 'change_number';
if (isParticipant || isContact) {
/**
* A GroupNotification object does not provide enough
* information about this event, so a Message is used.
* @type {Message}
*/
const message = new Message(this, msg);
const newId = isParticipant ? msg.recipients[0] : msg.to;
const oldId = isParticipant
? msg.author
: msg.templateParams.find((id) => id !== newId);
/**
* Emitted when a contact or a group participant changes their phone number.
* @event Client#contact_changed
* @param {Message} message Message with more information about the event.
* @param {String} oldId The user's id (an old one) who changed their phone number
* and who triggered the notification.
* @param {String} newId The user's new id after the change.
* @param {Boolean} isContact Indicates if a contact or a group participant changed their phone number.
*/
this.emit(
Events.CONTACT_CHANGED,
message,
oldId,
newId,
isContact,
);
}
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onRemoveMessageEvent',
(msg) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
if (!msg.isNewMsg) return;
const message = new Message(this, msg);
/**
* Emitted when a message is deleted by the current user.
* @event Client#message_revoke_me
* @param {Message} message The message that was revoked
*/
this.emit(Events.MESSAGE_REVOKED_ME, message);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onMessageAckEvent',
(msg, ack) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
const message = new Message(this, msg);
/**
* Emitted when an ack event occurrs on message type.
* @event Client#message_ack
* @param {Message} message The message that was affected
* @param {MessageAck} ack The new ACK value
*/
this.emit(Events.MESSAGE_ACK, message, ack);
if (Number(ack) >= 1) emitMediaUploadedEvent(msg);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onChatUnreadCountEvent',
async (data) => {
if (
shouldIgnoreHistoricalEvent(
resolveChatTimestampSeconds(data),
)
) {
return;
}
const chat = await this.getChatById(data.id);
/**
* Emitted when the chat unread count changes
*/
this.emit(Events.UNREAD_COUNT, chat);
// Fallback path for incoming messages that can miss Msg.on('add'),
// such as first-contact conversations synced with unreadCount > 0.
const fallbackMessage = chat?.lastMessage;
const fallbackMessageData = fallbackMessage?._data;
if (!fallbackMessageData) return;
if (fallbackMessage.fromMe || fallbackMessage.isStatus) return;
if (!chat?.unreadCount || chat.unreadCount <= 0) return;
if (shouldIgnoreHistoricalMessage(fallbackMessageData)) return;
if (hasMessageEventBeenEmitted(fallbackMessageData)) return;
markMessageEventAsEmitted(fallbackMessageData);
rememberMessageData(fallbackMessageData);
this.emit(Events.MESSAGE_CREATE, fallbackMessage);
this.emit(Events.MESSAGE_RECEIVED, fallbackMessage);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onMessagePinnedEvent',
(payload) => {
const entries = Array.isArray(payload) ? payload : [payload];
for (const entry of entries) {
if (!entry) continue;
const source = entry.source || 'addon_pin';
const msg =
entry.message && typeof entry.message === 'object'
? entry.message
: entry;
const pinData =
entry.pinData && typeof entry.pinData === 'object'
? entry.pinData
: null;
emitMessagePinEvent(msg, { source, pinData });
}
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onChatStateEvent',
(chatState) => {
if (shouldIgnoreHistoricalEvent(chatState?.timestamp)) return;
if (!chatState?.chatId || !chatState?.state) return;
if (
chatStateDebugEnabled &&
shouldLogChatState(chatState.chatId)
) {
console.log(
'[wwebjs][debug][chat_state]',
summarizeChatStateDebugShape(chatState),
);
}
/**
* Emitted when a chat state changes
* @event Client#chat_state
* @param {object} chatState
* @param {string} chatState.chatId
* @param {?string} chatState.userId
* @param {string} chatState.state
* @param {boolean} chatState.isOnline
* @param {boolean} chatState.isGroup
* @param {Array<string>} chatState.typingUserIds
* @param {Array<string>} chatState.recordingUserIds
* @param {?number} chatState.timestamp
* @param {?boolean} chatState.deny
*/
this.emit(Events.CHAT_STATE, chatState);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onChatStateDebugEvent',
(debugState) => {
if (!chatStateDebugEnabled) return;
const debugChatId = debugState?.chatId || null;
if (!shouldLogChatState(debugChatId)) return;
console.log(
'[wwebjs][debug][chat_state_internal]',
summarizeChatStateDebugShape(debugState),
);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onMessageMediaUploadedEvent',
(msg) => {
emitMediaUploadedEvent(msg);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onAppStateChangedEvent',
async (state) => {
/**
* Emitted when the connection state changes
* @event Client#change_state
* @param {WAState} state the new connection state
*/
this._emitConnectionLifecycleEvent(Events.STATE_CHANGED, state);
if (state === WAState.CONNECTED && this._authReadyPublished) {
await this._recoverWWebJSUtilitiesAfterReconnect(
'socket_reconnected',
);
}
const ACCEPTED_STATES = [
WAState.CONNECTED,
WAState.OPENING,
WAState.PAIRING,
WAState.TIMEOUT,
];
// WAWebLaunchSocketUtils.refreshQR() briefly drives the
// socket through PAIRING -> UNPAIRED before publishing the
// replacement ref. While authentication has not reached its
// durable ready boundary these are QR lifecycle states, not a
// session disconnect, and destroying the client here turns a
// harmless refresh into an endless recreate loop.
if (!this._authReadyPublished) {
ACCEPTED_STATES.push(
WAState.UNPAIRED,
WAState.UNPAIRED_IDLE,
);
}
if (this.options.takeoverOnConflict) {
ACCEPTED_STATES.push(WAState.CONFLICT);
if (state === WAState.CONFLICT) {
setTimeout(() => {
this.pupPage.evaluate(() =>
window
.require('WAWebSocketModel')
.Socket.takeover(),
);
}, this.options.takeoverTimeoutMs);
}
}
if (!ACCEPTED_STATES.includes(state)) {
/**
* Emitted when the client has been disconnected
* @event Client#disconnected
* @param {WAState|"LOGOUT"} reason reason that caused the disconnect
*/
try {
await this.authStrategy.disconnect();
} finally {
this._emitConnectionLifecycleEvent(
Events.DISCONNECTED,
state,
);
await this.destroy();
}
}
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onBatteryStateChangedEvent',
(state) => {
const batteryTimestamp =
state?.timestamp ?? state?.t ?? state?.lastUpdate ?? null;
if (shouldIgnoreHistoricalEvent(batteryTimestamp)) return;
const { battery, plugged } = state;
if (battery === undefined) return;
/**
* Emitted when the battery percentage for the attached device changes. Will not be sent if using multi-device.
* @event Client#change_battery
* @param {object} batteryInfo
* @param {number} batteryInfo.battery - The current battery percentage
* @param {boolean} batteryInfo.plugged - Indicates if the phone is plugged in (true) or not (false)
* @deprecated
*/
this.emit(Events.BATTERY_CHANGED, { battery, plugged });
},
);
await exposeFunctionIfAbsent(this.pupPage, 'onIncomingCall', (call) => {
const callTimestamp =
call?.timestamp ??
call?.t ??
call?.offerTime ??
call?.startTime ??
call?.receivedTime ??
null;
if (shouldIgnoreHistoricalEvent(callTimestamp)) return;
/**
* Emitted when a call is received
* @event Client#incoming_call
* @param {object} call
* @param {number} call.id - Call id
* @param {string} call.peerJid - Who called
* @param {boolean} call.isVideo - if is video
* @param {boolean} call.isGroup - if is group
* @param {boolean} call.canHandleLocally - if we can handle in waweb
* @param {boolean} call.outgoing - if is outgoing
* @param {boolean} call.webClientShouldHandle - If Waweb should handle
* @param {object} call.participants - Participants
*/
const cll = new Call(this, call);
this.emit(Events.INCOMING_CALL, cll);
});
await exposeFunctionIfAbsent(
this.pupPage,
'onReaction',
(reactions) => {
for (const reaction of reactions) {
const reactionTimestamp =
reaction?.timestamp ?? reaction?.reactionTimestamp;
if (
shouldIgnoreHistoricalNonEssentialEvent(
reactionTimestamp,
)
) {
continue;
}
/**
* Emitted when a reaction is sent, received, updated or removed
* @event Client#message_reaction
* @param {object} reaction
* @param {object} reaction.id - Reaction id
* @param {number} reaction.orphan - Orphan
* @param {?string} reaction.orphanReason - Orphan reason
* @param {number} reaction.timestamp - Timestamp
* @param {string} reaction.reaction - Reaction
* @param {boolean} reaction.read - Read
* @param {object} reaction.msgId - Parent message id
* @param {string} reaction.senderId - Sender id
* @param {?number} reaction.ack - Ack
*/
this.emit(
Events.MESSAGE_REACTION,
new Reaction(this, reaction),
);
}
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onRemoveChatEvent',
async (chat) => {
if (
shouldIgnoreHistoricalEvent(
resolveChatTimestampSeconds(chat),
)
) {
return;
}
const _chat = await this.getChatById(chat.id);
/**
* Emitted when a chat is removed
* @event Client#chat_removed
* @param {Chat} chat
*/
this.emit(Events.CHAT_REMOVED, _chat);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onArchiveChatEvent',
async (chat, currState, prevState) => {
if (
shouldIgnoreHistoricalEvent(
resolveChatTimestampSeconds(chat),
)
) {
return;
}
const _chat = await this.getChatById(chat.id);
/**
* Emitted when a chat is archived/unarchived
* @event Client#chat_archived
* @param {Chat} chat
* @param {boolean} currState
* @param {boolean} prevState
*/
this.emit(Events.CHAT_ARCHIVED, _chat, currState, prevState);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onEditMessageEvent',
(msg, newBody, prevBody) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
if (msg.type === 'revoked') {
return;
}
/**
* Emitted when messages are edited
* @event Client#message_edit
* @param {Message} message
* @param {string} newBody
* @param {string} prevBody
*/
this.emit(
Events.MESSAGE_EDIT,
new Message(this, msg),
newBody,
prevBody,
);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onAddMessageCiphertextEvent',
(msg) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
/**
* Emitted when a message is received as ciphertext (not yet decrypted)
* @event Client#message_ciphertext
* @param {Message} message
*/
this.emit(Events.MESSAGE_CIPHERTEXT, new Message(this, msg));
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onCiphertextFailedEvent',
(msg) => {
if (shouldIgnoreHistoricalMessage(msg)) return;
/**
* Emitted when a ciphertext message failed to decrypt after recovery attempt
* @event Client#message_ciphertext_failed
* @param {Message} message
*/
this.emit(
Events.MESSAGE_CIPHERTEXT_FAILED,
new Message(this, msg),
);
},
);
await exposeFunctionIfAbsent(
this.pupPage,
'onPollVoteEvent',
(votes) => {
for (const vote of votes) {
const voteTimestamp =
vote?.timestamp ??
(typeof vote?.senderTimestampMs === 'number'
? vote.senderTimestampMs / 1000
: null) ??
(typeof vote?.t === 'number' ? vote.t / 1000 : null);
if (
shouldIgnoreHistoricalNonEssentialEvent(voteTimestamp)
) {
continue;
}
/**
* Emitted when some poll option is selected or deselected,
* shows a user's current selected option(s) on the poll
* @event Client#vote_update
*/
this.emit(Events.VOTE_UPDATE, new PollVote(this, vote));
}
},
);
await this.pupPage.evaluate(
(
chatStateForceAvailable,
emitHistoricalEvents,
resolveCiphertextMessages,
ciphertextResolutionDelaysMs,
expectedDocumentEpoch,
) => {
const documentEpoch = window.performance?.timeOrigin;
if (
Number.isFinite(expectedDocumentEpoch) &&
documentEpoch !== expectedDocumentEpoch
) {
throw new Error('wwebjs_event_bridge_document_replaced');
}
if (
Number.isFinite(documentEpoch) &&
window._wwjsFullEventBridgeDocumentEpoch === documentEpoch
) {
return;
}
const { Msg, Chat } = window.require('WAWebCollections');
const AppState = window.require('WAWebSocketModel').Socket;
// Enable placeholder message resend (recovery for ciphertext messages)
const gatingUtils = window.require('WAWebSyncGatingUtils');
gatingUtils.isPlaceholderMessageResendEnabled = () => true;
Msg.on('change', (msg) => {
window.onChangeMessageEvent(
window.WWebJS.getMessageModel(msg),
);
});
Msg.on('change:type', (msg) => {
const model = window.WWebJS.getMessageModel(msg);
if (msg.type === 'revoked') {
window.onChangeMessageTypeEvent(model);
}
if (
msg.type === 'pin_message' ||
msg.type === 'pinned_message'
) {
window.onMessagePinnedEvent({
message: model,
source: 'msg_change_type',
});
}
});
Msg.on('change:ack', (msg, ack) => {
window.onMessageAckEvent(
window.WWebJS.getMessageModel(msg),
ack,
);
});
Msg.on('change:isUnsentMedia', (msg, unsent) => {
if (msg.id.fromMe && !unsent)
window.onMessageMediaUploadedEvent(
window.WWebJS.getMessageModel(msg),
);
});
Msg.on('remove', (msg) => {
if (msg.isNewMsg)
window.onRemoveMessageEvent(
window.WWebJS.getMessageModel(msg),
);
});
Msg.on(
'change:body change:caption',
(msg, newBody, prevBody) => {
window.onEditMessageEvent(
window.WWebJS.getMessageModel(msg),
newBody,
prevBody,
);
},
);
AppState.on('change:state', (_AppState, state) => {
window.onAppStateChangedEvent(state);
});
window
.require('WAWebConnModel')
.Conn.on('change:battery', (state) => {
window.onBatteryStateChangedEvent(state);
});
const WAWebCallCollection = window.require(
'WAWebCallCollection',
);
if (
WAWebCallCollection &&
typeof WAWebCallCollection.on === 'function'
) {
const mapKey = Object.keys(WAWebCallCollection).find(
(k) => WAWebCallCollection[k] instanceof Map,
);
const internalCallMap = WAWebCallCollection[mapKey];
const originalMapSet =
internalCallMap.set.bind(internalCallMap);
internalCallMap.set = function (key, value) {
window.onIncomingCall({
id: value.id,
peerJid: value.peerJid,
timestamp: value.timestamp,
t: value.t,
offerTime: value.offerTime,
startTime: value.startTime,
receivedTime: value.receivedTime,
isVideo: value.isVideo,
isGroup: value.isGroup,
canHandleLocally: value.canHandleLocally,
outgoing: value.outgoing,
webClientShouldHandle: value.webClientShouldHandle,
participants: value.participants,
});
return originalMapSet(key, value);
};
}
Chat.on('remove', async (chat) => {
window.onRemoveChatEvent(
await window.WWebJS.getChatModel(chat),
);
});
Chat.on(
'change:archive',
async (chat, currState, prevState) => {
window.onArchiveChatEvent(
await window.WWebJS.getChatModel(chat),
currState,
prevState,
);
},
);
const pendingResend = new Set();
let resendFlush = null;
function requestResend(msg) {
pendingResend.add(msg);
if (resendFlush) return;
resendFlush = setTimeout(() => {
resendFlush = null;
const msgs = [...pendingResend];
pendingResend.clear();
if (msgs.length === 0) return;
window
.require(
'WAWebNonMessageDataRequestPlaceholderMessageResendUtils',
)
.handlePlaceholderMsgsSeen(msgs, true);
}, 5000);
}
Msg.on('add', (msg) => {
const fromMe = Boolean(msg?.id?.fromMe || msg?.fromMe);
const isFreshIncomingMessage =
!fromMe && msg?.recvFresh === true;
// Some first-contact messages can arrive with isNewMsg=false
// while still being fresh incoming events. Historical sync
// should pass when explicitly enabled by client options.
const shouldEmitAddedMessage =
emitHistoricalEvents ||
msg.isNewMsg ||
isFreshIncomingMessage;
if (!shouldEmitAddedMessage) return;
if (msg.type !== 'ciphertext') {
window.onAddMessageEvent(
window.WWebJS.getMessageModel(msg),
);
return;
}
// defer message event until ciphertext is resolved (type changed)
let resolved = false;
let failTimer = null;
const msgKey = window.WWebJS.serializeMessageId(msg?.id);
const emitResolvedMessage = (_msg) => {
if (resolved || !_msg || _msg.type === 'ciphertext') {
return false;
}
resolved = true;
clearTimeout(failTimer);
pendingResend.delete(msg);
pendingResend.delete(_msg);
if (_msg.type === 'revoked') return true;
window.onAddMessageEvent(
window.WWebJS.getMessageModel(_msg),
);
return true;
};
const resolveCiphertextFromStore = async () => {
if (!msgKey) return null;
const fromStore =
Msg.get(msgKey) ||
(await Msg.getMessagesById([msgKey]))
?.messages?.[0] ||
null;
return fromStore;
};
msg.once('change:type', (_msg) =>
emitResolvedMessage(_msg),
);
const ciphertextSubtype =
typeof msg.subtype === 'string'
? msg.subtype.toLowerCase()
: '';
const isUnavailableFanoutCiphertext =
ciphertextSubtype.endsWith('_unavailable_fanout');
if (
resolveCiphertextMessages &&
!isUnavailableFanoutCiphertext &&
Array.isArray(ciphertextResolutionDelaysMs) &&
ciphertextResolutionDelaysMs.length > 0
) {
// Some ciphertext messages can miss the change:type callback; retry by reading from Store.
ciphertextResolutionDelaysMs.forEach((delayMs) => {
setTimeout(async () => {
try {
if (resolved) return;
const resolvedMsg =
await resolveCiphertextFromStore();
if (
!resolvedMsg ||
resolvedMsg.type === 'ciphertext'
) {
return;
}
emitResolvedMessage(resolvedMsg);
} catch {
return;
}
}, delayMs);
});
}
window.onAddMessageCiphertextEvent(
window.WWebJS.getMessageModel(msg),
);
if (isUnavailableFanoutCiphertext) return;
requestResend(msg);
failTimer = setTimeout(() => {
if (msg.type !== 'ciphertext' || resolved) return;
window.onCiphertextFailedEvent(
window.WWebJS.getMessageModel(msg),
);
}, 15000);
});
Chat.on('change:unreadCount', (chat) => {
window.onChatUnreadCountEvent(chat);
});
const req = (moduleName) => {
try {
const mod = window.require(moduleName);
return mod && mod.default ? mod.default : mod;
} catch {
return null;
}
};
const WidFactory = req('WAWebWidFactory');
const PresenceCollection =
req('WAWebPresenceCollection')?.PresenceCollection || null;
const PresenceUtils = req('WAWebPresenceChatAction');
const ContactPresenceBridge = req('WAWebContactPresenceBridge');
const WAWebStreamModel = req('WAWebStreamModel');
const AddonDBTable =
req('WAWebAddonDBTable')?.addonInternalDBTable || null;
const AddonConstants = req('WAWebAddonConstants');
const AddonPinTable =
req('WAWebAddonPinTableMode')?.pinTableMode || null;
const DBCRUDPinInChat = req('WAWebDBCRUDOperationsPinInChat');
const PinMessageProcessor = req('WAWebPinMessageProcessor');
const BackendApi = req('WAWebBackendApi');
const ProtobufsE2E =
req('WAWebProtobufsE2E.pb') ||
req('WAWebProtobufsE2E')?.pb ||
null;
const PinnedMsgUtils = {
...(req('WAWebPinInChatSchema') || {}),
...(req('WAWebSendPinMessageAction') || {}),
};
const normalizeWid = (wid) => {
if (!wid) return null;
if (typeof wid === 'string') return wid;
if (wid._serialized) return wid._serialized;
if (typeof wid.toString === 'function')
return wid.toString();
return null;
};
const normalizeWidArray = (wids = []) => {
if (!Array.isArray(wids)) return [];
return wids.map((wid) => normalizeWid(wid)).filter(Boolean);
};
const pinAddonEventCache = new Map();
const PIN_ADDON_EVENT_CACHE_TTL_MS = 15000;
const cleanupPinAddonEventCache = (now) => {
for (const [key, value] of pinAddonEventCache.entries()) {
if (now - value > PIN_ADDON_EVENT_CACHE_TTL_MS) {
pinAddonEventCache.delete(key);
}
}
};
const parseSerializedMessageId = (serializedMessageId) => {
if (typeof serializedMessageId !== 'string') return null;
try {
const parsed =
req('WAWebMsgKey')?.fromString?.(
serializedMessageId,
);
if (parsed) {
return window.WWebJS.normalizeMessageId(parsed);
}
} catch {
// Fall through to the structural parser below.
}
const parts = serializedMessageId.split('_');
if (parts.length !== 3 && parts.length !== 4) return null;
const participant = parts[3] || null;
return {
fromMe: parts[0] === 'true',
remote: parts[1],
id: parts[2],
...(participant ? { participant } : {}),
_serialized: serializedMessageId,
};
};
const normalizePinTypeToPinnedState = (rawPinType) => {
const pinEnum = ProtobufsE2E?.Message$PinInChatMessage$Type;
if (rawPinType === pinEnum?.PIN_FOR_ALL) return true;
if (rawPinType === pinEnum?.UNPIN_FOR_ALL) return false;
if (rawPinType === pinEnum?.UNKNOWN_TYPE) return null;
if (typeof rawPinType === 'number') {
if (rawPinType === 1) return true;
if (rawPinType === 2) return false;
if (rawPinType === 0) return null;
return rawPinType > 0;
}
if (typeof rawPinType === 'string') {
const normalized = rawPinType.toLowerCase().trim();
if (!normalized) return null;
if (
[
'pin',
'pinned',
'pin_for_all',
'pininchat$type.pin_for_all',
'1',
].includes(normalized)
) {
return true;
}
if (
[
'unpin',
'unpinned',
'unpin_for_all',
'pininchat$type.unpin_for_all',
'2',
].includes(normalized)
) {
return false;
}
if (
[
'unknown_type',
'pininchat$type.unknown_type',
'0',
].includes(normalized)
) {
return null;
}
}
return null;
};
const getAddonTableModeName = (tableMode) => {
if (tableMode == null) return null;
const addonTableMode = AddonConstants?.AddonTableMode;
if (
addonTableMode &&
typeof addonTableMode.getName === 'function'
) {
try {
return addonTableMode.getName(tableMode);
} catch {
return String(tableMode);
}
}
return String(tableMode);
};
const isPinAddonTableMode = (tableMode) => {
const modeName = getAddonTableModeName(tableMode);
if (typeof modeName === 'string') {
const normalized = modeName.toLowerCase();
if (normalized === 'pin' || normalized === 'unified') {
return true;
}
}
const addonTableMode = AddonConstants?.AddonTableMode;
if (addonTableMode) {
return (
tableMode === addonTableMode.Pin ||
tableMode === addonTableMode.Unified
);
}
return false;
};
const isPinAddonData = (entry) => {
if (!entry || typeof entry !== 'object') return false;
const type =
typeof entry.type === 'string'
? entry.type.toLowerCase()
: '';
if (type === 'pin_message' || type === 'pinned_message') {
return true;
}
const kind =
typeof entry.kind === 'string'
? entry.kind.toLowerCase()
: '';
if (kind === 'pininchat' || kind === 'pinnedmessage') {
return true;
}
if (
entry.pinMessageType !== undefined ||
entry.pinType !== undefined
) {
return true;
}
if (
entry.pinParentKey !== undefined ||
entry.parentMsgKey !== undefined
) {
return true;
}
return (
typeof entry.rowType === 'number' && entry.rowType === 1
);
};
const serializePinAddonRow = (pinRow) => {
if (!pinRow || typeof pinRow !== 'object') return null;
if (
typeof pinRow.msgKey === 'string' &&
typeof pinRow.parentMsgKey === 'string'
) {
return pinRow;
}
if (
typeof pinRow.msgKey === 'string' &&
typeof pinRow.targetMsgKey === 'string' &&
pinRow.addonProps
) {
return {
msgKey: pinRow.msgKey,
parentMsgKey: pinRow.targetMsgKey,
chatId: pinRow.arbitraryIndex ?? null,
pinType: pinRow.addonProps.pinMessageType ?? null,
senderTimestampMs:
pinRow.addonProps.pinSenderTimestampMs ?? null,
pinExpiryDuration:
pinRow.addonProps.pinExpiryDuration ?? null,
};
}
if (PinnedMsgUtils?.serializePinInChatMsgData) {
try {
return PinnedMsgUtils.serializePinInChatMsgData(
pinRow,
);
} catch {
return null;
}
}
return null;
};
const buildPinMessageFallback = (serializedPinRow) => {
const parsedMessageId = parseSerializedMessageId(
serializedPinRow.msgKey,
);
const remote =
parsedMessageId?.remote ||
serializedPinRow.chatId ||
'';
const fallbackRemoteJid =
typeof remote === 'string' &&
remote.toLowerCase().endsWith('@s.whatsapp.net')
? remote
: null;
const remoteJid =
typeof window.WWebJS.getPreferredRemoteJid ===
'function'
? window.WWebJS.getPreferredRemoteJid(remote)
: fallbackRemoteJid;
const name =
remoteJid &&
typeof window.WWebJS.getMessageIdNameFromRemoteJid ===
'function'
? window.WWebJS.getMessageIdNameFromRemoteJid(
remoteJid,
)
: null;
const id = parsedMessageId?.id || `pin_${Date.now()}`;
const fromMe = Boolean(parsedMessageId?.fromMe);
const serialized =
parsedMessageId?._serialized ||
`${fromMe}_${remote}_${id}`;
const normalizedMessageId =
typeof window.WWebJS.normalizeMessageId === 'function'
? window.WWebJS.normalizeMessageId({
fromMe,
remote,
remoteJid,
name,
id,
_serialized: serialized,
})
: {
fromMe,
remote,
remoteJid,
name,
id,
_serialized: serialized,
};
return {
id: {
...normalizedMessageId,
},
ack: serializedPinRow.ack ?? 0,
body: '',
type: 'pin_message',
t: serializedPinRow.t ?? Math.floor(Date.now() / 1000),
from: serializedPinRow.sender || remote,
to: remote,
author: serializedPinRow.sender || undefined,
isNewMsg: true,
mentionedJidList: [],
groupMentions: [],
links: [],
pinMessageType: serializedPinRow.pinType ?? null,
pinType: serializedPinRow.pinType ?? null,
pinParentKey: serializedPinRow.parentMsgKey ?? null,
pinExpiryDuration:
serializedPinRow.pinExpiryDuration ?? null,
};
};
const toPinnedMessageBridgePayload = async (pinRow, source) => {
if (!isPinAddonData(pinRow)) return null;
const serializedPinRow = serializePinAddonRow(pinRow);
if (!serializedPinRow) return null;
const msgKey = serializedPinRow.msgKey || null;
const pinTypeRaw =
serializedPinRow.pinType ??
pinRow?.pinMessageType ??
pinRow?.pinType ??
null;
const parentMessageId =
serializedPinRow.parentMsgKey || null;
const dedupeKey = `${msgKey || ''}|${String(pinTypeRaw)}|${
parentMessageId || ''
}`;
const now = Date.now();
cleanupPinAddonEventCache(now);
if (pinAddonEventCache.has(dedupeKey)) return null;
pinAddonEventCache.set(dedupeKey, now);
let messageModel = null;
if (msgKey) {
try {
const fromStore =
Msg.get(msgKey) ||
(await Msg.getMessagesById([msgKey]))
?.messages?.[0];
if (
fromStore &&
typeof fromStore.serialize === 'function'
) {
messageModel =
window.WWebJS.getMessageModel(fromStore);
}
} catch {
messageModel = null;
}
}
if (!messageModel) {
messageModel =
buildPinMessageFallback(serializedPinRow);
}
messageModel.pinMessageType =
messageModel.pinMessageType ?? pinTypeRaw ?? null;
messageModel.pinType =
messageModel.pinType ?? pinTypeRaw ?? null;
messageModel.pinParentKey =
messageModel.pinParentKey ?? parentMessageId ?? null;
messageModel.pinExpiryDuration =
messageModel.pinExpiryDuration ??
serializedPinRow.pinExpiryDuration ??
null;
const senderId =
serializedPinRow.sender ||
normalizeWid(messageModel.author) ||
normalizeWid(messageModel.from) ||
null;
const chatId =
serializedPinRow.chatId ||
normalizeWid(messageModel.id?.remote) ||
normalizeWid(messageModel.from) ||
null;
return {
source,
message: messageModel,
pinData: {
pinType:
pinTypeRaw == null ? null : String(pinTypeRaw),
isPinned: normalizePinTypeToPinnedState(pinTypeRaw),
parentMessageId:
parentMessageId == null
? null
: String(parentMessageId),
senderId:
senderId == null ? null : String(senderId),
chatId: chatId == null ? null : String(chatId),
timestamp:
typeof messageModel.t === 'number'
? messageModel.t
: null,
timestampMs:
serializedPinRow.senderTimestampMs ?? null,
senderTimestampMs:
serializedPinRow.senderTimestampMs ?? null,
messageType: messageModel.type || 'pin_message',
},
};
};
const emitPinnedMessagePayloads = async (pinRows, source) => {
if (!Array.isArray(pinRows) || pinRows.length === 0) return;
const payloads = [];
for (const pinRow of pinRows) {
const payload = await toPinnedMessageBridgePayload(
pinRow,
source,
);
if (payload) payloads.push(payload);
}
if (payloads.length > 0) {
window.onMessagePinnedEvent(payloads);
}
};
const presenceStateCache = new Map();
const presenceSubscribeCache = new Map();
const presenceVerifyRetryState = new Map();
const PRESENCE_RESUBSCRIBE_INTERVAL = 1500;
const PRESENCE_FORCE_RESUBSCRIBE_INTERVAL = 8000;
const PRESENCE_VERIFY_MAX_RETRIES = 3;
const PRESENCE_VERIFY_COOLDOWN_MS = 60000;
const emitChatStateDebug = (debugState) => {
if (typeof window.onChatStateDebugEvent === 'function') {
window.onChatStateDebugEvent(debugState);
}
};
const keepAvailability = () => {
if (!chatStateForceAvailable) return;
try {
const stream = WAWebStreamModel?.Stream;
if (
stream?.available === false &&
typeof stream.markAvailable === 'function'
) {
stream.markAvailable();
}
} catch {
return;
}
if (PresenceUtils?.sendPresenceAvailable) {
try {
const maybePromise =
PresenceUtils.sendPresenceAvailable();
if (
maybePromise &&
typeof maybePromise.catch === 'function'
) {
maybePromise.catch(() => null);
}
} catch {
return;
}
}
};
const getPresenceSignature = (state) =>
[
state.state,
state.userId || '',
state.isOnline ? '1' : '0',
state.typingUserIds.join(','),
state.recordingUserIds.join(','),
state.timestamp == null ? '' : String(state.timestamp),
state.deny == null ? '' : state.deny ? '1' : '0',
state.stale == null ? '' : state.stale ? '1' : '0',
state.isSubscribed == null
? ''
: state.isSubscribed
? '1'
: '0',
state.hasData == null ? '' : state.hasData ? '1' : '0',
].join('|');
const ensurePresenceSubscription = (
presence,
{ force = false, reason = 'unknown' } = {},
) => {
const chatId = normalizeWid(presence?.id);
if (!chatId || !presence?.isUser) return;
const now = Date.now();
const lastSubscribe =
presenceSubscribeCache.get(chatId) || 0;
const subscribeInterval = force
? PRESENCE_FORCE_RESUBSCRIBE_INTERVAL
: PRESENCE_RESUBSCRIBE_INTERVAL;
if (now - lastSubscribe < subscribeInterval) return;
if (
!PresenceCollection ||
typeof PresenceCollection.find !== 'function'
) {
return;
}
presenceSubscribeCache.set(chatId, now);
keepAvailability();
let sourceChat = null;
if (Chat) {
try {
const wid = WidFactory?.createWid
? WidFactory.createWid(chatId)
: chatId;
sourceChat =
Chat.get?.(wid) || Chat.get?.(chatId) || null;
} catch {
sourceChat = Chat.get?.(chatId) || null;
}
if (
!sourceChat &&
typeof Chat.getModelsArray === 'function'
) {
sourceChat =
Chat.getModelsArray().find((chat) => {
const modelId = normalizeWid(chat?.id);
const accountLid = normalizeWid(
chat?.accountLid,
);
return (
modelId === chatId ||
accountLid === chatId
);
}) || null;
}
}
try {
let maybePromise = null;
if (
sourceChat &&
ContactPresenceBridge?.subscribePresence
) {
const targetWid =
sourceChat.accountLid || sourceChat.id;
const tcToken =
typeof sourceChat.getTcToken === 'function'
? sourceChat.getTcToken()
: undefined;
maybePromise =
ContactPresenceBridge.subscribePresence(
targetWid,
tcToken,
);
} else if (sourceChat) {
maybePromise = PresenceCollection.find(
sourceChat.id || chatId,
);
} else {
const chatWid = WidFactory?.createWid
? WidFactory.createWid(chatId)
: chatId;
maybePromise = PresenceCollection.find(chatWid);
}
if (
maybePromise &&
typeof maybePromise.catch === 'function'
) {
maybePromise
.then(() => {
if (
presence.isSubscribed === true &&
presence.stale !== true
) {
presenceVerifyRetryState.delete(chatId);
return;
}
setTimeout(() => {
if (
presence.isSubscribed === false ||
presence.stale === true
) {
const retryState =
presenceVerifyRetryState.get(
chatId,
) || {
retries: 0,
cooldownUntil: 0,
};
const nowTs = Date.now();
if (
retryState.cooldownUntil > nowTs
)
return;
retryState.retries += 1;
if (
retryState.retries >
PRESENCE_VERIFY_MAX_RETRIES
) {
retryState.retries = 0;
retryState.cooldownUntil =
nowTs +
PRESENCE_VERIFY_COOLDOWN_MS;
presenceVerifyRetryState.set(
chatId,
retryState,
);
return;
}
retryState.cooldownUntil = 0;
presenceVerifyRetryState.set(
chatId,
retryState,
);
ensurePresenceSubscription(
presence,
{
force: true,
reason: `${reason}_verify`,
},
);
} else {
presenceVerifyRetryState.delete(
chatId,
);
}
}, 1200);
})
.catch(() => null);
}
} catch {
return;
}
};
const emitChatState = (presence, trigger = 'unknown') => {
const chatId = normalizeWid(presence?.id);
const stateModel = presence?.chatstate;
const state = stateModel?.type;
if (!chatId || !state) return;
const chatState = {
chatId,
userId: normalizeWid(stateModel?.id),
state,
isOnline: Boolean(presence?.isOnline),
isGroup: Boolean(presence?.isGroup),
typingUserIds: normalizeWidArray(
presence?.typingUserIds,
),
recordingUserIds: normalizeWidArray(
presence?.recordingUserIds,
),
timestamp:
typeof stateModel?.t === 'number'
? stateModel.t
: null,
deny:
typeof stateModel?.deny === 'boolean'
? stateModel.deny
: null,
stale:
typeof presence?.stale === 'boolean'
? presence.stale
: null,
isSubscribed:
typeof presence?.isSubscribed === 'boolean'
? presence.isSubscribed
: null,
hasData:
typeof presence?.hasData === 'boolean'
? presence.hasData
: null,
trigger,
};
const signature = getPresenceSignature(chatState);
if (presenceStateCache.get(chatId) === signature) return;
presenceStateCache.set(chatId, signature);
window.onChatStateEvent(chatState);
};
const bindPresenceModel = (presence) => {
if (!presence || presence.__wwebjsChatStateBound) return;
presence.__wwebjsChatStateBound = true;
const emit = (trigger) => () =>
emitChatState(presence, trigger);
if (
presence.chatstate &&
typeof presence.chatstate.on === 'function'
) {
presence.chatstate.on(
'change:type',
emit('chatstate_change_type'),
);
presence.chatstate.on(
'change:t',
emit('chatstate_change_t'),
);
presence.chatstate.on(
'change:deny',
emit('chatstate_change_deny'),
);
}
if (typeof presence.on === 'function') {
presence.on(
'change:typingUserIds change:recordingUserIds change:isOnline change:hasData',
emit('presence_change'),
);
presence.on('change:isSubscribed change:stale', () => {
const chatId = normalizeWid(presence.id);
if (
presence.isSubscribed === true &&
presence.stale !== true
) {
presenceVerifyRetryState.delete(chatId);
} else if (
presence.isSubscribed === false ||
presence.stale === true
) {
ensurePresenceSubscription(presence, {
force: true,
reason: 'flag_change',
});
}
});
}
ensurePresenceSubscription(presence, {
reason: 'bind_init',
});
emit('bind_init')();
};
if (
PresenceCollection &&
typeof PresenceCollection.on === 'function'
) {
if (
typeof PresenceCollection.getModelsArray === 'function'
) {
PresenceCollection.getModelsArray().forEach(
bindPresenceModel,
);
} else if (
typeof PresenceCollection.forEach === 'function'
) {
PresenceCollection.forEach(bindPresenceModel);
}
PresenceCollection.on('add', bindPresenceModel);
}
const stream = WAWebStreamModel?.Stream;
if (
stream &&
typeof stream.on === 'function' &&
!stream.__wwebjsChatStateStreamBound
) {
stream.__wwebjsChatStateStreamBound = true;
stream.on(
'change:available change:info change:mode',
() => {
emitChatStateDebug({
phase: 'stream_change',
available: stream.available,
info: stream.info,
mode: stream.mode,
});
if (stream.available === false) {
keepAvailability();
}
},
);
}
try {
if (
AddonDBTable &&
typeof AddonDBTable.bulkUpsert === 'function' &&
!AddonDBTable.__wwebjsMessagePinHooked
) {
const originalBulkUpsert =
AddonDBTable.bulkUpsert.bind(AddonDBTable);
AddonDBTable.bulkUpsert = (async (...args) => {
const tableMode = args[0];
const rows = Array.isArray(args[1]) ? args[1] : [];
const result = await originalBulkUpsert(...args);
if (
isPinAddonTableMode(tableMode) &&
rows.length > 0
) {
const pinRows = rows.filter(isPinAddonData);
if (pinRows.length > 0) {
await emitPinnedMessagePayloads(
pinRows,
`addon_internal_bulkUpsert_${
getAddonTableModeName(tableMode) ||
'unknown'
}`,
);
}
}
return result;
}).bind(AddonDBTable);
AddonDBTable.__wwebjsMessagePinHooked = true;
}
} catch {
/* empty */
}
try {
if (
BackendApi &&
typeof BackendApi.frontendSendAndReceive ===
'function' &&
!BackendApi.__wwebjsMessagePinHooked
) {
const originalFrontendSendAndReceive =
BackendApi.frontendSendAndReceive.bind(BackendApi);
BackendApi.frontendSendAndReceive = (async (
...args
) => {
const command = args[0];
const payload = args[1];
const result = await originalFrontendSendAndReceive(
...args,
);
if (command === 'updatePinCollection') {
const requestedPinRows = Array.isArray(
payload?.msgs,
)
? payload.msgs.filter(isPinAddonData)
: [];
const removedPinRows = Array.isArray(
payload?.removeMsgs,
)
? payload.removeMsgs.filter(isPinAddonData)
: [];
const returnedPinRows = Array.isArray(result)
? result
: [];
if (requestedPinRows.length > 0) {
await emitPinnedMessagePayloads(
requestedPinRows,
'backend_updatePinCollection_request',
);
}
if (removedPinRows.length > 0) {
await emitPinnedMessagePayloads(
removedPinRows,
'backend_updatePinCollection_remove',
);
}
if (returnedPinRows.length > 0) {
await emitPinnedMessagePayloads(
returnedPinRows,
'backend_updatePinCollection_result',
);
}
}
return result;
}).bind(BackendApi);
BackendApi.__wwebjsMessagePinHooked = true;
}
} catch {
/* empty */
}
try {
if (
AddonPinTable &&
typeof AddonPinTable.bulkUpsert === 'function' &&
!AddonPinTable.__wwebjsMessagePinHooked
) {
const originalPinBulkUpsert =
AddonPinTable.bulkUpsert.bind(AddonPinTable);
AddonPinTable.bulkUpsert = (async (...args) => {
const rows = Array.isArray(args[0]) ? args[0] : [];
const result = await originalPinBulkUpsert(...args);
await emitPinnedMessagePayloads(
rows,
'addon_pin_table_bulkUpsert',
);
return result;
}).bind(AddonPinTable);
AddonPinTable.__wwebjsMessagePinHooked = true;
}
} catch {
/* empty */
}
try {
if (
DBCRUDPinInChat &&
typeof DBCRUDPinInChat.createOrUpdatePinInChat ===
'function' &&
!DBCRUDPinInChat.__wwebjsMessagePinHooked
) {
const originalCreateOrUpdatePinInChat =
DBCRUDPinInChat.createOrUpdatePinInChat.bind(
DBCRUDPinInChat,
);
DBCRUDPinInChat.createOrUpdatePinInChat = (async (
...args
) => {
const rows = Array.isArray(args[1]) ? args[1] : [];
const result =
await originalCreateOrUpdatePinInChat(...args);
await emitPinnedMessagePayloads(
rows,
'dbcrud_pin_createOrUpdate',
);
return result;
}).bind(DBCRUDPinInChat);
DBCRUDPinInChat.__wwebjsMessagePinHooked = true;
}
} catch {
/* empty */
}
try {
if (
PinMessageProcessor &&
typeof PinMessageProcessor.processPinMessages ===
'function' &&
!PinMessageProcessor.__wwebjsMessagePinHooked
) {
const originalProcessPinMessages =
PinMessageProcessor.processPinMessages.bind(
PinMessageProcessor,
);
PinMessageProcessor.processPinMessages = (async (
...args
) => {
const pinRows = Array.isArray(args[1])
? args[1]
: [];
const result = await originalProcessPinMessages(
...args,
);
await emitPinnedMessagePayloads(
pinRows,
'pin_message_processor',
);
return result;
}).bind(PinMessageProcessor);
PinMessageProcessor.__wwebjsMessagePinHooked = true;
}
} catch {
/* empty */
}
window.WWebJS.injectToFunction(
{
module: 'WAWebAddonReactionTableMode',
function: 'reactionTableMode.bulkUpsert',
},
(module, origFunction, ...args) => {
window.onReaction(
args[0].map((reaction) => {
const msgKey = window.WWebJS.normalizeMessageId(
reaction.id,
);
const parentMsgKey =
window.WWebJS.normalizeMessageId(
reaction.reactionParentKey,
);
const timestamp =
reaction.reactionTimestamp / 1000;
const sender = reaction.author ?? reaction.from;
const senderUserJid =
sender?._serialized || null;
return {
...reaction,
msgKey,
parentMsgKey,
senderUserJid,
timestamp,
};
}),
);
return origFunction.apply(module, args);
},
);
window.WWebJS.injectToFunction(
{
module: 'WAWebAddonPollVoteTableMode',
function: 'pollVoteTableMode.bulkUpsert',
},
async (module, origFunction, ...args) => {
const votes = await Promise.all(
args[0].map(async (vote) => {
const msgKey = window.WWebJS.normalizeMessageId(
vote.id,
);
const parentMsgKey =
window.WWebJS.normalizeMessageId(
vote.pollUpdateParentKey,
);
const timestamp = vote.t / 1000;
const sender = vote.author ?? vote.from;
const senderUserJid =
sender?._serialized || null;
const parentMsgKeySerialized =
parentMsgKey?._serialized ||
vote.pollUpdateParentKey?._serialized ||
null;
let parentMessage = null;
if (parentMsgKeySerialized) {
parentMessage = Msg.get(
parentMsgKeySerialized,
);
if (!parentMessage) {
const fetched =
await Msg.getMessagesById([
parentMsgKeySerialized,
]);
parentMessage =
fetched?.messages?.[0] || null;
}
}
if (parentMessage) {
if (
typeof parentMessage.serialize ===
'function'
) {
parentMessage =
window.WWebJS.getMessageModel(
parentMessage,
);
} else if (
typeof parentMessage === 'object' &&
parentMessage.id
) {
parentMessage = {
...parentMessage,
id: window.WWebJS.normalizeMessageId(
parentMessage.id,
),
};
}
}
return {
...vote,
msgKey,
sender,
parentMsgKey,
senderUserJid,
timestamp,
parentMessage,
};
}),
);
window.onPollVoteEvent(votes);
return origFunction.apply(module, args);
},
);
window._wwjsFullEventBridgeDocumentEpoch = documentEpoch;
},
chatStateForceAvailable,
emitHistoricalEvents,
resolveCiphertextMessages,
ciphertextResolutionDelaysMs,
expectedDocumentEpoch,
);
}
async initWebVersionCache() {
const { type: webCacheType, ...webCacheOptions } =
this.options.webVersionCache;
const webCache = WebCacheFactory.createWebCache(
webCacheType,
webCacheOptions,
);
const requestedVersion = this.options.webVersion;
const versionContent = await webCache.resolve(requestedVersion);
if (versionContent) {
await this.pupPage.setRequestInterception(true);
this.pupPage.on('request', async (req) => {
if (req.url() === WhatsWebURL) {
req.respond({
status: 200,
contentType: 'text/html',
body: versionContent,
});
} else {
req.continue();
}
});
} else {
this.pupPage.on('response', async (res) => {
if (res.ok() && res.url() === WhatsWebURL) {
const indexHtml = await res.text();
this.currentIndexHtml = indexHtml;
}
});
}
}
/**
* Closes the client
*/
async destroy() {
// Stop application events and publish the terminal intent immediately,
// but keep the page runtime/connection proof intact until the auth
// strategy has fenced network writers and captured its final
// projection. Invalidating the runtime first makes an otherwise live
// WA socket look "unknown" to RemoteAuth and turns a clean SIGTERM
// into a false quiescence failure.
this._discardPublicEventGate({ block: true });
if (
this._connectionStatus &&
typeof this._setConnectionStatus === 'function' &&
!CONNECTION_STATUS_TERMINAL.has(this._connectionStatus.status)
) {
this._setConnectionStatus(ConnectionStatus.STOPPED, {
reason: 'client_destroyed',
});
}
const errors = [];
try {
await this.authStrategy.beforeBrowserDestroyed?.();
} catch (error) {
errors.push(error);
}
if (this._injectAbort) this._injectAbort.abort();
this._connectionRuntimeEpoch += 1;
this._connectionProofAttempt += 1;
this._connectionDocumentEpoch = null;
this._lastHasSynced = false;
this._onlineReadinessWaits?.clear?.();
this._onlineReadinessWaitActive = false;
this._postReadyUtilityRecovery = null;
this._eventBridgeDocumentEpoch = null;
this._eventBridgeAttachState = null;
Client.prototype._clearFramenavigatedHandler.call(this);
this._authReadyInFlightDocumentEpoch = null;
this._logoutRecoveryEpoch += 1;
this._logoutRecoveryInProgress = false;
const browser = this.pupBrowser;
try {
await Client.prototype._closeBrowserProcess.call(this, browser);
} catch (error) {
errors.push(error);
}
try {
await this.authStrategy.destroy();
} catch (error) {
errors.push(error);
}
if (
errors.length > 0 &&
typeof this._setConnectionStatusFromError === 'function'
) {
this._setConnectionStatusFromError(errors[0]);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(
errors,
'Unable to destroy client cleanly.',
);
}
}
/**
* Logs out the client, closing the current session
*/
async logout() {
this._connectionProofAttempt += 1;
this._lastHasSynced = false;
this._eventBridgeDocumentEpoch = null;
this._eventBridgeAttachState = null;
this._setConnectionStatus(ConnectionStatus.LOGGED_OUT, {
reason: 'logout',
});
const errors = [];
let socketLogoutError;
try {
await this.pupPage.evaluate(() => {
return window.require('WAWebSocketModel').Socket.logout();
});
} catch (error) {
// Socket.logout() commonly navigates the page that invoked it.
// Puppeteer can therefore reject even though WhatsApp accepted the
// logout. Browser termination and durable session deletion remain
// mandatory in that case.
socketLogoutError = error;
errors.push(error);
}
const browser = this.pupBrowser;
let browserTerminationConfirmed;
try {
browserTerminationConfirmed =
await Client.prototype._closeBrowserProcess.call(this, browser);
} catch (error) {
errors.push(error);
browserTerminationConfirmed =
error.browserTerminationConfirmed === true;
}
let durableSessionRemovalCompleted = false;
if (browserTerminationConfirmed) {
try {
await this.authStrategy.logout();
durableSessionRemovalCompleted = true;
} catch (error) {
errors.push(error);
}
} else {
errors.push(
new Error('wwebjs_logout_browser_termination_unconfirmed'),
);
}
// Once Chromium is fenced and the auth strategy has removed the
// durable session, a page-level rejection is only the expected effect
// of Socket.logout() navigating its own execution context.
if (durableSessionRemovalCompleted) {
const cleanupErrors = errors.filter(
(error) => error !== socketLogoutError,
);
if (cleanupErrors.length === 0) return;
if (cleanupErrors.length === 1) throw cleanupErrors[0];
throw new AggregateError(
cleanupErrors,
'Unable to close the browser cleanly during logout.',
{ cause: cleanupErrors[0] },
);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, 'Unable to log out cleanly.', {
cause: errors[0],
});
}
}
/**
* Returns the version of WhatsApp Web currently being run
* @returns {Promise<string>}
*/
async getWWebVersion() {
return await this.pupPage.evaluate(() => {
return window.Debug.VERSION;
});
}
/**
* Ensures that an integrity-pinned cache cannot silently execute a
* different WhatsApp Web build after the document has loaded.
* @param {string} actualVersion
* @private
*/
assertPinnedWebVersion(actualVersion) {
const integrity = this.options.webVersionCache?.integrity;
if (
this.options.webVersionCache?.type !== 'none' &&
integrity?.[this.options.webVersion] &&
actualVersion !== this.options.webVersion
) {
const error = new Error('wwebjs_web_version_mismatch');
error.expected = this.options.webVersion;
error.actual = actualVersion;
throw error;
}
}
/**
* Resolves the canonical companion identity without returning key material
* from the page realm.
* @returns {Promise<{jid: string, companionFingerprint: string, fingerprintVersion: string, moduleAbi: string, webVersion: string}>}
*/
async getCompanionIdentity() {
const identity = await this.pupPage.evaluate(
extractCanonicalCompanionIdentity,
);
if (
identity?.fingerprintVersion !== COMPANION_FINGERPRINT_VERSION ||
identity?.moduleAbi !== COMPANION_PRIVATE_MODULE_ABI ||
typeof identity?.webVersion !== 'string' ||
!identity.webVersion ||
identity.webVersion === 'unknown' ||
!/^[0-9a-f]{64}$/.test(identity?.companionFingerprint || '')
) {
throw new Error('wwebjs_companion_identity_invalid');
}
const wid = this.info?.wid;
const jid =
wid?._serialized ||
(wid?.user && wid?.server
? `${wid.user}@${wid.server}`
: undefined);
if (!jid || !String(jid).includes('@')) {
throw new Error('wwebjs_companion_identity_jid_missing');
}
return {
jid: String(jid),
companionFingerprint: identity.companionFingerprint,
fingerprintVersion: identity.fingerprintVersion,
moduleAbi: identity.moduleAbi,
webVersion: identity.webVersion,
};
}
async setDeviceName(deviceName, browserName) {
(deviceName || browserName) &&
(await this.pupPage.evaluate(
(deviceName, browserName) => {
const func = window.require('WAWebMiscBrowserUtils').info;
window.require('WAWebMiscBrowserUtils').info = () => {
return {
...func(),
...(deviceName ? { os: deviceName } : {}),
...(browserName ? { name: browserName } : {}),
};
};
},
deviceName,
browserName,
));
}
/**
* Mark as seen for the Chat
* @param {string} chatId
* @returns {Promise<boolean>} result
*
*/
async sendSeen(chatId) {
return await this.pupPage.evaluate(async (chatId) => {
return window.WWebJS.sendSeen(chatId);
}, chatId);
}
/**
* An object representing mentions of groups
* @typedef {Object} GroupMention
* @property {string} subject - The name of a group to mention (can be custom)
* @property {string} id - The group ID, e.g.: 'XXXXXXXXXX@g.us'
*/
/**
* Message options.
* @typedef {Object} MessageSendOptions
* @property {boolean} [linkPreview=true] - Show links preview. Has no effect on multi-device accounts.
* @property {boolean} [sendAudioAsVoice=false] - Send audio as voice message with a generated waveform
* @property {boolean} [sendVideoAsGif=false] - Send video as gif
* @property {boolean} [sendMediaAsSticker=false] - Send media as a sticker
* @property {boolean} [sendMediaAsDocument=false] - Send media as a document
* @property {boolean} [sendMediaAsHd=false] - Send image as quality HD
* @property {boolean} [isViewOnce=false] - Send photo/video as a view once message
* @property {boolean} [parseVCards=true] - Automatically parse vCards and send them as contacts
* @property {string} [caption] - Image or video caption
* @property {string} [quotedMessageId] - Id of the message that is being quoted (or replied to)
* @property {GroupMention[]} [groupMentions] - An array of object that handle group mentions
* @property {string[]} [mentions] - User IDs to mention in the message
* @property {boolean} [sendSeen=true] - Mark the conversation as seen after sending the message
* @property {string} [invokedBotWid=undefined] - Bot Wid when doing a bot mention like @Meta AI
* @property {string} [stickerAuthor=undefined] - Sets the author of the sticker, (if sendMediaAsSticker is true).
* @property {string} [stickerName=undefined] - Sets the name of the sticker, (if sendMediaAsSticker is true).
* @property {string[]} [stickerCategories=undefined] - Sets the categories of the sticker, (if sendMediaAsSticker is true). Provide emoji char array, can be null.
* @property {boolean} [ignoreQuoteErrors = true] - Should the bot send a quoted message without the quoted message if it fails to get the quote?
* @property {boolean} [waitUntilMsgSent = false] - Should the bot wait for the message send result?
* @property {MessageMedia} [media] - Media to be sent
* @property {any} [extra] - Extra options
*/
/**
* Send a message to a specific chatId
* @param {string} chatId
* @param {string|MessageMedia|Location|Poll|Contact|Array<Contact>|Buttons|List} content
* @param {MessageSendOptions} [options] - Options used when sending the message
*
* @returns {Promise<Message|null|undefined>} Message that was just sent
*/
async sendMessage(chatId, content, options = {}) {
const isChannel = /@\w*newsletter\b/.test(chatId);
const isStatus = /@\w*broadcast\b/.test(chatId);
if (
isChannel &&
[
options.sendMediaAsDocument,
options.quotedMessageId,
options.parseVCards,
options.isViewOnce,
content instanceof Location,
content instanceof Contact,
content instanceof Buttons,
content instanceof List,
Array.isArray(content) &&
content.length > 0 &&
content[0] instanceof Contact,
].includes(true)
) {
console.warn(
'The message type is currently not supported for sending in channels,\nthe supported message types are: text, image, sticker, gif, video, voice and poll.',
);
return null;
} else if (
isStatus &&
[
options.sendMediaAsDocument,
options.quotedMessageId,
options.parseVCards,
options.isViewOnce,
options.sendMediaAsSticker,
content instanceof Location,
content instanceof Contact,
content instanceof Poll,
content instanceof Buttons,
content instanceof List,
Array.isArray(content) &&
content.length > 0 &&
content[0] instanceof Contact,
].includes(true)
) {
console.warn(
'The message type is currently not supported for sending in status broadcast,\nthe supported message types are: text, image, gif, audio and video.',
);
return null;
}
if (options.mentions) {
!Array.isArray(options.mentions) &&
(options.mentions = [options.mentions]);
if (
options.mentions.some(
(possiblyContact) => possiblyContact instanceof Contact,
)
) {
console.warn(
'Mentions with an array of Contact are now deprecated. See more at https://github.com/wwebjssapp-web.js/pull/2166.',
);
options.mentions = options.mentions.map(
(a) => a.id._serialized,
);
}
}
options.groupMentions &&
!Array.isArray(options.groupMentions) &&
(options.groupMentions = [options.groupMentions]);
let internalOptions = {
linkPreview: options.linkPreview === false ? undefined : true,
sendAudioAsVoice: options.sendAudioAsVoice,
sendVideoAsGif: options.sendVideoAsGif,
sendMediaAsSticker: options.sendMediaAsSticker,
sendMediaAsDocument: options.sendMediaAsDocument,
sendMediaAsHd: options.sendMediaAsHd,
caption: options.caption,
isCaptionByUser: options.caption ? true : false,
quotedMessageId: options.quotedMessageId,
parseVCards: options.parseVCards !== false,
mentionedJidList: options.mentions || [],
groupMentions: options.groupMentions,
invokedBotWid: options.invokedBotWid,
ignoreQuoteErrors: options.ignoreQuoteErrors !== false,
waitUntilMsgSent: options.waitUntilMsgSent || false,
extraOptions: options.extra,
};
const sendSeen = options.sendSeen !== false;
if (content instanceof MessageMedia) {
internalOptions.media = content;
((internalOptions.isViewOnce = options.isViewOnce), (content = ''));
} else if (options.media instanceof MessageMedia) {
internalOptions.media = options.media;
internalOptions.caption = content;
((internalOptions.isViewOnce = options.isViewOnce), (content = ''));
} else if (content instanceof Location) {
internalOptions.location = content;
content = '';
} else if (content instanceof Poll) {
internalOptions.poll = content;
content = '';
} else if (content instanceof ScheduledEvent) {
internalOptions.event = content;
content = '';
} else if (content instanceof Contact) {
internalOptions.contactCard = content.id._serialized;
content = '';
} else if (
Array.isArray(content) &&
content.length > 0 &&
content[0] instanceof Contact
) {
internalOptions.contactCardList = content.map(
(contact) => contact.id._serialized,
);
content = '';
} else if (content instanceof Buttons) {
console.warn(
'Buttons are now deprecated. See more at https://www.youtube.com/watch?v=hv1R1rLeVVE.',
);
if (content.type !== 'chat') {
internalOptions.attachment = content.body;
}
internalOptions.buttons = content;
content = '';
} else if (content instanceof List) {
console.warn(
'Lists are now deprecated. See more at https://www.youtube.com/watch?v=hv1R1rLeVVE.',
);
internalOptions.list = content;
content = '';
}
if (internalOptions.sendMediaAsSticker && internalOptions.media) {
internalOptions.media = await Util.formatToWebpSticker(
internalOptions.media,
{
name: options.stickerName,
author: options.stickerAuthor,
categories: options.stickerCategories,
},
this.pupPage,
);
}
const sentMsg = await this.pupPage.evaluate(
async (chatId, content, options, sendSeen) => {
const chat = await window.WWebJS.getChat(chatId, {
getAsModel: false,
});
if (!chat) return null;
if (sendSeen) {
await window.WWebJS.sendSeen(chatId);
}
const msg = await window.WWebJS.sendMessage(
chat,
content,
options,
);
return msg ? window.WWebJS.getMessageModel(msg) : undefined;
},
chatId,
content,
internalOptions,
sendSeen,
);
return sentMsg ? new Message(this, sentMsg) : undefined;
}
/**
* Send an emoji reaction to a specific message
* @param {string} messageId - Id of the message to add the reaction.
* @param {string} reaction - Emoji to react with. Send an empty string to remove the reaction.
* @return {Promise}
*/
async sendReaction(messageId, reaction) {
await this.pupPage.evaluate(
async (messageId, reaction) => {
if (!messageId) return null;
const msg =
window.require('WAWebCollections').Msg.get(messageId) ||
(
await window
.require('WAWebCollections')
.Msg.getMessagesById([messageId])
)?.messages?.[0];
if (!msg) return null;
await window
.require('WAWebSendReactionMsgAction')
.sendReactionToMsg(msg, reaction);
},
messageId,
reaction,
);
}
/**
* @typedef {Object} SendChannelAdminInviteOptions
* @property {?string} comment The comment to be added to an invitation
*/
/**
* Sends a channel admin invitation to a user, allowing them to become an admin of the channel
* @param {string} chatId The ID of a user to send the channel admin invitation to
* @param {string} channelId The ID of a channel for which the invitation is being sent
* @param {SendChannelAdminInviteOptions} options
* @returns {Promise<boolean>} Returns true if an invitation was sent successfully, false otherwise
*/
async sendChannelAdminInvite(chatId, channelId, options = {}) {
const response = await this.pupPage.evaluate(
async (chatId, channelId, options) => {
const { createWid } = window.require('WAWebWidFactory');
const channelWid = createWid(channelId);
const chatWid = createWid(chatId);
const chat =
window.require('WAWebCollections').Chat.get(chatWid) ||
(await window
.require('WAWebCollections')
.Chat.find(chatWid));
if (!chatWid.isUser()) {
return false;
}
return await window
.require('WAWebNewsletterSendMsgAction')
.sendNewsletterAdminInviteMessage(chat, {
newsletterWid: channelWid,
invitee: chatWid,
inviteMessage: options.comment,
base64Thumb:
await window.WWebJS.getProfilePicThumbToBase64(
channelWid,
),
});
},
chatId,
channelId,
options,
);
return response.messageSendResult === 'OK';
}
/**
* Searches for messages
* @param {string} query
* @param {Object} [options]
* @param {number} [options.page]
* @param {number} [options.limit]
* @param {string} [options.chatId]
* @returns {Promise<Message[]>}
*/
async searchMessages(query, options = {}) {
const messages = await this.pupPage.evaluate(
async (query, page, count, remote) => {
const { messages } = await window
.require('WAWebCollections')
.Msg.search(query, page, count, remote);
return messages.map((msg) =>
window.WWebJS.getMessageModel(msg),
);
},
query,
options.page,
options.limit,
options.chatId,
);
return messages.map((msg) => new Message(this, msg));
}
/**
* Get all current chat instances
* @returns {Promise<Array<Chat>>}
*/
async getChats() {
const chats = await this.pupPage.evaluate(async () => {
return await window.WWebJS.getChats();
});
return chats.map((chat) => ChatFactory.create(this, chat));
}
/**
* Gets all cached {@link Channel} instance
* @returns {Promise<Array<Channel>>}
*/
async getChannels() {
const channels = await this.pupPage.evaluate(async () => {
return await window.WWebJS.getChannels();
});
return channels.map((channel) => ChatFactory.create(this, channel));
}
/**
* Gets chat or channel instance by ID
* @param {string} chatId
* @returns {Promise<Chat|Channel>}
*/
async getChatById(chatId) {
const chat = await this.pupPage.evaluate(async (chatId) => {
return await window.WWebJS.getChat(chatId);
}, chatId);
return chat ? ChatFactory.create(this, chat) : undefined;
}
/**
* Gets a {@link Channel} instance by invite code
* @param {string} inviteCode The code that comes after the 'https://whatsapp.com/channel/'
* @returns {Promise<Channel>}
*/
async getChannelByInviteCode(inviteCode) {
const channel = await this.pupPage.evaluate(async (inviteCode) => {
let channelMetadata;
try {
channelMetadata =
await window.WWebJS.getChannelMetadata(inviteCode);
} catch (err) {
if (err.name === 'ServerStatusCodeError') return null;
throw err;
}
return await window.WWebJS.getChat(channelMetadata.id);
}, inviteCode);
return channel ? ChatFactory.create(this, channel) : undefined;
}
/**
* Get all current contact instances
* @returns {Promise<Array<Contact>>}
*/
async getContacts() {
let contacts = await this.pupPage.evaluate(() => {
return window.WWebJS.getContacts();
});
return contacts.map((contact) => ContactFactory.create(this, contact));
}
/**
* Get contact instance by ID
* @param {string} contactId
* @returns {Promise<Contact>}
*/
async getContactById(contactId) {
let contact = await this.pupPage.evaluate((contactId) => {
return window.WWebJS.getContact(contactId);
}, contactId);
return ContactFactory.create(this, contact);
}
/**
* Get message by ID
* @param {string} messageId
* @returns {Promise<Message>}
*/
async getMessageById(messageId) {
const msg = await this.pupPage.evaluate(async (messageId) => {
let msg = window.require('WAWebCollections').Msg.get(messageId);
if (msg) return window.WWebJS.getMessageModel(msg);
const params = messageId.split('_');
if (params.length !== 3 && params.length !== 4)
throw new Error('Invalid serialized message id specified');
let messagesObject = await window
.require('WAWebCollections')
.Msg.getMessagesById([messageId]);
if (messagesObject && messagesObject.messages.length)
msg = messagesObject.messages[0];
if (msg) return window.WWebJS.getMessageModel(msg);
}, messageId);
if (msg) return new Message(this, msg);
return null;
}
/**
* Gets instances of all pinned messages in a chat
* @param {string} chatId The chat ID
* @returns {Promise<Array<Message>>}
*/
async getPinnedMessages(chatId) {
const pinnedMsgs = await this.pupPage.evaluate(async (chatId) => {
const chatWid = window.require('WAWebWidFactory').createWid(chatId);
const chat =
window.require('WAWebCollections').Chat.get(chatWid) ??
(await window.require('WAWebCollections').Chat.find(chatWid));
if (!chat) return [];
const msgs = await window
.require('WAWebPinInChatSchema')
.getTable()
.equals(['chatId'], chatWid.toString());
const pinnedMsgs = (
await Promise.all(
msgs
.filter((msg) => msg.pinType == 1)
.map(async (msg) => {
const res = await window
.require('WAWebCollections')
.Msg.getMessagesById([msg.parentMsgKey]);
return res?.messages?.[0];
}),
)
).filter(Boolean);
return !pinnedMsgs.length
? []
: await Promise.all(
pinnedMsgs.map((msg) =>
window.WWebJS.getMessageModel(msg),
),
);
}, chatId);
return pinnedMsgs.map((msg) => new Message(this, msg));
}
/**
* Returns an object with information about the invite code's group
* @param {string} inviteCode
* @returns {Promise<object>} Invite information
*/
async getInviteInfo(inviteCode) {
return await this.pupPage.evaluate((inviteCode) => {
return window
.require('WAWebGroupQueryJob')
.queryGroupInvite(inviteCode);
}, inviteCode);
}
/**
* Accepts an invitation to join a group
* @param {string} inviteCode Invitation code
* @returns {Promise<string>} Id of the joined Chat
*/
async acceptInvite(inviteCode) {
const res = await this.pupPage.evaluate(async (inviteCode) => {
return await window
.require('WAWebGroupInviteJob')
.joinGroupViaInvite(inviteCode);
}, inviteCode);
return res.gid._serialized;
}
/**
* Accepts a channel admin invitation and promotes the current user to a channel admin
* @param {string} channelId The channel ID to accept the admin invitation from
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async acceptChannelAdminInvite(channelId) {
return await this.pupPage.evaluate(async (channelId) => {
try {
await window
.require('WAWebMexAcceptNewsletterAdminInviteJob')
.acceptNewsletterAdminInvite(channelId);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
}, channelId);
}
/**
* Revokes a channel admin invitation sent to a user by a channel owner
* @param {string} channelId The channel ID an invitation belongs to
* @param {string} userId The user ID the invitation was sent to
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async revokeChannelAdminInvite(channelId, userId) {
return await this.pupPage.evaluate(
async (channelId, userId) => {
try {
const userWid = window
.require('WAWebWidFactory')
.createWid(userId);
await window
.require('WAWebMexRevokeNewsletterAdminInviteJob')
.revokeNewsletterAdminInvite(channelId, userWid);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
},
channelId,
userId,
);
}
/**
* Demotes a channel admin to a regular subscriber (can be used also for self-demotion)
* @param {string} channelId The channel ID to demote an admin in
* @param {string} userId The user ID to demote
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async demoteChannelAdmin(channelId, userId) {
return await this.pupPage.evaluate(
async (channelId, userId) => {
try {
const userWid = window
.require('WAWebWidFactory')
.createWid(userId);
await window
.require('WAWebDemoteNewsletterAdminAction')
.demoteNewsletterAdmin(channelId, userWid);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
},
channelId,
userId,
);
}
/**
* Accepts a private invitation to join a group
* @param {object} inviteInfo Invite V4 Info
* @returns {Promise<Object>}
*/
async acceptGroupV4Invite(inviteInfo) {
if (!inviteInfo.inviteCode)
throw 'Invalid invite code, try passing the message.inviteV4 object';
if (inviteInfo.inviteCodeExp == 0) throw 'Expired invite code';
return this.pupPage.evaluate(async (inviteInfo) => {
let { groupId, fromId, inviteCode, inviteCodeExp } = inviteInfo;
let userWid = window.require('WAWebWidFactory').createWid(fromId);
return await window
.require('WAWebGroupInviteV4Job')
.joinGroupViaInviteV4(
inviteCode,
String(inviteCodeExp),
groupId,
userWid,
);
}, inviteInfo);
}
/**
* Sets the current user's status message
* @param {string} status New status message
*/
async setStatus(status) {
await this.pupPage.evaluate(async (status) => {
return await window
.require('WAWebContactStatusBridge')
.setMyStatus(status);
}, status);
}
/**
* Sets the current user's display name.
* This is the name shown to WhatsApp users that have not added you as a contact beside your number in groups and in your profile.
* @param {string} displayName New display name
* @returns {Promise<Boolean>}
*/
async setDisplayName(displayName) {
const couldSet = await this.pupPage.evaluate(async (displayName) => {
if (!window.require('WAWebConnModel').Conn.canSetMyPushname())
return false;
await window
.require('WAWebSetPushnameConnAction')
.setPushname(displayName);
return true;
}, displayName);
return couldSet;
}
/**
* Gets the current connection state for the client
* @returns {WAState}
*/
async getState() {
return await this.pupPage.evaluate(() => {
return window.require('WAWebSocketModel').Socket.state ?? null;
});
}
/**
* Refreshes the current QR code while the socket is still waiting for a scan.
* A refresh is deliberately deferred once pairing has started so the active
* companion handshake is never invalidated by an expiring QR timer.
* @returns {Promise<boolean>} Whether a refresh was requested
*/
async refreshQR() {
return await this.pupPage.evaluate(async () => {
const requireOptional = (moduleName) => {
try {
return window.require(moduleName);
} catch {
return null;
}
};
const state = requireOptional('WAWebSocketModel')?.Socket?.state;
if (state !== 'UNPAIRED' && state !== 'UNPAIRED_IDLE') {
return false;
}
const launchSocketUtils = requireOptional('WAWebLaunchSocketUtils');
if (typeof launchSocketUtils?.refreshQR === 'function') {
await launchSocketUtils.refreshQR();
return true;
}
const command = requireOptional('WAWebCmd')?.Cmd;
if (typeof command?.refreshQR === 'function') {
await command.refreshQR();
return true;
}
return false;
});
}
/**
* Marks the client as online
*/
async sendPresenceAvailable() {
return await this.pupPage.evaluate(() => {
return window
.require('WAWebPresenceChatAction')
.sendPresenceAvailable();
});
}
/**
* Marks the client as unavailable
*/
async sendPresenceUnavailable() {
return await this.pupPage.evaluate(() => {
return window
.require('WAWebPresenceChatAction')
.sendPresenceUnavailable();
});
}
/**
* Enables and returns the archive state of the Chat
* @returns {boolean}
*/
async archiveChat(chatId) {
return await this.pupPage.evaluate(async (chatId) => {
let chat = await window.WWebJS.getChat(chatId, {
getAsModel: false,
});
await window.require('WAWebCmd').Cmd.archiveChat(chat, true);
return true;
}, chatId);
}
/**
* Changes and returns the archive state of the Chat
* @returns {boolean}
*/
async unarchiveChat(chatId) {
return await this.pupPage.evaluate(async (chatId) => {
let chat = await window.WWebJS.getChat(chatId, {
getAsModel: false,
});
await window.require('WAWebCmd').Cmd.archiveChat(chat, false);
return false;
}, chatId);
}
/**
* Pins the Chat
* @returns {Promise<boolean>} New pin state. Could be false if the max number of pinned chats was reached.
*/
async pinChat(chatId) {
return this.pupPage.evaluate(async (chatId) => {
let chat = await window.WWebJS.getChat(chatId, {
getAsModel: false,
});
if (chat.pin) {
return true;
}
const MAX_PIN_COUNT = 3;
const chatModels = window
.require('WAWebCollections')
.Chat.getModelsArray();
if (chatModels.length > MAX_PIN_COUNT) {
let maxPinned = chatModels[MAX_PIN_COUNT - 1].pin;
if (maxPinned) {
return false;
}
}
await window.require('WAWebCmd').Cmd.pinChat(chat, true);
return true;
}, chatId);
}
/**
* Unpins the Chat
* @returns {Promise<boolean>} New pin state
*/
async unpinChat(chatId) {
return this.pupPage.evaluate(async (chatId) => {
let chat = await window.WWebJS.getChat(chatId, {
getAsModel: false,
});
if (!chat.pin) {
return false;
}
await window.require('WAWebCmd').Cmd.pinChat(chat, false);
return false;
}, chatId);
}
/**
* Mutes this chat forever, unless a date is specified
* @param {string} chatId ID of the chat that will be muted
* @param {?Date} unmuteDate Date when the chat will be unmuted, don't provide a value to mute forever
* @returns {Promise<{isMuted: boolean, muteExpiration: number}>}
*/
async muteChat(chatId, unmuteDate) {
unmuteDate = unmuteDate ? Math.floor(unmuteDate.getTime() / 1000) : -1;
return this._muteUnmuteChat(chatId, 'MUTE', unmuteDate);
}
/**
* Unmutes the Chat
* @param {string} chatId ID of the chat that will be unmuted
* @returns {Promise<{isMuted: boolean, muteExpiration: number}>}
*/
async unmuteChat(chatId) {
return this._muteUnmuteChat(chatId, 'UNMUTE');
}
/**
* Internal method to mute or unmute the chat
* @param {string} chatId ID of the chat that will be muted/unmuted
* @param {string} action The action: 'MUTE' or 'UNMUTE'
* @param {number} unmuteDateTs Timestamp at which the chat will be unmuted
* @returns {Promise<{isMuted: boolean, muteExpiration: number}>}
*/
async _muteUnmuteChat(chatId, action, unmuteDateTs) {
return this.pupPage.evaluate(
async (chatId, action, unmuteDateTs) => {
const chat =
window.require('WAWebCollections').Chat.get(chatId) ??
(await window
.require('WAWebCollections')
.Chat.find(chatId));
action === 'MUTE'
? await chat.mute.mute({
expiration: unmuteDateTs,
sendDevice: true,
})
: await chat.mute.unmute({ sendDevice: true });
return {
isMuted: chat.mute.expiration !== 0,
muteExpiration: chat.mute.expiration,
};
},
chatId,
action,
unmuteDateTs || -1,
);
}
/**
* Mark the Chat as unread
* @param {string} chatId ID of the chat that will be marked as unread
*/
async markChatUnread(chatId) {
await this.pupPage.evaluate(async (chatId) => {
let chat = await window.WWebJS.getChat(chatId, {
getAsModel: false,
});
await window.require('WAWebCmd').Cmd.markChatUnread(chat, true);
}, chatId);
}
/**
* Returns the contact ID's profile picture URL, if privacy settings allow it
* @param {string} contactId the whatsapp user's ID
* @returns {Promise<string>}
*/
async getProfilePicUrl(contactId) {
const profilePicUrl = await this.pupPage.evaluate(async (contactId) => {
try {
const chatWid = window
.require('WAWebWidFactory')
.createWid(contactId);
// We force the synchronization so the thumb/picture is loaded
// from the server if it's not already in memory.
// Using .catch() to silently ignore if it fails to find the thumbnail
await window
.require('WAWebCollections')
.ProfilePicThumb.find(chatWid)
.catch(() => {});
// We extract the URL directly from the ProfilePicThumb collection
// bypassing requestProfilePicFromServer completely
const thumb = window
.require('WAWebCollections')
.ProfilePicThumb.get(chatWid);
if (thumb && thumb.eurl) {
return thumb.eurl;
}
return undefined;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return undefined;
throw err;
}
}, contactId);
return profilePicUrl || undefined;
}
/**
* Gets the Contact's common groups with you. Returns empty array if you don't have any common group.
* @param {string} contactId the whatsapp user's ID (_serialized format)
* @returns {Promise<WAWebJS.ChatId[]>}
*/
async getCommonGroups(contactId) {
const commonGroups = await this.pupPage.evaluate(async (contactId) => {
let contact = window
.require('WAWebCollections')
.Contact.get(contactId);
if (!contact) {
const wid = window
.require('WAWebWidFactory')
.createWid(contactId);
const chatConstructor = window
.require('WAWebCollections')
.Contact.getModelsArray()
.find((c) => !c.isGroup).constructor;
contact = new chatConstructor({ id: wid });
}
if (contact.commonGroups) {
return contact.commonGroups.serialize();
}
const status = await window
.require('WAWebFindCommonGroupsContactAction')
.findCommonGroups(contact);
if (status) {
return contact.commonGroups.serialize();
}
return [];
}, contactId);
const chats = [];
for (const group of commonGroups) {
chats.push(group.id);
}
return chats;
}
/**
* Force reset of connection state for the client
*/
async resetState() {
await this.pupPage.evaluate(() => {
window.require('WAWebSocketModel').Socket.reconnect();
});
}
/**
* Check if a given ID is registered in whatsapp
* @param {string} id the whatsapp user's ID
* @returns {Promise<Boolean>}
*/
async isRegisteredUser(id) {
return Boolean(await this.getNumberId(id));
}
/**
* Get the registered WhatsApp ID for a number.
* Will return null if the number is not registered on WhatsApp.
* @param {string} number Number or ID ("@c.us" will be automatically appended if not specified)
* @returns {Promise<Object|null>}
*/
async getNumberId(number) {
if (!number.endsWith('@c.us')) {
number += '@c.us';
}
return await this.pupPage.evaluate(async (number) => {
const wid = window.require('WAWebWidFactory').createWid(number);
const result = await window
.require('WAWebQueryExistsJob')
.queryWidExists(wid);
if (!result || result.wid === undefined) return null;
return result.wid;
}, number);
}
/**
* Check whether numbers or IDs are registered on WhatsApp.
*
* Accepted input formats (per item):
* - Plain number as string: `5511999999999`
* - Plain number as number: `5511999999999`
* - Legacy user ID: `5511999999999@c.us`
* - Canonical user JID: `5511999999999@s.whatsapp.net`
* - LID: `227959751348297@lid`
*
* Call styles:
* - Variadic: `client.onWhatsApp('5511999999999', '227959751348297@lid')`
* - Array: `client.onWhatsApp(['5511999999999', '227959751348297@lid'])`
* - Numeric value: `client.onWhatsApp(5511999999999)`
*
* Output order matches input order.
*
* @param {...(string|number|Array.<string|number>)} phoneNumber Numbers or IDs to check
* @returns {Promise<Array<{jid: string|null, exists: boolean}>>} One item per input:
* - `exists`: `true` if the account exists on WhatsApp.
* - `jid`: preferred `@s.whatsapp.net` when available; otherwise resolved `_serialized`
* (`@c.us` or `@lid`); `null` when not found or invalid input.
*/
async onWhatsApp(...phoneNumber) {
const normalizedNumbers = phoneNumber.flatMap((entry) =>
Array.isArray(entry) ? entry : [entry],
);
return await this.pupPage.evaluate(async (phoneNumber) => {
return await Promise.all(
phoneNumber.map(async (rawInput) => {
let input = null;
if (
typeof rawInput === 'number' &&
Number.isFinite(rawInput)
) {
input = String(rawInput);
} else if (typeof rawInput === 'string') {
input = rawInput.trim();
}
if (!input) {
return { jid: null, exists: false };
}
try {
let normalizedInput = input;
if (!normalizedInput.includes('@')) {
normalizedInput = normalizedInput.replace(
/[^\d]/g,
'',
);
if (!normalizedInput) {
return { jid: null, exists: false };
}
normalizedInput = `${normalizedInput}@c.us`;
} else if (
/@s\.whatsapp\.net$/i.test(normalizedInput)
) {
normalizedInput = normalizedInput.replace(
/@s\.whatsapp\.net$/i,
'@c.us',
);
}
const wid = window
.require('WAWebWidFactory')
.createWid(normalizedInput);
const result = await window
.require('WAWebQueryExistsJob')
.queryWidExists(wid);
if (!result?.wid) {
return { jid: null, exists: false };
}
const preferredJid =
window.WWebJS.getPreferredRemoteJid(result.wid) ||
result.wid?._serialized ||
null;
return { jid: preferredJid, exists: true };
} catch {
return { jid: null, exists: false };
}
}),
);
}, normalizedNumbers);
}
/**
* Get the formatted number of a WhatsApp ID.
* @param {string} number Number or ID
* @returns {Promise<string>}
*/
async getFormattedNumber(number) {
if (!number.endsWith('@s.whatsapp.net'))
number = number.replace('c.us', 's.whatsapp.net');
if (!number.includes('@s.whatsapp.net'))
number = `${number}@s.whatsapp.net`;
return await this.pupPage.evaluate(async (numberId) => {
return window
.require('WAWebPhoneUtils')
.formattedPhoneNumber(numberId);
}, number);
}
/**
* Get the country code of a WhatsApp ID.
* @param {string} number Number or ID
* @returns {Promise<string>}
*/
async getCountryCode(number) {
number = number.replace(' ', '').replace('+', '').replace('@c.us', '');
return await this.pupPage.evaluate(async (numberId) => {
return window.require('WAPhoneFindCC').findCC(numberId);
}, number);
}
/**
* An object that represents the result for a participant added to a group
* @typedef {Object} ParticipantResult
* @property {number} statusCode The status code of the result
* @property {string} message The result message
* @property {boolean} isGroupCreator Indicates if the participant is a group creator
* @property {boolean} isInviteV4Sent Indicates if the inviteV4 was sent to the participant
*/
/**
* An object that handles the result for {@link createGroup} method
* @typedef {Object} CreateGroupResult
* @property {string} title A group title
* @property {Object} gid An object that handles the newly created group ID
* @property {string} gid.server
* @property {string} gid.user
* @property {string} gid._serialized
* @property {Object.<string, ParticipantResult>} participants An object that handles the result value for each added to the group participant
*/
/**
* An object that handles options for group creation
* @typedef {Object} CreateGroupOptions
* @property {number} [messageTimer = 0] The number of seconds for the messages to disappear in the group (0 by default, won't take an effect if the group is been creating with myself only)
* @property {string|undefined} parentGroupId The ID of a parent community group to link the newly created group with (won't take an effect if the group is been creating with myself only)
* @property {boolean} [autoSendInviteV4 = true] If true, the inviteV4 will be sent to those participants who have restricted others from being automatically added to groups, otherwise the inviteV4 won't be sent (true by default)
* @property {string} [comment = ''] The comment to be added to an inviteV4 (empty string by default)
* @property {boolean} [memberAddMode = false] If true, only admins can add members to the group (false by default)
* @property {boolean} [membershipApprovalMode = false] If true, group admins will be required to approve anyone who wishes to join the group (false by default)
* @property {boolean} [isRestrict = true] If true, only admins can change group group info (true by default)
* @property {boolean} [isAnnounce = false] If true, only admins can send messages (false by default)
*/
/**
* Creates a new group
* @param {string} title Group title
* @param {string|Contact|Array<Contact|string>|undefined} participants A single Contact object or an ID as a string or an array of Contact objects or contact IDs to add to the group
* @param {CreateGroupOptions} options An object that handles options for group creation
* @returns {Promise<CreateGroupResult|string>} Object with resulting data or an error message as a string
*/
async createGroup(title, participants = [], options = {}) {
!Array.isArray(participants) && (participants = [participants]);
participants.map((p) => (p instanceof Contact ? p.id._serialized : p));
return await this.pupPage.evaluate(
async (title, participants, options) => {
const {
messageTimer = 0,
parentGroupId,
autoSendInviteV4 = true,
comment = '',
} = options;
const participantData = {},
participantWids = [],
failedParticipants = [];
let createGroupResult, parentGroupWid;
const addParticipantResultCodes = {
default:
'An unknown error occupied while adding a participant',
200: 'The participant was added successfully',
403: 'The participant can be added by sending private invitation only',
404: 'The phone number is not registered on WhatsApp',
};
for (const participant of participants) {
const pWid = window
.require('WAWebWidFactory')
.createWid(participant);
if (
(
await window
.require('WAWebQueryExistsJob')
.queryWidExists(pWid)
)?.wid
) {
participantWids.push({ phoneNumber: pWid });
} else failedParticipants.push(participant);
}
parentGroupId &&
(parentGroupWid = window
.require('WAWebWidFactory')
.createWid(parentGroupId));
try {
createGroupResult = await window
.require('WAWebGroupCreateJob')
.createGroup(
{
addressingModeOverride: 'lid',
memberAddMode: options.memberAddMode ?? false,
membershipApprovalMode:
options.membershipApprovalMode ?? false,
announce: options.announce ?? false,
restrict:
options.isRestrict !== undefined
? !options.isRestrict
: false,
ephemeralDuration: messageTimer,
parentGroupId: parentGroupWid,
title: title,
},
participantWids,
);
} catch (ignoredError) {
return 'CreateGroupError: An unknown error occupied while creating a group';
}
for (const participant of createGroupResult.participants) {
let isInviteV4Sent = false;
participant.wid.server == 'lid' &&
(participant.wid = window
.require('WAWebApiContact')
.getPhoneNumber(participant.wid));
const participantId = participant.wid._serialized;
const statusCode = participant.error || 200;
if (autoSendInviteV4 && statusCode === 403) {
window
.require('WAWebCollections')
.Contact.gadd(participant.wid, { silent: true });
const addParticipantResult = await window
.require('WAWebChatSendMessages')
.sendGroupInviteMessage(
window
.require('WAWebCollections')
.Chat.get(participant.wid) ||
(await window
.require('WAWebCollections')
.Chat.find(participant.wid)),
createGroupResult.wid._serialized,
createGroupResult.subject,
participant.invite_code,
participant.invite_code_exp,
comment,
await window.WWebJS.getProfilePicThumbToBase64(
createGroupResult.wid,
),
);
isInviteV4Sent =
addParticipantResult.messageSendResult === 'OK';
}
participantData[participantId] = {
statusCode: statusCode,
message:
addParticipantResultCodes[statusCode] ||
addParticipantResultCodes.default,
isGroupCreator: participant.type === 'superadmin',
isInviteV4Sent: isInviteV4Sent,
};
}
for (const f of failedParticipants) {
participantData[f] = {
statusCode: 404,
message: addParticipantResultCodes[404],
isGroupCreator: false,
isInviteV4Sent: false,
};
}
return {
title: title,
gid: createGroupResult.wid,
participants: participantData,
};
},
title,
participants,
options,
);
}
/**
* An object that handles the result for {@link createChannel} method
* @typedef {Object} CreateChannelResult
* @property {string} title A channel title
* @property {ChatId} nid An object that handels the newly created channel ID
* @property {string} nid.server 'newsletter'
* @property {string} nid.user 'XXXXXXXXXX'
* @property {string} nid._serialized 'XXXXXXXXXX@newsletter'
* @property {string} inviteLink The channel invite link, starts with 'https://whatsapp.com/channel/'
* @property {number} createdAtTs The timestamp the channel was created at
*/
/**
* Options for the channel creation
* @typedef {Object} CreateChannelOptions
* @property {?string} description The channel description
* @property {?MessageMedia} picture The channel profile picture
*/
/**
* Creates a new channel
* @param {string} title The channel name
* @param {CreateChannelOptions} options
* @returns {Promise<CreateChannelResult|string>} Returns an object that handles the result for the channel creation or an error message as a string
*/
async createChannel(title, options = {}) {
return await this.pupPage.evaluate(
async (title, options) => {
let response,
{ description = null, picture = null } = options;
if (
!window
.require('WAWebNewsletterGatingUtils')
.isNewsletterCreationEnabled()
) {
return 'CreateChannelError: A channel creation is not enabled';
}
if (picture) {
picture = await window.WWebJS.cropAndResizeImage(picture, {
asDataUrl: true,
mimetype: 'image/jpeg',
size: 640,
quality: 1,
});
}
try {
response = await window
.require('WAWebNewsletterCreateQueryJob')
.createNewsletterQuery({
name: title,
description: description,
picture: picture,
});
} catch (err) {
if (err.name === 'ServerStatusCodeError') {
return 'CreateChannelError: An error occupied while creating a channel';
}
throw err;
}
return {
title: title,
nid: window
.require('WAWebJidToWid')
.newsletterJidToWid(response.idJid),
inviteLink: `https://whatsapp.com/channel/${response.newsletterInviteLinkMetadataMixin.inviteCode}`,
createdAtTs:
response.newsletterCreationTimeMetadataMixin
.creationTimeValue,
};
},
title,
options,
);
}
/**
* Subscribe to channel
* @param {string} channelId The channel ID
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async subscribeToChannel(channelId) {
return await this.pupPage.evaluate(async (channelId) => {
return await window.WWebJS.subscribeToUnsubscribeFromChannel(
channelId,
'Subscribe',
);
}, channelId);
}
/**
* Options for unsubscribe from a channel
* @typedef {Object} UnsubscribeOptions
* @property {boolean} [deleteLocalModels = false] If true, after an unsubscription, it will completely remove a channel from the channel collection making it seem like the current user have never interacted with it. Otherwise it will only remove a channel from the list of channels the current user is subscribed to and will set the membership type for that channel to GUEST
*/
/**
* Unsubscribe from channel
* @param {string} channelId The channel ID
* @param {UnsubscribeOptions} options
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async unsubscribeFromChannel(channelId, options) {
return await this.pupPage.evaluate(
async (channelId, options) => {
return await window.WWebJS.subscribeToUnsubscribeFromChannel(
channelId,
'Unsubscribe',
options,
);
},
channelId,
options,
);
}
/**
* Options for transferring a channel ownership to another user
* @typedef {Object} TransferChannelOwnershipOptions
* @property {boolean} [shouldDismissSelfAsAdmin = false] If true, after the channel ownership is being transferred to another user, the current user will be dismissed as a channel admin and will become to a channel subscriber.
*/
/**
* Transfers a channel ownership to another user.
* Note: the user you are transferring the channel ownership to must be a channel admin.
* @param {string} channelId
* @param {string} newOwnerId
* @param {TransferChannelOwnershipOptions} options
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async transferChannelOwnership(channelId, newOwnerId, options = {}) {
return await this.pupPage.evaluate(
async (channelId, newOwnerId, options) => {
const channel = await window.WWebJS.getChat(channelId, {
getAsModel: false,
});
const newOwner =
window
.require('WAWebCollections')
.Contact.get(newOwnerId) ||
(await window
.require('WAWebCollections')
.Contact.find(newOwnerId));
if (!channel.newsletterMetadata) {
await window
.require('WAWebCollections')
.NewsletterMetadataCollection.update(channel.id);
}
try {
await window
.require('WAWebChangeNewsletterOwnerAction')
.changeNewsletterOwnerAction(channel, newOwner);
if (options.shouldDismissSelfAsAdmin) {
const meContact = window
.require('WAWebContactCollection')
.getMeContact();
meContact &&
(await window
.require('WAWebNewsletterDemoteAdminJob')
.demoteNewsletterAdminAction(
channel,
meContact,
));
}
} catch (ignoredError) {
return false;
}
return true;
},
channelId,
newOwnerId,
options,
);
}
/**
* Searches for channels based on search criteria, there are some notes:
* 1. The method finds only channels you are not subscribed to currently
* 2. If you have never been subscribed to a found channel
* or you have unsubscribed from it with {@link UnsubscribeOptions.deleteLocalModels} set to 'true',
* the lastMessage property of a found channel will be 'null'
*
* @param {Object} searchOptions Search options
* @param {string} [searchOptions.searchText = ''] Text to search
* @param {Array<string>} [searchOptions.countryCodes = [your local region]] Array of country codes in 'ISO 3166-1 alpha-2' standart (@see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) to search for channels created in these countries
* @param {boolean} [searchOptions.skipSubscribedNewsletters = false] If true, channels that user is subscribed to won't appear in found channels
* @param {number} [searchOptions.view = 0] View type, makes sense only when the searchText is empty. Valid values to provide are:
* 0 for RECOMMENDED channels
* 1 for TRENDING channels
* 2 for POPULAR channels
* 3 for NEW channels
* @param {number} [searchOptions.limit = 50] The limit of found channels to be appear in the returnig result
* @returns {Promise<Array<Channel>>} Returns an array of Channel objects or an empty array if no channels were found
*/
async searchChannels(searchOptions = {}) {
return await this.pupPage.evaluate(
async ({
searchText = '',
countryCodes = [],
skipSubscribedNewsletters = false,
view = 0,
limit = 50,
}) => {
searchText = searchText.trim();
const currentRegion = window.require('WAWebL10N').getRegion();
if (countryCodes.length === 0) countryCodes[0] = currentRegion;
if (![0, 1, 2, 3].includes(view)) view = 0;
const { countryCodesIso } = window.require(
'WAWebCountriesNativeCountryNames',
);
countryCodes =
countryCodes.length === 1 &&
countryCodes[0] === currentRegion
? countryCodes
: countryCodes.filter((code) =>
Object.keys(countryCodesIso).includes(code),
);
const viewTypeMapping = {
0: 'RECOMMENDED',
1: 'TRENDING',
2: 'POPULAR',
3: 'NEW',
};
searchOptions = {
searchText: searchText,
countryCodes: countryCodes,
skipSubscribedNewsletters: skipSubscribedNewsletters,
view: viewTypeMapping[view],
categories: [],
cursorToken: '',
};
const originalFunction = window.require(
'WAWebNewsletterGatingUtils',
).getNewsletterDirectoryPageSize;
limit !== 50 &&
(window.require(
'WAWebNewsletterGatingUtils',
).getNewsletterDirectoryPageSize = () => limit);
const channels = (
await window
.require('WAWebNewsletterDirectorySearchAction')
.fetchNewsletterDirectories(searchOptions)
).newsletters;
limit !== 50 &&
(window.require(
'WAWebNewsletterGatingUtils',
).getNewsletterDirectoryPageSize = originalFunction);
return channels
? await Promise.all(
channels.map((channel) =>
window.WWebJS.getChatModel(channel, {
isChannel: true,
}),
),
)
: [];
},
searchOptions,
);
}
/**
* Deletes the channel you created
* @param {string} channelId The ID of a channel to delete
* @returns {Promise<boolean>} Returns true if the operation completed successfully, false otherwise
*/
async deleteChannel(channelId) {
return await this.pupPage.evaluate(async (channelId) => {
const channel = await window.WWebJS.getChat(channelId, {
getAsModel: false,
});
if (!channel) return false;
try {
await window
.require('WAWebNewsletterDeleteAction')
.deleteNewsletterAction(channel);
return true;
} catch (err) {
if (err.name === 'ServerStatusCodeError') return false;
throw err;
}
}, channelId);
}
/**
* Get all current Labels
* @returns {Promise<Array<Label>>}
*/
async getLabels() {
const labels = await this.pupPage.evaluate(async () => {
return window.WWebJS.getLabels();
});
return labels.map((data) => new Label(this, data));
}
/**
* Get all current Broadcast
* @returns {Promise<Array<Broadcast>>}
*/
async getBroadcasts() {
const broadcasts = await this.pupPage.evaluate(async () => {
return window.WWebJS.getAllStatuses();
});
return broadcasts.map((data) => new Broadcast(this, data));
}
/**
* Get broadcast instance by current user ID
* @param {string} contactId
* @returns {Promise<Broadcast>}
*/
async getBroadcastById(contactId) {
const broadcast = await this.pupPage.evaluate(async (userId) => {
let status;
try {
status = window.require('WAWebCollections').Status.get(userId);
if (!status) {
status = await window
.require('WAWebCollections')
.Status.find(userId);
}
} catch {
status = null;
}
if (status) return window.WWebJS.getStatusModel(status);
}, contactId);
return new Broadcast(this, broadcast);
}
/**
* Revoke current own status messages
* @param {string} messageId
* @returns {Promise<void>}
*/
async revokeStatusMessage(messageId) {
return await this.pupPage.evaluate(async (msgId) => {
const status = window
.require('WAWebCollections')
.Status.getMyStatus();
if (!status) return;
const msg =
window.require('WAWebCollections').Msg.get(msgId) ||
(
await window
.require('WAWebCollections')
.Msg.getMessagesById([msgId])
)?.messages?.[0];
if (!msg) return;
if (!msg.id.fromMe || !msg.id.remote.isStatus())
throw 'Invalid usage! Can only revoke the message its from own status broadcast';
return await window
.require('WAWebRevokeStatusAction')
.sendStatusRevokeMsgAction(status, msg);
}, messageId);
}
/**
* Get Label instance by ID
* @param {string} labelId
* @returns {Promise<Label>}
*/
async getLabelById(labelId) {
const label = await this.pupPage.evaluate(async (labelId) => {
return window.WWebJS.getLabel(labelId);
}, labelId);
return new Label(this, label);
}
/**
* Get all Labels assigned to a chat
* @param {string} chatId
* @returns {Promise<Array<Label>>}
*/
async getChatLabels(chatId) {
const labels = await this.pupPage.evaluate(async (chatId) => {
return window.WWebJS.getChatLabels(chatId);
}, chatId);
return labels.map((data) => new Label(this, data));
}
/**
* Get all Chats for a specific Label
* @param {string} labelId
* @returns {Promise<Array<Chat>>}
*/
async getChatsByLabelId(labelId) {
const chatIds = await this.pupPage.evaluate(async (labelId) => {
const label = window.require('WAWebCollections').Label.get(labelId);
const labelItems = label.labelItemCollection.getModelsArray();
return labelItems.reduce((result, item) => {
if (item.parentType === 'Chat') {
result.push(item.parentId);
}
return result;
}, []);
}, labelId);
return Promise.all(chatIds.map((id) => this.getChatById(id)));
}
/**
* Gets all blocked contacts by host account
* @returns {Promise<Array<Contact>>}
*/
async getBlockedContacts() {
const blockedContacts = await this.pupPage.evaluate(() => {
let chatIds = window
.require('WAWebCollections')
.Blocklist.getModelsArray()
.map((a) => a.id._serialized);
return Promise.all(
chatIds.map((id) => window.WWebJS.getContact(id)),
);
});
return blockedContacts.map((contact) =>
ContactFactory.create(this.client, contact),
);
}
/**
* Sets the current user's profile picture.
* @param {MessageMedia} media
* @returns {Promise<boolean>} Returns true if the picture was properly updated.
*/
async setProfilePicture(media) {
const success = await this.pupPage.evaluate(
(chatid, media) => {
return window.WWebJS.setPicture(chatid, media);
},
this.info.wid._serialized,
media,
);
return success;
}
/**
* Deletes the current user's profile picture.
* @returns {Promise<boolean>} Returns true if the picture was properly deleted.
*/
async deleteProfilePicture() {
const success = await this.pupPage.evaluate((chatid) => {
return window.WWebJS.deletePicture(chatid);
}, this.info.wid._serialized);
return success;
}
/**
* Change labels in chats
* @param {Array<number|string>} labelIds
* @param {Array<string>} chatIds
* @returns {Promise<void>}
*/
async addOrRemoveLabels(labelIds, chatIds) {
return this.pupPage.evaluate(
async (labelIds, chatIds) => {
if (
['smba', 'smbi'].indexOf(
window.require('WAWebConnModel').Conn.platform,
) === -1
) {
throw '[LT01] Only Whatsapp business';
}
const labels = window.WWebJS.getLabels().filter(
(e) => labelIds.find((l) => l == e.id) !== undefined,
);
const chats = window
.require('WAWebCollections')
.Chat.filter((e) => chatIds.includes(e.id._serialized));
let actions = labels.map((label) => ({
id: label.id,
type: 'add',
}));
chats.forEach((chat) => {
(chat.labels || []).forEach((n) => {
if (!actions.find((e) => e.id == n)) {
actions.push({ id: n, type: 'remove' });
}
});
});
return await window
.require('WAWebCollections')
.Label.addOrRemoveLabels(actions, chats);
},
labelIds,
chatIds,
);
}
/**
* An object that handles the information about the group membership request
* @typedef {Object} GroupMembershipRequest
* @property {Object} id The wid of a user who requests to enter the group
* @property {Object} addedBy The wid of a user who created that request
* @property {Object|null} parentGroupId The wid of a community parent group to which the current group is linked
* @property {string} requestMethod The method used to create the request: NonAdminAdd/InviteLink/LinkedGroupJoin
* @property {number} t The timestamp the request was created at
*/
/**
* Gets an array of membership requests
* @param {string} groupId The ID of a group to get membership requests for
* @returns {Promise<Array<GroupMembershipRequest>>} An array of membership requests
*/
async getGroupMembershipRequests(groupId) {
return await this.pupPage.evaluate(async (groupId) => {
const groupWid = window
.require('WAWebWidFactory')
.createWid(groupId);
return await window
.require('WAWebApiMembershipApprovalRequestStore')
.getMembershipApprovalRequests(groupWid);
}, groupId);
}
/**
* An object that handles the result for membership request action
* @typedef {Object} MembershipRequestActionResult
* @property {string} requesterId User ID whos membership request was approved/rejected
* @property {number|undefined} error An error code that occurred during the operation for the participant
* @property {string} message A message with a result of membership request action
*/
/**
* An object that handles options for {@link approveGroupMembershipRequests} and {@link rejectGroupMembershipRequests} methods
* @typedef {Object} MembershipRequestActionOptions
* @property {Array<string>|string|null} requesterIds User ID/s who requested to join the group, if no value is provided, the method will search for all membership requests for that group
* @property {Array<number>|number|null} sleep The number of milliseconds to wait before performing an operation for the next requester. If it is an array, a random sleep time between the sleep[0] and sleep[1] values will be added (the difference must be >=100 ms, otherwise, a random sleep time between sleep[1] and sleep[1] + 100 will be added). If sleep is a number, a sleep time equal to its value will be added. By default, sleep is an array with a value of [250, 500]
*/
/**
* Approves membership requests if any
* @param {string} groupId The group ID to get the membership request for
* @param {MembershipRequestActionOptions} options Options for performing a membership request action
* @returns {Promise<Array<MembershipRequestActionResult>>} Returns an array of requester IDs whose membership requests were approved and an error for each requester, if any occurred during the operation. If there are no requests, an empty array will be returned
*/
async approveGroupMembershipRequests(groupId, options = {}) {
return await this.pupPage.evaluate(
async (groupId, options) => {
const { requesterIds = null, sleep = [250, 500] } = options;
return await window.WWebJS.membershipRequestAction(
groupId,
'Approve',
requesterIds,
sleep,
);
},
groupId,
options,
);
}
/**
* Rejects membership requests if any
* @param {string} groupId The group ID to get the membership request for
* @param {MembershipRequestActionOptions} options Options for performing a membership request action
* @returns {Promise<Array<MembershipRequestActionResult>>} Returns an array of requester IDs whose membership requests were rejected and an error for each requester, if any occurred during the operation. If there are no requests, an empty array will be returned
*/
async rejectGroupMembershipRequests(groupId, options = {}) {
return await this.pupPage.evaluate(
async (groupId, options) => {
const { requesterIds = null, sleep = [250, 500] } = options;
return await window.WWebJS.membershipRequestAction(
groupId,
'Reject',
requesterIds,
sleep,
);
},
groupId,
options,
);
}
/**
* Setting autoload download audio
* @param {boolean} flag true/false
*/
async setAutoDownloadAudio(flag) {
await this.pupPage.evaluate(async (flag) => {
const autoDownload = window
.require('WAWebUserPrefsGeneral')
.getAutoDownloadAudio();
if (autoDownload === flag) {
return flag;
}
await window
.require('WAWebUserPrefsGeneral')
.setAutoDownloadAudio(flag);
return flag;
}, flag);
}
/**
* Setting autoload download documents
* @param {boolean} flag true/false
*/
async setAutoDownloadDocuments(flag) {
await this.pupPage.evaluate(async (flag) => {
const autoDownload = window
.require('WAWebUserPrefsGeneral')
.getAutoDownloadDocuments();
if (autoDownload === flag) {
return flag;
}
await window
.require('WAWebUserPrefsGeneral')
.setAutoDownloadDocuments(flag);
return flag;
}, flag);
}
/**
* Setting autoload download photos
* @param {boolean} flag true/false
*/
async setAutoDownloadPhotos(flag) {
await this.pupPage.evaluate(async (flag) => {
const autoDownload = window
.require('WAWebUserPrefsGeneral')
.getAutoDownloadPhotos();
if (autoDownload === flag) {
return flag;
}
await window
.require('WAWebUserPrefsGeneral')
.setAutoDownloadPhotos(flag);
return flag;
}, flag);
}
/**
* Setting autoload download videos
* @param {boolean} flag true/false
*/
async setAutoDownloadVideos(flag) {
await this.pupPage.evaluate(async (flag) => {
const autoDownload = window
.require('WAWebUserPrefsGeneral')
.getAutoDownloadVideos();
if (autoDownload === flag) {
return flag;
}
await window
.require('WAWebUserPrefsGeneral')
.setAutoDownloadVideos(flag);
return flag;
}, flag);
}
/**
* Setting background synchronization.
* NOTE: this action will take effect after you restart the client.
* @param {boolean} flag true/false
* @returns {Promise<boolean>}
*/
async setBackgroundSync(flag) {
return await this.pupPage.evaluate(async (flag) => {
const backSync = window
.require('WAWebUserPrefsNotifications')
.getGlobalOfflineNotifications();
if (backSync === flag) {
return flag;
}
await window
.require('WAWebUserPrefsNotifications')
.setGlobalOfflineNotifications(flag);
return flag;
}, flag);
}
/**
* Get user device count by ID
* Each WaWeb Connection counts as one device, and the phone (if exists) counts as one
* So for a non-enterprise user with one WaWeb connection it should return "2"
* @param {string} userId
* @returns {Promise<number>}
*/
async getContactDeviceCount(userId) {
return await this.pupPage.evaluate(async (userId) => {
const devices = await window
.require('WAWebApiDeviceList')
.getDeviceIds([
window.require('WAWebWidFactory').createWid(userId),
]);
if (
devices &&
devices.length &&
devices[0] != null &&
typeof devices[0].devices == 'object'
) {
return devices[0].devices.length;
}
return 0;
}, userId);
}
/**
* Sync chat history conversation
* @param {string} chatId
* @return {Promise<boolean>} True if operation completed successfully, false otherwise.
*/
async syncHistory(chatId) {
return await this.pupPage.evaluate(async (chatId) => {
const chatWid = window.require('WAWebWidFactory').createWid(chatId);
const chat =
window.require('WAWebCollections').Chat.get(chatWid) ??
(await window.require('WAWebCollections').Chat.find(chatWid));
if (chat?.endOfHistoryTransferType === 0) {
await window
.require('WAWebSendNonMessageDataRequest')
.sendPeerDataOperationRequest(3, {
chatId: chat.id,
});
return true;
}
return false;
}, chatId);
}
/**
* Generates a WhatsApp call link (video call or voice call)
* @param {Date} startTime The start time of the call
* @param {string} callType The type of a WhatsApp call link to generate, valid values are: `video` | `voice`
* @returns {Promise<string>} The WhatsApp call link (https://call.whatsapp.com/video/XxXxXxXxXxXxXx) or an empty string if a generation failed.
*/
async createCallLink(startTime, callType) {
if (!['video', 'voice'].includes(callType)) {
throw new (class CreateCallLinkError extends Error {
constructor(m) {
super(m);
}
})(
"Invalid 'callType' parameter value is provided. Valid values are: 'voice' | 'video'.",
);
}
startTime = Math.floor(startTime.getTime() / 1000);
return await this.pupPage.evaluate(
async (startTimeTs, callType) => {
const response = await window
.require('WAWebGenerateEventCallLink')
.createEventCallLink(startTimeTs, callType);
return response ?? '';
},
startTime,
callType,
);
}
/**
* Sends a response to the scheduled event message, indicating whether a user is going to attend the event or not
* @param {number} response The response code to the scheduled event message. Valid values are: `0` for NONE response (removes a previous response) | `1` for GOING | `2` for NOT GOING | `3` for MAYBE going
* @param {string} eventMessageId The scheduled event message ID
* @returns {Promise<boolean>}
*/
async sendResponseToScheduledEvent(response, eventMessageId) {
if (![0, 1, 2, 3].includes(response)) return false;
return await this.pupPage.evaluate(
async (response, msgId) => {
const eventMsg =
window.require('WAWebCollections').Msg.get(msgId) ||
(
await window
.require('WAWebCollections')
.Msg.getMessagesById([msgId])
)?.messages?.[0];
if (!eventMsg) return false;
await window
.require('WAWebSendEventResponseMsgAction')
.sendEventResponseMsg(response, eventMsg);
return true;
},
response,
eventMessageId,
);
}
/**
* Save new contact to user's addressbook or edit the existing one
* @param {string} phoneNumber The contact's phone number in a format "17182222222", where "1" is a country code
* @param {string} firstName
* @param {string} lastName
* @param {boolean} [syncToAddressbook = false] If set to true, the contact will also be saved to the user's address book on their phone. False by default
* @returns {Promise<void>}
*/
async saveOrEditAddressbookContact(
phoneNumber,
firstName,
lastName,
syncToAddressbook = false,
) {
return await this.pupPage.evaluate(
async (phoneNumber, firstName, lastName, syncToAddressbook) => {
return await window
.require('WAWebSaveContactAction')
.saveContactAction({
firstName: firstName,
lastName: lastName,
phoneNumber: phoneNumber,
prevPhoneNumber: phoneNumber,
syncToAddressbook: syncToAddressbook,
username: undefined,
});
},
phoneNumber,
firstName,
lastName,
syncToAddressbook,
);
}
/**
* Deletes the contact from user's addressbook
* @param {string} phoneNumber The contact's phone number in a format "17182222222", where "1" is a country code
* @returns {Promise<void>}
*/
async deleteAddressbookContact(phoneNumber) {
return await this.pupPage.evaluate(async (phoneNumber) => {
const wid = window
.require('WAWebWidFactory')
.createWid(phoneNumber);
return await window
.require('WAWebDeleteContactAction')
.deleteContactAction({ phoneNumber: wid });
}, phoneNumber);
}
/**
* Get lid and phone number for multiple users
* @param {string[]} userIds - Array of user IDs
* @returns {Promise<Array<{ lid: string, pn: string }>>}
*/
async getContactLidAndPhone(userIds) {
return await this.pupPage.evaluate(async (userIds) => {
if (!Array.isArray(userIds)) userIds = [userIds];
return await Promise.all(
userIds.map(async (userId) => {
const { lid, phone } =
await window.WWebJS.enforceLidAndPnRetrieval(userId);
return {
lid: lid?._serialized,
pn: phone?._serialized,
};
}),
);
}, userIds);
}
/**
* Add or edit a customer note
* @see https://faq.whatsapp.com/1433099287594476
* @param {string} userId The ID of a customer to add a note to
* @param {string} note The note to add
* Customer notes are available only to eligible WhatsApp Business accounts.
* @returns {Promise<void>}
*/
async addOrEditCustomerNote(userId, note) {
return await this.pupPage.evaluate(
async (userId, note) => {
if (!window.WWebJS?.isCustomerNotesEnabled?.()) return;
return window
.require('WAWebNoteAction')
.noteAddAction(
'unstructured',
window
.require('WAWebWidToJid')
.widToUserJid(
window
.require('WAWebWidFactory')
.createWid(userId),
),
note,
);
},
userId,
note,
);
}
/**
* Get a customer note
* @see https://faq.whatsapp.com/1433099287594476
* @param {string} userId The ID of a customer to get a note from
* @returns {Promise<{
* chatId: string,
* content: string,
* createdAt: number,
* id: string,
* modifiedAt: number,
* type: string
* }|null>} Null when customer notes are unavailable for the account.
*/
async getCustomerNote(userId) {
return await this.pupPage.evaluate(async (userId) => {
if (!window.WWebJS?.isCustomerNotesEnabled?.()) return null;
const note = await window
.require('WAWebNoteAction')
.retrieveOnlyNoteForChatJid(
window
.require('WAWebWidToJid')
.widToUserJid(
window.require('WAWebWidFactory').createWid(userId),
),
);
let serialized = note?.serialize();
if (!serialized) return null;
serialized.chatId = window
.require('WAWebJidToWid')
.userJidToUserWid(serialized.chatJid)._serialized;
delete serialized.chatJid;
return serialized;
}, userId);
}
/**
* Get Poll Votes
* @param {string} messageId
* @return {Promise<Array<PollVote>>}
*/
async getPollVotes(messageId) {
const msg = await this.getMessageById(messageId);
if (!msg) return [];
if (msg.type != MessageTypes.POLL_CREATION)
throw 'Invalid usage! Can only be used with a pollCreation message';
const pollVotes = await this.pupPage.evaluate(async (msg) => {
const msgKey = window
.require('WAWebMsgKey')
.fromString(msg.id._serialized);
let pollVotes = await window
.require('WAWebPollsVotesSchema')
.getTable()
.equals(['parentMsgKey'], msgKey.toString());
const toNormalizedMessageId = (keyLike) => {
if (!keyLike) return null;
if (typeof keyLike === 'object') {
return window.WWebJS.normalizeMessageId(keyLike);
}
if (typeof keyLike === 'string') {
try {
const parsed = window
.require('WAWebMsgKey')
.fromString(keyLike);
return window.WWebJS.normalizeMessageId(parsed);
} catch {
return null;
}
}
return null;
};
const fallbackParentMsgKey = window.WWebJS.normalizeMessageId(
msg.id,
);
return pollVotes.map((item) => {
const typedArray = new Uint8Array(item.selectedOptionLocalIds);
const parentMsgKey =
toNormalizedMessageId(item.parentMsgKey) ||
fallbackParentMsgKey;
return {
...item,
parentMsgKey,
selectedOptionLocalIds: Array.from(typedArray),
};
});
}, msg);
return pollVotes.map(
(pollVote) =>
new PollVote(this.client, { ...pollVote, parentMessage: msg }),
);
}
}
module.exports = Client;