9c626801b0
Accounts registered as hosted (Meta Business Cloud API) have view-once blocked by the WA server for all clients (Android, iOS, Web). This is a server-side policy with no code workaround. Added prominent warning block in section 1 and dedicated row in the limitations table. Co-Authored-By: Renato Alcará
532 lines
16 KiB
Markdown
532 lines
16 KiB
Markdown
# Mensagens View-Once (Visualização Única) — Guia de Implementação
|
|
|
|
**Data:** 2026-03-23
|
|
**Versão:** 1.0
|
|
|
|
---
|
|
|
|
## 1. O que é View-Once
|
|
|
|
Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.
|
|
|
|
---
|
|
|
|
> ## ⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)
|
|
>
|
|
> **Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.**
|
|
>
|
|
> Essa restrição se aplica a **todos os clientes** (Android, iOS, WA Web) e **não há como contornar por código** — é uma política imposta diretamente pelo servidor WA para contas hosted.
|
|
>
|
|
> **Como identificar se sua conta é hosted:**
|
|
> - A conta foi criada via Meta Business Cloud API (WABA)
|
|
> - Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager
|
|
>
|
|
> **Contas não-hosted** (registradas diretamente via QR code ou pair code) **funcionam normalmente** com view-once.
|
|
|
|
---
|
|
|
|
**Comportamento em cada dispositivo após envio via API (contas não-hosted):**
|
|
|
|
| Dispositivo | O que aparece |
|
|
|---|---|
|
|
| Destinatário (Android/iOS) | Recebe a mídia com ícone de olhinho — pode abrir uma vez |
|
|
| Android principal do remetente | Mostra na conversa que a mensagem foi enviada (ícone de olhinho) |
|
|
| WA Web do remetente | "Aguardando mensagem. Abra o WhatsApp no seu celular." |
|
|
|
|
> **Nota:** A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.
|
|
|
|
---
|
|
|
|
## 2. Como Enviar via API
|
|
|
|
### 2.1 Imagem
|
|
|
|
```bash
|
|
curl -X POST https://sua-api.com/v1/messages/send_image \
|
|
-H "Content-Type: application/json" \
|
|
-H "x-api-key: SEU_API_KEY" \
|
|
-d '{
|
|
"instance": "nome-da-instancia",
|
|
"to": "5511999999999",
|
|
"caption": "Olha só isso!",
|
|
"imageUrl": "https://exemplo.com/foto.jpg",
|
|
"viewOnce": true
|
|
}'
|
|
```
|
|
|
|
**Resposta de sucesso:**
|
|
```json
|
|
{ "ok": true }
|
|
```
|
|
|
|
### 2.2 Vídeo
|
|
|
|
```bash
|
|
curl -X POST https://sua-api.com/v1/messages/send_video \
|
|
-H "Content-Type: application/json" \
|
|
-H "x-api-key: SEU_API_KEY" \
|
|
-d '{
|
|
"instance": "nome-da-instancia",
|
|
"to": "5511999999999",
|
|
"caption": "Assista uma vez",
|
|
"videoUrl": "https://exemplo.com/video.mp4",
|
|
"viewOnce": true
|
|
}'
|
|
```
|
|
|
|
### 2.3 Áudio
|
|
|
|
```bash
|
|
curl -X POST https://sua-api.com/v1/messages/send_audio \
|
|
-H "Content-Type: application/json" \
|
|
-H "x-api-key: SEU_API_KEY" \
|
|
-d '{
|
|
"instance": "nome-da-instancia",
|
|
"to": "5511999999999",
|
|
"audioUrl": "https://exemplo.com/audio.ogg",
|
|
"ptt": false,
|
|
"viewOnce": true
|
|
}'
|
|
```
|
|
|
|
### 2.4 Usando Base64 (sem URL pública)
|
|
|
|
```bash
|
|
curl -X POST https://sua-api.com/v1/messages/send_image \
|
|
-H "Content-Type: application/json" \
|
|
-H "x-api-key: SEU_API_KEY" \
|
|
-d '{
|
|
"instance": "nome-da-instancia",
|
|
"to": "5511999999999",
|
|
"imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
|
|
"viewOnce": true
|
|
}'
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Como Receber via Webhook
|
|
|
|
Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com `key.isViewOnce = true`.
|
|
|
|
### 3.1 Estrutura do evento recebido
|
|
|
|
```json
|
|
{
|
|
"event": "messages.upsert",
|
|
"instance": "nome-da-instancia",
|
|
"data": {
|
|
"messages": [
|
|
{
|
|
"key": {
|
|
"remoteJid": "5511999999999@s.whatsapp.net",
|
|
"fromMe": false,
|
|
"id": "3EB0ABCDEF123456",
|
|
"isViewOnce": true
|
|
},
|
|
"message": {
|
|
"viewOnceMessageV2": {
|
|
"message": {
|
|
"imageMessage": {
|
|
"url": "https://mmg.whatsapp.net/...",
|
|
"mimetype": "image/jpeg",
|
|
"mediaKey": "base64...",
|
|
"fileEncSha256": "base64...",
|
|
"directPath": "/v/...",
|
|
"viewOnce": true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"messageTimestamp": 1742700000,
|
|
"pushName": "João Silva"
|
|
}
|
|
],
|
|
"type": "notify"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.2 Como identificar que é view-once
|
|
|
|
O campo principal para identificar é `key.isViewOnce = true`. Existem dois caminhos para confirmar:
|
|
|
|
```javascript
|
|
function isViewOnce(msg) {
|
|
// Caminho 1: flag direta (mais simples)
|
|
if (msg.key?.isViewOnce) return true
|
|
|
|
// Caminho 2: verificar o wrapper da mensagem
|
|
const inner =
|
|
msg.message?.viewOnceMessageV2?.message ||
|
|
msg.message?.viewOnceMessage?.message ||
|
|
msg.message?.viewOnceMessageV2Extension?.message
|
|
|
|
return !!(
|
|
inner?.imageMessage?.viewOnce ||
|
|
inner?.videoMessage?.viewOnce ||
|
|
inner?.audioMessage?.viewOnce
|
|
)
|
|
}
|
|
```
|
|
|
|
### 3.3 Como acessar a mídia
|
|
|
|
```javascript
|
|
function getViewOnceMedia(msg) {
|
|
const inner =
|
|
msg.message?.viewOnceMessageV2?.message ||
|
|
msg.message?.viewOnceMessage?.message ||
|
|
msg.message?.viewOnceMessageV2Extension?.message
|
|
|
|
if (!inner) return null
|
|
|
|
if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
|
|
if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
|
|
if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }
|
|
|
|
return null
|
|
}
|
|
|
|
// Uso:
|
|
const media = getViewOnceMedia(msg)
|
|
if (media) {
|
|
console.log(`View-once ${media.type}`)
|
|
console.log('URL:', media.media.url)
|
|
console.log('DirectPath:', media.media.directPath)
|
|
console.log('MediaKey:', media.media.mediaKey) // necessário para download
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Como Exibir numa Ferramenta (Frontend/CRM)
|
|
|
|
### 4.1 Lógica de detecção e exibição
|
|
|
|
```javascript
|
|
function renderMessage(msg) {
|
|
if (!isViewOnce(msg)) {
|
|
// Renderizar mensagem normal
|
|
return renderNormalMessage(msg)
|
|
}
|
|
|
|
const media = getViewOnceMedia(msg)
|
|
if (!media) return
|
|
|
|
// Verificar se já foi aberta (controle local da ferramenta)
|
|
const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)
|
|
|
|
if (alreadyOpened) {
|
|
// Mostrar placeholder de "já visualizado"
|
|
return renderViewOncePlaceholder(media.type, msg.key.fromMe)
|
|
}
|
|
|
|
if (msg.key.fromMe) {
|
|
// Mensagem enviada por mim — nunca mostrar conteúdo
|
|
return renderSentViewOnce(media.type)
|
|
}
|
|
|
|
// Mensagem recebida — mostrar botão para "abrir uma vez"
|
|
return renderViewOnceButton(media.type, msg)
|
|
}
|
|
```
|
|
|
|
### 4.2 Componente de exibição — mensagem recebida
|
|
|
|
```javascript
|
|
function renderViewOnceButton(type, msg) {
|
|
const icons = { image: '📷', video: '🎥', audio: '🎵' }
|
|
const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }
|
|
|
|
return `
|
|
<div class="view-once-bubble received">
|
|
<span class="view-once-icon">${icons[type]}</span>
|
|
<span class="view-once-label">${labels[type]} de visualização única</span>
|
|
<button onclick="openViewOnce('${msg.key.id}', '${type}')">
|
|
Abrir
|
|
</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
async function openViewOnce(msgId, type) {
|
|
// 1. Marcar como aberta ANTES de mostrar (só uma chance)
|
|
localStorage.setItem(`vo_${msgId}`, '1')
|
|
|
|
// 2. Baixar e mostrar a mídia
|
|
const mediaUrl = await downloadViewOnceMedia(msgId)
|
|
showMedia(type, mediaUrl)
|
|
|
|
// 3. Após fechar/visualizar, substituir pelo placeholder
|
|
onMediaClosed(() => {
|
|
replaceWithPlaceholder(msgId, type)
|
|
})
|
|
}
|
|
```
|
|
|
|
### 4.3 Componente de exibição — mensagem enviada
|
|
|
|
```javascript
|
|
function renderSentViewOnce(type) {
|
|
const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
|
|
return `
|
|
<div class="view-once-bubble sent">
|
|
<span class="view-once-icon">👁️</span>
|
|
<span>${labels[type]} de visualização única</span>
|
|
<span class="view-once-status">Enviada</span>
|
|
</div>
|
|
`
|
|
}
|
|
```
|
|
|
|
### 4.4 Placeholder após visualização
|
|
|
|
```javascript
|
|
function renderViewOncePlaceholder(type, fromMe) {
|
|
const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
|
|
const text = fromMe
|
|
? `Você enviou uma ${labels[type]} de visualização única`
|
|
: `${labels[type]} de visualização única aberta`
|
|
|
|
return `
|
|
<div class="view-once-bubble placeholder">
|
|
<span class="view-once-icon">🚫</span>
|
|
<span>${text}</span>
|
|
</div>
|
|
`
|
|
}
|
|
```
|
|
|
|
### 4.5 CSS sugerido
|
|
|
|
```css
|
|
.view-once-bubble {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 14px;
|
|
border-radius: 12px;
|
|
max-width: 280px;
|
|
}
|
|
|
|
.view-once-bubble.received {
|
|
background: #f0f0f0;
|
|
color: #333;
|
|
}
|
|
|
|
.view-once-bubble.sent {
|
|
background: #dcf8c6;
|
|
color: #333;
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.view-once-bubble.placeholder {
|
|
background: #e8e8e8;
|
|
color: #999;
|
|
font-style: italic;
|
|
}
|
|
|
|
.view-once-bubble button {
|
|
background: #25d366;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 6px 12px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Código Implementado na API
|
|
|
|
### 5.1 Geração da mensagem — `src/Utils/messages.ts`
|
|
|
|
Quando `viewOnce: true` é passado, a mídia é encapsulada no wrapper moderno `viewOnceMessageV2` (campo 55 do proto) e o flag `viewOnce=true` é setado na mídia interna:
|
|
|
|
```typescript
|
|
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
|
|
// Seta viewOnce=true na mensagem de mídia interna
|
|
if (m.imageMessage) m.imageMessage.viewOnce = true
|
|
else if (m.videoMessage) m.videoMessage.viewOnce = true
|
|
else if (m.audioMessage) m.audioMessage.viewOnce = true
|
|
// Usa viewOnceMessageV2 (campo 55) — formato atual do WA
|
|
m = { viewOnceMessageV2: { message: m } }
|
|
}
|
|
```
|
|
|
|
### 5.2 Envio — `src/Socket/messages-send.ts`
|
|
|
|
**Detecção de view-once real** (não confunde com botões/listas que também usam o wrapper `viewOnceMessage`):
|
|
|
|
```typescript
|
|
// Detecta view-once real pela flag viewOnce na mídia interna.
|
|
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
|
|
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
|
|
const viewOnceInner =
|
|
message.viewOnceMessageV2?.message ||
|
|
message.viewOnceMessage?.message ||
|
|
message.viewOnceMessageV2Extension?.message
|
|
const isViewOnceMsg = !!(
|
|
viewOnceInner?.imageMessage?.viewOnce ||
|
|
viewOnceInner?.videoMessage?.viewOnce ||
|
|
viewOnceInner?.audioMessage?.viewOnce
|
|
)
|
|
|
|
// Para view-once: DSM enviado apenas para o celular principal (device=0).
|
|
// Companions (device>0) são omitidos — o servidor WA gera
|
|
// <unavailable type="view_once"/> automaticamente para eles.
|
|
const viewOnceMeRecipients = isViewOnceMsg
|
|
? meRecipients.filter(jid => !jidDecode(jid)?.device)
|
|
: meRecipients
|
|
|
|
// assertSessions apenas para quem vai realmente receber
|
|
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])
|
|
|
|
const [meNodes, otherNodes] = await Promise.all([
|
|
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
|
|
createParticipantNodes(otherRecipients, message, extraAttrs)
|
|
])
|
|
participants.push(...meNodes)
|
|
participants.push(...otherNodes)
|
|
|
|
// phash recalculado com os participantes reais
|
|
const phashRecipients = isViewOnceMsg
|
|
? [...viewOnceMeRecipients, ...otherRecipients]
|
|
: [...meRecipients, ...otherRecipients]
|
|
if (phashRecipients.length > 0) {
|
|
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
|
|
}
|
|
```
|
|
|
|
**`getMediaType()` — garante que vídeo e áudio view-once sejam entregues:**
|
|
|
|
```typescript
|
|
const getMediaType = (message: proto.IMessage) => {
|
|
// Desencapsula wrapper view-once antes de verificar o tipo de mídia.
|
|
// Sem isso, message.videoMessage e message.audioMessage são undefined
|
|
// (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
|
|
// enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
|
|
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) return 'video'
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### 5.3 Recepção — `src/Utils/decode-wa-message.ts`
|
|
|
|
Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:
|
|
|
|
- **Stanza 1** (`enc`): conteúdo completo com metadados da mídia
|
|
- **Stanza 2** (`unavailable type="view_once"`): sinal de sync
|
|
|
|
O stanza 1 é detectado e marcado corretamente:
|
|
|
|
```typescript
|
|
// Detecta view-once na stanza 1 recebida por linked device.
|
|
// O wrapper viewOnceMessage também é usado por mensagens interativas
|
|
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
|
|
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
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Fluxo Completo
|
|
|
|
```
|
|
API envia view-once para destinatário
|
|
│
|
|
├── Protobuf gerado:
|
|
│ viewOnceMessageV2 {
|
|
│ message {
|
|
│ imageMessage { viewOnce: true, url, mediaKey, ... }
|
|
│ }
|
|
│ }
|
|
│
|
|
├── Stanza enviado ao servidor WA:
|
|
│ <message to="destinatario@s.whatsapp.net" type="media">
|
|
│ <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc> ← para o destinatário
|
|
│ <participants>
|
|
│ <to jid="meu-numero:0@lid"> ← celular principal
|
|
│ <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
|
|
│ </to>
|
|
│ <!-- device>0 omitidos — servidor gera unavailable -->
|
|
│ </participants>
|
|
│ </message>
|
|
│
|
|
└── Distribuição pelo servidor:
|
|
├── Destinatário → recebe enc → abre uma vez ✅
|
|
├── Celular principal → recebe DSM → mostra "você enviou" ✅
|
|
└── WA Web → recebe <unavailable> → "Aguardando..." ✅
|
|
|
|
|
|
Destinatário envia view-once de volta para a API
|
|
│
|
|
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
|
|
│ <message from="destinatario@s.whatsapp.net" type="media">
|
|
│ <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
|
|
│ </message>
|
|
│
|
|
├── decode-wa-message.ts:
|
|
│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
|
|
│
|
|
├── Stanza recebido (stanza 2 — unavailable, ignorado):
|
|
│ <message from="...">
|
|
│ <unavailable type="view_once"/>
|
|
│ </message>
|
|
│
|
|
└── Webhook emitido:
|
|
messages.upsert → msg.key.isViewOnce = true ✅
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Limitações
|
|
|
|
| Situação | Comportamento | Motivo |
|
|
|---|---|---|
|
|
| **Conta hosted (Meta Business Cloud API)** | **View-once bloqueado para todos os clientes** | **Política do servidor WA para contas hosted — sem workaround** |
|
|
| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `<unavailable>` vindo do celular principal, não de companions |
|
|
| View-once de documento/sticker | Não suportado | Limitação do protocolo WA — só imagem, vídeo e áudio |
|
|
| `error_479` nos logs | Normal, não é erro | TcToken é atualizado após o envio de view-once |
|
|
| Companion não consegue abrir | Por design | O conteúdo não é entregue para linked devices |
|
|
|
|
---
|
|
|
|
## 8. Checklist de Validação
|
|
|
|
**Envio:**
|
|
- [ ] Destinatário recebe com ícone de olhinho
|
|
- [ ] Destinatário consegue abrir a mídia
|
|
- [ ] Após abrir, a mídia some
|
|
- [ ] Celular principal do remetente mostra "você enviou" com ícone de olhinho
|
|
- [ ] WA Web do remetente mostra "Aguardando mensagem..."
|
|
- [ ] Funciona para imagem, vídeo e áudio
|
|
- [ ] Logs mostram `📤 Message sent` (error_479 é normal)
|
|
|
|
**Recepção:**
|
|
- [ ] Webhook recebe `key.isViewOnce = true`
|
|
- [ ] `viewOnceMessageV2.message.imageMessage` (ou video/audio) está presente
|
|
- [ ] API não crasha ao receber view-once de outro dispositivo
|
|
- [ ] Mensagem interativa (botão/lista) enviada após view-once não é afetada
|