Files
InfiniteAPI/src/Socket/groups.ts
T
Renato Alcara e1e3d88a1c 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
2026-03-01 17:44:02 -03:00

398 lines
13 KiB
TypeScript

import { proto } from '../../WAProto/index.js'
import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types'
import { WAMessageAddressingMode, WAMessageStubType } from '../Types'
import { generateMessageIDV2, resolveLidToPn, unixTimestampSeconds } from '../Utils'
import {
type BinaryNode,
getBinaryNodeChild,
getBinaryNodeChildren,
getBinaryNodeChildString,
isLidUser,
isPnUser,
jidEncode,
jidNormalizedUser
} from '../WABinary'
import { makeChatsSocket } from './chats'
export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config)
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[]) =>
query({
tag: 'iq',
attrs: {
type,
xmlns: 'w:g2',
to: jid
},
content
})
const groupMetadata = async (jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
return normalizeGroupMetadata(extractGroupMetadata(result))
}
const groupFetchAllParticipating = async () => {
const result = await query({
tag: 'iq',
attrs: {
to: '@g.us',
xmlns: 'w:g2',
type: 'get'
},
content: [
{
tag: 'participating',
attrs: {},
content: [
{ tag: 'participants', attrs: {} },
{ tag: 'description', attrs: {} }
]
}
]
})
const data: { [_: string]: GroupMetadata } = {}
const groupsChild = getBinaryNodeChild(result, 'groups')
if (groupsChild) {
const groups = getBinaryNodeChildren(groupsChild, 'group')
for (const groupNode of groups) {
const meta = await normalizeGroupMetadata(extractGroupMetadata({
tag: 'result',
attrs: {},
content: [groupNode]
}))
data[meta.id] = meta
}
}
sock.ev.emit('groups.update', Object.values(data))
return data
}
sock.ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')!
if (attrs.type !== 'groups') {
return
}
await groupFetchAllParticipating()
await sock.cleanDirtyBits('groups')
})
return {
...sock,
groupMetadata,
groupCreate: async (subject: string, participants: string[]) => {
const key = generateMessageIDV2()
const result = await groupQuery('@g.us', 'set', [
{
tag: 'create',
attrs: {
subject,
key
},
content: participants.map(jid => ({
tag: 'participant',
attrs: { jid }
}))
}
])
return normalizeGroupMetadata(extractGroupMetadata(result))
},
groupLeave: async (id: string) => {
await groupQuery('@g.us', 'set', [
{
tag: 'leave',
attrs: {},
content: [{ tag: 'group', attrs: { id } }]
}
])
},
groupUpdateSubject: async (jid: string, subject: string) => {
await groupQuery(jid, 'set', [
{
tag: 'subject',
attrs: {},
content: Buffer.from(subject, 'utf-8')
}
])
},
groupRequestParticipantsList: async (jid: string) => {
const result = await groupQuery(jid, 'get', [
{
tag: 'membership_approval_requests',
attrs: {}
}
])
const node = getBinaryNodeChild(result, 'membership_approval_requests')
const participants = getBinaryNodeChildren(node, 'membership_approval_request')
return participants.map(v => v.attrs)
},
groupRequestParticipantsUpdate: async (jid: string, participants: string[], action: 'approve' | 'reject') => {
const result = await groupQuery(jid, 'set', [
{
tag: 'membership_requests_action',
attrs: {},
content: [
{
tag: action,
attrs: {},
content: participants.map(jid => ({
tag: 'participant',
attrs: { jid }
}))
}
]
}
])
const node = getBinaryNodeChild(result, 'membership_requests_action')
const nodeAction = getBinaryNodeChild(node, action)
const participantsAffected = getBinaryNodeChildren(nodeAction, 'participant')
return participantsAffected.map(p => {
return { status: p.attrs.error || '200', jid: p.attrs.jid }
})
},
groupParticipantsUpdate: async (jid: string, participants: string[], action: ParticipantAction) => {
const result = await groupQuery(jid, 'set', [
{
tag: action,
attrs: {},
content: participants.map(jid => ({
tag: 'participant',
attrs: { jid }
}))
}
])
const node = getBinaryNodeChild(result, action)
const participantsAffected = getBinaryNodeChildren(node, 'participant')
return participantsAffected.map(p => {
return { status: p.attrs.error || '200', jid: p.attrs.jid, content: p }
})
},
groupUpdateDescription: async (jid: string, description?: string) => {
const metadata = await groupMetadata(jid)
const prev = metadata.descId ?? null
await groupQuery(jid, 'set', [
{
tag: 'description',
attrs: {
...(description ? { id: generateMessageIDV2() } : { delete: 'true' }),
...(prev ? { prev } : {})
},
content: description ? [{ tag: 'body', attrs: {}, content: Buffer.from(description, 'utf-8') }] : undefined
}
])
},
groupInviteCode: async (jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'invite', attrs: {} }])
const inviteNode = getBinaryNodeChild(result, 'invite')
return inviteNode?.attrs.code
},
groupRevokeInvite: async (jid: string) => {
const result = await groupQuery(jid, 'set', [{ tag: 'invite', attrs: {} }])
const inviteNode = getBinaryNodeChild(result, 'invite')
return inviteNode?.attrs.code
},
groupAcceptInvite: async (code: string) => {
const results = await groupQuery('@g.us', 'set', [{ tag: 'invite', attrs: { code } }])
const result = getBinaryNodeChild(results, 'group')
return result?.attrs.jid
},
/**
* revoke a v4 invite for someone
* @param groupJid group jid
* @param invitedJid jid of person you invited
* @returns true if successful
*/
groupRevokeInviteV4: async (groupJid: string, invitedJid: string) => {
const result = await groupQuery(groupJid, 'set', [
{ tag: 'revoke', attrs: {}, content: [{ tag: 'participant', attrs: { jid: invitedJid } }] }
])
return !!result
},
/**
* accept a GroupInviteMessage
* @param key the key of the invite message, or optionally only provide the jid of the person who sent the invite
* @param inviteMessage the message to accept
*/
groupAcceptInviteV4: ev.createBufferedFunction(
async (key: string | WAMessageKey, inviteMessage: proto.Message.IGroupInviteMessage) => {
key = typeof key === 'string' ? { remoteJid: key } : key
const results = await groupQuery(inviteMessage.groupJid!, 'set', [
{
tag: 'accept',
attrs: {
code: inviteMessage.inviteCode!,
expiration: inviteMessage.inviteExpiration!.toString(),
admin: key.remoteJid!
}
}
])
// if we have the full message key
// update the invite message to be expired
if (key.id) {
// create new invite message that is expired
inviteMessage = proto.Message.GroupInviteMessage.fromObject(inviteMessage)
inviteMessage.inviteExpiration = 0
inviteMessage.inviteCode = ''
ev.emit('messages.update', [
{
key,
update: {
message: {
groupInviteMessage: inviteMessage
}
}
}
])
}
// generate the group add message
await upsertMessage(
{
key: {
remoteJid: inviteMessage.groupJid,
id: generateMessageIDV2(sock.user?.id),
fromMe: false,
participant: key.remoteJid
},
messageStubType: WAMessageStubType.GROUP_PARTICIPANT_ADD,
messageStubParameters: [JSON.stringify(authState.creds.me)],
participant: key.remoteJid,
messageTimestamp: unixTimestampSeconds()
},
'notify'
)
return results.attrs.from
}
),
groupGetInviteInfo: async (code: string) => {
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
return normalizeGroupMetadata(extractGroupMetadata(results))
},
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration
? { tag: 'ephemeral', attrs: { expiration: ephemeralExpiration.toString() } }
: { tag: 'not_ephemeral', attrs: {} }
await groupQuery(jid, 'set', [content])
},
groupSettingUpdate: async (jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked') => {
await groupQuery(jid, 'set', [{ tag: setting, attrs: {} }])
},
groupMemberAddMode: async (jid: string, mode: 'admin_add' | 'all_member_add') => {
await groupQuery(jid, 'set', [{ tag: 'member_add_mode', attrs: {}, content: mode }])
},
groupJoinApprovalMode: async (jid: string, mode: 'on' | 'off') => {
await groupQuery(jid, 'set', [
{ tag: 'membership_approval_mode', attrs: {}, content: [{ tag: 'group_join', attrs: { state: mode } }] }
])
},
groupFetchAllParticipating
}
}
export const extractGroupMetadata = (result: BinaryNode) => {
const group = getBinaryNodeChild(result, 'group')!
const descChild = getBinaryNodeChild(group, 'description')
let desc: string | undefined
let descId: string | undefined
let descOwner: string | undefined
let descOwnerPn: string | undefined
let descTime: number | undefined
if (descChild) {
desc = getBinaryNodeChildString(descChild, 'body')
descOwner = descChild.attrs.participant ? jidNormalizedUser(descChild.attrs.participant) : undefined
descOwnerPn = descChild.attrs.participant_pn ? jidNormalizedUser(descChild.attrs.participant_pn) : undefined
descTime = +descChild.attrs.t!
descId = descChild.attrs.id
}
const groupId = group.attrs.id!.includes('@') ? group.attrs.id! : jidEncode(group.attrs.id!, 'g.us')
const eph = getBinaryNodeChild(group, 'ephemeral')?.attrs.expiration
const memberAddMode = getBinaryNodeChildString(group, 'member_add_mode') === 'all_member_add'
const metadata: GroupMetadata = {
id: groupId,
notify: group.attrs.notify,
addressingMode: group.attrs.addressing_mode === 'lid' ? WAMessageAddressingMode.LID : WAMessageAddressingMode.PN,
subject: group.attrs.subject!,
subjectOwner: group.attrs.s_o,
subjectOwnerPn: group.attrs.s_o_pn,
subjectTime: +(group.attrs.s_t ?? '0'),
size: group.attrs.size ? +group.attrs.size : getBinaryNodeChildren(group, 'participant').length,
creation: +(group.attrs.creation ?? '0'),
owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined,
ownerPn: group.attrs.creator_pn ? jidNormalizedUser(group.attrs.creator_pn) : undefined,
owner_country_code: group.attrs.creator_country_code,
desc,
descId,
descOwner,
descOwnerPn,
descTime,
linkedParent: getBinaryNodeChild(group, 'linked_parent')?.attrs.jid || undefined,
restrict: !!getBinaryNodeChild(group, 'locked'),
announce: !!getBinaryNodeChild(group, 'announcement'),
isCommunity: !!getBinaryNodeChild(group, 'parent'),
isCommunityAnnounce: !!getBinaryNodeChild(group, 'default_sub_group'),
joinApprovalMode: !!getBinaryNodeChild(group, 'membership_approval_mode'),
memberAddMode,
participants: getBinaryNodeChildren(group, 'participant').map(({ attrs }) => {
// TODO: Store LID MAPPINGS
return {
id: attrs.jid!,
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
admin: (attrs.type || null) as GroupParticipant['admin']
}
}),
ephemeralDuration: eph ? +eph : undefined
}
return metadata
}
export type GroupsSocket = ReturnType<typeof makeGroupsSocket>