From 75b0ba26521a6e31426f578587ce901b1d41b331 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 10:29:06 +0200 Subject: [PATCH 01/14] chat-utils,sync-action-utils: provide alternatives for the contact name --- src/Utils/chat-utils.ts | 6 +++++- src/Utils/sync-action-utils.ts | 2 +- src/__tests__/Utils/sync-action-utils.test.ts | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 04cd8547..0b8787de 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -930,7 +930,11 @@ export const processSyncAction = ( ev.emit('contacts.upsert', [ { id: id!, - name: action.lidContactAction.fullName || undefined, + name: + action.lidContactAction.fullName || + action.lidContactAction.firstName || + action.lidContactAction.username || + undefined, lid: id!, phoneNumber: undefined } diff --git a/src/Utils/sync-action-utils.ts b/src/Utils/sync-action-utils.ts index d53b00fb..14ecd310 100644 --- a/src/Utils/sync-action-utils.ts +++ b/src/Utils/sync-action-utils.ts @@ -45,7 +45,7 @@ export const processContactAction = ( data: [ { id, - name: action.fullName || undefined, + name: action.fullName || action.firstName || action.username || undefined, lid: lidJid || undefined, phoneNumber } diff --git a/src/__tests__/Utils/sync-action-utils.test.ts b/src/__tests__/Utils/sync-action-utils.test.ts index 5c19d29e..fbf54303 100644 --- a/src/__tests__/Utils/sync-action-utils.test.ts +++ b/src/__tests__/Utils/sync-action-utils.test.ts @@ -2,7 +2,6 @@ import { jest } from '@jest/globals' import type { ILogger } from '../../Utils/logger' import { processContactAction } from '../../Utils/sync-action-utils' - describe('processContactAction', () => { const mockLogger: ILogger = { warn: jest.fn(), From 506017b0c4bfc3ad26e8b97964a9b6321a1cbe95 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 14:12:25 +0200 Subject: [PATCH 02/14] example: revamp logging for example --- Example/example.ts | 51 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/Example/example.ts b/Example/example.ts index 3f075c8c..782039ff 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -48,7 +48,7 @@ const startSock = async() => { } // fetch latest version of WA Web const { version, isLatest } = await fetchLatestBaileysVersion() - console.log(`using WA v${version.join('.')}, isLatest: ${isLatest}`) + logger.debug({version: version.join('.'), isLatest}, `using latest WA version`) const sock = makeWASocket({ version, @@ -83,7 +83,7 @@ const startSock = async() => { if((lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) { startSock() } else { - console.log('Connection closed. You are logged out.') + logger.fatal('Connection closed. You are logged out.') } } @@ -96,43 +96,44 @@ const startSock = async() => { } } - console.log('connection update', update) + logger.debug(update, 'connection update') } // credentials updated -- save them if(events['creds.update']) { await saveCreds() + logger.debug({}, 'creds save triggered') } if(events['labels.association']) { - console.log(events['labels.association']) + logger.debug(events['labels.association'], 'labels.association event fired') } if(events['labels.edit']) { - console.log(events['labels.edit']) + logger.debug(events['labels.edit'], 'labels.edit event fired') } - if(events.call) { - console.log('recv call event', events.call) + if(events['call']) { + logger.debug(events['call'], 'call event fired') } // history received if(events['messaging-history.set']) { const { chats, contacts, messages, isLatest, progress, syncType } = events['messaging-history.set'] if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) { - console.log('received on-demand history sync, messages=', messages) + logger.debug(messages, 'received on-demand history sync') } - console.log(`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest}, progress: ${progress}%), type: ${syncType}`) + logger.debug({contacts: contacts.length, chats: chats.length, messages: messages.length, isLatest, progress, syncType: syncType?.toString() }, 'messaging-history.set event fired') } // received a new message if (events['messages.upsert']) { const upsert = events['messages.upsert'] - logger.debug(upsert, 'recv messages') + logger.debug(upsert, 'messages.upsert fired') if (!!upsert.requestId) { - console.log("placeholder message received for request of id=" + upsert.requestId, upsert) + logger.debug(upsert, 'placeholder request message received') } @@ -143,13 +144,13 @@ const startSock = async() => { const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text if (text == "requestPlaceholder" && !upsert.requestId) { const messageId = await sock.requestPlaceholderResend(msg.key) - console.log('requested placeholder resync, id=', messageId) + logger.debug({ id: messageId }, 'requested placeholder resync') } // go to an old chat and send this if (text == "onDemandHistSync") { const messageId = await sock.fetchMessageHistory(50, msg.key, msg.messageTimestamp!) - console.log('requested on-demand sync, id=', messageId) + logger.debug({ id: messageId }, 'requested on-demand history resync') } if (!msg.key.fromMe && doReplies && !isJidNewsletter(msg.key?.remoteJid!)) { @@ -164,9 +165,7 @@ const startSock = async() => { // messages updated like status delivered, message deleted etc. if(events['messages.update']) { - console.log( - JSON.stringify(events['messages.update'], undefined, 2) - ) + logger.debug(events['messages.update'], 'messages.update fired') for(const { key, update } of events['messages.update']) { if(update.pollUpdates) { @@ -185,19 +184,23 @@ const startSock = async() => { } if(events['message-receipt.update']) { - console.log(events['message-receipt.update']) + logger.debug(events['message-receipt.update']) + } + + if (events['contacts.upsert']) { + logger.debug(events['message-receipt.update']) } if(events['messages.reaction']) { - console.log(events['messages.reaction']) + logger.debug(events['messages.reaction']) } if(events['presence.update']) { - console.log(events['presence.update']) + logger.debug(events['presence.update']) } if(events['chats.update']) { - console.log(events['chats.update']) + logger.debug(events['chats.update']) } if(events['contacts.update']) { @@ -206,19 +209,17 @@ const startSock = async() => { const newUrl = contact.imgUrl === null ? null : await sock!.profilePictureUrl(contact.id!).catch(() => null) - console.log( - `contact ${contact.id} has a new profile pic: ${newUrl}`, - ) + logger.debug({id: contact.id, newUrl}, `contact has a new profile pic` ) } } } if(events['chats.delete']) { - console.log('chats deleted ', events['chats.delete']) + logger.debug('chats deleted ', events['chats.delete']) } if(events['group.member-tag.update']) { - console.log('group member tag update', JSON.stringify(events['group.member-tag.update'], undefined, 2)) + logger.debug('group member tag update', JSON.stringify(events['group.member-tag.update'], undefined, 2)) } } ) From c81c074dbd5048c82527577ac0fd475eec450df6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 14:13:00 +0200 Subject: [PATCH 03/14] defaults, index: change shouldSyncHistoryMessage behavior --- src/Defaults/index.ts | 4 +++- src/Socket/index.ts | 7 ------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index e46035d8..e940efa0 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -62,7 +62,9 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { markOnlineOnConnect: true, syncFullHistory: true, patchMessageBeforeSending: msg => msg, - shouldSyncHistoryMessage: () => true, + shouldSyncHistoryMessage: ({ syncType }: proto.Message.IHistorySyncNotification) => { + return syncType !== proto.HistorySync.HistorySyncType.FULL + }, shouldIgnoreJid: () => false, linkPreviewImageThumbnailWidth: 192, transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 }, diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 3ee2d850..97c4785e 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -9,13 +9,6 @@ const makeWASocket = (config: UserFacingSocketConfig) => { ...config } - // If the user hasn't provided their own history sync function, - // let's create a default one that respects the syncFullHistory flag. - // TODO: Change - if (config.shouldSyncHistoryMessage === undefined) { - newConfig.shouldSyncHistoryMessage = () => !!newConfig.syncFullHistory - } - return makeCommunitiesSocket(newConfig) } From d4e5b4167d0b728376d5537a38d28acf0947c411 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 14:13:33 +0200 Subject: [PATCH 04/14] history: fortify contact data --- src/Socket/messages-recv.ts | 1 + src/Utils/history.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 95957134..decff29c 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -755,6 +755,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const setPicture = getBinaryNodeChild(node, 'set') const delPicture = getBinaryNodeChild(node, 'delete') + // TODO: WAJIDHASH stuff proper support inhouse ev.emit('contacts.update', [ { id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '', diff --git a/src/Utils/history.ts b/src/Utils/history.ts index b8c1a504..7572940e 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -45,8 +45,8 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { for (const chat of item.conversations! as Chat[]) { contacts.push({ id: chat.id!, - name: chat.name || undefined, - lid: chat.lidJid || undefined, + name: chat.displayName || chat.name || chat.username || undefined, + lid: chat.lidJid || chat.accountLid || undefined, phoneNumber: chat.pnJid || undefined }) From 0826895c6b8c2660305a7e0f94c614cf60cbbc6a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 14:34:22 +0200 Subject: [PATCH 05/14] history: add proper logging support in history --- src/Utils/history.ts | 13 +++++++++---- src/Utils/process-message.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 7572940e..ff7207e1 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -6,6 +6,7 @@ import { WAMessageStubType } from '../Types' import { toNumber } from './generics' import { normalizeMessageContent } from './messages' import { downloadContentFromMessage } from './messages-media' +import type { ILogger } from './logger.js' const inflatePromise = promisify(inflate) @@ -25,12 +26,15 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio return syncData } -export const processHistoryMessage = (item: proto.IHistorySync) => { +export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger) => { const messages: WAMessage[] = [] const contacts: Contact[] = [] const chats: Chat[] = [] - // Extract LID-PN mappings for all sync types const lidPnMappings: LIDMapping[] = [] + + logger?.trace({ progress: item.progress }, 'processing history of type '+ item.syncType?.toString()) + + // Extract LID-PN mappings for all sync types for (const m of item.phoneNumberToLidMappings || []) { if (m.lidJid && m.pnJid) { lidPnMappings.push({ lid: m.lidJid, pn: m.pnJid }) @@ -102,7 +106,8 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { export const downloadAndProcessHistorySyncNotification = async ( msg: proto.Message.IHistorySyncNotification, - options: RequestInit + options: RequestInit, + logger?: ILogger ) => { let historyMsg: proto.HistorySync if (msg.initialHistBootstrapInlinePayload) { @@ -111,7 +116,7 @@ export const downloadAndProcessHistorySyncNotification = async ( historyMsg = await downloadHistory(msg, options) } - return processHistoryMessage(historyMsg) + return processHistoryMessage(historyMsg, logger) } export const getHistoryMsg = (message: proto.IMessage) => { diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 6817fb3b..2e8a0f40 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -285,7 +285,7 @@ const processMessage = async ( }) } - const data = await downloadAndProcessHistorySyncNotification(histNotification, options) + const data = await downloadAndProcessHistorySyncNotification(histNotification, options, logger) // Emit LID-PN mappings from history sync // This is how WhatsApp Web learns mappings for chats with non-contacts From bfde86bce75a272f15d4ef65c33737c7f12252c6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 14:34:54 +0200 Subject: [PATCH 06/14] example: cleanup --- Example/example.ts | 5 +---- src/Utils/history.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Example/example.ts b/Example/example.ts index 782039ff..8925862c 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -1,10 +1,7 @@ import { Boom } from '@hapi/boom' import NodeCache from '@cacheable/node-cache' import readline from 'readline' -import makeWASocket, { AnyMessageContent, BinaryInfo, CacheStore, DEFAULT_CONNECTION_CONFIG, delay, DisconnectReason, downloadAndProcessHistorySyncNotification, encodeWAM, fetchLatestBaileysVersion, generateMessageIDV2, getAggregateVotesInPollMessage, getHistoryMsg, isJidNewsletter, jidDecode, makeCacheableSignalKeyStore, normalizeMessageContent, PatchedMessageWithRecipientJID, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src' -//import MAIN_LOGGER from '../src/Utils/logger' -import open from 'open' -import fs from 'fs' +import makeWASocket, { CacheStore, DEFAULT_CONNECTION_CONFIG, DisconnectReason, fetchLatestBaileysVersion, generateMessageIDV2, getAggregateVotesInPollMessage, isJidNewsletter, makeCacheableSignalKeyStore, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src' import P from 'pino' const logger = P({ diff --git a/src/Utils/history.ts b/src/Utils/history.ts index ff7207e1..5e94daa0 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -4,9 +4,9 @@ import { proto } from '../../WAProto/index.js' import type { Chat, Contact, LIDMapping, WAMessage } from '../Types' import { WAMessageStubType } from '../Types' import { toNumber } from './generics' +import type { ILogger } from './logger.js' import { normalizeMessageContent } from './messages' import { downloadContentFromMessage } from './messages-media' -import type { ILogger } from './logger.js' const inflatePromise = promisify(inflate) @@ -32,7 +32,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger const chats: Chat[] = [] const lidPnMappings: LIDMapping[] = [] - logger?.trace({ progress: item.progress }, 'processing history of type '+ item.syncType?.toString()) + logger?.trace({ progress: item.progress }, 'processing history of type ' + item.syncType?.toString()) // Extract LID-PN mappings for all sync types for (const m of item.phoneNumberToLidMappings || []) { From 1ef04d5329efd5dc6c8f96eef8689563a2962e6a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 22 Jan 2026 14:43:19 +0200 Subject: [PATCH 07/14] socket: no sync warning!!!!! --- src/Socket/socket.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 8aeb02ab..b258fc40 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -10,6 +10,7 @@ import { MIN_PREKEY_COUNT, MIN_UPLOAD_INTERVAL, NOISE_WA_HEADER, + PROCESSABLE_HISTORY_TYPES, UPLOAD_TIMEOUT } from '../Defaults' import type { LIDMapping, SocketConfig } from '../Types' @@ -80,11 +81,21 @@ export const makeSocket = (config: SocketConfig) => { const generateMessageTag = () => `${uqTagId}${epoch++}` if (printQRInTerminal) { - console.warn( + logger.warn( + {}, '⚠️ The printQRInTerminal option has been deprecated. You will no longer receive QR codes in the terminal automatically. Please listen to the connection.update event yourself and handle the QR your way. You can remove this message by removing this opttion. This message will be removed in a future version.' ) } + const syncDisabled = + PROCESSABLE_HISTORY_TYPES.map(syncType => config.shouldSyncHistoryMessage({ syncType })).filter(x => x === false) + .length === PROCESSABLE_HISTORY_TYPES.length + if (syncDisabled) { + logger.warn( + '⚠️ DANGER: DISABLING ALL SYNC BY shouldSyncHistoryMsg PREVENTS BAILEYS FROM ACCESSING INITIAL LID MAPPINGS, LEADING TO INSTABILIY AND SESSION ERRORS' + ) + } + const url = typeof waWebSocketUrl === 'string' ? new URL(waWebSocketUrl) : waWebSocketUrl if (config.mobile || url.protocol === 'tcp:') { From 5cbad3170b2cd1969a95b84dd21ebce0d0fde3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:54:10 -0300 Subject: [PATCH 08/14] 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 --- src/Socket/messages-recv.ts | 33 ++- src/Utils/generics.ts | 4 + src/Utils/identity-change-handler.ts | 83 +++++++ src/Utils/index.ts | 1 + .../Socket/identity-change-handling.test.ts | 207 ++++++++++++++++++ 5 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 src/Utils/identity-change-handler.ts create mode 100644 src/__tests__/Socket/identity-change-handling.test.ts diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index decff29c..dd61df59 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -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) } diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index 60788954..803517c2 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -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 diff --git a/src/Utils/identity-change-handler.ts b/src/Utils/identity-change-handler.ts new file mode 100644 index 00000000..58cce57d --- /dev/null +++ b/src/Utils/identity-change-handler.ts @@ -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 + debounceCache: NodeCache + logger: ILogger +} + +export async function handleIdentityChange( + node: BinaryNode, + ctx: IdentityChangeContext +): Promise { + 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 } + } +} diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 852854d4..e102ffe5 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -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' diff --git a/src/__tests__/Socket/identity-change-handling.test.ts b/src/__tests__/Socket/identity-change-handling.test.ts new file mode 100644 index 00000000..3f12ae14 --- /dev/null +++ b/src/__tests__/Socket/identity-change-handling.test.ts @@ -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 + +describe('Identity Change Handling', () => { + let mockValidateSession: jest.Mock + let mockAssertSessions: jest.Mock + let identityAssertDebounce: NodeCache + 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({ 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') + }) + }) +}) From 92d4198ff174a41a41779e352da0331229088088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:55:29 -0300 Subject: [PATCH 09/14] fix: skip retry for expired status messages over 24 hours old (#2280) --- src/Defaults/index.ts | 3 +++ src/Socket/messages-recv.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index e940efa0..10d140fc 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -22,6 +22,9 @@ export const WA_ADV_HOSTED_DEVICE_SIG_PREFIX = Buffer.from([6, 6]) export const WA_DEFAULT_EPHEMERAL = 7 * 24 * 60 * 60 +/** Status messages older than 24 hours are considered expired */ +export const STATUS_EXPIRY_SECONDS = 24 * 60 * 60 + export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0' export const DICT_VERSION = 3 export const KEY_BUNDLE_TYPE = Buffer.from([5]) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index dd61df59..15c4c545 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -3,7 +3,7 @@ import { Boom } from '@hapi/boom' import { randomBytes } from 'crypto' import Long from 'long' import { proto } from '../../WAProto/index.js' -import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' +import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, STATUS_EXPIRY_SECONDS } from '../Defaults' import type { GroupParticipant, MessageReceiptType, @@ -1230,6 +1230,18 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { return sendMessageAck(node) } + // Skip retry for expired status messages (>24h old) + if (isJidStatusBroadcast(msg.key.remoteJid!)) { + const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp) + if (messageAge > STATUS_EXPIRY_SECONDS) { + logger.debug( + { msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid }, + 'skipping retry for expired status message' + ) + return sendMessageAck(node) + } + } + const errorMessage = msg?.messageStubParameters?.[0] || '' const isPreKeyError = errorMessage.includes('PreKey') From 52fcad2b9c47294c4e9c441fdd3de446b9190288 Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:58:02 -0300 Subject: [PATCH 10/14] fix: optimize getLIDsForPNs and add getPNsForLIDs (#2274) * fix: optimize lid-mapping and add getpnsforlids * fix: lint * fix: reintroduce store and fix partial returns * fix: lint --- src/Signal/lid-mapping.ts | 149 ++++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 45 deletions(-) diff --git a/src/Signal/lid-mapping.ts b/src/Signal/lid-mapping.ts index 44cca351..2e8b2cd9 100644 --- a/src/Signal/lid-mapping.ts +++ b/src/Signal/lid-mapping.ts @@ -39,7 +39,7 @@ export class LIDMappingStore { const lidDecoded = jidDecode(lid) const pnDecoded = jidDecode(pn) - if (!lidDecoded || !pnDecoded) return + if (!lidDecoded || !pnDecoded) continue const pnUser = pnDecoded.user const lidUser = lidDecoded.user @@ -92,6 +92,26 @@ export class LIDMappingStore { const usyncFetch: { [_: string]: number[] } = {} // mapped from pn to lid mapping to prevent duplication in results later const successfulPairs: { [_: string]: LIDMapping } = {} + const pending: Array<{ pn: string; pnUser: string; decoded: ReturnType }> = [] + + const addResolvedPair = (pn: string, decoded: ReturnType, lidUser: string) => { + const normalizedLidUser = lidUser.toString() + if (!normalizedLidUser) { + this.logger.warn(`Invalid or empty LID user for PN ${pn}: lidUser = "${lidUser}"`) + return false + } + + // Push the PN device ID to the LID to maintain device separation + const pnDevice = decoded!.device !== undefined ? decoded!.device : 0 + const deviceSpecificLid = `${normalizedLidUser}${!!pnDevice ? `:${pnDevice}` : ``}@${ + decoded!.server === 'hosted' ? 'hosted.lid' : 'lid' + }` + + this.logger.trace(`getLIDForPN: ${pn} → ${deviceSpecificLid} (user mapping with device ${pnDevice})`) + successfulPairs[pn] = { lid: deviceSpecificLid, pn } + return true + } + for (const pn of pns) { if (!isPnUser(pn) && !isHostedPnUser(pn)) continue @@ -100,19 +120,40 @@ export class LIDMappingStore { // Check cache first for PN → LID mapping const pnUser = decoded.user - let lidUser = this.mappingCache.get(`pn:${pnUser}`) + const cached = this.mappingCache.get(`pn:${pnUser}`) + if (cached && typeof cached === 'string') { + if (!addResolvedPair(pn, decoded, cached)) { + this.logger.warn(`Invalid entry for ${pn} (pair not resolved)`) + continue + } - if (!lidUser) { - // Cache miss - check database - const stored = await this.keys.get('lid-mapping', [pnUser]) - lidUser = stored[pnUser] + continue + } - if (lidUser) { + pending.push({ pn, pnUser, decoded }) + } + + if (pending.length) { + const pnUsers = [...new Set(pending.map(item => item.pnUser))] + const stored = await this.keys.get('lid-mapping', pnUsers) + for (const pnUser of pnUsers) { + const lidUser = stored[pnUser] + if (lidUser && typeof lidUser === 'string') { this.mappingCache.set(`pn:${pnUser}`, lidUser) this.mappingCache.set(`lid:${lidUser}`, pnUser) + } + } + + for (const { pn, pnUser, decoded } of pending) { + const cached = this.mappingCache.get(`pn:${pnUser}`) + if (cached && typeof cached === 'string') { + if (!addResolvedPair(pn, decoded, cached)) { + this.logger.warn(`Invalid entry for ${pn} (pair not resolved)`) + continue + } } else { this.logger.trace(`No LID mapping found for PN user ${pnUser}; batch getting from USync`) - const device = decoded.device || 0 + const device = decoded!.device || 0 let normalizedPn = jidNormalizedUser(pn) if (isHostedPnUser(normalizedPn)) { normalizedPn = `${pnUser}@s.whatsapp.net` @@ -123,23 +164,8 @@ export class LIDMappingStore { } else { usyncFetch[normalizedPn]?.push(device) } - - continue } } - - lidUser = lidUser.toString() - if (!lidUser) { - this.logger.warn(`Invalid or empty LID user for PN ${pn}: lidUser = "${lidUser}"`) - return null - } - - // Push the PN device ID to the LID to maintain device separation - const pnDevice = decoded.device !== undefined ? decoded.device : 0 - const deviceSpecificLid = `${lidUser}${!!pnDevice ? `:${pnDevice}` : ``}@${decoded.server === 'hosted' ? 'hosted.lid' : 'lid'}` - - this.logger.trace(`getLIDForPN: ${pn} → ${deviceSpecificLid} (user mapping with device ${pnDevice})`) - successfulPairs[pn] = { lid: deviceSpecificLid, pn } } if (Object.keys(usyncFetch).length > 0) { @@ -166,44 +192,77 @@ export class LIDMappingStore { } } } else { - return null + this.logger.warn('USync fetch yielded no results for pending PNs') } } - return Object.values(successfulPairs) + return Object.values(successfulPairs).length > 0 ? Object.values(successfulPairs) : null } /** * Get PN for LID - USER LEVEL with device construction */ async getPNForLID(lid: string): Promise { - if (!isLidUser(lid)) return null + return (await this.getPNsForLIDs([lid]))?.[0]?.pn || null + } - const decoded = jidDecode(lid) - if (!decoded) return null - - // Check cache first for LID → PN mapping - const lidUser = decoded.user - let pnUser = this.mappingCache.get(`lid:${lidUser}`) - - if (!pnUser || typeof pnUser !== 'string') { - // Cache miss - check database - const stored = await this.keys.get('lid-mapping', [`${lidUser}_reverse`]) - pnUser = stored[`${lidUser}_reverse`] + async getPNsForLIDs(lids: string[]): Promise { + const successfulPairs: { [_: string]: LIDMapping } = {} + const pending: Array<{ lid: string; lidUser: string; decoded: ReturnType }> = [] + const addResolvedPair = (lid: string, decoded: ReturnType, pnUser: string) => { if (!pnUser || typeof pnUser !== 'string') { - this.logger.trace(`No reverse mapping found for LID user: ${lidUser}`) - return null + return false } - this.mappingCache.set(`lid:${lidUser}`, pnUser) + const lidDevice = decoded!.device !== undefined ? decoded!.device : 0 + const pnJid = `${pnUser}:${lidDevice}@${ + decoded!.domainType === WAJIDDomains.HOSTED_LID ? 'hosted' : 's.whatsapp.net' + }` + + this.logger.trace(`Found reverse mapping: ${lid} → ${pnJid}`) + successfulPairs[lid] = { lid, pn: pnJid } + return true } - // Construct device-specific PN JID - const lidDevice = decoded.device !== undefined ? decoded.device : 0 - const pnJid = `${pnUser}:${lidDevice}@${decoded.domainType === WAJIDDomains.HOSTED_LID ? 'hosted' : 's.whatsapp.net'}` + for (const lid of lids) { + if (!isLidUser(lid)) continue - this.logger.trace(`Found reverse mapping: ${lid} → ${pnJid}`) - return pnJid + const decoded = jidDecode(lid) + if (!decoded) continue + + const lidUser = decoded.user + const cached = this.mappingCache.get(`lid:${lidUser}`) + if (cached && typeof cached === 'string') { + addResolvedPair(lid, decoded, cached) + continue + } + + pending.push({ lid, lidUser, decoded }) + } + + if (pending.length) { + const reverseKeys = [...new Set(pending.map(item => `${item.lidUser}_reverse`))] + const stored = await this.keys.get('lid-mapping', reverseKeys) + + for (const { lid, lidUser, decoded } of pending) { + let pnUser = this.mappingCache.get(`lid:${lidUser}`) + if (!pnUser || typeof pnUser !== 'string') { + pnUser = stored[`${lidUser}_reverse`] + if (pnUser && typeof pnUser === 'string') { + this.mappingCache.set(`lid:${lidUser}`, pnUser) + this.mappingCache.set(`pn:${pnUser}`, lidUser) + } + } + + if (pnUser && typeof pnUser === 'string') { + addResolvedPair(lid, decoded, pnUser) + } else { + this.logger.trace(`No reverse mapping found for LID user: ${lidUser}`) + } + } + } + + return Object.values(successfulPairs).length ? Object.values(successfulPairs) : null } } From f829b6d7a848f46eb6f52f111309f59050c85ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:00:41 -0300 Subject: [PATCH 11/14] fix: extract LID-PN mappings from conversation objects in history sync (#2282) * fix: extract LID-PN mappings from conversation objects in history sync * fix: extract PN from userReceipt when pnJid is missing for LID chats --- src/Utils/history.ts | 34 ++++ src/__tests__/Utils/history.test.ts | 268 ++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 5e94daa0..7edb9c81 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -3,6 +3,7 @@ import { inflate } from 'zlib' import { proto } from '../../WAProto/index.js' import type { Chat, Contact, LIDMapping, WAMessage } from '../Types' import { WAMessageStubType } from '../Types' +import { isHostedLidUser, isHostedPnUser, isLidUser, isPnUser } from '../WABinary' import { toNumber } from './generics' import type { ILogger } from './logger.js' import { normalizeMessageContent } from './messages' @@ -10,6 +11,24 @@ import { downloadContentFromMessage } from './messages-media' const inflatePromise = promisify(inflate) +const extractPnFromMessages = (messages: proto.IHistorySyncMsg[]): string | undefined => { + for (const msgItem of messages) { + const message = msgItem.message + // Only extract from outgoing messages (fromMe: true) in 1:1 chats + // because userReceipt.userJid is the recipient's JID + if (!message?.key?.fromMe || !message.userReceipt?.length) { + continue + } + + const userJid = message.userReceipt[0]?.userJid + if (userJid && (isPnUser(userJid) || isHostedPnUser(userJid))) { + return userJid + } + } + + return undefined +} + export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => { const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options }) const bufferArray: Buffer[] = [] @@ -54,6 +73,21 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger phoneNumber: chat.pnJid || undefined }) + const chatId = chat.id! + const isLid = isLidUser(chatId) || isHostedLidUser(chatId) + const isPn = isPnUser(chatId) || isHostedPnUser(chatId) + if (isLid && chat.pnJid) { + lidPnMappings.push({ lid: chatId, pn: chat.pnJid }) + } else if (isPn && chat.lidJid) { + lidPnMappings.push({ lid: chat.lidJid, pn: chatId }) + } else if (isLid && !chat.pnJid) { + // Fallback: extract PN from userReceipt in messages when pnJid is missing + const pnFromReceipt = extractPnFromMessages(chat.messages || []) + if (pnFromReceipt) { + lidPnMappings.push({ lid: chatId, pn: pnFromReceipt }) + } + } + const msgs = chat.messages || [] delete chat.messages diff --git a/src/__tests__/Utils/history.test.ts b/src/__tests__/Utils/history.test.ts index 2676fcb6..c6477da0 100644 --- a/src/__tests__/Utils/history.test.ts +++ b/src/__tests__/Utils/history.test.ts @@ -91,4 +91,272 @@ describe('processHistoryMessage', () => { }) }) }) + + describe('LID-PN mapping extraction from conversations', () => { + it('should extract mapping when chat.id is LID and pnJid exists', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '11111111111111@lid', + pnJid: '1234567890123@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '1234567890123@s.whatsapp.net' + }) + }) + + it('should extract mapping when chat.id is PN and lidJid exists', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '1234567890123@s.whatsapp.net', + lidJid: '11111111111111@lid' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '1234567890123@s.whatsapp.net' + }) + }) + + it('should not extract mapping for group chats', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '123456789012345678@g.us', + lidJid: '11111111111111@lid', + pnJid: '1234567890123@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([]) + }) + + it('should combine mappings from phoneNumberToLidMappings and conversations', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + phoneNumberToLidMappings: [{ lidJid: '11111111111111@lid', pnJid: '1111111111111@s.whatsapp.net' }], + conversations: [ + { + id: '22222222222222@lid', + pnJid: '2222222222222@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toHaveLength(2) + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '1111111111111@s.whatsapp.net' + }) + expect(result.lidPnMappings).toContainEqual({ + lid: '22222222222222@lid', + pn: '2222222222222@s.whatsapp.net' + }) + }) + + it('should extract mapping for hosted LID users', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '11111111111111@hosted.lid', + pnJid: '1234567890123@hosted' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@hosted.lid', + pn: '1234567890123@hosted' + }) + }) + + it('should extract mapping for hosted PN users', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '1234567890123@hosted', + lidJid: '11111111111111@hosted.lid' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@hosted.lid', + pn: '1234567890123@hosted' + }) + }) + + it('should extract mapping from userReceipt when pnJid is missing and chat.id is LID', () => { + // Based on real-world case: LID chat without pnJid but userReceipt contains PN + // See: https://github.com/WhiskeySockets/Baileys/pull/2282#issuecomment-3777941679 + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '211071956705386@lid', + // pnJid is intentionally missing + messages: [ + { + message: { + key: { + remoteJid: '211071956705386@lid', + fromMe: true, + id: '3EB052FF8D9D00646C9994' + }, + messageTimestamp: 1768320044, + userReceipt: [ + { + userJid: '5518999991234@s.whatsapp.net', + receiptTimestamp: 1768320045, + readTimestamp: 1768327083 + } + ] + } + } + ] + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '211071956705386@lid', + pn: '5518999991234@s.whatsapp.net' + }) + }) + + it('should not extract mapping from userReceipt when pnJid already exists', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '211071956705386@lid', + pnJid: '5518888881234@s.whatsapp.net', // pnJid exists + messages: [ + { + message: { + key: { + remoteJid: '211071956705386@lid', + fromMe: true, + id: '3EB052FF8D9D00646C9994' + }, + userReceipt: [ + { + userJid: '5518999991234@s.whatsapp.net' // different PN + } + ] + } + } + ] + } + ] + } + + const result = processHistoryMessage(historySync) + + // Should use pnJid, not userReceipt + expect(result.lidPnMappings).toContainEqual({ + lid: '211071956705386@lid', + pn: '5518888881234@s.whatsapp.net' + }) + // Should NOT contain the userReceipt PN + expect(result.lidPnMappings).not.toContainEqual({ + lid: '211071956705386@lid', + pn: '5518999991234@s.whatsapp.net' + }) + }) + + it('should not extract mapping from userReceipt when fromMe is false', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '211071956705386@lid', + messages: [ + { + message: { + key: { + remoteJid: '211071956705386@lid', + fromMe: false, // Not from me + id: '3EB052FF8D9D00646C9994' + }, + userReceipt: [ + { + userJid: '5518999991234@s.whatsapp.net' + } + ] + } + } + ] + } + ] + } + + const result = processHistoryMessage(historySync) + + // Should not extract mapping when fromMe is false + expect(result.lidPnMappings).not.toContainEqual({ + lid: '211071956705386@lid', + pn: '5518999991234@s.whatsapp.net' + }) + }) + + it('should not extract mapping from userReceipt when userJid is also a LID', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '211071956705386@lid', + messages: [ + { + message: { + key: { + remoteJid: '211071956705386@lid', + fromMe: true, + id: '3EB052FF8D9D00646C9994' + }, + userReceipt: [ + { + userJid: '152230971891797@lid' // Also a LID, not a PN + } + ] + } + } + ] + } + ] + } + + const result = processHistoryMessage(historySync) + + // Should not create a LID->LID mapping + expect(result.lidPnMappings).toHaveLength(0) + }) + }) }) From f2dd5c81c834174ae637328f6c450d7b220aa42e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 19:59:20 +0000 Subject: [PATCH 12/14] config: skip FULL history sync by default (market standard) Remove the override that was forcing full history sync when syncFullHistory was true. Now uses the default from Defaults/index.ts which skips FULL sync type for better performance and stability. Benefits: - Faster connection time (2-10s vs 30s-5min) - Lower bandwidth usage (1-10MB vs 50-500MB+) - More stable connections (no timeouts) - INITIAL_BOOTSTRAP + RECENT provide sufficient data Users can still customize via shouldSyncHistoryMessage if needed. --- src/Socket/index.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 8862579b..ab00b6ee 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -39,13 +39,6 @@ const makeWASocket = (config: UserFacingSocketConfig) => { ...config } - // If the user hasn't provided their own history sync function, - // let's create a default one that respects the syncFullHistory flag. - // TODO: Change - if (config.shouldSyncHistoryMessage === undefined) { - newConfig.shouldSyncHistoryMessage = () => !!newConfig.syncFullHistory - } - return makeCommunitiesSocket(newConfig) } From b69f6c6b6b067c0f1c0b7435dc5766cf5178d241 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 20:03:32 +0000 Subject: [PATCH 13/14] example: use structured logging instead of console.log Refactor example.ts to use pino logger with structured data: - Replace all console.log with logger.debug/fatal - Use objects for data instead of string concatenation - Add contacts.upsert event handler - Improves log filtering and searchability --- Example/example.ts | 51 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/Example/example.ts b/Example/example.ts index 3f075c8c..782039ff 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -48,7 +48,7 @@ const startSock = async() => { } // fetch latest version of WA Web const { version, isLatest } = await fetchLatestBaileysVersion() - console.log(`using WA v${version.join('.')}, isLatest: ${isLatest}`) + logger.debug({version: version.join('.'), isLatest}, `using latest WA version`) const sock = makeWASocket({ version, @@ -83,7 +83,7 @@ const startSock = async() => { if((lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) { startSock() } else { - console.log('Connection closed. You are logged out.') + logger.fatal('Connection closed. You are logged out.') } } @@ -96,43 +96,44 @@ const startSock = async() => { } } - console.log('connection update', update) + logger.debug(update, 'connection update') } // credentials updated -- save them if(events['creds.update']) { await saveCreds() + logger.debug({}, 'creds save triggered') } if(events['labels.association']) { - console.log(events['labels.association']) + logger.debug(events['labels.association'], 'labels.association event fired') } if(events['labels.edit']) { - console.log(events['labels.edit']) + logger.debug(events['labels.edit'], 'labels.edit event fired') } - if(events.call) { - console.log('recv call event', events.call) + if(events['call']) { + logger.debug(events['call'], 'call event fired') } // history received if(events['messaging-history.set']) { const { chats, contacts, messages, isLatest, progress, syncType } = events['messaging-history.set'] if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) { - console.log('received on-demand history sync, messages=', messages) + logger.debug(messages, 'received on-demand history sync') } - console.log(`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest}, progress: ${progress}%), type: ${syncType}`) + logger.debug({contacts: contacts.length, chats: chats.length, messages: messages.length, isLatest, progress, syncType: syncType?.toString() }, 'messaging-history.set event fired') } // received a new message if (events['messages.upsert']) { const upsert = events['messages.upsert'] - logger.debug(upsert, 'recv messages') + logger.debug(upsert, 'messages.upsert fired') if (!!upsert.requestId) { - console.log("placeholder message received for request of id=" + upsert.requestId, upsert) + logger.debug(upsert, 'placeholder request message received') } @@ -143,13 +144,13 @@ const startSock = async() => { const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text if (text == "requestPlaceholder" && !upsert.requestId) { const messageId = await sock.requestPlaceholderResend(msg.key) - console.log('requested placeholder resync, id=', messageId) + logger.debug({ id: messageId }, 'requested placeholder resync') } // go to an old chat and send this if (text == "onDemandHistSync") { const messageId = await sock.fetchMessageHistory(50, msg.key, msg.messageTimestamp!) - console.log('requested on-demand sync, id=', messageId) + logger.debug({ id: messageId }, 'requested on-demand history resync') } if (!msg.key.fromMe && doReplies && !isJidNewsletter(msg.key?.remoteJid!)) { @@ -164,9 +165,7 @@ const startSock = async() => { // messages updated like status delivered, message deleted etc. if(events['messages.update']) { - console.log( - JSON.stringify(events['messages.update'], undefined, 2) - ) + logger.debug(events['messages.update'], 'messages.update fired') for(const { key, update } of events['messages.update']) { if(update.pollUpdates) { @@ -185,19 +184,23 @@ const startSock = async() => { } if(events['message-receipt.update']) { - console.log(events['message-receipt.update']) + logger.debug(events['message-receipt.update']) + } + + if (events['contacts.upsert']) { + logger.debug(events['message-receipt.update']) } if(events['messages.reaction']) { - console.log(events['messages.reaction']) + logger.debug(events['messages.reaction']) } if(events['presence.update']) { - console.log(events['presence.update']) + logger.debug(events['presence.update']) } if(events['chats.update']) { - console.log(events['chats.update']) + logger.debug(events['chats.update']) } if(events['contacts.update']) { @@ -206,19 +209,17 @@ const startSock = async() => { const newUrl = contact.imgUrl === null ? null : await sock!.profilePictureUrl(contact.id!).catch(() => null) - console.log( - `contact ${contact.id} has a new profile pic: ${newUrl}`, - ) + logger.debug({id: contact.id, newUrl}, `contact has a new profile pic` ) } } } if(events['chats.delete']) { - console.log('chats deleted ', events['chats.delete']) + logger.debug('chats deleted ', events['chats.delete']) } if(events['group.member-tag.update']) { - console.log('group member tag update', JSON.stringify(events['group.member-tag.update'], undefined, 2)) + logger.debug('group member tag update', JSON.stringify(events['group.member-tag.update'], undefined, 2)) } } ) From fe9a3166a89340d3a8afc4734f6fd6d9091a79ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 20:11:20 +0000 Subject: [PATCH 14/14] chat-utils: add fallbacks for contact name extraction Apply consistent fallback pattern for contact names: - chat-utils.ts: fullName || firstName || username for lidContactAction - sync-action-utils.ts: fullName || firstName || username for processContactAction Ensures contact names are extracted regardless of which field WhatsApp populates. --- src/Utils/chat-utils.ts | 6 +++++- src/Utils/sync-action-utils.ts | 2 +- src/__tests__/Utils/sync-action-utils.test.ts | 1 - 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index c03bd3c2..65f11571 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -930,7 +930,11 @@ export const processSyncAction = ( ev.emit('contacts.upsert', [ { id: id!, - name: action.lidContactAction.fullName || undefined, + name: + action.lidContactAction.fullName || + action.lidContactAction.firstName || + action.lidContactAction.username || + undefined, lid: id!, phoneNumber: undefined } diff --git a/src/Utils/sync-action-utils.ts b/src/Utils/sync-action-utils.ts index d53b00fb..14ecd310 100644 --- a/src/Utils/sync-action-utils.ts +++ b/src/Utils/sync-action-utils.ts @@ -45,7 +45,7 @@ export const processContactAction = ( data: [ { id, - name: action.fullName || undefined, + name: action.fullName || action.firstName || action.username || undefined, lid: lidJid || undefined, phoneNumber } diff --git a/src/__tests__/Utils/sync-action-utils.test.ts b/src/__tests__/Utils/sync-action-utils.test.ts index 5c19d29e..fbf54303 100644 --- a/src/__tests__/Utils/sync-action-utils.test.ts +++ b/src/__tests__/Utils/sync-action-utils.test.ts @@ -2,7 +2,6 @@ import { jest } from '@jest/globals' import type { ILogger } from '../../Utils/logger' import { processContactAction } from '../../Utils/sync-action-utils' - describe('processContactAction', () => { const mockLogger: ILogger = { warn: jest.fn(),