Compare commits

..

6 Commits

Author SHA1 Message Date
Renato Alcara cdab2e3c63 fix: address PR review — stale comments, createCallLink compat, relay guard
- Fix call link URL: remove media prefix (Frida shows https://call.whatsapp.com/<token>)
- Make createCallLink() backward compatible with chats.ts signature (event, timeoutMs)
- Guard CB:relay handler — require both callId AND callCreator before emitting
- Extract extractParticipants() helper to eliminate 3x code duplication

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:17:17 -03:00
Renato Alcara 3535a16b32 fix: address audit findings in call signaling implementation
- Fix handleCall to process ALL children of <call> node (not just first)
- Add CB:relay handler for top-level relay stanzas (TURN servers, tokens)
- Add 'video' and 'relay' to WACallUpdateType and getCallStatusFromNode
- Add <device-identity /> to voice call offers (verified via Frida capture)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:33:25 -03:00
Renato Alcara 919bc52c77 feat: complete call signaling — types, status handling, call link parsing
Complete the call signaling implementation with proper type definitions,
status handling, and call link URL construction:

- WACallEvent: add linkToken, linkCreator, participants, media, duration,
  terminateReason, connectedLimit fields
- WACallParticipant: new type for group/link call participants
- WACallUpdateType: add preaccept, transport, relaylatency, group_update,
  reminder, heartbeat, mute_v2, enc_rekey status types
- getCallStatusFromNode: handle all new call node tags
- handleCall: extract group_info, link_info, call_summary, participants
  from incoming call link/group stanzas
- createCallLink: parse server response to return token and full URL
  (https://call.whatsapp.com/video/<token>)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:20:32 -03:00
Renato Alcara 46622bd8c1 feat: implement complete call signaling from Frida captures
Add all remaining call signaling functions verified via Frida capture
on WhatsApp Android v2.26:

- acceptCall(): answer an incoming call with audio/video capabilities
- preacceptCall(): signal device capabilities before accepting
- sendRelayLatency(): report relay server latency measurements
- sendTransport(): send p2p/ICE transport candidates
- sendCallDuration(): log call duration to server after hangup
- muteCall(): mute/unmute during active call (MUTE_V2)
- sendHeartbeat(): keep group call alive
- sendEncRekey(): encryption re-key during call
- sendVideoState(): toggle video on/off during call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:14:39 -03:00
Renato Alcara 664964216c feat: add call link support (create, query, join)
Add createCallLink(), queryCallLink(), and joinCallLink() functions
for WhatsApp call link feature. Stanza structures verified via Frida
capture on WhatsApp Android v2.26.

- createCallLink(media) creates a shareable call link
- queryCallLink(token, media) queries call info before joining
- joinCallLink(token, media) joins a call via its link token
  with proper audio/video/net/capability child nodes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:10:12 -03:00
Renato Alcara 1bbcebc59c feat: add offerCall and terminateCall for call signaling
Add outgoing call support with offerCall() and terminateCall() functions.
Stanza structures verified via Frida captures on WhatsApp Android v2.26,
matching real voice and video call flows.

- offerCall(jid, isVideo?) sends call offer with correct child nodes
  (privacy, audio×2, video if applicable, net, capability, enc, encopt)
- terminateCall(callId, callTo, ...) sends call termination with optional
  reason and duration attributes
- Both functions exported from the socket chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:06:21 -03:00
4 changed files with 91 additions and 344 deletions
+13 -40
View File
@@ -50,8 +50,7 @@ import {
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState,
processSyncAction,
resolveLidToPn
processSyncAction
} from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message'
@@ -758,16 +757,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'resync-app-state')
const { onMutation } = newAppStateChunkHandler(isInitialSync)
const lidMapping = signalRepository.lidMapping
for (const key in globalMutationMap) {
const mutation = globalMutationMap[key]
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)
}
}
@@ -920,16 +912,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
})
}
const handlePresenceUpdate = async ({ tag, attrs, content }: BinaryNode) => {
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => {
let presence: PresenceData | undefined
const rawJid = attrs.from
const rawParticipant = attrs.participant || attrs.from
if (!rawJid) {
const jid = attrs.from
const participant = attrs.participant || attrs.from
if (!jid) {
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
return
}
if (shouldIgnoreJid(rawJid) && rawJid !== S_WHATSAPP_NET) {
if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) {
return
}
@@ -941,7 +933,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
} else if (Array.isArray(content)) {
const [firstChild] = content
if (!firstChild) {
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
return
}
@@ -960,19 +952,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
if (presence) {
if (!rawParticipant) {
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: participant is missing, skipping')
if (!participant) {
logger.warn({ jid }, 'handlePresenceUpdate: participant is missing, skipping')
return
}
// 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 } })
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
}
}
@@ -1046,16 +1031,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
undefined,
logger
)
const lidMapping = signalRepository.lidMapping
for (const key in mutationMap) {
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)
onMutation(mutationMap[key]!)
}
}
}
@@ -1399,12 +1376,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
})
ws.on('CB:presence', (node: BinaryNode) => {
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:presence', handlePresenceUpdate)
ws.on('CB:chatstate', handlePresenceUpdate)
ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')!
+7 -43
View File
@@ -1,7 +1,7 @@
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 { generateMessageIDV2, unixTimestampSeconds } from '../Utils'
import {
type BinaryNode,
getBinaryNodeChild,
@@ -17,43 +17,6 @@ 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({
@@ -68,7 +31,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const groupMetadata = async (jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
return normalizeGroupMetadata(extractGroupMetadata(result))
return extractGroupMetadata(result)
}
const groupFetchAllParticipating = async () => {
@@ -95,15 +58,16 @@ export const makeGroupsSocket = (config: SocketConfig) => {
if (groupsChild) {
const groups = getBinaryNodeChildren(groupsChild, 'group')
for (const groupNode of groups) {
const meta = await normalizeGroupMetadata(extractGroupMetadata({
const meta = extractGroupMetadata({
tag: 'result',
attrs: {},
content: [groupNode]
}))
})
data[meta.id] = meta
}
}
// TODO: properly parse LID / PN DATA
sock.ev.emit('groups.update', Object.values(data))
return data
@@ -137,7 +101,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
}))
}
])
return normalizeGroupMetadata(extractGroupMetadata(result))
return extractGroupMetadata(result)
},
groupLeave: async (id: string) => {
await groupQuery('@g.us', 'set', [
@@ -313,7 +277,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
),
groupGetInviteInfo: async (code: string) => {
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
return normalizeGroupMetadata(extractGroupMetadata(results))
return extractGroupMetadata(results)
},
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration
+43 -168
View File
@@ -49,9 +49,7 @@ import {
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT,
normalizeKeyLidToPn,
normalizeMessageJids,
resolveLidToPn,
SERVER_ERROR_CODES,
toNumber,
unixTimestampSeconds,
@@ -367,14 +365,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'NotificationNewsletterAdminPromote':
for (const update of updates) {
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', {
id: update.jid,
author: resolvedAuthor || node.attrs.from!,
user: resolvedUser || update.user,
author: node.attrs.from!,
user: update.user,
new_role: 'ADMIN',
action: 'promote'
})
@@ -393,9 +387,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleNewsletterNotification = async (node: BinaryNode) => {
const from = node.attrs.from!
const child = getAllBinaryNodeChildren(node)[0]!
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
const author = node.attrs.participant!
logger.info({ from, child }, 'got newsletter notification')
@@ -422,11 +414,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
break
case 'participant':
const resolvedParticipantUser = await resolveLidToPn(child.attrs.jid!, signalRepository.lidMapping, logger) || child.attrs.jid!
const participantUpdate = {
id: from,
author,
user: resolvedParticipantUser,
user: child.attrs.jid!,
action: child.attrs.action!,
new_role: child.attrs.role!
}
@@ -1415,8 +1406,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
}
const handleGroupNotification = async (fullNode: BinaryNode, child: BinaryNode, msg: Partial<WAMessage>) => {
const lidMapping = signalRepository.lidMapping
const handleGroupNotification = (fullNode: BinaryNode, child: BinaryNode, msg: Partial<WAMessage>) => {
// TODO: Support PN/LID (Here is only LID now)
const actingParticipantLid = fullNode.attrs.participant
const actingParticipantPn = fullNode.attrs.participant_pn
@@ -1424,52 +1415,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid!
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) {
case 'create':
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.messageStubParameters = [metadata.subject]
msg.key = { participant: actingParticipant }
msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn }
ev.emit('chats.upsert', [
{
@@ -1481,7 +1433,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
ev.emit('groups.upsert', [
{
...metadata,
author: actingParticipant
author: actingParticipantLid,
authorPn: actingParticipantPn
}
])
break
@@ -1495,13 +1448,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
break
case 'modify':
const oldNumbers = await Promise.all(
getBinaryNodeChildren(child, 'participant').map(async p => {
const resolved = await resolveLidToPn(p.attrs.jid!, lidMapping, logger)
return resolved || p.attrs.jid!
})
)
msg.messageStubParameters = oldNumbers || []
const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid!)
msg.messageStubParameters = oldNumber || []
msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER
break
case 'promote':
@@ -1512,48 +1460,21 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}`
msg.messageStubType = WAMessageStubType[stubType as keyof typeof WAMessageStubType]
const participants = await Promise.all(
getBinaryNodeChildren(child, 'participant').map(async ({ attrs }) => {
const rawJid = attrs.jid!
let id = rawJid
let phoneNumber: string | undefined
let lid: string | undefined
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']
}
})
)
const participants = getBinaryNodeChildren(child, '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']
}
})
if (
participants.length === 1 &&
// if recv. "remove" message and sender removed themselves
// mark as left
(areJidsSameUser(participants[0]!.id, actingParticipant) ||
areJidsSameUser(participants[0]!.id, actingParticipantLid) ||
(areJidsSameUser(participants[0]!.id, actingParticipantLid) ||
areJidsSameUser(participants[0]!.id, actingParticipantPn)) &&
child.tag === 'remove'
) {
@@ -1604,27 +1525,21 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'created_membership_requests':
msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
msg.messageStubParameters = [
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipant }),
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }),
'created',
child.attrs.request_method!
]
break
case 'revoked_membership_requests':
const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid)
// TODO: LIDMAPPING SUPPORT
msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
msg.messageStubParameters = [
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipant }),
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }),
isDenied ? 'revoked' : 'rejected'
]
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) => {
@@ -1641,12 +1556,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await handleMexNewsletterNotification(node)
break
case 'w:gp2':
await handleGroupNotification(node, child!, result)
// TODO: HANDLE PARTICIPANT_PN
handleGroupNotification(node, child!, result)
break
case 'mediaretry':
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])
break
case 'encrypt':
@@ -1677,16 +1591,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const setPicture = getBinaryNodeChild(node, 'set')
const delPicture = getBinaryNodeChild(node, 'delete')
{
const rawPictureJid = jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || ''
const pictureJid = await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger) || rawPictureJid
ev.emit('contacts.update', [
{
id: pictureJid,
imgUrl: setPicture ? 'changed' : 'removed'
}
])
}
// TODO: WAJIDHASH stuff proper support inhouse
ev.emit('contacts.update', [
{
id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '',
imgUrl: setPicture ? 'changed' : 'removed'
}
])
if (isJidGroup(from)) {
const node = setPicture || delPicture
@@ -1724,8 +1635,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const blocklists = getBinaryNodeChildren(child, 'item')
for (const { attrs } of blocklists) {
const resolvedBlockJid = await resolveLidToPn(attrs.jid!, signalRepository.lidMapping, logger) || attrs.jid!
const blocklist = [resolvedBlockJid]
const blocklist = [attrs.jid!]
const type = attrs.action === 'block' ? 'add' : 'remove'
ev.emit('blocklist.update', { blocklist, type })
}
@@ -2017,15 +1927,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
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) {
logger.trace({ remoteJid }, 'ignoring receipt from jid')
await sendMessageAck(node)
@@ -2052,13 +1953,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (attrs.participant) {
const updateKey: keyof MessageUserReceipt =
status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
const resolvedReceiptUserJid = await resolveLidToPn(attrs.participant, lidMapping, logger) || jidNormalizedUser(attrs.participant)
ev.emit(
'message-receipt.update',
ids.map(id => ({
key: { ...key, id },
receipt: {
userJid: jidNormalizedUser(resolvedReceiptUserJid),
userJid: jidNormalizedUser(attrs.participant),
[updateKey]: +attrs.t!
}
}))
@@ -2187,11 +2087,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Check if mapping already exists to avoid unnecessary storage operations
const existingMapping = await signalRepository.lidMapping.getPNForLID(alt)
if (!existingMapping) {
// MUST await: normalizeMessageJids() runs after this and needs the mapping
// in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
// Store mapping in background (non-critical, doesn't block decrypt)
signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed'))
}
// CRITICAL: ALWAYS migrate session, even if mapping exists
@@ -2204,11 +2103,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Check if reverse mapping exists
const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt)
if (!existingMapping) {
// MUST await: normalizeMessageJids() runs after this and needs the mapping
// in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
// Store mapping in background (non-critical)
signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed'))
}
// CRITICAL: ALWAYS migrate session, even if mapping exists
@@ -2709,26 +2607,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
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])
}
@@ -2737,7 +2615,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleBadAck = async ({ attrs }: BinaryNode) => {
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
// // current hypothesis is that if pash is sent in the ack
@@ -2925,12 +2802,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Top-level <relay> stanzas carry TURN server info, tokens and crypto keys
ws.on('CB:relay', async (node: BinaryNode) => {
const callId = node.attrs['call-id']
const rawCallCreator = node.attrs['call-creator']
const callCreator = node.attrs['call-creator']
// Both callId and callCreator must be present to emit a valid event
// (call link relays may arrive without these attrs — just log them)
if (callId && rawCallCreator) {
// Resolve LID→PN for call creator
const callCreator = await resolveLidToPn(rawCallCreator, signalRepository.lidMapping, logger) || rawCallCreator
if (callId && callCreator) {
logger.debug(
{ callId, callCreator, uuid: node.attrs.uuid },
'received relay info'
+28 -93
View File
@@ -19,13 +19,10 @@ import type {
WAMessage,
WAMessageKey
} from '../Types'
import type { LIDMappingStore } from '../Signal/lid-mapping'
import { WAMessageStubType } from '../Types'
import { getContentType, normalizeMessageContent } from '../Utils/messages'
import {
areJidsSameUser,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidBroadcast,
@@ -128,94 +125,42 @@ 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 (
message: WAMessage,
signalRepository: SignalRepositoryWithLIDStore,
logger?: ILogger
): Promise<void> => {
const lidMapping = signalRepository.lidMapping
const key = message.key
// FAST PATH: Use alt JIDs directly when available (avoids LIDMappingStore race condition).
// The stanza always carries both formats (LID + PN) in the attributes.
// When addressing_mode=lid, remoteJid is LID and remoteJidAlt is PN.
// When addressing_mode=pn, remoteJid is already PN (no conversion needed).
if (key.remoteJid && isAnyLidUser(key.remoteJid) && key.remoteJidAlt && isAnyPnUser(key.remoteJidAlt)) {
logger?.debug({ lid: key.remoteJid, pn: key.remoteJidAlt }, 'Resolved remoteJid LID→PN via alt (fast path)')
key.remoteJid = key.remoteJidAlt
}
if (key.participant && isAnyLidUser(key.participant) && key.participantAlt && isAnyPnUser(key.participantAlt)) {
logger?.debug({ lid: key.participant, pn: key.participantAlt }, 'Resolved participant LID→PN via alt (fast path)')
key.participant = key.participantAlt
}
// SLOW PATH: Resolve any remaining LIDs via LIDMappingStore lookup
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
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => {
if (!jid) {
return undefined
}
if (isLidUser(jid) || isHostedLidUser(jid)) {
const pn = await signalRepository.lidMapping.getPNForLID(jid)
if (pn) {
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message')
} else {
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID')
}
return pn || jid
}
return jid
}
// 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)
// Execute both lookups in parallel instead of sequentially to reduce latency
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(message.key.remoteJid),
resolveLidToPn(message.key.participant)
])
if (resolvedRemoteJid) {
message.key.remoteJid = resolvedRemoteJid
}
if (content?.pollUpdateMessage?.pollCreationMessageKey) {
await normalizeKeyLidToPn(content.pollUpdateMessage.pollCreationMessageKey, lidMapping, logger)
if (resolvedParticipant) {
message.key.participant = resolvedParticipant
}
}
@@ -589,12 +534,6 @@ const processMessage = async (
'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
// TODO: parse through proper message handling utilities (to add relevant key fields)
ev.emit('messages.upsert', {
@@ -735,13 +674,9 @@ const processMessage = async (
response: responseMsg
}
// Normalize creationMsgKey JIDs for the emitted event
const normalizedCreationKey = { ...creationMsgKey }
await normalizeKeyLidToPn(normalizedCreationKey, signalRepository.lidMapping, logger)
ev.emit('messages.update', [
{
key: normalizedCreationKey,
key: creationMsgKey,
update: {
eventResponses: [eventResponse]
}
@@ -784,7 +719,7 @@ const processMessage = async (
})
}
const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID
switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER: