diff --git a/src/Utils/identity-change-handler.ts b/src/Utils/identity-change-handler.ts index d185b068..08511212 100644 --- a/src/Utils/identity-change-handler.ts +++ b/src/Utils/identity-change-handler.ts @@ -37,8 +37,7 @@ export type IdentityChangeResult = | { action: 'skipped_self_primary' } | { action: 'debounced' } | { action: 'skipped_offline' } - | { action: 'skipped_no_session' } - | { action: 'session_refreshed' } + | { action: 'session_refreshed'; hadExistingSession: boolean } | { action: 'session_refresh_failed'; error: unknown } /** @@ -74,9 +73,12 @@ export type IdentityChangeContext = { * 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 + * 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 @@ -95,7 +97,7 @@ export type IdentityChangeContext = { * * switch (result.action) { * case 'session_refreshed': - * console.log('Session successfully refreshed') + * console.log('Session refreshed, had existing:', result.hadExistingSession) * break * case 'session_refresh_failed': * console.error('Failed to refresh session:', result.error) @@ -132,45 +134,49 @@ export async function handleIdentityChange( } // Skip self-primary identity changes - const isSelfPrimary = ctx.meId && ( - areJidsSameUser(from, ctx.meId) || - (ctx.meLid && areJidsSameUser(from, ctx.meLid)) - ) - if (isSelfPrimary) { + // 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' } } - // 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 + // 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' } } - // Attempt session refresh + // 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' } + return { action: 'session_refreshed', hadExistingSession: hasExistingSession.exists } } catch (error) { ctx.logger.warn( { error, jid: from }, diff --git a/src/__tests__/Socket/identity-change-handling.test.ts b/src/__tests__/Socket/identity-change-handling.test.ts index 3f12ae14..74f9751b 100644 --- a/src/__tests__/Socket/identity-change-handling.test.ts +++ b/src/__tests__/Socket/identity-change-handling.test.ts @@ -1,7 +1,7 @@ import NodeCache from '@cacheable/node-cache' import { jest } from '@jest/globals' import P from 'pino' -import { handleIdentityChange, type IdentityChangeContext } from '../../Utils/identity-change-handler' +import { handleIdentityChange, type IdentityChangeContext, type IdentityChangeResult } from '../../Utils/identity-change-handler' import { type BinaryNode } from '../../WABinary' const logger = P({ level: 'silent' }) @@ -13,7 +13,7 @@ describe('Identity Change Handling', () => { let mockValidateSession: jest.Mock let mockAssertSessions: jest.Mock let identityAssertDebounce: NodeCache - let mockMeId: string + let mockMeId: string | undefined let mockMeLid: string | undefined function createIdentityChangeNode(from: string, offline?: string): BinaryNode { @@ -71,6 +71,7 @@ describe('Identity Change Handling', () => { 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 () => { @@ -89,22 +90,38 @@ describe('Identity Change Handling', () => { expect(result.action).toBe('skipped_self_primary') }) - it('should skip when no existing session', async () => { + 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).not.toHaveBeenCalled() - expect(result.action).toBe('skipped_no_session') + 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 () => { - mockValidateSession.mockResolvedValue({ exists: true }) - 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') }) @@ -118,6 +135,7 @@ describe('Identity Change Handling', () => { expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) expect(result.action).toBe('session_refreshed') + expect((result as { hadExistingSession: boolean }).hadExistingSession).toBe(true) }) }) @@ -147,6 +165,24 @@ describe('Identity Change Handling', () => { 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', () => { @@ -203,5 +239,49 @@ describe('Identity Change Handling', () => { 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 + + 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 + + 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 + + expect(result.action).toBe('skipped_companion_device') + expect(result.device).toBe(5) + }) }) })