Compare commits

...

5 Commits

Author SHA1 Message Date
Renato Alcara 3f4e83a8af fix: address codex/copilot review on view-once send/recv
P1 (messages-recv.ts): remove early return for <unavailable type="view_once"/>.
decryptMessageNode already handles this gracefully — decode-wa-message.ts sets
fullMessage.key.isViewOnce=true and skips the CIPHERTEXT stub (line 464).
The early return was preventing upsertMessage from emitting an event to consumers,
making view-once sends from the primary phone invisible on linked devices.

P2 (messages-send.ts): restrict isViewOnceMsg detection to actual view-once media.
Previously checked only for the presence of viewOnceMessage* wrappers, which are
also used by buttons/lists/carousels (non-view-once). Now unwraps the inner message
and checks imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce,
consistent with the detection in decode-wa-message.ts. Prevents companion DSM
filtering from incorrectly applying to interactive messages.

P3 (messages-send.ts): move assertSessions to use only the recipients actually
encrypted for. For view-once, omitted companions no longer cause assertSessions
to fail or do unnecessary session validation work.

Co-Authored-By: Renato Alcara
2026-03-23 00:35:10 -03:00
Renato Alcara d870ffb2a3 fix: handle view-once send/recv on linked devices
- messages-send.ts: for view-once messages, send DSM only to device=0
  (primary phone). Companion devices (device>0) are omitted from
  participants — the WA server generates <unavailable type="view_once"/>
  for them automatically. Sending explicit <unavailable> from a companion
  is rejected by the server and breaks delivery.
  phash is recalculated to match the actual participants sent.

- messages-recv.ts: add early return for <unavailable type="view_once"/>
  stanzas received by the companion (API). When the primary phone sends
  a view-once, linked companions receive this node with no enc content.
  Previously the code tried to decrypt it and crashed with a 500 error.

Co-Authored-By: Renato Alcara
2026-03-23 00:02:54 -03:00
Renato Alcara f6c72530a2 fix: unwrap viewOnceMessage in getMediaType so enc node gets mediatype attribute
Without this fix, view-once media (image/video/audio) was sent without
mediatype="image/video/audio" on the enc node because getMediaType only
checked the top-level message fields. The viewOnceMessage wrapper hides
the inner imageMessage, causing mediatype to be empty and WA servers to
silently drop the message on the recipient side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:45:14 -03:00
Renato Alcara a4e7422487 fix: remove unnecessary non-null assertion on fullMessage.key
fullMessage.key is always present on WAMessage. Use consistent style
with stanza-2 handling at line 297.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:09:09 -03:00
Renato Alcara c6511d3034 fix: detect view-once media (image/video/audio) on stanza 1 for linked devices
When a view-once arrives at a linked device (InfiniteAPI), the server sends:
- Stanza 1: enc payload with full media metadata (mediaKey, directPath) wrapped in
  viewOnceMessage > imageMessage/videoMessage/audioMessage with viewOnce: true
- Stanza 2: unavailable view_once fanout placeholder (already handled)

Previously only stanza 2 set key.isViewOnce = true. Stanza 1 was emitted as
plain media with no view-once indicator, making it indistinguishable from regular
media on the consumer side (messages.upsert).

This fix inspects the decrypted proto for viewOnceMessage (v1/v2/v2Ext) wrappers
containing a media message with viewOnce=true and propagates key.isViewOnce=true.

The viewOnceMessage wrapper is also used for interactive messages (carousel,
buttons, lists) but those carry interactiveMessage/listMessage inside -- never
imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce.
The distinction is unambiguous and does not affect interactive message handling.

Verified via WA Desktop CDP capture (2026-03-19) and Android Frida DB capture
of view-once types 42/43/82 in msgstore.db (2026-03-20).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:35:26 -03:00
2 changed files with 57 additions and 4 deletions
+40 -4
View File
@@ -1277,21 +1277,48 @@ export const makeMessagesSocket = (config: SocketConfig) => {
: otherRecipients
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
await assertSessions(effectiveAllRecipients)
// P2: detect actual view-once media by checking inner message's viewOnce flag.
// viewOnceMessage wrapper is also used for interactive messages (buttons, lists, etc)
// which do NOT carry viewOnce=true on the media — those must NOT be filtered.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// For view-once: only send DSM to primary phone (device=0).
// Companion devices (device>0) are omitted — WA server generates
// <unavailable type="view_once"/> for them automatically.
// Sending explicit <unavailable> from a companion is rejected by the server.
const viewOnceMeRecipients = isViewOnceMsg
? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
: effectiveMeRecipients
// P3: assert sessions only for recipients we actually encrypt for.
// For view-once, companions are omitted — asserting their sessions is wasteful
// and could block the send if a companion session is corrupted.
await assertSessions([...viewOnceMeRecipients, ...effectiveOtherRecipients])
const [
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
] = await Promise.all([
// For own devices: use DSM (deviceSentMessage) wrapper
createParticipantNodes(effectiveMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
if (effectiveMeRecipients.length > 0 || effectiveOtherRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(effectiveAllRecipients)
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
: effectiveAllRecipients
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
@@ -1897,6 +1924,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
const getMediaType = (message: proto.IMessage) => {
// For view-once media, unwrap the viewOnceMessage wrapper before checking media type
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) {
return 'image'
} else if (message.videoMessage) {
+17
View File
@@ -392,6 +392,23 @@ export const decryptMessageNode = (
} else {
fullMessage.message = msg
}
// Detect view-once media on stanza 1 received by linked device.
// viewOnceMessage wrapper is also used for interactive messages
// (interactiveMessage, listMessage, nativeFlowMessage) -- those do NOT have
// imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce = true.
// Only real view-once media carries viewOnce: true on the inner media message.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
} catch (err: any) {
// Check if this is a final failure after all retries exhausted
const isRetryExhausted = err instanceof RetryExhaustedError