feat: add identity change handler for improved connection stability

Cherry-pick from upstream PR #2264 (commit 5cbad317) with enhancements:

## Changes

### New Files
- `src/Utils/identity-change-handler.ts`: Centralized identity change handling
  - Enterprise-grade JSDoc documentation
  - Clear result types for all 9 possible outcomes
  - Debouncing to prevent duplicate session refreshes
  - Companion device filtering
  - Offline notification handling

- `src/__tests__/Socket/identity-change-handling.test.ts`: Comprehensive tests
  - Core functionality tests
  - Debounce behavior tests
  - Error handling tests
  - Edge case coverage

### Modified Files
- `src/Socket/messages-recv.ts`: Updated error handling
  - MISSING_KEYS_ERROR now returns NACK with ParsingError
  - Maintains CTWA recovery logic intact
  - Preserves all Prometheus metrics integration

- `src/Utils/generics.ts`: Added `isStringNullOrEmpty()` helper
- `src/Utils/index.ts`: Added identity-change-handler export

## Benefits
- Fixes "Online" status shown when actually disconnected (#2132)
- Better session management after contact identity changes
- Clear separation of concerns with dedicated handler module

Co-authored-by: João Lucas de Oliveira Lopes <55464917+jlucaso1@users.noreply.github.com>
This commit is contained in:
Claude
2026-01-22 18:34:19 +00:00
parent 9e1e67df6a
commit c4da46321f
5 changed files with 408 additions and 16 deletions
+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
+181
View File
@@ -0,0 +1,181 @@
/**
* 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: 'skipped_no_session' }
| { action: 'session_refreshed' }
| { 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. **Session Check**: Verifies an existing session exists before attempting refresh
* 6. **Offline Handling**: Skips refresh during offline notification processing
* 7. **Session Refresh**: Attempts to refresh the session with force flag
*
* @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 successfully refreshed')
* 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
const isSelfPrimary = ctx.meId && (
areJidsSameUser(from, ctx.meId) ||
(ctx.meLid && areJidsSameUser(from, ctx.meLid))
)
if (isSelfPrimary) {
ctx.logger.info({ jid: from }, 'self primary identity changed')
return { action: 'skipped_self_primary' }
}
// Debounce to prevent duplicate processing
if (ctx.debounceCache.get(from)) {
ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)')
return { action: 'debounced' }
}
// Mark as processing in debounce cache
ctx.debounceCache.set(from, true)
// Check if we have an existing session to refresh
const hasExistingSession = await ctx.validateSession(from)
if (!hasExistingSession.exists) {
ctx.logger.debug({ jid: from }, 'no old session, skipping session refresh')
return { action: 'skipped_no_session' }
}
ctx.logger.debug({ jid: from }, 'old session exists, will refresh session')
// Skip refresh during offline notification processing
// Offline notifications are processed in batch and shouldn't trigger immediate refreshes
const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline)
if (isOfflineNotification) {
ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing')
return { action: 'skipped_offline' }
}
// Attempt session refresh
try {
await ctx.assertSessions([from], true)
return { action: 'session_refreshed' }
} 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