Fix connection showing "Online" but disconnected (#2132) (#2264)

* fix(messages): handle identity change notifications correctly (#2132)

* fix: tests and linting, add a helper like waweb
This commit is contained in:
João Lucas de Oliveira Lopes
2026-01-22 09:54:10 -03:00
committed by GitHub
parent 1ef04d5329
commit 5cbad3170b
5 changed files with 310 additions and 18 deletions
+15 -18
View File
@@ -33,6 +33,7 @@ import {
getHistoryMsg,
getNextPreKeys,
getStatusFromReceiptType,
handleIdentityChange,
hkdf,
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
@@ -550,21 +551,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')
}
}
@@ -1226,10 +1222,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await decrypt()
// message failed to decrypt
if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
if (
msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT ||
msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT
) {
if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) {
return sendMessageAck(node, NACK_REASONS.ParsingError)
}
if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
return sendMessageAck(node)
}
+4
View File
@@ -47,6 +47,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
+83
View File
@@ -0,0 +1,83 @@
import NodeCache from '@cacheable/node-cache'
import { areJidsSameUser, type BinaryNode, getBinaryNodeChild, jidDecode } from '../WABinary'
import { isStringNullOrEmpty } from './generics'
import type { ILogger } from './logger'
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 }
export type IdentityChangeContext = {
meId: string | undefined
meLid: string | undefined
validateSession: (jid: string) => Promise<{ exists: boolean; reason?: string }>
assertSessions: (jids: string[], force?: boolean) => Promise<boolean>
debounceCache: NodeCache<boolean>
logger: ILogger
}
export async function handleIdentityChange(
node: BinaryNode,
ctx: IdentityChangeContext
): Promise<IdentityChangeResult> {
const from = node.attrs.from
if (!from) {
return { action: 'invalid_notification' }
}
const identityNode = getBinaryNodeChild(node, 'identity')
if (!identityNode) {
return { action: 'no_identity_node' }
}
ctx.logger.info({ jid: from }, 'identity changed')
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 }
}
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' }
}
if (ctx.debounceCache.get(from)) {
ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)')
return { action: 'debounced' }
}
ctx.debounceCache.set(from, true)
const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline)
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')
if (isOfflineNotification) {
ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing')
return { action: 'skipped_offline' }
}
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 }
}
}
+1
View File
@@ -16,3 +16,4 @@ export * from './event-buffer'
export * from './process-message'
export * from './message-retry-manager'
export * from './browser-utils'
export * from './identity-change-handler'
@@ -0,0 +1,207 @@
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 { 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
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')
})
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 when no existing session', async () => {
mockValidateSession.mockResolvedValue({ exists: false })
const node = createIdentityChangeNode('user@s.whatsapp.net')
const result = await handleIdentityChange(node, createContext())
expect(mockAssertSessions).not.toHaveBeenCalled()
expect(result.action).toBe('skipped_no_session')
})
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())
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')
})
})
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)
})
})
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')
})
})
})