From 2b9b855fc98dbf74a7d6922c06a1a3509c6bef34 Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Mon, 11 May 2026 16:02:08 +0200 Subject: [PATCH] perf(inbox): cache de avatares no Wasabi e IndexedDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solução 3 — Wasabi: - Após download do CDN do WhatsApp, faz upload fire-and-forget para Wasabi com path determinístico (avatars/{tenant}/{instance}/{jid}.jpg) - Armazena o path relativo em contact.avatarUrl no banco - Na próxima requisição, serve bytes direto do Wasabi sem tocar no CDN do WA - Adiciona customPath em StorageUploadPayload e getBuffer() na interface StorageProviderInterface/StorageProviderRegistry e plugin Solução 4 — IndexedDB: - Backend expõe avatarVersion (MD5 curto do avatarUrl) no response /api/chats - Campo propagado por chatStore → chatToLegacy → NewWhatsChat - Hook useAvatarCache (IndexedDB nativo, sem deps) armazena dataUrl do avatar com chave jid:version — invalida automaticamente quando o avatar muda no banco - Avatar.tsx serve do IndexedDB antes de disparar qualquer request HTTP; converte via canvas e persiste no IndexedDB na primeira carga bem-sucedida Co-Authored-By: Claude Sonnet 4.6 --- .../backend/src/core/StorageProvider.ts | 5 + .../newwhats.local/backend/src/core/types.ts | 4 + .../backend/src/modules/chats/chat.routes.ts | 8 +- .../modules/whatsapp/whatsapp.send.routes.ts | 98 ++++++++++++------- .../frontend/components/ui/Avatar.tsx | 92 ++++++++++++----- .../frontend/hooks/inbox/useNewInboxBridge.ts | 3 + .../frontend/hooks/useAvatarCache.ts | 89 +++++++++++++++++ .../frontend/store/chatStore.ts | 8 +- .../frontend/types/inboxTypes.ts | 1 + .../plugins/UnifiedStorageProvider/index.ts | 27 ++++- 10 files changed, 268 insertions(+), 67 deletions(-) create mode 100644 clube67/newwhats.local/frontend/hooks/useAvatarCache.ts diff --git a/clube67/newwhats.local/backend/src/core/StorageProvider.ts b/clube67/newwhats.local/backend/src/core/StorageProvider.ts index 20584c9..f2ecd13 100644 --- a/clube67/newwhats.local/backend/src/core/StorageProvider.ts +++ b/clube67/newwhats.local/backend/src/core/StorageProvider.ts @@ -90,6 +90,11 @@ class StorageProviderRegistry { } } + async getBuffer(path: string): Promise { + if (!this.provider) return null + return this.provider.getBuffer(path) + } + async deletePrefix(prefix: string): Promise<{ deleted: number }> { if (!this.provider) { rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado') diff --git a/clube67/newwhats.local/backend/src/core/types.ts b/clube67/newwhats.local/backend/src/core/types.ts index 65cb2cd..8090489 100644 --- a/clube67/newwhats.local/backend/src/core/types.ts +++ b/clube67/newwhats.local/backend/src/core/types.ts @@ -119,6 +119,8 @@ export interface StorageUploadPayload { usuarioSis?: string /** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */ sessionJID?: string + /** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */ + customPath?: string file: { originalname: string buffer: Buffer @@ -131,4 +133,6 @@ export interface StorageProviderInterface { checkDetailedHealth(): Promise upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> deletePrefix(prefix: string): Promise<{ deleted: number }> + /** Recupera os bytes de um arquivo pelo seu path relativo no bucket */ + getBuffer(path: string): Promise } diff --git a/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts b/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts index d539bbc..a6910e4 100644 --- a/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts +++ b/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts @@ -19,6 +19,7 @@ * @see MessageRepository — busca e paginação de mensagens */ import { Router, type Request, type Response } from 'express' +import { createHash } from 'crypto' import { z } from 'zod' import { ChatRepository } from './chat.repository' import { MessageRepository } from './message.repository' @@ -114,11 +115,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router { name: resolvedName, phone: contact.phone, avatarUrl: contact.avatarUrl, + // Hash curto da URL armazenada — muda quando o avatar é atualizado no banco. + // Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto). + avatarVersion: contact.avatarUrl + ? createHash('md5').update(contact.avatarUrl).digest('hex').slice(0, 8) + : null, scoreReputacao: contact.scoreReputacao, flagRestricao: contact.flagRestricao, } : isGroup - ? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } + ? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false } : null, lastMessage: lastMsg ? { diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts index bdf0a29..f3e9f4c 100644 --- a/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts @@ -23,6 +23,7 @@ import { z } from 'zod' import { prisma } from '../../infra/database/prisma' import { dragonfly } from '../../infra/cache/dragonfly' import { logger } from '../../config/logger' +import { storageProvider } from '../../core/StorageProvider' import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager' import { sendRichMessage } from './rich-message' @@ -715,6 +716,26 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router { // Dragonfly indisponível — segue o fluxo normal sem quebrar } + // ── Helpers para persistência no Wasabi ──────────────────────────────────── + // Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg + // Não usa timestamp — sobrescreve o arquivo quando o avatar muda. + const avatarWasabiPath = (tid: string, iid: string, j: string) => + `avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg` + + // Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo. + // Chamado após servir os bytes ao browser para não bloquear a resposta. + const persistToWasabi = (buffer: Buffer) => { + if (!storageProvider.isRegistered()) return + const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid) + storageProvider.uploadFile({ + category: 'whatsapp', + customPath: wasabiPath, + file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' }, + }).then(() => + prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } }) + ).catch(() => {}) + } + // 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID) const contact = await prisma.contact.findFirst({ where: { tenantId, instanceId, jid }, @@ -722,34 +743,48 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router { }) if (contact?.avatarUrl) { - // Revalidação condicional: se o browser mandou If-None-Match e bate, - // responde 304 sem baixar do CDN. Reduz o custo em rehit a ~zero. - const etag = etagFor(contact.avatarUrl) + const storedUrl = contact.avatarUrl + const isWasabi = !storedUrl.startsWith('http') + + const etag = etagFor(storedUrl) if (req.headers['if-none-match'] === etag) { - res.set('ETag', etag) - res.set('Cache-Control', HIT_CACHE_CONTROL) - res.status(304).end() + res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end() logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304') return } - try { - // Busca imagem do CDN do WhatsApp (URL temporária, expira em ~24h) - const upstream = await fetch(contact.avatarUrl) - if (upstream.ok) { - const buffer = Buffer.from(await upstream.arrayBuffer()) - res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') - res.set('Cache-Control', HIT_CACHE_CONTROL) - res.set('ETag', etag) - res.set('Content-Length', String(buffer.length)) - res.send(buffer) - logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served') - return - } - // URL da DB expirou — tenta via Baileys abaixo - logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys') - } catch { - // URL da DB expirou ou erro de rede — tenta via Baileys abaixo + if (isWasabi) { + // Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp + try { + const buffer = await storageProvider.getBuffer(storedUrl) + if (buffer) { + res.set('Content-Type', 'image/jpeg') + .set('Cache-Control', HIT_CACHE_CONTROL) + .set('ETag', etag) + .set('Content-Length', String(buffer.length)) + .send(buffer) + logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served') + return + } + } catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ } + } else { + // CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi + try { + const upstream = await fetch(storedUrl) + if (upstream.ok) { + const buffer = Buffer.from(await upstream.arrayBuffer()) + res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') + .set('Cache-Control', HIT_CACHE_CONTROL) + .set('ETag', etag) + .set('Content-Length', String(buffer.length)) + .send(buffer) + logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served') + persistToWasabi(buffer) + return + } + // URL expirou — cai no fallback Baileys abaixo + logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys') + } catch { /* Erro de rede — cai no fallback Baileys abaixo */ } } } @@ -764,24 +799,19 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router { const url = await sock.profilePictureUrl(jid, 'image') if (!url) { noAvatar(); return } - // Faz proxy do buffer — evita CORS ao redirecionar para o CDN do WhatsApp const upstream = await fetch(url) if (!upstream.ok) { noAvatar(); return } const buffer = Buffer.from(await upstream.arrayBuffer()) res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') - res.set('Cache-Control', HIT_CACHE_CONTROL) - res.set('ETag', etagFor(url)) - res.set('Content-Length', String(buffer.length)) - res.send(buffer) + .set('Cache-Control', HIT_CACHE_CONTROL) + .set('ETag', etagFor(url)) + .set('Content-Length', String(buffer.length)) + .send(buffer) logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served') - // Salva URL nova no banco para próximas requisições (fire-and-forget). - // Também invalida o negative cache, caso exista: agora temos foto. - await prisma.contact.updateMany({ - where: { tenantId, instanceId, jid }, - data: { avatarUrl: url }, - }).catch(() => {}) + // Persiste no Wasabi (fire-and-forget) e invalida negative cache + persistToWasabi(buffer) dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {}) } catch { noAvatar() diff --git a/clube67/newwhats.local/frontend/components/ui/Avatar.tsx b/clube67/newwhats.local/frontend/components/ui/Avatar.tsx index fb63c8b..1473dbb 100644 --- a/clube67/newwhats.local/frontend/components/ui/Avatar.tsx +++ b/clube67/newwhats.local/frontend/components/ui/Avatar.tsx @@ -1,11 +1,9 @@ 'use client'; -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { - getAvatarInitials, - getAvatarColor, -} from '../../utils/inboxUtils'; +import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils'; +import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache'; interface AvatarProps { chat: any; @@ -14,30 +12,73 @@ interface AvatarProps { } export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) { + const [displayUrl, setDisplayUrl] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); const [showInitials, setShowInitials] = useState(false); - const [isLoaded, setIsLoaded] = useState(false); + const cacheAttempted = useRef(false); - // chat.avatar_url é preenchido upstream (useNewInboxBridge, phonebook, - // ContactPicker) com a URL do proxy SÓ quando o banco confirma que existe - // foto. Null aqui = sem foto → Avatar pula a requisição e mostra iniciais. - const imageUrl = chat?.avatar_url || null; + const imageUrl = chat?.avatar_url || null; + const version = chat?.avatar_version || null; + const remoteJid = chat?.remote_jid || ''; + // Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP. + // Se não houver cache (primeira vez), usa a URL do proxy normalmente. useEffect(() => { + cacheAttempted.current = false; setIsLoaded(false); - setShowInitials(!imageUrl); - }, [chat?.remote_jid, imageUrl]); - const currentUrl = useMemo(() => { - if (showInitials || !imageUrl) return null; - return imageUrl; - }, [showInitials, imageUrl]); + if (!imageUrl || !version) { + setShowInitials(true); + setDisplayUrl(null); + return; + } - const handleImageError = () => { + setShowInitials(false); + + // Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário + getAvatarFromCache(remoteJid, version).then((cached) => { + if (cached) { + setDisplayUrl(cached); + setIsLoaded(true); // já temos os bytes — sem skeleton + } else { + setDisplayUrl(imageUrl); // busca pelo proxy normalmente + } + cacheAttempted.current = true; + }).catch(() => { + setDisplayUrl(imageUrl); + cacheAttempted.current = true; + }); + }, [remoteJid, version, imageUrl]); + + const handleLoad = (e: React.SyntheticEvent) => { + setIsLoaded(true); + + // Salva no IndexedDB após primeira carga bem-sucedida do proxy. + // Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache). + if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return; + try { + const img = e.currentTarget; + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || size; + canvas.height = img.naturalHeight || size; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.drawImage(img, 0, 0); + const dataUrl = canvas.toDataURL('image/jpeg', 0.85); + setAvatarInCache(remoteJid, version, dataUrl); + } catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ } + }; + + const handleError = () => { setShowInitials(true); + setDisplayUrl(null); }; const initials = useMemo(() => getAvatarInitials(chat), [chat]); - const bgColor = useMemo(() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'), [chat]); + const bgColor = useMemo( + () => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'), + [chat] + ); return (
- {!showInitials && currentUrl ? ( + {!showInitials && displayUrl ? ( setIsLoaded(true)} - onError={handleImageError} + onLoad={handleLoad} + onError={handleError} className="w-full h-full object-cover" /> ) : ( @@ -72,8 +114,8 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) )} - {/* Skeleton / Placeholder while loading first image */} - {!isLoaded && !showInitials && ( + {/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */} + {!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
)}
diff --git a/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts b/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts index 95fac31..11acfb2 100644 --- a/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts +++ b/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts @@ -42,6 +42,8 @@ function chatToLegacy(c: ReturnType['chats'][0]): ? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact') : null + const avatar_version: string | null = c.contactAvatarVersion ?? null + return { id: c.id as any, instance_name: c.instanceId, @@ -49,6 +51,7 @@ function chatToLegacy(c: ReturnType['chats'][0]): phone: formattedPhone, lead_name: c.contactName, avatar_url, + avatar_version, bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'), displayName, subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone), diff --git a/clube67/newwhats.local/frontend/hooks/useAvatarCache.ts b/clube67/newwhats.local/frontend/hooks/useAvatarCache.ts new file mode 100644 index 0000000..148b8d2 --- /dev/null +++ b/clube67/newwhats.local/frontend/hooks/useAvatarCache.ts @@ -0,0 +1,89 @@ +'use client' + +/** + * useAvatarCache — cache de avatares em IndexedDB. + * + * Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco). + * Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida + * automaticamente — sem TTL fixo, sem stale data visível ao usuário. + * + * Armazenamento: dataURL base64 do JPEG/WebP (compatível com ). + * Limpeza: entradas com mais de 30 dias são removidas na abertura do DB. + */ + +const DB_NAME = 'nw-avatars' +const STORE_NAME = 'blobs' +const DB_VERSION = 1 +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias + +interface CacheEntry { + key: string // `${jid}:${version}` + dataUrl: string + savedAt: number +} + +let dbPromise: Promise | null = null + +function openDB(): Promise { + if (dbPromise) return dbPromise + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'key' }) + } + } + req.onsuccess = () => { + const db = req.result + pruneOldEntries(db) + resolve(db) + } + req.onerror = () => { dbPromise = null; reject(req.error) } + }) + return dbPromise +} + +function pruneOldEntries(db: IDBDatabase): void { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + const cutoff = Date.now() - MAX_AGE_MS + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result + if (!cursor) return + if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete() + cursor.continue() + } +} + +export async function getAvatarFromCache(jid: string, version: string): Promise { + if (typeof window === 'undefined') return null + try { + const db = await openDB() + const key = `${jid}:${version}` + return new Promise((resolve) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const req = tx.objectStore(STORE_NAME).get(key) + req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null) + req.onerror = () => resolve(null) + }) + } catch { + return null + } +} + +export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise { + if (typeof window === 'undefined') return + try { + const db = await openDB() + const key = `${jid}:${version}` + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() } + const req = tx.objectStore(STORE_NAME).put(entry) + req.onsuccess = () => resolve() + req.onerror = () => reject(req.error) + }) + } catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ } +} diff --git a/clube67/newwhats.local/frontend/store/chatStore.ts b/clube67/newwhats.local/frontend/store/chatStore.ts index 7ff584c..11b1024 100644 --- a/clube67/newwhats.local/frontend/store/chatStore.ts +++ b/clube67/newwhats.local/frontend/store/chatStore.ts @@ -22,9 +22,10 @@ function mapBackendChat(raw: any): Chat { createdAt: raw.createdAt ?? null, isPinned: raw.isPinned ?? false, isArchived: raw.isArchived ?? false, - contactName: raw.contact?.name ?? raw.contactName ?? null, - contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null, - contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null, + contactName: raw.contact?.name ?? raw.contactName ?? null, + contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null, + contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null, + contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null, scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0, flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false, lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null, @@ -52,6 +53,7 @@ export interface Chat { isArchived: boolean contactName: string | null contactAvatarUrl: string | null + contactAvatarVersion: string | null contactPhone: string | null scoreReputacao: number flagRestricao: boolean diff --git a/clube67/newwhats.local/frontend/types/inboxTypes.ts b/clube67/newwhats.local/frontend/types/inboxTypes.ts index 15ca9e8..4d65e9f 100644 --- a/clube67/newwhats.local/frontend/types/inboxTypes.ts +++ b/clube67/newwhats.local/frontend/types/inboxTypes.ts @@ -79,6 +79,7 @@ export interface NewWhatsChat { phone: string lead_name: string | null avatar_url: string | null + avatar_version: string | null bio: string | null displayName: string | null subject: string | null diff --git a/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts index 923ef22..bf39be5 100644 --- a/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts +++ b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts @@ -324,10 +324,11 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const fileName = `${Date.now()}-${safeBase}`; - // Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file} - const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) - ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` - : `${category}/${fileName}`; + // Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file} + const relativePath = payload.customPath + ?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) + ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` + : `${category}/${fileName}`); if (!this.s3 || !this.bucket) { return this.saveToLocalFallback(relativePath, buffer, originalName, category); @@ -353,6 +354,24 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt } } + async getBuffer(relativePath: string): Promise { + // 1. Fallback local + const localPath = path.join(this.localFallbackDir, relativePath); + if (fs.existsSync(localPath)) { + return fs.readFileSync(localPath); + } + // 2. Wasabi + if (!this.s3 || !this.bucket) return null; + try { + const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath })); + if (!result.Body) return null; + const bytes = await (result.Body as any).transformToByteArray(); + return Buffer.from(bytes); + } catch { + return null; + } + } + /** Salva buffer no diretório de fallback local, criando subpastas se necessário */ private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } { const dest = path.join(this.localFallbackDir, relativePath);