feat: implement view-once message sending and receiving
- Add viewonce-image/video/audio to MEDIA_PATH_MAP and MEDIA_HKDF_KEY_MAPPING - messages-send: use viewOnceMessageV2 wrapper, omit companion devices for DSM (server auto-generates <unavailable type="view_once"/> for linked devices) - messages-send: fix mediatype detection by unwrapping viewOnce before getMediaType - messages-recv: handle <unavailable type="view_once"/> sync from primary device - messages-recv: note — do NOT send view_once_read from linked device Co-Authored-By: Renato Alcara
This commit is contained in:
+10
-2
@@ -154,7 +154,11 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
|
|||||||
'md-msg-hist': '/mms/md-app-state',
|
'md-msg-hist': '/mms/md-app-state',
|
||||||
'biz-cover-photo': '/pps/biz-cover-photo',
|
'biz-cover-photo': '/pps/biz-cover-photo',
|
||||||
'sticker-pack': '/mms/sticker-pack',
|
'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 = {
|
export const MEDIA_HKDF_KEY_MAPPING = {
|
||||||
@@ -178,7 +182,11 @@ export const MEDIA_HKDF_KEY_MAPPING = {
|
|||||||
ptv: 'Video',
|
ptv: 'Video',
|
||||||
'biz-cover-photo': 'Image',
|
'biz-cover-photo': 'Image',
|
||||||
'sticker-pack': 'Sticker Pack',
|
'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
|
export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
|
||||||
|
|||||||
@@ -2254,6 +2254,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle view-once unavailable sync from primary device.
|
||||||
|
// When the primary device sends a view-once, linked companions receive
|
||||||
|
// <unavailable type="view_once"/> — 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 {
|
const {
|
||||||
fullMessage: msg,
|
fullMessage: msg,
|
||||||
category,
|
category,
|
||||||
@@ -2548,6 +2559,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type)
|
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
|
// send ack for history message
|
||||||
const isAnyHistoryMsg = getHistoryMsg(msg.message!)
|
const isAnyHistoryMsg = getHistoryMsg(msg.message!)
|
||||||
if (isAnyHistoryMsg) {
|
if (isAnyHistoryMsg) {
|
||||||
|
|||||||
@@ -1013,7 +1013,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await authState.keys.transaction(async () => {
|
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) {
|
if (mediaType) {
|
||||||
extraAttrs['mediatype'] = mediaType
|
extraAttrs['mediatype'] = mediaType
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1046,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types
|
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) {
|
if (isGroupOrStatus && !isRetryResend) {
|
||||||
const [groupData, senderKeyMap] = await Promise.all([
|
const [groupData, senderKeyMap] = await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -1251,19 +1254,38 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
await assertSessions(effectiveAllRecipients)
|
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 <unavailable type="view_once"/>
|
||||||
|
// automaticamente para eles (apenas o celular principal pode enviar <unavailable> 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 <unavailable> para eles.
|
||||||
|
const viewOnceMeRecipients = isViewOnceMsg
|
||||||
|
? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
|
||||||
|
: effectiveMeRecipients
|
||||||
|
|
||||||
const [
|
const [
|
||||||
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
|
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
|
||||||
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
|
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
// For own devices: use DSM (deviceSentMessage) wrapper
|
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
|
||||||
createParticipantNodes(effectiveMeRecipients, meMsg || message, extraAttrs),
|
|
||||||
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
|
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
|
||||||
])
|
])
|
||||||
participants.push(...meNodes)
|
participants.push(...meNodes)
|
||||||
participants.push(...otherNodes)
|
participants.push(...otherNodes)
|
||||||
|
|
||||||
if (effectiveMeRecipients.length > 0 || effectiveOtherRecipients.length > 0) {
|
// phash: para view-once inclui só celular principal + destinatários
|
||||||
extraAttrs['phash'] = generateParticipantHashV2(effectiveAllRecipients)
|
const phashRecipients = isViewOnceMsg
|
||||||
|
? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
|
||||||
|
: effectiveAllRecipients
|
||||||
|
if (phashRecipients.length > 0) {
|
||||||
|
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
|
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
|
||||||
|
|||||||
Reference in New Issue
Block a user