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
This commit is contained in:
Claude
2026-01-20 22:17:09 +00:00
parent bb73662e8f
commit 86c1cf439a
2 changed files with 425 additions and 5 deletions
+135 -4
View File
@@ -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<string, LIDMapping>()
/**
* 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!
+290 -1
View File
@@ -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' }
])
})
})
})