Skip to content

Commit 424260a

Browse files
authored
release: develop → master 2026-06-06 (v2) — #509 + #510 + #511 + #512 (#513)
release: develop → master 2026-06-06 (v2) — #509 + #510 + #511 + #512 (#513)
2 parents 3a15f64 + 373a6f3 commit 424260a

10 files changed

Lines changed: 868 additions & 113 deletions

src/Signal/lid-mapping.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,53 @@ export class LIDMappingStore {
505505
})
506506
}
507507

508+
/**
509+
* Port of upstream PR #2614 (`fix: nest profile picture tctoken and avoid
510+
* usync on lookup`). Returns the LID for a PN ONLY if the mapping is
511+
* already known (memory cache or on-disk store). Never triggers a USync
512+
* lookup.
513+
*
514+
* Use this on hot paths where firing a USync just to opportunistically
515+
* attach metadata (e.g. profile-picture tctoken) is undesired — both
516+
* because the latency is wasted (the operation must still proceed if the
517+
* mapping is unknown) AND because USync-on-look-up is a behavioral
518+
* fingerprint WA Web / whatsmeow don't emit, so doing it makes our
519+
* traffic profile stand out and may serve as a ban signal.
520+
*
521+
* Thread safety: wrapped in `checkDestroyed()` + `trackOperation()` —
522+
* same contract every other public method on this store follows. Without
523+
* these, the async `keys.get()` could race with `destroy()` (UAF on the
524+
* key store) and a post-destroy call could silently return stale data.
525+
* (PR #510 review — addresses cubic / copilot P2.)
526+
*/
527+
async getKnownLIDForPN(pn: string): Promise<string | null> {
528+
this.checkDestroyed()
529+
530+
return this.trackOperation(async () => {
531+
if (!isAnyPnUser(pn)) return null
532+
533+
const decoded = jidDecode(pn)
534+
if (!decoded) return null
535+
536+
const pnUser = decoded.user
537+
let lidUser = this.mappingCache.get(`pn:${pnUser}`)
538+
if (!lidUser) {
539+
const stored = await this.keys.get('lid-mapping', [pnUser])
540+
const storedLidUser = stored[pnUser]
541+
if (typeof storedLidUser === 'string' && storedLidUser) {
542+
lidUser = storedLidUser
543+
this.mappingCache.set(`pn:${pnUser}`, lidUser)
544+
this.mappingCache.set(`lid:${lidUser}`, pnUser)
545+
}
546+
}
547+
548+
if (!lidUser) return null
549+
550+
const pnDevice = decoded.device !== undefined ? decoded.device : 0
551+
return `${lidUser}${pnDevice ? `:${pnDevice}` : ''}@${decoded.server === 'hosted' ? 'hosted.lid' : 'lid'}`
552+
})
553+
}
554+
508555
/**
509556
* Get LIDs for multiple PNs - Optimized batch operation
510557
*

src/Socket/chats.ts

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
} from '../Utils'
5656
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
5757
import processMessage from '../Utils/process-message'
58-
import { buildTcTokenFromJid } from '../Utils/tc-token-utils'
58+
import { buildTcTokenFromJid, buildTcTokenNode } from '../Utils/tc-token-utils'
5959
import {
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
)

src/Socket/messages-recv.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2236,8 +2236,19 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
22362236

22372237
if (updatedDevices.length === 0) {
22382238
await userDevicesCache?.del(user)
2239-
} else {
2240-
await userDevicesCache?.set(user, updatedDevices)
2239+
} else if (userDevicesCache) {
2240+
// PR #513 review (chatgpt-codex P2): mirror the ECACHEFULL
2241+
// handling already in place for `userDevicesCache.set` in
2242+
// messages-send.ts. With the `maxKeys` cap added in PR
2243+
// #509, `@cacheable/node-cache` throws ECACHEFULL when
2244+
// `keyCount() + 1 > maxKeys` — and that check is hit even
2245+
// for an UPDATE of an already-cached key when the cache
2246+
// is at capacity, because the underlying `set` doesn't
2247+
// short-circuit on existing-key writes. `safeCacheSet`
2248+
// swallows the throw with a debug log; the durable USync
2249+
// state is unaffected (next message-send will re-fetch
2250+
// via `getUSyncDevices`, same fallback semantics).
2251+
await safeCacheSet(userDevicesCache, user, updatedDevices, logger, 'userDevicesCache')
22412252
}
22422253
}
22432254
})

src/Socket/messages-send.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import NodeCache from '@cacheable/node-cache'
22
import { Boom } from '@hapi/boom'
33
import { randomBytes } from 'crypto'
44
import { proto } from '../../WAProto/index.js'
5-
import { DEFAULT_CACHE_TTLS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults'
5+
import { DEFAULT_CACHE_MAX_KEYS, DEFAULT_CACHE_TTLS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults'
66
import type {
77
AlbumMediaItem,
88
AlbumMediaResult,
@@ -40,6 +40,7 @@ import {
4040
normalizeMessageContent,
4141
parseAndInjectE2ESessions,
4242
runDetached,
43+
safeCacheSet,
4344
unixTimestampSeconds
4445
} from '../Utils'
4546
import { logMessageSent, logTcToken } from '../Utils/baileys-logger'
@@ -198,7 +199,16 @@ export const makeMessagesSocket = (config: SocketConfig) => {
198199
config.userDevicesCache ||
199200
new NodeCache<JidWithDevice[]>({
200201
stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES, // 5 minutes
201-
useClones: false
202+
useClones: false,
203+
// Audit memory — cap defined in DEFAULT_CACHE_MAX_KEYS but never
204+
// applied here. NodeCache TTL re-extends on every `.set()`, so
205+
// under sustained traffic the TTL never expires; only `maxKeys`
206+
// provides a hard ceiling. Empirical Frida capture on WA Android
207+
// 2.26.21.75 confirms each outbound group msg can lazy-discover
208+
// a new device → in a gateway with 50 active groups × 100 members
209+
// × 1.5 devices avg the cache hits ~7,500 entries (already over
210+
// the intended 5,000 cap).
211+
maxKeys: DEFAULT_CACHE_MAX_KEYS.USER_DEVICES
202212
})
203213
/** Serializes writes to userDevicesCache across USync refresh and device-notification handling. */
204214
const devicesMutex = makeMutex()
@@ -525,12 +535,36 @@ export const makeMessagesSocket = (config: SocketConfig) => {
525535
}
526536

527537
await devicesMutex.mutex(async () => {
538+
// Audit ECACHEFULL — @cacheable/node-cache throws "Cache max keys amount
539+
// exceeded" when `maxKeys` is reached (instead of LRU-evicting). For
540+
// `mset` we fall back to a per-key path on ECACHEFULL so partial writes
541+
// don't surface as send failures; for `.set` we route through
542+
// `safeCacheSet` which already swallows ECACHEFULL with a debug log.
543+
// Either way, durable state is unaffected — a cache miss next round
544+
// triggers a USync refresh, same as a TTL-expired entry.
528545
if (userDevicesCache.mset) {
529-
// if the cache supports mset, we can set all devices in one go
530-
await userDevicesCache.mset(Object.entries(deviceMap).map(([key, value]) => ({ key, value })))
546+
try {
547+
await userDevicesCache.mset(
548+
Object.entries(deviceMap).map(([key, value]) => ({ key, value }))
549+
)
550+
} catch (err) {
551+
const msg = (err as Error)?.message ?? ''
552+
if (!msg.includes('max keys') && !msg.includes('ECACHEFULL')) throw err
553+
logger.debug(
554+
{ entries: Object.keys(deviceMap).length },
555+
'userDevicesCache mset hit ECACHEFULL — falling back to per-key safeCacheSet'
556+
)
557+
for (const key in deviceMap) {
558+
if (deviceMap[key]) {
559+
await safeCacheSet(userDevicesCache, key, deviceMap[key], logger, 'userDevicesCache')
560+
}
561+
}
562+
}
531563
} else {
532564
for (const key in deviceMap) {
533-
if (deviceMap[key]) await userDevicesCache.set(key, deviceMap[key])
565+
if (deviceMap[key]) {
566+
await safeCacheSet(userDevicesCache, key, deviceMap[key], logger, 'userDevicesCache')
567+
}
534568
}
535569
}
536570
})

0 commit comments

Comments
 (0)