/** * Identity Change Handler * * Handles WhatsApp identity change notifications which occur when a contact's * encryption keys change. This typically happens when: * - User reinstalls WhatsApp * - User changes devices * - User's phone number verification expires and is re-verified * * This module centralizes the identity change logic to: * - Prevent duplicate session refreshes via debouncing * - Skip unnecessary refreshes for companion devices * - Handle offline notification scenarios correctly * - Provide clear result types for monitoring and debugging * * @module identity-change-handler * @see https://github.com/WhiskeySockets/Baileys/issues/2132 */ import NodeCache from '@cacheable/node-cache' import { areJidsSameUser, type BinaryNode, getBinaryNodeChild, jidDecode } from '../WABinary' import { isStringNullOrEmpty } from './generics' import type { ILogger } from './logger' // ============================================================================ // TYPES // ============================================================================ /** * Result type for identity change handling operations. * Each action variant indicates a specific outcome with relevant context. */ export type IdentityChangeResult = | { action: 'no_identity_node' } | { action: 'invalid_notification' } | { action: 'skipped_companion_device'; device: number } | { action: 'skipped_self_primary' } | { action: 'debounced' } | { action: 'skipped_offline' } | { action: 'session_refreshed'; hadExistingSession: boolean } | { action: 'session_refresh_failed'; error: unknown } /** * Context required for identity change handling. * Provides access to session management and logging capabilities. */ export type IdentityChangeContext = { /** Current user's phone number JID (e.g., "5511999999999@s.whatsapp.net") */ meId: string | undefined /** Current user's LID (Logical ID) if available */ meLid: string | undefined /** Function to validate if a session exists for a JID */ validateSession: (jid: string) => Promise<{ exists: boolean; reason?: string }> /** Function to assert/refresh sessions for given JIDs */ assertSessions: (jids: string[], force?: boolean) => Promise /** Cache for debouncing identity change processing */ debounceCache: NodeCache /** Logger instance for debugging and monitoring */ logger: ILogger /** * Invoked right before `assertSessions` is called for an existing-session identity * change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance) * in the same order WA Web does — i.e. before the E2E session is re-established. * Must not throw; implementations are responsible for their own error handling. * * Skipped when the refresh itself is skipped (no_identity_node, invalid_notification, * skipped_companion_device, skipped_self_primary, debounced, skipped_offline). */ onBeforeSessionRefresh?: (jid: string) => void } // ============================================================================ // MAIN HANDLER // ============================================================================ /** * Handles an identity change notification from WhatsApp. * * This function processes identity change events with the following logic: * * 1. **Validation**: Ensures the notification has required fields (from, identity node) * 2. **Companion Device Filter**: Skips notifications from companion devices (device > 0) * as they don't require session refresh * 3. **Self-Primary Skip**: Skips notifications for the current user's own identity * 4. **Debouncing**: Prevents duplicate processing for the same JID within TTL window * 5. **Offline Handling**: Skips refresh during offline notification processing * 6. **Session Refresh**: Attempts to refresh/create the session with force flag * * **Important**: Identity change notifications signal that we need to rebuild the session, * regardless of whether an existing session exists. This is critical for cases where * the local session was cleared (e.g., after key reset or device restore). * * @param node - The binary node containing the identity change notification * @param ctx - Context object with required dependencies * @returns Promise resolving to the result of the identity change handling * * @example * ```typescript * const result = await handleIdentityChange(notificationNode, { * meId: '5511999999999@s.whatsapp.net', * meLid: undefined, * validateSession: async (jid) => authState.keys.get('session', [jid]), * assertSessions: async (jids, force) => assertSession(jids, force), * debounceCache: new NodeCache({ stdTTL: 5 }), * logger: pino() * }) * * switch (result.action) { * case 'session_refreshed': * console.log('Session refreshed, had existing:', result.hadExistingSession) * break * case 'session_refresh_failed': * console.error('Failed to refresh session:', result.error) * break * // ... handle other cases * } * ``` */ export async function handleIdentityChange( node: BinaryNode, ctx: IdentityChangeContext ): Promise { const from = node.attrs.from if (!from) { return { action: 'invalid_notification' } } // Check for identity node presence const identityNode = getBinaryNodeChild(node, 'identity') if (!identityNode) { return { action: 'no_identity_node' } } ctx.logger.info({ jid: from }, 'identity changed') // Skip companion devices - they don't need session refresh const decoded = jidDecode(from) if (decoded?.device && decoded.device !== 0) { ctx.logger.debug({ jid: from, device: decoded.device }, 'ignoring identity change from companion device') return { action: 'skipped_companion_device', device: decoded.device } } // Skip self-primary identity changes // FIX: Check meId and meLid independently to handle cases where only meLid exists const matchesMeId = ctx.meId && areJidsSameUser(from, ctx.meId) const matchesMeLid = ctx.meLid && areJidsSameUser(from, ctx.meLid) if (matchesMeId || matchesMeLid) { ctx.logger.info({ jid: from }, 'self primary identity changed') return { action: 'skipped_self_primary' } } // Debounce to prevent duplicate processing // Check early but don't set yet - we only want to debounce actual refresh attempts if (ctx.debounceCache.get(from)) { ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)') return { action: 'debounced' } } // Skip refresh during offline notification processing // Offline notifications are processed in batch and shouldn't trigger immediate refreshes // FIX: Check this BEFORE setting debounce cache to avoid incorrect debouncing const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline) if (isOfflineNotification) { ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing') return { action: 'skipped_offline' } } // Check if we have an existing session (for logging purposes only) // FIX: We no longer skip refresh when no session exists - identity change is the // signal to rebuild the session, which is critical after key reset or device restore const hasExistingSession = await ctx.validateSession(from) if (hasExistingSession.exists) { ctx.logger.debug({ jid: from }, 'existing session found, will refresh') } else { ctx.logger.debug({ jid: from }, 'no existing session, will create new session') } // FIX: Set debounce cache only immediately before the actual refresh attempt // This ensures we don't incorrectly debounce when we exit early (offline, etc.) ctx.debounceCache.set(from, true) // Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is // re-established. WA Web runs these in parallel with the session refresh — // running afterwards would race with the next outbound send and risk error 463. // // Wrapped in try/catch so a misbehaving consumer callback cannot abort identity // change recovery. We log and continue — assertSessions still runs so the E2E // session always gets refreshed. try { ctx.onBeforeSessionRefresh?.(from) } catch (error) { ctx.logger.warn({ error, jid: from }, 'onBeforeSessionRefresh callback threw — continuing with session refresh') } // Attempt session refresh/creation try { await ctx.assertSessions([from], true) return { action: 'session_refreshed', hadExistingSession: hasExistingSession.exists } } catch (error) { ctx.logger.warn({ error, jid: from }, 'failed to assert sessions after identity change') return { action: 'session_refresh_failed', error } } }