feat: centralized LID→PN normalization for all emitted events (#246)

* feat: centralized LID→PN normalization for all emitted events

WhatsApp's server increasingly uses LID (Linked ID) as the primary
addressing format. Consumers expect phone numbers (PN),
not opaque LID identifiers. This adds comprehensive LID→PN resolution
across all ev.emit() paths so downstream consumers always receive PN.

Key changes:

- Add resolveLidToPn() and normalizeKeyLidToPn() centralized helpers
  in process-message.ts for consistent LID→PN resolution

- normalizeMessageJids() fast path: use alt JID directly from stanza
  attributes (zero I/O, eliminates race condition with LIDMappingStore)

- Await storeLIDPNMappings() before normalization in message receipt
  flow (was fire-and-forget, could race with subsequent getPNForLID)

- Normalize LID→PN in all event emission points:
  * messages.upsert (keys, nested reaction/poll keys, participantAlt)
  * presence.update (jid + participant)
  * message-receipt.update (key + userJid)
  * contacts.update (picture notifications)
  * blocklist.update (blocklist JIDs)
  * call events (chatId, from)
  * group metadata (participants, owner, subjectOwner)
  * group notifications (acting participant, add/remove/promote/demote)
  * newsletter notifications (author, user JIDs)
  * sync actions (mutation index normalization)

- Make handlePresenceUpdate and handleGroupNotification async to
  support await on LID resolution

- Add normalizeGroupMetadata() helper in groups.ts for all
  extractGroupMetadata call sites

Performance: ~0.01ms per message (LRU cache hit). No impact on
message sending. First-contact resolution ~2-5ms (one-time per contact).

* fix: normalize LID→PN in handleBadAck, media retry, and PDO recovery

Additional leak points found during final audit:
- handleBadAck: key.remoteJid from ack stanza could be LID
- messages.media-update: media retry key JIDs not normalized
- CTWA PDO recovery: webMessageInfo.key from phone response not normalized

* fix: address PR review — parallelize LID resolution, use helper consistently

- normalizeGroupMetadata: resolve participant LIDs with Promise.all
  instead of sequential loop (perf on large groups)
- handleCall: resolve participant JIDs with Promise.all
- handleBadAck: use normalizeKeyLidToPn() helper instead of manual
  resolveLidToPn + assignment (consistency with other code paths)
- CB:relay: resolve callCreator LID→PN before emitting
This commit is contained in:
Renato Alcara
2026-03-01 17:44:02 -03:00
committed by GitHub
parent 758ab6d8af
commit e1e3d88a1c
4 changed files with 344 additions and 91 deletions
+40 -13
View File
@@ -50,7 +50,8 @@ import {
isMissingKeyError, isMissingKeyError,
MAX_SYNC_ATTEMPTS, MAX_SYNC_ATTEMPTS,
newLTHashState, newLTHashState,
processSyncAction processSyncAction,
resolveLidToPn
} from '../Utils' } from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex' import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message' import processMessage from '../Utils/process-message'
@@ -757,9 +758,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'resync-app-state') }, authState?.creds?.me?.id || 'resync-app-state')
const { onMutation } = newAppStateChunkHandler(isInitialSync) const { onMutation } = newAppStateChunkHandler(isInitialSync)
const lidMapping = signalRepository.lidMapping
for (const key in globalMutationMap) { for (const key in globalMutationMap) {
const mutation = globalMutationMap[key] const mutation = globalMutationMap[key]
if (!mutation) continue if (!mutation) continue
// Normalize LID→PN in sync action index[1] (chat/contact ID)
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
if (resolved) mutation.index[1] = resolved
}
onMutation(mutation) onMutation(mutation)
} }
} }
@@ -912,16 +920,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
}) })
} }
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => { const handlePresenceUpdate = async ({ tag, attrs, content }: BinaryNode) => {
let presence: PresenceData | undefined let presence: PresenceData | undefined
const jid = attrs.from const rawJid = attrs.from
const participant = attrs.participant || attrs.from const rawParticipant = attrs.participant || attrs.from
if (!jid) { if (!rawJid) {
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping') logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
return return
} }
if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) { if (shouldIgnoreJid(rawJid) && rawJid !== S_WHATSAPP_NET) {
return return
} }
@@ -933,7 +941,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
} else if (Array.isArray(content)) { } else if (Array.isArray(content)) {
const [firstChild] = content const [firstChild] = content
if (!firstChild) { if (!firstChild) {
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping') logger.warn({ jid: rawJid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
return return
} }
@@ -952,12 +960,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
if (presence) { if (presence) {
if (!participant) { if (!rawParticipant) {
logger.warn({ jid }, 'handlePresenceUpdate: participant is missing, skipping') logger.warn({ jid: rawJid }, 'handlePresenceUpdate: participant is missing, skipping')
return return
} }
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } }) // Resolve LID→PN so consumers always see phone-number JIDs
const lidMapping = signalRepository.lidMapping
const [jid, participant] = await Promise.all([
resolveLidToPn(rawJid, lidMapping, logger),
resolveLidToPn(rawParticipant, lidMapping, logger)
])
ev.emit('presence.update', { id: jid!, presences: { [participant!]: presence } })
} }
} }
@@ -1031,8 +1046,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
undefined, undefined,
logger logger
) )
const lidMapping = signalRepository.lidMapping
for (const key in mutationMap) { for (const key in mutationMap) {
onMutation(mutationMap[key]!) const mutation = mutationMap[key]!
// Normalize LID→PN in sync action index[1] (chat/contact ID)
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
if (resolved) mutation.index[1] = resolved
}
onMutation(mutation)
} }
} }
} }
@@ -1376,8 +1399,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
}) })
ws.on('CB:presence', handlePresenceUpdate) ws.on('CB:presence', (node: BinaryNode) => {
ws.on('CB:chatstate', handlePresenceUpdate) handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling presence update'))
})
ws.on('CB:chatstate', (node: BinaryNode) => {
handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling chatstate update'))
})
ws.on('CB:ib,,dirty', async (node: BinaryNode) => { ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')! const { attrs } = getBinaryNodeChild(node, 'dirty')!
+43 -7
View File
@@ -1,7 +1,7 @@
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types' import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types'
import { WAMessageAddressingMode, WAMessageStubType } from '../Types' import { WAMessageAddressingMode, WAMessageStubType } from '../Types'
import { generateMessageIDV2, unixTimestampSeconds } from '../Utils' import { generateMessageIDV2, resolveLidToPn, unixTimestampSeconds } from '../Utils'
import { import {
type BinaryNode, type BinaryNode,
getBinaryNodeChild, getBinaryNodeChild,
@@ -17,6 +17,43 @@ import { makeChatsSocket } from './chats'
export const makeGroupsSocket = (config: SocketConfig) => { export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config) const sock = makeChatsSocket(config)
const { authState, ev, query, upsertMessage } = sock const { authState, ev, query, upsertMessage } = sock
const { signalRepository } = sock
const { logger } = config
/** Normalize group metadata participant IDs from LID to PN */
const normalizeGroupMetadata = async (metadata: GroupMetadata): Promise<GroupMetadata> => {
const lidMapping = signalRepository.lidMapping
// Resolve all participant LIDs in parallel for better performance on large groups
await Promise.all(metadata.participants.map(async (p) => {
if (isLidUser(p.id)) {
if (p.phoneNumber) {
p.lid = p.id
p.id = p.phoneNumber
} else {
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
if (resolved && resolved !== p.id) {
p.lid = p.id
p.id = resolved
}
}
}
}))
// Normalize owner/subjectOwner if LID (parallel)
const [resolvedOwner, resolvedSubjectOwner] = await Promise.all([
metadata.owner && isLidUser(metadata.owner)
? (metadata.ownerPn || resolveLidToPn(metadata.owner, lidMapping, logger))
: null,
metadata.subjectOwner && isLidUser(metadata.subjectOwner)
? (metadata.subjectOwnerPn || resolveLidToPn(metadata.subjectOwner, lidMapping, logger))
: null
])
if (resolvedOwner) metadata.owner = resolvedOwner
if (resolvedSubjectOwner) metadata.subjectOwner = resolvedSubjectOwner
return metadata
}
const groupQuery = async (jid: string, type: 'get' | 'set', content: BinaryNode[]) => const groupQuery = async (jid: string, type: 'get' | 'set', content: BinaryNode[]) =>
query({ query({
@@ -31,7 +68,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const groupMetadata = async (jid: string) => { const groupMetadata = async (jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }]) const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
return extractGroupMetadata(result) return normalizeGroupMetadata(extractGroupMetadata(result))
} }
const groupFetchAllParticipating = async () => { const groupFetchAllParticipating = async () => {
@@ -58,16 +95,15 @@ export const makeGroupsSocket = (config: SocketConfig) => {
if (groupsChild) { if (groupsChild) {
const groups = getBinaryNodeChildren(groupsChild, 'group') const groups = getBinaryNodeChildren(groupsChild, 'group')
for (const groupNode of groups) { for (const groupNode of groups) {
const meta = extractGroupMetadata({ const meta = await normalizeGroupMetadata(extractGroupMetadata({
tag: 'result', tag: 'result',
attrs: {}, attrs: {},
content: [groupNode] content: [groupNode]
}) }))
data[meta.id] = meta data[meta.id] = meta
} }
} }
// TODO: properly parse LID / PN DATA
sock.ev.emit('groups.update', Object.values(data)) sock.ev.emit('groups.update', Object.values(data))
return data return data
@@ -101,7 +137,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
})) }))
} }
]) ])
return extractGroupMetadata(result) return normalizeGroupMetadata(extractGroupMetadata(result))
}, },
groupLeave: async (id: string) => { groupLeave: async (id: string) => {
await groupQuery('@g.us', 'set', [ await groupQuery('@g.us', 'set', [
@@ -277,7 +313,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
), ),
groupGetInviteInfo: async (code: string) => { groupGetInviteInfo: async (code: string) => {
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }]) const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
return extractGroupMetadata(results) return normalizeGroupMetadata(extractGroupMetadata(results))
}, },
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => { groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration const content: BinaryNode = ephemeralExpiration
+168 -43
View File
@@ -49,7 +49,9 @@ import {
MISSING_KEYS_ERROR_TEXT, MISSING_KEYS_ERROR_TEXT,
NACK_REASONS, NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT, NO_MESSAGE_FOUND_ERROR_TEXT,
normalizeKeyLidToPn,
normalizeMessageJids, normalizeMessageJids,
resolveLidToPn,
SERVER_ERROR_CODES, SERVER_ERROR_CODES,
toNumber, toNumber,
unixTimestampSeconds, unixTimestampSeconds,
@@ -365,10 +367,14 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'NotificationNewsletterAdminPromote': case 'NotificationNewsletterAdminPromote':
for (const update of updates) { for (const update of updates) {
if (update.jid && update.user) { if (update.jid && update.user) {
const [resolvedAuthor, resolvedUser] = await Promise.all([
resolveLidToPn(node.attrs.from!, signalRepository.lidMapping, logger),
resolveLidToPn(update.user, signalRepository.lidMapping, logger)
])
ev.emit('newsletter-participants.update', { ev.emit('newsletter-participants.update', {
id: update.jid, id: update.jid,
author: node.attrs.from!, author: resolvedAuthor || node.attrs.from!,
user: update.user, user: resolvedUser || update.user,
new_role: 'ADMIN', new_role: 'ADMIN',
action: 'promote' action: 'promote'
}) })
@@ -387,7 +393,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleNewsletterNotification = async (node: BinaryNode) => { const handleNewsletterNotification = async (node: BinaryNode) => {
const from = node.attrs.from! const from = node.attrs.from!
const child = getAllBinaryNodeChildren(node)[0]! const child = getAllBinaryNodeChildren(node)[0]!
const author = node.attrs.participant! const rawAuthor = node.attrs.participant!
// Resolve author LID→PN (participant is a user JID that could be LID)
const author = await resolveLidToPn(rawAuthor, signalRepository.lidMapping, logger) || rawAuthor
logger.info({ from, child }, 'got newsletter notification') logger.info({ from, child }, 'got newsletter notification')
@@ -414,10 +422,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
break break
case 'participant': case 'participant':
const resolvedParticipantUser = await resolveLidToPn(child.attrs.jid!, signalRepository.lidMapping, logger) || child.attrs.jid!
const participantUpdate = { const participantUpdate = {
id: from, id: from,
author, author,
user: child.attrs.jid!, user: resolvedParticipantUser,
action: child.attrs.action!, action: child.attrs.action!,
new_role: child.attrs.role! new_role: child.attrs.role!
} }
@@ -1406,8 +1415,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
const handleGroupNotification = (fullNode: BinaryNode, child: BinaryNode, msg: Partial<WAMessage>) => { const handleGroupNotification = async (fullNode: BinaryNode, child: BinaryNode, msg: Partial<WAMessage>) => {
// TODO: Support PN/LID (Here is only LID now) const lidMapping = signalRepository.lidMapping
const actingParticipantLid = fullNode.attrs.participant const actingParticipantLid = fullNode.attrs.participant
const actingParticipantPn = fullNode.attrs.participant_pn const actingParticipantPn = fullNode.attrs.participant_pn
@@ -1415,13 +1424,52 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid! const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid!
const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn! const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn!
// Resolve acting participant to PN — prefer inline PN, fall back to LID→PN resolution
const actingParticipant = actingParticipantPn
|| await resolveLidToPn(actingParticipantLid, lidMapping, logger)
// Resolve affected participant to PN
const affectedParticipant = affectedParticipantPn
|| await resolveLidToPn(affectedParticipantLid, lidMapping, logger)
// Store LID↔PN mappings from notification attributes
const mappingsToStore: Array<{ lid: string; pn: string }> = []
if (actingParticipantLid && actingParticipantPn && isLidUser(actingParticipantLid) && isPnUser(actingParticipantPn)) {
mappingsToStore.push({ lid: actingParticipantLid, pn: actingParticipantPn })
}
switch (child?.tag) { switch (child?.tag) {
case 'create': case 'create':
const metadata = extractGroupMetadata(child) const metadata = extractGroupMetadata(child)
// Normalize group metadata participant IDs to PN
for (const p of metadata.participants) {
if (isLidUser(p.id)) {
// Use inline phoneNumber if available, otherwise resolve via mapping
if (p.phoneNumber) {
// Store the mapping
mappingsToStore.push({ lid: p.id, pn: p.phoneNumber })
p.lid = p.id
p.id = p.phoneNumber
} else {
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
if (resolved && resolved !== p.id) {
p.lid = p.id
p.id = resolved
}
}
}
}
// Resolve metadata owner to PN
if (metadata.owner && isLidUser(metadata.owner)) {
const resolvedOwner = metadata.ownerPn || await resolveLidToPn(metadata.owner, lidMapping, logger)
if (resolvedOwner) metadata.owner = resolvedOwner
}
msg.messageStubType = WAMessageStubType.GROUP_CREATE msg.messageStubType = WAMessageStubType.GROUP_CREATE
msg.messageStubParameters = [metadata.subject] msg.messageStubParameters = [metadata.subject]
msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn } msg.key = { participant: actingParticipant }
ev.emit('chats.upsert', [ ev.emit('chats.upsert', [
{ {
@@ -1433,8 +1481,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
ev.emit('groups.upsert', [ ev.emit('groups.upsert', [
{ {
...metadata, ...metadata,
author: actingParticipantLid, author: actingParticipant
authorPn: actingParticipantPn
} }
]) ])
break break
@@ -1448,8 +1495,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
break break
case 'modify': case 'modify':
const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid!) const oldNumbers = await Promise.all(
msg.messageStubParameters = oldNumber || [] getBinaryNodeChildren(child, 'participant').map(async p => {
const resolved = await resolveLidToPn(p.attrs.jid!, lidMapping, logger)
return resolved || p.attrs.jid!
})
)
msg.messageStubParameters = oldNumbers || []
msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER
break break
case 'promote': case 'promote':
@@ -1460,21 +1512,48 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}` const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}`
msg.messageStubType = WAMessageStubType[stubType as keyof typeof WAMessageStubType] msg.messageStubType = WAMessageStubType[stubType as keyof typeof WAMessageStubType]
const participants = getBinaryNodeChildren(child, 'participant').map(({ attrs }) => { const participants = await Promise.all(
// TODO: Store LID MAPPINGS getBinaryNodeChildren(child, 'participant').map(async ({ attrs }) => {
return { const rawJid = attrs.jid!
id: attrs.jid!, let id = rawJid
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined, let phoneNumber: string | undefined
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined, let lid: string | undefined
admin: (attrs.type || null) as GroupParticipant['admin']
} if (isLidUser(rawJid)) {
}) // Primary is LID — resolve to PN
phoneNumber = isPnUser(attrs.phone_number) ? attrs.phone_number : undefined
if (phoneNumber) {
mappingsToStore.push({ lid: rawJid, pn: phoneNumber })
lid = rawJid
id = phoneNumber
} else {
const resolved = await resolveLidToPn(rawJid, lidMapping, logger)
if (resolved && resolved !== rawJid) {
lid = rawJid
id = resolved
}
}
} else if (isPnUser(rawJid) && isLidUser(attrs.lid)) {
// Primary is PN — store LID for reference
lid = attrs.lid
mappingsToStore.push({ lid: attrs.lid!, pn: rawJid })
}
return {
id,
phoneNumber,
lid,
admin: (attrs.type || null) as GroupParticipant['admin']
}
})
)
if ( if (
participants.length === 1 && participants.length === 1 &&
// if recv. "remove" message and sender removed themselves // if recv. "remove" message and sender removed themselves
// mark as left // mark as left
(areJidsSameUser(participants[0]!.id, actingParticipantLid) || (areJidsSameUser(participants[0]!.id, actingParticipant) ||
areJidsSameUser(participants[0]!.id, actingParticipantLid) ||
areJidsSameUser(participants[0]!.id, actingParticipantPn)) && areJidsSameUser(participants[0]!.id, actingParticipantPn)) &&
child.tag === 'remove' child.tag === 'remove'
) { ) {
@@ -1525,21 +1604,27 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'created_membership_requests': case 'created_membership_requests':
msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
msg.messageStubParameters = [ msg.messageStubParameters = [
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipant }),
'created', 'created',
child.attrs.request_method! child.attrs.request_method!
] ]
break break
case 'revoked_membership_requests': case 'revoked_membership_requests':
const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid) const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid)
// TODO: LIDMAPPING SUPPORT
msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
msg.messageStubParameters = [ msg.messageStubParameters = [
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipant }),
isDenied ? 'revoked' : 'rejected' isDenied ? 'revoked' : 'rejected'
] ]
break break
} }
// Persist any LID↔PN mappings discovered from notification attributes
if (mappingsToStore.length) {
await lidMapping.storeLIDPNMappings(mappingsToStore).catch(err => {
logger.warn({ err, count: mappingsToStore.length }, 'Failed to store LID↔PN mappings from group notification')
})
}
} }
const processNotification = async (node: BinaryNode) => { const processNotification = async (node: BinaryNode) => {
@@ -1556,11 +1641,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await handleMexNewsletterNotification(node) await handleMexNewsletterNotification(node)
break break
case 'w:gp2': case 'w:gp2':
// TODO: HANDLE PARTICIPANT_PN await handleGroupNotification(node, child!, result)
handleGroupNotification(node, child!, result)
break break
case 'mediaretry': case 'mediaretry':
const event = decodeMediaRetryNode(node) const event = decodeMediaRetryNode(node)
// Normalize LID→PN in media retry key before emitting
await normalizeKeyLidToPn(event.key, signalRepository.lidMapping, logger)
ev.emit('messages.media-update', [event]) ev.emit('messages.media-update', [event])
break break
case 'encrypt': case 'encrypt':
@@ -1591,13 +1677,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const setPicture = getBinaryNodeChild(node, 'set') const setPicture = getBinaryNodeChild(node, 'set')
const delPicture = getBinaryNodeChild(node, 'delete') const delPicture = getBinaryNodeChild(node, 'delete')
// TODO: WAJIDHASH stuff proper support inhouse {
ev.emit('contacts.update', [ const rawPictureJid = jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || ''
{ const pictureJid = await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger) || rawPictureJid
id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '', ev.emit('contacts.update', [
imgUrl: setPicture ? 'changed' : 'removed' {
} id: pictureJid,
]) imgUrl: setPicture ? 'changed' : 'removed'
}
])
}
if (isJidGroup(from)) { if (isJidGroup(from)) {
const node = setPicture || delPicture const node = setPicture || delPicture
@@ -1635,7 +1724,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const blocklists = getBinaryNodeChildren(child, 'item') const blocklists = getBinaryNodeChildren(child, 'item')
for (const { attrs } of blocklists) { for (const { attrs } of blocklists) {
const blocklist = [attrs.jid!] const resolvedBlockJid = await resolveLidToPn(attrs.jid!, signalRepository.lidMapping, logger) || attrs.jid!
const blocklist = [resolvedBlockJid]
const type = attrs.action === 'block' ? 'add' : 'remove' const type = attrs.action === 'block' ? 'add' : 'remove'
ev.emit('blocklist.update', { blocklist, type }) ev.emit('blocklist.update', { blocklist, type })
} }
@@ -1927,6 +2017,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
participant: attrs.participant participant: attrs.participant
} }
// Normalize LID→PN in receipt key so events always emit PN JIDs
const lidMapping = signalRepository.lidMapping
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(key.remoteJid, lidMapping, logger),
resolveLidToPn(key.participant, lidMapping, logger)
])
if (resolvedRemoteJid) key.remoteJid = resolvedRemoteJid
if (resolvedParticipant) key.participant = resolvedParticipant
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) { if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.trace({ remoteJid }, 'ignoring receipt from jid') logger.trace({ remoteJid }, 'ignoring receipt from jid')
await sendMessageAck(node) await sendMessageAck(node)
@@ -1953,12 +2052,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (attrs.participant) { if (attrs.participant) {
const updateKey: keyof MessageUserReceipt = const updateKey: keyof MessageUserReceipt =
status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp' status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
const resolvedReceiptUserJid = await resolveLidToPn(attrs.participant, lidMapping, logger) || jidNormalizedUser(attrs.participant)
ev.emit( ev.emit(
'message-receipt.update', 'message-receipt.update',
ids.map(id => ({ ids.map(id => ({
key: { ...key, id }, key: { ...key, id },
receipt: { receipt: {
userJid: jidNormalizedUser(attrs.participant), userJid: jidNormalizedUser(resolvedReceiptUserJid),
[updateKey]: +attrs.t! [updateKey]: +attrs.t!
} }
})) }))
@@ -2087,10 +2187,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Check if mapping already exists to avoid unnecessary storage operations // Check if mapping already exists to avoid unnecessary storage operations
const existingMapping = await signalRepository.lidMapping.getPNForLID(alt) const existingMapping = await signalRepository.lidMapping.getPNForLID(alt)
if (!existingMapping) { if (!existingMapping) {
// Store mapping in background (non-critical, doesn't block decrypt) // MUST await: normalizeMessageJids() runs after this and needs the mapping
signalRepository.lidMapping // in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]) .storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed')) .catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
} }
// CRITICAL: ALWAYS migrate session, even if mapping exists // CRITICAL: ALWAYS migrate session, even if mapping exists
@@ -2103,10 +2204,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Check if reverse mapping exists // Check if reverse mapping exists
const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt) const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt)
if (!existingMapping) { if (!existingMapping) {
// Store mapping in background (non-critical) // MUST await: normalizeMessageJids() runs after this and needs the mapping
signalRepository.lidMapping // in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]) .storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed')) .catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
} }
// CRITICAL: ALWAYS migrate session, even if mapping exists // CRITICAL: ALWAYS migrate session, even if mapping exists
@@ -2607,6 +2709,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await callOfferCache.del(call.id) await callOfferCache.del(call.id)
} }
// Normalize LID→PN in call event JIDs before emitting to consumers
const callLidMapping = signalRepository.lidMapping
const [resolvedChatId, resolvedFrom, resolvedLinkCreator] = await Promise.all([
resolveLidToPn(call.chatId, callLidMapping, logger),
resolveLidToPn(call.from, callLidMapping, logger),
resolveLidToPn(call.linkCreator, callLidMapping, logger)
])
if (resolvedChatId) call.chatId = resolvedChatId
if (resolvedFrom) call.from = resolvedFrom
if (resolvedLinkCreator) call.linkCreator = resolvedLinkCreator
// Resolve participant JIDs in parallel
if (call.participants) {
await Promise.all(call.participants.map(async (p) => {
if (p.jid) {
const resolved = p.userPn || await resolveLidToPn(p.jid, callLidMapping, logger)
if (resolved) p.jid = resolved
}
}))
}
ev.emit('call', [call]) ev.emit('call', [call])
} }
@@ -2615,6 +2737,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleBadAck = async ({ attrs }: BinaryNode) => { const handleBadAck = async ({ attrs }: BinaryNode) => {
const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id } const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id }
await normalizeKeyLidToPn(key, signalRepository.lidMapping, logger)
// WARNING: REFRAIN FROM ENABLING THIS FOR NOW. IT WILL CAUSE A LOOP // WARNING: REFRAIN FROM ENABLING THIS FOR NOW. IT WILL CAUSE A LOOP
// // current hypothesis is that if pash is sent in the ack // // current hypothesis is that if pash is sent in the ack
@@ -2802,10 +2925,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Top-level <relay> stanzas carry TURN server info, tokens and crypto keys // Top-level <relay> stanzas carry TURN server info, tokens and crypto keys
ws.on('CB:relay', async (node: BinaryNode) => { ws.on('CB:relay', async (node: BinaryNode) => {
const callId = node.attrs['call-id'] const callId = node.attrs['call-id']
const callCreator = node.attrs['call-creator'] const rawCallCreator = node.attrs['call-creator']
// Both callId and callCreator must be present to emit a valid event // Both callId and callCreator must be present to emit a valid event
// (call link relays may arrive without these attrs — just log them) // (call link relays may arrive without these attrs — just log them)
if (callId && callCreator) { if (callId && rawCallCreator) {
// Resolve LID→PN for call creator
const callCreator = await resolveLidToPn(rawCallCreator, signalRepository.lidMapping, logger) || rawCallCreator
logger.debug( logger.debug(
{ callId, callCreator, uuid: node.attrs.uuid }, { callId, callCreator, uuid: node.attrs.uuid },
'received relay info' 'received relay info'
+93 -28
View File
@@ -19,10 +19,13 @@ import type {
WAMessage, WAMessage,
WAMessageKey WAMessageKey
} from '../Types' } from '../Types'
import type { LIDMappingStore } from '../Signal/lid-mapping'
import { WAMessageStubType } from '../Types' import { WAMessageStubType } from '../Types'
import { getContentType, normalizeMessageContent } from '../Utils/messages' import { getContentType, normalizeMessageContent } from '../Utils/messages'
import { import {
areJidsSameUser, areJidsSameUser,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser, isHostedLidUser,
isHostedPnUser, isHostedPnUser,
isJidBroadcast, isJidBroadcast,
@@ -125,42 +128,94 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
} }
} }
/**
* Resolves a LID JID to its PN equivalent using the LID mapping store.
* Returns the original JID if it's not a LID or if no mapping is found.
* Safe to call with any JID type (group, newsletter, PN, etc.).
*/
export const resolveLidToPn = async (
jid: string | undefined | null,
lidMapping: LIDMappingStore,
logger?: ILogger
): Promise<string | undefined> => {
if (!jid) {
return undefined
}
if (isAnyLidUser(jid)) {
const pn = await lidMapping.getPNForLID(jid)
if (pn) {
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN')
}
return pn || jid
}
return jid
}
/**
* Normalizes a WAMessageKey by resolving LID→PN for remoteJid and participant.
*/
export const normalizeKeyLidToPn = async (
key: WAMessageKey,
lidMapping: LIDMappingStore,
logger?: ILogger
): Promise<void> => {
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(key.remoteJid, lidMapping, logger),
resolveLidToPn(key.participant, lidMapping, logger)
])
if (resolvedRemoteJid) {
key.remoteJid = resolvedRemoteJid
}
if (resolvedParticipant) {
key.participant = resolvedParticipant
}
}
export const normalizeMessageJids = async ( export const normalizeMessageJids = async (
message: WAMessage, message: WAMessage,
signalRepository: SignalRepositoryWithLIDStore, signalRepository: SignalRepositoryWithLIDStore,
logger?: ILogger logger?: ILogger
): Promise<void> => { ): Promise<void> => {
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => { const lidMapping = signalRepository.lidMapping
if (!jid) { const key = message.key
return undefined
}
if (isLidUser(jid) || isHostedLidUser(jid)) { // FAST PATH: Use alt JIDs directly when available (avoids LIDMappingStore race condition).
const pn = await signalRepository.lidMapping.getPNForLID(jid) // The stanza always carries both formats (LID + PN) in the attributes.
if (pn) { // When addressing_mode=lid, remoteJid is LID and remoteJidAlt is PN.
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message') // When addressing_mode=pn, remoteJid is already PN (no conversion needed).
} else { if (key.remoteJid && isAnyLidUser(key.remoteJid) && key.remoteJidAlt && isAnyPnUser(key.remoteJidAlt)) {
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID') logger?.debug({ lid: key.remoteJid, pn: key.remoteJidAlt }, 'Resolved remoteJid LID→PN via alt (fast path)')
} key.remoteJid = key.remoteJidAlt
return pn || jid
}
return jid
} }
// Execute both lookups in parallel instead of sequentially to reduce latency if (key.participant && isAnyLidUser(key.participant) && key.participantAlt && isAnyPnUser(key.participantAlt)) {
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([ logger?.debug({ lid: key.participant, pn: key.participantAlt }, 'Resolved participant LID→PN via alt (fast path)')
resolveLidToPn(message.key.remoteJid), key.participant = key.participantAlt
resolveLidToPn(message.key.participant)
])
if (resolvedRemoteJid) {
message.key.remoteJid = resolvedRemoteJid
} }
if (resolvedParticipant) { // SLOW PATH: Resolve any remaining LIDs via LIDMappingStore lookup
message.key.participant = resolvedParticipant await normalizeKeyLidToPn(key, lidMapping, logger)
// Also normalize participantAlt (the alternative JID format — can be LID when addressing_mode=pn)
if (key.participantAlt && isAnyLidUser(key.participantAlt)) {
const resolved = await resolveLidToPn(key.participantAlt, lidMapping, logger)
if (resolved) {
key.participantAlt = resolved
}
}
// Normalize nested message keys (reaction, poll) that may contain LID JIDs
const content = normalizeMessageContent(message.message)
if (content?.reactionMessage?.key) {
await normalizeKeyLidToPn(content.reactionMessage.key, lidMapping, logger)
}
if (content?.pollUpdateMessage?.pollCreationMessageKey) {
await normalizeKeyLidToPn(content.pollUpdateMessage.pollCreationMessageKey, lidMapping, logger)
} }
} }
@@ -534,6 +589,12 @@ const processMessage = async (
'CTWA: Successfully recovered message via placeholder resend' 'CTWA: Successfully recovered message via placeholder resend'
) )
// Normalize LID→PN in PDO-recovered message key before emitting
// eslint-disable-next-line max-depth
if (webMessageInfo.key && signalRepository) {
await normalizeKeyLidToPn(webMessageInfo.key as WAMessageKey, signalRepository.lidMapping, logger)
}
// wait till another upsert event is available, don't want it to be part of the PDO response message // wait till another upsert event is available, don't want it to be part of the PDO response message
// TODO: parse through proper message handling utilities (to add relevant key fields) // TODO: parse through proper message handling utilities (to add relevant key fields)
ev.emit('messages.upsert', { ev.emit('messages.upsert', {
@@ -674,9 +735,13 @@ const processMessage = async (
response: responseMsg response: responseMsg
} }
// Normalize creationMsgKey JIDs for the emitted event
const normalizedCreationKey = { ...creationMsgKey }
await normalizeKeyLidToPn(normalizedCreationKey, signalRepository.lidMapping, logger)
ev.emit('messages.update', [ ev.emit('messages.update', [
{ {
key: creationMsgKey, key: normalizedCreationKey,
update: { update: {
eventResponses: [eventResponse] eventResponses: [eventResponse]
} }
@@ -719,7 +784,7 @@ const processMessage = async (
}) })
} }
const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
switch (message.messageStubType) { switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER: case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER: