diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 6b2e1651..83d05612 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -9,8 +9,12 @@ import { downloadContentFromMessage } from './messages-media' import { isHostedLidUser, isHostedPnUser, + isJidBroadcast, + isJidGroup, + isJidNewsletter, isLidUser, isPnUser, + jidDecode, jidNormalizedUser } from '../WABinary/index.js' @@ -39,6 +43,32 @@ 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. * @@ -49,6 +79,11 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio * 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 @@ -63,12 +98,22 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio * // 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 @@ -93,16 +138,85 @@ export function extractLidPnFromConversation( 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) + * + * @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 + } + + // Skip non-person JIDs + 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 two sources: + * 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 dual extraction ensures maximum mapping coverage, as WhatsApp may - * provide mappings in either or both locations depending on the sync type. + * 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 @@ -172,6 +286,18 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { const message = item.message! as WAMessage messages.push(message) + // 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 }] diff --git a/src/__tests__/Utils/history.test.ts b/src/__tests__/Utils/history.test.ts index 04447b47..6f739acd 100644 --- a/src/__tests__/Utils/history.test.ts +++ b/src/__tests__/Utils/history.test.ts @@ -1,5 +1,156 @@ import { proto } from '../../../WAProto/index.js' -import { processHistoryMessage, extractLidPnFromConversation } from '../../Utils/history' +import { + processHistoryMessage, + extractLidPnFromConversation, + extractLidPnFromMessage, + 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() + }) + }) +}) describe('extractLidPnFromConversation', () => { describe('LID chat with pnJid', () => { @@ -71,6 +222,26 @@ describe('extractLidPnFromConversation', () => { 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', @@ -379,5 +550,144 @@ describe('processHistoryMessage', () => { { 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([]) + }) + }) + + describe('LID-PN extraction from messages (remoteJidAlt)', () => { + // Note: remoteJidAlt and participantAlt are runtime extensions to IMessageKey + // defined in WAMessageKey type. We cast via unknown for test data. + 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 + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + 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 + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toContainEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) + + 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 + + const result = processHistoryMessage(historySync) + + // Should have only 1 mapping (deduplicated across all 3 sources) + expect(result.lidPnMappings).toHaveLength(1) + expect(result.lidPnMappings[0]).toEqual({ + lid: '11111111111111@lid', + pn: '5511999999999@s.whatsapp.net' + }) + }) }) })