From d98f8fca2db79f7288b655c65a6fe2d51d1f470d Mon Sep 17 00:00:00 2001 From: Manish <76832269+manishkumar1601@users.noreply.github.com> Date: Fri, 29 May 2026 15:15:23 +0530 Subject: [PATCH 1/2] fix(messages-recv): resolve LID for profile-picture contacts.update) --- src/Socket/messages-recv.ts | 32 +++-- src/Utils/contact-picture-identity.ts | 49 ++++++++ src/Utils/index.ts | 1 + .../Socket/contact-picture-identity.test.ts | 112 ++++++++++++++++++ 4 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/Utils/contact-picture-identity.ts create mode 100644 src/__tests__/Socket/contact-picture-identity.test.ts diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 02d0a584091..c57395788c6 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -47,6 +47,7 @@ import { MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, + resolveContactPictureIdentity, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, @@ -139,6 +140,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } = sock const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping) /** this mutex ensures that each retryRequest will wait for the previous one to finish */ const retryMutex = makeMutex() @@ -740,7 +742,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken') - const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping) const issueJid = await resolveIssuanceJid( normalizedJid, sock.serverProps.lidTrustedTokenIssueToLid, @@ -1075,28 +1076,34 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { case 'picture': const setPicture = getBinaryNodeChild(node, 'set') const delPicture = getBinaryNodeChild(node, 'delete') - - // TODO: WAJIDHASH stuff proper support inhouse - ev.emit('contacts.update', [ - { - id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '', - imgUrl: setPicture ? 'changed' : 'removed' - } - ]) + const pictureNode = setPicture || delPicture + const pictureImgUrl = setPicture ? 'changed' : 'removed' if (isJidGroup(from)) { - const node = setPicture || delPicture + // group icon change: `from` is the group jid, never resolve LID<->PN + ev.emit('contacts.update', [{ id: from, imgUrl: pictureImgUrl }]) + result.messageStubType = WAMessageStubType.GROUP_CHANGE_ICON if (setPicture) { result.messageStubParameters = [setPicture.attrs.id!] } - result.participant = node?.attrs.author + result.participant = pictureNode?.attrs.author result.key = { ...(result.key || {}), - participant: setPicture?.attrs.author + participant: pictureNode?.attrs.author } + } else if (from) { + // individual contact picture change: enrich with LID<->PN so consumers can + // correlate the change with a cached contact regardless of addressing form + const identity = await resolveContactPictureIdentity(from, { + getPNForLID, + getLIDForPN, + meId: authState.creds.me?.id, + meLid: authState.creds.me?.lid + }) + ev.emit('contacts.update', [{ ...identity, imgUrl: pictureImgUrl }]) } break @@ -1883,7 +1890,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { inFlight463Recoveries.add(ackFrom) void (async () => { try { - const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping) const tcStorageJid = await resolveTcTokenJid(ackFrom, getLIDForPN) const issueJid = await resolveIssuanceJid( ackFrom, diff --git a/src/Utils/contact-picture-identity.ts b/src/Utils/contact-picture-identity.ts new file mode 100644 index 00000000000..bd05c62b7cf --- /dev/null +++ b/src/Utils/contact-picture-identity.ts @@ -0,0 +1,49 @@ +import { areJidsSameUser, isLidUser, isPnUser, jidNormalizedUser } from '../WABinary' + +export type ContactPictureIdentityContext = { + getPNForLID: (lid: string) => Promise + getLIDForPN: (pn: string) => Promise + meId: string | undefined + meLid: string | undefined +} + +/** + * Resolve the best-effort contact identity for a profile-picture notification. + * `from` must be the already-normalized individual JID (never a group jid). + * + * Returns the fields to merge into a `contacts.update` entry. When `from` is a LID we + * attempt to resolve the PN (and vice-versa) so consumers can correlate the change with a + * cached contact regardless of which addressing form they store. For non-saved contacts WA + * omits the canonical identity, so resolution may fail — in that case we still return the + * raw LID so the event is never empty. + */ +export async function resolveContactPictureIdentity( + from: string, + ctx: ContactPictureIdentityContext +): Promise<{ id: string; lid?: string; phoneNumber?: string }> { + const result: { id: string; lid?: string; phoneNumber?: string } = { id: from } + + if (isLidUser(from)) { + result.lid = from + const resolvedPn = await ctx.getPNForLID(from).catch(() => null) + const normalizedPn = jidNormalizedUser(resolvedPn || undefined) + // guard: discard a resolution that points at our own PN unless `from` is our own LID + const isBogusSelf = + !!normalizedPn && + !!ctx.meId && + areJidsSameUser(normalizedPn, ctx.meId) && + !(ctx.meLid && areJidsSameUser(from, ctx.meLid)) + if (normalizedPn && !isBogusSelf) { + result.id = normalizedPn + result.phoneNumber = normalizedPn + } + } else if (isPnUser(from)) { + result.phoneNumber = from + const resolvedLid = await ctx.getLIDForPN(from).catch(() => null) + if (resolvedLid && isLidUser(resolvedLid)) { + result.lid = jidNormalizedUser(resolvedLid) + } + } + + return result +} diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 06fcdd5c35c..923334fc500 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -17,5 +17,6 @@ export * from './process-message' export * from './message-retry-manager' export * from './browser-utils' export * from './companion-reg-client-utils' +export * from './contact-picture-identity' export * from './identity-change-handler' export * from './stanza-ack' diff --git a/src/__tests__/Socket/contact-picture-identity.test.ts b/src/__tests__/Socket/contact-picture-identity.test.ts new file mode 100644 index 00000000000..d4e4c218d65 --- /dev/null +++ b/src/__tests__/Socket/contact-picture-identity.test.ts @@ -0,0 +1,112 @@ +import { jest } from '@jest/globals' +import { + type ContactPictureIdentityContext, + resolveContactPictureIdentity +} from '../../Utils/contact-picture-identity' + +type ResolverFn = (jid: string) => Promise + +describe('resolveContactPictureIdentity', () => { + let mockGetPNForLID: jest.Mock + let mockGetLIDForPN: jest.Mock + + const ME_ID = 'myuser@s.whatsapp.net' + const ME_LID = 'mylid@lid' + + function createContext(): ContactPictureIdentityContext { + return { + getPNForLID: mockGetPNForLID, + getLIDForPN: mockGetLIDForPN, + meId: ME_ID, + meLid: ME_LID + } + } + + beforeEach(() => { + jest.clearAllMocks() + mockGetPNForLID = jest.fn().mockResolvedValue(null) + mockGetLIDForPN = jest.fn().mockResolvedValue(null) + }) + + it('resolves a LID to a PN and fills id, phoneNumber and lid', async () => { + mockGetPNForLID.mockResolvedValue('12345:0@s.whatsapp.net') + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ + id: '12345@s.whatsapp.net', + phoneNumber: '12345@s.whatsapp.net', + lid: '98765@lid' + }) + expect(mockGetPNForLID).toHaveBeenCalledWith('98765@lid') + }) + + it('falls back to the raw LID when it cannot be resolved', async () => { + mockGetPNForLID.mockResolvedValue(null) + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ id: '98765@lid', lid: '98765@lid' }) + expect(result.phoneNumber).toBeUndefined() + }) + + it('discards a resolution that points at our own PN (bogus self) and keeps the raw LID', async () => { + // a stranger's LID wrongly resolving to our own number must not be emitted as the contact + mockGetPNForLID.mockResolvedValue(ME_ID) + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ id: '98765@lid', lid: '98765@lid' }) + expect(result.phoneNumber).toBeUndefined() + }) + + it('keeps the resolved own PN when the source LID is our own LID', async () => { + mockGetPNForLID.mockResolvedValue(ME_ID) + + const result = await resolveContactPictureIdentity(ME_LID, createContext()) + + expect(result).toEqual({ + id: ME_ID, + phoneNumber: ME_ID, + lid: ME_LID + }) + }) + + it('fills lid from getLIDForPN when the input is a PN', async () => { + mockGetLIDForPN.mockResolvedValue('98765@lid') + + const result = await resolveContactPictureIdentity('12345@s.whatsapp.net', createContext()) + + expect(result).toEqual({ + id: '12345@s.whatsapp.net', + phoneNumber: '12345@s.whatsapp.net', + lid: '98765@lid' + }) + expect(mockGetLIDForPN).toHaveBeenCalledWith('12345@s.whatsapp.net') + }) + + it('only fills phoneNumber when the PN resolves to a non-LID value', async () => { + mockGetLIDForPN.mockResolvedValue('not-a-lid@s.whatsapp.net') + + const result = await resolveContactPictureIdentity('12345@s.whatsapp.net', createContext()) + + expect(result).toEqual({ id: '12345@s.whatsapp.net', phoneNumber: '12345@s.whatsapp.net' }) + expect(result.lid).toBeUndefined() + }) + + it('swallows resolver errors and falls back to the raw LID', async () => { + mockGetPNForLID.mockRejectedValue(new Error('lookup failed')) + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ id: '98765@lid', lid: '98765@lid' }) + }) + + it('does not attempt resolution for hosted LIDs (leaves id untouched)', async () => { + const result = await resolveContactPictureIdentity('98765@hosted.lid', createContext()) + + expect(result).toEqual({ id: '98765@hosted.lid' }) + expect(mockGetPNForLID).not.toHaveBeenCalled() + expect(mockGetLIDForPN).not.toHaveBeenCalled() + }) +}) From f991b3173bc2eee54e46ff0b637ca687abe88cae Mon Sep 17 00:00:00 2001 From: Manish <76832269+manishkumar1601@users.noreply.github.com> Date: Fri, 29 May 2026 15:45:24 +0530 Subject: [PATCH 2/2] test(contact-picture-identity): move test under Utils and document helper Mirror the source module path (src/Utils/contact-picture-identity.ts) by relocating the spec to src/__tests__/Utils, and add a docstring to the test context helper to satisfy docstring-coverage. Addresses CodeRabbit review notes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/{Socket => Utils}/contact-picture-identity.test.ts | 1 + 1 file changed, 1 insertion(+) rename src/__tests__/{Socket => Utils}/contact-picture-identity.test.ts (97%) diff --git a/src/__tests__/Socket/contact-picture-identity.test.ts b/src/__tests__/Utils/contact-picture-identity.test.ts similarity index 97% rename from src/__tests__/Socket/contact-picture-identity.test.ts rename to src/__tests__/Utils/contact-picture-identity.test.ts index d4e4c218d65..3a4d41d4e24 100644 --- a/src/__tests__/Socket/contact-picture-identity.test.ts +++ b/src/__tests__/Utils/contact-picture-identity.test.ts @@ -13,6 +13,7 @@ describe('resolveContactPictureIdentity', () => { const ME_ID = 'myuser@s.whatsapp.net' const ME_LID = 'mylid@lid' + /** Build a resolver context wired to the per-test mock resolvers and our own identity. */ function createContext(): ContactPictureIdentityContext { return { getPNForLID: mockGetPNForLID,