fix(decode): preserve outer messageContextInfo when unwrapping deviceSentMessage#520
Conversation
…SentMessage
When WhatsApp delivers a `fromMe:true` message via a linked device, the
stanza arrives wrapped in a `deviceSentMessage` envelope whose OUTER
`Message` carries the operationally important `messageContextInfo`
fields — including `messageContextInfo.messageSecret`. The previous
unwrap
msg = msg.deviceSentMessage?.message || msg
dropped that outer context entirely. Two concrete consequences in our
fork:
1. Encrypted edit envelopes (the upstream PR WhiskeySockets#2554 path) need the
original `messageContextInfo.messageSecret` to derive the edit
key. With it lost on the fromMe-via-linked-device delivery,
`getMessage` later returns the cached message without the secret
and the edit decrypt fails.
2. The Meta AI / FBID bot msmsg cache shipped in PR #518
(`cacheMessageSecretIfPresent`, called immediately AFTER this
unwrap on the same `msg`) needs the secret to land against the
outgoing message's id so subsequent bot replies decrypt. Without
preserving the outer context info, single-device sends and the
first linked-device delivery both miss the cache — the exact
"outgoing-side secret not cached" gap codex flagged on PR #518.
Why a named per-field merge instead of a generic `{...outer, ...inner}`
spread (upstream PR WhiskeySockets#2566's approach):
PR WhiskeySockets#2566 fixes the common case with a spread that lets the inner
message's `messageContextInfo` ENTIRELY override the outer's. If the
inner ships a PARTIAL `messageContextInfo` (e.g. a thread reply with
just `threadId` set), the outer's `messageSecret` is dropped silently.
WA Web's `WAWebDeviceSentMessageProtoUtils.l(e)` — extracted from
the live web client via CDP source dump during the msmsg
investigation — merges field-by-field with explicit per-field rules:
- `messageSecret`, `messageAssociation`, `threadId`, `botMetadata`:
inner preferred when present, outer fallback. Inner expresses
message-local intent; outer is the envelope's best knowledge.
- `limitSharingV2`: ONLY from outer. The sharing policy attached to
the linked-device fanout is set by the originator at the envelope
level; the inner has no authoritative voice.
- All other (future) messageContextInfo fields: pulled in via
`...innerCtx` spread so adding a new field to the proto doesn't
require touching this code.
Helper:
* NEW `unwrapDeviceSentMessage(msg)` at module scope. The wider
decrypt switch had no business knowing about
`messageContextInfo` field semantics — the helper isolates the
rules in one place and keeps the call site a single expression
(`msg = unwrapDeviceSentMessage(msg)`).
* Returns the input unchanged when there is no `deviceSentMessage`
envelope (matches the previous fallthrough semantics).
Tests:
* NEW `src/__tests__/Utils/dsm-context-info-preservation.test.ts`
with 9 cases:
- no-op when no deviceSentMessage envelope
- the case that motivated upstream PR WhiskeySockets#2566 (outer-only
messageSecret) — works under both our impl AND PR WhiskeySockets#2566
- the edge case PR WhiskeySockets#2566 FAILS on (inner partial
messageContextInfo + outer messageSecret) — works only with
per-field merge
- inner-preferred semantics for shared fields
- `limitSharingV2` sourced ONLY from outer
- `threadId` defaults to `[]` to match WA Web
- inner precedence for botMetadata + messageAssociation
- result drops the deviceSentMessage envelope
- SHAPE-PIN: explicitly re-derives the naive
`{...outer, ...inner}` approach and asserts it loses
messageSecret on the partial-inner case. If a future
refactor swaps our helper for the naive spread, this
assertion breaks first and points at the divergence.
Validation:
* `npm run build` clean
* 47/47 tests pass across DSM (9 new) + msmsg (21) + Lottie sticker
(9) + protocol-guard (8) suites.
Customizations preserved (NOT touched):
Carousel, lists, buttons, polls, view-once, biz `quality_control`,
`useLegacyLock`, TC token custom flow, LID↔PN batched, Phase 9
multi-DB, `lidDbMigrated:false`, `cacheMetricsInterval` memory-leak
fix, schema migrations + statement cache + busy retry, recoverable-
error compact logging, Meta AI msmsg decryption, Lottie sticker
wrap/unwrap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
|
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 worksIf you’ve tested this PR, please comment below with: This helps us speed up the review and merge process. 📦 To test this PR locally:If you encounter any issues or have feedback, feel free to comment as well. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 69902b7ac2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…E-PIN
CI lint failed on `dsm-context-info-preservation.test.ts:202` with
`'deviceSentMessage' is assigned a value but never used`. The
destructure is intentional — it re-derives the naive
`{...outer, ...inner}` approach from upstream PR WhiskeySockets#2566 by pulling
`deviceSentMessage` out of the outer object and discarding it. The
discarded binding name matches the proto field by design; renaming it
to `_deviceSentMessage` would also work but would obscure the parallel
to PR WhiskeySockets#2566's exact code.
Disable the rule for that one line and add a comment explaining why
the throwaway is named the way it is.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes linked-device (deviceSentMessage) unwrapping so fromMe:true messages retain the outer messageContextInfo (notably messageSecret) needed by downstream features like encrypted edits and the Meta AI msmsg secret cache.
Changes:
- Added a new
unwrapDeviceSentMessage(msg)helper that merges outer/innermessageContextInfowith WA Web–matching precedence rules. - Updated
decryptMessageNodeto use the helper instead ofmsg.deviceSentMessage?.message || msg. - Added a new regression test suite intended to pin the merge behavior for partial inner
messageContextInfo.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/Utils/decode-wa-message.ts |
Introduces unwrapDeviceSentMessage and applies it during message decode to preserve outer messageContextInfo. |
src/__tests__/Utils/dsm-context-info-preservation.test.ts |
Adds regression tests around DSM unwrap + context merge behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
3 issues found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/__tests__/Utils/dsm-context-info-preservation.test.ts">
<violation number="1" location="src/__tests__/Utils/dsm-context-info-preservation.test.ts:30">
P2: Duplicated production logic: test re-implements `unwrapDeviceSentMessage` instead of importing it, creating a maintenance hazard where tests won't detect production changes</violation>
</file>
<file name="src/Utils/decode-wa-message.ts">
<violation number="1" location="src/Utils/decode-wa-message.ts:87">
P2: Outer-only `messageContextInfo` fields are still discarded because the merge starts from `innerCtx` instead of combining outer + inner.</violation>
<violation number="2" location="src/Utils/decode-wa-message.ts:91">
P2: Depending on how the WAProto types are generated, decoded `MessageContextInfo` may have `threadId` set to `[]` (rather than `undefined`) even when the field was absent on the wire. If so, `innerCtx?.threadId ?? outerCtx?.threadId` will always pick the inner empty array whenever any `messageContextInfo` exists on the inner message, silently dropping the outer thread association. Consider checking for a non-empty inner array before falling back:
```ts
threadId: (innerCtx?.threadId?.length ? innerCtx.threadId : null) ?? outerCtx?.threadId ?? [],
```</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
cubic + chatgpt audit (Threads 1 and 6) caught a real bug that the
previous spec-pin tests did not exercise:
`proto.MessageContextInfo.decode(...)` initializes the `repeated`
`threadId` field to `[]` (proto3 default), NOT `undefined`. The
per-field merge expression
threadId: innerCtx?.threadId ?? outerCtx?.threadId ?? []
therefore NEVER falls through to the outer side when the inner
message has a `messageContextInfo` instance — `[]` isn't nullish.
Any thread context the outer envelope carried is silently dropped
for thread replies delivered via a linked device.
Empirical verification:
proto.MessageContextInfo.decode(encode({ messageSecret: X }))
.threadId
→ [] ← not undefined
→ [] ?? outerCtx?.threadId
→ [] ← outer threadId lost
Fix mirrors `WAWebDeviceSentMessageProtoUtils.l(e)`'s implicit
length-check (the JS source ships a similar guard via the runtime
`Array.length` truthiness chain):
threadId: (innerCtx?.threadId?.length ? innerCtx.threadId : null)
?? outerCtx?.threadId
?? []
An empty inner array is now treated as "inner didn't set it" so the
outer wins. A non-empty inner array (sender explicitly set a thread
context) keeps inner-preferred semantics. Both sides empty falls to
the `[]` default that matches WA Web.
Test added:
- `outer threadId WINS when inner messageContextInfo exists without
a threadId in the wire (protobuf [] vs ?? trap)`. Uses an actual
`proto.MessageContextInfo.decode(...)` to produce the inner
`messageContextInfo` so the `[]` default comes from real protobuf
behaviour, not a hand-constructed object. Asserts both outer
threadId is preserved AND inner messageSecret still wins.
10/10 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
When WhatsApp delivers a
fromMe:truemessage via a linked device, the stanza arrives wrapped in adeviceSentMessageenvelope whose OUTERMessagecarries the operationally importantmessageContextInfofields — includingmessageContextInfo.messageSecret. The previous unwrapdropped that outer context entirely. Two concrete consequences in our fork:
Encrypted edit envelopes (upstream PR #2554) need the original
messageContextInfo.messageSecretto derive the edit key. Without it on the fromMe-via-linked-device delivery,getMessagelater returns the cached message without the secret and the edit decrypt fails.Meta AI / FBID bot msmsg cache shipped in PR feat(meta-ai): decrypt msmsg via empirically-validated WA Web algorithm #518 (
cacheMessageSecretIfPresent, called immediately AFTER this unwrap on the samemsg). Without preserving the outer context info, single-device sends and the first linked-device delivery both miss the cache — the exact "outgoing-side secret not cached" gap codex flagged on PR feat(meta-ai): decrypt msmsg via empirically-validated WA Web algorithm #518.How this differs from upstream PR WhiskeySockets#2566
Upstream PR #2566 fixes the same bug with a generic
{...outer, ...inner}spread. That covers the common case but LOSES outer fields when the inner ships a partialmessageContextInfo(e.g. a thread reply with justthreadIdset — outer'smessageSecretis dropped silently).WA Web's actual implementation (
WAWebDeviceSentMessageProtoUtils.l(e)— extracted from the live web client via the CDP source dump during the msmsg investigation, not via runtime hooking) merges field-by-field:messageSecretmessageAssociationthreadId[]botMetadatalimitSharingV2...innerCtxspreadValidation methodology
CDP live runtime hooking was deliberately NOT used here. The relevant module (
WAWebDeviceSentMessageProtoUtils) was already captured to disk inscript-395.jsduring the PR WhiskeySockets#2592 (msmsg) investigation. The full functionl(e)was extracted via grep and matched line-for-line against this implementation.Live hooking would have added zero structural information — the algorithm is purely static proto-object manipulation, no dynamic crypto inputs, no per-call state. Reserved CDP hooking for cases where the inputs themselves drive the algorithm (e.g. HKDF info bytes for msmsg).
Changes
src/Utils/decode-wa-message.tsunwrapDeviceSentMessage(msg)helper at module scope. Call site collapses tomsg = unwrapDeviceSentMessage(msg)src/__tests__/Utils/dsm-context-info-preservation.test.ts{...outer, ...inner}approach and asserts it loses messageSecret on the partial-inner caseValidation
npm run buildcleanCustomizations preserved (NOT touched)
Carousel, lists, buttons, polls, view-once, biz
quality_control,useLegacyLock, TC token custom flow, LID↔PN batched, Phase 9 multi-DB,lidDbMigrated:false,cacheMetricsIntervalmemory-leak fix, schema migrations + statement cache + busy retry, recoverable-error compact logging, Meta AI msmsg decryption, Lottie sticker wrap/unwrap.🤖 Generated with Claude Code
Summary by cubic
Preserves the outer
messageContextInfowhen unwrappingdeviceSentMessageso linked-device fromMe messages keepmessageSecretand related fields. Fixes encrypted edit decrypts and ensures the msmsg secret cache is populated on first delivery.unwrapDeviceSentMessageto merge outer/inner context like WA Web: inner wins formessageSecret,messageAssociation,botMetadata;threadIdfalls back to outer when inner is empty, else inner wins;limitSharingV2is outer-only;threadIddefaults to[].decryptMessageNodewith the helper to prevent dropping outer context.threadId: []default case; kept the SHAPE-PIN for the naive spread and silenced its intentional unused-var lint.Written for commit aef7ac5. Summary will update on new commits.