From 86c1cf439a6b1bc18602043997dbdfe5957385c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 22:17:09 +0000 Subject: [PATCH] feat(history): extract LID-PN mappings from conversation objects WhatsApp provides LID-PN mappings in two locations within history sync: 1. Top-level `phoneNumberToLidMappings` array (already processed) 2. Individual conversation objects with `lidJid`/`pnJid` properties (new) This change adds extraction from conversation objects, ensuring maximum mapping coverage regardless of sync type or payload structure. Key improvements: - Add `extractLidPnFromConversation()` function with comprehensive JSDoc - Use Map for O(1) deduplication of mappings across both sources - Handle all JID formats: @lid, @hosted.lid, @s.whatsapp.net, @hosted - Normalize JIDs using `jidNormalizedUser()` for consistency - Skip group chats (@g.us) that don't have LID-PN mappings Includes 22 comprehensive tests covering: - LID chat with pnJid extraction - PN chat with lidJid extraction - Hosted format handling - Deduplication between sources - Edge cases (nulls, groups, missing data) - All conversation sync types Related: WhiskeySockets/Baileys#2263 See-also: WhiskeySockets/Baileys#2282 --- src/Utils/history.ts | 139 ++++++++++++- src/__tests__/Utils/history.test.ts | 291 +++++++++++++++++++++++++++- 2 files changed, 425 insertions(+), 5 deletions(-) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index b8c1a504..6b2e1651 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -6,9 +6,23 @@ import { WAMessageStubType } from '../Types' import { toNumber } from './generics' import { normalizeMessageContent } from './messages' import { downloadContentFromMessage } from './messages-media' +import { + isHostedLidUser, + isHostedPnUser, + isLidUser, + isPnUser, + jidNormalizedUser +} from '../WABinary/index.js' const inflatePromise = promisify(inflate) +/** + * Downloads and decompresses history sync data from WhatsApp servers. + * + * @param msg - The history sync notification message containing download info + * @param options - Request options for the download + * @returns Decoded HistorySync protocol buffer + */ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => { const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options }) const bufferArray: Buffer[] = [] @@ -25,15 +39,103 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio return syncData } +/** + * Extracts LID-PN mapping from a conversation object. + * + * WhatsApp uses two identifier systems: + * - LID (Logical ID): Format `{number}@lid` or `{number}@hosted.lid` + * - PN (Phone Number): Format `{number}@s.whatsapp.net` or `{number}@hosted` + * + * Conversations may have their ID in either format, with the alternate + * format stored in `lidJid` or `pnJid` properties respectively. + * + * @param chatId - The conversation ID (may be LID or PN format) + * @param lidJid - The LID JID if chat ID is PN format + * @param pnJid - The PN JID if chat ID is LID format + * @returns LID-PN mapping if extractable, undefined otherwise + * + * @example + * // Chat ID is LID, pnJid contains phone number + * extractLidPnFromConversation('123456789@lid', undefined, '5511999999999@s.whatsapp.net') + * // Returns: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' } + * + * @example + * // Chat ID is PN, lidJid contains LID + * extractLidPnFromConversation('5511999999999@s.whatsapp.net', '123456789@lid', undefined) + * // Returns: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' } + */ +export function extractLidPnFromConversation( + chatId: string, + lidJid: string | undefined | null, + pnJid: string | undefined | null +): LIDMapping | undefined { + // Check if chat ID is in LID format + const chatIsLid = isLidUser(chatId) || isHostedLidUser(chatId) + // Check if chat ID is in PN format + const chatIsPn = isPnUser(chatId) || isHostedPnUser(chatId) + + if (chatIsLid && pnJid) { + // Chat ID is LID, pnJid contains the phone number + return { + lid: jidNormalizedUser(chatId), + pn: jidNormalizedUser(pnJid) + } + } + + if (chatIsPn && lidJid) { + // Chat ID is PN, lidJid contains the LID + return { + lid: jidNormalizedUser(lidJid), + pn: jidNormalizedUser(chatId) + } + } + + return undefined +} + +/** + * Processes a history sync message and extracts chats, contacts, messages, + * and LID-PN mappings. + * + * LID-PN mappings are extracted from two sources: + * 1. Top-level `phoneNumberToLidMappings` array in the history sync payload + * 2. Individual conversation objects that contain both LID and PN identifiers + * + * This dual extraction ensures maximum mapping coverage, as WhatsApp may + * provide mappings in either or both locations depending on the sync type. + * + * @param item - The history sync protocol buffer to process + * @returns Processed data including chats, contacts, messages, and LID-PN mappings + * + * @see https://github.com/WhiskeySockets/Baileys/issues/2263 + */ export const processHistoryMessage = (item: proto.IHistorySync) => { const messages: WAMessage[] = [] const contacts: Contact[] = [] const chats: Chat[] = [] - // Extract LID-PN mappings for all sync types - const lidPnMappings: LIDMapping[] = [] + + // Use Map for O(1) deduplication of LID-PN mappings + const lidPnMap = new Map() + + /** + * Adds a LID-PN mapping to the map with deduplication. + * Uses LID as key since each LID should map to exactly one PN. + */ + const addLidPnMapping = (mapping: LIDMapping): void => { + // Normalize and validate + if (!mapping.lid || !mapping.pn) { + return + } + lidPnMap.set(mapping.lid, mapping) + } + + // Source 1: Extract from top-level phoneNumberToLidMappings array for (const m of item.phoneNumberToLidMappings || []) { if (m.lidJid && m.pnJid) { - lidPnMappings.push({ lid: m.lidJid, pn: m.pnJid }) + addLidPnMapping({ + lid: jidNormalizedUser(m.lidJid), + pn: jidNormalizedUser(m.pnJid) + }) } } @@ -43,8 +145,21 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { case proto.HistorySync.HistorySyncType.FULL: case proto.HistorySync.HistorySyncType.ON_DEMAND: for (const chat of item.conversations! as Chat[]) { + const chatId = chat.id! + + // Source 2: Extract LID-PN mapping from conversation object + // This handles cases where the mapping isn't in phoneNumberToLidMappings + const conversationMapping = extractLidPnFromConversation( + chatId, + chat.lidJid, + chat.pnJid + ) + if (conversationMapping) { + addLidPnMapping(conversationMapping) + } + contacts.push({ - id: chat.id!, + id: chatId, name: chat.name || undefined, lid: chat.lidJid || undefined, phoneNumber: chat.pnJid || undefined @@ -90,6 +205,9 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { break } + // Convert Map back to array for return + const lidPnMappings = Array.from(lidPnMap.values()) + return { chats, contacts, @@ -100,6 +218,13 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { } } +/** + * Downloads and processes a history sync notification in one step. + * + * @param msg - The history sync notification message + * @param options - Request options for the download + * @returns Processed history data + */ export const downloadAndProcessHistorySyncNotification = async ( msg: proto.Message.IHistorySyncNotification, options: RequestInit @@ -114,6 +239,12 @@ export const downloadAndProcessHistorySyncNotification = async ( return processHistoryMessage(historyMsg) } +/** + * Extracts the history sync notification from a protocol message. + * + * @param message - The protocol message to check + * @returns The history sync notification if present, undefined otherwise + */ export const getHistoryMsg = (message: proto.IMessage) => { const normalizedContent = !!message ? normalizeMessageContent(message) : undefined const anyHistoryMsg = normalizedContent?.protocolMessage?.historySyncNotification! diff --git a/src/__tests__/Utils/history.test.ts b/src/__tests__/Utils/history.test.ts index 2676fcb6..04447b47 100644 --- a/src/__tests__/Utils/history.test.ts +++ b/src/__tests__/Utils/history.test.ts @@ -1,5 +1,119 @@ import { proto } from '../../../WAProto/index.js' -import { processHistoryMessage } from '../../Utils/history' +import { processHistoryMessage, extractLidPnFromConversation } from '../../Utils/history' + +describe('extractLidPnFromConversation', () => { + describe('LID chat with pnJid', () => { + it('should extract mapping when chat ID is @lid format with pnJid', () => { + const result = extractLidPnFromConversation( + '123456789012345@lid', + undefined, + '5511999999999@s.whatsapp.net' + ) + + expect(result).toEqual({ + lid: '123456789012345@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + it('should extract mapping when chat ID is @hosted.lid format with pnJid', () => { + const result = extractLidPnFromConversation( + '123456789012345@hosted.lid', + undefined, + '5511999999999@hosted' + ) + + // jidNormalizedUser preserves hosted formats (they are distinct servers) + expect(result).toEqual({ + lid: '123456789012345@hosted.lid', + pn: '5511999999999@hosted' + }) + }) + }) + + describe('PN chat with lidJid', () => { + it('should extract mapping when chat ID is @s.whatsapp.net format with lidJid', () => { + const result = extractLidPnFromConversation( + '5511999999999@s.whatsapp.net', + '123456789012345@lid', + undefined + ) + + expect(result).toEqual({ + lid: '123456789012345@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + it('should extract mapping when chat ID is @hosted format with lidJid', () => { + const result = extractLidPnFromConversation( + '5511999999999@hosted', + '123456789012345@hosted.lid', + undefined + ) + + // jidNormalizedUser preserves hosted formats (they are distinct servers) + expect(result).toEqual({ + lid: '123456789012345@hosted.lid', + pn: '5511999999999@hosted' + }) + }) + }) + + describe('edge cases', () => { + it('should return undefined for group chats', () => { + const result = extractLidPnFromConversation( + '123456789012345@g.us', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when no alternate JID is available', () => { + const result = extractLidPnFromConversation( + '123456789012345@lid', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when both lidJid and pnJid are null', () => { + const result = extractLidPnFromConversation( + '5511999999999@s.whatsapp.net', + null, + null + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined for LID chat with lidJid (no pnJid)', () => { + // Edge case: LID chat has lidJid but no pnJid + const result = extractLidPnFromConversation( + '123456789012345@lid', + '987654321098765@lid', + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined for PN chat with pnJid (no lidJid)', () => { + // Edge case: PN chat has pnJid but no lidJid + const result = extractLidPnFromConversation( + '5511999999999@s.whatsapp.net', + undefined, + '5511888888888@s.whatsapp.net' + ) + + expect(result).toBeUndefined() + }) + }) +}) describe('processHistoryMessage', () => { describe('phoneNumberToLidMappings extraction', () => { @@ -91,4 +205,179 @@ describe('processHistoryMessage', () => { }) }) }) + + describe('LID-PN extraction from conversations', () => { + it('should extract LID-PN mapping when chat ID is LID with pnJid', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '11111111111111@lid', + name: 'LID User', + pnJid: '5511999999999@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@lid', pn: '5511999999999@s.whatsapp.net' } + ]) + }) + + it('should extract LID-PN mapping when chat ID is PN with lidJid', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '5511999999999@s.whatsapp.net', + name: 'PN User', + lidJid: '11111111111111@lid' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@lid', pn: '5511999999999@s.whatsapp.net' } + ]) + }) + + it('should deduplicate mappings from both sources', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + phoneNumberToLidMappings: [ + { lidJid: '11111111111111@lid', pnJid: '5511999999999@s.whatsapp.net' } + ], + conversations: [ + { + id: '11111111111111@lid', + name: 'Same User', + pnJid: '5511999999999@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + // Should have only 1 mapping (deduplicated) + expect(result.lidPnMappings).toHaveLength(1) + expect(result.lidPnMappings[0]).toEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + it('should combine mappings from both sources', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + phoneNumberToLidMappings: [ + { lidJid: '11111111111111@lid', pnJid: '5511999999999@s.whatsapp.net' } + ], + conversations: [ + { + id: '22222222222222@lid', + name: 'Different User', + pnJid: '5511888888888@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toHaveLength(2) + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + expect(result.lidPnMappings).toContainEqual({ + lid: '22222222222222@lid', + pn: '5511888888888@s.whatsapp.net' + }) + }) + + it('should not extract mapping from group chats', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '123456789012345@g.us', + name: 'Group Chat' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([]) + }) + + it('should handle hosted LID format', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '11111111111111@hosted.lid', + name: 'Hosted LID User', + pnJid: '5511999999999@hosted' + } + ] + } + + const result = processHistoryMessage(historySync) + + // jidNormalizedUser preserves hosted formats (they are distinct servers) + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@hosted.lid', pn: '5511999999999@hosted' } + ]) + }) + + it('should extract mappings for all conversation sync types', () => { + const syncTypes = [ + proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + proto.HistorySync.HistorySyncType.RECENT, + proto.HistorySync.HistorySyncType.FULL, + proto.HistorySync.HistorySyncType.ON_DEMAND + ] + + for (const syncType of syncTypes) { + const historySync: proto.IHistorySync = { + syncType, + conversations: [ + { + id: '11111111111111@lid', + pnJid: '5511999999999@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@lid', pn: '5511999999999@s.whatsapp.net' } + ]) + } + }) + + it('should not extract conversation mappings for PUSH_NAME sync type', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.PUSH_NAME, + pushnames: [ + { id: '5511999999999@s.whatsapp.net', pushname: 'User Name' } + ], + phoneNumberToLidMappings: [ + { lidJid: '11111111111111@lid', pnJid: '5511999999999@s.whatsapp.net' } + ] + } + + const result = processHistoryMessage(historySync) + + // Should still extract from phoneNumberToLidMappings + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@lid', pn: '5511999999999@s.whatsapp.net' } + ]) + }) + }) })