@@ -55,7 +55,7 @@ import {
5555} from '../Utils'
5656import { makeKeyedMutex , makeMutex } from '../Utils/make-mutex'
5757import processMessage from '../Utils/process-message'
58- import { buildTcTokenFromJid } from '../Utils/tc-token-utils'
58+ import { buildTcTokenFromJid , buildTcTokenNode } from '../Utils/tc-token-utils'
5959import {
6060 type BinaryNode ,
6161 getBinaryNodeChild ,
@@ -96,6 +96,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
9696 } = sock
9797
9898 const getLIDForPN = signalRepository . lidMapping . getLIDForPN . bind ( signalRepository . lidMapping )
99+ /**
100+ * Local-only LID resolver (port of upstream PR #2614). Use on
101+ * profile-picture / similar paths where we OPPORTUNISTICALLY attach
102+ * metadata: a USync miss should NOT bubble out as a network round-trip
103+ * because that traffic profile diverges from WA Web / whatsmeow and
104+ * smells like a custom client.
105+ */
106+ const getKnownLIDForPN = signalRepository . lidMapping . getKnownLIDForPN . bind ( signalRepository . lidMapping )
99107
100108 let privacySettings : { [ _ : string ] : string } | undefined
101109
@@ -132,7 +140,15 @@ export const makeChatsSocket = (config: SocketConfig) => {
132140 config . placeholderResendCache ||
133141 ( new NodeCache < number > ( {
134142 stdTTL : DEFAULT_CACHE_TTLS . MSG_RETRY , // 1 hour
135- useClones : false
143+ useClones : false ,
144+ // Audit memory — this is the cache instantiation that REALLY
145+ // runs in the standard socket chain (chats is the base; it
146+ // mutates `config.placeholderResendCache` so the duplicated
147+ // instantiation in messages-recv.ts with the same cap never
148+ // fires). Without `maxKeys`, the NodeCache TTL of 1h plus
149+ // re-extend-on-set means it grows unbounded under a stream of
150+ // unique-id placeholder retries.
151+ maxKeys : DEFAULT_CACHE_MAX_KEYS . PLACEHOLDER_RESEND
136152 } ) as CacheStore )
137153
138154 if ( ! config . placeholderResendCache ) {
@@ -776,43 +792,88 @@ export const makeChatsSocket = (config: SocketConfig) => {
776792 )
777793
778794 /**
779- * fetch the profile picture of a user/group
780- * type = "preview" for a low res picture
781- * type = "image for the high res picture"
795+ * Fetch the profile picture URL of a user/group.
796+ *
797+ * `type`: "preview" for a low-res picture, "image" for the high-res picture.
798+ * `existingId`: the `id` of the last known picture for this JID. If supplied AND the
799+ * picture has not changed, the server responds with a `304`-style empty
800+ * result (sentinel for "use cached URL") instead of returning a fresh URL.
801+ * Saves a CDN re-fetch per refresh — matches WA Web `pictureId` and
802+ * whatsmeow `ExistingID`.
803+ * `invite`: group-invite code. Allows fetching a group's picture without joining,
804+ * used by the "preview before accepting invite" flow. Mutually exclusive
805+ * with `tctoken` (server doesn't require it for invite-code lookups).
806+ * `personaId`: Meta-AI bot persona id. Required to fetch the picture of an AI persona.
807+ * `commonGid`: a group jid both parties belong to. Required when the target's privacy
808+ * is set to "My contacts" and we are NOT in their contacts but share a
809+ * group — without it the server returns 401/403. Matches WA Web.
810+ *
811+ * Stanza shape (matches WA Web `WASmaxOutProfilePictureGetRequest` + whatsmeow):
812+ *
813+ * <iq xmlns="w:profile:picture" type="get" target="{jid}" to="s.whatsapp.net">
814+ * <picture type="..." query="url" [id] [invite] [persona_id] [common_gid]>
815+ * [<tctoken>...</tctoken>] ← nested CHILD (not sibling)
816+ * </picture>
817+ * </iq>
782818 */
783- const profilePictureUrl = async ( jid : string , type : 'preview' | 'image' = 'preview' , timeoutMs ?: number ) => {
784- const baseContent : BinaryNode [ ] = [ { tag : 'picture' , attrs : { type, query : 'url' } } ]
785-
786- // WA Web only includes tctoken for user JIDs (not groups/newsletters)
787- // and never for own profile pic (Chat model for self has no tcToken).
788- // Including tctoken for own JID causes the server to never respond.
819+ const profilePictureUrl = async (
820+ jid : string ,
821+ type : 'preview' | 'image' = 'preview' ,
822+ timeoutMs ?: number ,
823+ opts ?: {
824+ existingId ?: string
825+ invite ?: string
826+ personaId ?: string
827+ commonGid ?: string
828+ }
829+ ) => {
789830 const normalizedJid = jidNormalizedUser ( jid )
790831 const isUserJid = isAnyPnUser ( normalizedJid ) || isAnyLidUser ( normalizedJid )
791832 const me = authState . creds . me
792833 const isSelf =
793834 me && ( normalizedJid === jidNormalizedUser ( me . id ) || ( me . lid && normalizedJid === jidNormalizedUser ( me . lid ) ) )
794- let content : BinaryNode [ ] | undefined = baseContent
795835
796- if ( isUserJid && ! isSelf ) {
797- content = await buildTcTokenFromJid ( {
836+ // Build the <picture> attrs — include only the fields the caller supplied,
837+ // so unset ones map to DROP_ATTR (matching WA Web's OPTIONAL serializer behavior).
838+ const pictureAttrs : { [ k : string ] : string } = { type, query : 'url' }
839+ if ( opts ?. existingId ) pictureAttrs . id = opts . existingId
840+ if ( opts ?. invite ) pictureAttrs . invite = opts . invite
841+ if ( opts ?. personaId ) pictureAttrs . persona_id = opts . personaId
842+ if ( opts ?. commonGid ) pictureAttrs . common_gid = opts . commonGid
843+
844+ const pictureNode : BinaryNode = { tag : 'picture' , attrs : pictureAttrs }
845+
846+ // Attach tctoken (if known) as a CHILD of <picture>. Match WA Web
847+ // (WASmaxOutProfilePictureTCTokenMixin) and whatsmeow (pictureContent).
848+ // WA Web only includes tctoken for user JIDs (not groups/newsletters)
849+ // and never for own profile pic — including it for self causes the
850+ // server to never respond. Invite-code lookups also skip the token
851+ // (the invite IS the authorization).
852+ if ( isUserJid && ! isSelf && ! opts ?. invite ) {
853+ const tctokenNode = await buildTcTokenNode ( {
798854 authState,
799855 jid : normalizedJid ,
800- baseContent,
801- getLIDForPN
856+ // Port of upstream PR #2614: never fire USync from the profile-picture
857+ // path. If the LID mapping is unknown we send the IQ without the
858+ // tctoken and let the server tell us (vs. doing a USync round trip
859+ // that fingerprints us as non-WA-Web).
860+ getLIDForPN : getKnownLIDForPN
802861 } )
862+ if ( tctokenNode ) {
863+ pictureNode . content = [ tctokenNode ]
864+ }
803865 }
804866
805- jid = normalizedJid
806867 const result = await query (
807868 {
808869 tag : 'iq' ,
809870 attrs : {
810- target : jid ,
871+ target : normalizedJid ,
811872 to : S_WHATSAPP_NET ,
812873 type : 'get' ,
813874 xmlns : 'w:profile:picture'
814875 } ,
815- content
876+ content : [ pictureNode ]
816877 } ,
817878 timeoutMs
818879 )
0 commit comments