6c52b01ea7
Two bugs fixed compared to prior implementation:
1. history.ts — normalize userJid LID→PN
pastParticipants[].userJid may arrive as a LID identifier (e.g. "46802258641027@lid").
The consumer expects a phone number. After building the lidPnMap from conversations
and phoneNumberToLidMappings, each userJid is resolved to its PN equivalent.
If the mapping is not available the original value is preserved (no data loss).
2. event-buffer.ts — deduplicate by groupJid + userJid across chunks
Multiple HistorySync chunks can contain the same group. The previous approach
concatenated blindly, producing duplicate entries. Now a keyed map
{ [groupJid]: IPastParticipant[] } is used so each group appears once and
each participant within a group appears at most once.
Types updated (Events.ts):
- messaging-history.set event includes pastParticipants?: IPastParticipants[]
- BufferedEventData.historySets.pastParticipants typed as keyed map
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
import { promisify } from 'util'
|
|
import { inflate } from 'zlib'
|
|
import { proto } from '../../WAProto/index.js'
|
|
import type { Chat, Contact, LIDMapping, WAMessage } from '../Types'
|
|
import { WAMessageStubType } from '../Types'
|
|
import {
|
|
isAnyLidUser,
|
|
isAnyPnUser,
|
|
isJidBroadcast,
|
|
isJidGroup,
|
|
isJidNewsletter,
|
|
jidDecode,
|
|
jidNormalizedUser
|
|
} from '../WABinary/index.js'
|
|
import { toNumber } from './generics'
|
|
import type { ILogger } from './logger.js'
|
|
import { normalizeMessageContent } from './messages'
|
|
import { downloadContentFromMessage } from './messages-media'
|
|
|
|
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[] = []
|
|
for await (const chunk of stream) {
|
|
bufferArray.push(chunk)
|
|
}
|
|
|
|
let buffer: Buffer = Buffer.concat(bufferArray)
|
|
|
|
// decompress buffer
|
|
buffer = await inflatePromise(buffer)
|
|
|
|
const syncData = proto.HistorySync.decode(buffer)
|
|
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 isAnyLidUser(jid) || isAnyPnUser(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 = isAnyLidUser(chatId)
|
|
// Check if chat ID is in PN format
|
|
const chatIsPn = isAnyPnUser(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
|
|
}
|
|
|
|
/**
|
|
* Extracts phone number (PN) from message userReceipt fields.
|
|
*
|
|
* This is a fallback mechanism for LID chats that don't have a pnJid property.
|
|
* It looks at outgoing messages (fromMe: true) and extracts the recipient's
|
|
* phone number from the userReceipt.userJid field.
|
|
*
|
|
* @param messages - Array of history sync messages from a conversation
|
|
* @returns The extracted phone number JID, or undefined if not found
|
|
*
|
|
* @see https://github.com/WhiskeySockets/Baileys/pull/2282
|
|
*/
|
|
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 && isAnyPnUser(userJid)) {
|
|
return userJid
|
|
}
|
|
}
|
|
|
|
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
|
|
* @param logger - Optional logger instance for trace-level debugging
|
|
* @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, logger?: ILogger) => {
|
|
logger?.trace({ syncType: item.syncType, progress: item.progress }, 'processing history sync')
|
|
const messages: WAMessage[] = []
|
|
const contacts: Contact[] = []
|
|
const chats: Chat[] = []
|
|
|
|
// 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) {
|
|
addLidPnMapping({
|
|
lid: jidNormalizedUser(m.lidJid),
|
|
pn: jidNormalizedUser(m.pnJid)
|
|
})
|
|
}
|
|
}
|
|
|
|
switch (item.syncType) {
|
|
case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP:
|
|
case proto.HistorySync.HistorySyncType.RECENT:
|
|
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)
|
|
} else if (isAnyLidUser(chatId) && !chat.pnJid) {
|
|
// Source 2b: Fallback - extract PN from userReceipt in messages when pnJid is missing
|
|
// This handles edge cases where the conversation is LID but pnJid wasn't provided
|
|
const pnFromReceipt = extractPnFromMessages(chat.messages || [])
|
|
if (pnFromReceipt) {
|
|
addLidPnMapping({
|
|
lid: jidNormalizedUser(chatId),
|
|
pn: jidNormalizedUser(pnFromReceipt)
|
|
})
|
|
}
|
|
}
|
|
|
|
contacts.push({
|
|
id: chatId,
|
|
name: chat.displayName || chat.name || chat.username || undefined,
|
|
lid: chat.lidJid || chat.accountLid || undefined,
|
|
phoneNumber: chat.pnJid || undefined
|
|
})
|
|
|
|
const msgs = chat.messages || []
|
|
delete chat.messages
|
|
|
|
for (const item of msgs) {
|
|
if (!item.message) continue
|
|
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 }]
|
|
}
|
|
|
|
if (!message.key.fromMe && !chat.lastMessageRecvTimestamp) {
|
|
chat.lastMessageRecvTimestamp = toNumber(message.messageTimestamp)
|
|
}
|
|
|
|
if (
|
|
(message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_BSP ||
|
|
message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_FB) &&
|
|
message.messageStubParameters?.[0]
|
|
) {
|
|
contacts.push({
|
|
id: message.key.participant || message.key.remoteJid || '',
|
|
verifiedName: message.messageStubParameters?.[0]
|
|
})
|
|
}
|
|
}
|
|
|
|
chats.push({ ...chat })
|
|
}
|
|
|
|
break
|
|
case proto.HistorySync.HistorySyncType.PUSH_NAME:
|
|
for (const c of item.pushnames ?? []) {
|
|
contacts.push({ id: c.id!, notify: c.pushname! })
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
// Convert Map back to array for return
|
|
const lidPnMappings = Array.from(lidPnMap.values())
|
|
|
|
// Normalize pastParticipants: resolve LID userJids → PN so the consumer always
|
|
// receives a phone number, never an opaque LID identifier.
|
|
// Uses the lidPnMap built above (populated from phoneNumberToLidMappings + conversations).
|
|
// If a LID cannot be resolved, the original value is kept rather than dropping the participant.
|
|
const pastParticipants: proto.IPastParticipants[] = (item.pastParticipants ?? []).map(group => ({
|
|
groupJid: group.groupJid,
|
|
pastParticipants: (group.pastParticipants ?? []).map(participant => {
|
|
const userJid = participant.userJid
|
|
if (!userJid || !isAnyLidUser(userJid)) return participant
|
|
const mapping = lidPnMap.get(jidNormalizedUser(userJid))
|
|
return mapping?.pn ? { ...participant, userJid: mapping.pn } : participant
|
|
})
|
|
}))
|
|
|
|
return {
|
|
chats,
|
|
contacts,
|
|
messages,
|
|
pastParticipants,
|
|
lidPnMappings,
|
|
syncType: item.syncType,
|
|
progress: item.progress
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads and processes a history sync notification in one step.
|
|
*
|
|
* Handles two cases:
|
|
* - Inline payload: Decodes directly from `initialHistBootstrapInlinePayload`
|
|
* - Remote download: Fetches from WhatsApp servers via `downloadHistory`
|
|
*
|
|
* @param msg - The history sync notification message
|
|
* @param options - Request options for the download
|
|
* @param logger - Optional logger instance for trace-level debugging
|
|
* @returns Processed history data including chats, contacts, messages, and LID-PN mappings
|
|
*/
|
|
export const downloadAndProcessHistorySyncNotification = async (
|
|
msg: proto.Message.IHistorySyncNotification,
|
|
options: RequestInit,
|
|
logger?: ILogger
|
|
) => {
|
|
let historyMsg: proto.HistorySync
|
|
if (msg.initialHistBootstrapInlinePayload) {
|
|
historyMsg = proto.HistorySync.decode(await inflatePromise(msg.initialHistBootstrapInlinePayload))
|
|
} else {
|
|
historyMsg = await downloadHistory(msg, options)
|
|
}
|
|
|
|
return processHistoryMessage(historyMsg, logger)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
return anyHistoryMsg
|
|
}
|