9ff21db749
feat(tctoken): complete lifecycle (TIER 1 + 2 + 3 of upstream PR)
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
/**
|
|
* 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
|
|
/**
|
|
* 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<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)
|
|
|
|
// 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 }
|
|
}
|
|
}
|