3e8c89d7d0
Merge master branch and resolve conflicts: - Use import from '../WABinary/index.js' (master convention) - Keep the bug fix: || instead of && in extractLidPnFromMessage - Combine comprehensive tests from both branches - Use Map for deduplication (master implementation) All 55 tests passing.
386 lines
12 KiB
TypeScript
386 lines
12 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 { toNumber } from './generics'
|
|
import { normalizeMessageContent } from './messages'
|
|
import { downloadContentFromMessage } from './messages-media'
|
|
import {
|
|
isHostedLidUser,
|
|
isHostedPnUser,
|
|
isJidBroadcast,
|
|
isJidGroup,
|
|
isJidNewsletter,
|
|
isLidUser,
|
|
isPnUser,
|
|
jidDecode,
|
|
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[] = []
|
|
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 !!(
|
|
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[] = []
|
|
|
|
// 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)
|
|
}
|
|
|
|
contacts.push({
|
|
id: chatId,
|
|
name: chat.name || undefined,
|
|
lid: chat.lidJid || undefined,
|
|
phoneNumber: chat.pnJid || undefined
|
|
})
|
|
|
|
const msgs = chat.messages || []
|
|
delete chat.messages
|
|
|
|
for (const item of msgs) {
|
|
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())
|
|
|
|
return {
|
|
chats,
|
|
contacts,
|
|
messages,
|
|
lidPnMappings,
|
|
syncType: item.syncType,
|
|
progress: item.progress
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
) => {
|
|
let historyMsg: proto.HistorySync
|
|
if (msg.initialHistBootstrapInlinePayload) {
|
|
historyMsg = proto.HistorySync.decode(await inflatePromise(msg.initialHistBootstrapInlinePayload))
|
|
} else {
|
|
historyMsg = await downloadHistory(msg, options)
|
|
}
|
|
|
|
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!
|
|
|
|
return anyHistoryMsg
|
|
}
|