Skip to content

Commit 5ed014b

Browse files
rsalcaraclaude
andauthored
fix(profile-picture): WA Web parity — port PR WhiskeySockets#2614 + add id/invite/persona_id/common_gid (#510)
* fix(profile-picture): WA Web parity — nest tctoken + add id/invite/persona_id/common_gid Port of upstream WhiskeySockets#2614 + adicional gaps identified by comparing our `profilePictureUrl` against WA Web (live source via CDP) and whatsmeow source code. Brings our `<iq xmlns="w:profile:picture">` stanza to byte-for-byte parity with the official client behavior. What changes: 1. New `LIDMappingStore.getKnownLIDForPN(pn)` (port of PR WhiskeySockets#2614, literal). Returns the LID for a PN **only if locally known** (cache or store) — never triggers USync. Opportunistic call sites (profile-picture, etc.) use this so the failure-to-resolve path does NOT fire a USync round-trip. USync- on-profile-picture is not a behavior WA Web or whatsmeow emit; doing it makes our traffic profile stand out and may serve as a ban signal. 2. New `buildTcTokenNode({ authState, jid, getLIDForPN })` helper in tc-token-utils.ts. Same expiry / opportunistic-cleanup semantics as the pre-existing `buildTcTokenFromJid`, but returns the single <tctoken> BinaryNode (or undefined) instead of pushing into a content array. Callers that need the token as a nested CHILD of another node use this; the older `buildTcTokenFromJid` is kept intact for `presenceSubscribe` which uses the sibling-array shape. 3. `profilePictureUrl` refactored to match WA Web's `WASmaxOutProfilePictureGetRequest` mixin composition: - tctoken is now nested as a CHILD of <picture> (was a sibling — verified wrong against WA Web JS source: mergeStanzas(<picture>, <tctoken>) places tctoken inside picture, NOT alongside it). - tctoken resolver swapped to getKnownLIDForPN (no USync, PR WhiskeySockets#2614). - New optional params accepted in `opts`: * existingId → <picture id="..."> for 304-NotModified caching (matches WA Web pictureId + whatsmeow ExistingID). Saves a CDN re-fetch when the picture hasn't changed. * invite → <picture invite="..."> for group-invite-link preview (fetch a group's picture without joining). When set, we skip the tctoken (the invite IS the authorization). * personaId → <picture persona_id="..."> for Meta AI bot personas. * commonGid → <picture common_gid="..."> required when the target's privacy is "My contacts" and we share a group but are not in their contacts — without it the server returns 401/403. Matches WA Web. - Same callers using the old signature work unchanged (all new params are in an optional `opts` object). 4. `presenceSubscribe` is intentionally NOT changed — it doesn't wrap the tctoken inside a `<picture>` (no nesting question), and a USync miss during presence-subscribe is far less common (we usually know the LID by the time we subscribe). Kept on `buildTcTokenFromJid` + `getLIDForPN`. Empirical validation — triple-confirmed before applying: a) PR WhiskeySockets#2614 description (`fix: nest profile picture tctoken and avoid usync on lookup`) plus cubic + coderabbit summaries. b) whatsmeow's `Client.GetProfilePictureInfo` in user.go — pictureContent is the `Content` of the picture node (Go semantics: child). c) WA Web's live JS bundle, extracted via Chrome DevTools Protocol (`Debugger.getScriptSource`). The mixin chain in `WASmaxOutProfilePictureGetRequest` is: smax("picture", attrs) → optionalMerge(mergeAddRequestMixin, ...) → optionalMerge(mergeTCTokenMixin, ...) → optionalMerge(mergeAvatarMixin, ...) where `mergeStanzas(target, mixin)` places the mixin INSIDE target. The tctoken is undeniably a child of <picture>. Tests: - Existing 72 tests in `tc-token.test.ts` continue to pass (no signature changes to `buildTcTokenFromJid`). - 6 new tests for `buildTcTokenNode` (valid token, missing, expired + cleanup, no-wipe-when-missing, error swallow, return shape). - Full TC token suite: 78/78 passing. Out of scope (intentionally NOT touched): - Carousel, lists, buttons, polls, view-once, biz quality_control, useLegacyLock, the broader TC token issuance flow, LID↔PN batched, Phase 9 multi-DB, lidDbMigrated:false, the cacheMetricsInterval memory-leak fix, schema migrations + statement cache + busy retry, presence- subscribe path (covered separately above). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(lid-mapping): wrap getKnownLIDForPN in checkDestroyed + trackOperation (PR #510 review) PR #510 review caught a P2 contract violation: every other public method on `LIDMappingStore` calls `this.checkDestroyed()` at entry and runs its body inside `this.trackOperation(...)`. The new `getKnownLIDForPN` (added in this PR as part of the WA Web profile-picture parity port) was missing both guards. Why this matters: - Without `checkDestroyed()`: the method runs after `destroy()` has been called, when `this.keys` may already have been torn down. The first `await this.keys.get(...)` then races UAF on the underlying signal key store. - Without `trackOperation()`: `destroy()` does not wait for this method's in-flight I/O before clearing the mapping cache. A concurrent call can observe a half-cleared cache (a `mappingCache.set(...)` against a Map that `destroy()` is about to `clear()`) and silently return stale data rather than failing fast. Both threads (copilot + cubic) flagged the same issue, cubic with confidence 9. The fix is identical to the suggestion both reviewers provided: wrap the body in `this.trackOperation(async () => { … })` preceded by `this.checkDestroyed()`. Caller-side impact: none. `buildTcTokenNode` (the sole call site) has a try/catch that swallows any throw — so the only effect a post-destroy call has is "tctoken not attached" (same as the existing miss path), not a crash. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(tc-token): extract resolveTcTokenForJid helper (PR #510 review) PR #510 cubic review (Thread 3, P3) flagged that `buildTcTokenFromJid` and `buildTcTokenNode` carried byte-for-byte identical retrieval / expiry / opportunistic-cleanup pipelines — only the return shape differed (sibling array vs single node). Extracted that pipeline into a private `resolveTcTokenForJid(...)` helper so the two public builders become thin adapters around a single source of truth. Behavioral contract preserved (verified against the existing 78 tests): - Both public builders keep their exact public signatures. - `buildTcTokenFromJid` still mutates `baseContent` in place when a token exists, and still returns `baseContent.length > 0 ? baseContent : undefined` on every "no token" branch — same legacy behavior the presenceSubscribe call site relies on. - `buildTcTokenNode` still returns a single `BinaryNode` or `undefined` — same shape profilePictureUrl uses for nesting under `<picture>`. - The opportunistic expired-token wipe (with senderTimestamp preservation) runs exactly once, in the helper, with the same write shape as before. Missing-token entries are still NOT wiped. - Key-store errors are still swallowed inside the helper; both public builders fall back to their no-token branch on throw. Why the two builders stay as separate public surfaces (instead of one): - `presenceSubscribe` builds a `<presence>` whose content is the legacy sibling-array shape; it cares about preserving `baseContent` when no token exists. Forcing it to the single-node shape would require every call site to special-case `undefined`. - `profilePictureUrl` (port of PR WhiskeySockets#2614) needs the token nested as a CHILD of `<picture>`. It MUST get a single node, not an array. So the API split is meaningful; only the internal implementation was duplicated. The helper kills the duplication without changing the surface. Test impact: 0 changes to the test suite, 78/78 still pass. Same numbers for the LID-mapping suite (15) and identity-change suite (23) that indirectly exercise the same signal-store paths — 116/116 in the combined run. Out of scope (intentionally NOT touched): - Carousel, lists, buttons, polls, view-once, biz quality_control, useLegacyLock, the broader TC token issuance flow (only retrieval helpers refactored), LID↔PN batched, Phase 9 multi-DB, lidDbMigrated:false, the cacheMetricsInterval memory-leak fix, schema migrations + statement cache + busy retry. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 74052ce commit 5ed014b

4 files changed

Lines changed: 288 additions & 33 deletions

File tree

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: 71 additions & 18 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

@@ -784,43 +792,88 @@ export const makeChatsSocket = (config: SocketConfig) => {
784792
)
785793

786794
/**
787-
* fetch the profile picture of a user/group
788-
* type = "preview" for a low res picture
789-
* 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>
790818
*/
791-
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
792-
const baseContent: BinaryNode[] = [{ tag: 'picture', attrs: { type, query: 'url' } }]
793-
794-
// WA Web only includes tctoken for user JIDs (not groups/newsletters)
795-
// and never for own profile pic (Chat model for self has no tcToken).
796-
// 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+
) => {
797830
const normalizedJid = jidNormalizedUser(jid)
798831
const isUserJid = isAnyPnUser(normalizedJid) || isAnyLidUser(normalizedJid)
799832
const me = authState.creds.me
800833
const isSelf =
801834
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
802-
let content: BinaryNode[] | undefined = baseContent
803835

804-
if (isUserJid && !isSelf) {
805-
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({
806854
authState,
807855
jid: normalizedJid,
808-
baseContent,
809-
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
810861
})
862+
if (tctokenNode) {
863+
pictureNode.content = [tctokenNode]
864+
}
811865
}
812866

813-
jid = normalizedJid
814867
const result = await query(
815868
{
816869
tag: 'iq',
817870
attrs: {
818-
target: jid,
871+
target: normalizedJid,
819872
to: S_WHATSAPP_NET,
820873
type: 'get',
821874
xmlns: 'w:profile:picture'
822875
},
823-
content
876+
content: [pictureNode]
824877
},
825878
timeoutMs
826879
)

src/Utils/tc-token-utils.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -101,22 +101,55 @@ type TcTokenParams = {
101101
getLIDForPN?: (pn: string) => Promise<string | null>
102102
}
103103

104-
export async function buildTcTokenFromJid({
104+
/**
105+
* Resolved tctoken state for a JID, shared by the two public builders.
106+
*
107+
* - `buffer`: present iff a non-expired, non-empty token exists in store.
108+
* - Absent `buffer`: caller produces a "no tctoken" result (the shape of
109+
* that result differs per builder — array vs single node).
110+
*
111+
* The helper is also responsible for the opportunistic expired-token wipe
112+
* documented inline. Callers must NOT re-do that bookkeeping.
113+
*/
114+
type ResolvedTcToken = { buffer?: Buffer }
115+
116+
/**
117+
* Shared retrieval + expiry + opportunistic-cleanup pipeline used by both
118+
* `buildTcTokenFromJid` (sibling-array shape, kept for legacy call sites)
119+
* and `buildTcTokenNode` (single-node shape, used for nested tctoken in
120+
* `<picture>`). Extracting this collapses what used to be two
121+
* byte-for-byte identical critical sections so any future change to the
122+
* expiry / cleanup semantics happens in one place.
123+
*
124+
* Returns `{}` (no buffer) on every "no usable token" outcome:
125+
* - store miss
126+
* - empty token
127+
* - expired token (also performs the cleanup write)
128+
* - key-store error (swallowed; callers fall back to base content)
129+
*
130+
* Notes on the cleanup write (preserved from the original implementation):
131+
* - Only fires when an EXPIRED non-empty token was found. Missing tokens
132+
* are NOT wiped because nothing exists to wipe.
133+
* - If the entry carried a `senderTimestamp`, we preserve it via a
134+
* placeholder `{ token: Buffer.alloc(0), senderTimestamp }` so the
135+
* fire-and-forget issuance dedupe in messages-send survives. Otherwise
136+
* we tombstone the entry with `null`.
137+
* - Matches the exact same shape messages-send writes for issuance
138+
* placeholders, so we never accidentally widen the wipe to clear a
139+
* legitimate placeholder.
140+
*/
141+
async function resolveTcTokenForJid({
105142
authState,
106143
jid,
107-
baseContent = [],
108144
getLIDForPN
109-
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
145+
}: Pick<TcTokenParams, 'authState' | 'jid' | 'getLIDForPN'>): Promise<ResolvedTcToken> {
110146
try {
111147
const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid
112148
const tcTokenData = await authState.keys.get('tctoken', [storageJid])
113149
const entry = tcTokenData?.[storageJid]
114150
const tcTokenBuffer = entry?.token
115151

116152
if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
117-
// Opportunistic cleanup: drop the expired token but preserve senderTimestamp so the
118-
// fire-and-forget issuance dedupe survives — same shape used in messages-send, so this
119-
// path no longer wipes that placeholder (only clears a real, non-empty expired token).
120153
if (tcTokenBuffer?.length) {
121154
const cleared =
122155
entry?.senderTimestamp !== undefined
@@ -125,19 +158,61 @@ export async function buildTcTokenFromJid({
125158
await authState.keys.set({ tctoken: { [storageJid]: cleared } })
126159
}
127160

128-
return baseContent.length > 0 ? baseContent : undefined
161+
return {}
129162
}
130163

131-
baseContent.push({
132-
tag: 'tctoken',
133-
attrs: {},
134-
content: tcTokenBuffer
135-
})
164+
return { buffer: tcTokenBuffer }
165+
} catch {
166+
return {}
167+
}
168+
}
136169

137-
return baseContent
138-
} catch (error) {
170+
/**
171+
* Legacy sibling-array shape. Used by `presenceSubscribe` where the tctoken
172+
* is the only content of a `<presence>` (so the "sibling" framing is moot —
173+
* there's nothing to sibling against). Kept on the legacy
174+
* `buildTcTokenFromJid` + `getLIDForPN` resolver pair for behavioral
175+
* stability.
176+
*
177+
* Returns `baseContent` (mutated in place with the `<tctoken>` appended)
178+
* when a token exists, or `baseContent | undefined` otherwise — same exact
179+
* contract as before the helper extraction.
180+
*/
181+
export async function buildTcTokenFromJid({
182+
authState,
183+
jid,
184+
baseContent = [],
185+
getLIDForPN
186+
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
187+
const { buffer } = await resolveTcTokenForJid({ authState, jid, getLIDForPN })
188+
189+
if (!buffer) {
139190
return baseContent.length > 0 ? baseContent : undefined
140191
}
192+
193+
baseContent.push({ tag: 'tctoken', attrs: {}, content: buffer })
194+
return baseContent
195+
}
196+
197+
/**
198+
* Build a standalone <tctoken> BinaryNode (no container, no sibling array).
199+
*
200+
* Use this when the caller needs the tctoken as a CHILD of another stanza node
201+
* — e.g. nested inside <picture> for `w:profile:picture` queries (port of
202+
* upstream PR #2614 / matches WA Web's `WASmaxOutProfilePictureTCTokenMixin`
203+
* + whatsmeow's `pictureContent`).
204+
*
205+
* Returns the node when a valid (non-expired, non-empty) tctoken exists for
206+
* the resolved storage JID, or `undefined` otherwise.
207+
*/
208+
export async function buildTcTokenNode({
209+
authState,
210+
jid,
211+
getLIDForPN
212+
}: Omit<TcTokenParams, 'baseContent'>): Promise<BinaryNode | undefined> {
213+
const { buffer } = await resolveTcTokenForJid({ authState, jid, getLIDForPN })
214+
215+
return buffer ? { tag: 'tctoken', attrs: {}, content: buffer } : undefined
141216
}
142217

143218
type StoreTcTokensParams = {

0 commit comments

Comments
 (0)