feat(media): re-download de mídia não baixada ao clicar
- Armazena payload WAMessage (serializado com _b64 para Uint8Array) em messages.mediaPayload no momento da recepção da mensagem - Nova rota POST /instances/:id/redownload-media/:msgId que usa sock.updateMediaMessage para renovar URL expirada + re-upload Wasabi - Frontend: placeholders clicáveis (spinner + texto) para imagem, vídeo, áudio, documento e figurinha sem media_url; atualização automática via socket message:media_ready existente Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,7 @@ model Message {
|
||||
senderJid String? // JID do remetente real (grupos: key.participant)
|
||||
replyToId String? // ID da mensagem citada (reply)
|
||||
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
||||
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
||||
status MessageStatus @default(PENDING)
|
||||
timestamp DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -824,12 +824,43 @@ class MessageHandler {
|
||||
messageId: savedMsgId,
|
||||
mediaUrl,
|
||||
});
|
||||
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||
if (contentType === 'stickerMessage') {
|
||||
this.persistSticker(msg, mediaUrl).catch(() => { });
|
||||
}
|
||||
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
||||
}
|
||||
}
|
||||
async persistSticker(msg, wasabiPath) {
|
||||
const stickerMsg = msg.message?.stickerMessage;
|
||||
if (!stickerMsg)
|
||||
return;
|
||||
const sha256Bytes = stickerMsg.fileSha256;
|
||||
if (!sha256Bytes || sha256Bytes.length === 0)
|
||||
return;
|
||||
const sha256 = Buffer.from(sha256Bytes).toString('hex');
|
||||
const isAnimated = stickerMsg.isAnimated ?? false;
|
||||
await prisma_1.prisma.sticker.upsert({
|
||||
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
sha256,
|
||||
wasabiPath,
|
||||
isAnimated,
|
||||
useCount: 1,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
useCount: { increment: 1 },
|
||||
lastSeenAt: new Date(),
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UTILITÁRIOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -590,6 +590,10 @@ export class MessageHandler {
|
||||
replyToId,
|
||||
status: fromMe ? 'SENT' : 'PENDING',
|
||||
timestamp: new Date((messageTimestamp as number) * 1000),
|
||||
// Armazena payload da mídia para permitir re-download posterior
|
||||
...(this.isMediaMessage(contentType) ? {
|
||||
mediaPayload: this.buildMediaPayload(key, message) as any,
|
||||
} : {}),
|
||||
},
|
||||
update: {}, // Se já existe (duplicata), não sobrescreve
|
||||
})
|
||||
@@ -940,6 +944,48 @@ export class MessageHandler {
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAYLOAD DE MÍDIA (serialização para re-download)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Constrói o payload serializado da mensagem de mídia para armazenamento.
|
||||
* Uint8Array e Buffer são convertidos para { _b64: base64 } para sobreviver ao JSON.
|
||||
* jpegThumbnail é descartado para economizar espaço (não necessário para re-download).
|
||||
*/
|
||||
buildMediaPayload(key: any, innerMessage: any): Record<string, unknown> {
|
||||
const stripped = this.stripThumbnails(innerMessage)
|
||||
return {
|
||||
key: { remoteJid: key.remoteJid, fromMe: key.fromMe, id: key.id },
|
||||
message: this.serializeForStorage(stripped),
|
||||
}
|
||||
}
|
||||
|
||||
private stripThumbnails(obj: any): any {
|
||||
if (!obj || typeof obj !== 'object') return obj
|
||||
const result: any = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'jpegThumbnail' || k === 'thumbnailDirectPath' || k === 'thumbnailSha256' || k === 'thumbnailEncSha256') continue
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private serializeForStorage(val: unknown): unknown {
|
||||
if (val instanceof Uint8Array || Buffer.isBuffer(val as any)) {
|
||||
return { _b64: Buffer.from(val as Uint8Array).toString('base64') }
|
||||
}
|
||||
if (Array.isArray(val)) return val.map(v => this.serializeForStorage(v))
|
||||
if (val !== null && typeof val === 'object') {
|
||||
// Long de protobuf — converte para número
|
||||
if (typeof (val as any).toNumber === 'function') return (val as any).toNumber()
|
||||
return Object.fromEntries(
|
||||
Object.entries(val as Record<string, unknown>).map(([k, v]) => [k, this.serializeForStorage(v)])
|
||||
)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UTILITÁRIOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import { downloadMediaMessage, getContentType } from './engine'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { logger } from '../../config/logger'
|
||||
import { storageProvider } from '../../core/StorageProvider'
|
||||
@@ -285,5 +286,109 @@ export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
}
|
||||
)
|
||||
|
||||
// ── POST /api/instances/:instanceId/redownload-media/:messageDbId ─────────
|
||||
// Re-solicita ao WhatsApp a mídia de uma mensagem cujo download falhou.
|
||||
// Usa o payload WAMessage salvo em messages.mediaPayload para reconstruir
|
||||
// a requisição com updateMediaMessage (renova URL expirada se necessário).
|
||||
router.post('/redownload-media/:messageDbId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.params['instanceId'] as string
|
||||
const messageDbId = req.params['messageDbId'] as string
|
||||
|
||||
const msg = await prisma.message.findFirst({
|
||||
where: { id: messageDbId, instanceId, tenantId },
|
||||
select: { id: true, mediaPayload: true, type: true, chatId: true, mediaUrl: true, messageId: true },
|
||||
})
|
||||
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
|
||||
if (msg.mediaUrl) { res.json({ mediaUrl: msg.mediaUrl }); return }
|
||||
if (!msg.mediaPayload) { res.status(400).json({ error: 'Payload de mídia não disponível para esta mensagem' }); return }
|
||||
|
||||
const sock = manager.getSocket(instanceId)
|
||||
if (!sock) { res.status(503).json({ error: 'Instância não conectada' }); return }
|
||||
|
||||
// Desserializa payload: { _b64: base64 } → Buffer/Uint8Array
|
||||
const waMsg = deserializePayload(msg.mediaPayload as Record<string, unknown>) as any
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
waMsg,
|
||||
'buffer',
|
||||
{},
|
||||
{ logger: logger as any, reuploadRequest: sock.updateMediaMessage as any }
|
||||
)
|
||||
if (!buffer) { res.status(500).json({ error: 'Nenhum buffer retornado pelo WhatsApp' }); return }
|
||||
|
||||
const contentType = getContentType(waMsg.message) ?? 'imageMessage'
|
||||
const ext = EXT_MAP[contentType] ?? 'bin'
|
||||
const mime = MIME_FOR_TYPE[contentType] ?? 'application/octet-stream'
|
||||
const fileName = `${messageDbId}.${ext}`
|
||||
|
||||
let mediaUrl = `/media/${instanceId}/${fileName}`
|
||||
if (storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname: fileName, buffer: buffer as Buffer, mimetype: mime },
|
||||
})
|
||||
mediaUrl = result.path
|
||||
} catch (uploadErr) {
|
||||
logger.warn({ uploadErr, messageDbId }, 'Upload Wasabi falhou no re-download, usando path local')
|
||||
const filePath = path.resolve('./media', instanceId, fileName)
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, buffer as Buffer)
|
||||
}
|
||||
} else {
|
||||
const filePath = path.resolve('./media', instanceId, fileName)
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, buffer as Buffer)
|
||||
}
|
||||
|
||||
await prisma.message.update({ where: { id: messageDbId }, data: { mediaUrl } })
|
||||
|
||||
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', {
|
||||
messageId: messageDbId,
|
||||
mediaUrl,
|
||||
})
|
||||
|
||||
logger.info({ messageDbId, contentType }, 'Mídia re-baixada com sucesso')
|
||||
res.json({ mediaUrl })
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, 'Erro ao re-baixar mídia')
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao re-baixar mídia' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EXT_MAP: Record<string, string> = {
|
||||
imageMessage: 'jpg',
|
||||
videoMessage: 'mp4',
|
||||
audioMessage: 'ogg',
|
||||
documentMessage: 'bin',
|
||||
stickerMessage: 'webp',
|
||||
}
|
||||
|
||||
const MIME_FOR_TYPE: Record<string, string> = {
|
||||
imageMessage: 'image/jpeg',
|
||||
videoMessage: 'video/mp4',
|
||||
audioMessage: 'audio/ogg',
|
||||
documentMessage: 'application/octet-stream',
|
||||
stickerMessage: 'image/webp',
|
||||
}
|
||||
|
||||
function deserializePayload(val: unknown): unknown {
|
||||
if (Array.isArray(val)) return val.map(deserializePayload)
|
||||
if (val !== null && typeof val === 'object') {
|
||||
const obj = val as Record<string, unknown>
|
||||
if ('_b64' in obj && typeof obj._b64 === 'string') {
|
||||
return Buffer.from(obj._b64, 'base64')
|
||||
}
|
||||
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializePayload(v)]))
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user