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