feat: add identity change handler for improved connection stability

feat: add identity change handler for improved connection stability
This commit is contained in:
Renato Alcara
2026-01-22 16:01:11 -03:00
committed by GitHub
5 changed files with 494 additions and 16 deletions
+13 -16
View File
@@ -36,6 +36,7 @@ import {
getHistoryMsg,
getNextPreKeys,
getStatusFromReceiptType,
handleIdentityChange,
hkdf,
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
@@ -564,21 +565,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await uploadPreKeys()
}
} else {
const identityNode = getBinaryNodeChild(node, 'identity')
if (identityNode) {
logger.info({ jid: from }, 'identity changed')
if (identityAssertDebounce.get(from!)) {
logger.debug({ jid: from }, 'skipping identity assert (debounced)')
return
}
const result = await handleIdentityChange(node, {
meId: authState.creds.me?.id,
meLid: authState.creds.me?.lid,
validateSession: signalRepository.validateSession,
assertSessions,
debounceCache: identityAssertDebounce,
logger
})
identityAssertDebounce.set(from!, true)
try {
await assertSessions([from!], true)
} catch (error) {
logger.warn({ error, jid: from }, 'failed to assert sessions after identity change')
}
} else {
if (result.action === 'no_identity_node') {
logger.info({ node }, 'unknown encrypt notification')
}
}
@@ -1239,9 +1235,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await decrypt()
// message failed to decrypt
if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
// Handle "Missing keys" - standard decryption failure, just ACK
// Handle "Missing keys" - standard decryption failure
// Return NACK with parsing error to signal the issue
if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) {
return sendMessageAck(node)
return sendMessageAck(node, NACK_REASONS.ParsingError)
}
// Handle "Message absent from node" - likely a CTWA (Click-to-WhatsApp) ads message
+4
View File
@@ -50,6 +50,10 @@ export const BufferJSON = {
export const getKeyAuthor = (key: WAMessageKey | undefined | null, meId = 'me') =>
(key?.fromMe ? meId : key?.participantAlt || key?.remoteJidAlt || key?.participant || key?.remoteJid) || ''
export const isStringNullOrEmpty = (value: string | null | undefined): value is null | undefined | '' =>
// eslint-disable-next-line eqeqeq
value == null || value === ''
export const writeRandomPadMax16 = (msg: Uint8Array) => {
const pad = randomBytes(1)
const padLength = (pad[0]! & 0x0f) + 1
+187
View File
@@ -0,0 +1,187 @@
/**
* 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<boolean>
/** Cache for debouncing identity change processing */
debounceCache: NodeCache<boolean>
/** Logger instance for debugging and monitoring */
logger: ILogger
}
// ============================================================================
// 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<IdentityChangeResult> {
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)
// 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 }
}
}
+3
View File
@@ -17,6 +17,9 @@ export * from './process-message'
export * from './message-retry-manager'
export * from './browser-utils'
// === Identity and Session Management ===
export * from './identity-change-handler'
// === Observability and Resilience Utilities ===
// Structured logging
@@ -0,0 +1,287 @@
import NodeCache from '@cacheable/node-cache'
import { jest } from '@jest/globals'
import P from 'pino'
import { handleIdentityChange, type IdentityChangeContext, type IdentityChangeResult } from '../../Utils/identity-change-handler'
import { type BinaryNode } from '../../WABinary'
const logger = P({ level: 'silent' })
type ValidateSessionFn = (jid: string) => Promise<{ exists: boolean; reason?: string }>
type AssertSessionsFn = (jids: string[], force?: boolean) => Promise<boolean>
describe('Identity Change Handling', () => {
let mockValidateSession: jest.Mock<ValidateSessionFn>
let mockAssertSessions: jest.Mock<AssertSessionsFn>
let identityAssertDebounce: NodeCache<boolean>
let mockMeId: string | undefined
let mockMeLid: string | undefined
function createIdentityChangeNode(from: string, offline?: string): BinaryNode {
return {
tag: 'notification',
attrs: {
from,
type: 'encrypt',
...(offline !== undefined ? { offline } : {})
},
content: [
{
tag: 'identity',
attrs: {},
content: Buffer.from('test-identity-key')
}
]
}
}
function createContext(): IdentityChangeContext {
return {
meId: mockMeId,
meLid: mockMeLid,
validateSession: mockValidateSession,
assertSessions: mockAssertSessions,
debounceCache: identityAssertDebounce,
logger
}
}
beforeEach(() => {
jest.clearAllMocks()
mockValidateSession = jest.fn()
mockAssertSessions = jest.fn()
identityAssertDebounce = new NodeCache<boolean>({ stdTTL: 5, useClones: false })
mockMeId = 'myuser@s.whatsapp.net'
mockMeLid = 'mylid@lid'
})
describe('Core Checks', () => {
it('should skip companion devices (device > 0)', async () => {
const node = createIdentityChangeNode('user:5@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(mockValidateSession).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_companion_device')
})
it('should process primary device (device 0 or undefined)', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('session_refreshed')
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true)
})
it('should skip self-primary identity (PN match)', async () => {
const node = createIdentityChangeNode('myuser@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(mockValidateSession).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_self_primary')
})
it('should skip self-primary identity (LID match)', async () => {
const node = createIdentityChangeNode('mylid@lid')
const result = await handleIdentityChange(node, createContext())
expect(mockValidateSession).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_self_primary')
})
it('should skip self-primary identity when only meLid exists', async () => {
// FIX: Test for the case where meId is undefined but meLid matches
mockMeId = undefined
mockMeLid = 'mylid@lid'
const node = createIdentityChangeNode('mylid@lid')
const result = await handleIdentityChange(node, createContext())
expect(mockValidateSession).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_self_primary')
})
it('should create session when no existing session exists', async () => {
// FIX: Identity change is the signal to rebuild session, even if none exists
// This is critical for key reset or device restore scenarios
mockValidateSession.mockResolvedValue({ exists: false })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true)
expect(result.action).toBe('session_refreshed')
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(false)
})
it('should skip session refresh during offline processing', async () => {
const node = createIdentityChangeNode('user@s.whatsapp.net', '0')
const result = await handleIdentityChange(node, createContext())
// FIX: validateSession should not be called for offline notifications
// because we skip before reaching that point
expect(mockAssertSessions).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_offline')
})
it('should refresh session when online with existing session', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true)
expect(result.action).toBe('session_refreshed')
expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true)
})
})
describe('Debounce', () => {
it('should debounce multiple identity changes for the same JID', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result1 = await handleIdentityChange(node, createContext())
expect(result1.action).toBe('session_refreshed')
const result2 = await handleIdentityChange(node, createContext())
expect(result2.action).toBe('debounced')
expect(mockAssertSessions).toHaveBeenCalledTimes(1)
})
it('should allow different JIDs to process independently', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const result1 = await handleIdentityChange(createIdentityChangeNode('user1@s.whatsapp.net'), createContext())
const result2 = await handleIdentityChange(createIdentityChangeNode('user2@s.whatsapp.net'), createContext())
expect(result1.action).toBe('session_refreshed')
expect(result2.action).toBe('session_refreshed')
expect(mockAssertSessions).toHaveBeenCalledTimes(2)
})
it('should NOT set debounce cache when skipping due to offline', async () => {
// FIX: Debounce should only be set when we actually attempt refresh
const node = createIdentityChangeNode('user@s.whatsapp.net', '0')
const result1 = await handleIdentityChange(node, createContext())
expect(result1.action).toBe('skipped_offline')
// Now process the same JID online - it should NOT be debounced
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const onlineNode = createIdentityChangeNode('user@s.whatsapp.net')
const result2 = await handleIdentityChange(onlineNode, createContext())
expect(result2.action).toBe('session_refreshed')
expect(mockAssertSessions).toHaveBeenCalledTimes(1)
})
})
describe('Error Handling', () => {
it('should handle assertSessions failure gracefully', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
const testError = new Error('Session assertion failed')
mockAssertSessions.mockRejectedValue(testError)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('session_refresh_failed')
expect((result as { error: unknown }).error).toBe(testError)
})
it('should propagate validateSession errors', async () => {
mockValidateSession.mockRejectedValue(new Error('Database error'))
const node = createIdentityChangeNode('user@s.whatsapp.net')
await expect(handleIdentityChange(node, createContext())).rejects.toThrow('Database error')
})
})
describe('Edge Cases', () => {
it('should return invalid_notification when from is missing', async () => {
const node: BinaryNode = {
tag: 'notification',
attrs: { type: 'encrypt' },
content: [{ tag: 'identity', attrs: {}, content: Buffer.from('key') }]
}
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('invalid_notification')
})
it('should return no_identity_node when identity child is missing', async () => {
const node: BinaryNode = {
tag: 'notification',
attrs: { from: 'user@s.whatsapp.net', type: 'encrypt' },
content: []
}
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('no_identity_node')
})
it('should handle LID JIDs correctly', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('12345@lid')
const result = await handleIdentityChange(node, createContext())
expect(mockValidateSession).toHaveBeenCalledWith('12345@lid')
expect(result.action).toBe('session_refreshed')
})
it('should process when both meId and meLid are undefined', async () => {
mockMeId = undefined
mockMeLid = undefined
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('anyuser@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(result.action).toBe('session_refreshed')
})
})
describe('Result Types', () => {
it('should include hadExistingSession in session_refreshed result', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext()) as Extract<IdentityChangeResult, { action: 'session_refreshed' }>
expect(result.action).toBe('session_refreshed')
expect(result.hadExistingSession).toBe(true)
})
it('should return hadExistingSession=false when creating new session', async () => {
mockValidateSession.mockResolvedValue({ exists: false })
mockAssertSessions.mockResolvedValue(true)
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext()) as Extract<IdentityChangeResult, { action: 'session_refreshed' }>
expect(result.action).toBe('session_refreshed')
expect(result.hadExistingSession).toBe(false)
})
it('should include device number in skipped_companion_device result', async () => {
const node = createIdentityChangeNode('user:5@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext()) as Extract<IdentityChangeResult, { action: 'skipped_companion_device' }>
expect(result.action).toBe('skipped_companion_device')
expect(result.device).toBe(5)
})
})
})