diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts
index 7f7a140d..5b2a236c 100644
--- a/src/Defaults/index.ts
+++ b/src/Defaults/index.ts
@@ -154,7 +154,11 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'md-msg-hist': '/mms/md-app-state',
'biz-cover-photo': '/pps/biz-cover-photo',
'sticker-pack': '/mms/sticker-pack',
- 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
+ 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack',
+ // View-once types — same upload path as regular media (server CDN routing TBD)
+ 'viewonce-image': '/mms/image',
+ 'viewonce-video': '/mms/video',
+ 'viewonce-audio': '/mms/audio'
}
export const MEDIA_HKDF_KEY_MAPPING = {
@@ -178,7 +182,11 @@ export const MEDIA_HKDF_KEY_MAPPING = {
ptv: 'Video',
'biz-cover-photo': 'Image',
'sticker-pack': 'Sticker Pack',
- 'thumbnail-sticker-pack': 'Sticker Pack Thumbnail'
+ 'thumbnail-sticker-pack': 'Sticker Pack Thumbnail',
+ // View-once types — same HKDF keys as regular types (recipients decrypt with normal keys)
+ 'viewonce-image': 'Image',
+ 'viewonce-video': 'Video',
+ 'viewonce-audio': 'Audio'
}
export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts
index 1a08ea88..b7ead314 100644
--- a/src/Socket/messages-recv.ts
+++ b/src/Socket/messages-recv.ts
@@ -2254,6 +2254,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return
}
+ // Handle view-once unavailable sync from primary device.
+ // When the primary device sends a view-once, linked companions receive
+ // — no enc content, just a sync signal.
+ // Acknowledge and skip decryption (nothing to decrypt).
+ const unavailableNode = getBinaryNodeChild(node, 'unavailable')
+ if (unavailableNode?.attrs?.type === 'view_once') {
+ logger.debug({ id: node.attrs.id, from: node.attrs.from }, 'received view_once unavailable sync from primary device')
+ await sendMessageAck(node)
+ return
+ }
+
const {
fullMessage: msg,
category,
@@ -2548,6 +2559,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type)
+ // NOTE: do NOT send view_once_read from a linked device (API).
+ // WA shows view-once as view_once_unavailable_fanout on linked devices — only the
+ // primary phone sends view_once_read when the user actually opens it.
+ // Sending it from a linked device causes WA to flag the account and disables view-once.
+
// send ack for history message
const isAnyHistoryMsg = getHistoryMsg(msg.message!)
if (isAnyHistoryMsg) {
diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts
index cb2ec727..4e6f8563 100644
--- a/src/Socket/messages-send.ts
+++ b/src/Socket/messages-send.ts
@@ -1013,7 +1013,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
await authState.keys.transaction(async () => {
- const mediaType = getMediaType(message)
+ // normalizeMessageContent unwraps viewOnceMessage/viewOnceMessageV2 so getMediaType works correctly
+ const mediaType = getMediaType(normalizeMessageContent(message) || message)
if (mediaType) {
extraAttrs['mediatype'] = mediaType
}
@@ -1045,6 +1046,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types
}
+ // view_once_write disabled — causes server to drop message (validation fails for linked device sessions)
+
if (isGroupOrStatus && !isRetryResend) {
const [groupData, senderKeyMap] = await Promise.all([
(async () => {
@@ -1251,19 +1254,38 @@ export const makeMessagesSocket = (config: SocketConfig) => {
await assertSessions(effectiveAllRecipients)
+ // Para view-once: separar dispositivos próprios por tipo.
+ // - device=0 (celular principal): recebe DSM normalmente — mostra "você enviou" na conversa.
+ // - device>0 (WA Web, outros companions): omitir — servidor WA gera
+ // automaticamente para eles (apenas o celular principal pode enviar explícito).
+ const isViewOnceMsg = !!(
+ message.viewOnceMessageV2 ||
+ message.viewOnceMessage ||
+ message.viewOnceMessageV2Extension
+ )
+
+ // Para view-once, enviar DSM apenas para o celular principal (device=0/undefined).
+ // Companions (device>0) são omitidos — servidor WA envia para eles.
+ const viewOnceMeRecipients = isViewOnceMsg
+ ? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
+ : effectiveMeRecipients
+
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)
+ // phash: para view-once inclui só celular principal + destinatários
+ const phashRecipients = isViewOnceMsg
+ ? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
+ : effectiveAllRecipients
+ if (phashRecipients.length > 0) {
+ extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2