Skip to content

fix(profile-picture): WA Web parity — port PR #2614 + add id/invite/persona_id/common_gid#510

Merged
rsalcara merged 3 commits into
developfrom
fix/profile-picture-wa-web-parity
Jun 7, 2026
Merged

fix(profile-picture): WA Web parity — port PR #2614 + add id/invite/persona_id/common_gid#510
rsalcara merged 3 commits into
developfrom
fix/profile-picture-wa-web-parity

Conversation

@rsalcara

@rsalcara rsalcara commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Summary

Bring our profilePictureUrl stanza to byte-for-byte parity with WA Web (and whatsmeow). Includes the literal port of upstream WhiskeySockets/Baileys#2614 plus 4 additional <picture> attrs that WA Web emits but we don't.

Empirical evidence — triple confirmation

Source Method Result
PR WhiskeySockets#2614 (cubic + coderabbit summaries) description read "nest tctoken inside <picture>" + "skip USync"
whatsmeow Client.GetProfilePictureInfo curl raw.githubusercontent.com/.../user.go pictureContent is Go Content: of the picture node (= child)
WA Web live JS bundle Chrome DevTools Debugger.getScriptSource WASmaxOutProfilePictureGetRequest: mergeStanzas(<picture>, <tctoken>) ⇒ tctoken inside picture

The WA Web mixin chain proves it definitively:

smax("picture", attrs)
  → optionalMerge(mergeAddRequestMixin, ...)
  → optionalMerge(mergeTCTokenMixin, ...)
  → optionalMerge(mergeAvatarMixin, ...)

mergeStanzas(target, mixin) places the mixin INSIDE target.

Changes

File Change
src/Signal/lid-mapping.ts NEW getKnownLIDForPN(pn) — port of PR WhiskeySockets#2614 (cache+disk only, no USync)
src/Utils/tc-token-utils.ts NEW buildTcTokenNode({ … }) returning the single <tctoken> BinaryNode (or undefined) for nesting. Old buildTcTokenFromJid kept intact.
src/Socket/chats.ts Refactor profilePictureUrl — nest tctoken in <picture>, use getKnownLIDForPN, accept existingId / invite / personaId / commonGid in opts
src/__tests__/Utils/tc-token.test.ts +6 tests for buildTcTokenNode (72 → 78 passing)

New profilePictureUrl opts

profilePictureUrl(jid, type?, timeoutMs?, opts?: {
    existingId?: string  // ETag for 304 NotModified caching (matches WA Web pictureId)
    invite?: string      // group invite code — get pic without joining
    personaId?: string   // Meta AI bot persona
    commonGid?: string   // common group — privacy "My contacts" bypass
})

Old signature still works (all new params optional).

Stanza emitted (after)

<iq xmlns="w:profile:picture" type="get" target="contato@s.whatsapp.net" to="s.whatsapp.net">
  <picture type="preview" query="url" [id="..."] [invite="..."] [persona_id="..."] [common_gid="..."]>
    <tctoken>BUFFER</tctoken>     ← NESTED child (matches WA Web)
  </picture>
</iq>

vs before:

<iq xmlns="w:profile:picture" ...>
  <picture type="preview" query="url"/>
  <tctoken>BUFFER</tctoken>      ← SIBLING (diverges from WA Web)
</iq>

What's NOT touched

Explicitly out of scope — zero impact on the customizations the maintainer flagged as off-limits:

  • Carousel, lists, buttons, polls, view-once
  • biz quality_control, useLegacyLock
  • The broader TC token issuance flow (only the profile-picture call-site changes)
  • LID↔PN batched, Phase 9 multi-DB, lidDbMigrated:false
  • The cacheMetricsInterval memory-leak fix in libsignal
  • Schema migrations + statement cache + busy retry
  • presenceSubscribe — kept on the old helper / getLIDForPN because it doesn't nest tctoken inside another node (no nesting question)

Risks

Risk Mitigation
Server rejects new stanza shape Validated by 3 independent sources including live WA Web source code
Callers using old signature break All new params are in an optional opts object — signature backward-compatible
USync misses surface as missing tctoken Intentional — same as PR WhiskeySockets#2614 design. Server tells us the token state, vs us fingerprinting ourselves with USync
Tests 78/78 passing (72 existing + 6 new)

Test plan

  • npm run build passes
  • tc-token.test.ts — 78/78 passing
  • Manual smoke after merge: connect to live socket, call profilePictureUrl(knownContactJid), verify response

🤖 Generated with Claude Code


Summary by cubic

Brings profilePictureUrl to WA Web parity by nesting <tctoken> inside <picture>, adding id, invite, persona_id, and common_gid, and avoiding USync via a local-only LID resolver. Also unifies TC token retrieval/expiry logic to keep behavior consistent across call sites.

  • New Features

    • profilePictureUrl(jid, type, timeoutMs, opts) accepts existingId, invite, personaId, commonGid. Backward compatible.
  • Bug Fixes

    • <tctoken> is nested under <picture>; skipped for self, non-user JIDs, and invite lookups.
    • No USync on this path; uses getKnownLIDForPN, which calls checkDestroyed and trackOperation to prevent post-destroy races.
    • Extracted resolveTcTokenForJid to centralize token retrieval/expiry cleanup for buildTcTokenFromJid and buildTcTokenNode (no behavior change).

Written for commit fea38ba. Summary will update on new commits.

Review in cubic

…rsona_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>
Copilot AI review requested due to automatic review settings June 7, 2026 00:50
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9fbb2615-88e9-44d6-9914-a52501513736

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/profile-picture-wa-web-parity

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

Thanks for opening this pull request and contributing to the project!

The next step is for the maintainers to review your changes. If everything looks good, it will be approved and merged into the main branch.

In the meantime, anyone in the community is encouraged to test this pull request and provide feedback.

✅ How to confirm it works

If you’ve tested this PR, please comment below with:

Tested and working ✅

This helps us speed up the review and merge process.

📦 To test this PR locally:

# NPM
npm install @whiskeysockets/baileys@rsalcara/InfiniteAPI#fix/profile-picture-wa-web-parity

# Yarn (v2+)
yarn add @whiskeysockets/baileys@rsalcara/InfiniteAPI#fix/profile-picture-wa-web-parity

# PNPM
pnpm add @whiskeysockets/baileys@rsalcara/InfiniteAPI#fix/profile-picture-wa-web-parity

If you encounter any issues or have feedback, feel free to comment as well.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the profilePictureUrl IQ stanza to match WhatsApp Web behavior by nesting <tctoken> inside <picture>, avoiding USync-triggering LID lookups on this path, and adding support for additional WA Web-emitted <picture> attributes.

Changes:

  • Add buildTcTokenNode() to return a standalone <tctoken> BinaryNode suitable for nesting.
  • Refactor profilePictureUrl() to nest <tctoken> under <picture>, use a local-only PN→LID resolver, and accept new optional attrs (id, invite, persona_id, common_gid).
  • Add unit tests covering buildTcTokenNode() behavior and cleanup semantics.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/Utils/tc-token-utils.ts Adds buildTcTokenNode() helper for nested <tctoken> construction.
src/Socket/chats.ts Refactors profilePictureUrl() stanza construction and adds new optional query attrs.
src/Signal/lid-mapping.ts Adds getKnownLIDForPN() local-only mapping lookup (no USync).
src/tests/Utils/tc-token.test.ts Adds tests for buildTcTokenNode() return/cleanup/error behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Signal/lid-mapping.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/Signal/lid-mapping.ts
Comment thread src/Utils/tc-token-utils.ts
rsalcara and others added 2 commits June 6, 2026 22:02
…ation (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>
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>
@rsalcara rsalcara merged commit 5ed014b into develop Jun 7, 2026
6 checks passed
rsalcara added a commit that referenced this pull request Jun 7, 2026
…#513)

release: develop → master 2026-06-06 (v2) — #509 + #510 + #511 + #512 (#513)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants