diff --git a/src/Utils/history.ts b/src/Utils/history.ts index a9845bca..b55562ee 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -15,125 +15,18 @@ import { isLidUser, isPnUser, jidDecode, - jidNormalizedUser, -} from '../WABinary/jid-utils' + jidNormalizedUser +} from '../WABinary/index.js' const inflatePromise = promisify(inflate) /** - * Checks if a JID represents a person (individual user) rather than a group, broadcast, or newsletter. - * Only person JIDs (LID or PN formats) can have LID-PN mappings. - */ -export function isPersonJid(jid: string | undefined): boolean { - if(!jid) { - return false - } - - // Groups, broadcasts, and newsletters don't have LID-PN mappings - if(isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) { - return false - } - - // Only person JIDs (LID or PN formats) can have mappings - return !!( - isLidUser(jid) || - isHostedLidUser(jid) || - isPnUser(jid) || - isHostedPnUser(jid) - ) -} - -/** - * Extracts a LID-PN mapping from conversation data. - * Returns undefined if the conversation doesn't represent a valid person-to-person mapping. - */ -export function extractLidPnFromConversation( - chatId: string, - lidJid: string | undefined | null, - pnJid: string | undefined | null -): LIDMapping | undefined { - // Skip non-person JIDs (groups, broadcasts, newsletters) - if(!isPersonJid(chatId)) { - return undefined - } - - const chatIsLid = isLidUser(chatId) || isHostedLidUser(chatId) - const chatIsPn = isPnUser(chatId) || isHostedPnUser(chatId) - - if(chatIsLid && pnJid) { - return { - lid: jidNormalizedUser(chatId), - pn: jidNormalizedUser(pnJid) - } - } - - if(chatIsPn && lidJid) { - return { - lid: jidNormalizedUser(lidJid), - pn: jidNormalizedUser(chatId) - } - } - - return undefined -} - -/** - * Extracts a LID-PN mapping from message data (remoteJidAlt/participantAlt fields). - * Returns undefined if the message doesn't contain a valid person-to-person mapping. + * Downloads and decompresses history sync data from WhatsApp servers. * - * IMPORTANT: Uses || (OR) to ensure BOTH JIDs are person JIDs before extracting. - * This prevents "poisoned" mappings where one side is a group/newsletter/broadcast. + * @param msg - The history sync notification message containing download info + * @param options - Request options for the download + * @returns Decoded HistorySync protocol buffer */ -export function extractLidPnFromMessage( - remoteJid: string | undefined | null, - remoteJidAlt: string | undefined | null, - participant: string | undefined | null, - participantAlt: string | undefined | null -): LIDMapping | undefined { - const primaryJid = participant || remoteJid - const altJid = participantAlt || remoteJidAlt - - if(!primaryJid || !altJid) { - return undefined - } - - // FIXED: Use || (OR) instead of && (AND) - // Both JIDs MUST be person JIDs to create a valid mapping. - // Using && would allow mixed scenarios (e.g., person + group) to proceed, - // resulting in invalid "poisoned" mappings. - if(!isPersonJid(primaryJid) || !isPersonJid(altJid)) { - return undefined - } - - const primaryDecoded = jidDecode(primaryJid) - const altDecoded = jidDecode(altJid) - - if(!primaryDecoded || !altDecoded) { - return undefined - } - - const primaryIsLid = primaryDecoded.server === 'lid' || - primaryDecoded.server === 'hosted.lid' - const altIsLid = altDecoded.server === 'lid' || - altDecoded.server === 'hosted.lid' - - if(primaryIsLid && !altIsLid) { - return { - lid: jidNormalizedUser(primaryJid), - pn: jidNormalizedUser(altJid) - } - } - - if(!primaryIsLid && altIsLid) { - return { - lid: jidNormalizedUser(altJid), - pn: jidNormalizedUser(primaryJid) - } - } - - return undefined -} - export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => { const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options }) const bufferArray: Buffer[] = [] @@ -150,15 +43,219 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio return syncData } +/** + * Checks if a JID represents a person (can have LID-PN mapping). + * Excludes groups, broadcasts, newsletters, and bots. + * + * @param jid - The JID to check + * @returns true if the JID can have LID-PN mapping + */ +export function isPersonJid(jid: string | undefined): boolean { + if (!jid) { + return false + } + + // Groups, broadcasts, and newsletters don't have LID-PN mappings + if (isJidGroup(jid) || isJidBroadcast(jid) || isJidNewsletter(jid)) { + return false + } + + // Only person JIDs (LID or PN formats) can have mappings + return !!( + isLidUser(jid) || + isHostedLidUser(jid) || + isPnUser(jid) || + isHostedPnUser(jid) + ) +} + +/** + * 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. + * + * Skips non-person JIDs: + * - `@g.us` (groups) + * - `@broadcast` (broadcast lists) + * - `@newsletter` (channels) + * + * @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' } + * + * @example + * // Newsletter - returns undefined (no mapping) + * extractLidPnFromConversation('123456789@newsletter', undefined, undefined) + * // Returns: undefined + */ +export function extractLidPnFromConversation( + chatId: string, + lidJid: string | undefined | null, + pnJid: string | undefined | null +): LIDMapping | undefined { + // Skip non-person JIDs (groups, broadcasts, newsletters) + if (!isPersonJid(chatId)) { + return 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 +} + +/** + * Extracts LID-PN mapping from a message's alternative JID fields. + * + * Messages may contain alternate JID formats in: + * - `key.remoteJidAlt` - Alternative remote JID format + * - `key.participantAlt` - Alternative participant JID format (for groups) + * + * IMPORTANT: Uses || (OR) to ensure BOTH JIDs are person JIDs before extracting. + * This prevents "poisoned" mappings where one side is a group/newsletter/broadcast. + * + * @param remoteJid - The primary remote JID + * @param remoteJidAlt - The alternative remote JID (may be LID or PN) + * @param participant - The primary participant JID (for group messages) + * @param participantAlt - The alternative participant JID + * @returns LID-PN mapping if extractable, undefined otherwise + */ +export function extractLidPnFromMessage( + remoteJid: string | undefined | null, + remoteJidAlt: string | undefined | null, + participant: string | undefined | null, + participantAlt: string | undefined | null +): LIDMapping | undefined { + // For group messages, use participant fields + const primaryJid = participant || remoteJid + const altJid = participantAlt || remoteJidAlt + + if (!primaryJid || !altJid) { + return undefined + } + + // FIXED: Use || (OR) instead of && (AND) + // Both JIDs MUST be person JIDs to create a valid mapping. + // Using && would allow mixed scenarios (e.g., person + group) to proceed, + // resulting in invalid "poisoned" mappings. + if (!isPersonJid(primaryJid) || !isPersonJid(altJid)) { + return undefined + } + + const primaryDecoded = jidDecode(primaryJid) + const altDecoded = jidDecode(altJid) + + if (!primaryDecoded || !altDecoded) { + return undefined + } + + // Determine which is LID and which is PN + const primaryIsLid = primaryDecoded.server === 'lid' || primaryDecoded.server === 'hosted.lid' + const altIsLid = altDecoded.server === 'lid' || altDecoded.server === 'hosted.lid' + + if (primaryIsLid && !altIsLid) { + // Primary is LID, alt is PN + return { + lid: jidNormalizedUser(primaryJid), + pn: jidNormalizedUser(altJid) + } + } + + if (!primaryIsLid && altIsLid) { + // Primary is PN, alt is LID + return { + lid: jidNormalizedUser(altJid), + pn: jidNormalizedUser(primaryJid) + } + } + + return undefined +} + +/** + * Processes a history sync message and extracts chats, contacts, messages, + * and LID-PN mappings. + * + * LID-PN mappings are extracted from three sources: + * 1. Top-level `phoneNumberToLidMappings` array in the history sync payload + * 2. Individual conversation objects that contain both LID and PN identifiers + * (via `lidJid` and `pnJid` properties) + * 3. Message objects with alternate JID fields (`remoteJidAlt`, `participantAlt`) + * + * This multi-source extraction ensures maximum mapping coverage, as WhatsApp may + * provide mappings in different locations depending on the sync type and context. + * + * Skipped JID types (no LID-PN mapping): + * - `@g.us` (groups) + * - `@broadcast` (broadcast lists) + * - `@newsletter` (channels) + * + * @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) + }) } } @@ -167,41 +264,56 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { case proto.HistorySync.HistorySyncType.RECENT: case proto.HistorySync.HistorySyncType.FULL: case proto.HistorySync.HistorySyncType.ON_DEMAND: - for(const chat of item.conversations! as Chat[]) { + 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 }) - // Extract LID-PN mapping from conversation (lidJid/pnJid properties) - const convMapping = extractLidPnFromConversation( - chat.id!, - chat.lidJid, - chat.pnJid - ) - if(convMapping) { - lidPnMappings.push(convMapping) - } - const msgs = chat.messages || [] delete chat.messages - for(const item of msgs) { + for (const item of msgs) { const message = item.message! as WAMessage messages.push(message) - if(!chat.messages?.length) { + // Source 3: Extract LID-PN mapping from message's alternative JID fields + // Messages may have remoteJidAlt or participantAlt with alternate format + const messageMapping = extractLidPnFromMessage( + message.key.remoteJid, + message.key.remoteJidAlt, + message.key.participant, + message.key.participantAlt + ) + if (messageMapping) { + addLidPnMapping(messageMapping) + } + + if (!chat.messages?.length) { // keep only the most recent message in the chat array chat.messages = [{ message }] } - if(!message.key.fromMe && !chat.lastMessageRecvTimestamp) { + if (!message.key.fromMe && !chat.lastMessageRecvTimestamp) { chat.lastMessageRecvTimestamp = toNumber(message.messageTimestamp) } - if( + if ( (message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_BSP || message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_FB) && message.messageStubParameters?.[0] @@ -211,17 +323,6 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { verifiedName: message.messageStubParameters?.[0] }) } - - // Extract LID-PN mapping from message (remoteJidAlt/participantAlt fields) - const msgMapping = extractLidPnFromMessage( - message.key.remoteJid, - (message.key as { remoteJidAlt?: string }).remoteJidAlt, - message.key.participant, - (message.key as { participantAlt?: string }).participantAlt - ) - if(msgMapping) { - lidPnMappings.push(msgMapping) - } } chats.push({ ...chat }) @@ -236,6 +337,9 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { break } + // Convert Map back to array for return + const lidPnMappings = Array.from(lidPnMap.values()) + return { chats, contacts, @@ -246,6 +350,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 @@ -260,6 +371,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 a900e275..d5eb2928 100644 --- a/src/__tests__/Utils/history.test.ts +++ b/src/__tests__/Utils/history.test.ts @@ -1,11 +1,367 @@ import { proto } from '../../../WAProto/index.js' import { + processHistoryMessage, extractLidPnFromConversation, extractLidPnFromMessage, - isPersonJid, - processHistoryMessage + isPersonJid } from '../../Utils/history' +describe('isPersonJid', () => { + it('should return true for @s.whatsapp.net format', () => { + expect(isPersonJid('5511999999999@s.whatsapp.net')).toBe(true) + }) + + it('should return true for @lid format', () => { + expect(isPersonJid('123456789012345@lid')).toBe(true) + }) + + it('should return true for @hosted format', () => { + expect(isPersonJid('5511999999999@hosted')).toBe(true) + }) + + it('should return true for @hosted.lid format', () => { + expect(isPersonJid('123456789012345@hosted.lid')).toBe(true) + }) + + it('should return false for @g.us (groups)', () => { + expect(isPersonJid('123456789012345@g.us')).toBe(false) + }) + + it('should return false for @broadcast', () => { + expect(isPersonJid('status@broadcast')).toBe(false) + }) + + it('should return false for @newsletter (channels)', () => { + expect(isPersonJid('123456789012345@newsletter')).toBe(false) + }) + + it('should return false for undefined', () => { + expect(isPersonJid(undefined)).toBe(false) + }) + + it('should return false for empty string', () => { + expect(isPersonJid('')).toBe(false) + }) +}) + +describe('extractLidPnFromMessage', () => { + describe('direct messages', () => { + it('should extract mapping when remoteJid is LID and remoteJidAlt is PN', () => { + const result = extractLidPnFromMessage( + '123456789012345@lid', + '5511999999999@s.whatsapp.net', + undefined, + undefined + ) + + expect(result).toEqual({ + lid: '123456789012345@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + it('should extract mapping when remoteJid is PN and remoteJidAlt is LID', () => { + const result = extractLidPnFromMessage( + '5511999999999@s.whatsapp.net', + '123456789012345@lid', + undefined, + undefined + ) + + expect(result).toEqual({ + lid: '123456789012345@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + }) + + describe('group messages', () => { + it('should extract mapping from participant fields in groups', () => { + const result = extractLidPnFromMessage( + '123456789012345@g.us', // group JID + undefined, + '5511999999999@s.whatsapp.net', // participant + '123456789012345@lid' // participantAlt + ) + + expect(result).toEqual({ + lid: '123456789012345@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + it('should prefer participant over remoteJid for mapping', () => { + const result = extractLidPnFromMessage( + '5511888888888@s.whatsapp.net', // ignored + '987654321098765@lid', // ignored + '5511999999999@s.whatsapp.net', // participant used + '123456789012345@lid' // participantAlt used + ) + + expect(result).toEqual({ + lid: '123456789012345@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + }) + + describe('edge cases', () => { + it('should return undefined when no alt JID is present', () => { + const result = extractLidPnFromMessage( + '5511999999999@s.whatsapp.net', + undefined, + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when both are same type (LID)', () => { + const result = extractLidPnFromMessage( + '123456789012345@lid', + '987654321098765@lid', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when both are same type (PN)', () => { + const result = extractLidPnFromMessage( + '5511999999999@s.whatsapp.net', + '5511888888888@s.whatsapp.net', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined for newsletter messages', () => { + const result = extractLidPnFromMessage( + '123456789012345@newsletter', + undefined, + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + // CRITICAL: Edge cases for the && vs || fix (bug fix validation) + it('should return undefined when primary is person but alt is GROUP', () => { + const result = extractLidPnFromMessage( + '123456789012345@lid', + '123456789@g.us', // group JID + undefined, + undefined + ) + + // With the fix (using ||), this should return undefined + // because group JIDs cannot be part of a valid LID-PN mapping + expect(result).toBeUndefined() + }) + + it('should return undefined when primary is person but alt is BROADCAST', () => { + const result = extractLidPnFromMessage( + '123456789012345@lid', + 'status@broadcast', // broadcast JID + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when primary is person but alt is NEWSLETTER', () => { + const result = extractLidPnFromMessage( + '123456789012345@lid', + '123456789@newsletter', // newsletter JID + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when alt is person but primary is GROUP', () => { + const result = extractLidPnFromMessage( + '123456789@g.us', // group JID + '5511999999999@s.whatsapp.net', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when alt is person but primary is BROADCAST', () => { + const result = extractLidPnFromMessage( + 'status@broadcast', // broadcast JID + '5511999999999@s.whatsapp.net', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when alt is person but primary is NEWSLETTER', () => { + const result = extractLidPnFromMessage( + '123456789@newsletter', // newsletter JID + '5511999999999@s.whatsapp.net', + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined when BOTH are non-person JIDs', () => { + const result = extractLidPnFromMessage( + '123456789@g.us', // group JID + 'status@broadcast', // broadcast JID + undefined, + undefined + ) + + expect(result).toBeUndefined() + }) + }) +}) + +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' + ) + + 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 + ) + + 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 for newsletters (@newsletter)', () => { + const result = extractLidPnFromConversation( + '123456789012345@newsletter', + '11111111111111@lid', + '5511999999999@s.whatsapp.net' + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined for broadcast lists (@broadcast)', () => { + const result = extractLidPnFromConversation( + 'status@broadcast', + 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)', () => { + const result = extractLidPnFromConversation( + '123456789012345@lid', + '987654321098765@lid', + undefined + ) + + expect(result).toBeUndefined() + }) + + it('should return undefined for PN chat with pnJid (no lidJid)', () => { + const result = extractLidPnFromConversation( + '5511999999999@s.whatsapp.net', + undefined, + '5511888888888@s.whatsapp.net' + ) + + expect(result).toBeUndefined() + }) + }) +}) + describe('processHistoryMessage', () => { describe('phoneNumberToLidMappings extraction', () => { it('should extract LID-PN mappings from history sync payload', () => { @@ -111,7 +467,6 @@ describe('processHistoryMessage', () => { const result = processHistoryMessage(historySync) - // Should include mapping extracted from conversation expect(result.lidPnMappings).toContainEqual({ lid: '11111111111111@lid', pn: '1234567890123@s.whatsapp.net' @@ -133,287 +488,315 @@ describe('processHistoryMessage', () => { const result = processHistoryMessage(historySync) - // Should NOT include mapping from group conversation expect(result.lidPnMappings).toEqual([]) }) }) -}) -describe('isPersonJid', () => { - it('should return true for LID users', () => { - expect(isPersonJid('11111111111111@lid')).toBe(true) + 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) + + 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) + + 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) + + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@lid', pn: '5511999999999@s.whatsapp.net' } + ]) + }) + + it('should not extract mapping from newsletter conversations', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '123456789012345@newsletter', + name: 'News Channel' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([]) + }) + + it('should not extract mapping from broadcast conversations', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: 'status@broadcast', + name: 'Status' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([]) + }) }) - it('should return true for hosted LID users', () => { - expect(isPersonJid('11111111111111@hosted.lid')).toBe(true) - }) + describe('LID-PN extraction from messages (remoteJidAlt)', () => { + it('should extract mapping from message with remoteJidAlt', () => { + const historySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '5511999999999@s.whatsapp.net', + name: 'User', + messages: [ + { + message: { + key: { + remoteJid: '5511999999999@s.whatsapp.net', + remoteJidAlt: '11111111111111@lid', + id: 'MSG123', + fromMe: false + }, + messageTimestamp: 1234567890 + } + } + ] + } + ] + } as unknown as proto.IHistorySync - it('should return true for PN users (s.whatsapp.net)', () => { - expect(isPersonJid('1234567890123@s.whatsapp.net')).toBe(true) - }) + const result = processHistoryMessage(historySync) - it('should return true for hosted PN users', () => { - expect(isPersonJid('1234567890123@hosted')).toBe(true) - }) + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) - it('should return false for groups', () => { - expect(isPersonJid('123456789@g.us')).toBe(false) - }) + it('should extract mapping from group message with participantAlt', () => { + const historySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '123456789012345@g.us', + name: 'Group Chat', + messages: [ + { + message: { + key: { + remoteJid: '123456789012345@g.us', + participant: '5511999999999@s.whatsapp.net', + participantAlt: '11111111111111@lid', + id: 'MSG456', + fromMe: false + }, + messageTimestamp: 1234567890 + } + } + ] + } + ] + } as unknown as proto.IHistorySync - it('should return false for broadcasts', () => { - expect(isPersonJid('status@broadcast')).toBe(false) - }) + const result = processHistoryMessage(historySync) - it('should return false for newsletters', () => { - expect(isPersonJid('123456789@newsletter')).toBe(false) - }) + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) - it('should return false for undefined', () => { - expect(isPersonJid(undefined)).toBe(false) - }) + it('should deduplicate mappings from all three sources', () => { + const historySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + phoneNumberToLidMappings: [ + { lidJid: '11111111111111@lid', pnJid: '5511999999999@s.whatsapp.net' } + ], + conversations: [ + { + id: '11111111111111@lid', + name: 'User', + pnJid: '5511999999999@s.whatsapp.net', + messages: [ + { + message: { + key: { + remoteJid: '11111111111111@lid', + remoteJidAlt: '5511999999999@s.whatsapp.net', + id: 'MSG789', + fromMe: true + }, + messageTimestamp: 1234567890 + } + } + ] + } + ] + } as unknown as proto.IHistorySync - it('should return false for empty string', () => { - expect(isPersonJid('')).toBe(false) - }) -}) - -describe('extractLidPnFromConversation', () => { - it('should extract mapping when chat is PN and lidJid is provided', () => { - const result = extractLidPnFromConversation( - '1234567890123@s.whatsapp.net', - '11111111111111@lid', - null - ) - - expect(result).toEqual({ - lid: '11111111111111@lid', - pn: '1234567890123@s.whatsapp.net' - }) - }) - - it('should extract mapping when chat is LID and pnJid is provided', () => { - const result = extractLidPnFromConversation( - '11111111111111@lid', - null, - '1234567890123@s.whatsapp.net' - ) - - expect(result).toEqual({ - lid: '11111111111111@lid', - pn: '1234567890123@s.whatsapp.net' - }) - }) - - it('should return undefined for group chats', () => { - const result = extractLidPnFromConversation( - '123456789@g.us', - '11111111111111@lid', - '1234567890123@s.whatsapp.net' - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined for broadcast chats', () => { - const result = extractLidPnFromConversation( - 'status@broadcast', - '11111111111111@lid', - '1234567890123@s.whatsapp.net' - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined for newsletter chats', () => { - const result = extractLidPnFromConversation( - '123456789@newsletter', - '11111111111111@lid', - '1234567890123@s.whatsapp.net' - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when no mapping data is provided', () => { - const result = extractLidPnFromConversation( - '1234567890123@s.whatsapp.net', - null, - null - ) - - expect(result).toBeUndefined() - }) -}) - -describe('extractLidPnFromMessage', () => { - it('should extract mapping when primary is LID and alt is PN', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - '1234567890123@s.whatsapp.net', - null, - null - ) - - expect(result).toEqual({ - lid: '11111111111111@lid', - pn: '1234567890123@s.whatsapp.net' - }) - }) - - it('should extract mapping when primary is PN and alt is LID', () => { - const result = extractLidPnFromMessage( - '1234567890123@s.whatsapp.net', - '11111111111111@lid', - null, - null - ) - - expect(result).toEqual({ - lid: '11111111111111@lid', - pn: '1234567890123@s.whatsapp.net' - }) - }) - - it('should prefer participant over remoteJid', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - '22222222222222@lid', - '33333333333333@lid', - '1234567890123@s.whatsapp.net' - ) - - // participant (33333333333333@lid) and participantAlt (1234567890123@s.whatsapp.net) should be used - expect(result).toEqual({ - lid: '33333333333333@lid', - pn: '1234567890123@s.whatsapp.net' - }) - }) - - it('should return undefined when primaryJid is missing', () => { - const result = extractLidPnFromMessage( - null, - '1234567890123@s.whatsapp.net', - null, - null - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when altJid is missing', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - null, - null, - null - ) - - expect(result).toBeUndefined() - }) - - // CRITICAL: Edge cases for the && vs || fix - describe('edge cases for mixed JID types (bug fix validation)', () => { - it('should return undefined when primary is person but alt is GROUP', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - '123456789@g.us', // group JID - null, - null - ) - - // With the fix (using ||), this should return undefined - // because group JIDs cannot be part of a valid LID-PN mapping - expect(result).toBeUndefined() - }) - - it('should return undefined when primary is person but alt is BROADCAST', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - 'status@broadcast', // broadcast JID - null, - null - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when primary is person but alt is NEWSLETTER', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - '123456789@newsletter', // newsletter JID - null, - null - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when alt is person but primary is GROUP', () => { - const result = extractLidPnFromMessage( - '123456789@g.us', // group JID - '1234567890123@s.whatsapp.net', - null, - null - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when alt is person but primary is BROADCAST', () => { - const result = extractLidPnFromMessage( - 'status@broadcast', // broadcast JID - '1234567890123@s.whatsapp.net', - null, - null - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when alt is person but primary is NEWSLETTER', () => { - const result = extractLidPnFromMessage( - '123456789@newsletter', // newsletter JID - '1234567890123@s.whatsapp.net', - null, - null - ) - - expect(result).toBeUndefined() - }) - - it('should return undefined when BOTH are non-person JIDs', () => { - const result = extractLidPnFromMessage( - '123456789@g.us', // group JID - 'status@broadcast', // broadcast JID - null, - null - ) - - expect(result).toBeUndefined() - }) - }) - - it('should return undefined when both are same type (both LID)', () => { - const result = extractLidPnFromMessage( - '11111111111111@lid', - '22222222222222@lid', - null, - null - ) - - // Both are LID, no PN - can't determine mapping - expect(result).toBeUndefined() - }) - - it('should return undefined when both are same type (both PN)', () => { - const result = extractLidPnFromMessage( - '1234567890123@s.whatsapp.net', - '9876543210987@s.whatsapp.net', - null, - null - ) - - // Both are PN, no LID - can't determine mapping - expect(result).toBeUndefined() + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toHaveLength(1) + expect(result.lidPnMappings[0]).toEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) }) })